Skip to content

Commit 51f0a92

Browse files
authored
Merge pull request #16 from bx33661/feat/threat-map-rich-render
feat(cli): render full ThreatMap.v1 dataflow in findings show
2 parents 3c60e87 + 7401885 commit 51f0a92

3 files changed

Lines changed: 96 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Unreleased
44

5-
- Added `omv threat-map init <id>`the producer side of the ThreatMap.v1 pipeline. Scaffolds `.omv/threatmaps/<id>.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.
5+
- 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.
66

77
## v0.9.0 - CLI command split and local findings dedup
88

src/cli/__tests__/findings.test.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,68 @@ summary:
356356

357357
const detail = await showFinding("demo", projectRoot);
358358
assert.equal(detail.threatMap?.path, threatMapPath("demo", projectRoot));
359-
assert.deepEqual(detail.threatMap?.rendered, ["[HTTP body] -> [http.Get()] x no-allowlist"]);
359+
assert.deepEqual(detail.threatMap?.rendered, [
360+
"path 1",
361+
" source: HTTP body — src/server.js:10",
362+
" sink: http.Get() — src/fetch.js:22",
363+
" guard missing: no-allowlist",
364+
"summary: 1 paths",
365+
]);
366+
} finally {
367+
await rm(projectRoot, { recursive: true, force: true });
368+
}
369+
});
370+
371+
test("showFinding renders transforms, confidence, and bypassable guards", async () => {
372+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-findings-"));
373+
374+
try {
375+
const dir = await ensureFindingsDir(projectRoot);
376+
await writeFile(join(dir, "demo.yaml"), BASE_FINDING, "utf-8");
377+
await mkdir(join(projectRoot, ".omv", "threatmaps"), { recursive: true });
378+
await writeFile(
379+
threatMapPath("demo", projectRoot),
380+
`schema_version: "1"
381+
finding_id: demo
382+
paths:
383+
- id: 1
384+
source:
385+
type: file
386+
location: lib/extract.js:42
387+
description: zip entry name
388+
transforms:
389+
- type: normalize
390+
location: lib/extract.js:60
391+
description: entry joined without canonicalization
392+
- type: parse
393+
location: lib/extract.js:64
394+
sink:
395+
type: fs_write
396+
location: lib/extract.js:88
397+
description: createWriteStream outside base dir
398+
guard:
399+
present: true
400+
description: path prefix check
401+
bypassable: true
402+
confidence: high
403+
summary:
404+
path_count: 1
405+
confirmed_paths: 1
406+
highest_confidence: high
407+
`,
408+
"utf-8",
409+
);
410+
411+
const detail = await showFinding("demo", projectRoot);
412+
assert.deepEqual(detail.threatMap?.rendered, [
413+
"path 1 (high confidence)",
414+
" source: zip entry name — lib/extract.js:42",
415+
" transform: entry joined without canonicalization — lib/extract.js:60",
416+
" transform: parse — lib/extract.js:64",
417+
" sink: createWriteStream outside base dir — lib/extract.js:88",
418+
" guard: path prefix check (bypassable)",
419+
"summary: 1 paths, 1 confirmed, highest: high",
420+
]);
360421
} finally {
361422
await rm(projectRoot, { recursive: true, force: true });
362423
}

src/cli/findings.ts

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1038,28 +1038,51 @@ async function readThreatMap(id: string, projectRoot: string): Promise<FindingTh
10381038
}
10391039

10401040
function renderThreatMap(data: Record<string, unknown>): string[] {
1041-
return getList(data, "paths").map((item, index) => {
1041+
const lines: string[] = [];
1042+
const paths = getList(data, "paths");
1043+
paths.forEach((item, index) => {
10421044
if (!isRecord(item)) {
1043-
return `[path ${index + 1}] -> invalid threat map path`;
1045+
lines.push(`path ${index + 1}: invalid threat map path`);
1046+
return;
10441047
}
1045-
const source = describeThreatNode(item.source, "source");
1046-
const sink = describeThreatNode(item.sink, "sink");
1048+
const confidence = getRecordString(item, "confidence");
1049+
lines.push(`path ${index + 1}${confidence ? ` (${confidence} confidence)` : ""}`);
1050+
lines.push(` source: ${describeThreatNode(item.source, "source")}`);
1051+
const transforms = getList(item, "transforms");
1052+
for (const transform of transforms) {
1053+
lines.push(` transform: ${describeThreatNode(transform, "transform")}`);
1054+
}
1055+
lines.push(` sink: ${describeThreatNode(item.sink, "sink")}`);
10471056
const guard = isRecord(item.guard) ? item.guard : {};
10481057
const present = guard.present === true;
1049-
const guardText = getRecordString(guard, "description") || (present ? "guard-present" : "no-guard");
1050-
return `${source} -> ${sink} ${present ? "guard:" : "x"} ${guardText}`;
1058+
const guardText = getRecordString(guard, "description") || (present ? "guard present" : "no guard");
1059+
const bypassable = guard.bypassable === true ? " (bypassable)" : "";
1060+
lines.push(` ${present ? "guard" : "guard missing"}: ${guardText}${bypassable}`);
10511061
});
1062+
1063+
const summary = isRecord(data.summary) ? data.summary : undefined;
1064+
if (summary) {
1065+
const pathCount = getRecordString(summary, "path_count");
1066+
const confirmed = getRecordString(summary, "confirmed_paths");
1067+
const highest = getRecordString(summary, "highest_confidence");
1068+
const parts: string[] = [];
1069+
if (pathCount) parts.push(`${pathCount} paths`);
1070+
if (confirmed) parts.push(`${confirmed} confirmed`);
1071+
if (highest) parts.push(`highest: ${highest}`);
1072+
if (parts.length > 0) lines.push(`summary: ${parts.join(", ")}`);
1073+
}
1074+
return lines;
10521075
}
10531076

10541077
function describeThreatNode(value: unknown, fallback: string): string {
10551078
if (!isRecord(value)) {
1056-
return `[${fallback}]`;
1079+
return fallback;
10571080
}
10581081
const description = getRecordString(value, "description");
10591082
const location = getRecordString(value, "location");
1060-
const type = getRecordString(value, "type") || fallback;
1061-
const label = description || location || type;
1062-
return `[${label}]`;
1083+
const type = getRecordString(value, "type");
1084+
const label = description || type || fallback;
1085+
return location ? `${label}${location}` : label;
10631086
}
10641087

10651088
function cvssConfidencePenalty(data: Record<string, unknown>): number {

0 commit comments

Comments
 (0)