From 74018854cc1ae9a820ebf8417fe97d2d310a99c5 Mon Sep 17 00:00:00 2001 From: Bpple Date: Thu, 18 Jun 2026 23:07:15 +0800 Subject: [PATCH] feat(cli): render full ThreatMap.v1 dataflow in findings show renderThreatMap collapsed each path to a single [source] -> [sink] line, discarding the transforms (the actual dataflow), per-path confidence, bypassable guards, and the summary block that omv threat-map init now writes. Complete the read side to match the producer: - Multi-line rendering per path: source, each transform, sink, guard (present/missing, bypassable flag), confidence in the path header. - Optional summary line (path_count, confirmed_paths, highest_confidence). - describeThreatNode now surfaces location alongside the label. - Updated the pinned read test to the richer format and added a test covering transforms, confidence, bypassable guard, and summary. 37 tests pass; full validate green. --- CHANGELOG.md | 2 +- src/cli/__tests__/findings.test.ts | 63 +++++++++++++++++++++++++++++- src/cli/findings.ts | 43 +++++++++++++++----- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7556b0a..f81dbb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Added `omv threat-map init ` — the producer side of the ThreatMap.v1 pipeline. Scaffolds `.omv/threatmaps/.yaml` (finding_id + package filled from the finding, `paths: []` ready to fill) so `omv-audit` records source → transform → sink dataflow instead of hand-authoring YAML. The read pipeline (`omv findings show` rendering, archive sidecar handling) was already in place; this connects it. +- Richer ThreatMap.v1 rendering: `omv findings show` now displays the full `source → transforms → sink` dataflow per path with per-path confidence, bypassable guards, and a summary line. Previously the renderer collapsed each path to a single `[source] -> [sink]` line, discarding transforms, confidence, and the summary block that the producer now writes. ## v0.9.0 - CLI command split and local findings dedup diff --git a/src/cli/__tests__/findings.test.ts b/src/cli/__tests__/findings.test.ts index 3b99912..17bbc69 100644 --- a/src/cli/__tests__/findings.test.ts +++ b/src/cli/__tests__/findings.test.ts @@ -356,7 +356,68 @@ summary: const detail = await showFinding("demo", projectRoot); assert.equal(detail.threatMap?.path, threatMapPath("demo", projectRoot)); - assert.deepEqual(detail.threatMap?.rendered, ["[HTTP body] -> [http.Get()] x no-allowlist"]); + assert.deepEqual(detail.threatMap?.rendered, [ + "path 1", + " source: HTTP body — src/server.js:10", + " sink: http.Get() — src/fetch.js:22", + " guard missing: no-allowlist", + "summary: 1 paths", + ]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("showFinding renders transforms, confidence, and bypassable guards", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-findings-")); + + try { + const dir = await ensureFindingsDir(projectRoot); + await writeFile(join(dir, "demo.yaml"), BASE_FINDING, "utf-8"); + await mkdir(join(projectRoot, ".omv", "threatmaps"), { recursive: true }); + await writeFile( + threatMapPath("demo", projectRoot), + `schema_version: "1" +finding_id: demo +paths: + - id: 1 + source: + type: file + location: lib/extract.js:42 + description: zip entry name + transforms: + - type: normalize + location: lib/extract.js:60 + description: entry joined without canonicalization + - type: parse + location: lib/extract.js:64 + sink: + type: fs_write + location: lib/extract.js:88 + description: createWriteStream outside base dir + guard: + present: true + description: path prefix check + bypassable: true + confidence: high +summary: + path_count: 1 + confirmed_paths: 1 + highest_confidence: high +`, + "utf-8", + ); + + const detail = await showFinding("demo", projectRoot); + assert.deepEqual(detail.threatMap?.rendered, [ + "path 1 (high confidence)", + " source: zip entry name — lib/extract.js:42", + " transform: entry joined without canonicalization — lib/extract.js:60", + " transform: parse — lib/extract.js:64", + " sink: createWriteStream outside base dir — lib/extract.js:88", + " guard: path prefix check (bypassable)", + "summary: 1 paths, 1 confirmed, highest: high", + ]); } finally { await rm(projectRoot, { recursive: true, force: true }); } diff --git a/src/cli/findings.ts b/src/cli/findings.ts index 981426b..ef34d3a 100644 --- a/src/cli/findings.ts +++ b/src/cli/findings.ts @@ -1038,28 +1038,51 @@ async function readThreatMap(id: string, projectRoot: string): Promise): string[] { - return getList(data, "paths").map((item, index) => { + const lines: string[] = []; + const paths = getList(data, "paths"); + paths.forEach((item, index) => { if (!isRecord(item)) { - return `[path ${index + 1}] -> invalid threat map path`; + lines.push(`path ${index + 1}: invalid threat map path`); + return; } - const source = describeThreatNode(item.source, "source"); - const sink = describeThreatNode(item.sink, "sink"); + 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"); - return `${source} -> ${sink} ${present ? "guard:" : "x"} ${guardText}`; + 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}]`; + return fallback; } const description = getRecordString(value, "description"); const location = getRecordString(value, "location"); - const type = getRecordString(value, "type") || fallback; - const label = description || location || type; - return `[${label}]`; + const type = getRecordString(value, "type"); + const label = description || type || fallback; + return location ? `${label} — ${location}` : label; } function cvssConfidencePenalty(data: Record): number {