Skip to content

Commit d9ddd39

Browse files
committed
feat(cli): add omv threat-map init producer for ThreatMap.v1
The ThreatMap.v1 read pipeline was fully built and tested but nothing produced the sidecar. Add: - writeThreatMap() domain fn (findings.ts): scaffolds .omv/threatmaps/<id>.yaml from a ThreatMap.v1 template, finding_id and package block filled from the finding, idempotent with --force. - omv threat-map init <id> CLI command (commands/threat-map.ts), registered in commands/index.ts, validated in args.ts, with usage. - omv-audit SKILL.md now invokes the helper instead of hand-authoring. - omv-audit eval id 6 + golden + threatmap_sidecar_present checker assertion; unit test for writeThreatMap (36 tests pass). Sidecar-only per the contract: the parent Evidence.v1 file is not modified; evidence source/sink/guard remain the compatibility summary.
1 parent 271cdbb commit d9ddd39

14 files changed

Lines changed: 375 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## Unreleased
4+
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.
6+
37
## v0.9.0 - CLI command split and local findings dedup
48

59
- Split the `omv` CLI dispatcher (`omv.ts`, 1434 lines) into one module per command under `commands/`, plus a `commands/shared.ts` arg-helper module and a `commands/index.ts` registry replacing the dispatch `switch`. The 12 duplicated command error handlers collapse into one. `omv.ts` is now a 7-line entry.

scripts/sync_skill_assets.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
("shared/references/patterns/java.md", "skills/omv-audit/references/patterns/java.md"),
3939
("shared/references/patterns/ruby.md", "skills/omv-audit/references/patterns/ruby.md"),
4040
("contracts/evidence.v1.yaml", "skills/omv-audit/contracts/evidence.v1.yaml"),
41+
("contracts/threat-map.v1.yaml", "skills/omv-audit/contracts/threat-map.v1.yaml"),
4142
("contracts/evidence.v1.yaml", "skills/omv-repro/contracts/evidence.v1.yaml"),
4243
("shared/references/cvss-builder.md", "skills/omv-report/references/shared/cvss-builder.md"),
4344
("contracts/evidence.v1.yaml", "skills/omv-report/contracts/evidence.v1.yaml"),

skills/omv-audit/SKILL.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@ Stay in passive research mode: read public source code only. Do not send request
4747

4848
如果 finding 属于 npm、Python、Go、Rust、Java 或 Ruby,按需加载 `references/patterns/` 下对应生态文件,用其中的 source pattern、sink signature、expected guard、evidence criteria、false-positive checks 和 CWE 映射辅助判断。Pattern registry 是方法论,不是真实漏洞案例库;不要加载无关生态 registry。
4949

50-
当证据链足够清楚时,额外生成可选 ThreatMap.v1 sidecar:
50+
当证据链足够清楚时,生成可选 ThreatMap.v1 sidecar,记录 source → transform → sink 的 dataflow 路径
5151

