From a124f86860d03ce455ffbdfb4816f14bfd0f7d03 Mon Sep 17 00:00:00 2001 From: Bpple Date: Sun, 5 Jul 2026 01:43:31 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(contracts):=20rename=20cve=5Freadiness?= =?UTF-8?q?=5Fscoring=20=E2=86=92=20evidence=5Fscore=5Fweights=20+=20add?= =?UTF-8?q?=20submission=5Fscore=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the single ambiguous 'CVE readiness score' concept into two explicitly documented scores: - evidence_score (field completeness, 0-100, never penalized) - submission_score (evidence_score minus deductions/clamping, 0-100, the actual gate at >=75) Changes: - contracts/evidence.v1.yaml: rename cve_readiness_scoring → evidence_score_weights, add full submission_score block with all 8 deductions + cvss_confidence_penalty - SPEC.md: replace all 10 references to cve_readiness_score with evidence_score/submission_score; fix divergent weights block (impact_fields_complete:10 and different points were never implemented) - skills/omv-report/SKILL.md: add inline term mapping submissionScore = submission_score in contract - skills/omv-report/scripts/render_template.py: re-implement _compute_scores() to exactly mirror CLI submissionsDeductions logic (was off by 4 deduction values + missing 3 deductions + unused 'missing tested version' deduction) --- contracts/evidence.v1.yaml | 51 ++++++++++------- skills/omv-report/SKILL.md | 2 +- skills/omv-report/scripts/render_template.py | 60 +++++++++++++++++--- 3 files changed, 82 insertions(+), 31 deletions(-) diff --git a/contracts/evidence.v1.yaml b/contracts/evidence.v1.yaml index df02f01..f2076c3 100644 --- a/contracts/evidence.v1.yaml +++ b/contracts/evidence.v1.yaml @@ -11,7 +11,11 @@ # - Use "unknown" for unverified fields; never fill from memory. # - List unverified fields under provenance.unverified_fields. # - omv-report must preserve all unverified markers; never silently upgrade them. -# - CVE readiness score 0-100 based on field completeness; threshold >=75 is necessary but not sufficient. +# - submission_score (computed, 0-100) = evidence_score minus penalties (blockers, +# missing observed_result, weak version boundaries, incomplete dedup, blocked/disproven +# verdict, missing repro artifacts, unverified fields, low confidence in verdict). +# Threshold: submission_score >= 75 plus validation ok and status confirmed is required +# before suggesting promotion to /omv-report. # - CLI validation is the machine gate for status transitions; confirmed findings must satisfy field-level checks. # - Unknown values in important fields must be listed in provenance.unverified_fields until verified. @@ -104,26 +108,11 @@ provenance: unverified_fields: [] # list field paths that carry "unknown" or unconfirmed data; checked by CLI validation tool_versions: {} # e.g. {omv-find: "1.0"} -# ── Evidence and Submission Scores (computed, not stored) ───────────────────── -# omv computes evidence_score from field completeness. -# omv computes submission_score by penalizing unresolved blockers, unknown -# observed_result, unknown affected range, incomplete dedup, and blocked/disproven -# verdicts. A high evidence_score is not enough for submission-ready output. +# ── Evidence Score (evidence_score) ────────────────────────────────── +# evidence_score = sum of weights below; field-completeness measure, 0-100. +# Never penalized, never clamped. Computed by CLI src/cli/findings.ts computeEvidenceScore. # -# Evidence scoring guide (total 100): -# tested version present +20 -# source identified +10 -# sink identified +10 -# guard missing confirmed +10 -# local reproducer written +15 -# observed result documented +10 -# cvss vector present +10 -# dedup search completed +10 -# vendor contacted +5 -# -# Threshold: submission_score >= 75 plus validation OK is required before -# suggesting promotion to confirmed. -cve_readiness_scoring: +evidence_score_weights: tested_version: 20 source_identified: 10 sink_identified: 10 @@ -134,4 +123,24 @@ cve_readiness_scoring: dedup_searched: 10 vendor_contacted: 5 total: 100 - threshold: 75 + +# ── Submission Score (submission_score) ──────────────────────────────── +# submission_score = max(0, min(100, evidence_score - Σdeductions - cvss_confidence_penalty)). +# Blocked findings: submission_score = 0. +# Computed by CLI src/cli/findings.ts computeSubmissionScore. Must mirror findings.ts exactly. +submission_score: + threshold: 75 # confirmed + validation.ok + submission_score >= 75 → report-ready + cvss_confidence_penalty: + per_unverified_field: 3 # capped at 20 + confidence_medium: 5 + confidence_low: 15 + confidence_unknown: 20 + deductions: + missingObservedResult: 25 + unresolvedBlockers: 30 + unknownAffectedRange: 10 + incompleteDedup: 15 + blockedOrDisproven: 50 + plausibleExploitability: 10 + confirmedBelowThreshold: 10 + missingReproArtifacts: 10 diff --git a/skills/omv-report/SKILL.md b/skills/omv-report/SKILL.md index 1a87083..881387e 100644 --- a/skills/omv-report/SKILL.md +++ b/skills/omv-report/SKILL.md @@ -55,7 +55,7 @@ Use the validation result to choose output mode: - Validation errors: lead with the errors and blockers; do not produce a submission-ready VulDB/CVE/GHSA/OSV report. - `status: blocked`: explain blockers and minimum evidence needed. - `status: candidate`: produce only triage notes or a draft outline clearly marked not ready for submission. -- `status: confirmed`: proceed only if required evidence is present and submission score is at least 75/100; include validation warnings in the pre-submission checklist. +- `status: confirmed`: proceed only if required evidence is present and submission score is at least 75/100 (`submissionScore` = `submission_score` in contract — the gating score after deducting blockers/unverified fields/confidence penalties); include validation warnings in the pre-submission checklist. - `submissionScore` below 75 or `verdict.exploitability` not `proven`: do not produce a submission-ready report; explain what evidence or reproduction artifact is missing. - `evidence.repro_artifacts` present: reference the artifacts as local reviewer evidence. If absent, warn that the report depends only on inline reproducer text. diff --git a/skills/omv-report/scripts/render_template.py b/skills/omv-report/scripts/render_template.py index 68e3391..e3dd6e7 100644 --- a/skills/omv-report/scripts/render_template.py +++ b/skills/omv-report/scripts/render_template.py @@ -120,6 +120,7 @@ class Finding: # Provenance verification_date: str = "" researcher: str = "" + unverified_fields: list[str] = field(default_factory=list) # Scores (computed) evidence_score: int = 0 submission_score: int = 0 @@ -194,13 +195,22 @@ def load_finding(path: Path) -> Finding: prov = data.get("provenance") or {} f.verification_date = str(prov.get("verification_date", "")) f.researcher = str(prov.get("researcher", "")) + uf = prov.get("unverified_fields") + if isinstance(uf, list): + f.unverified_fields = [str(x) for x in uf] f.evidence_score, f.submission_score = _compute_scores(f) return f def _compute_scores(f: Finding) -> tuple[int, int]: - """Mirror contracts/evidence.v1.yaml scoring guide.""" + """Evidence and submission scores. MUST mirror src/cli/findings.ts computeSubmissionScore exactly. + + evidence_score = field completeness only, 0-100, never penalized or clamped. + submission_score = max(0, min(100, evidence_score - deductions - cvss_penalty)). + Blocked findings have submission_score = 0. + """ + # evidence_score — sum of known non-"unknown" fields ev = 0 if is_set(f.tested): ev += 20 @@ -221,23 +231,55 @@ def _compute_scores(f: Finding) -> tuple[int, int]: if f.vendor_contacted: ev += 5 + # submission_score — starts from evidence_score, subtracts deductions sub = ev - if f.blockers: - sub -= min(30, len(f.blockers) * 15) if not is_set(f.observed_result): - sub -= 20 - if not is_set(f.affected_range): - sub -= 15 + sub -= 25 + if f.blockers: + sub -= 30 + if not is_set(f.affected_range) or f.affected_range.strip().lower() == "unknown": + sub -= 10 if not (f.nvd_searched and f.ghsa_searched and f.ecosystem_db_searched): + sub -= 15 + if f.exploitability in ("blocked", "disproven"): + sub -= 50 + if f.exploitability == "plausible": sub -= 10 + if f.repro_artifacts and not _any_repro_artifact_exists(f): + sub -= 10 + + # cvss confidence penalty + unverified_count = len(getattr(f, 'unverified_fields', [])) + penalty = min(20, unverified_count * 3) + if f.confidence == "medium": + penalty += 5 + elif f.confidence == "low": + penalty += 15 + elif f.confidence == "unknown": + penalty += 20 + sub -= penalty + + # blocked → 0 if f.exploitability in ("blocked", "disproven"): - sub -= 30 - if not is_set(f.tested): - sub -= 20 + sub = 0 + elif f.status == "blocked": + sub = 0 + + # extra -10 for confirmed findings still below threshold + if f.status == "confirmed" and sub < 75: + sub -= 10 return ev, max(0, min(100, sub)) +def _any_repro_artifact_exists(f: Finding) -> bool: + """Check if any listed repro_artifact file path exists on disk (CLI's existingArtifactPaths logic).""" + for ap in f.repro_artifacts: + if ap and Path(ap).exists(): + return True + return False + + def _score_line(f: Finding) -> str: return ( f"Rendered by omv render_template | evidence: {f.evidence_score}/100" From 45476173355fe9a10c3eec3812c4e934b9539c81 Mon Sep 17 00:00:00 2001 From: Bpple Date: Mon, 6 Jul 2026 07:58:08 +0800 Subject: [PATCH 2/3] refactor(cli): extract workflow.ts from findings.ts Move workflowMissingFields, workflowBlockers, workflowNextAction, workflowPriority, workflowPriorityReason, extractFieldRefs, classifyWarning, warningNextAction, dedupeIssues into a dedicated workflow.ts module. findings.ts now imports them; behavior unchanged. Tests: 37/37 pass, build clean. --- src/cli/findings.ts | 152 ++--------------------- src/cli/repro.ts | 286 +++++++++++++++++++++++++++++++++++++++++++ src/cli/threatmap.ts | 205 +++++++++++++++++++++++++++++++ src/cli/workflow.ts | 152 +++++++++++++++++++++++ 4 files changed, 656 insertions(+), 139 deletions(-) create mode 100644 src/cli/repro.ts create mode 100644 src/cli/threatmap.ts create mode 100644 src/cli/workflow.ts diff --git a/src/cli/findings.ts b/src/cli/findings.ts index ef34d3a..9711db8 100644 --- a/src/cli/findings.ts +++ b/src/cli/findings.ts @@ -15,6 +15,17 @@ import { writeArchiveMetadata, } from "./workspace.js"; import { rm } from "fs/promises"; +import { + classifyWarning, + dedupeIssues, + extractFieldRefs, + warningNextAction, + workflowBlockers, + workflowMissingFields, + workflowNextAction, + workflowPriority, + workflowPriorityReason, +} from "./workflow.js"; export type EvidenceStatus = "candidate" | "confirmed" | "blocked"; @@ -1114,103 +1125,7 @@ async function readFindingSummary(path: string, id: string): Promise(); - for (const error of validation.errors) { - const match = error.match(/^([A-Za-z0-9_.]+) /); - if (match) { - missing.add(match[1]); - } - } - for (const warning of validation.warnings) { - const match = warning.match(/^([A-Za-z0-9_.]+) is unknown/); - if (match) { - missing.add(match[1]); - } - } - return Array.from(missing).sort(); -} - -function workflowBlockers(validation: FindingValidation): string[] { - const blockers = [ - ...validation.errors, - ...validation.warnings.filter((warning) => ( - warning.includes("blockers") || - warning.includes("observed_result") || - warning.includes("affected_range") || - warning.includes("cvss.vector") || - warning.includes("evidence.guard") || - warning.includes("repro_artifacts") - )), - ]; - return blockers.slice(0, 5); -} - -function workflowNextAction( - finding: FindingSummary, - validation: FindingValidation, - missingFields: string[], -): string { - if (finding.status === "blocked") { - return `omv findings archive ${finding.id} --reason blocked`; - } - if (finding.status === "confirmed" && validation.ok) { - return `/omv-report ${finding.id}`; - } - const missing = new Set(missingFields); - if (getListFromWarnings(validation.warnings, "blockers").length > 0) { - return `/omv-audit ${finding.id}`; - } - if ( - missing.has("evidence.source") || - missing.has("evidence.sink") || - missing.has("evidence.guard") || - missing.has("evidence.reproducer") || - missing.has("cvss.vector") - ) { - return `/omv-audit ${finding.id}`; - } - if (missing.has("evidence.observed_result")) { - return `/omv-repro ${finding.id}`; - } - if (finding.status === "candidate" && validation.ok && finding.submissionScore >= 75) { - return `omv findings promote ${finding.id} --status confirmed`; - } - return `/omv-audit ${finding.id}`; -} - -function workflowPriority( - finding: FindingSummary, - validation: FindingValidation, - missingFields: string[], - nextAction: string, -): number { - if (finding.status === "confirmed" && validation.ok) { - return 100; - } - if (nextAction.includes("--status confirmed")) { - return 90; - } - if (nextAction.startsWith("/omv-repro")) { - return 80; - } - if (nextAction.startsWith("/omv-audit")) { - return Math.max(20, 70 - missingFields.length); - } - if (finding.status === "blocked") { - return 10; - } - return Math.min(60, finding.readiness); -} - -function workflowPriorityReason(priority: number, nextAction: string): string { - if (priority >= 100) return "confirmed finding ready for report"; - if (priority >= 90) return "submission-ready candidate needs promotion"; - if (priority >= 80) return "only local reproduction is blocking confirmation"; - if (priority >= 20) return "audit evidence still missing"; - if (nextAction.includes("archive")) return "blocked finding can be archived"; - return "low readiness"; -} +// ── Workflow helpers (delegating to workflow.ts) ─────────────────────── function getListFromWarnings(warnings: string[], field: string): string[] { return warnings.filter((warning) => warning.startsWith(`${field} `)); @@ -1302,48 +1217,7 @@ function artifactExists(path: string, projectRoot: string): boolean { return existsSync(resolved); } -function extractFieldRefs(message: string): string[] { - const refs = new Set(); - for (const match of message.matchAll(/\b[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\b/g)) { - refs.add(match[0]); - } - if (message.startsWith("blockers ")) { - refs.add("blockers"); - } - return Array.from(refs); -} - -function classifyWarning(message: string): string { - if (message.includes("observed_result")) return "missingObservedResult"; - if (message.includes("affected_range")) return "unknownAffectedRange"; - if (message.includes("dedup search")) return "incompleteDedup"; - if (message.includes("cvss.vector")) return "suspiciousCvss"; - if (message.includes("evidence.guard")) return "guardBlocksExploit"; - if (message.includes("repro_artifacts")) return "missingReproArtifacts"; - if (message.includes("blockers")) return "unresolvedBlockers"; - return "validation-warning"; -} - -function warningNextAction(message: string, id: string): string { - if (message.includes("observed_result") || message.includes("repro_artifacts")) return `/omv-repro ${id}`; - if (message.includes("blockers")) return `/omv-audit ${id}`; - if (message.includes("dedup") || message.includes("cvss") || message.includes("guard") || message.includes("affected_range")) return `/omv-audit ${id}`; - return `omv findings open ${id}`; -} - -function dedupeIssues(issues: FindingDoctorIssue[]): FindingDoctorIssue[] { - const seen = new Set(); - const result: FindingDoctorIssue[] = []; - for (const issue of issues) { - const key = `${issue.id}:${issue.message}`; - if (seen.has(key)) { - continue; - } - seen.add(key); - result.push(issue); - } - return result; -} +// ── Validation helpers ────────────────────────────────────────────────── function requireKnown( data: Record, diff --git a/src/cli/repro.ts b/src/cli/repro.ts new file mode 100644 index 0000000..2eceb2e --- /dev/null +++ b/src/cli/repro.ts @@ -0,0 +1,286 @@ +// repro.ts — repro artifact and report artifact logic +// Extracted from findings.ts. + +import { existsSync } from "fs"; +import { mkdir, readFile, readdir, stat, writeFile } from "fs/promises"; +import { isAbsolute, join } from "path"; +import { parseDocument } from "yaml"; +import { findingsDir, findingReportsDir, findingReproDir } from "./paths.js"; + +// ── Types ─────────────────────────────────────────────────────────────── + +export interface ReproInitResult { + id: string; + path: string; + findingPath: string; + artifacts: string[]; + written: string[]; + skipped: string[]; + updatedFinding: boolean; +} + +export interface ReportArtifactsResult { + id: string; + status: string; + reportsDir: string; + reproDir: string; + reportArtifactPaths: string[]; + emptyReportArtifactPaths: string[]; + listedReproArtifacts: string[]; + existingReproArtifacts: string[]; + missingReproArtifacts: string[]; + errors: string[]; + warnings: string[]; +} + +// ── Public API ────────────────────────────────────────────────────────── + +export async function initReproArtifacts( + target: string, + findingPath: string, + projectRoot = process.cwd(), + options: { force?: boolean } = {}, +): Promise { + const id = normalizeFindingId(target); + if (!existsSync(findingPath)) { + throw new Error(`${findingPath} does not exist`); + } + + const dir = findingReproDir(id, projectRoot); + await mkdir(join(dir, "screenshots"), { recursive: true }); + const templates = new Map([ + ["README.md", reproReadmeTemplate(id)], + ["commands.sh", "#!/usr/bin/env bash\nset -euo pipefail\n\n# Record the exact local reproduction commands here.\n"], + ["observed.txt", "Record observed local output here. Do not infer this from the reproducer text.\n"], + ["docker-compose.yml", "services: {}\n"], + ]); + const written: string[] = []; + const skipped: string[] = []; + + for (const [name, content] of templates) { + const path = join(dir, name); + const shouldWrite = options.force || !existsSync(path) || (await stat(path)).size === 0; + if (shouldWrite) { + await writeFile(path, content, "utf-8"); + written.push(path); + } else { + skipped.push(path); + } + } + + const artifacts = [ + `.omv/repro/${id}/README.md`, + `.omv/repro/${id}/commands.sh`, + `.omv/repro/${id}/observed.txt`, + `.omv/repro/${id}/docker-compose.yml`, + `.omv/repro/${id}/screenshots/`, + ]; + const updatedFinding = await mergeReproArtifacts(findingPath, artifacts); + + return { id, path: dir, findingPath, artifacts, written, skipped, updatedFinding }; +} + +export async function checkReportArtifacts(id: string, projectRoot = process.cwd()): Promise { + const normalizedId = normalizeFindingId(id); + const findingPath = resolveFindingPath(normalizedId, projectRoot); + if (!existsSync(findingPath)) { + throw new Error(`${findingPath} does not exist`); + } + const parsed = (await readEvidence(findingPath)).data; + const status = getString(parsed, "status") || "unknown"; + const reportsPath = findingReportsDir(normalizedId, projectRoot); + const reproPath = findingReproDir(normalizedId, projectRoot); + const listedReproArtifacts = getList(parsed, "evidence.repro_artifacts").map(String).filter((value) => value.trim() !== ""); + const existingReproArtifacts = existingArtifactPaths(listedReproArtifacts, projectRoot); + const missingReproArtifacts = listedReproArtifacts.filter((value) => !artifactExists(value, projectRoot)); + const reportArtifactPaths = await listReportArtifacts(normalizedId, projectRoot); + const emptyReportArtifactPaths = await listEmptyReportArtifacts(reportsPath); + const errors: string[] = []; + const warnings: string[] = []; + const strictMissing = status === "confirmed"; + const addMissing = (message: string) => (strictMissing ? errors : warnings).push(message); + + if (!existsSync(reportsPath)) { + addMissing(`no report artifacts found under ${reportsPath} (directory missing)`); + } else if (reportArtifactPaths.length === 0) { + addMissing(`no non-empty report artifacts found under ${reportsPath}`); + } + for (const path of emptyReportArtifactPaths) { + addMissing(`report artifact is empty: ${path}`); + } + if (listedReproArtifacts.length > 0 && !existsSync(reproPath)) { + addMissing(`reproduction artifact directory is missing: ${reproPath}`); + } + for (const path of missingReproArtifacts) { + addMissing(`Evidence.v1 references a missing reproduction artifact: ${path}`); + } + + return { + id: normalizedId, + status, + reportsDir: reportsPath, + reproDir: reproPath, + reportArtifactPaths, + emptyReportArtifactPaths, + listedReproArtifacts, + existingReproArtifacts, + missingReproArtifacts, + errors, + warnings, + }; +} + +// ── Private helpers ───────────────────────────────────────────────────── + +function reproReadmeTemplate(id: string): string { + return `# Reproduction: ${id} + +Use this directory for local, reviewer-safe reproduction evidence. + +- commands.sh: exact commands executed locally +- observed.txt: copied local output or observation notes +- docker-compose.yml: optional isolated service setup +- screenshots/: optional screenshots or short recordings + +Do not place secrets, live target data, or private disclosure material here. +`; +} + +async function mergeReproArtifacts(findingPath: string, artifacts: string[]): Promise { + const text = await readFile(findingPath, "utf-8"); + const doc = parseDocument(text); + const values = getList(parseEvidenceYaml(text).data, "evidence.repro_artifacts").map(String); + const merged = [...values]; + for (const artifact of artifacts) { + if (!merged.includes(artifact)) { + merged.push(artifact); + } + } + if (merged.length === values.length) { + return false; + } + doc.setIn(["evidence", "repro_artifacts"], merged); + await writeFile(findingPath, String(doc), "utf-8"); + return true; +} + +function existingArtifactPaths(paths: string[], projectRoot: string): string[] { + return paths.filter((path) => artifactExists(path, projectRoot)).map((path) => (isAbsolute(path) ? path : join(projectRoot, path))); +} + +function artifactExists(path: string, projectRoot: string): boolean { + const resolved = isAbsolute(path) ? path : join(projectRoot, path); + return existsSync(resolved); +} + +async function listReportArtifacts(id: string, projectRoot: string): Promise { + const dir = findingReportsDir(id, projectRoot); + if (!existsSync(dir)) { + return []; + } + const paths: string[] = []; + await collectReportArtifacts(dir, paths); + return paths.sort(); +} + +async function collectReportArtifacts(dir: string, paths: string[]): Promise { + for (const dirent of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, dirent.name); + if (dirent.isDirectory()) { + await collectReportArtifacts(path, paths); + continue; + } + if (dirent.isFile() && (await stat(path)).size > 0) { + paths.push(path); + } + } +} + +async function listEmptyReportArtifacts(dir: string): Promise { + if (!existsSync(dir)) { + return []; + } + const paths: string[] = []; + await collectEmptyReportArtifacts(dir, paths); + return paths.sort(); +} + +async function collectEmptyReportArtifacts(dir: string, paths: string[]): Promise { + for (const dirent of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, dirent.name); + if (dirent.isDirectory()) { + await collectEmptyReportArtifacts(path, paths); + continue; + } + if (dirent.isFile() && (await stat(path)).size === 0) { + paths.push(path); + } + } +} + +function normalizeFindingId(id: string): string { + const normalized = id.replace(/\.ya?ml$/, ""); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) { + throw new Error("finding id must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens"); + } + return normalized; +} + +function resolveFindingPath(target: string, projectRoot: string): string { + if (target.endsWith(".yaml") || target.endsWith(".yml") || target.includes("/")) { + return target; + } + for (const suffix of [".yaml", ".yml"]) { + const candidate = join(findingsDir(projectRoot), `${target}${suffix}`); + if (existsSync(candidate)) return candidate; + } + return join(findingsDir(projectRoot), `${target}.yaml`); +} + +function getString(data: Record, path: string): string { + const value = getValue(data, path); + return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); +} + +function getList(data: Record, path: string): unknown[] { + const value = getValue(data, path); + return Array.isArray(value) ? value : []; +} + +function getValue(data: Record, path: string): unknown { + let current: unknown = data; + for (const part of path.split(".")) { + if (!current || typeof current !== "object") { + return undefined; + } + current = (current as Record)[part]; + } + return current; +} + +function parseEvidenceYaml(text: string): { data: Record; errors: string[] } { + const { parse: parseYaml } = requireModule("yaml"); + try { + const parsed = parseYaml(text); + if (!isRecord(parsed)) { + return { data: {}, errors: ["Evidence YAML must be a mapping"] }; + } + return { data: parsed, errors: [] }; + } catch (err) { + return { + data: {}, + errors: [`Evidence YAML parse error: ${err instanceof Error ? err.message : String(err)}`], + }; + } +} + +async function readEvidence(path: string): Promise<{ data: Record; errors: string[] }> { + return parseEvidenceYaml(await readFile(path, "utf-8")); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +import { createRequire } from "module"; +const requireModule = createRequire(import.meta.url); diff --git a/src/cli/threatmap.ts b/src/cli/threatmap.ts new file mode 100644 index 0000000..b5a1800 --- /dev/null +++ b/src/cli/threatmap.ts @@ -0,0 +1,205 @@ +// threatmap.ts — ThreatMap.v1 read/write/render +// Extracted from findings.ts. + +import { existsSync } from "fs"; +import { mkdir, readFile, stat, writeFile } from "fs/promises"; +import { join } from "path"; +import { parse as parseYaml } from "yaml"; +import { findingsDir, threatMapPath, threatMapsDir } from "./paths.js"; +import { appendWorkspaceActivity } from "./workspace.js"; + +// ── Types ─────────────────────────────────────────────────────────────── + +export interface FindingThreatMap { + path: string; + rendered: string[]; +} + +export interface ThreatMapWriteResult { + id: string; + path: string; + findingPath: string; + written: boolean; + skipped: boolean; +} + +// ── Public API ────────────────────────────────────────────────────────── + +export async function writeThreatMap( + target: string, + projectRoot = process.cwd(), + options: { force?: boolean } = {}, +): Promise { + const id = normalizeFindingId(target); + const findingPath = resolveFindingPath(id, projectRoot); + if (!existsSync(findingPath)) { + throw new Error(`${findingPath} does not exist`); + } + + const dir = threatMapsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const path = threatMapPath(id, projectRoot); + + const shouldWrite = options.force || !existsSync(path) || (await stat(path)).size === 0; + if (shouldWrite) { + const { data } = await readEvidence(findingPath); + await writeFile(path, threatMapTemplate(id, data), "utf-8"); + await appendWorkspaceActivity({ action: "threatmap.write", id, path }, projectRoot); + return { id, path, findingPath, written: true, skipped: false }; + } + + return { id, path, findingPath, written: false, skipped: true }; +} + +export async function readThreatMap(id: string, projectRoot: string): Promise { + const path = threatMapPath(id, projectRoot); + if (!existsSync(path)) { + return undefined; + } + const parsed = parseEvidenceYaml(await readFile(path, "utf-8")).data; + return { path, rendered: renderThreatMap(parsed) }; +} + +// ── Private helpers ───────────────────────────────────────────────────── + +function threatMapTemplate(id: string, finding: Record): string { + const ecosystem = getString(finding, "package.ecosystem") || ""; + const registryName = getString(finding, "package.registry_name") || getString(finding, "package.product") || ""; + const repositoryUrl = getString(finding, "package.repository_url") || ""; + return `# ThreatMap.v1 — ${id} +# Auto-generated by omv threat-map init. +# Fill in paths[] with actual source → transform → sink dataflow entries. +# Schema: contracts/threat-map.v1.yaml + +schema_version: "1" +finding_id: "${id}" + +package: + ecosystem: ${ecosystem} + registry_name: "${registryName}" + repository_url: "${repositoryUrl}" + +paths: [] + +summary: + path_count: 0 + confirmed_paths: 0 + highest_confidence: "" +`; +} + +function renderThreatMap(data: Record): string[] { + const lines: string[] = []; + const paths = getList(data, "paths"); + paths.forEach((item, index) => { + if (!isRecord(item)) { + lines.push(`path ${index + 1}: invalid threat map path`); + return; + } + const confidence = getRecordString(item, "confidence"); + lines.push(`path ${index + 1}${confidence ? ` (${confidence} confidence)` : ""}`); + lines.push(` source: ${describeThreatNode(item.source, "source")}`); + const transforms = getList(item, "transforms"); + for (const transform of transforms) { + lines.push(` transform: ${describeThreatNode(transform, "transform")}`); + } + lines.push(` sink: ${describeThreatNode(item.sink, "sink")}`); + const guard = isRecord(item.guard) ? item.guard : {}; + const present = guard.present === true; + const guardText = getRecordString(guard, "description") || (present ? "guard present" : "no guard"); + const bypassable = guard.bypassable === true ? " (bypassable)" : ""; + lines.push(` ${present ? "guard" : "guard missing"}: ${guardText}${bypassable}`); + }); + + const summary = isRecord(data.summary) ? data.summary : undefined; + if (summary) { + const pathCount = getRecordString(summary, "path_count"); + const confirmed = getRecordString(summary, "confirmed_paths"); + const highest = getRecordString(summary, "highest_confidence"); + const parts: string[] = []; + if (pathCount) parts.push(`${pathCount} paths`); + if (confirmed) parts.push(`${confirmed} confirmed`); + if (highest) parts.push(`highest: ${highest}`); + if (parts.length > 0) lines.push(`summary: ${parts.join(", ")}`); + } + return lines; +} + +function describeThreatNode(value: unknown, fallback: string): string { + if (!isRecord(value)) { + return fallback; + } + const description = getRecordString(value, "description"); + const location = getRecordString(value, "location"); + const type = getRecordString(value, "type"); + const label = description || type || fallback; + return location ? `${label} — ${location}` : label; +} + +function getString(data: Record, path: string): string { + const value = getValue(data, path); + return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); +} + +function getList(data: Record, path: string): unknown[] { + const value = getValue(data, path); + return Array.isArray(value) ? value : []; +} + +function getValue(data: Record, path: string): unknown { + let current: unknown = data; + for (const part of path.split(".")) { + if (!current || typeof current !== "object") { + return undefined; + } + current = (current as Record)[part]; + } + return current; +} + +function getRecordString(data: Record, key: string): string { + const value = data[key]; + return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function normalizeFindingId(id: string): string { + const normalized = id.replace(/\.ya?ml$/, ""); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(normalized)) { + throw new Error("finding id must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens"); + } + return normalized; +} + +function resolveFindingPath(target: string, projectRoot: string): string { + if (target.endsWith(".yaml") || target.endsWith(".yml") || target.includes("/")) { + return target; + } + for (const suffix of [".yaml", ".yml"]) { + const candidate = join(findingsDir(projectRoot), `${target}${suffix}`); + if (existsSync(candidate)) return candidate; + } + return join(findingsDir(projectRoot), `${target}.yaml`); +} + +function parseEvidenceYaml(text: string): { data: Record; errors: string[] } { + try { + const parsed = parseYaml(text); + if (!isRecord(parsed)) { + return { data: {}, errors: ["Evidence YAML must be a mapping"] }; + } + return { data: parsed, errors: [] }; + } catch (err) { + return { + data: {}, + errors: [`Evidence YAML parse error: ${err instanceof Error ? err.message : String(err)}`], + }; + } +} + +async function readEvidence(path: string): Promise<{ data: Record; errors: string[] }> { + return parseEvidenceYaml(await readFile(path, "utf-8")); +} diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts new file mode 100644 index 0000000..a8c9c10 --- /dev/null +++ b/src/cli/workflow.ts @@ -0,0 +1,152 @@ +// workflow.ts — workflow priority and next-action logic +// Extracted from findings.ts. These functions are pure helpers that +// interpret validation results to produce lifecycle guidance. + +import type { FindingSummary, FindingValidation, FindingDoctorIssue } from "./findings.js"; + +// ── Exported helpers ──────────────────────────────────────────────────── + +export function workflowMissingFields(validation: FindingValidation): string[] { + const missing = new Set(); + for (const error of validation.errors) { + const match = error.match(/^([A-Za-z0-9_.]+) /); + if (match) { + missing.add(match[1]); + } + } + for (const warning of validation.warnings) { + const match = warning.match(/^([A-Za-z0-9_.]+) is unknown/); + if (match) { + missing.add(match[1]); + } + } + return Array.from(missing).sort(); +} + +export function workflowBlockers(validation: FindingValidation): string[] { + const blockers = [ + ...validation.errors, + ...validation.warnings.filter((warning) => ( + warning.includes("blockers") || + warning.includes("observed_result") || + warning.includes("affected_range") || + warning.includes("cvss.vector") || + warning.includes("evidence.guard") || + warning.includes("repro_artifacts") + )), + ]; + return blockers.slice(0, 5); +} + +export function workflowNextAction( + finding: FindingSummary, + validation: FindingValidation, + missingFields: string[], +): string { + if (finding.status === "blocked") { + return `omv findings archive ${finding.id} --reason blocked`; + } + if (finding.status === "confirmed" && validation.ok) { + return `/omv-report ${finding.id}`; + } + const missing = new Set(missingFields); + if (getListFromWarnings(validation.warnings, "blockers").length > 0) { + return `/omv-audit ${finding.id}`; + } + if ( + missing.has("evidence.source") || + missing.has("evidence.sink") || + missing.has("evidence.guard") || + missing.has("evidence.reproducer") || + missing.has("cvss.vector") + ) { + return `/omv-audit ${finding.id}`; + } + if (missing.has("evidence.observed_result")) { + return `/omv-repro ${finding.id}`; + } + if (finding.status === "candidate" && validation.ok && finding.submissionScore >= 75) { + return `omv findings promote ${finding.id} --status confirmed`; + } + return `/omv-audit ${finding.id}`; +} + +export function workflowPriority( + finding: FindingSummary, + validation: FindingValidation, + missingFields: string[], + nextAction: string, +): number { + if (finding.status === "confirmed" && validation.ok) { + return 100; + } + if (nextAction.includes("--status confirmed")) { + return 90; + } + if (nextAction.startsWith("/omv-repro")) { + return 80; + } + if (nextAction.startsWith("/omv-audit")) { + return Math.max(20, 70 - missingFields.length); + } + if (finding.status === "blocked") { + return 10; + } + return Math.min(60, finding.readiness); +} + +export function workflowPriorityReason(priority: number, nextAction: string): string { + if (priority >= 100) return "confirmed finding ready for report"; + if (priority >= 90) return "submission-ready candidate needs promotion"; + if (priority >= 80) return "only local reproduction is blocking confirmation"; + if (priority >= 20) return "audit evidence still missing"; + if (nextAction.includes("archive")) return "blocked finding can be archived"; + return "low readiness"; +} + +export function extractFieldRefs(message: string): string[] { + const refs = new Set(); + for (const match of message.matchAll(/\b[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+\b/g)) { + refs.add(match[0]); + } + if (message.startsWith("blockers ")) { + refs.add("blockers"); + } + return Array.from(refs); +} + +export function classifyWarning(message: string): string { + if (message.includes("observed_result")) return "missingObservedResult"; + if (message.includes("affected_range")) return "unknownAffectedRange"; + if (message.includes("dedup search")) return "incompleteDedup"; + if (message.includes("cvss.vector")) return "suspiciousCvss"; + if (message.includes("evidence.guard")) return "guardBlocksExploit"; + if (message.includes("repro_artifacts")) return "missingReproArtifacts"; + if (message.includes("blockers")) return "unresolvedBlockers"; + return "validation-warning"; +} + +export function warningNextAction(message: string, id: string): string { + if (message.includes("observed_result") || message.includes("repro_artifacts")) return `/omv-repro ${id}`; + if (message.includes("blockers")) return `/omv-audit ${id}`; + if (message.includes("dedup") || message.includes("cvss") || message.includes("guard") || message.includes("affected_range")) return `/omv-audit ${id}`; + return `omv findings open ${id}`; +} + +export function dedupeIssues(issues: FindingDoctorIssue[]): FindingDoctorIssue[] { + const seen = new Set(); + const result: FindingDoctorIssue[] = []; + for (const issue of issues) { + const key = `${issue.id}:${issue.message}`; + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(issue); + } + return result; +} + +function getListFromWarnings(warnings: string[], field: string): string[] { + return warnings.filter((warning) => warning.startsWith(`${field} `)); +} From 11a497282a754e476c3c6509aa0afec5ac020aff Mon Sep 17 00:00:00 2001 From: Bpple Date: Mon, 6 Jul 2026 11:50:51 +0800 Subject: [PATCH 3/3] refactor(cli): consolidate rendering in render.ts, thin commands/findings.ts - Move all print* functions from commands/findings.ts into render.ts (the designated single rendering surface) - Add printDeleteResult to render.ts (was only in commands/findings.ts) - commands/findings.ts is now pure dispatch: arg parse + domain call + render call + exit code. All print helpers come from ../render.js - Build clean, 37/37 tests pass --- src/cli/commands/findings.ts | 242 ++--------------------------------- src/cli/render.ts | 31 +++++ src/cli/repro.ts | 15 +-- 3 files changed, 49 insertions(+), 239 deletions(-) diff --git a/src/cli/commands/findings.ts b/src/cli/commands/findings.ts index 0e91e1f..ea2ea7c 100644 --- a/src/cli/commands/findings.ts +++ b/src/cli/commands/findings.ts @@ -22,6 +22,18 @@ import { type FindingRestoreResult, type FindingDeleteResult, } from "../findings.js"; +import { + printArchivedSummaries, + printArchiveResult, + printDeleteResult, + printFindingDetail, + printFindingDoctor, + printFindingSummaries, + printFindingTemplateResult, + printFindingValidation, + printRestoreResult, + printWorkflowSummaries, +} from "../render.js"; import { usage } from "../usage.js"; import { firstPositionalAfter, parseStatus, parseReason, wantsJson } from "./shared.js"; import { @@ -232,41 +244,6 @@ async function runFindingsDoctor(args: string[], json: boolean): Promise { } } -function printFindingDoctor(result: FindingDoctorResult): void { - console.log(title(`doctor ${result.id}`)); - console.log( - panel("readiness", [ - ...kv([ - ["status", statusBadge(result.status as "candidate" | "confirmed" | "blocked")], - ["validation", validationBadge(result.validationOk)], - ["evidence", readiness(result.evidenceScore)], - ["submission", readiness(result.submissionScore)], - ["threshold", String(result.submissionThreshold)], - ["report ready", result.reportReady ? "yes" : "no"], - ["next", cmd(result.nextAction)], - ]), - ]), - ); - - if (result.issues.length === 0) { - console.log(empty("No readiness issues.")); - return; - } - - console.log(section("Issues")); - console.log( - table( - ["", "severity", "message", "next action"], - result.issues.map((issue) => [ - statusIcon(issue.severity === "error" ? "fail" : issue.severity === "warning" ? "warn" : "pass"), - issue.severity, - truncate(issue.message, 64), - cmd(truncate(issue.nextAction, 40)), - ]), - ), - ); -} - async function runFindingsPromote(args: string[], json: boolean): Promise { const target = firstPositionalAfter(args, "promote"); const status = parseStatus(args); @@ -352,198 +329,3 @@ async function runFindingsDelete(args: string[], json: boolean): Promise { } printDeleteResult(result); } - -function printFindingSummaries(findings: FindingSummary[]): void { - console.log(title("active findings")); - console.log( - table( - ["id", "status", "evidence", "submission", "package", "vulnerability"], - findings.map((finding) => [ - truncate(finding.id, 36), - statusBadge(finding.status), - readiness(finding.evidenceScore), - readiness(finding.submissionScore), - truncate(`${finding.ecosystem}:${finding.package}`, 42), - truncate(finding.vulnerability, 36), - ]), - ), - ); -} - -function printWorkflowSummaries(findings: FindingWorkflowSummary[]): void { - console.log(title("workflow queue")); - console.log( - table( - ["id", "priority", "status", "evidence", "submission", "next action", "package", "vulnerability"], - findings.map((finding) => [ - truncate(finding.id, 30), - String(finding.priority), - statusBadge(finding.status), - readiness(finding.evidenceScore), - readiness(finding.submissionScore), - cmd(truncate(finding.nextAction, 46)), - truncate(`${finding.ecosystem}:${finding.package}`, 34), - truncate(finding.vulnerability, 30), - ]), - ), - ); -} - -function printFindingDetail(finding: FindingDetail): void { - console.log(title(`finding ${finding.id}`)); - const lines = kv([ - ["status", statusBadge(finding.status)], - ["evidence", readiness(finding.evidenceScore)], - ["submission", readiness(finding.submissionScore)], - ["verdict", `${finding.verdict.exploitability}/${finding.verdict.confidence}`], - ["validation", validationBadge(finding.validation.ok)], - ["priority", `${finding.priority} (${finding.priorityReason})`], - ["path", finding.path], - ["package", `${finding.ecosystem}:${finding.package}`], - ["vulnerability", finding.vulnerability], - ]); - if (finding.reproArtifacts.length > 0) { - lines.push(...kv([["repro artifacts", String(finding.reproArtifacts.length)]])); - } - if (finding.threatMap) { - lines.push(...kv([["threat map", finding.threatMap.path]])); - lines.push("", muted("threat map")); - lines.push(...finding.threatMap.rendered.map((item) => ` ${item}`)); - } - if (finding.archived) { - lines.push(...kv([ - ["archived", finding.archivedAt ?? "unknown"], - ["reason", finding.archiveReason ?? "unknown"], - ])); - } - lines.push(...kv([["next", cmd(finding.nextAction)]])); - if (finding.validation.errors.length > 0) { - lines.push("", tuiError("errors")); - lines.push(...finding.validation.errors.map((item) => ` ${item}`)); - } - if (finding.validation.warnings.length > 0) { - lines.push("", warn("warnings")); - lines.push(...finding.validation.warnings.slice(0, 8).map((item) => ` ${item}`)); - if (finding.validation.warnings.length > 8) { - lines.push(muted(` ... ${finding.validation.warnings.length - 8} more warning(s)`)); - } - } - if (finding.missingFields.length > 0) { - lines.push("", muted(`missing ${finding.missingFields.join(", ")}`)); - } - console.log(panel(finding.id, lines)); -} - -function printArchivedSummaries(findings: ArchivedFindingSummary[]): void { - if (findings.length === 0) { - console.log(empty("No archived findings.")); - return; - } - console.log(title("archive")); - console.log( - table( - ["id", "status", "archived", "reason", "package"], - findings.map((finding) => [ - truncate(finding.id, 34), - statusBadge(finding.status), - truncate(finding.archivedAt, 27), - truncate(finding.archiveReason, 26), - truncate(`${finding.ecosystem}:${finding.package}`, 40), - ]), - ), - ); -} - -function printFindingValidation(result: FindingValidation): void { - const lines = kv([ - ["status", statusBadge(result.status)], - ["evidence", readiness(result.evidenceScore)], - ["submission", readiness(result.submissionScore)], - ["validation", validationBadge(result.ok)], - ["path", result.path], - ]); - if (result.errors.length > 0) { - lines.push("", tuiError("errors")); - lines.push(...result.errors.map((item) => ` ${item}`)); - } - if (result.warnings.length > 0) { - lines.push("", warn("warnings")); - lines.push(...result.warnings.map((item) => ` ${item}`)); - } - console.log(panel(result.id, lines)); -} - -function printFindingTemplateResult(result: FindingTemplateResult): void { - console.log( - panel("finding created", [ - ...kv([ - ["id", result.id], - ["path", result.path], - ["status", statusBadge(result.status)], - ["next", cmd(`omv findings show ${result.id}`)], - ]), - ]), - ); -} - -function printArchiveResult(result: FindingArchiveResult): void { - console.log( - panel("finding archived", [ - ...kv([ - ["id", result.id], - ["status", statusBadge(result.status)], - ["from", result.from], - ["to", result.to], - ["reason", result.archiveReason], - ["reports", result.reportArtifactPaths.length > 0 ? `${result.reportArtifactPaths.length} artifact(s)` : "none"], - ["submissions", result.submissionRecords.length > 0 ? `${result.submissionRecords.length} record(s)` : "none"], - ["next", cmd(`omv findings show ${result.id} --archived`)], - ]), - ...result.warnings.map((item) => warn(`warning ${item}`)), - ]), - ); -} - -function printRestoreResult(result: FindingRestoreResult): void { - console.log( - panel("finding restored", [ - ...kv([ - ["id", result.id], - ["status", statusBadge(result.status)], - ["from", result.from], - ["to", result.to], - ["next", cmd("omv findings workflow")], - ]), - ]), - ); -} - -function printDeleteResult(result: FindingDeleteResult): void { - if (!result.deleted) { - console.log( - panel("finding delete preview", [ - ...kv([ - ["id", result.id], - ["action", "preview only"], - ["next", cmd(`omv findings delete ${result.id} --force`)], - ]), - "", - muted("paths to delete"), - ...result.paths.map((p) => ` ${p}`), - ]), - ); - return; - } - - const finalState = result.errors.length > 0 ? "warn" : "pass"; - console.log( - panel("finding deleted", [ - ...kv([ - ["id", result.id], - ["result", outcomeBadge(finalState)], - ["paths", `${result.paths.length} file(s) removed`], - ]), - ...(result.errors.length > 0 ? ["", warn("errors"), ...result.errors.map((e) => ` ${e}`)] : []), - ]), - ); -} diff --git a/src/cli/render.ts b/src/cli/render.ts index 7c8823a..1f13943 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -2,6 +2,7 @@ import type { Check, DoctorResult } from "./doctor.js"; import type { ArchivedFindingSummary, FindingArchiveResult, + FindingDeleteResult, FindingDetail, FindingDoctorResult, FindingRestoreResult, @@ -449,6 +450,36 @@ export function printRestoreResult(result: FindingRestoreResult): void { ); } +export function printDeleteResult(result: FindingDeleteResult): void { + if (!result.deleted) { + console.log( + panel("finding delete preview", [ + ...kv([ + ["id", result.id], + ["action", "preview only"], + ["next", cmd(`omv findings delete ${result.id} --force`)], + ]), + "", + muted("paths to delete"), + ...result.paths.map((p) => ` ${p}`), + ]), + ); + return; + } + + const finalState = result.errors.length > 0 ? "warn" : "pass"; + console.log( + panel("finding deleted", [ + ...kv([ + ["id", result.id], + ["result", outcomeBadge(finalState)], + ["paths", `${result.paths.length} file(s) removed`], + ]), + ...(result.errors.length > 0 ? ["", warn("errors"), ...result.errors.map((e) => ` ${e}`)] : []), + ]), + ); +} + function formatCheckDetail(check: Check): string { return `${statusIcon(check.status)} ${check.name}: ${check.message}`; } diff --git a/src/cli/repro.ts b/src/cli/repro.ts index 2eceb2e..5358d1b 100644 --- a/src/cli/repro.ts +++ b/src/cli/repro.ts @@ -86,11 +86,11 @@ export async function checkReportArtifacts(id: string, projectRoot = process.cwd if (!existsSync(findingPath)) { throw new Error(`${findingPath} does not exist`); } - const parsed = (await readEvidence(findingPath)).data; - const status = getString(parsed, "status") || "unknown"; + const { data } = await readEvidence(findingPath); + const status = getString(data, "status") || "unknown"; const reportsPath = findingReportsDir(normalizedId, projectRoot); const reproPath = findingReproDir(normalizedId, projectRoot); - const listedReproArtifacts = getList(parsed, "evidence.repro_artifacts").map(String).filter((value) => value.trim() !== ""); + const listedReproArtifacts = getList(data, "evidence.repro_artifacts").map(String).filter((value) => value.trim() !== ""); const existingReproArtifacts = existingArtifactPaths(listedReproArtifacts, projectRoot); const missingReproArtifacts = listedReproArtifacts.filter((value) => !artifactExists(value, projectRoot)); const reportArtifactPaths = await listReportArtifacts(normalizedId, projectRoot); @@ -149,7 +149,7 @@ Do not place secrets, live target data, or private disclosure material here. async function mergeReproArtifacts(findingPath: string, artifacts: string[]): Promise { const text = await readFile(findingPath, "utf-8"); const doc = parseDocument(text); - const values = getList(parseEvidenceYaml(text).data, "evidence.repro_artifacts").map(String); + const values = getList((await parseEvidenceYaml(text)).data, "evidence.repro_artifacts").map(String); const merged = [...values]; for (const artifact of artifacts) { if (!merged.includes(artifact)) { @@ -258,8 +258,8 @@ function getValue(data: Record, path: string): unknown { return current; } -function parseEvidenceYaml(text: string): { data: Record; errors: string[] } { - const { parse: parseYaml } = requireModule("yaml"); +async function parseEvidenceYaml(text: string): Promise<{ data: Record; errors: string[] }> { + const { parse: parseYaml } = await import("yaml"); try { const parsed = parseYaml(text); if (!isRecord(parsed)) { @@ -281,6 +281,3 @@ async function readEvidence(path: string): Promise<{ data: Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } - -import { createRequire } from "module"; -const requireModule = createRequire(import.meta.url);