Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Unreleased

- 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.
- 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

Expand Down
63 changes: 62 additions & 1 deletion src/cli/__tests__/findings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
43 changes: 33 additions & 10 deletions src/cli/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1038,28 +1038,51 @@ async function readThreatMap(id: string, projectRoot: string): Promise<FindingTh
}

function renderThreatMap(data: Record<string, unknown>): 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<string, unknown>): number {
Expand Down
Loading