Skip to content

Commit 54f5d56

Browse files
committed
fix: harden research workspace resolution and evidence gates
Prevent nested .omv workspaces under checkouts, ignore surface sidecars during campaign listing, accept file:line ranges, allow verification nuance notes with agrees:true, route needs-repro only for missing observed_result, and add an ssrf-filter attack-surface pack from dogfood audits.
1 parent d77ff86 commit 54f5d56

36 files changed

Lines changed: 532 additions & 127 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,14 @@
22

33
## Unreleased
44

5+
- Fixed research workspace resolution so commands run from under `.omv/` (for example `.omv/checkouts/<pkg>`) reuse the owning project instead of creating a nested empty `.omv`. Supports `OMV_PROJECT_ROOT` / `OMV_ROOT` and global `--root <path>`.
6+
- Fixed `omv dashboard` / `omv campaign list` treating AttackSurfaceList sidecars (`*.surfaces.yaml`) as Campaign.v1 files, which produced hard validation errors after `surfaces propose`.
7+
- Confirmed evidence `file:line` checks now accept inclusive ranges (`path/file.go:12-18`) as well as column forms.
8+
- Verification `decision.status: pass` no longer fails when a review sets `agrees: true` but still lists nuance notes under `disagreements`.
9+
- `omv review` routes to `/omv-repro` only for missing `observed_result`, not merely for `plausible` exploitability.
10+
- Added `ssrf-filter` attack-surface pack for HTTP client SSRF filters and private-IP agents.
11+
- Documented allowed `--mode`, `--goal`, and `--budget` enums in `omv help start`, plus project-root resolution in top-level help.
12+
513
## v1.0.0 - 2026-07-11
614

715
- Added Windows as a validated platform with Python 3 runtime discovery, shell-independent Node test execution, cross-platform Skill packaging and LOC estimation, and an Ubuntu/Windows CI matrix. Claude Code remains the default platform when `--platform` is omitted.

scripts/run_node_tests.mjs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ if (tests.length === 0) {
1818
console.error("No compiled Node tests found. Run npm run build first.");
1919
process.exit(1);
2020
}
21-
const result = spawnSync(process.execPath, ["--test", ...tests], { stdio: "inherit", windowsHide: true });
21+
const testEnv = { ...process.env, NO_COLOR: "1" };
22+
delete testEnv.FORCE_COLOR;
23+
const result = spawnSync(process.execPath, ["--test", ...tests], {
24+
stdio: "inherit",
25+
windowsHide: true,
26+
env: testEnv,
27+
});
2228
if (result.error) {
2329
console.error(result.error.message);
2430
process.exit(1);

shared/surface-catalog/packs.v1.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,35 @@
6262
"guards": ["scheme/host allowlist", "private-IP block", "redirect policy", "signature validation"],
6363
"false_positive_checks": ["admin-only callback URLs in single-tenant tools"]
6464
},
65+
{
66+
"id": "ssrf-filter",
67+
"title": "HTTP client SSRF filter or private-IP block",
68+
"vulnerability_classes": ["ssrf", "redirect"],
69+
"discovery_hints": [
70+
"ssrf",
71+
"private ip",
72+
"request filter",
73+
"filtering agent",
74+
"ipaddr",
75+
"allowlist",
76+
"denylist",
77+
"metadata",
78+
"169.254"
79+
],
80+
"sources": ["caller-supplied URL or request options", "DNS lookup results", "redirect targets"],
81+
"sinks": ["http.Agent createConnection", "fetch/axios agent", "socket connect", "unix socket path"],
82+
"guards": [
83+
"block non-unicast IP ranges after DNS",
84+
"reject metadata and link-local",
85+
"redirect policy",
86+
"unix socket / socketPath policy",
87+
"scheme allowlist"
88+
],
89+
"false_positive_checks": [
90+
"libraries that only document SSRF without implementing network connect filtering",
91+
"pure DNS utilities with no outbound HTTP"
92+
]
93+
},
6594
{
6695
"id": "upload-handler",
6796
"title": "Multipart upload and storage placement",

skills/omv-find/references/pattern-packs.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Each pack is a discovery filter plus a source -> sink -> guard checklist. Use it
1414
| `config-loader` | `yaml`, `proto`, `deser`, `auth`, `crypto` | config, yaml, json, dotenv, rc, schema, plugin config | config files, environment maps, CLI overrides, JSON/YAML objects | unsafe loader, deep merge, object hook, secret/key parsing | schema validation, safe loader, key denylist, null-prototype objects, secret handling |
1515
| `media-tool` | `ssrf`, `upload`, `overflow`, `infoleak`, `xxe` | image, svg, pdf, video, thumbnail, metadata, exif | uploaded files, remote media URLs, embedded metadata, SVG/XML | remote fetch, parser decode, thumbnailer shell, XML parser, native bindings | URL allowlist, file magic, parser sandbox, DTD disabled, bounded decode |
1616
| `webhook-client` | `ssrf`, `redirect`, `auth`, `crypto` | webhook, callback, integration, notifier, bot, oauth | user-provided webhook URLs, callback targets, token config | HTTP client, redirect follow, signature verifier, callback redirect | scheme/host allowlist, private-IP block, redirect policy, signature validation |
17+
| `ssrf-filter` | `ssrf`, `redirect` | ssrf, private ip, request filter, filtering agent, ipaddr, metadata | caller URL/options, DNS results, redirects | agent createConnection, fetch agent, socket connect, unix socket | non-unicast block after DNS, metadata/link-local reject, redirect and socketPath policy |
1718
| `upload-handler` | `upload`, `traversal`, `race`, `infoleak` | upload, multipart, avatar, import, attachment, file manager | multipart filenames, content types, temp files, user-supplied paths | extension checks, move/write, public storage, post-process parser | extension allowlist, content sniffing, hash rename, quarantine, atomic move |
1819

1920
## Discovery Rules

src/cli/__tests__/campaign.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
workspaceActivityLogPath,
3434
workspaceIndexPath,
3535
} from "../paths.js";
36+
import { proposeSurfaces } from "../surfaces.js";
3637
import { initWorkspace, readWorkspaceActivity } from "../workspace.js";
3738