52-
```text
53-
.omv/threatmaps/<id>.yaml
52+
```bash
53+
omv threat-map init <id>
5454
```
5555

56-
ThreatMap 是 Evidence.v1 的补充,不替代 `evidence.source` / `evidence.sink` / `evidence.guard` 摘要字段。
56+
这会在 `.omv/threatmaps/<id>.yaml` 生成骨架(`finding_id``package` 块已从 finding 填好,`paths: []` 留待填写)。然后在每个已确认的 source → sink 路径下补一条 `paths[]` 条目:`source`(type/location/description)、`transforms[]`(中间每一步:parse/decode/normalize/validate/authorize)、`sink``guard`(present/bypassable)、`confidence`。Schema 见 `contracts/threat-map.v1.yaml`ThreatMap 是 Evidence.v1 的补充,不替代 `evidence.source` / `evidence.sink` / `evidence.guard` 摘要字段;sidecar 不修改父 Evidence.v1 文件
5757

5858
解释审计结论时使用方法论语言:说明输入如何到达 sink、guard 为什么缺失或可绕过、哪些证据仍不充分。除非用户提供真实 finding 作为上下文,否则不要把真实包或真实 CVE 当作教程示例。
5959

@@ -111,6 +111,7 @@ Use the CLI result for lifecycle handoff:
111111

112112
- `omv findings validate <id>` — 校验字段完整性,输出 evidence/submission 分数
113113
- `omv findings promote <id> --status confirmed|blocked` — 更新 status 字段
114+
- `omv threat-map init <id>` — 生成 `.omv/threatmaps/<id>.yaml` ThreatMap.v1 dataflow 骨架
114115
- `omv findings workflow` — 显示 active findings 的下一步动作
115116
- `python3 shared/scripts/resolve_source_path.py --ecosystem npm --pkg <name>` — 获取源文件 raw URL
116117
- `python3 shared/scripts/collect_metadata.py --repo <github-url>` — 获取仓库元数据
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# ThreatMap.v1 — optional dataflow threat map sidecar
2+
# Produced by: omv-audit
3+
# Consumed by: omv findings show, omv-critic, omv-report
4+
#
5+
# The full threat map for a single finding lives in a sidecar file:
6+
# .omv/threatmaps/<id>.yaml
7+
# The parent `.omv/findings/<id>.yaml` Evidence.v1 file remains valid without
8+
# this sidecar. Evidence.v1 source/sink/guard fields stay as compatibility
9+
# summaries; this graph is used when a richer source -> transform -> sink path
10+
# is useful for audit review or report criticism.
11+
12+
schema_version: "1"
13+
14+
# ── Identity ──────────────────────────────────────────────────────────────────
15+
finding_id: "" # id matching `.omv/findings/<id>.yaml`
16+
package:
17+
ecosystem: ""
18+
registry_name: ""
19+
repository_url: ""
20+
version_analyzed: ""
21+
22+
# ── Dataflow Paths ────────────────────────────────────────────────────────────
23+
# One entry per discovered source-to-sink path.
24+
paths: []
25+
# Each path entry:
26+
# - id: 1
27+
# source:
28+
# type: user_input | file | network | env | config
29+
# location: "src/routes/upload.js:42"
30+
# description: "req.body.filename passed directly"
31+
# transforms:
32+
# - type: parse | decode | normalize | validate | authorize | other
33+
# location: "src/routes/upload.js:45"
34+
# description: "filename is joined without canonical path check"
35+
# sink:
36+
# type: fs_write | exec | eval | html_render | sql | network_req
37+
# location: "src/utils/file.js:88"
38+
# description: "path.join(baseDir, filename) without normalization"
39+
# guard:
40+
# present: false
41+
# description: "no path traversal check before join"
42+
# bypassable: true
43+
# confidence: high # high | medium | low
44+
# notes: ""
45+
46+
# ── Summary ───────────────────────────────────────────────────────────────────
47+
# Digest fields written into the parent Evidence.v1 object.
48+
summary:
49+
path_count: 0
50+
confirmed_paths: 0
51+
highest_confidence: unknown # high | medium | low | unknown
52+
vuln_classes: [] # list of vulnerability class names identified
53+
notes: ""
54+
55+
# ── Provenance ────────────────────────────────────────────────────────────────
56+
provenance:
57+
analysis_date: ""
58+
tool: manual # manual | semgrep | codeql | other
59+
tool_version: unknown
60+
analyst: ""

skills/omv-audit/evals/evals.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,24 @@
8484
"text": "scope-changed SSTI must have CVSS >= 9.0 and S:C in vector"
8585
}
8686
]
87+
},
88+
{
89+
"id": 6,
90+
"prompt": "/omv-audit demo-threatmap-path-traversal",
91+
"expected_output": "Confirmed audit that also scaffolds a ThreatMap.v1 sidecar at .omv/threatmaps/<id>.yaml via `omv threat-map init`, then fills one paths[] entry with source, transforms, sink, and a missing/bypassable guard for a path-traversal finding.",
92+
"files": [
93+
"evals/golden/threatmap-path-traversal.md"
94+
],
95+
"assertions": [
96+
{
97+
"type": "confirmed_complete_evidence",
98+
"text": "must still produce a complete confirmed Evidence.v1 block"
99+
},
100+
{
101+
"type": "threatmap_sidecar_present",
102+
"text": "must scaffold .omv/threatmaps/<id>.yaml and fill a paths[] source->sink->guard entry"
103+
}
104+
]
87105
}
88106
]
89107
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
## Audit Result
2+
3+
status: confirmed
4+
5+
evidence:
6+
source: lib/extract.js:42 zip entry name from archive
7+
sink: lib/extract.js:88 fs.createWriteStream path joined without normalization
8+
guard: missing path traversal check before join
9+
reproducer: run `node repro.js` in a local checkout with version 0.4.1
10+
observed_result: local run writes a file outside the configured base directory
11+
12+
cvss:
13+
vector: CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:N/A:N
14+
score: 5.5
15+
severity: Medium
16+
17+
dedup:
18+
nvd_searched: true
19+
ghsa_searched: true
20+
ecosystem_db_searched: true
21+
existing_cve: none
22+
23+
## ThreatMap.v1 sidecar
24+
25+
Ran `omv threat-map init demo-threatmap-path-traversal` to scaffold `.omv/threatmaps/demo-threatmap-path-traversal.yaml`, then filled one `paths[]` entry for the confirmed route:
26+
27+
```yaml
28+
schema_version: "1"
29+
finding_id: "demo-threatmap-path-traversal"
30+
package:
31+
ecosystem: "npm"
32+
registry_name: "demo-archive-extractor"
33+
repository_url: "https://github.com/example/demo-archive-extractor"
34+
version_analyzed: "0.4.1"
35+
paths:
36+
- id: 1
37+
source:
38+
type: file
39+
location: "lib/extract.js:42"
40+
description: "zip entry name read from archive"
41+
transforms:
42+
- type: normalize
43+
location: "lib/extract.js:60"
44+
description: "entry.name joined to base dir with path.join, no canonicalization"
45+
sink:
46+
type: fs_write
47+
location: "lib/extract.js:88"
48+
description: "fs.createWriteStream resolves outside base dir via ../"
49+
guard:
50+
present: false
51+
description: "no path traversal check before join"
52+
bypassable: true
53+
confidence: high
54+
summary:
55+
path_count: 1
56+
confirmed_paths: 1
57+
highest_confidence: high
58+
vuln_classes: ["Path Traversal"]
59+
```
60+
61+
The sidecar is optional and does not modify the parent Evidence.v1 file; `evidence.source`/`sink`/`guard` remain the compatibility summaries.

skills/omv-audit/scripts/check_output.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,13 @@ def check(assertion_type: str, text: str) -> bool:
7676
and "S:C" in text
7777
and any(float(m) >= 9.0 for m in re.findall(r"score:\s*([\d.]+)", text))
7878
)
79+
if assertion_type == "threatmap_sidecar_present":
80+
return (
81+
"threat-map init" in text
82+
and ".omv/threatmaps/" in text
83+
and "paths:" in text
84+
and contains_all(text, ["source:", "sink:", "guard:"])
85+
)
7986
raise SystemExit(f"unknown assertion type: {assertion_type}")
8087

8188

src/cli/__tests__/findings.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
showFinding,
2020
validateFinding,
2121
validateFindings,
22+
writeThreatMap,
2223
} from "../findings.js";
2324
import { archiveMetadataPath, archivedFindingsDir, findingReportsDir, findingReproDir, findingsDir, threatMapPath, workspaceIndexPath } from "../paths.js";
2425
import { recordSubmission } from "../submissions.js";
@@ -361,6 +362,44 @@ summary:
361362
}
362363
});
363364

365+
test("writeThreatMap scaffolds an idempotent ThreatMap.v1 sidecar", async () => {
366+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-findings-"));
367+
368+
try {
369+
const dir = await ensureFindingsDir(projectRoot);
370+
await writeFile(join(dir, "demo.yaml"), BASE_FINDING, "utf-8");
371+
372+
const path = threatMapPath("demo", projectRoot);
373+
374+
const first = await writeThreatMap("demo", projectRoot);
375+
assert.equal(first.written, true);
376+
assert.equal(first.skipped, false);
377+
assert.equal(first.path, path);
378+
assert.ok(existsSync(path));
379+
380+
const body = await readFile(path, "utf-8");
381+
assert.match(body, /schema_version: "1"/);
382+
assert.match(body, /finding_id: "demo"/);
383+
assert.match(body, /paths: \[\]/);
384+
385+
// showFinding picks up the scaffolded sidecar
386+
const detail = await showFinding("demo", projectRoot);
387+
assert.equal(detail.threatMap?.path, path);
388+
389+
// non-empty sidecar is skipped without --force
390+
const second = await writeThreatMap("demo", projectRoot);
391+
assert.equal(second.written, false);
392+
assert.equal(second.skipped, true);
393+
394+
// --force overwrites
395+
const third = await writeThreatMap("demo", projectRoot, { force: true });
396+
assert.equal(third.written, true);
397+
assert.equal(third.skipped, false);
398+
} finally {
399+
await rm(projectRoot, { recursive: true, force: true });
400+
}
401+
});
402+
364403
test("submission score is confidence weighted", async () => {
365404
const projectRoot = await mkdtemp(join(tmpdir(), "omv-findings-"));
366405

src/cli/args.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export function validateArgs(args: string[]): ArgsValidation {
6262
return validateReproArgs(args.slice(1));
6363
case "report":
6464
return validateReportArgs(args.slice(1));
65+
case "threat-map":
66+
return validateThreatMapArgs(args.slice(1));
6567
case "workspace":
6668
return validateWorkspaceArgs(args.slice(1));
6769
case "findings":
@@ -84,7 +86,7 @@ export function validateArgs(args: string[]): ArgsValidation {
8486
case "submissions":
8587
return validateSubmissionsArgs(args.slice(1));
8688
default:
87-
return fail(`Unknown command: ${command}. Valid commands: version, setup, uninstall, config, doctor, dashboard, workspace, findings, radar, request, dedup, disclose, submissions, repro, report, help`);
89+
return fail(`Unknown command: ${command}. Valid commands: version, setup, uninstall, config, doctor, dashboard, workspace, findings, radar, request, dedup, disclose, submissions, repro, report, threat-map, help`);
8890
}
8991
}
9092

@@ -189,6 +191,27 @@ function validateReportArgs(args: string[]): ArgsValidation {
189191
}
190192
}
191193

194+
function validateThreatMapArgs(args: string[]): ArgsValidation {
195+
const subcommand = args[0];
196+
const rest = args.slice(1);
197+
switch (subcommand) {
198+
case "init":
199+
return validateOptions(rest, {
200+
command: "threat-map init",
201+
flags: new Set(["--force", "--json", ...HELP_FLAGS]),
202+
options: new Map(),
203+
minPositionals: 1,
204+
maxPositionals: 1,
205+
});
206+
case "help":
207+
case "--help":
208+
case "-h":
209+
return rest.length === 0 ? ok() : fail(`threat-map ${subcommand} accepts no arguments`);
210+
default:
211+
return fail(`Unknown threat-map command: ${subcommand ?? ""}. Valid commands: init, help`);
212+
}
213+
}
214+
192215
function validateDiscloseArgs(args: string[]): ArgsValidation {
193216
const subcommand = args[0] ?? "timeline";
194217
const rest = args[0] ? args.slice(1) : args;

src/cli/commands/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as submissions from "./submissions.js";
1616
import * as config from "./config.js";
1717
import * as repro from "./repro.js";
1818
import * as report from "./report.js";
19+
import * as threatMap from "./threat-map.js";
1920

2021
const REGISTRY: Record<string, (args: string[]) => Promise<void>> = {
2122
version: version.run,
@@ -33,6 +34,7 @@ const REGISTRY: Record<string, (args: string[]) => Promise<void>> = {
3334
config: config.run,
3435
repro: repro.run,
3536
report: report.run,
37+
"threat-map": threatMap.run,
3638
};
3739

3840
export async function run(): Promise<void> {

0 commit comments

Comments
 (0)