3839
const FIXED_ISO = "2026-07-10T00:00:00.000Z";
@@ -1107,6 +1108,29 @@ test("Campaign list and show leave workspace index bytes unchanged", async () =>
11071108
}
11081109
});
11091110

1111+
test("Campaign list ignores attack-surface sidecars next to Campaign.v1 files", async () => {
1112+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-"));
1113+
1114+
try {
1115+
const campaign = await initCampaign(
1116+
{ id: "demo", target: "Acme", ecosystem: "npm", vulnerabilities: ["ssrf"] },
1117+
{ projectRoot, now: fixedNow },
1118+
);
1119+
await proposeSurfaces(campaign.campaign.id, projectRoot);
1120+
const surfacesPath = join(campaignsDir(projectRoot), `${campaign.campaign.id}.surfaces.yaml`);
1121+
assert.equal(existsSync(surfacesPath), true);
1122+
1123+
const listed = await listCampaigns(projectRoot);
1124+
assert.deepEqual(
1125+
listed.map((item) => item.id),
1126+
[campaign.campaign.id],
1127+
);
1128+
assert.equal(listed[0]?.target, "Acme");
1129+
} finally {
1130+
await rm(projectRoot, { recursive: true, force: true });
1131+
}
1132+
});
1133+
11101134
test("Campaign ecosystem-aware next actions gate unknown targets before seed", async () => {
11111135
const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-"));
11121136

src/cli/__tests__/findings.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,3 +997,71 @@ test("end-to-end finding flow validates, promotes, checks artifacts, and archive
997997
await rm(projectRoot, { recursive: true, force: true });
998998
}
999999
});
1000+
1001+
1002+
test("confirmed findings accept file:line ranges in evidence traces", async () => {
1003+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-fileline-"));
1004+
try {
1005+
const dir = await ensureFindingsDir(projectRoot);
1006+
const body = BASE_FINDING
1007+
.replace("status: candidate", "status: confirmed")
1008+
.replace(
1009+
"source: lib/index.js:12 options.filename",
1010+
"source: lib/index.js:12-18 options.filename enters sanitize",
1011+
)
1012+
.replace(
1013+
"sink: lib/index.js:44 fs.readFileSync",
1014+
"sink: lib/index.js:44-50 fs.readFileSync",
1015+
)
1016+
.replace(
1017+
"guard: missing path normalization",
1018+
"guard: lib/index.js:30-36 incomplete prefix check after normalize",
1019+
);
1020+
await writeFile(join(dir, "ranged.yaml"), body, "utf-8");
1021+
const result = await validateFinding("ranged", projectRoot);
1022+
assert.equal(result.ok, true, result.errors.join("\n"));
1023+
assert.equal(result.status, "confirmed");
1024+
} finally {
1025+
await rm(projectRoot, { recursive: true, force: true });
1026+
}
1027+
});
1028+
1029+
test("Verification.v1 pass allows nuance notes when agrees is true", async () => {
1030+
const projectRoot = await mkdtemp(join(tmpdir(), "omv-verif-nuance-"));
1031+
try {
1032+
const dir = await ensureFindingsDir(projectRoot);
1033+
await writeFile(join(dir, "confirmed.yaml"), BASE_FINDING.replace("status: candidate", "status: confirmed"), "utf-8");
1034+
const init = await initVerification("confirmed", projectRoot);
1035+
await writeFile(
1036+
verificationPath("confirmed", projectRoot),
1037+
`schema_version: "1"
1038+
finding_id: "confirmed"
1039+
finding_sha256: "${init.findingSha256}"
1040+
reviews:
1041+
- reviewer: verifier
1042+
target: evidence.guard
1043+
agrees: true
1044+
disagreements:
1045+
- "impact requires sibling path pre-positioning"
1046+
required_changes: []
1047+
confidence: high
1048+
reviewed_at: "2026-04-29"
1049+
decision:
1050+
status: pass
1051+
reason: agrees with nuance notes only
1052+
required_for_confirmed: true
1053+
provenance:
1054+
generated_at: "2026-04-29"
1055+
tool: omv
1056+
tool_version: test
1057+
`,
1058+
"utf-8",
1059+
);
1060+
const validation = await validateVerification("confirmed", projectRoot);
1061+
assert.equal(validation.ok, true, validation.errors.join("\n"));
1062+
assert.equal(validation.status, "pass");
1063+
assert.equal(validation.disagreements, 0);
1064+
} finally {
1065+
await rm(projectRoot, { recursive: true, force: true });
1066+
}
1067+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { projectRootIfInsideOmvState, resolveProjectRoot } from "../paths.js";
7+
import { extractProjectRootOption } from "../commands/index.js";
8+
9+
test("projectRootIfInsideOmvState maps checkouts back to the owner project", () => {
10+
const root = "/tmp/research-root";
11+
assert.equal(
12+
projectRootIfInsideOmvState(join(root, ".omv", "checkouts", "pkg")),
13+
root,
14+
);
15+
assert.equal(
16+
projectRootIfInsideOmvState(join(root, ".omv", "findings")),
17+
root,
18+
);
19+
assert.equal(projectRootIfInsideOmvState(root), undefined);
20+
assert.equal(projectRootIfInsideOmvState(join(root, "src")), undefined);
21+
});
22+
23+
test("resolveProjectRoot prefers OMV_PROJECT_ROOT over walk-up", async () => {
24+
const outer = await mkdtemp(join(tmpdir(), "omv-root-outer-"));
25+
const inner = await mkdtemp(join(tmpdir(), "omv-root-inner-"));
26+
try {
27+
await mkdir(join(outer, ".omv"), { recursive: true });
28+
await mkdir(join(inner, ".omv"), { recursive: true });
29+
const previous = process.env.OMV_PROJECT_ROOT;
30+
process.env.OMV_PROJECT_ROOT = inner;
31+
try {
32+
assert.equal(resolveProjectRoot(outer), inner);
33+
} finally {
34+
if (previous === undefined) {
35+
delete process.env.OMV_PROJECT_ROOT;
36+
} else {
37+
process.env.OMV_PROJECT_ROOT = previous;
38+
}
39+
}
40+
} finally {
41+
await rm(outer, { recursive: true, force: true });
42+
await rm(inner, { recursive: true, force: true });
43+
}
44+
});
45+
46+
test("resolveProjectRoot walks up to nearest .omv owner from a subdirectory", async () => {
47+
const project = await mkdtemp(join(tmpdir(), "omv-root-walk-"));
48+
try {
49+
await mkdir(join(project, ".omv", "findings"), { recursive: true });
50+
await writeFile(join(project, ".omv", "index.json"), "{\"version\":1,\"findings\":[]}\n", "utf-8");
51+
const nested = join(project, "src", "cli");
52+
await mkdir(nested, { recursive: true });
53+
const previous = process.env.OMV_PROJECT_ROOT;
54+
delete process.env.OMV_PROJECT_ROOT;
55+
delete process.env.OMV_ROOT;
56+
try {
57+
assert.equal(resolveProjectRoot(nested), project);
58+
} finally {
59+
if (previous !== undefined) {
60+
process.env.OMV_PROJECT_ROOT = previous;
61+
}
62+
}
63+
} finally {
64+
await rm(project, { recursive: true, force: true });
65+
}
66+
});
67+
68+
test("resolveProjectRoot does not create nested workspaces under .omv/checkouts", async () => {
69+
const project = await mkdtemp(join(tmpdir(), "omv-root-checkout-"));
70+
try {
71+
const checkout = join(project, ".omv", "checkouts", "sanitize-url");
72+
await mkdir(checkout, { recursive: true });
73+
await mkdir(join(project, ".omv", "findings"), { recursive: true });
74+
// Accidental nested workspace under the checkout must not win.
75+
await mkdir(join(checkout, ".omv", "findings"), { recursive: true });
76+
const previous = process.env.OMV_PROJECT_ROOT;
77+
delete process.env.OMV_PROJECT_ROOT;
78+
delete process.env.OMV_ROOT;
79+
try {
80+
assert.equal(resolveProjectRoot(checkout), project);
81+
} finally {
82+
if (previous !== undefined) {
83+
process.env.OMV_PROJECT_ROOT = previous;
84+
}
85+
}
86+
} finally {
87+
await rm(project, { recursive: true, force: true });
88+
}
89+
});
90+
91+
test("extractProjectRootOption strips --root forms", () => {
92+
assert.deepEqual(extractProjectRootOption(["--root", "/tmp/ws", "dashboard", "--json"]), {
93+
args: ["dashboard", "--json"],
94+
root: "/tmp/ws",
95+
});
96+
assert.deepEqual(extractProjectRootOption(["findings", "list", "--root=/tmp/ws"]), {
97+
args: ["findings", "list"],
98+
root: "/tmp/ws",
99+
});
100+
assert.equal(extractProjectRootOption(["--root"]).error, "--root requires a directory path");
101+
});

src/cli/__tests__/surfaces.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,31 @@ test("surface catalog proposes cards that intersect campaign vulnerability class
2626
const list = proposeCardsForCampaign(campaign, catalog);
2727
const ids = list.cards.map((card) => card.id).sort();
2828
assert.ok(ids.includes("renderer-pipeline"));
29+
assert.ok(ids.includes("ssrf-filter"), `expected ssrf-filter in ${ids.join(",")}`);
2930
assert.ok(ids.includes("webhook-client") || ids.includes("media-tool"));
3031
assert.ok(list.cards.every((card) => card.status === "proposed"));
3132
assert.ok(list.cards.every((card) => card.finding_id.startsWith(`${campaign.id}-`)));
3233
});
3334

35+
test("ssrf-only campaigns propose the ssrf-filter pack", async () => {
36+
const catalog = await loadSurfaceCatalog();
37+
const campaign = (
38+
await initCampaign(
39+
{
40+
target: "FilterLib",
41+
version: "1",
42+
ecosystem: "npm",
43+
vulnerabilities: ["ssrf"],
44+
},
45+
{ projectRoot: await emptyProject() },
46+
)
47+
).campaign;
48+
const list = proposeCardsForCampaign(campaign, catalog);
49+
const ids = list.cards.map((card) => card.id);
50+
assert.ok(ids.includes("ssrf-filter"));
51+
assert.ok(ids.includes("webhook-client") || ids.includes("media-tool"));
52+
});
53+
3454
test("surfaces propose/select drive campaign seed finding ids", async () => {
3555
const projectRoot = await emptyProject();
3656
try {

src/cli/campaign-seed.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type EvidenceResearcherGoal,
1111
type FindingTemplateResult,
1212
} from "./findings.js";
13-
import { campaignSurfacesPath, findingsDir } from "./paths.js";
13+
import { campaignSurfacesPath, findingsDir, resolveProjectRoot } from "./paths.js";
1414
import { readSurfaceList, selectedSeedTargets, type AttackSurfaceCard } from "./surfaces.js";
1515

1616
export interface CampaignSeedSkipped {
@@ -51,7 +51,7 @@ export interface SeedCampaignDependencies {
5151

5252
export async function seedCampaign(
5353
id: string,
54-
projectRoot = process.cwd(),
54+
projectRoot = resolveProjectRoot(),
5555
dependencies: SeedCampaignDependencies = {},
5656
): Promise<CampaignSeedResult> {
5757
const detail = await showCampaign(id, projectRoot);

src/cli/campaign.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { existsSync } from "fs";
22
import { link, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, rmdir, unlink, writeFile } from "fs/promises";
33
import { basename, join } from "path";
44
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
5-
import { campaignPath, campaignRunbookPath, campaignsDir, workspaceActivityLogPath } from "./paths.js";
5+
import { campaignPath, campaignRunbookPath, campaignsDir, workspaceActivityLogPath, resolveProjectRoot } from "./paths.js";
66
import { appendWorkspaceActivity } from "./workspace.js";
77

88
export const CAMPAIGN_MODES = ["whitebox", "graybox", "local-lab", "passive", "mixed"] as const;
@@ -374,7 +374,7 @@ export async function initCampaign(
374374
options: InitCampaignOptions = {},
375375
): Promise<InitCampaignResult> {
376376
const campaign = buildCampaign(input, options.now);
377-
const projectRoot = options.projectRoot ?? process.cwd();
377+
const projectRoot = options.projectRoot ?? resolveProjectRoot();
378378
const force = options.force ?? false;
379379
await ensureRealCampaignDirectory(projectRoot);
380380
const result = await withCampaignLock(
@@ -395,17 +395,19 @@ export async function initCampaign(
395395
}
396396
}
397397

398-
export async function listCampaigns(projectRoot = process.cwd()): Promise<CampaignSummary[]> {
398+
export async function listCampaigns(projectRoot = resolveProjectRoot()): Promise<CampaignSummary[]> {
399399
const dir = campaignsDir(projectRoot);
400400
if (!existsSync(dir)) {
401401
return [];
402402
}
403403

404+
// Only Campaign.v1 sources: <id>.yaml / <id>.yml.
405+
// Sidecars such as <id>.surfaces.yaml share the campaigns directory and must not be listed.
404406
const files = (await readdir(dir, { withFileTypes: true }))
405-
.filter((dirent) => dirent.isFile() && /\.ya?ml$/.test(dirent.name))
407+
.filter((dirent) => dirent.isFile() && isCampaignSourceFileName(dirent.name))
406408
.map((dirent) => dirent.name);
407409
const summaries: CampaignSummary[] = [];
408-
const ids = [...new Set(files.map((file) => file.replace(/\.ya?ml$/, "")))];
410+
const ids = [...new Set(files.map((file) => file.replace(/\.ya?ml$/i, "")))];
409411
for (const id of ids) {
410412
const path = resolveCampaignSource(id, projectRoot);
411413
if (!path) {
@@ -425,9 +427,17 @@ export async function listCampaigns(projectRoot = process.cwd()): Promise<Campai
425427
return summaries.sort((left, right) => left.id.localeCompare(right.id));
426428
}
427429

430+
/** True for Campaign.v1 filenames; false for sidecars (e.g. *.surfaces.yaml) and other artifacts. */
431+
export function isCampaignSourceFileName(name: string): boolean {
432+
if (/\.surfaces\.ya?ml$/i.test(name)) {
433+
return false;
434+
}
435+
return /\.ya?ml$/i.test(name);
436+
}
437+
428438
export async function showCampaign(
429439
id: string,
430-
projectRoot = process.cwd(),
440+
projectRoot = resolveProjectRoot(),
431441
): Promise<ShowCampaignResult> {
432442
const normalizedId = normalizeCampaignId(id);
433443
const yamlPath = resolveCampaignSource(normalizedId, projectRoot);

0 commit comments

Comments
 (0)