diff --git a/.gitignore b/.gitignore index 8f24ce5..c2a03f3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,16 +4,19 @@ # Project-scoped Codex installs created by `omv setup --scope project` .codex/ -# Local assistant/OpenSpec scaffolding and nested worktrees +# Local assistant scaffolding and nested worktrees .agents/ .claude/ .github/prompts/ .github/skills/ -openspec/ -SPEC.md oh-my-codex/ oh-my-claudecode/ +# OpenSpec: publish accepted specs; keep in-progress change drafts local +openspec/changes/ +# Root historical vision draft only (do not match openspec/**/spec.md on case-insensitive FS) +/SPEC.md + # TypeScript build output dist/ node_modules/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..1a99dd7 --- /dev/null +++ b/.npmignore @@ -0,0 +1,5 @@ +**/__pycache__/** +**/*.pyc +**/*.pyd +**/*.pyo +**/test_*.py diff --git a/AGENTS.md b/AGENTS.md index 0d5ccfe..aa0bf30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,68 +9,44 @@ The project ships a TypeScript CLI (`omv`) for installing skills, plus Markdown ``` src/ cli/ - omv.ts — CLI entry point (setup / doctor / findings / help) - setup.ts — copies installable skills to ~/.claude/skills/ or ./.claude/skills/ - doctor.ts — checks installation health - findings.ts — creates, lists, validates, and promotes Evidence.v1 handoffs - paths.ts — path utilities (claudeSkillsDir, projectSkillsDir, findingsDir, packageRoot, …) + omv.ts — thin CLI entry (dispatches to commands/) + commands/ — one module per top-level command (findings, campaign, review, …) + findings.ts — Evidence.v1 parse / validate / score / doctor / archive + workflow.ts — shared readiness + next-action policy + review.ts — report-readiness verdicts (ready | needs-*) + campaign.ts — Campaign.v1 first-mile research plans + setup.ts / doctor.ts — install skills+agents; health checks + paths.ts — claudeSkillsDir, findingsDir, packageRoot, … index.ts — package exports -skills/ - omv/SKILL.md — collection manager (/omv) - omv-find/SKILL.md — find and rank audit targets (/omv-find) - omv-find/references/ - scoring.md — scoring rubric, confidence adjustments, filtering, LOC estimation - output-contract.md — final table contract, audit tips, invalid-request template - omv-find/scripts/check_output.py — heuristic eval checker - omv-find/evals/evals.json — behavior-focused eval scenarios - omv-find/evals/golden/ — stable golden outputs - omv-report/SKILL.md — generate VulDB/CVE/GHSA/OSV reports (/omv-report) - omv-report/references/ - ecosystems.md — vendor/product/version rules, CWE mapping, duplicate-CVE databases - report-templates.md — VulDB, GHSA, OSV JSON, Markdown advisory templates - examples/ — filled advisory examples - omv-report/scripts/check_output.py — heuristic eval checker - omv-report/evals/evals.json — behavior-focused report-generation eval scenarios - omv-report/evals/golden/ — stable golden outputs +skills/ — 9 installable skills (self-contained after setup) + omv, omv-find, omv-audit, omv-repro, omv-report, + omv-radar, omv-dedup, omv-disclose, omv-critic shared/ - references/ - ecosystems.md — ecosystem registry sources, GitHub search shapes, flagship exclusions - vuln-patterns.md — vulnerability aliases and source -> sink -> guard patterns - cvss-builder.md — CVSS v3.1 metric decision table and common vectors - scripts/ - collect_metadata.py — collects GitHub and selected registry metadata as JSON - estimate_loc.sh — estimates source LOC from a GitHub URL or local checkout + references/ — ecosystems, vuln-patterns, cvss-builder, per-eco patterns/ + pattern-packs/ — 14 PatternPack.v1 JSON manifests + scripts/ — collect_metadata, estimate_loc, run_evals, … contracts/ - evidence.v1.yaml — finding object: the typed boundary between omv-find and omv-report - candidate-list.v1.yaml — candidate table entry schema produced by omv-find - threat-map.v1.yaml — dataflow threat map schema (planned: omv-audit M2+) - -agents/ - vuln-scanner.md — passive candidate discovery - dataflow-tracer.md — source -> sink -> guard analysis - cvss-analyst.md — CVSS v3.1 computation - dedup-analyst.md — duplicate CVE/GHSA search - report-writer.md — platform-specific advisory rendering - guard-checker.md — adversarial guard bypass assessment - verifier.md — adversarial conclusion refutation - -.claude/agents/ — Claude Code project subagent registration (auto-discovered) - .md — frontmatter (name, description, tools, model) + system prompt body - Each subagent's body references the matching agents/*.md domain spec. See - docs/architecture/agent-team-upgrade.md for the orchestration design. + evidence.v1.yaml — finding object (find → report boundary) + candidate-list.v1.yaml — omv-find table entries + threat-map.v1.yaml — source → transform → sink graph (omv-audit sidecar) + verification.v1.yaml — adversarial verifier review sidecar + campaign.v1.yaml — research campaign plan + seed lanes + source-ref.v1.yaml / report-provenance.v1.yaml / submission.v1.yaml + +agents/ — Claude Code subagent specs (installed by omv setup) + vuln-scanner, dataflow-tracer, guard-checker, cvss-analyst, + dedup-analyst, report-writer, verifier scripts/ - sync_metadata.py — sync package, registry, and README metadata - sync_skill_assets.py — sync canonical shared/contract assets into self-contained skill dirs - validate_skill.py — validates all skill directories and optional .skill packages - package_skill.sh — builds a .skill archive from a skill directory - release_check.py — release-time validator, package builder, SHA-256 manifest printer - -registry.yaml — collection metadata: versions, produces/consumes bindings -.github/workflows/validate.yml — CI validation, packaging checks, stable golden evals + sync_metadata.py / sync_skill_assets.py / validate_skill.py + package_skill.sh / release_check.py / pattern_packs.py + +registry.yaml — skills, agents, contracts, versions +openspec/ — accepted specs + change archive +.github/workflows/validate.yml ``` ## CLI @@ -86,6 +62,12 @@ npx oh-my-vul setup --dry-run # preview only omv doctor omv doctor --json +# Workspace + campaign +omv dashboard +omv first --target --ecosystem npm --vuln traversal --no-interactive +omv campaign list|show|seed +omv review --strict + # Manage project-local Evidence.v1 findings omv findings list omv findings init @@ -93,6 +75,17 @@ omv findings init --status candidate|confirmed|blocked --force omv findings validate omv findings validate omv findings promote --status candidate|confirmed|blocked +omv findings workflow +omv findings doctor +omv findings archive --reason blocked|reported + +# Sidecars and release gates +omv threat-map init|validate +omv verification init|validate +omv sources init|validate +omv report artifacts|provenance +omv repro init +omv eval --json ``` Build the CLI: diff --git a/CHANGELOG.md b/CHANGELOG.md index f81dbb3..1eca756 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,15 @@ ## Unreleased -- 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. +### Planned for v0.10.0 — Campaign + evidence graph (draft) + +Ship when release notes and `registry.yaml` / `package.json` versions are bumped together. + +- **Campaign.v1 first-mile planning** — `omv campaign init|list|show|seed` and the `omv first` alias. Seeding creates conservative candidate Evidence only and never overwrites existing findings or creates proof artifacts. +- **SourceRef.v1 + report provenance** — `omv sources init|show|validate` and `omv report provenance` manifests that hash Evidence, reports, and available local dependencies. Missing manifests warn; stale confirmed manifests fail artifact checks. +- **PatternPack.v1 + unified evals** — 14 JSON pattern-pack manifests (including R/Lua), manifest-driven find/audit asset sync, and `omv eval` with human/JSON/JUnit output. +- **ThreatMap rich render** — `omv findings show` prints full `source → transforms → sink` paths with confidence, bypassable guards, and summary (no longer collapses to a single source→sink line). +- **Readiness policy helpers** — `isReportReady` / `isSubmissionScoreReady` / `resolveDoctorNextAction` in `workflow.ts` as the shared report-readiness gate used by doctor and review; maintainer docs (`AGENTS.md`, `CLAUDE.md`, `SPEC.md` banner) aligned with the current tree. ## v0.9.0 - CLI command split and local findings dedup diff --git a/CLAUDE.md b/CLAUDE.md index 92aea26..d5ac8e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,72 +7,32 @@ The project ships a TypeScript CLI (`omv`) for installing skills, plus Markdown ## Structure ``` -src/ - cli/ - omv.ts — CLI entry point (setup / doctor / help) - setup.ts — copies skills/ to ~/.claude/skills/ - doctor.ts — checks installation health - paths.ts — path utilities (claudeSkillsDir, packageRoot, …) - index.ts — package exports - -skills/ - omv/SKILL.md — collection manager (/omv) - omv-find/SKILL.md — find and rank audit targets (/omv-find) - omv-find/references/ - scoring.md — scoring rubric, confidence adjustments, filtering, LOC estimation - output-contract.md — final table contract, audit tips, invalid-request template - omv-find/scripts/check_output.py — heuristic eval checker - omv-find/evals/evals.json — behavior-focused eval scenarios - omv-find/evals/golden/ — stable golden outputs - omv-report/SKILL.md — generate VulDB/CVE/GHSA/OSV reports (/omv-report) - omv-report/references/ - ecosystems.md — vendor/product/version rules, CWE mapping, duplicate-CVE databases - report-templates.md — VulDB, GHSA, OSV JSON, Markdown advisory templates - examples/ — filled advisory examples - omv-report/scripts/check_output.py — heuristic eval checker - omv-report/evals/evals.json — behavior-focused report-generation eval scenarios - omv-report/evals/golden/ — stable golden outputs - -shared/ - references/ - ecosystems.md — ecosystem registry sources, GitHub search shapes, flagship exclusions - vuln-patterns.md — vulnerability aliases and source -> sink -> guard patterns - cvss-builder.md — CVSS v3.1 metric decision table and common vectors - scripts/ - collect_metadata.py — collects GitHub and selected registry metadata as JSON - estimate_loc.sh — estimates source LOC from a GitHub URL or local checkout - -contracts/ - evidence.v1.yaml — finding object: the typed boundary between omv-find and omv-report - candidate-list.v1.yaml — candidate table entry schema produced by omv-find - threat-map.v1.yaml — dataflow threat map schema (planned: omv-audit M2+) - -agents/ - vuln-scanner.md — passive candidate discovery - dataflow-tracer.md — source -> sink -> guard analysis - cvss-analyst.md — CVSS v3.1 computation - dedup-analyst.md — duplicate CVE/GHSA search - report-writer.md — platform-specific advisory rendering - -scripts/ - validate_skill.py — validates all skill directories and optional .skill packages - package_skill.sh — builds a .skill archive from a skill directory - release_check.py — release-time validator, package builder, SHA-256 manifest printer - -registry.yaml — collection metadata: versions, produces/consumes bindings -.github/workflows/validate.yml — CI validation, packaging checks, stable golden evals +src/cli/ — TypeScript CLI (commands/ split; findings/workflow/review/campaign domain modules) +skills/ — 9 omv-* skills (find, audit, repro, report, radar, dedup, disclose, critic, manager) +shared/ — references, pattern-packs, eval runner helpers +contracts/ — Evidence, ThreatMap, Verification, Campaign, SourceRef, Submission, … +agents/ — subagent specs installed to ~/.claude/agents/ by omv setup +openspec/ — accepted specs + change archive +registry.yaml — versions and produces/consumes bindings ``` +Canonical maintainer map: see `AGENTS.md` (kept in sync with the current tree). Early vision draft `SPEC.md` is historical only. + ## CLI ```sh -# Install skills to ~/.claude/skills/ +# Install skills + agents npx oh-my-vul setup -npx oh-my-vul setup --force # overwrite existing -npx oh-my-vul setup --dry-run # preview only +npx oh-my-vul setup --scope project +npx oh-my-vul setup --force +npx oh-my-vul setup --dry-run -# Check installation health +# Health and workspace omv doctor +omv doctor --strict +omv dashboard +omv review --strict +omv findings workflow ``` Build the CLI: diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b5dbd56 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,73 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Security Research Norms + +This project is for **passive vulnerability research** and responsible disclosure +preparation. Community participation also means: + +* Do not share live exploit traffic against third-party systems +* Do not post real private finding evidence, credentials, or unreleased + vulnerability details in issues or pull requests +* Prefer sanitized fixtures and public, already-disclosed examples in demos + +## Enforcement Responsibilities + +Project maintainers are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers through the contact options listed in +[SECURITY.md](SECURITY.md). All complaints will be reviewed and investigated +promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 2.1, +available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 267dee1..2cfb1e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,12 @@ Thanks for helping improve `oh-my-vul`. This project is a Claude Code skill collection, so the most important contribution quality is reproducibility: installed skills, `.skill` archives, and source checkout behavior should all match. +By participating, you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). + +**Do not commit** local research state (`.omv/`), secrets, live target data, or unreleased vulnerability details. Use sanitized fixtures in issues and pull requests. + +Accepted behavior specs live under [`openspec/specs/`](openspec/specs/). In-progress OpenSpec change drafts stay local (`openspec/changes/` is gitignored). + ## Development Setup ```sh diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d92dc10..1425215 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -190,21 +190,62 @@ Tradeoff: The ledger is intentionally lightweight YAML, not a database or platform. That keeps the project easy to install and review, but it means deeper validation and rendering still need future deterministic helpers. +### Current iteration - Manifest-driven PatternPacks and evals + +What changed: + +- Added one PatternPack.v1 JSON manifest for every supported ecosystem, including R and Lua. +- Made manifests drive skill-local pattern distribution and release-time methodology checks. +- Added one stable eval manifest plus a human/JSON/JUnit Python runner and `omv eval` CLI adapter. + +Core idea: + +Repeated asset and eval lists are contracts disguised as source code. Moving membership into validated JSON keeps progressive disclosure, package self-containment, local CI, and release checks aligned without rewriting skill-specific assertions. + +Tradeoff: + +The unified runner still starts one Python process per checker. The suite is small, and preserving each Skill's domain-specific checker is more valuable than premature shared assertion abstractions. + ## Current Weaknesses - The Evidence.v1 contract is copied into runtime skill directories; drift is checked by `scripts/sync_skill_assets.py --check`, but the duplication still adds release-surface noise. - `omv-find` can guide Evidence.v1 handoff creation, but candidate discovery quality still depends on model discipline, source inspection, and available metadata. - `omv-report` consumes validation guidance, but advisory rendering is still primarily model-written rather than deterministic. - Examples are partly synthetic. -- Package archives are tracked but not independently diffable. -- The README is user-facing; keep maintainer workflow details in `CONTRIBUTING.md`, `RELEASE.md`, and this file. +- Subagent orchestration is documented and installable, but still optional prose-driven rather than a forced fan-out runtime (see `docs/architecture/agent-team-upgrade.md`). +- Active skills (`omv-radar`, `omv-dedup`, `omv-disclose`, `omv-critic`) have thinner golden coverage than find/audit/repro/report. +- `src/cli/findings.ts` remains a large domain module (validate + score + doctor + archive). +- Historical `SPEC.md` listed skills that never shipped; treat OpenSpec + `registry.yaml` as truth. ## Proposed Next Iterations -### v0.8 - Real Workflow Walkthrough +### v0.10 - Campaign + evidence graph (in flight / Unreleased) + +Goal: + +Make the first-mile campaign story and evidence-graph sidecars a coherent release: + +- `omv first` / Campaign.v1 seed → candidate queue +- ThreatMap + Verification + SourceRef + provenance on the report path +- `omv review --strict` as the pre-report gate in user docs +- Ship Unreleased items as `v0.10.0` + +### v0.11 - Deterministic report compiler Goal: +Evidence.v1 → structured advisory IR → VulDB/GHSA/OSV/Markdown via CLI render, with LLM only polishing narrative paragraphs. + +### v0.12 - Agent team runtime minimum + +Goal: + +Tighten subagent tools (no unrestricted Bash), default omv-audit orchestration stages, and strict Verification requirements before confirmed/report-ready. + +### Earlier: Real Workflow Walkthrough (delivered in docs/examples) + +Goal (historical): + Add a sanitized end-to-end example that demonstrates the intended user path: - run `/omv-find` for a realistic candidate; diff --git a/README.md b/README.md index 932506f..8cdc60d 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,24 @@ Evidence-first vulnerability research skills for Claude Code. [![npm](https://img.shields.io/npm/v/oh-my-vul)](https://www.npmjs.com/package/oh-my-vul) [![license: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -`oh-my-vul` helps you find open-source audit targets, keep evidence in local YAML files, review source -> sink -> guard claims, and draft VulDB/CVE/GHSA/OSV reports from confirmed findings. +`oh-my-vul` is a local-first workbench for passive CVE-style research: -It is built for passive research and local verification. Do not use it to attack live third-party services or invent findings from weak evidence. +1. **Find** audit targets (`/omv-find`) +2. **Prove** source → sink → guard with Evidence.v1 (`/omv-audit`, `/omv-repro`) +3. **Gate** readiness (`omv review --strict`) +4. **Report** VulDB / GHSA / OSV drafts (`/omv-report`) + +It does **not** attack live third-party services. Keep real findings under private `.omv/` state; never invent proof from weak evidence. + +| | | +|---|---| +| Install | `npx oh-my-vul setup` then `omv doctor` | +| Docs (zh) | [README.zh-CN.md](README.zh-CN.md) | +| Contribute | [CONTRIBUTING.md](CONTRIBUTING.md) · [Code of Conduct](CODE_OF_CONDUCT.md) | +| Security | [SECURITY.md](SECURITY.md) | +| Specs | [`openspec/specs/`](openspec/specs/) (accepted behavior) | + +> **Version note:** npm package is currently **0.9.x**. Campaign / PatternPack / provenance work in this branch is **Unreleased** toward 0.10 — see [CHANGELOG.md](CHANGELOG.md). ## Install @@ -32,6 +47,12 @@ npx oh-my-vul setup --scope project ## Fast Workflow ```text +omv first --target acme --ecosystem npm --vuln traversal,auth --no-interactive + -> .omv/campaigns/acme.yaml + deterministic runbook + +omv campaign seed acme + -> candidate Evidence.v1 hypotheses only + /omv-find --lang npm --vuln traversal --count 10 -> choose a candidate @@ -47,6 +68,9 @@ omv review --strict /omv-report /omv-critic +omv sources init +omv report provenance +omv report artifacts omv submissions record --platform vuldb --submission-id 12345 --url https://example.test/submission/12345 omv findings archive --reason reported ``` @@ -57,12 +81,17 @@ Use `omv dashboard` or `/omv next` whenever you are unsure what to do next. ```sh omv dashboard +omv campaign list +omv campaign show omv findings workflow omv findings show omv findings validate omv review --strict +omv sources validate +omv report provenance omv report artifacts omv submissions track +omv eval --json ``` Useful setup and health checks: @@ -71,6 +100,7 @@ Useful setup and health checks: omv doctor --strict omv request preflight omv version --json +omv eval --junit ``` ## Skills @@ -78,7 +108,7 @@ omv version --json | Skill | Command | Category | Purpose | |---|---|---|---| -| `omv` | `/omv` | manager | Local-first project manager — shows workspace status, active finding next actions, archive state, and installed skills | +| `omv` | `/omv` | manager | Local-first project manager — creates research campaigns, shows workspace status, and delegates finding lifecycle actions | | `omv-find` | `/omv-find` | research | Find and rank open-source packages worth auditing for passive CVE research | | `omv-audit` | `/omv-audit` | audit | Deep-audit a candidate finding — prove or disprove the vulnerability, fill Evidence.v1 fields for omv-report | | `omv-repro` | `/omv-repro` | audit | Guide local reproduction of a finding — walk through execution, record observed_result, confirm or block | @@ -116,11 +146,14 @@ Project state lives under `.omv/` and is private by default. | Path | Purpose | |---|---| +| `.omv/campaigns/.yaml` | Campaign.v1 target, scope, priorities, and lanes | +| `.omv/campaigns/.md` | deterministic campaign runbook | | `.omv/findings/.yaml` | Evidence.v1 finding ledger | +| `.omv/sources/.yaml` | SourceRef.v1 local source identity and Evidence hash | | `.omv/threatmaps/.yaml` | ThreatMap.v1 source -> sink -> guard graph | | `.omv/verifications/.yaml` | Verification.v1 adversarial review result | | `.omv/repro//` | local reproduction notes and artifacts | -| `.omv/reports//` | generated report drafts | +| `.omv/reports//` | generated report drafts and `provenance.json` input hashes | | `.omv/submissions/.yaml` | submission tracking | Create or inspect findings: diff --git a/README.zh-CN.md b/README.zh-CN.md index 242c8b5..cdbe393 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -20,7 +20,11 @@ > **定位:** `oh-my-vul` 用来辅助研究员发现值得审计的开源项目,整理 **source -> sink -> guard** 证据链,并把已确认的问题转成适合提交给 **VulDB**、**CVE**、**GHSA**、**OSV** 或 Markdown advisory 的报告草稿。 > -> **安全边界:** 本项目只面向*被动研究*和*本地验证*。它不是批量扫描器,也不是线上攻击工具。 +> **安全边界:** 本项目只面向*被动研究*和*本地验证*。它不是批量扫描器,也不是线上攻击工具。真实 finding 放在私有 `.omv/` 中,不要提交到 Git。 +> +> **版本说明:** npm 当前为 **0.9.x**;Campaign / PatternPack / provenance 等能力在 CHANGELOG 中记为 **Unreleased(面向 0.10)**。 +> +> **社区:** [贡献指南](CONTRIBUTING.md) · [行为准则](CODE_OF_CONDUCT.md) · [安全政策](SECURITY.md) · [行为规格](openspec/specs/) --- @@ -53,6 +57,9 @@ npx -p oh-my-vul omv doctor 在 Claude Code 中使用典型流程: ```text +omv first --target acme --ecosystem npm --vuln traversal,auth --no-interactive +omv campaign seed acme + /omv-find --lang npm --vuln traversal --count 10 omv findings init demo-traversal @@ -63,6 +70,8 @@ omv findings validate demo-traversal omv findings doctor demo-traversal /omv-report demo-traversal +omv sources init demo-traversal +omv report provenance demo-traversal omv report artifacts demo-traversal ``` @@ -99,6 +108,10 @@ omv version --json ## 工作流 ```text +omv campaign init + -> .omv/campaigns/.yaml + .md + -> omv campaign seed (只创建 candidate 假设,不生成 PoC/复现/ThreatMap) + -> /omv-find -> 候选项目和源码入口 -> .omv/findings/.yaml @@ -108,6 +121,8 @@ omv version --json -> omv findings validate -> omv findings doctor -> /omv-report + -> omv sources init (只记录 Evidence 中已有的来源信息,不证明远端真实性) + -> omv report provenance -> omv report artifacts -> 提交前报告草稿 ``` @@ -194,6 +209,19 @@ Evidence 文件遵循 [contracts/evidence.v1.yaml](contracts/evidence.v1.yaml) - 去重状态 - unknown 字段记录 +## 本地评测 + +统一 runner 会读取 `shared/evals/stable.json`,复用每个 Skill 已有的 checker,不执行模型或网络请求: + +```sh +omv eval +omv eval --json +omv eval --junit +omv eval --skill omv-find --eval-id 26 --output result.md --json +``` + +14 个生态的 PatternPack manifest 位于 `shared/pattern-packs/`,并驱动 omv-find/omv-audit 的自包含 pattern 资产同步。 +
状态含义 @@ -228,6 +256,8 @@ omv findings promote demo-traversal --status blocked - 针对 VulDB、GHSA、OSV、Markdown 选择合适格式 - 保持 PoC 语言克制,只面向本地验证和审稿 +报告文件写入 `.omv/reports//` 后,运行 `omv report provenance ` 记录 Evidence、报告文件及现有 SourceRef/ThreatMap/Verification/复现文件的 SHA-256,再用 `omv report artifacts ` 检查新鲜度。旧报告没有 manifest 时只告警,不会被直接判定为无效。 + ## 安全边界 `oh-my-vul` 只支持**非破坏性研究**: @@ -252,7 +282,7 @@ omv findings promote demo-traversal --status blocked | [docs/request-broker.md](docs/request-broker.md) | 英文 request broker 指南 | | [docs/vulnerability-research-best-practices.zh-CN.md](docs/vulnerability-research-best-practices.zh-CN.md) | 使用本项目做漏洞研究的最佳实践 | | [docs/examples/demo-finding-flow.md](docs/examples/demo-finding-flow.md) | 脱敏的端到端 finding 工作流示例 | -| [docs/roadmap-0.8.md](docs/roadmap-0.8.md) | `v0.8` CLI 改进计划 | +| [docs/roadmap-0.8.md](docs/roadmap-0.8.md) | `v0.8` CLI 改进交付记录 | | [CONTRIBUTING.md](CONTRIBUTING.md) | 开发与贡献规则 | | [SECURITY.md](SECURITY.md) | 报告本项目安全问题 | | [RELEASE.md](RELEASE.md) | 发布与兼容性检查 | diff --git a/contracts/README.md b/contracts/README.md index 924f4aa..f28bc31 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -8,6 +8,9 @@ Skills reference these files directly rather than duplicating schema content in | File | Description | |---|---| +| `campaign.v1.yaml` | Local research target, scope, priorities, and candidate finding lanes used by `omv campaign`. | +| `source-ref.v1.yaml` | Optional local source identity and Evidence hash sidecar used by `omv sources`. | +| `report-provenance.v1.yaml` | Generated hash manifest for report artifacts and their local inputs. | | `evidence.v1.yaml` | Finding object passed between `omv-find` and `omv-report`. Replaces the old `handoff-contract.md` in both skills. | | `candidate-list.v1.yaml` | Schema for the candidate list output produced by `omv-find`. | | `threat-map.v1.yaml` | Optional dataflow threat map sidecar produced by `omv-audit`. | diff --git a/contracts/campaign.v1.yaml b/contracts/campaign.v1.yaml new file mode 100644 index 0000000..344bf14 --- /dev/null +++ b/contracts/campaign.v1.yaml @@ -0,0 +1,60 @@ +# Campaign.v1 - local research campaign contract +# Produced and consumed by: omv campaign +# +# Storage: +# .omv/campaigns/.yaml - source of truth +# .omv/campaigns/.md - deterministic human runbook +# +# A campaign records research intent and unproven hypothesis lanes. It does not +# claim that discovery, proof, reproduction, or exploitation has occurred. + +schema_version: "1" + +# Identity +id: acme-1-2 # safe filename id; derived from target and known version when omitted +title: Acme 1.2 research campaign +status: active # active is the only Campaign.v1 initialization status +profile: generic # generic only; lanes come solely from normalized user input +created_at: "2026-07-10T00:00:00.000Z" # ISO 8601 +updated_at: "2026-07-10T00:00:00.000Z" # ISO 8601 + +# Target facts. Unknown optional values stay explicit rather than being guessed. +target: + name: Acme # required, non-empty user input + version: "1.2" # normalized user input or unknown; unknown is omitted from a derived id + source: unknown # user-supplied source location/reference or unknown + ecosystem: unknown # unknown | npm | python | go | rust | java | ruby | php | csharp | swift | dart | elixir | perl | r | lua + +# Conservative scope defaults. Supported modes: +# whitebox | graybox | local-lab | passive | mixed +scope: + mode: passive + local_reproduction: unknown # yes | no | unknown + boundaries: + - local or explicitly authorized assets only + - no live third-party testing + - no automatic exploitation + +# Supported outputs: +# course-report | cve | vuldb | internal-report | research-notes +goal: + output: research-notes + +# Supported depths: quick | standard | deep +budget: + depth: standard + +priorities: + # At least one class is required. Classes are lowercase safe slugs, + # deduplicated after normalization while preserving first-seen order. + vulnerability_classes: + - xss + +# There is exactly one generic lane per normalized vulnerability class, in the +# same order. Lane id and vulnerability_class equal the class slug. finding_id +# is -; each lane remains an unproven hypothesis. +lanes: + - id: xss + title: Review xss hypotheses + vulnerability_class: xss + finding_id: acme-1-2-xss diff --git a/contracts/report-provenance.v1.yaml b/contracts/report-provenance.v1.yaml new file mode 100644 index 0000000..1291ab3 --- /dev/null +++ b/contracts/report-provenance.v1.yaml @@ -0,0 +1,11 @@ +# ReportProvenance.v1 — generated report input manifest +# JSON instances are stored at: .omv/reports//provenance.json + +schema_version: "1" +finding_id: "" +generated_at: "" + +inputs: [] +# - role: evidence # evidence | report | source-ref | threat-map | verification | reproduction +# path: .omv/findings/example.yaml +# sha256: "" diff --git a/contracts/source-ref.v1.yaml b/contracts/source-ref.v1.yaml new file mode 100644 index 0000000..d19451a --- /dev/null +++ b/contracts/source-ref.v1.yaml @@ -0,0 +1,15 @@ +# SourceRef.v1 — local source identity sidecar +# Stored at: .omv/sources/.yaml +# This records local research inputs; it does not prove remote authenticity. + +schema_version: "1" +finding_id: "" +finding_sha256: "" +captured_at: "" + +sources: [] +# - kind: repository # repository | registry | archive | file | advisory | other +# locator: "" +# revision: unknown +# path: unknown +# sha256: unknown # lowercase SHA-256 or unknown diff --git a/docs/request-broker.md b/docs/request-broker.md index 8b539c2..7b428c6 100644 --- a/docs/request-broker.md +++ b/docs/request-broker.md @@ -46,6 +46,23 @@ Cache keys include both URL and `Accept` header. Successful responses use respon Use `--refresh` to bypass a fresh cache entry and fetch again. +## Destination And Resource Safety + +The broker accepts only public HTTP(S) destinations. It rejects URL credentials, local hostnames, non-public literal IP addresses, and hostnames when any DNS result is private, loopback, link-local, multicast, documentation-only, or otherwise non-public. + +Redirects are followed manually. Every hop is resolved and validated again, the chain is limited to five redirects, and host-specific headers are rebuilt for each hop. A GitHub API token is therefore never forwarded to another host. + +Response bodies are streamed with an 8 MiB default hard limit. The broker rejects an oversized declared `Content-Length` before buffering and cancels a stream as soon as its observed bytes exceed the limit. + +The request controls can be tuned with environment variables: + +| Variable | Default | Purpose | +|---|---:|---| +| `OMV_HTTP_TIMEOUT_MS` | `20000` | Timeout for DNS validation and each network attempt. | +| `OMV_HTTP_RETRIES` | `1` | Retry count for timeouts, transport errors, and retryable HTTP status codes. | +| `OMV_HTTP_MAX_BODY_BYTES` | `8388608` | Maximum response bytes read into memory. | +| `OMV_USER_AGENT` | package-derived | Override the default `omv-cli/` identity. | + ## JSON Shape `omv request fetch --json` returns a structured result: @@ -58,8 +75,8 @@ Use `--refresh` to bypass a fresh cache entry and fetch again. "status": 403, "cached": false, "cachePath": ".omv/cache/http/.json", - "fetchedAt": "2026-05-08T17:31:38.616Z", - "expiresAt": "2026-05-08T17:36:38.616Z", + "fetchedAt": "2026-07-10T05:31:38.616Z", + "expiresAt": "2026-07-10T05:36:38.616Z", "headers": { "x-ratelimit-remaining": "0" }, @@ -68,7 +85,7 @@ Use `--refresh` to bypass a fresh cache entry and fetch again. "rateLimit": { "limit": 60, "remaining": 0, - "reset": "2026-05-08T17:52:00.000Z", + "reset": "2026-07-10T05:52:00.000Z", "resource": "core" }, "recommendation": "Set GITHUB_TOKEN/GH_TOKEN or wait for the rate-limit reset before deep GitHub metadata checks.", @@ -94,6 +111,9 @@ Sensitive response headers such as `set-cookie`, `cookie`, and `authorization` a | `network_error` | DNS/TLS/transport error. | Retry later and keep the field unverified. | | `upstream_error` | 5xx response from upstream. | Retry later; do not infer candidate quality from this. | | `invalid_url` | URL is not `http` or `https`. | Fix the URL before retrying. | +| `unsafe_destination` | URL credentials or a local/non-public destination was rejected. | Use a public primary-source endpoint. | +| `too_many_redirects` | The redirect chain exceeded five hops. | Use the final public URL directly. | +| `response_too_large` | The response exceeded the configured byte limit. | Use a smaller metadata endpoint or raise the limit only for a trusted source. | ## GitHub Token diff --git a/docs/request-broker.zh-CN.md b/docs/request-broker.zh-CN.md index f2469f6..72be576 100644 --- a/docs/request-broker.zh-CN.md +++ b/docs/request-broker.zh-CN.md @@ -46,6 +46,23 @@ GitHub 的 `/rate_limit` 可能返回 HTTP 200,但 `x-ratelimit-remaining` 已 使用 `--refresh` 可以绕过新鲜缓存并重新请求。 +## 目标与资源安全边界 + +broker 只接受公开 HTTP(S) 目标。带 URL 凭据、本地主机名、非公网 IP 字面量,以及 DNS 结果中包含私网、回环、链路本地、多播、文档保留或其它非公网地址的目标都会在请求前被拒绝。 + +redirect 由 broker 手动处理。每一跳都会重新解析和校验,最多允许 5 跳;host 专用 header 也会按当前目标重建,因此 GitHub API token 不会被转发到其它 host。 + +响应 body 默认最多读取 8 MiB。声明的 `Content-Length` 已超限时不会缓冲 body;实际流式字节数超限时会立即取消读取。 + +可通过环境变量调整请求控制: + +| 环境变量 | 默认值 | 用途 | +|---|---:|---| +| `OMV_HTTP_TIMEOUT_MS` | `20000` | DNS 校验和每次网络尝试的超时。 | +| `OMV_HTTP_RETRIES` | `1` | timeout、传输错误和可重试 HTTP 状态的重试次数。 | +| `OMV_HTTP_MAX_BODY_BYTES` | `8388608` | 允许读入内存的最大响应字节数。 | +| `OMV_USER_AGENT` | 跟随 package 版本 | 覆盖默认 `omv-cli/` 标识。 | + ## JSON 字段 `omv request fetch --json` 会返回结构化结果: @@ -58,8 +75,8 @@ GitHub 的 `/rate_limit` 可能返回 HTTP 200,但 `x-ratelimit-remaining` 已 "status": 403, "cached": false, "cachePath": ".omv/cache/http/.json", - "fetchedAt": "2026-05-08T17:31:38.616Z", - "expiresAt": "2026-05-08T17:36:38.616Z", + "fetchedAt": "2026-07-10T05:31:38.616Z", + "expiresAt": "2026-07-10T05:36:38.616Z", "headers": { "x-ratelimit-remaining": "0" }, @@ -68,7 +85,7 @@ GitHub 的 `/rate_limit` 可能返回 HTTP 200,但 `x-ratelimit-remaining` 已 "rateLimit": { "limit": 60, "remaining": 0, - "reset": "2026-05-08T17:52:00.000Z", + "reset": "2026-07-10T05:52:00.000Z", "resource": "core" }, "recommendation": "Set GITHUB_TOKEN/GH_TOKEN or wait for the rate-limit reset before deep GitHub metadata checks.", @@ -94,6 +111,9 @@ GitHub 的 `/rate_limit` 可能返回 HTTP 200,但 `x-ratelimit-remaining` 已 | `network_error` | DNS/TLS/传输错误。 | 稍后重试,并把字段保持为未确认。 | | `upstream_error` | 上游返回 5xx。 | 稍后重试;不要据此判断候选项目质量。 | | `invalid_url` | URL 不是 `http` 或 `https`。 | 修正 URL 后重试。 | +| `unsafe_destination` | URL 凭据或本地/非公网目标被拒绝。 | 改用公开主来源 endpoint。 | +| `too_many_redirects` | redirect 链超过 5 跳。 | 直接使用最终公开 URL。 | +| `response_too_large` | 响应超过配置的字节上限。 | 使用更小的 metadata endpoint,或仅对可信来源提高上限。 | ## GitHub Token diff --git a/docs/roadmap-0.8.md b/docs/roadmap-0.8.md index 6a58b3c..6f958a4 100644 --- a/docs/roadmap-0.8.md +++ b/docs/roadmap-0.8.md @@ -1,6 +1,6 @@ -# Roadmap 0.8 +# Roadmap 0.8 (Complete) -`v0.7.1` stabilized the Evidence.v1 workflow, npm packaging, Chinese documentation, and the evidence/submission score split. `v0.8` should focus on making local reproduction and report readiness harder to misuse. +`v0.7.1` stabilized the Evidence.v1 workflow, npm packaging, Chinese documentation, and the evidence/submission score split. `v0.8` delivered the local reproduction and report-readiness work below. This document is retained as a release record. ## Theme @@ -8,7 +8,7 @@ Make the CLI answer one question clearly: > What exact work remains before this finding can become a responsible disclosure report? -## Planned Work +## Delivered Work ### 1. `omv repro init ` — implemented @@ -117,9 +117,9 @@ Acceptance criteria: - Do not add live target scanning. - Do not weaken the passive research boundary. -## Release Gate +## Release Gate Used -Before `v0.8.0`: +The `v0.8.0` release was gated with: ```sh npm run release:check @@ -127,4 +127,4 @@ npm pack --dry-run npx -p oh-my-vul omv version ``` -The release should include at least one new deterministic test per new command. +Each new command included deterministic regression coverage. diff --git a/docs/superpowers/plans/2026-07-10-add-campaign-workflow.md b/docs/superpowers/plans/2026-07-10-add-campaign-workflow.md new file mode 100644 index 0000000..8dd3d75 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-add-campaign-workflow.md @@ -0,0 +1,454 @@ +# Campaign Workflow Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a conservative Campaign.v1 first-mile workflow with canonical `omv campaign` commands, `omv first` aliases, deterministic runbooks, and candidate-only finding seeding. + +**Architecture:** A focused `src/cli/campaign.ts` domain module owns normalization, validation, persistence, runbooks, prompting contracts, and safe seeding. Thin parser/router/command adapters converge canonical and alias invocations, while `src/cli/render.ts` owns all human output. Campaign files are scanned directly under `.omv/campaigns/`; the finding index remains unchanged. + +**Tech Stack:** TypeScript 6, Node.js 20 built-ins, `yaml`, Node test runner, Markdown/YAML contracts, Python stdlib release tooling. + +**Working-tree note:** This branch already contains uncommitted in-scope preflight fixes. Preserve them, use diff checkpoints, and do not create partial commits unless the user explicitly requests commits. + +--- + +## File Map + +- Create `contracts/campaign.v1.yaml`: canonical annotated Campaign.v1 example and field semantics. +- Create `src/cli/campaign.ts`: Campaign types, normalization, validation, persistence, prompt resolution, runbook generation, and seeding. +- Create `src/cli/commands/campaign.ts`: canonical/alias invocation normalization, flag extraction, TTY prompt adapter, JSON routing. +- Create `src/cli/__tests__/campaign.test.ts`: domain, persistence, prompting, and seeding behavior. +- Modify `src/cli/paths.ts`: campaign directory/YAML/runbook path helpers. +- Modify `src/cli/workspace.ts`: create campaign directory only; do not index it. +- Modify `src/cli/findings.ts`: narrow typed initial values for candidate templates. +- Modify `src/cli/args.ts`: canonical and alias command grammar. +- Modify `src/cli/commands/index.ts`: register both top-level names to one adapter. +- Modify `src/cli/commands/shared.ts`: campaign value flags for positional extraction. +- Modify `src/cli/render.ts`: Campaign human output. +- Modify `src/cli/usage.ts`: Campaign/first help. +- Modify `src/index.ts`: public Campaign and path exports. +- Modify existing tests in `src/cli/__tests__/args.test.ts`, `workspace.test.ts`, `findings.test.ts`, `render.test.ts`, and `commands.test.ts`. +- Modify `skills/omv/SKILL.md`, `README.md`, `README.zh-CN.md`, `contracts/README.md`, `registry.yaml`, `skills/omv/references/registry.yaml`, `CHANGELOG.md`, and the historical design record. +- Modify `scripts/check_npm_pack.py`: require Campaign contract and compiled command assets. + +## Task 1: Contract, Paths, and Workspace Storage + +**Files:** + +- Create: `contracts/campaign.v1.yaml` +- Modify: `src/cli/paths.ts` +- Modify: `src/cli/workspace.ts` +- Test: `src/cli/__tests__/workspace.test.ts` + +- [ ] **Step 1: Write the failing workspace test** + +Add imports for `campaignsDir`, `campaignPath`, and `campaignRunbookPath`, then extend the idempotency test with assertions equivalent to: + +```ts +assert.equal(existsSync(campaignsDir(projectRoot)), true); +assert.equal(campaignPath("demo", projectRoot), join(projectRoot, ".omv", "campaigns", "demo.yaml")); +assert.equal(campaignRunbookPath("demo", projectRoot), join(projectRoot, ".omv", "campaigns", "demo.md")); + +await writeFile(campaignPath("demo", projectRoot), "schema_version: \"1\"\n", "utf-8"); +await writeFile(campaignRunbookPath("demo", projectRoot), "# Demo\n", "utf-8"); +await initWorkspace(projectRoot); +assert.equal(await readFile(campaignRunbookPath("demo", projectRoot), "utf-8"), "# Demo\n"); +assert.equal((await readWorkspaceIndex(projectRoot)).findings.some((entry) => entry.id === "demo"), false); +``` + +- [ ] **Step 2: Run RED** + +Run `npm run build`. + +Expected: TypeScript fails because the three campaign path helpers do not exist. + +- [ ] **Step 3: Add the path helpers and workspace directory** + +Add this ownership to `paths.ts`: + +```ts +export function campaignsDir(projectRoot = process.cwd()): string; +export function campaignPath(id: string, projectRoot = process.cwd()): string; +export function campaignRunbookPath(id: string, projectRoot = process.cwd()): string; +``` + +Import `campaignsDir` in `workspace.ts` and create it in `ensureWorkspaceDirs()`. Do not add fields to `WorkspaceIndex` or `WorkspaceStatus`. + +- [ ] **Step 4: Add the annotated Campaign contract** + +Write the exact v1 shape from the OpenSpec design, including comments for supported modes, goals, depths, local-reproduction values, supported Evidence ecosystems plus `unknown`, safe default boundaries, class normalization, and lane/finding-id derivation. + +- [ ] **Step 5: Verify GREEN** + +Run `npm run build`, then `node --test dist/cli/__tests__/workspace.test.js`. + +Expected: build succeeds and all workspace tests pass. + +- [ ] **Step 6: Mark OpenSpec tasks 1.1 through 1.3 complete** + +Change their checkboxes in `openspec/changes/add-campaign-workflow/tasks.md` immediately after the focused test passes. + +## Task 2: Campaign Domain, Persistence, and Prompt Resolution + +**Files:** + +- Create: `src/cli/campaign.ts` +- Create: `src/cli/__tests__/campaign.test.ts` + +- [ ] **Step 1: Write failing construction and validation tests** + +The tests must use a fixed timestamp and assert the complete object, including these public interfaces and values: + +```ts +const campaign = buildCampaign({ + target: " Acme ", + version: " 1.2 ", + vulnerabilities: [" XSS ", "auth z", "xss"], +}, () => new Date("2026-07-10T00:00:00.000Z")); + +assert.equal(campaign.id, "acme-1-2"); +assert.equal(campaign.profile, "generic"); +assert.deepEqual(campaign.priorities.vulnerability_classes, ["xss", "auth-z"]); +assert.deepEqual(campaign.lanes.map((lane) => lane.finding_id), [ + "acme-1-2-xss", + "acme-1-2-auth-z", +]); +assert.equal(campaign.scope.mode, "passive"); +assert.equal(campaign.goal.output, "research-notes"); +assert.equal(campaign.budget.depth, "standard"); +assert.equal(campaign.target.ecosystem, "unknown"); +``` + +Also test empty target, empty/unsluggable class lists, unsafe explicit ids, unsupported enums, malformed YAML, mismatched lanes, duplicate finding ids, and invalid timestamps. + +- [ ] **Step 2: Run RED** + +Run `npm run build`. + +Expected: TypeScript fails because `../campaign.js` and its exports do not exist. + +- [ ] **Step 3: Implement types and pure construction** + +Define discriminated string unions for mode, goal, depth, local reproduction, status, and supported ecosystem. Define these main shapes: + +```ts +export interface Campaign { /* exact OpenSpec design shape */ } +export interface CampaignInput { + id?: string; + target?: string; + version?: string; + source?: string; + ecosystem?: string; + mode?: CampaignMode; + output?: CampaignOutput; + depth?: CampaignDepth; + vulnerabilities?: string[]; + localReproduction?: CampaignLocalReproduction; +} +export interface CampaignPromptAdapter { + askTarget(): Promise; + askVulnerabilities(): Promise; +} +``` + +Implement and export `normalizeCampaignId`, `normalizeVulnerabilityClasses`, `buildCampaign`, `validateCampaign`, `parseCampaignYaml`, and `renderCampaignRunbook`. Keep runbook sections limited to target facts, boundaries, lanes, candidate wording, and concrete OMV next commands. + +- [ ] **Step 4: Verify pure-domain GREEN** + +Run `npm run build`, then `node --test --test-name-pattern="Campaign construction|Campaign validation|Campaign runbook" dist/cli/__tests__/campaign.test.js`. + +Expected: selected tests pass. + +- [ ] **Step 5: Write failing persistence tests** + +Cover: + +```ts +await initCampaign(input, { projectRoot, now: fixedNow }); +await assert.rejects(() => initCampaign(input, { projectRoot, now: fixedNow }), /already exists/); +await initCampaign(changedInput, { projectRoot, now: fixedNow, force: true }); +assert.deepEqual((await listCampaigns(projectRoot)).map((item) => item.id), ["a", "b"]); +assert.deepEqual(await listCampaigns(missingRoot), []); +assert.equal((await showCampaign("a", projectRoot)).campaign.id, "a"); +``` + +Record `index.json` bytes before campaign list/show and assert they are unchanged afterward. + +- [ ] **Step 6: Implement persistence** + +Implement `initCampaign`, `listCampaigns`, `showCampaign`, and result/summary types. Check both YAML and Markdown paths before init; write both only after validation. List only `.yaml`/`.yml` files, validate each, sort by id, and never call workspace-index helpers. + +- [ ] **Step 7: Write failing prompt tests** + +Use a recording adapter and assert: + +```ts +const completed = await resolveCampaignInput({}, { interactive: true, prompt }); +assert.deepEqual(prompt.calls, ["target", "vulnerabilities"]); +await assert.rejects( + () => resolveCampaignInput({}, { interactive: false, prompt }), + /target.*vulnerability/i, +); +assert.deepEqual(prompt.calls, []); +``` + +Test supplied target only, supplied vulnerabilities only, blank prompt responses, and comma splitting. + +- [ ] **Step 8: Implement prompt resolution** + +`resolveCampaignInput` may ask only for missing required values. Optional fields use safe defaults. It must call no adapter method when `interactive` is false. + +- [ ] **Step 9: Verify all Campaign domain tests** + +Run `npm run build`, then `node --test dist/cli/__tests__/campaign.test.js`. + +Expected: domain, persistence, missing-directory, and prompt tests all pass. + +- [ ] **Step 10: Mark OpenSpec tasks 2.1 through 2.6 complete** + +Update the six checkboxes immediately. + +## Task 3: Typed Candidate Templates and Safe Seeding + +**Files:** + +- Modify: `src/cli/findings.ts` +- Modify: `src/cli/campaign.ts` +- Test: `src/cli/__tests__/findings.test.ts` +- Test: `src/cli/__tests__/campaign.test.ts` + +- [ ] **Step 1: Write the failing typed-template test** + +Call `createFindingTemplate` with a narrow seed object: + +```ts +await createFindingTemplate("demo-xss", { + projectRoot, + seed: { + researcherGoal: "triage", + product: "Acme", + ecosystem: "npm", + vulnerabilityClass: "xss", + }, +}); +``` + +Parse the YAML and assert `status === "candidate"`, target identity is present, `versions.tested === "unknown"`, source/sink/guard/reproducer/observed result are `unknown`, all unknown accounting fields are listed, and neither `campaign_id` nor proof artifacts exist. + +- [ ] **Step 2: Run RED** + +Run `npm run build`. + +Expected: TypeScript rejects the unknown `seed` option. + +- [ ] **Step 3: Implement the narrow template extension** + +Add a typed `FindingTemplateSeed` to `CreateFindingTemplateOptions`. Keep ordinary finding initialization byte-compatible with the commented contract template; only seed mode parses the canonical YAML, applies allowed identity fields and explicit unknowns, then serializes it with `yaml.stringify`. + +- [ ] **Step 4: Verify template GREEN** + +Run `npm run build`, then `node --test --test-name-pattern="campaign seed values" dist/cli/__tests__/findings.test.js`. + +Expected: selected finding test passes. + +- [ ] **Step 5: Write failing Campaign seed tests** + +Create a two-lane campaign with a known ecosystem. Assert first seed creates two valid candidate findings; second seed creates none and reports two skips. Pre-create one `.yml` finding and assert it is skipped byte-for-byte. Assert unknown ecosystem fails before any finding is written. Assert these paths remain absent for every lane: + +```ts +threatMapPath(id, projectRoot); +findingReproDir(id, projectRoot); +verificationPath(id, projectRoot); +findingReportsDir(id, projectRoot); +``` + +- [ ] **Step 6: Run seed RED** + +Run `npm run build`, then `node --test --test-name-pattern="Campaign seed" dist/cli/__tests__/campaign.test.js`. + +Expected: tests fail because `seedCampaign` is missing. + +- [ ] **Step 7: Implement idempotent seeding** + +Prevalidate the Campaign and its ecosystem. Preflight every lane for duplicate ids and both `.yaml`/`.yml` existing paths. For each unoccupied lane call the typed finding initializer with exclusive creation and `force: false`. Return stable `created`, `skipped`, and `failed` arrays plus a finding-level next action. Catch per-lane I/O failures after validation so successful lanes remain visible and retries stay idempotent. Do not expose or pass a force option. + +- [ ] **Step 8: Verify seeding GREEN** + +Run `npm run build`, then run the complete Campaign and findings test files. + +Expected: all selected tests pass and seeded findings validate as candidates. + +- [ ] **Step 9: Mark OpenSpec tasks 3.1 through 3.4 complete** + +Update the four checkboxes immediately. + +## Task 4: CLI Grammar, Aliases, Command Adapter, and Renderers + +**Files:** + +- Create: `src/cli/commands/campaign.ts` +- Modify: `src/cli/args.ts` +- Modify: `src/cli/commands/index.ts` +- Modify: `src/cli/commands/shared.ts` +- Modify: `src/cli/render.ts` +- Modify: `src/cli/usage.ts` +- Test: `src/cli/__tests__/args.test.ts` +- Test: `src/cli/__tests__/render.test.ts` +- Test: `src/cli/__tests__/commands.test.ts` + +- [ ] **Step 1: Write parser RED tests** + +Add the full canonical/alias matrix from the OpenSpec task, including: + +```ts +assert.equal(validateArgs(["campaign"]).ok, true); +assert.equal(validateArgs(["first", "--target", "acme", "--vuln", "xss", "--no-interactive"]).ok, true); +assert.equal(validateArgs(["first", "show", "demo", "--json"]).ok, true); +assert.equal(validateArgs(["campaign", "seed", "demo", "--force"]).ok, false); +assert.equal(validateArgs(["campaign", "init", "--mode", "live"]).ok, false); +assert.equal(validateArgs(["campaign", "show"]).ok, false); +``` + +- [ ] **Step 2: Run parser RED** + +Run `npm run build`, then `node --test dist/cli/__tests__/args.test.js` if compilation succeeds. + +Expected: new grammar assertions fail. + +- [ ] **Step 3: Implement shared grammar** + +Add one `validateCampaignArgs(args, aliasMode)` function. Canonical no-subcommand maps to list; `first` no-subcommand or leading flag maps to init. Validate mode, goal, budget, local-lab, and ecosystem enum values; treat target/version/source/vuln/id as valued options. Register `campaign` and `first` in the valid-command error string. + +- [ ] **Step 4: Verify parser GREEN** + +Run `npm run build`, then `node --test dist/cli/__tests__/args.test.js`. + +- [ ] **Step 5: Write renderer and process RED tests** + +Renderer tests capture output for init, list, show, and seed and assert stable headings, target/lane counts, created/skipped counts, paths, and next commands. Process tests run compiled commands in temporary project roots and assert: + +- canonical non-interactive init writes both files; +- `first` produces the same normalized JSON shape; +- `campaign` defaults to list; +- show JSON is one parseable document; +- seed JSON is one parseable document and second invocation reports skips; +- `first --json` with missing values exits non-zero without prompt text; +- `help campaign`, `help first`, and unknown subcommands are actionable. + +- [ ] **Step 6: Run process RED** + +Run `npm run build`. + +Expected: compilation or process tests fail because the command module, registry entries, renderers, and help are missing. + +- [ ] **Step 7: Implement the thin command adapter** + +Normalize raw invocations to one of `init|list|show|seed`. Extract options with shared helpers. Compute interactive mode as `!json && !noInteractive && Boolean(process.stdin.isTTY && process.stdout.isTTY)`. Create/close a Node readline interface only in that branch. Route JSON with exactly one `JSON.stringify` call and human results through exported canonical renderers. + +- [ ] **Step 8: Register aliases and help** + +Map both registry keys to the same `campaign.run`. Add top-level and detailed usage for canonical and alias forms, flags, defaults, and the explicit ecosystem prerequisite for seed. + +- [ ] **Step 9: Verify CLI GREEN** + +Run `npm run build`, then run args, render, commands, Campaign, and workspace tests. + +Expected: all selected suites pass with no warnings or stray prompt output. + +- [ ] **Step 10: Mark OpenSpec tasks 4.1 through 4.4 complete** + +Update all four checkboxes immediately. + +## Task 5: Public API, Pack Gate, Skill, and Documentation + +**Files:** + +- Modify: `src/index.ts` +- Modify: `scripts/check_npm_pack.py` +- Modify: `skills/omv/SKILL.md` +- Modify: `README.md` +- Modify: `README.zh-CN.md` +- Modify: `contracts/README.md` +- Modify: `registry.yaml` +- Modify generated: `skills/omv/references/registry.yaml` +- Modify: `CHANGELOG.md` +- Modify: `docs/superpowers/specs/2026-07-08-omv-first-campaign-plan-design.md` + +- [ ] **Step 1: Add public exports and pack requirements** + +Export Campaign types, init/list/show/seed/validation helpers, and campaign paths from `src/index.ts`. Add `contracts/campaign.v1.yaml` and `dist/cli/commands/campaign.js` to required npm files while preserving the `.omv/` forbidden prefix. + +- [ ] **Step 2: Run the pack-gate RED check before the contract is recognized** + +Temporarily verify the new assertion catches a missing required path by running `python3 scripts/check_npm_pack.py` before the built output is refreshed, or remove only the generated Campaign output and run the check. + +Expected: the checker names the missing Campaign asset. Restore/build immediately afterward; do not leave generated output deleted. + +- [ ] **Step 3: Update manager guidance** + +Add `/omv first`, `/omv campaign`, `show`, and `seed` delegation. State that Campaign files are private and that seeded findings are unproven candidates. Explicitly forbid manual Campaign/Evidence writes for delegated operations and forbid claims that seed performed an audit or reproduction. + +- [ ] **Step 4: Update user and contract documentation** + +Add Campaign before finding creation in English and Chinese quick workflows, document non-interactive examples and safe defaults, list `campaign.v1.yaml`, add a changelog entry, update manager registry description/contract binding, and mark the old Plan.v1 design as superseded by Campaign.v1. Do not reintroduce `.omv/plans`, notes, target profiles, or ThreatMap seeding. + +- [ ] **Step 5: Sync generated assets** + +Run `npm run sync-metadata`, then `npm run sync-assets`. Inspect the diff and ensure only expected metadata copies changed. + +- [ ] **Step 6: Validate skill and package integration** + +Run `python3 scripts/validate_skill.py`, `npm run build`, and `python3 scripts/check_npm_pack.py`. + +Expected: every skill validates and the dry-run npm tarball contains both Campaign assets while excluding `.omv/`. + +- [ ] **Step 7: Mark OpenSpec tasks 5.1 through 6.3 complete** + +Update the five checkboxes immediately. + +## Task 6: Release Verification and Reviews + +**Files:** + +- Modify only files required by discovered defects. +- Update `openspec/changes/add-campaign-workflow/tasks.md` after each verified gate. + +- [ ] **Step 1: Run focused and full gates** + +Run, separately and read each exit status: + +```text +npm run typecheck +npm run build +npm test +openspec validate add-campaign-workflow --strict --json +git diff --check +``` + +Mark OpenSpec task 7.1 only after every command succeeds. + +- [ ] **Step 2: Run release and independent tarball checks** + +Run `npm run release:check`. Then run `npm pack --json --dry-run` independently and inspect the returned file list for `contracts/campaign.v1.yaml`, all compiled Campaign module variants, forbidden `.omv/`, Python caches, source tests, and stale compiled CLI files. Record file count and leak list. Mark task 7.2 only after both checks succeed. + +- [ ] **Step 3: Run isolated installation smoke tests** + +Create temporary HOME and project directories. Run the compiled CLI setup with `HOME` and `CLAUDE_HOME` pointing into the temp tree, then `doctor --json`. In the temp project run canonical init/list/show/seed and equivalent `first` aliases with `--json`; validate the seeded findings. Remove the temp directories afterward. Mark task 7.3 only on complete success. + +- [ ] **Step 4: Obtain two-stage and final review** + +Give a spec reviewer the complete Campaign requirements and diff. Resolve all missing/extra behavior, then re-review. Give a separate quality reviewer the approved diff and test evidence. Resolve Critical/Important findings, rerun affected gates, then request a final whole-change review. Mark task 7.4 only after all reviewers approve. + +- [ ] **Step 5: Archive the OpenSpec change** + +Confirm `openspec instructions apply --change add-campaign-workflow --json` reports every task complete. Archive with the repository's OpenSpec archive workflow, run strict validation again, and verify the main specs contain the Campaign requirements. + +--- + +## Plan Self-Review + +- Every Campaign workflow scenario maps to a test or documentation step above. +- No task introduces Plan.v1, `.omv/plans`, campaign notes, target-name branches, automatic proof artifacts, or an Evidence `campaign_id`. +- JSON non-interactivity and seed overwrite protection have parser, domain, and process-level coverage. +- Ecosystem `unknown` remains a valid Campaign default but blocks seed before writes, preserving Evidence.v1 validity. +- Public type names and field names match the OpenSpec design throughout the plan. diff --git a/docs/superpowers/plans/2026-07-10-add-pattern-pack-eval-runner.md b/docs/superpowers/plans/2026-07-10-add-pattern-pack-eval-runner.md new file mode 100644 index 0000000..5acb954 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-add-pattern-pack-eval-runner.md @@ -0,0 +1,123 @@ +# PatternPack and Eval Runner Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make all fourteen ecosystem pattern packs manifest-driven and expose existing eval checks through one human/JSON/JUnit runner and CLI command. + +**Architecture:** Canonical per-ecosystem JSON manifests drive both methodology validation and skill-local asset copies. A Python runner reads a stable case manifest and invokes existing checkers; the TypeScript adapter only validates and forwards arguments. + +**Tech Stack:** Python 3 stdlib, TypeScript 6, Node 20 child processes/test runner, JSON, JUnit XML. + +--- + +## File Structure + +- `scripts/pattern_packs.py`: strict manifest loader shared by dev scripts. +- `shared/pattern-packs/*.json`: fourteen canonical PatternPack manifests. +- `shared/references/patterns/{r,lua}.md`: missing method registries. +- `shared/evals/stable.json`: data-driven stable case registry. +- `shared/scripts/run_evals.py`: packaged unified runner. +- `src/cli/commands/eval.ts`: Python transport adapter. +- `src/cli/__tests__/eval.test.ts`: process coverage for output and exit semantics. + +### Task 1: PatternPack Loader and Data + +- [ ] **Step 1: Write failing loader tests** + +Use temporary manifests to assert exactly fourteen supported ecosystems, safe relative references, closed keys, unique ids, valid consumer skill directories, and actionable invalid-field errors. + +- [ ] **Step 2: Verify RED** + +Run `python3 -m unittest scripts.test_pattern_packs` and confirm the missing loader fails. + +- [ ] **Step 3: Implement loader and manifests** + +Expose: + +```py +def load_pattern_packs(root: Path = REPO_ROOT) -> list[dict[str, object]]: ... +def pattern_asset_mappings(root: Path = REPO_ROOT) -> list[tuple[Path, Path]]: ... +``` + +Add one JSON file per Evidence ecosystem with `schema_version`, `id`, `ecosystem`, `aliases`, `reference`, `vulnerability_classes`, and `consumers`. + +- [ ] **Step 4: Add R/Lua references and verify GREEN** + +Each Markdown entry must contain the seven methodology markers used by release validation. Run the loader tests. + +### Task 2: Manifest-Driven Sync + +- [ ] **Step 1: Add failing sync checks** + +Assert mappings include both `references/patterns/.md` and `references/pattern-packs/.json` for every declared consumer, including R and Lua. + +- [ ] **Step 2: Verify RED** + +Run `python3 scripts/sync_skill_assets.py --check` and confirm missing generated targets are reported. + +- [ ] **Step 3: Replace static pattern mappings** + +Keep non-pattern `ASSET_MAPPINGS`; append `pattern_asset_mappings()` and use `load_pattern_packs()` in `release_check.py#validate_pattern_registry`. + +- [ ] **Step 4: Generate and verify GREEN** + +Run `python3 scripts/sync_skill_assets.py` followed by `--check` and `python3 scripts/validate_skill.py`. + +### Task 3: Unified Eval Runner + +- [ ] **Step 1: Write failing runner tests** + +Test a passing checker, failing checker, unsafe path, targeted invocation, JSON parsing, and JUnit parsing with `xml.etree.ElementTree`. + +- [ ] **Step 2: Verify RED** + +Run `python3 -m unittest shared.scripts.test_run_evals` and confirm the missing runner fails. + +- [ ] **Step 3: Implement result model and execution** + +```py +@dataclass(frozen=True) +class EvalResult: + id: str + skill: str + eval_id: int + passed: bool + duration_ms: int + stdout: str + stderr: str +``` + +Validate every path stays below package root, invoke checker scripts with `sys.executable`, and serialize one summary consistently across formats. + +- [ ] **Step 4: Add stable manifest and release integration** + +Move every existing stable case plus documented finder/report goldens into `shared/evals/stable.json`; replace `STABLE_EVAL_CHECKS` with one runner call. + +- [ ] **Step 5: Verify GREEN** + +Run the Python tests and `python3 shared/scripts/run_evals.py --format json`; assert zero failures. + +### Task 4: `omv eval` Adapter + +- [ ] **Step 1: Add failing argument/process tests** + +Cover stable JSON, JUnit XML, full targeted options, incomplete triples, invalid ids, conflicting formats, and extra positionals. + +- [ ] **Step 2: Verify RED** + +Run `npm run build && node --test dist/cli/__tests__/args.test.js dist/cli/__tests__/eval.test.js`. + +- [ ] **Step 3: Implement transport-only adapter** + +Use `OMV_PYTHON || "python3"`, `packageRoot()`, and `spawnSync`; forward `--format` and targeted arguments, inherit stdout/stderr, and preserve non-zero status. + +- [ ] **Step 4: Verify GREEN** + +Run focused tests, typecheck, and direct `node dist/cli/omv.js eval --json` / `--junit` smoke checks. + +### Task 5: Docs and Release Gate + +- [ ] Update README files, DEVELOPMENT.md, changelog, manager/finder/audit guidance, npm assertions, and synced assets. +- [ ] Run `npm run release:check`, both OpenSpec strict validations, and `git diff --check`. +- [ ] Inspect `npm pack --json --dry-run` for runner/manifests and private/cache leaks; reinstall under isolated HOME and rerun `omv eval --json`. + diff --git a/docs/superpowers/plans/2026-07-10-add-source-provenance.md b/docs/superpowers/plans/2026-07-10-add-source-provenance.md new file mode 100644 index 0000000..b65515e --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-add-source-provenance.md @@ -0,0 +1,129 @@ +# Source Provenance Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add SourceRef sidecars and hash-based report provenance with backward-compatible freshness checks. + +**Architecture:** Keep SourceRef and report provenance in separate domain modules. SourceRef is hand-editable YAML tied to Evidence bytes; the generated JSON report manifest hashes Evidence, reports, and optional local dependencies. Existing report artifact checks consume manifest validation additively. + +**Tech Stack:** TypeScript 6, Node 20 filesystem/crypto APIs, `yaml`, Node test runner, OpenSpec. + +--- + +## File Structure + +- `contracts/source-ref.v1.yaml`: documented SourceRef shape. +- `contracts/report-provenance.v1.yaml`: documented generated manifest shape. +- `src/cli/source-ref.ts`: SourceRef construction, parsing, persistence, and freshness. +- `src/cli/report-provenance.ts`: manifest generation, parsing, dependency hashing, and freshness. +- `src/cli/commands/sources.ts`: source command routing only. +- `src/cli/commands/report.ts`: add provenance routing without formatting logic. +- `src/cli/__tests__/source-ref.test.ts`: domain and filesystem behavior. +- `src/cli/__tests__/report-provenance.test.ts`: manifest and artifact integration behavior. + +### Task 1: Paths, Contracts, and Workspace + +- [ ] **Step 1: Write failing workspace/path tests** + +Add assertions equivalent to: + +```ts +assert.equal(sourcesDir(root), join(root, ".omv", "sources")); +await initWorkspace(root); +assert.equal((await stat(sourcesDir(root))).isDirectory(), true); +``` + +- [ ] **Step 2: Verify RED** + +Run `npm run build && node --test dist/cli/__tests__/workspace.test.js` and confirm missing exports/directories fail. + +- [ ] **Step 3: Add contract files and path helpers** + +Implement `sourcesDir()`, `sourceRefPath()`, and `reportProvenancePath()` in `src/cli/paths.ts`; call `mkdir(sourcesDir(...), {recursive:true})` from workspace initialization. Add both contracts to `contracts/README.md` and npm pack assertions. + +- [ ] **Step 4: Verify GREEN** + +Run the same focused test and `npm run typecheck`. + +### Task 2: SourceRef Domain + +- [ ] **Step 1: Write failing SourceRef tests** + +Cover known repository derivation, empty sources with warnings, unknown-key rejection, invalid hashes/timestamps, filename mismatch, `--force` protection, and stale Evidence: + +```ts +const result = await initSourceRef("demo", root); +assert.equal(result.sourceRef.finding_id, "demo"); +assert.equal(result.sourceRef.finding_sha256, await sha256File(findingPath)); +assert.equal((await validateSourceRef("demo", root)).stale, false); +``` + +- [ ] **Step 2: Verify RED** + +Run `npm run build && node --test dist/cli/__tests__/source-ref.test.js`; failure must be missing SourceRef behavior. + +- [ ] **Step 3: Implement the minimal public API** + +```ts +export async function initSourceRef(target: string, projectRoot = process.cwd(), options = {}): Promise; +export async function showSourceRef(target: string, projectRoot = process.cwd()): Promise; +export async function validateSourceRef(target: string, projectRoot = process.cwd()): Promise; +``` + +Use closed key sets, `parseYaml`, existing `sha256File`, exclusive writes, safe ids, and current Evidence package fields only. + +- [ ] **Step 4: Verify GREEN and refactor names** + +Run the focused test until all cases pass, then rerun `npm run typecheck`. + +### Task 3: Report Provenance + +- [ ] **Step 1: Write failing manifest tests** + +Create a report file and optional sidecars/repro files. Assert deterministic roles, SHA-256 values, project-relative paths, existing-manifest protection, force replacement, and manifest exclusion from report artifact counts. + +- [ ] **Step 2: Verify RED** + +Run `npm run build && node --test dist/cli/__tests__/report-provenance.test.js`. + +- [ ] **Step 3: Implement generation and validation** + +```ts +export async function createReportProvenance(id: string, projectRoot = process.cwd(), options = {}): Promise; +export async function validateReportProvenance(id: string, projectRoot = process.cwd()): Promise; +``` + +Hash required Evidence and each non-empty report; add optional SourceRef, ThreatMap, Verification, and existing declared repro files. Reject manifest-only report directories. Write sorted, two-space JSON plus newline. + +- [ ] **Step 4: Integrate status-aware artifact freshness** + +Extend `ReportArtifactsResult` additively. Missing manifest is always a warning. Malformed/stale dependencies are errors only when Evidence status is `confirmed`. + +- [ ] **Step 5: Verify GREEN** + +Run provenance and existing findings tests together. + +### Task 4: CLI, Renderers, and Public API + +- [ ] **Step 1: Add failing parser/process tests** + +Cover `sources init|show|validate`, `report provenance`, JSON purity, help, force restrictions, and positional arity. + +- [ ] **Step 2: Verify RED** + +Run `npm run build && node --test dist/cli/__tests__/args.test.js dist/cli/__tests__/commands.test.js`. + +- [ ] **Step 3: Implement thin adapters** + +Register `sources`; extend report routing; add canonical renderer functions and usage text; export domain/path types from `src/index.ts`. + +- [ ] **Step 4: Verify GREEN** + +Run focused tests and confirm JSON stdout parses as one document. + +### Task 5: Docs and Release Gate + +- [ ] Update `skills/omv/SKILL.md`, `skills/omv-report/SKILL.md`, README files, changelog, registry, and package checks; run `npm run sync-assets` and `npm run sync-metadata`. +- [ ] Run `npm run release:check`, `openspec validate add-source-provenance --strict`, and `git diff --check`. +- [ ] Under an isolated HOME, run setup, doctor, SourceRef init/validate, report provenance creation, and report artifact validation through `dist/cli/omv.js`. + diff --git a/docs/superpowers/plans/2026-07-10-fix-preflight-issues.md b/docs/superpowers/plans/2026-07-10-fix-preflight-issues.md new file mode 100644 index 0000000..1cb8dd9 --- /dev/null +++ b/docs/superpowers/plans/2026-07-10-fix-preflight-issues.md @@ -0,0 +1,233 @@ +# Preflight Issues Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking. + +**Goal:** Fix the four preflight issues identified in the repository review: private workspace defaults, report-readiness consistency, clean/reproducible package builds, and dashboard renderer drift. + +**Architecture:** Preserve the existing domain modules and command split. Add behavior-level regressions first, then make the smallest changes needed so privacy policy, readiness policy, package contents, and human rendering each have one effective implementation. + +**Tech Stack:** TypeScript 6, Node.js built-in test runner, Python release checks, npm packaging, YAML fixtures. + +**Status (2026-07-10):** Implemented on mainline. All four tasks are done: + +1. `workspace.ts` uses a single `.omv/` gitignore entry. +2. `workflow.ts` gates report recommendations on `submissionScore >= 75` via `isSubmissionScoreReady` / `isReportReady`. +3. `package.json` clean build + `dist/cli/commands/*` pack patterns; `check_npm_pack.py` rejects stale/missing modules. +4. `commands/dashboard.ts` routes through `printDashboard` from `render.ts`. + +Do not re-implement from this plan unless a regression reopens one of the items. + +--- + +### Task 1: Make `.omv/` private by default in gitignore guidance + +**Files:** +- Modify: `src/cli/workspace.ts` +- Test: `src/cli/__tests__/workspace.test.ts` + +- [x] **Step 1: Write failing privacy tests** + +Add tests that establish these behaviors: + +```ts +test("workspace init --gitignore ignores the entire private .omv directory", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-workspace-")); + try { + await writeFile(join(projectRoot, ".gitignore"), "", "utf-8"); + const result = await initWorkspace(projectRoot, { gitignore: true }); + assert.equal(await readFile(join(projectRoot, ".gitignore"), "utf-8"), ".omv/\n"); + assert.equal(result.warnings.some((warning) => warning.includes("add .omv/")), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("workspace init --gitignore creates a missing gitignore", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-workspace-")); + try { + await initWorkspace(projectRoot, { gitignore: true }); + assert.equal(await readFile(join(projectRoot, ".gitignore"), "utf-8"), ".omv/\n"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); +``` + +Also assert that non-mutating advice recommends `.omv/` and never says to keep `.omv/findings/` tracked. + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```sh +npm run build +node --test dist/cli/__tests__/workspace.test.js +``` + +Expected: FAIL because the current implementation appends only `.omv/repro/`, `.omv/reports/`, and `.omv/archive/`, and does not create a missing `.gitignore`. + +- [x] **Step 3: Implement the minimal privacy fix** + +Change gitignore advice to use `.omv/` as the single private-state entry. When `gitignore: true`, create or append the entry idempotently. Compute the returned workspace warnings after this mutation so a successful auto-add does not return a stale privacy warning. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run the same focused test. Expected: all workspace tests pass. + +### Task 2: Use submission readiness in every report recommendation + +**Files:** +- Modify: `src/cli/workflow.ts` +- Test: `src/cli/__tests__/findings.test.ts` + +- [x] **Step 1: Write a failing workflow regression** + +Create a confirmed fixture with valid structural evidence but low submission confidence: + +```ts +const lowConfidence = BASE_FINDING + .replace("status: candidate", "status: confirmed") + + `verdict:\n exploitability: plausible\n confidence: low\n reason: incomplete confidence\n`; +``` + +Assert `validation.ok === true`, `validation.submissionScore < 75`, `nextAction !== "/omv-report low-confidence"`, and `priority < 100`. + +- [x] **Step 2: Run the focused test and verify RED** + +Run: + +```sh +npm run build +node --test dist/cli/__tests__/findings.test.js +``` + +Expected: FAIL because `workflowNextAction()` and `workflowPriority()` currently treat every structurally valid confirmed finding as report-ready. + +- [x] **Step 3: Implement the minimal readiness fix** + +Require all three conditions before recommending a report or assigning priority 100: + +```ts +finding.status === "confirmed" + && validation.ok + && finding.submissionScore >= 75 +``` + +Do not change score weights or the default/non-strict review policy. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run the same focused test. Expected: all findings tests pass. + +### Task 3: Make builds clean and npm packages complete and exact + +**Files:** +- Modify: `package.json` +- Modify: `scripts/check_npm_pack.py` + +- [x] **Step 1: Strengthen the package checker first** + +Before changing build/package configuration, make `check_npm_pack.py` fail when: + +- `dist/cli/commands/index.js` and the other compiled command modules are absent; +- a top-level compiled module exists under `dist/cli/` without a matching `src/cli/*.ts` source, such as stale `dist/cli/plan.js`; +- `dist/cli/__tests__/` is packaged. + +Derive expected command modules from `src/cli/commands/*.ts` instead of maintaining another static command list. + +- [x] **Step 2: Run the package check and verify RED** + +Run: + +```sh +npm run pack:check +``` + +Expected: FAIL because command modules are not currently packaged and the local `dist/cli/plan.*` stale output is included. + +- [x] **Step 3: Implement clean build and complete package patterns** + +Add a cross-platform `clean` npm script using Node `fs.rmSync("dist", { recursive: true, force: true })`, and invoke it before `tsc` in `build`. + +Extend `package.json#files` with the four command-module patterns: + +```json +"dist/cli/commands/*.d.ts", +"dist/cli/commands/*.d.ts.map", +"dist/cli/commands/*.js", +"dist/cli/commands/*.js.map" +``` + +Keep compiled tests excluded. + +- [x] **Step 4: Verify GREEN and executable package entrypoints** + +Run: + +```sh +npm run build +npm run pack:check +node dist/cli/omv.js version --json +``` + +Expected: stale plan artifacts are gone, all command modules are present in the dry-run tarball, no tests are packaged, and the CLI entrypoint starts successfully. + +### Task 4: Route dashboard output through the canonical renderer + +**Files:** +- Modify: `src/cli/commands/dashboard.ts` +- Test: `src/cli/__tests__/commands.test.ts` + +- [x] **Step 1: Add a failing command-level dashboard test** + +Spawn the compiled CLI in a temporary project with one Evidence.v1 fixture and assert that human dashboard output contains the canonical columns `verdict` and `blocker`. Use `spawnSync(process.execPath, [cliPath, "dashboard"], { cwd: projectRoot, encoding: "utf-8" })` so the test does not change the test runner's global working directory. + +- [x] **Step 2: Run the command test and verify RED** + +Run: + +```sh +npm run build +node --test dist/cli/__tests__/commands.test.js +``` + +Expected: FAIL because `commands/dashboard.ts` uses its own older five-column renderer. + +- [x] **Step 3: Use the canonical renderer** + +Import `printDashboard` from `../render.js`, delete the local duplicate renderer, and remove the now-unused TUI imports. Do not change JSON output. + +- [x] **Step 4: Run the focused test and verify GREEN** + +Run the same focused test. Expected: PASS. + +### Task 5: Full verification and self-review + +**Files:** +- Review all files changed above + +- [x] **Step 1: Run static and full release checks** + +```sh +npm run typecheck +npx tsc --noEmit --noUnusedLocals --noUnusedParameters +npm run release:check +``` + +Expected: all commands exit zero. If pre-existing unused-code failures outside the touched files remain, do not broaden scope silently; report them separately. + +- [x] **Step 2: Check repository state** + +```sh +git diff --check +git status --short +git diff --stat +``` + +Expected: no whitespace errors, no generated `dist` files tracked, and only scoped source/test/plan changes. + +- [x] **Step 3: Self-review** + +Confirm each of the four original failures has a regression test, no score policy changed, `.omv/` remains opt-in to automatic `.gitignore` modification through `--gitignore`, command JSON shapes are unchanged, and no unrelated refactor was introduced. + +Do not commit; return the working-tree changes to the controller for independent review. diff --git a/docs/superpowers/specs/2026-07-08-omv-first-campaign-plan-design.md b/docs/superpowers/specs/2026-07-08-omv-first-campaign-plan-design.md new file mode 100644 index 0000000..c1b84aa --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-omv-first-campaign-plan-design.md @@ -0,0 +1,329 @@ +# omv first Campaign Plan Design + +Date: 2026-07-08 +Status: superseded by Campaign.v1 + +> Historical record only. The implemented design uses `Campaign.v1` under `.omv/campaigns/`, has no campaign notes file or target-specific TypeScript profile, and seed creates candidate Evidence only (no ThreatMap or force overwrite). + +## Summary + +Add `omv first` as the first-mile workflow for a vulnerability research campaign. The command turns a user's high-level research goal into a scoped, machine-readable `Plan.v1` file and a human-readable Markdown runbook that Claude Code can follow. + +The feature fills the gap between workspace setup and finding-level evidence. Today, `omv` manages `Evidence.v1`, `ThreatMap.v1`, `Verification.v1`, reports, and submissions, but it has no task-level object that records the research target, scope, expected output, attack surface, candidate lanes, and execution order. + +## Goals + +- Provide a guided starting point for users who know the target but need a structured research plan. +- Generate both machine-readable and human-readable campaign artifacts. +- Keep the first version conservative: plan generation first, optional seed finding creation second. +- Make the output directly usable by Claude Code as a one-stop campaign runbook. +- Preserve local-first safety boundaries: passive research, local validation, and no live third-party testing. + +## Non-Goals + +- Do not automatically audit code or execute PoCs. +- Do not automatically promote findings to `confirmed`. +- Do not replace `Evidence.v1`, `ThreatMap.v1`, or `Verification.v1`. +- Do not make `dashboard` depend on a plan existing. +- Do not create a large autonomous agent scheduler in the first version. + +## User Experience + +### Primary Interactive Flow + +```sh +omv first +``` + +The command asks a short series of questions and then writes a campaign plan: + +1. Target name, such as `Zimbra`, `npm package`, or `Java web app`. +2. Target version, code path, package path, repository URL, or install artifact. +3. Audit mode: `whitebox`, `graybox`, `local-lab`, `passive`, or `mixed`. +4. Expected output: `course-report`, `cve`, `vuldb`, `internal-report`, or `research-notes`. +5. Time budget: `quick`, `standard`, or `deep`. +6. Priority vulnerability classes, such as `xss`, `ssrf`, `authz`, `upload`, `parser`, `deser`, `xxe`, or `infoleak`. +7. Whether local reproduction will be available. + +After completion, it prints the generated paths and the recommended next action: + +```text +Created campaign plan: zimbra-latest-hunt + .omv/plans/zimbra-latest-hunt.yaml + .omv/plans/zimbra-latest-hunt.md + .omv/notes/zimbra-latest-hunt.md + +Next: review the runbook, then initialize seed findings with: + omv first seed zimbra-latest-hunt +``` + +### Non-Interactive Flow + +The command also accepts flags so future automation can skip prompts: + +```sh +omv first \ + --target zimbra \ + --version 10.1.19 \ + --mode whitebox \ + --goal course-report \ + --budget standard \ + --vuln xss,authz,ssrf,parser \ + --local-lab yes +``` + +Missing required values fall back to prompts unless `--no-interactive` is set. With `--no-interactive`, missing required values fail fast with a clear error. + +## Files + +Campaign artifacts live under `.omv/plans/`: + +```text +.omv/plans/.yaml +.omv/plans/.md +.omv/notes/.md +``` + +The YAML file is the source of truth for CLI features. The Markdown file is the human and Claude Code runbook. The notes file is an append-friendly campaign notebook. + +Add path helpers: + +```text +plansDir(projectRoot) +planPath(id, projectRoot) +planRunbookPath(id, projectRoot) +``` + +`ensureWorkspaceDirs()` should create `.omv/plans/`. + +## Plan.v1 + +Add `contracts/plan.v1.yaml` with this shape: + +```yaml +schema: Plan.v1 +id: zimbra-latest-hunt +title: Zimbra latest-version vulnerability hunt +created_at: "2026-07-08" +updated_at: "2026-07-08" +status: active + +target: + name: Zimbra + version: "10.1.19" + source: + type: local-path + value: /path/to/source-or-unpacked-artifact + environment: local-lab + +scope: + mode: whitebox + boundaries: + - local lab only + - no live third-party testing + assumptions: + - source review is the primary discovery method + +goals: + output: course-report + success_criteria: + - attack surface map completed + - candidate findings recorded with evidence gaps + - confirmed findings pass strict review before reporting + +budget: + depth: standard + timebox: unknown + +priorities: + vulnerability_classes: + - xss + - authz + - ssrf + - parser + +attack_surface: + - id: web-client + name: Web client rendering + status: planned + notes: Review user-controlled HTML, email body rendering, and sanitizer boundaries. + +candidate_lanes: + - id: zimbra-10119-classic-mail-render-xss + title: Classic mail rendering XSS variant review + vuln_class: xss + attack_surface: web-client + status: planned + seed_finding: true + +workflow: + strict_verification: true + steps: + - omv findings init + - omv threat-map init + - /omv-audit + - omv repro init + - /omv-repro + - omv verification init + - omv review --strict + +agent_pipeline: + - dataflow-tracer + - guard-checker + - dedup-analyst + - verifier + - omv-critic + +documentation: + outputs: + - attack-surface.md + - hunting-log.md + - ai-assisted-hunting.md +``` + +`id`, `target.name`, `scope.mode`, `goals.output`, and `priorities.vulnerability_classes` are required. Unknown values should be explicit strings such as `unknown`, not omitted. + +## Markdown Runbook + +Generate `.omv/plans/.md` from the YAML. It should include: + +- Campaign scope and safety boundaries. +- Success criteria and stopping conditions. +- Attack surface checklist. +- Candidate lanes with suggested finding IDs. +- OMV execution flow for each lane. +- Suggested agent pipeline. +- Documentation outline for the final deliverable. +- A "Next Actions" section with concrete commands. + +For the Zimbra example, the runbook should naturally include lanes like: + +```text +zimbra-10119-classic-mail-render-xss +zimbra-10119-attachment-preview +zimbra-10119-admin-soap-authz +zimbra-10119-proxy-mailboxd-boundary +``` + +The generator should use generic defaults for unknown targets and stronger target-specific defaults only when the target name matches a known profile. + +## Commands + +### `omv first` + +Create a new campaign plan. By default, prompts interactively. + +Flags: + +```text +--target +--version +--source +--mode whitebox|graybox|local-lab|passive|mixed +--goal course-report|cve|vuldb|internal-report|research-notes +--budget quick|standard|deep +--vuln +--local-lab yes|no|unknown +--id +--force +--json +--no-interactive +``` + +### `omv first list` + +List campaign IDs, target names, statuses, and next actions. + +### `omv first show ` + +Show the campaign summary and paths. With `--json`, return the parsed `Plan.v1`. + +### `omv first seed ` + +Optional command that creates findings and threat maps for `candidate_lanes` where `seed_finding: true`. + +It should be conservative: + +- Create only planned seed findings from the plan. +- Keep seeded findings at `status: candidate`. +- Treat candidate lanes as hypotheses, not evidence; do not fabricate source, sink, guard, reproducer, or observed result values. +- Skip existing findings unless `--force` is provided. +- Initialize matching threat maps. +- Never run `/omv-audit`, `/omv-repro`, or verification automatically. + +## Data Flow + +```text +user answers / flags + -> normalize campaign input + -> derive campaign id + -> build Plan.v1 object + -> render Markdown runbook + -> write .omv/plans/.yaml + -> write .omv/plans/.md + -> write .omv/notes/.md + -> append workspace activity +``` + +`omv first seed ` then reads the plan and delegates to the existing finding and threat-map initialization helpers. + +## Safety and Error Handling + +- Refuse unsupported audit modes with a validation error. +- Refuse invalid campaign IDs that cannot safely become filenames. +- If a campaign already exists, fail unless `--force` is set. +- If `--no-interactive` is set and required inputs are missing, fail instead of guessing. +- If target-specific defaults are unavailable, generate a generic plan instead of inventing facts. +- Seeded findings must preserve unknown evidence fields until `/omv-audit` or `/omv-repro` supplies real values. +- If seed finding creation partially succeeds, report created and skipped items separately. +- Keep campaign notes and reproduction artifacts local by default. + +## Dashboard Integration + +MVP does not require dashboard integration, but the YAML contract should make it easy later. + +Future dashboard additions can show: + +```text +active campaign +target +planned attack surfaces +seed findings created / planned +findings by status +next campaign action +``` + +## Testing + +Add focused tests for: + +- Argument validation for `omv first`, `first list`, `first show`, and `first seed`. +- Interactive fallback behavior through a testable prompt abstraction. +- Non-interactive missing required fields. +- Campaign ID normalization. +- YAML and Markdown file creation. +- `--force` overwrite behavior. +- Listing and showing multiple campaign plans. +- Seed creation skips existing findings by default. +- Seed creation initializes matching threat maps. +- JSON output stability. + +Add at least one snapshot-like golden assertion for a Zimbra latest-version campaign runbook. + +## Documentation Updates + +Update: + +- `README.md`: add `omv first` to the fast workflow before finding creation. +- `README.zh-CN.md`: add a short Chinese first-mile workflow. +- `contracts/README.md`: list `plan.v1.yaml`. +- `skills/omv/SKILL.md`: add `/omv first` delegation guidance. + +## Rollout + +Implement in two steps: + +1. Plan generation: `omv first`, `first list`, `first show`, `Plan.v1`, and docs. +2. Seeding: `omv first seed ` and optional dashboard awareness. + +This keeps the first release useful without making the system over-automated. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..333ad96 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,35 @@ +schema: spec-driven + +context: | + Project: oh-my-vul — a modular collection of LLM-friendly vulnerability research skills for Claude Code. + + Tech stack: + - TypeScript CLI (src/cli/) built with tsc; entry point dist/cli/omv.js + - Python scripts (shared/scripts/, skills/*/scripts/) using stdlib only — no third-party deps + - Skill definitions: Markdown files (SKILL.md) with YAML frontmatter, installed to ~/.claude/skills/ + - Contracts: YAML schemas (contracts/*.yaml) defining typed handoff objects between skills + + Distribution: npm package `oh-my-vul`, installed via `npx oh-my-vul setup` + Skills installed to: ~/.claude/skills/ (user scope) or ./.claude/skills/ (project scope) + + Domain: passive CVE/VulDB security research — find open-source packages with potential vulnerabilities, + build source→sink→guard evidence chains, generate advisory reports. No live exploitation. + + Key conventions: + - Skill files are concise; ecosystem-specific detail lives in references/ subdirectories + - Scripts use only Python stdlib (no pip install required in skill sessions) + - Contracts define the typed boundary between omv-find (output) and omv-report (input) + - Evidence.v1 is the canonical finding schema: candidate → confirmed → blocked statuses + - Validate with: python3 scripts/validate_skill.py; release with: python3 scripts/release_check.py + + Active skills: omv, omv-find, omv-audit, omv-repro, omv-report, omv-radar, omv-dedup, + omv-disclose, and omv-critic. + +rules: + proposal: + - Keep proposals under 400 words + - Always include a Non-goals section + - Specify whether changes touch CLI (TypeScript), skills (Markdown), scripts (Python), or contracts (YAML) + tasks: + - Group tasks by file/component (one group per file changed) + - Include a validation task (validate_skill.py or typecheck) and a reinstall task (omv setup --force) in every change that touches skills or CLI diff --git a/openspec/specs/campaign-workflow/spec.md b/openspec/specs/campaign-workflow/spec.md new file mode 100644 index 0000000..c1e5c43 --- /dev/null +++ b/openspec/specs/campaign-workflow/spec.md @@ -0,0 +1,150 @@ +# campaign-workflow Specification + +## Purpose +TBD - created by archiving change add-campaign-workflow. Update Purpose after archive. +## Requirements +### Requirement: Campaigns have a validated local contract +The system SHALL represent a research campaign as a closed-schema `Campaign.v1` YAML object under `.omv/campaigns/.yaml` with a paired Markdown runbook at `.omv/campaigns/.md`, and SHALL reject malformed, calendar-invalid, non-normalized, semantically inconsistent, or undeclared fields before returning or using the object. + +#### Scenario: Valid Campaign object +- **WHEN** a campaign contains schema version 1, a safe id, target name, supported enum values, at least one normalized vulnerability class, and matching lanes +- **THEN** campaign validation succeeds and returns the typed normalized object + +#### Scenario: Invalid Campaign object +- **WHEN** a campaign YAML file is malformed or omits a required value +- **THEN** campaign validation fails with the artifact path and actionable field errors + +#### Scenario: Noncanonical or undeclared Campaign data +- **WHEN** a campaign contains uppercase unknown markers, whitespace-padded normalized text, an invented title, missing baseline safety boundaries, an impossible calendar timestamp, or an undeclared root, nested, or lane field +- **THEN** campaign validation fails before list, show, or seed returns the object + +### Requirement: Campaign initialization is deterministic and conservative +The system SHALL require a non-empty target and at least one vulnerability class; normalize text, ids, and vulnerability-class slugs; deduplicate classes while preserving first-seen order; and create one generic lane and deterministic finding id per class. Omitted optional values SHALL default to `mode: passive`, `goal.output: research-notes`, `budget.depth: standard`, `status: active`, `profile: generic`, and `unknown` for target version, source, ecosystem, and local reproduction. + +#### Scenario: Initialize with required flags +- **WHEN** the user initializes a campaign with a target and a comma-separated vulnerability-class list +- **THEN** the CLI writes one YAML source of truth and one Markdown runbook with deterministic lanes and finding ids + +#### Scenario: Normalize duplicate classes +- **WHEN** vulnerability classes differ only by whitespace, case, or slug punctuation +- **THEN** initialization preserves first-seen order and creates exactly one lane for each normalized class + +#### Scenario: Safe public defaults +- **WHEN** the user initializes target `Acme` with vulnerability classes `XSS, xss` and omits every optional value +- **THEN** the Campaign contains one `xss` lane and every documented safe default + +#### Scenario: Unknown version is omitted from derived id +- **WHEN** target `Acme` has an omitted or explicit `unknown` version and no explicit id +- **THEN** the derived campaign id is `acme` rather than `acme-unknown` + +#### Scenario: Unsafe explicit id +- **WHEN** an explicit campaign id contains unsafe filename characters +- **THEN** initialization fails before either Campaign artifact is written + +#### Scenario: Existing campaign is protected +- **WHEN** either campaign artifact already exists and initialization does not include `--force` +- **THEN** the CLI fails without overwriting either artifact + +#### Scenario: Concurrent initialization is serialized +- **WHEN** two no-force initializers race to create the same Campaign id +- **THEN** exactly one commits the YAML/runbook pair and the other fails without overwriting it + +#### Scenario: Dangling or external symlink is not followed +- **WHEN** a Campaign destination is a symlink, including a dangling symlink or a symlink to an external file +- **THEN** no-force initialization treats it as a collision and force replacement replaces only the directory entry without modifying the symlink target + +#### Scenario: Explicit overwrite +- **WHEN** the user repeats initialization with `--force` +- **THEN** the CLI replaces both campaign artifacts from the newly normalized Campaign object + +#### Scenario: Pair replacement rolls back on failure +- **WHEN** staging or committing either Campaign artifact fails +- **THEN** initialization leaves no partial new pair and restores any force-replaced artifact entries + +#### Scenario: Activity failure is non-fatal +- **WHEN** the YAML/runbook pair commits but workspace activity cannot be appended +- **THEN** initialization succeeds with a warning that names the activity error + +### Requirement: Campaign initialization supports safe interactive and non-interactive modes +The system SHALL obtain missing required initialization values through an injectable prompt adapter only in an interactive terminal, and SHALL never prompt when `--no-interactive` or `--json` is present. + +#### Scenario: Interactive required values +- **WHEN** an interactive user initializes without a target or vulnerability classes +- **THEN** the prompt adapter supplies the missing required values before normalization and persistence + +#### Scenario: JSON is non-interactive +- **WHEN** initialization includes `--json` but omits a required value +- **THEN** the CLI returns an error without invoking the prompt adapter or writing an artifact + +#### Scenario: Successful JSON initialization +- **WHEN** initialization supplies a target and vulnerability classes with `--json` but without `--no-interactive` +- **THEN** the CLI never invokes the prompt adapter, writes the YAML/runbook pair, and emits exactly one JSON document + +#### Scenario: Non-TTY input is non-interactive +- **WHEN** required values are missing and the command streams are not interactive terminals +- **THEN** the CLI fails with the missing fields instead of waiting for input + +### Requirement: Campaigns can be listed and shown without a workspace index +The system SHALL scan `.omv/campaigns/*.yaml` directly for list and show operations, sort campaign summaries by id, and SHALL NOT add Campaign records to `WorkspaceIndex`. + +#### Scenario: List campaigns +- **WHEN** multiple valid campaign YAML files exist +- **THEN** `omv campaign list` returns sorted summaries containing ids, targets, statuses, lane counts, and next actions + +#### Scenario: Duplicate Campaign source pair +- **WHEN** both `.yaml` and `.yml` exist for the same Campaign id +- **THEN** list and show fail with an actionable duplicate-source error instead of returning inconsistent identities + +#### Scenario: List missing campaign directory +- **WHEN** `.omv/campaigns/` does not exist +- **THEN** campaign listing returns an empty result without requiring an index rebuild + +#### Scenario: Show campaign as JSON +- **WHEN** the user runs `omv campaign show --json` +- **THEN** stdout is one JSON document containing the parsed Campaign object and its artifact paths + +#### Scenario: Unknown ecosystem has an actionable prerequisite +- **WHEN** init, list, show, or runbook rendering handles a Campaign whose target ecosystem is `unknown` +- **THEN** its next action tells the user to set a supported ecosystem before running seed + +### Requirement: Campaign command aliases preserve canonical behavior +The system SHALL make `omv first [flags]` an alias of `omv campaign init`, and SHALL map `omv first init|list|show|seed` to the corresponding canonical campaign subcommands while `omv campaign` defaults to `list`. + +#### Scenario: First without subcommand +- **WHEN** the user runs `omv first --target acme --vuln xss --no-interactive` +- **THEN** the same Campaign artifacts and result are produced as by the canonical `campaign init` command + +#### Scenario: Canonical command without subcommand +- **WHEN** the user runs `omv campaign` +- **THEN** the CLI performs the campaign list operation + +### Requirement: Campaign seeding creates hypotheses only +The system SHALL validate the complete Campaign before writes, require a known Evidence-compatible ecosystem, create at most one valid candidate `Evidence.v1` file per lane, never overwrite an existing `.yaml` or `.yml` finding, and MUST NOT create ThreatMap, reproduction, verification, audit, proof-of-concept, or report artifacts. Seed SHALL have no force mode. + +#### Scenario: Seed campaign lanes +- **WHEN** a valid campaign with a known Evidence-compatible ecosystem has unseeded lanes +- **THEN** `omv campaign seed ` creates candidate findings containing only target identity, ecosystem, vulnerability class, and explicit unknown evidence fields + +#### Scenario: Existing YAML or YML finding is skipped +- **WHEN** a lane's `.yaml` or `.yml` finding path already exists +- **THEN** seeding preserves the existing file byte-for-byte and reports the lane as skipped + +#### Scenario: Unknown ecosystem blocks seeding +- **WHEN** a campaign target ecosystem is `unknown` +- **THEN** seeding fails before creating any finding and asks for an explicit supported ecosystem + +#### Scenario: Seed output has no Campaign coupling or proof artifacts +- **WHEN** seeding completes +- **THEN** created Evidence files contain the deterministically mapped `researcher_goal` and no `campaign_id`, tested version remains unknown, proof fields remain unknown, and no other lane artifact path exists + +#### Scenario: Partial seed failure is structured and retryable +- **WHEN** one lane encounters an I/O error after other lanes were created or skipped +- **THEN** the result reports created, skipped, and failed ids with messages, and rerunning remains idempotent + +### Requirement: Campaign profiles remain data-driven +Every generated `Campaign.v1` SHALL use `profile: generic` and derive lanes solely from normalized user input. The CLI MUST NOT branch on target names. + +#### Scenario: Named target receives no built-in content +- **WHEN** a user initializes a Zimbra campaign with only the `xss` vulnerability class +- **THEN** the Campaign contains only the generic `xss` lane and no built-in Zimbra attack-surface claims + diff --git a/openspec/specs/cli-command-validation/spec.md b/openspec/specs/cli-command-validation/spec.md new file mode 100644 index 0000000..82f4f52 --- /dev/null +++ b/openspec/specs/cli-command-validation/spec.md @@ -0,0 +1,185 @@ +# cli-command-validation Specification + +## Purpose +TBD - created by archiving change harden-evidence-workflow. Update Purpose after archive. +## Requirements +### Requirement: CLI rejects unknown commands and flags +The `omv` CLI SHALL reject unknown commands, subcommands, and flags with a non-zero exit code and actionable usage output, and SHALL accept `review` as a valid top-level command. + +#### Scenario: Unknown top-level command +- **WHEN** the user runs `omv unknown` +- **THEN** the CLI exits non-zero and prints the list of valid top-level commands + +#### Scenario: Unknown findings flag +- **WHEN** the user runs `omv findings validate --bogus` +- **THEN** the CLI exits non-zero and prints the valid flags for the `findings validate` command + +#### Scenario: Valid review command +- **WHEN** the user runs `omv review demo --strict --json` +- **THEN** the CLI parser accepts the command with finding id `demo`, strict mode enabled, and JSON output enabled + +### Requirement: CLI validates option values +The CLI SHALL validate required option values before executing command behavior. + +#### Scenario: Missing scope value +- **WHEN** the user runs `omv setup --scope` +- **THEN** the CLI exits non-zero and reports that `--scope` requires `user` or `project` + +#### Scenario: Missing status value +- **WHEN** the user runs `omv findings promote demo --status` +- **THEN** the CLI exits non-zero and reports that `--status` requires `candidate`, `confirmed`, or `blocked` + +#### Scenario: Invalid status value +- **WHEN** the user runs `omv findings init demo --status done` +- **THEN** the CLI exits non-zero before writing a file and reports the accepted status values + +### Requirement: CLI positional arguments are command-specific +The CLI SHALL validate extra and missing positional arguments for each command and subcommand. + +#### Scenario: Missing finding id +- **WHEN** the user runs `omv findings init` +- **THEN** the CLI exits non-zero and reports that a finding id is required + +#### Scenario: Extra positional argument +- **WHEN** the user runs `omv doctor extra` +- **THEN** the CLI exits non-zero and reports that `doctor` accepts no positional arguments + +#### Scenario: Missing review finding id +- **WHEN** the user runs `omv review` +- **THEN** the CLI exits non-zero and reports that a finding id is required + +### Requirement: CLI parser has unit coverage +Command parsing behavior SHALL be covered by tests that exercise valid and invalid arguments without requiring filesystem writes for parser-only failures. + +#### Scenario: Parser-only error does not write files +- **WHEN** an invalid parser-only command is tested +- **THEN** the test asserts the error and verifies no setup or finding files were created + +### Requirement: Representative CLI commands have process-level regression coverage +The CLI test suite SHALL execute representative compiled commands in child processes so routing, rendering, JSON output, and exit codes are validated together. + +#### Scenario: Human command output +- **WHEN** tests run a representative human-facing workflow command +- **THEN** they assert the canonical headings or columns and a zero exit code + +#### Scenario: JSON command output +- **WHEN** tests run a representative command with `--json` +- **THEN** they parse the complete stdout as JSON and assert stable core fields + +#### Scenario: Invalid command exit code +- **WHEN** tests invoke an unknown command through the compiled entrypoint +- **THEN** they assert a non-zero exit code and actionable error text + +### Requirement: TypeScript build rejects unused implementation residue +The TypeScript project SHALL enable unused-local and unused-parameter checks, and the release gate MUST compile with zero such diagnostics. + +#### Scenario: Release compilation +- **WHEN** `npm run typecheck` and the release build execute +- **THEN** no unused local, import, helper, or parameter diagnostic is emitted + +### Requirement: Command adapters use canonical renderers +When a human renderer exists in `src/cli/render.ts`, command adapters SHALL call it rather than maintain a second implementation of the same output. + +#### Scenario: Renderer changes have one owner +- **WHEN** setup, doctor, workspace, dashboard, repro, or report output is changed +- **THEN** the corresponding command adapter contains routing only and the canonical renderer owns the human formatting + +### Requirement: CLI validates campaign commands and aliases +The CLI SHALL accept `campaign` and `first` as top-level commands and validate both through one grammar. `init` accepts `--target`, `--version`, `--source`, `--ecosystem`, `--mode`, `--goal`, `--budget`, `--vuln`, `--local-lab`, `--id`, `--force`, `--no-interactive`, and `--json`; `list` accepts only `--json`; `show` and `seed` require exactly one id and accept only `--json`. The `first` aliases SHALL share the same subcommand grammar, while `first [flags]` maps to init. + +#### Scenario: Canonical campaign grammar +- **WHEN** the user runs `omv campaign init --target acme --vuln xss,authz --mode passive --goal research-notes --budget standard --local-lab unknown --no-interactive --json` +- **THEN** the parser accepts the command and its option values + +#### Scenario: First initialization alias +- **WHEN** the user runs `omv first --target acme --vuln xss --no-interactive` +- **THEN** the parser treats the arguments as campaign initialization options + +#### Scenario: Alias subcommand grammar +- **WHEN** the user runs `omv first show demo --json` or `omv first seed demo --json` +- **THEN** the parser accepts exactly one campaign id for the selected alias subcommand + +#### Scenario: Unknown campaign subcommand +- **WHEN** the user runs `omv campaign run demo` +- **THEN** the parser exits non-zero and lists `init`, `list`, `show`, and `seed` as valid subcommands + +#### Scenario: Seed rejects force +- **WHEN** the user runs `omv campaign seed demo --force` +- **THEN** the parser rejects `--force` before any finding can be overwritten + +#### Scenario: Alias seed rejects force +- **WHEN** the user runs `omv first seed demo --force` +- **THEN** the parser rejects `--force` before any finding can be overwritten + +#### Scenario: Init accepts force +- **WHEN** the user runs `omv campaign init --target acme --vuln xss --force --no-interactive` +- **THEN** the parser accepts force as an initialization-only flag + +#### Scenario: All explicit aliases match canonical parsing +- **WHEN** the user invokes `first init`, `first list`, `first show`, or `first seed` with arguments accepted by the matching canonical command +- **THEN** the parser accepts the alias with the same option and positional semantics + +#### Scenario: Init and list reject positionals +- **WHEN** the user supplies a positional argument to Campaign init or list +- **THEN** the parser exits non-zero before command behavior + +#### Scenario: Show and seed enforce exactly one id +- **WHEN** the user omits the id or supplies an extra id to Campaign show or seed +- **THEN** the parser exits non-zero before command behavior + +### Requirement: CLI validates campaign option values +The CLI SHALL accept modes `whitebox|graybox|local-lab|passive|mixed`, goals `course-report|cve|vuldb|internal-report|research-notes`, budgets `quick|standard|deep`, local reproduction values `yes|no|unknown`, and ecosystems `unknown` plus every Evidence-supported ecosystem. It SHALL validate these enums, option value presence, and command-specific positional arity before filesystem writes. + +#### Scenario: Invalid campaign enum +- **WHEN** the user supplies an unsupported value to `--mode`, `--goal`, `--budget`, or `--local-lab` +- **THEN** the parser exits non-zero and reports the accepted values + +#### Scenario: Missing option value +- **WHEN** the user supplies `--target`, `--version`, `--source`, `--ecosystem`, `--vuln`, or `--id` without a value +- **THEN** the parser exits non-zero and identifies the option that requires a value + +#### Scenario: Campaign list positional arity +- **WHEN** the user runs `omv campaign list extra` +- **THEN** the parser exits non-zero because list accepts no campaign id + +#### Scenario: Campaign show missing id +- **WHEN** the user runs `omv campaign show` +- **THEN** the parser exits non-zero because show requires exactly one campaign id + +### Requirement: CLI validates source provenance commands +The CLI SHALL accept `sources init|show|validate `; init accepts `--force` and `--json`, while show and validate accept only `--json`. Every subcommand requires exactly one safe finding id. + +#### Scenario: Valid source initialization +- **WHEN** the user runs `omv sources init demo --force --json` +- **THEN** parser validation succeeds before command behavior + +#### Scenario: Source command rejects extra arguments +- **WHEN** the user omits the id, adds an extra id, or passes `--force` to show or validate +- **THEN** parser validation exits non-zero before filesystem writes + +### Requirement: CLI validates report provenance commands +The CLI SHALL accept `omv report provenance [--force] [--json]` while preserving `report artifacts` behavior. + +#### Scenario: Valid provenance creation +- **WHEN** the user runs `omv report provenance demo --force --json` +- **THEN** parser validation accepts exactly one id and the documented flags + +#### Scenario: Unknown report subcommand +- **WHEN** the user runs an unsupported report subcommand +- **THEN** the parser lists `artifacts`, `provenance`, and `help` as valid commands + +### Requirement: CLI validates eval command options +The CLI SHALL accept `omv eval` with either no targeted options or the complete `--skill --eval-id --output ` set, plus at most one of `--json` and `--junit`. + +#### Scenario: Stable JSON invocation +- **WHEN** the user runs `omv eval --json` +- **THEN** parser validation succeeds with no positionals + +#### Scenario: Complete targeted invocation +- **WHEN** the user runs `omv eval --skill omv-find --eval-id 26 --output result.md --junit` +- **THEN** parser validation accepts the complete targeted set + +#### Scenario: Partial or conflicting invocation +- **WHEN** any targeted option is missing, eval id is not a non-negative integer, both output formats are selected, or an extra positional is present +- **THEN** parser validation exits non-zero before starting Python + diff --git a/openspec/specs/disclosure-lifecycle/spec.md b/openspec/specs/disclosure-lifecycle/spec.md new file mode 100644 index 0000000..7c8b3b9 --- /dev/null +++ b/openspec/specs/disclosure-lifecycle/spec.md @@ -0,0 +1,44 @@ +# disclosure-lifecycle Specification + +## Purpose +TBD - created by archiving change advance-intelligence-disclosure-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: Disclosure template generation +`/omv-disclose` SHALL generate responsible disclosure email templates from a finding for initial contact, follow-up, and deadline reminder flows. + +#### Scenario: Vendor category selects template +- **WHEN** the finding identifies a vendor as an individual maintainer, company, or foundation +- **THEN** the skill selects the corresponding disclosure template and fills package, impact, affected version, and reproduction summary fields + +### Requirement: Disclosure Evidence writeback +The disclosure workflow SHALL update Evidence.v1 disclosure fields after user confirmation. + +#### Scenario: Contact date is recorded +- **WHEN** the user confirms that a vendor contact was sent +- **THEN** the CLI records `disclosure.vendor_contacted`, `disclosure.contact_date`, and `disclosure.planned_disclosure_date` + +### Requirement: Disclosure timeline +`omv disclose timeline ` SHALL print key disclosure dates for a default 90-day window and supported custom windows. + +#### Scenario: Default timeline is printed +- **WHEN** the user runs `omv disclose timeline demo` +- **THEN** the CLI prints initial contact, 45-day follow-up, 7-day reminder, and planned disclosure dates + +### Requirement: Submission records +`omv submissions` SHALL record and track platform submission metadata under `.omv/submissions/.yaml`. + +#### Scenario: Submission is recorded +- **WHEN** the user runs `omv submissions record demo --platform vuldb --submission-id 12345 --url https://example.test/submission/12345` +- **THEN** the CLI writes a local submission record associated with finding `demo` + +#### Scenario: Submission is closed with CVE +- **WHEN** the user runs `omv submissions close demo --cve CVE-2026-12345` +- **THEN** the submission record is marked closed and the CVE identifier is available to archive workflows + +### Requirement: Archive includes submission summary +Strict archive flows SHALL include submission status when a finding has submission records. + +#### Scenario: Reported archive preserves submission metadata +- **WHEN** a finding with submission records is archived as reported +- **THEN** the archive output includes the platform, submission id, URL, status, and CVE when present + diff --git a/openspec/specs/evidence-contract-validation/spec.md b/openspec/specs/evidence-contract-validation/spec.md new file mode 100644 index 0000000..f853b38 --- /dev/null +++ b/openspec/specs/evidence-contract-validation/spec.md @@ -0,0 +1,64 @@ +# evidence-contract-validation Specification + +## Purpose +TBD - created by archiving change harden-evidence-workflow. Update Purpose after archive. +## Requirements +### Requirement: Structured Evidence YAML parsing +The CLI SHALL parse Evidence.v1 files with a structured YAML parser that preserves nested objects, lists, quoted strings, inline objects, comments, and multiline scalar values. + +#### Scenario: Quoted comment marker is preserved +- **WHEN** a finding contains `dedup.notes: "searched #security advisory"` +- **THEN** validation reads the full string value and does not truncate at `#` + +#### Scenario: Multiline observed result is preserved +- **WHEN** `evidence.observed_result` is a YAML block scalar with multiple lines +- **THEN** validation treats it as a non-empty observed result and preserves the text when promoting or rewriting status + +### Requirement: Evidence contract validation +`omv findings validate` SHALL validate Evidence.v1 against machine-enforced field rules in addition to readiness scoring. + +#### Scenario: Invalid enum values fail validation +- **WHEN** a finding uses an unsupported `package.ecosystem`, `status`, `cvss.severity`, or impact enum value +- **THEN** validation returns FAIL with a field-specific error + +#### Scenario: Invalid structured fields fail validation +- **WHEN** a finding contains malformed repository URLs, dates, CVSS v3.1 vectors, or CWE identifiers +- **THEN** validation returns FAIL with a field-specific error + +### Requirement: Confirmed findings require strict evidence gates +The CLI SHALL reject `confirmed` findings unless all confirmed-required evidence fields are known, structurally valid, and consistent with the Evidence.v1 contract. When graph or verification sidecars are present, the CLI SHALL include their validation results in readiness warnings; when strict verification is enabled, the CLI SHALL require a passing Verification.v1 sidecar before recommending report generation. + +#### Scenario: Confirmed finding lacks file-line evidence +- **WHEN** a confirmed finding has `evidence.source`, `evidence.sink`, or `evidence.guard` without a `file:line` reference or an explicit "not present" guard explanation +- **THEN** validation returns FAIL and explains which field lacks traceable evidence + +#### Scenario: Confirmed finding has unknown observed result +- **WHEN** a confirmed finding has `evidence.observed_result: unknown` +- **THEN** validation returns FAIL even if readiness would otherwise reach the threshold + +#### Scenario: Strict verification blocks report recommendation +- **WHEN** a confirmed finding passes Evidence.v1 validation but strict verification is enabled and Verification.v1 is missing, stale, or failing +- **THEN** doctor/readiness output does not recommend `/omv-report` until verification passes + +### Requirement: Unknown-field accounting is enforced +The CLI SHALL compare `unknown` values with `provenance.unverified_fields` and warn or fail according to status. + +#### Scenario: Candidate finding has untracked unknown values +- **WHEN** a candidate finding has unknown evidence, CVSS, dedup, disclosure, or version fields not listed in `provenance.unverified_fields` +- **THEN** validation emits warnings naming the missing field paths + +#### Scenario: Confirmed finding has untracked unknown values in required fields +- **WHEN** a confirmed finding has unknown values in required evidence, version, CVSS, or dedup fields +- **THEN** validation returns FAIL and names the unverified required field paths + +### Requirement: Status promotion uses validation gates +`omv findings promote` SHALL write the requested status only when the target file can satisfy the target status gates, unless an explicit blocked status is being set with blockers. + +#### Scenario: Promotion to confirmed is rejected +- **WHEN** the user runs `omv findings promote demo --status confirmed` and required confirmed evidence is missing +- **THEN** the file remains at its previous status and the CLI prints validation errors + +#### Scenario: Promotion to blocked requires blockers +- **WHEN** the user runs `omv findings promote demo --status blocked` and `blockers` is empty +- **THEN** validation returns FAIL and the CLI tells the user to add at least one blocker + diff --git a/openspec/specs/finding-archive-management/spec.md b/openspec/specs/finding-archive-management/spec.md new file mode 100644 index 0000000..15fb03c --- /dev/null +++ b/openspec/specs/finding-archive-management/spec.md @@ -0,0 +1,42 @@ +# finding-archive-management Specification + +## Purpose +TBD - created by archiving change local-first-findings-manager. Update Purpose after archive. +## Requirements +### Requirement: CLI archives findings +The CLI SHALL archive findings by moving Evidence.v1 YAML files out of the active `.omv/findings/` queue while preserving their contents. + +#### Scenario: Archive confirmed finding +- **WHEN** the user runs `omv findings archive demo --reason reported` +- **THEN** the CLI moves `.omv/findings/demo.yaml` to `.omv/archive/findings/demo.yaml` and records the archive reason in workspace metadata + +#### Scenario: Archive candidate finding +- **WHEN** the user runs `omv findings archive demo --reason abandoned` +- **THEN** the CLI archives the finding and prints that it will no longer appear in active workflow views + +#### Scenario: Archive destination conflict +- **WHEN** the archive destination already exists and `--force` is not provided +- **THEN** the CLI fails without overwriting either file and prints the conflicting path + +### Requirement: CLI lists archived findings +The CLI SHALL provide an archive list command for reviewing inactive findings. + +#### Scenario: List archive +- **WHEN** the user runs `omv findings archive list` +- **THEN** the CLI prints archived IDs, original status, archive reason, archived timestamp, package, and vulnerability + +#### Scenario: List archive as JSON +- **WHEN** the user runs `omv findings archive list --json` +- **THEN** the CLI emits archived finding summaries with paths and archive metadata + +### Requirement: CLI restores archived findings +The CLI SHALL restore archived findings to the active queue on request. + +#### Scenario: Restore archived finding +- **WHEN** the user runs `omv findings restore demo` +- **THEN** the CLI moves `.omv/archive/findings/demo.yaml` back to `.omv/findings/demo.yaml` and refreshes the workspace index + +#### Scenario: Restore conflict +- **WHEN** an active finding with the same ID already exists +- **THEN** the CLI fails without overwriting and suggests `--force` only if the user intends replacement + diff --git a/openspec/specs/finding-lifecycle-management/spec.md b/openspec/specs/finding-lifecycle-management/spec.md new file mode 100644 index 0000000..b4f1327 --- /dev/null +++ b/openspec/specs/finding-lifecycle-management/spec.md @@ -0,0 +1,42 @@ +# finding-lifecycle-management Specification + +## Purpose +TBD - created by archiving change local-first-findings-manager. Update Purpose after archive. +## Requirements +### Requirement: CLI computes finding next actions +The CLI SHALL compute a `next_action` for each active finding from Evidence.v1 status, readiness, required fields, and report/archive state. + +#### Scenario: Candidate missing audit evidence +- **WHEN** a finding has `status: candidate` and missing source/sink/guard evidence +- **THEN** `omv findings list --workflow` recommends `/omv-audit ` and lists the missing evidence fields + +#### Scenario: Candidate ready for reproduction +- **WHEN** a candidate has source/sink/guard/reproducer/CVSS filled but `evidence.observed_result` is `unknown` +- **THEN** `omv findings list --workflow` recommends `/omv-repro ` + +#### Scenario: Confirmed finding ready for report +- **WHEN** a finding has `status: confirmed` and passes validation +- **THEN** `omv findings list --workflow` recommends `/omv-report ` + +### Requirement: CLI exposes a lifecycle dashboard +The CLI SHALL provide an active workflow view over `.omv/findings/` without requiring users to inspect YAML manually. + +#### Scenario: Workflow dashboard table +- **WHEN** the user runs `omv findings workflow` +- **THEN** the CLI prints ID, STATUS, READY, NEXT ACTION, PACKAGE, and VULNERABILITY for active findings + +#### Scenario: Workflow dashboard JSON +- **WHEN** the user runs `omv findings workflow --json` +- **THEN** the CLI returns each active finding with `id`, `status`, `readiness`, `nextAction`, `missingFields`, and `path` + +### Requirement: CLI records lifecycle timestamps +The CLI SHALL record project-management timestamps for lifecycle actions without weakening Evidence.v1 validation. + +#### Scenario: Finding initialized +- **WHEN** `omv findings init ` creates a finding +- **THEN** the workspace index records `createdAt` and `updatedAt` for that finding + +#### Scenario: Finding promoted +- **WHEN** `omv findings promote --status confirmed` succeeds +- **THEN** the workspace index updates `updatedAt` and records the new status + diff --git a/openspec/specs/findings-skill-delegation/spec.md b/openspec/specs/findings-skill-delegation/spec.md new file mode 100644 index 0000000..e425bfa --- /dev/null +++ b/openspec/specs/findings-skill-delegation/spec.md @@ -0,0 +1,82 @@ +# findings-skill-delegation Specification + +## Purpose +Define how agents delegate local findings management commands to the `omv` CLI instead of manually writing research-state files. +## Requirements +### Requirement: Agent delegates omv findings init to CLI +When the user invokes `omv findings init ` (or `/omv findings init `), the agent SHALL run `omv findings init ` as a shell command and display its output. The agent SHALL NOT manually create directories or write YAML files. + +#### Scenario: Init creates a finding file via CLI +- **WHEN** the user types `omv findings init demo` +- **THEN** the agent runs `Bash("omv findings init demo")` and prints the CLI output (path, status, next steps) + +#### Scenario: Init with explicit status flag +- **WHEN** the user types `omv findings init demo --status confirmed` +- **THEN** the agent runs `Bash("omv findings init demo --status confirmed")` + +#### Scenario: Init with duplicate id +- **WHEN** `omv findings init demo` is run and `.omv/findings/demo.yaml` already exists +- **THEN** the agent surfaces the CLI error and suggests `--force` flag + +### Requirement: Agent delegates omv findings list to CLI +When the user invokes `omv findings list` (or `/omv findings`), the agent SHALL run `omv findings list` as a shell command and display its tabular output. + +#### Scenario: List with existing findings +- **WHEN** the user invokes `/omv findings` or `omv findings list` +- **THEN** the agent runs `Bash("omv findings list")` and prints the ID/STATUS/READY/PACKAGE/VULNERABILITY table + +#### Scenario: List with no findings +- **WHEN** no `.omv/findings/*.yaml` files exist +- **THEN** the agent runs `Bash("omv findings list")` and surfaces the CLI's "No findings yet" message + +### Requirement: Agent delegates omv findings validate to CLI +When the user invokes `omv findings validate [id]`, the agent SHALL run `omv findings validate [id]` as a shell command. On non-zero exit, the agent SHALL surface the errors and suggest which fields to fill. + +#### Scenario: Validate a single finding +- **WHEN** the user types `omv findings validate demo` +- **THEN** the agent runs `Bash("omv findings validate demo")` and displays OK/FAIL output + +#### Scenario: Validate all findings +- **WHEN** the user types `omv findings validate` with no id +- **THEN** the agent runs `Bash("omv findings validate")` for the whole ledger + +### Requirement: Agent delegates omv findings promote to CLI +When the user invokes `omv findings promote --status `, the agent SHALL run `omv findings promote --status ` as a shell command. + +#### Scenario: Promote to confirmed +- **WHEN** the user types `omv findings promote demo --status confirmed` +- **THEN** the agent runs `Bash("omv findings promote demo --status confirmed")` and displays the validation result + +#### Scenario: Promote without --status flag +- **WHEN** the user invokes `omv findings promote demo` without a `--status` argument +- **THEN** the agent surfaces the CLI error requiring `--status` and lists valid values + +### Requirement: Agent handles missing omv binary gracefully +If the `omv` binary is not found on PATH, the agent SHALL tell the user to run `npx oh-my-vul setup` to install it. + +#### Scenario: omv binary not installed +- **WHEN** any `omv findings *` command is run and the shell returns "command not found" +- **THEN** the agent outputs: "omv is not installed. Run: npx oh-my-vul setup" + +### Requirement: Agent delegates workspace workflow commands to CLI +When the user invokes `/omv status`, `/omv next`, or equivalent workflow commands, the agent SHALL run the corresponding CLI command and display its output. + +#### Scenario: Workspace status delegation +- **WHEN** the user invokes `/omv status` +- **THEN** the agent runs `omv workspace status` + +#### Scenario: Next action delegation +- **WHEN** the user invokes `/omv next` +- **THEN** the agent runs `omv findings workflow` + +### Requirement: Agent delegates archive commands to CLI +When the user invokes archive or restore operations through `/omv`, the agent SHALL run the matching `omv findings` command instead of moving files itself. + +#### Scenario: Archive delegation +- **WHEN** the user invokes `/omv archive demo --reason reported` +- **THEN** the agent runs `omv findings archive demo --reason reported` + +#### Scenario: Restore delegation +- **WHEN** the user invokes `/omv restore demo` +- **THEN** the agent runs `omv findings restore demo` + diff --git a/openspec/specs/local-workspace-management/spec.md b/openspec/specs/local-workspace-management/spec.md new file mode 100644 index 0000000..b015a97 --- /dev/null +++ b/openspec/specs/local-workspace-management/spec.md @@ -0,0 +1,46 @@ +# local-workspace-management Specification + +## Purpose +TBD - created by archiving change local-first-findings-manager. Update Purpose after archive. +## Requirements +### Requirement: CLI initializes a local OMV workspace +The CLI SHALL provide a workspace initialization command that creates the local `.omv/` directory structure, including `.omv/campaigns/` and `.omv/sources/`, without requiring network access or a global install state, and SHALL preserve existing finding, campaign, and source files. + +#### Scenario: Initialize empty repository +- **WHEN** the user runs `omv workspace init` in a repository without `.omv/` +- **THEN** the CLI creates `.omv/findings/`, `.omv/campaigns/`, `.omv/sources/`, `.omv/archive/findings/`, and `.omv/index.json` + +#### Scenario: Initialize existing workspace +- **WHEN** the user runs `omv workspace init` and `.omv/` already exists +- **THEN** the CLI preserves existing finding, campaign, and source files and refreshes the finding index + +#### Scenario: Preserve campaign artifacts +- **WHEN** the user runs `omv workspace init` in a workspace with campaign YAML and Markdown files +- **THEN** the CLI leaves all campaign artifacts unchanged while refreshing finding index state + +#### Scenario: Preserve source artifacts +- **WHEN** the user runs `omv workspace init` in a workspace with SourceRef.v1 files +- **THEN** the CLI leaves all source artifacts byte-for-byte unchanged + +#### Scenario: Workspace index remains finding-specific +- **WHEN** campaign or source operations run +- **THEN** `.omv/index.json` contains no campaign or source records; ordinary finding operations retain their existing index behavior + +### Requirement: CLI reports workspace status +The CLI SHALL provide a workspace status command that summarizes local project state from `.omv/`. + +#### Scenario: Status with active and archived findings +- **WHEN** the user runs `omv workspace status` +- **THEN** the CLI prints workspace path, active finding count, archived finding count, status counts, and stale-index state + +#### Scenario: Status JSON output +- **WHEN** the user runs `omv workspace status --json` +- **THEN** the CLI emits machine-readable workspace metadata including `root`, `findingsDir`, `archiveDir`, `activeCount`, and `archivedCount` + +### Requirement: Workspace state remains private by default +The workspace commands SHALL treat `.omv/` as local research state and MUST NOT publish or sync it. + +#### Scenario: Workspace init updates ignore guidance +- **WHEN** `omv workspace init` creates `.omv/` +- **THEN** the CLI warns if `.omv/` is not ignored by the repository and suggests adding it to `.gitignore` + diff --git a/openspec/specs/methodology-first-guidance/spec.md b/openspec/specs/methodology-first-guidance/spec.md new file mode 100644 index 0000000..bdc55fa --- /dev/null +++ b/openspec/specs/methodology-first-guidance/spec.md @@ -0,0 +1,33 @@ +# methodology-first-guidance Specification + +## Purpose +TBD - created by archiving change methodology-first-security-guidance. Update Purpose after archive. +## Requirements +### Requirement: Public guidance is methodology-first +Skill docs, shared references, and walkthroughs SHALL teach repeatable security research methods rather than concrete real-world vulnerability walkthroughs. + +#### Scenario: Skill explains a vulnerability class +- **WHEN** a skill describes SSRF, traversal, deserialization, XSS, or another vulnerability class +- **THEN** it explains source patterns, sink behavior, guard expectations, evidence criteria, and false-positive checks without naming a real vulnerable package as the lesson + +### Requirement: Sanitized fixtures are clearly marked +Tests, evals, golden outputs, and walkthrough examples SHALL use clearly synthetic package names and advisory identifiers unless they are only validating a format. + +#### Scenario: Golden output includes an advisory-like identifier +- **WHEN** a golden output needs an advisory or CVE-like value +- **THEN** the surrounding text marks it as sanitized, synthetic, demo, example, or fixture data + +### Requirement: Real user findings remain allowed +The system SHALL allow users to provide and process real package names, real CVEs, and real advisory links during active research. + +#### Scenario: User asks about a real finding +- **WHEN** a user-provided Evidence.v1 file contains a real package or CVE +- **THEN** the workflow may analyze it while keeping generated guidance focused on method and evidence quality + +### Requirement: Release checks guard public examples +Release validation SHALL detect likely real package/CVE tutorial content in public skill docs, shared references, and golden outputs unless explicitly allowed as format-only or sanitized fixture text. + +#### Scenario: Public golden names a real CVE as an example +- **WHEN** release checks scan a golden output containing a real CVE-style identifier without sanitized context +- **THEN** the check fails with an actionable message + diff --git a/openspec/specs/omv-audit-core/spec.md b/openspec/specs/omv-audit-core/spec.md new file mode 100644 index 0000000..aa9d741 --- /dev/null +++ b/openspec/specs/omv-audit-core/spec.md @@ -0,0 +1,124 @@ +# omv-audit-core Specification + +## Purpose +Define the core `/omv-audit` workflow for turning Evidence.v1 candidate files into confirmed or blocked local audit findings. +## Requirements +### Requirement: omv-audit 接受 Evidence.v1 candidate 文件作为输入 +`/omv-audit ` 命令 SHALL 读取 `.omv/findings/.yaml`,验证 `status` 为 `candidate`,并从文件中提取 `package`、`vulnerability.class`、已知的 `evidence.source/sink/guard` 字段作为 audit 起点。 + +#### Scenario: 正常输入 candidate 文件 +- **WHEN** 用户执行 `/omv-audit npm-markdown-it-include-traversal` +- **THEN** agent 读取 `.omv/findings/npm-markdown-it-include-traversal.yaml`,确认 status 为 candidate,展示当前已填字段概览,开始 audit 流程 + +#### Scenario: 文件不存在 +- **WHEN** 指定的 id 对应的文件不存在于 `.omv/findings/` +- **THEN** agent 输出错误提示,建议先运行 `omv findings list` 查看可用文件,并停止执行 + +#### Scenario: 文件 status 不是 candidate +- **WHEN** 文件 status 已为 `confirmed` 或 `blocked` +- **THEN** agent 询问用户是否要重新审计,默认不覆盖已有结论 + +### Requirement: omv-audit 执行完整的五步审计流程 +agent SHALL 按序执行:(1) 数据流追踪、(2) guard 验证、(3) PoC 构造思路、(4) CVSS v3.1 初步评分、(5) NVD/GHSA 去重检索,并将结果逐步写入 Evidence.v1 对应字段。 + +#### Scenario: 数据流追踪成功 +- **WHEN** agent 委托 dataflow-tracer 分析源文件 +- **THEN** `evidence.source`、`evidence.sink`、`evidence.guard` 三个字段被填写为带 `file:line` 引用的具体值,置信度标注为 high / medium / low + +#### Scenario: 数据流无法追踪 +- **WHEN** 源文件不可访问或 fetch budget 耗尽 +- **THEN** agent 在 `blockers` 中添加 `"source to sink not proven"`,将 status 设为 `blocked`,并在 audit 摘要中说明原因 + +#### Scenario: PoC 构造思路描述 +- **WHEN** source→sink→guard 已确认 +- **THEN** agent 在 `evidence.reproducer` 字段填写本地复现的命令或步骤描述(仅描述,不自动执行),例如 `"构造包含 !!!include(../../../../etc/passwd)!!! 的 markdown 文件,调用插件渲染"` + +#### Scenario: CVSS 评分 +- **WHEN** 漏洞类别和影响面已明确 +- **THEN** agent 委托 cvss-analyst,读取 `shared/references/cvss-builder.md`,填写 `cvss.vector`、`cvss.score`、`cvss.severity`,并给出每个度量选择的一句话说明 + +#### Scenario: 去重检索 +- **WHEN** audit 流程进入去重步骤 +- **THEN** agent 检索 NVD(cve.mitre.org)、GHSA(github.com/advisories)和生态系统数据库,填写 `dedup.nvd_searched`、`dedup.ghsa_searched`、`dedup.ecosystem_db_searched`,若发现疑似重复则填写 `dedup.existing_cve` 并将 status 设为 `blocked` + +### Requirement: omv-audit 输出 confirmed 或 blocked 的 Evidence.v1 文件 +完成审计流程后,agent SHALL update `.omv/findings/.yaml` only to a status that passes the machine Evidence.v1 validation gates. `confirmed` requires concrete source/sink/guard/reproducer/observed_result evidence, a valid CVSS vector, completed dedup fields, and readiness >= 75. `evidence.observed_result` 字段若无法在 passive 模式下通过本地执行验证,SHALL 保留为 `unknown` 并在 `provenance.unverified_fields` 中注明,由后续 `/omv-repro` 填写;不得推断或编造观测结果。 + +#### Scenario: 审计结论为 confirmed(含 observed_result) +- **WHEN** source/sink/guard/reproducer/observed_result/cvss/dedup 全部已填,字段结构有效,readiness >= 75 +- **THEN** agent 将 status 更新为 `confirmed`,运行 `omv findings validate ` 输出 OK,提示用户可运行 `/omv-report` + +#### Scenario: 审计结论为 confirmed(observed_result 待复现) +- **WHEN** source/sink/guard/reproducer/cvss 已填,但 `evidence.observed_result` 为 `unknown` +- **THEN** agent 在 `provenance.unverified_fields` 中记录 `evidence.observed_result`,保持 status 为 `candidate`,运行 `omv findings validate `,提示用户运行 `/omv-repro ` 完成本地复现 + +#### Scenario: 审计结论为 blocked +- **WHEN** 任意必填字段无法确认,或发现疑似重复 CVE +- **THEN** agent 将 status 更新为 `blocked`,在 `blockers` 列表中逐条说明每个阻断原因,运行 `omv findings validate ` 输出 FAIL(预期行为) + +#### Scenario: readiness 在 50–74 区间 +- **WHEN** 大部分字段已填但未达到 75 分门槛 +- **THEN** agent 保持 status 为 `candidate`,展示缺失项清单,建议用户补充后重新运行 `/omv-audit` 或运行 `/omv-repro` + +#### Scenario: confirmed validation fails after audit +- **WHEN** agent attempts to mark a finding `confirmed` but `omv findings validate ` returns FAIL +- **THEN** agent reverts or keeps status as `candidate`, reports the validation errors, and does not suggest `/omv-report` + +### Requirement: omv-audit 按漏洞类别加载专项操作手册 +agent SHALL 根据 `vulnerability.class` 字段,从 `references/audit-playbook.md` 中仅加载对应漏洞类别的操作节,不加载无关章节。 + +#### Scenario: traversal 类漏洞 +- **WHEN** `vulnerability.class` 为路径穿越相关值 +- **THEN** agent 加载 audit-playbook.md 的 `## Path Traversal` 节,按其中的代码阅读路径和 guard 验证方法执行审计 + +#### Scenario: 未知漏洞类别 +- **WHEN** `vulnerability.class` 字段为空或不在 playbook 覆盖范围内 +- **THEN** agent 加载 audit-playbook.md 的 `## General` 通用节,并提示用户补充漏洞类别信息 + +### Requirement: omv-audit 严格保持 passive 模式 +agent SHALL 不对任何线上服务发送攻击请求,不自动执行 PoC,不生成可直接部署的 exploit 代码,并 SHALL not claim any local observation unless the Evidence file already contains a verified observed result or the user explicitly supplied one. + +#### Scenario: 尝试生成 exploit +- **WHEN** 审计过程中需要描述 PoC +- **THEN** agent 仅输出本地测试的命令描述或代码片段,明确标注"仅用于本地研究环境",不发送任何网络请求到目标服务 + +#### Scenario: Passive audit cannot observe runtime behavior +- **WHEN** audit analysis produces a plausible reproducer but no user-reported execution output exists +- **THEN** agent leaves `evidence.observed_result` as `unknown`, records it in `provenance.unverified_fields`, and keeps status `candidate` + +### Requirement: Audit can produce ThreatMap.v1 +`omv-audit` SHALL be able to produce `.omv/threatmaps/.yaml` when evidence supports a graph representation. + +#### Scenario: Audit maps source to sink +- **WHEN** audit identifies source, transform, sink, and guard evidence +- **THEN** it records those relationships in ThreatMap.v1 format or explains why no threat map was produced + +### Requirement: Audit appends notebook decisions +Audit workflows SHALL append key decisions to the research notebook when notebook recording is enabled. + +#### Scenario: Confirmation is logged +- **WHEN** audit changes a finding recommendation from candidate to confirmed +- **THEN** the notebook records the confidence, key evidence path, and remaining unverified fields + +### Requirement: Audit consumes pattern registry +`omv-audit` SHALL consult the relevant ecosystem sink registry when tracing source-to-sink paths. + +#### Scenario: Ecosystem-specific guards are considered +- **WHEN** auditing a Go SSRF candidate +- **THEN** audit considers Go-specific HTTP client sinks and guard patterns from the Go registry + +### Requirement: omv-audit emits lifecycle next-action guidance +After updating or validating a finding, `/omv-audit` SHALL tell the user which lifecycle command should run next based on CLI validation output. + +#### Scenario: Audit leaves candidate pending reproduction +- **WHEN** audit fills source/sink/guard/reproducer/CVSS but leaves `evidence.observed_result` as `unknown` +- **THEN** the agent tells the user to run `/omv-repro ` and `omv findings workflow` + +#### Scenario: Audit blocks finding +- **WHEN** audit sets a finding to `blocked` +- **THEN** the agent tells the user the finding can be archived with `omv findings archive --reason blocked` after reviewing blockers + +#### Scenario: Audit confirms finding +- **WHEN** audit confirms a finding and `omv findings validate ` passes +- **THEN** the agent tells the user to run `/omv-report ` + diff --git a/openspec/specs/omv-dedup-analysis/spec.md b/openspec/specs/omv-dedup-analysis/spec.md new file mode 100644 index 0000000..e06c2ff --- /dev/null +++ b/openspec/specs/omv-dedup-analysis/spec.md @@ -0,0 +1,55 @@ +# omv-dedup-analysis Specification + +## Purpose +TBD - created by archiving change advance-intelligence-disclosure-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: Finding-based dedup workflow +`/omv-dedup ` SHALL read a local Evidence.v1 finding and derive deterministic search queries for NVD, GHSA, OSV, and the relevant ecosystem advisory database. + +#### Scenario: Queries are shown before conclusions +- **WHEN** dedup runs for a finding with package, vulnerability type, affected versions, and sink evidence +- **THEN** the skill outputs source-specific query strings before assigning duplicate risk + +### Requirement: Dedup writeback +The system SHALL write dedup investigation results into Evidence.v1 dedup fields only after user confirmation. + +#### Scenario: User confirms dedup update +- **WHEN** the user confirms the generated dedup findings +- **THEN** the CLI updates fields such as `dedup.nvd_searched`, `dedup.ghsa_searched`, `dedup.ecosystem_db_searched`, `dedup.existing_cve`, and `dedup.notes` + +#### Scenario: User rejects dedup update +- **WHEN** the user rejects or edits the dedup conclusion +- **THEN** the original Evidence.v1 file remains unchanged unless the user supplies an explicit replacement + +### Requirement: Duplicate risk grading +Dedup output SHALL include a CNA duplicate-risk grade of `High`, `Medium`, or `Low` with reasons tied to matching advisories. + +#### Scenario: Likely duplicate is flagged +- **WHEN** an advisory matches the same package, vulnerability class, affected range, and sink behavior +- **THEN** dedup marks duplicate risk `High` and identifies the matching CVE or advisory + +#### Scenario: Weak overlap is not overclaimed +- **WHEN** advisories match only package name or only broad vulnerability class +- **THEN** dedup marks risk no higher than `Medium` and explains the missing overlap + +### Requirement: Dedup eval coverage +The dedup skill SHALL include deterministic eval coverage for sanitized duplicate fixtures and novel-looking findings. + +#### Scenario: Public CVE fixture is recognized +- **WHEN** a golden fixture represents a known disclosed vulnerability +- **THEN** the checker verifies that dedup reports `likely duplicate` or `High` duplicate risk + +### Requirement: Dedup guidance explains comparison methodology +Dedup guidance SHALL focus on constructing source-specific queries and comparing package identity, affected range, vulnerability class, sink behavior, guard/fix overlap, and advisory provenance. + +#### Scenario: Dedup risk is high +- **WHEN** dedup marks duplicate risk `High` +- **THEN** the explanation cites matching dimensions rather than teaching from a real public CVE case study + +### Requirement: Dedup evals use sanitized duplicates +Dedup evals and golden outputs SHALL use sanitized duplicate fixtures instead of known public CVE findings. + +#### Scenario: Duplicate fixture is checked +- **WHEN** a dedup checker validates a likely duplicate +- **THEN** it verifies methodology fields such as queries, overlap dimensions, risk grade, and writeback behavior + diff --git a/openspec/specs/omv-find-fetch-strategy/spec.md b/openspec/specs/omv-find-fetch-strategy/spec.md new file mode 100644 index 0000000..7a822ee --- /dev/null +++ b/openspec/specs/omv-find-fetch-strategy/spec.md @@ -0,0 +1,78 @@ +# omv-find-fetch-strategy Specification + +## Purpose +Define manifest-first source resolution and bounded fetch behavior for `omv-find` candidate source inspection. +## Requirements +### Requirement: Agent reads registry manifest before fetching npm source files +For npm ecosystem candidates, the agent SHALL fetch `https://registry.npmjs.org/` once per candidate to obtain the registry manifest before attempting any source file fetch. The agent SHALL extract the `main` field and `repository.url` from the manifest to determine the authoritative source file path. + +#### Scenario: npm package with main field +- **WHEN** the agent needs to inspect source for an npm package (e.g., `markdown-it-include`) +- **THEN** the agent fetches `https://registry.npmjs.org/markdown-it-include` first, reads `versions..main` or `main`, constructs the raw GitHub URL, and fetches that file directly — without probing other paths + +#### Scenario: main field points to dist/compiled output +- **WHEN** the manifest `main` field resolves to a `dist/`, `.min.js`, or bundled file +- **THEN** the agent SHALL fall back to `src/index.*` or `index.js` at repo root before trying further variants + +#### Scenario: repository.url absent from manifest +- **WHEN** `repository.url` is missing or not a GitHub URL +- **THEN** the agent SHALL use `homepage` or `bugs.url` fields to derive the repository, and note the uncertainty in the source risk evidence + +### Requirement: Agent respects a fetch budget during source inspection +The agent SHALL limit source file fetching to a maximum of **3 files per candidate** and **2 fetch attempts per file**. When the budget is exhausted without a successful read, the agent SHALL record confidence as low and move to the next candidate. + +#### Scenario: First fetch attempt succeeds +- **WHEN** the constructed raw URL returns HTTP 200 +- **THEN** the agent proceeds with source inspection of that file and counts it as 1 file toward the budget + +#### Scenario: First fetch attempt returns 404 +- **WHEN** the primary URL returns 404 +- **THEN** the agent makes one fallback attempt (unpkg or jsdelivr CDN) and stops — it SHALL NOT probe additional path variants + +#### Scenario: Budget reached with no successful reads +- **WHEN** all 3 file slots are exhausted or both attempts per file return non-200 +- **THEN** the agent records source risk as "not inspected — fetch budget exhausted" and assigns low confidence to that candidate's source risk score + +### Requirement: resolve_source_path.py script provides authoritative npm source URL +A new `shared/scripts/resolve_source_path.py` script SHALL accept a package name via CLI and print the resolved GitHub raw URL(s) for its main source file, using only the Python standard library. + +#### Scenario: Successful resolution for an npm package +- **WHEN** called as `python3 resolve_source_path.py --ecosystem npm --pkg markdown-it-include` +- **THEN** the script prints a JSON object with `package`, `main_file`, `raw_url`, `fallback_urls`, and `registry_url` fields + +#### Scenario: Package not found on registry +- **WHEN** the registry returns 404 for the package name +- **THEN** the script prints `{"error": "package not found", "package": ""}` and exits with code 1 + +### Requirement: ecosystems.md documents fetch priority order for npm, PyPI, and Go +The `shared/references/ecosystems.md` SHALL include a "Source fetch priority" subsection for npm, PyPI, and Go ecosystems that specifies: (1) manifest-first, (2) CDN fallback, (3) direct GitHub with path from directory listing. + +#### Scenario: Agent loads ecosystems.md for npm candidate +- **WHEN** the agent reads the npm section of `ecosystems.md` +- **THEN** it finds a numbered fetch priority list that resolves ambiguity about which URL to try first + +#### Scenario: Agent loads ecosystems.md for PyPI candidate +- **WHEN** the agent reads the PyPI section of `ecosystems.md` +- **THEN** it finds guidance to read `https://pypi.org/pypi//json` and extract `info.project_urls["Source Code"]` or `info.home_page` before fetching source files + +### Requirement: Discovery consumes pattern registry +`omv-find` SHALL use ecosystem-specific sink registries to guide candidate source-to-sink hypotheses. + +#### Scenario: npm discovery uses npm sinks +- **WHEN** `/omv-find --lang npm --vuln ssrf` runs +- **THEN** candidate reasoning uses npm SSRF sink and guard guidance when available + +### Requirement: Discovery can pre-dedup candidates +`omv-find` SHALL optionally use dedup query planning to lower priority for likely already-disclosed candidates. + +#### Scenario: Candidate overlaps known CVE +- **WHEN** a candidate strongly matches a known advisory by package, vulnerability class, and affected range +- **THEN** discovery marks it as likely duplicate or lowers its ranking rather than presenting it as novel + +### Requirement: Discovery preserves passive boundary +Pattern and dedup-enhanced discovery SHALL use only passive metadata, source repositories, registries, and advisory databases. + +#### Scenario: No target probing during discovery +- **WHEN** discovery evaluates a package candidate +- **THEN** it does not send runtime requests to target services or execute proof-of-concept code + diff --git a/openspec/specs/omv-find-research-radar/spec.md b/openspec/specs/omv-find-research-radar/spec.md new file mode 100644 index 0000000..8865360 --- /dev/null +++ b/openspec/specs/omv-find-research-radar/spec.md @@ -0,0 +1,155 @@ +# omv-find-research-radar Specification + +## Purpose +Define research-radar behavior for `omv-find`, including portfolio lanes, pattern-pack discovery, diff signals, novelty checks, duplicate risk, audit readiness, and request diagnostics. + +## Requirements +### Requirement: Finder supports research portfolio lanes +`omv-find` SHALL classify strong candidates into research portfolio lanes while preserving the existing ranked table output. + +#### Scenario: Candidate has a quick local validation path +- **WHEN** a candidate has a small focused codebase, exact source->sink->guard evidence, and an obvious first unit test or harness +- **THEN** the finder marks it as a `fast-win` lane candidate and includes the local validation entry point + +#### Scenario: Candidate needs deeper review +- **WHEN** a candidate has meaningful impact or complex reachability but requires multi-file review before confidence is high +- **THEN** the finder marks it as a `deep-audit` lane candidate and explains the unresolved reachability question + +#### Scenario: Candidate is interesting because of recent change +- **WHEN** a candidate's recent public commit, release, or file change adds or weakens a risky parser, loader, fetcher, renderer, importer, upload path, or guard +- **THEN** the finder marks it as a `diff-alert` lane candidate with exact public change evidence or `未确认` if the evidence could not be verified + +#### Scenario: Candidate has understated supply-chain reach +- **WHEN** a candidate has low stars but verified downloads, dependents, importers, or toolchain usage that suggest meaningful downstream exposure +- **THEN** the finder marks it as an `underrated` lane candidate and cites the verified reach signal + +### Requirement: Finder supports pattern-pack discovery +`omv-find` SHALL support research playbooks that combine ecosystem hints, vulnerability classes, source types, sinks, and guards. + +#### Scenario: User requests an archive extraction playbook +- **WHEN** the user asks for archive extractor targets or equivalent natural-language scope +- **THEN** the finder prioritizes packages with archive entry names, filenames, extraction helpers, filesystem write sinks, and path normalization guards + +#### Scenario: User requests rendering pipeline targets +- **WHEN** the user asks for markdown, HTML, template, or renderer targets +- **THEN** the finder prioritizes packages with renderer inputs, sanitizer or sandbox boundaries, and HTML/template execution sinks + +#### Scenario: Playbook spans multiple vulnerability aliases +- **WHEN** a playbook maps to more than one vulnerability class +- **THEN** the finder records the playbook name separately from `vuln_direction` instead of collapsing it into a single alias + +### Requirement: Finder uses bounded passive diff signals +`omv-find` SHALL use recent public repository changes as an optional ranking signal without exceeding explicit source inspection budgets. + +#### Scenario: Recent risky commit is verifiable +- **WHEN** public metadata identifies a recent commit or release that touches a risky file or guard +- **THEN** the finder includes the commit, file, or release reference in the candidate's risk evidence or radar context + +#### Scenario: Diff evidence cannot be verified cheaply +- **WHEN** rate limits, missing metadata, non-GitHub hosting, or budget limits prevent diff verification +- **THEN** the finder records the diff signal as `未确认` and does not let it dominate the candidate score + +### Requirement: Finder scores novelty and duplicate resistance +`omv-find` SHALL adjust candidate ranking based on passive duplicate and novelty signals. + +#### Scenario: Public advisory appears to match the same issue +- **WHEN** advisory, CVE, GHSA, issue, or release-note evidence strongly matches the same package, vulnerability class, affected behavior, and sink +- **THEN** the finder lowers the candidate score or marks it as likely duplicate with a concise reason + +#### Scenario: Package name overlaps but behavior does not match +- **WHEN** only package-name overlap is found without matching vulnerability class and sink behavior +- **THEN** the finder does not mark the candidate as duplicate and records the duplicate check as inconclusive + +### Requirement: Finder emits audit-readiness notes +`omv-find` SHALL include concise audit-readiness notes for high-ranked candidates. + +#### Scenario: Candidate has enough evidence for local review +- **WHEN** a candidate includes exact source->sink->guard evidence +- **THEN** the finder includes the first file/function to inspect, one local unit test or harness idea, the guard expected to accept or reject the input, and any blocker that prevents confirmation + +#### Scenario: Candidate lacks exact source evidence +- **WHEN** a candidate is metadata-strong but source evidence is missing or weak +- **THEN** the finder marks audit readiness as low and explains which source path, guard, or reachability fact remains unverified + +### Requirement: Finder remains passive and compatible with existing fetch strategy +`omv-find` SHALL keep all research radar behavior passive, local, and compatible with existing source fetch requirements. + +#### Scenario: Radar mode inspects source +- **WHEN** the finder inspects source files for radar scoring +- **THEN** it follows the existing manifest-first source resolution and per-candidate fetch budget requirements + +#### Scenario: Radar output suggests next steps +- **WHEN** the finder recommends follow-up work +- **THEN** it limits next steps to local code review, local unit tests, fuzz harness ideas, sanitizer checks, path normalization traces, and similar non-destructive activities + +### Requirement: Finder helper scripts classify request failures +`omv-find` helper scripts SHALL report external request failures with stable refusal categories instead of opaque transport errors. + +#### Scenario: GitHub API rate limit blocks metadata +- **WHEN** a GitHub API request returns a rate-limit response such as HTTP 403 or HTTP 429 +- **THEN** the helper reports `rate_limited`, includes the HTTP status and URL, and continues with any available registry metadata or archive fallback + +#### Scenario: Source path is missing +- **WHEN** a raw source request or metadata request indicates HTTP 404 +- **THEN** the helper reports `not_found` instead of treating the candidate as disproven + +#### Scenario: Request needs authentication +- **WHEN** a GitHub API request can use `GITHUB_TOKEN` or `GH_TOKEN` +- **THEN** the helper uses the token for GitHub API calls while still supporting unauthenticated operation + +### Requirement: Finder source resolver emits stable fallbacks +`omv-find` source resolution helpers SHALL prefer manifest-derived fallbacks that reduce failed raw GitHub requests. + +#### Scenario: npm package exposes a tarball +- **WHEN** the npm registry manifest includes `dist.tarball` +- **THEN** the resolver includes `source_archive_url` so source inspection can fall back to the registry archive when raw GitHub is blocked or path resolution is uncertain + +#### Scenario: PyPI package exposes an sdist +- **WHEN** the PyPI metadata includes an sdist URL +- **THEN** the resolver includes `source_archive_url` so source inspection can fall back to the package archive + +#### Scenario: GitHub default branch is discoverable +- **WHEN** the resolver can read the GitHub repository metadata +- **THEN** it uses the repository default branch instead of assuming `main` + +#### Scenario: GitHub default branch is blocked +- **WHEN** default branch lookup is rate-limited or otherwise refused +- **THEN** the resolver records `default_branch_error`, falls back to `main`, and includes alternate branch fallback URLs + +### Requirement: CLI exposes request broker diagnostics +The `omv` CLI SHALL expose request health and single-URL fetch diagnostics for finder metadata sources. + +#### Scenario: User checks request health +- **WHEN** the user runs `omv request preflight` +- **THEN** the CLI checks representative public metadata sources, reports pass/warn/fail state, and records whether each result came from cache + +#### Scenario: User fetches one URL through the broker +- **WHEN** the user runs `omv request fetch ` +- **THEN** the CLI fetches the URL with the shared request classification rules, stores a local cache entry, and reports status, cache path, response size, body hash, and failure reason when applicable + +#### Scenario: User requests machine-readable output +- **WHEN** the user passes `--json` to a request broker command +- **THEN** the CLI emits structured JSON containing the same request status, cache, and failure fields used by the human-readable output + +#### Scenario: Cached response exists +- **WHEN** a fresh cache entry exists for the URL and `--refresh` is not passed +- **THEN** the CLI returns the cached result without making another network request + +#### Scenario: Response contains sensitive headers +- **WHEN** a fetched response includes headers such as `set-cookie`, `cookie`, or `authorization` +- **THEN** the CLI excludes those headers from request broker output and cache files + +#### Scenario: Source reports rate-limit headers +- **WHEN** a response includes rate-limit headers +- **THEN** the CLI emits a structured `rateLimit` object and a recommendation when the remaining quota is exhausted + +### Requirement: Documentation explains request reliability workflow +The project documentation SHALL explain how request broker diagnostics fit into finder workflows. + +#### Scenario: User reads README +- **WHEN** the user reads the project README +- **THEN** they find the request preflight and single-URL fetch commands, cache location, failure classes, token behavior, and a link to detailed request broker documentation + +#### Scenario: User reads best-practices guidance +- **WHEN** the user reads vulnerability research best-practices guidance +- **THEN** they find instructions to treat request refusals as research-state signals instead of proof that a candidate is invalid diff --git a/openspec/specs/omv-mcp-readonly/spec.md b/openspec/specs/omv-mcp-readonly/spec.md new file mode 100644 index 0000000..63c9157 --- /dev/null +++ b/openspec/specs/omv-mcp-readonly/spec.md @@ -0,0 +1,33 @@ +# omv-mcp-readonly Specification + +## Purpose +TBD - created by archiving change advance-intelligence-disclosure-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: Read-only MCP server +`omv-mcp` SHALL expose only read-only views backed by existing CLI commands. + +#### Scenario: MCP lists findings +- **WHEN** an MCP client requests findings +- **THEN** the server returns data equivalent to `omv findings list` without modifying `.omv/` + +### Requirement: Supported MCP views +The MCP server SHALL expose findings list/show/workflow/validate, radar brief, and submissions track views. + +#### Scenario: Submission tracking is exposed +- **WHEN** an MCP client requests submission status for a finding +- **THEN** the server returns data equivalent to `omv submissions track ` + +### Requirement: Write commands are excluded +The MCP server SHALL NOT expose commands that initialize, promote, archive, restore, refresh, record, close, or otherwise mutate local state. + +#### Scenario: Write request is rejected +- **WHEN** an MCP client asks for a state-changing command +- **THEN** the server returns an unsupported-operation error and leaves files unchanged + +### Requirement: Local privacy boundary +The MCP server SHALL document that it reads private `.omv/` workspace state and should be connected only to trusted local clients. + +#### Scenario: Server starts with privacy notice +- **WHEN** the MCP server starts +- **THEN** startup output or documentation identifies `.omv/` data as local private research state + diff --git a/openspec/specs/omv-radar-intelligence/spec.md b/openspec/specs/omv-radar-intelligence/spec.md new file mode 100644 index 0000000..8b16171 --- /dev/null +++ b/openspec/specs/omv-radar-intelligence/spec.md @@ -0,0 +1,62 @@ +# omv-radar-intelligence Specification + +## Purpose +TBD - created by archiving change advance-intelligence-disclosure-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: Radar watchlist configuration +The system SHALL support a project-local radar watchlist at `.omv/radar/watchlist.yaml` containing packages, keywords, vulnerability classes, and ecosystems to monitor. + +#### Scenario: Valid watchlist is loaded +- **WHEN** the user runs `omv radar refresh` with a valid watchlist +- **THEN** the CLI loads the configured watch entries and reports which passive sources will be queried + +#### Scenario: Missing watchlist is actionable +- **WHEN** the user runs `omv radar refresh` without `.omv/radar/watchlist.yaml` +- **THEN** the CLI exits with instructions for creating the watchlist and does not create empty event output + +### Requirement: Passive radar refresh +`omv radar refresh` SHALL collect passive changes from configured advisory feeds and package registries without sending requests to target project runtime endpoints or running proof-of-concept code. + +#### Scenario: Refresh records events +- **WHEN** configured feeds contain new matching advisories or releases +- **THEN** the CLI appends normalized records to `.omv/radar/events.jsonl` + +#### Scenario: Passive boundary is preserved +- **WHEN** a watch entry references a package repository or package name +- **THEN** radar reads only registries, advisory databases, and public metadata sources and does not probe package services + +### Requirement: Offline radar dry run +`omv radar refresh --dry-run` SHALL run without network access by using checked-in fixtures. + +#### Scenario: Fixture refresh is deterministic +- **WHEN** release checks run the radar dry-run fixture +- **THEN** the produced event summary is stable across runs + +### Requirement: Radar brief generation +`omv radar brief` SHALL summarize recent radar events into a concise local intelligence brief. + +#### Scenario: Weekly brief groups signals +- **WHEN** events include CVEs, GHSA or OSV advisories, new package versions, and suspected fix commits +- **THEN** the brief groups them by ecosystem, package, and signal type + +### Requirement: Radar schedule offer +The radar skill SHALL offer an opt-in recurring weekly refresh after a successful manual refresh. + +#### Scenario: User receives a schedule suggestion +- **WHEN** `/omv-radar` or `omv radar refresh` completes successfully +- **THEN** the output suggests a weekly Monday refresh without creating automation unless the user confirms + +### Requirement: Radar guidance focuses on signal taxonomy +Radar guidance SHALL explain advisory, release, suspected-fix, and watchlist signals as prioritization inputs rather than presenting specific active vulnerabilities. + +#### Scenario: Radar brief is generated +- **WHEN** radar produces a brief +- **THEN** the output groups signal types and recommends review priorities without framing a specific real vulnerability as a tutorial target + +### Requirement: Radar fixtures are synthetic +Radar offline fixtures SHALL use synthetic package names, repository names, and advisory URLs. + +#### Scenario: Dry-run fixture is emitted +- **WHEN** `omv radar refresh --dry-run` uses fixture data +- **THEN** event titles and URLs are clearly fixture or example data + diff --git a/openspec/specs/omv-repro-core/spec.md b/openspec/specs/omv-repro-core/spec.md new file mode 100644 index 0000000..ee464a0 --- /dev/null +++ b/openspec/specs/omv-repro-core/spec.md @@ -0,0 +1,88 @@ +# omv-repro-core Specification + +## Purpose +Define the `/omv-repro` workflow for guiding local reproduction and writing observed Evidence.v1 results without autonomous exploit execution. +## Requirements +### Requirement: omv-repro 接受包含 reproducer 的 Evidence.v1 文件作为输入 +`/omv-repro ` SHALL 读取 `.omv/findings/.yaml`,验证 `evidence.reproducer` 字段非空且非 `unknown`,并展示当前 `versions.tested`、`evidence.source`、`evidence.sink` 字段概览作为复现背景。 + +#### Scenario: 正常输入已有 reproducer 的文件 +- **WHEN** 用户执行 `/omv-repro npm-markdown-it-include-traversal` +- **THEN** agent 读取对应 YAML,确认 `evidence.reproducer` 非空,展示复现背景概览,进入引导流程 + +#### Scenario: reproducer 字段为空或 unknown +- **WHEN** `evidence.reproducer` 为空或值为 `unknown` +- **THEN** agent 停止执行,提示用户先运行 `/omv-audit ` 填写复现步骤描述 + +#### Scenario: observed_result 已填写 +- **WHEN** `evidence.observed_result` 已为非 `unknown` 值 +- **THEN** agent 展示已有结果,询问用户是否要重新复现,默认不覆盖 + +### Requirement: omv-repro 引导研究员完成本地复现 +agent SHALL 将 `evidence.reproducer` 分解为可执行的操作序列,告知用户每步需要执行的命令或操作,等待用户报告执行结果,不自动执行任何命令,不声称自己已经安装依赖、运行 PoC、读取文件、发送请求或观测输出。 + +#### Scenario: 环境准备阶段 +- **WHEN** 开始引导流程 +- **THEN** agent 读取 `versions.tested` 和 `package` 字段,提示用户安装对应版本(如 `npm install markdown-it-include@1.0.0`),并说明为何版本须与测试版本一致 + +#### Scenario: 逐步引导执行 +- **WHEN** 用户确认环境准备完成 +- **THEN** agent 将 reproducer 分解为编号步骤(如 "步骤 1:创建包含以下内容的测试文件..."),每步结束后等待用户确认 + +#### Scenario: 用户报告执行输出 +- **WHEN** 用户粘贴执行输出或描述观测结果 +- **THEN** agent 解读输出是否符合预期 sink 行为(如文件读取成功、HTTP 响应包含敏感内容),向用户追问关键指标(状态码、返回内容片段、错误信息),确认后格式化为标准观测描述 + +#### Scenario: Agent lacks user-reported output +- **WHEN** the user has not reported execution output or observation details +- **THEN** agent does not fill `evidence.observed_result` and asks the user for the missing local result + +### Requirement: omv-repro 处理复现失败场景 +agent SHALL 识别复现失败原因并引导用户排查,或在无法继续时将 status 设为 `blocked`。 + +#### Scenario: 版本不匹配导致行为差异 +- **WHEN** 用户报告的输出与预期不符,且版本与 `versions.tested` 不一致 +- **THEN** agent 提示用户切换至测试版本后重试,说明版本差异对可复现性的影响 + +#### Scenario: guard 触发,PoC 被拦截 +- **WHEN** 用户报告输出显示请求被过滤或抛出安全异常 +- **THEN** agent 更新 `evidence.guard` 字段为"存在且有效",重新评估漏洞结论,询问用户是否需要回到 `/omv-audit` 修订 guard 分析 + +#### Scenario: 无法在本地复现 +- **WHEN** 经多步调试后仍无法复现(环境依赖缺失、闭源依赖等) +- **THEN** agent 在 `blockers` 中添加 "无法本地复现:<具体原因>",将 status 更新为 `blocked`,运行 `omv findings validate `(预期 FAIL) + +### Requirement: omv-repro 将观测结果写入 Evidence.v1 并验证 +成功复现后,agent SHALL 将用户报告的格式化观测结果写入 `evidence.observed_result` without modifying `evidence.reproducer`, run `omv findings validate `, and only promote to `confirmed` when validation passes all confirmed gates. + +#### Scenario: 复现成功,readiness 达标 +- **WHEN** `evidence.observed_result` 写入后 readiness >= 75 and `omv findings validate ` returns OK +- **THEN** agent 输出 OK,将 status 更新为 `confirmed`(若当前为 candidate),提示用户运行 `/omv-report` + +#### Scenario: 复现成功,但其他字段缺失导致 readiness 不足 +- **WHEN** `evidence.observed_result` 写入后 readiness 在 50–74 区间或 validation reports missing confirmed evidence +- **THEN** agent 展示仍缺失的字段清单,建议用户回到 `/omv-audit` 补充,不更改 status + +#### Scenario: Reproducer remains read-only +- **WHEN** agent updates `evidence.observed_result`, `status`, `blockers`, or `provenance` +- **THEN** `evidence.reproducer` remains byte-for-byte equivalent unless the user explicitly asks to revise the reproducer outside `/omv-repro` + +#### Scenario: Validation rejects confirmed promotion +- **WHEN** observed result is present but confirmed validation fails +- **THEN** agent keeps status as `candidate`, reports validation errors, and does not suggest `/omv-report` + +### Requirement: omv-repro emits lifecycle next-action guidance +After reproduction updates a finding, `/omv-repro` SHALL tell the user which lifecycle command should run next based on CLI validation output. + +#### Scenario: Reproduction confirms finding +- **WHEN** reproduction records `evidence.observed_result` and validation passes for a confirmed finding +- **THEN** the agent tells the user to run `/omv-report ` and `omv findings workflow` + +#### Scenario: Reproduction blocks finding +- **WHEN** reproduction determines the finding cannot be reproduced and marks it `blocked` +- **THEN** the agent tells the user to review blockers and optionally run `omv findings archive --reason not-reproducible` + +#### Scenario: Reproduction remains incomplete +- **WHEN** reproduction records partial progress but validation still fails for missing audit fields +- **THEN** the agent tells the user to return to `/omv-audit ` and shows `omv findings workflow` as the canonical queue view + diff --git a/openspec/specs/pattern-pack-manifests/spec.md b/openspec/specs/pattern-pack-manifests/spec.md new file mode 100644 index 0000000..141a3e5 --- /dev/null +++ b/openspec/specs/pattern-pack-manifests/spec.md @@ -0,0 +1,34 @@ +# pattern-pack-manifests Specification + +## Purpose +TBD - created by archiving change add-pattern-pack-eval-runner. Update Purpose after archive. +## Requirements +### Requirement: PatternPack manifests cover every supported ecosystem +The repository SHALL contain one closed-schema PatternPack.v1 JSON manifest for each of npm, Python, Go, Rust, Java, Ruby, PHP, C#, Swift, Dart, Elixir, Perl, R, and Lua, with unique ids, aliases, a canonical Markdown reference, vulnerability classes, and consuming skills. + +#### Scenario: Complete manifest set +- **WHEN** release validation loads `shared/pattern-packs/*.json` +- **THEN** it finds exactly fourteen unique manifests matching the Evidence ecosystem set + +#### Scenario: Invalid manifest +- **WHEN** a manifest contains an unknown key, unsafe path, unsupported ecosystem, missing consumer, or duplicate id +- **THEN** validation fails and names the manifest and field + +### Requirement: PatternPack references remain method-oriented +Every manifest SHALL reference an existing ecosystem Markdown file whose entries contain source pattern, sink signature, common misuse, expected guard, evidence criteria, false-positive checks, and CWE fields without relying on named real vulnerable packages. + +#### Scenario: R and Lua coverage +- **WHEN** validation inspects the newly covered R and Lua packs +- **THEN** both references pass the same methodology markers as the other twelve ecosystems + +### Requirement: PatternPack consumers are synchronized from manifests +Skill asset synchronization SHALL copy each canonical manifest and referenced Markdown file into every declared consuming skill and SHALL fail check mode when a declared copy is missing or stale. + +#### Scenario: Add a new consumer in JSON +- **WHEN** a maintainer adds a valid skill name to a manifest consumer list and runs sync +- **THEN** the corresponding manifest and Markdown reference are copied without editing a Python path list + +#### Scenario: Installed skill is self-contained +- **WHEN** an omv-find or omv-audit skill is packaged +- **THEN** it contains all fourteen declared PatternPack manifests and references under its own directory + diff --git a/openspec/specs/pre-submission-critic/spec.md b/openspec/specs/pre-submission-critic/spec.md new file mode 100644 index 0000000..f2b60ad --- /dev/null +++ b/openspec/specs/pre-submission-critic/spec.md @@ -0,0 +1,51 @@ +# pre-submission-critic Specification + +## Purpose +TBD - created by archiving change advance-intelligence-disclosure-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: Critic reads evidence and threat map +`/omv-critic ` SHALL review Evidence.v1, any linked ThreatMap.v1 artifact, and any linked Verification.v1 artifact before report generation. + +#### Scenario: Critic receives full local context +- **WHEN** a finding has `.omv/findings/.yaml`, `.omv/threatmaps/.yaml`, and `.omv/verifications/.yaml` +- **THEN** the critic considers all three files in its rejection-risk analysis + +#### Scenario: Critic highlights failed verification +- **WHEN** Verification.v1 has `decision.status: fail` +- **THEN** the critic reports failed adversarial verification as a high-priority rejection risk + +### Requirement: Adversarial rejection reasons +The critic SHALL list three to five likely CNA or maintainer rejection reasons when the finding is not low risk. + +#### Scenario: Weak evidence is challenged +- **WHEN** a finding lacks observed local results or has unverified affected versions +- **THEN** the critic reports those gaps as likely rejection reasons + +### Requirement: Reject risk classification +The critic SHALL output `reject_risk` as `low`, `medium`, or `high` with suggested strengthening actions. + +#### Scenario: High-risk report is blocked from recommendation +- **WHEN** critic risk is `high` +- **THEN** the output does not recommend `/omv-report` until the listed strengthening actions are addressed + +### Requirement: Critic is distinct from doctor +The critic SHALL evaluate argument quality while `omv findings doctor` remains responsible for deterministic structural readiness. + +#### Scenario: Structurally valid finding can still be criticized +- **WHEN** a finding passes `omv findings doctor` but has weak novelty or impact reasoning +- **THEN** the critic can assign `medium` or `high` reject risk with rationale + +### Requirement: Critic output evaluates argument quality +Critic guidance SHALL evaluate report argument quality through rejection-risk dimensions such as novelty, version proof, source-to-sink clarity, local observation, CVSS overclaiming, and disclosure readiness. + +#### Scenario: Critic finds high risk +- **WHEN** a finding has weak evidence or duplicate risk +- **THEN** the critic lists methodological rejection reasons and strengthening actions rather than vulnerability-specific conclusions copied from a real case + +### Requirement: Critic examples are sanitized +Critic evals and golden outputs SHALL use sanitized finding ids and generic rejection reasons. + +#### Scenario: Critic golden is checked +- **WHEN** a critic golden output is validated +- **THEN** it includes `reject_risk` and method-based gaps without naming real packages or real CVEs + diff --git a/openspec/specs/request-broker-safety/spec.md b/openspec/specs/request-broker-safety/spec.md new file mode 100644 index 0000000..f915109 --- /dev/null +++ b/openspec/specs/request-broker-safety/spec.md @@ -0,0 +1,57 @@ +# request-broker-safety Specification + +## Purpose +TBD - created by archiving change harden-quality-and-request-broker. Update Purpose after archive. +## Requirements +### Requirement: Request broker accepts only public destinations +The request broker SHALL reject URLs with credentials, local hostnames, private or non-routable literal addresses, and DNS names that resolve to any non-public address before invoking the network fetch implementation. + +#### Scenario: Literal loopback is rejected +- **WHEN** a request targets `http://127.0.0.1/metadata` +- **THEN** the broker returns `ok: false` with failure reason `unsafe_destination` without calling `fetch` + +#### Scenario: DNS resolves to a private address +- **WHEN** the configured resolver returns a private address for an otherwise valid hostname +- **THEN** the broker returns `unsafe_destination` before calling `fetch` + +#### Scenario: Public destination proceeds +- **WHEN** the URL is HTTP(S) without credentials and all resolved addresses are public +- **THEN** the broker performs the request using the existing cache and retry behavior + +### Requirement: Redirects are bounded and revalidated +The request broker MUST follow redirects manually, MUST validate each resolved redirect destination with the same public-destination policy, and MUST stop after at most five redirect hops. + +#### Scenario: Redirect to private destination is rejected +- **WHEN** a public response redirects to a loopback or private destination +- **THEN** the broker returns `unsafe_destination` and does not request the redirect target + +#### Scenario: Redirect limit is exceeded +- **WHEN** responses continue redirecting beyond the configured maximum +- **THEN** the broker returns failure reason `too_many_redirects` + +#### Scenario: Cross-host redirect drops host-specific credentials +- **WHEN** a GitHub API request redirects to a different hostname +- **THEN** request headers are recomputed and the GitHub token is not sent to the new hostname + +### Requirement: Response reads are memory bounded +The request broker SHALL stream response bodies and SHALL stop reading once the configured maximum body size is exceeded. + +#### Scenario: Content length is already too large +- **WHEN** a response declares a `Content-Length` above the configured maximum +- **THEN** the broker returns `response_too_large` without buffering the body + +#### Scenario: Stream grows beyond the limit +- **WHEN** streamed chunks exceed the maximum despite a missing or smaller declared length +- **THEN** the broker cancels the body reader and returns `response_too_large` + +#### Scenario: Bounded response retains current metadata +- **WHEN** a response body is within the limit +- **THEN** the broker returns its byte count, SHA-256, preview, sanitized headers, and cache metadata as before + +### Requirement: Broker identity tracks package version +The default request User-Agent SHALL contain the installed `oh-my-vul` package version rather than a hard-coded historical version. + +#### Scenario: Versioned User-Agent +- **WHEN** the broker sends a request without `OMV_USER_AGENT` +- **THEN** its User-Agent contains the version from the installed package metadata + diff --git a/openspec/specs/research-notebook/spec.md b/openspec/specs/research-notebook/spec.md new file mode 100644 index 0000000..14920df --- /dev/null +++ b/openspec/specs/research-notebook/spec.md @@ -0,0 +1,33 @@ +# research-notebook Specification + +## Purpose +TBD - created by archiving change advance-intelligence-disclosure-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: Per-finding notebook file +The system SHALL maintain optional local research notes at `.omv/notes/.md`. + +#### Scenario: Notebook is created on first append +- **WHEN** a skill or CLI command records a decision for finding `demo` +- **THEN** `.omv/notes/demo.md` is created if it does not already exist + +### Requirement: Timestamped decision entries +Notebook entries SHALL include timestamp, actor or skill name, key decision, and referenced local files or source lines when available. + +#### Scenario: Audit appends decision +- **WHEN** audit confirms a source-to-sink path +- **THEN** the notebook entry records the time, `omv-audit`, the decision summary, and relevant Evidence or ThreatMap paths + +### Requirement: Notebook stays outside Evidence.v1 +Research notebooks SHALL NOT be required by Evidence.v1 validation or schema scoring. + +#### Scenario: Missing notebook does not fail validation +- **WHEN** a finding has no `.omv/notes/.md` +- **THEN** `omv findings validate ` does not fail because notes are absent + +### Requirement: Notebook privacy guidance +Documentation SHALL state that `.omv/notes/` is private local research state unless explicitly sanitized for sharing. + +#### Scenario: Walkthrough warns before disclosure reuse +- **WHEN** a documentation walkthrough quotes notebook material +- **THEN** it explains that sensitive local details must be sanitized before publication + diff --git a/openspec/specs/review-command/spec.md b/openspec/specs/review-command/spec.md new file mode 100644 index 0000000..12ceade --- /dev/null +++ b/openspec/specs/review-command/spec.md @@ -0,0 +1,53 @@ +# review-command Specification + +## Purpose +TBD - created by archiving change add-review-command. Update Purpose after archive. +## Requirements +### Requirement: Unified finding review command +The CLI SHALL provide `omv review [--strict] [--json]` as the primary pre-submission readiness command for a project-local finding. + +#### Scenario: Review reports ready finding +- **WHEN** a confirmed finding has valid Evidence.v1 data and no blocking readiness issues +- **THEN** `omv review ` reports a `ready` verdict and recommends the report step + +#### Scenario: Review supports JSON output +- **WHEN** the user runs `omv review --json` +- **THEN** the CLI prints structured JSON containing the finding id, verdict, report readiness, strict mode, next action, blockers, warnings, and underlying doctor result + +### Requirement: Review verdict classification +The review command SHALL classify findings into `ready`, `needs-repro`, `needs-audit`, `needs-verification`, or `blocked` using existing deterministic checks. + +#### Scenario: Missing reproduction evidence +- **WHEN** a finding has unknown or missing observed reproduction evidence +- **THEN** `omv review ` reports `needs-repro` + +#### Scenario: Missing audit evidence +- **WHEN** a finding has Evidence validation errors or missing source, sink, guard, or CVSS evidence needed for reporting +- **THEN** `omv review ` reports `needs-audit` + +#### Scenario: Blocked finding +- **WHEN** a finding has status `blocked` +- **THEN** `omv review ` reports `blocked` + +### Requirement: Strict adversarial verification gate +The review command SHALL require a passing, non-stale Verification.v1 sidecar only when `--strict` is provided. + +#### Scenario: Strict review without verification +- **WHEN** the user runs `omv review --strict` and no Verification.v1 sidecar exists +- **THEN** the CLI reports `needs-verification` and recommends `omv verification init ` or `/omv-critic ` + +#### Scenario: Non-strict review without verification +- **WHEN** the user runs `omv review ` without `--strict` and no Verification.v1 sidecar exists +- **THEN** the CLI does not fail solely because Verification.v1 is absent + +### Requirement: Review reuses existing readiness checks +The review implementation SHALL reuse existing finding doctor and sidecar validation helpers rather than implementing an independent Evidence, ThreatMap, Verification, or artifact validator. + +#### Scenario: Threat map warning is preserved +- **WHEN** finding doctor reports a ThreatMap consistency warning +- **THEN** `omv review ` includes that warning in the review output + +#### Scenario: Artifact warning is preserved +- **WHEN** finding doctor reports missing report or reproduction artifacts +- **THEN** `omv review ` includes that artifact issue in the review output + diff --git a/openspec/specs/skill-eval-gates/spec.md b/openspec/specs/skill-eval-gates/spec.md new file mode 100644 index 0000000..5a6830b --- /dev/null +++ b/openspec/specs/skill-eval-gates/spec.md @@ -0,0 +1,57 @@ +# skill-eval-gates Specification + +## Purpose +TBD - created by archiving change harden-evidence-workflow. Update Purpose after archive. +## Requirements +### Requirement: omv-audit behavioral evals +The repository SHALL include deterministic eval fixtures and checker logic for `omv-audit` behaviors that affect Evidence.v1 status and trust. + +#### Scenario: Incomplete evidence remains candidate +- **WHEN** an `omv-audit` output has source, sink, and guard but `evidence.observed_result` remains `unknown` +- **THEN** the checker requires status to remain `candidate` and requires `provenance.unverified_fields` to include `evidence.observed_result` + +#### Scenario: Duplicate advisory blocks promotion +- **WHEN** an `omv-audit` output identifies a likely duplicate CVE, GHSA, or ecosystem advisory +- **THEN** the checker requires status `blocked`, a populated `dedup.existing_cve` or duplicate note, and at least one blocker + +#### Scenario: Confirmed output must cite evidence +- **WHEN** an `omv-audit` output marks a finding confirmed +- **THEN** the checker requires source, sink, guard, reproducer, observed result, CVSS vector, and dedup fields to be concrete and traceable + +### Requirement: omv-repro behavioral evals +The repository SHALL include deterministic eval fixtures and checker logic for `omv-repro` behaviors that affect local reproduction safety. + +#### Scenario: Repro skill does not execute commands +- **WHEN** an `omv-repro` output presents reproduction steps +- **THEN** the checker rejects language claiming the agent executed commands, installed packages, or observed results itself + +#### Scenario: Repro skill does not rewrite reproducer +- **WHEN** an `omv-repro` output updates a finding after user-reported observations +- **THEN** the checker requires `evidence.reproducer` to remain unchanged and only permits observed-result, status, blockers, or provenance updates + +#### Scenario: Repro failure becomes blocked +- **WHEN** an `omv-repro` output concludes local reproduction cannot proceed +- **THEN** the checker requires status `blocked` and a blocker with a specific reason + +### Requirement: Eval checks run in release validation +Release validation SHALL execute the stable eval manifest through the unified runner, including the existing audit, repro, manager, radar, dedup, disclosure, critic, finder, and report golden checks. + +#### Scenario: Release check catches unsafe audit behavior +- **WHEN** an audit golden output incorrectly marks incomplete evidence as confirmed +- **THEN** `python3 scripts/release_check.py` fails through the unified runner + +#### Scenario: Release check catches unsafe repro behavior +- **WHEN** a repro golden output claims the agent ran commands locally +- **THEN** `python3 scripts/release_check.py` fails through the unified runner + +#### Scenario: Stable manifest has an invalid entry +- **WHEN** a checker, eval file, or golden path in the stable manifest is missing or escapes the package root +- **THEN** release validation fails before reporting the suite as passed + +### Requirement: Stable eval registry is data-driven +Stable case membership SHALL live in `shared/evals/stable.json`, not in TypeScript or `release_check.py`, and each case SHALL have a unique id, skill, eval id, checker path, and golden output path. + +#### Scenario: Add stable golden case +- **WHEN** a maintainer adds a valid case to the JSON manifest +- **THEN** the next unified stable run executes it without a Python source-code list change + diff --git a/openspec/specs/skill-install-integrity/spec.md b/openspec/specs/skill-install-integrity/spec.md new file mode 100644 index 0000000..4c4b493 --- /dev/null +++ b/openspec/specs/skill-install-integrity/spec.md @@ -0,0 +1,60 @@ +# skill-install-integrity Specification + +## Purpose +TBD - created by archiving change harden-evidence-workflow. Update Purpose after archive. +## Requirements +### Requirement: Setup writes an install manifest +`omv setup` SHALL write a manifest describing installed skills, package version, source registry version, installed file paths, and file hashes for each copied runtime asset. + +#### Scenario: User-scope setup writes manifest +- **WHEN** the user runs `omv setup` +- **THEN** the CLI writes an install manifest under the user Claude home containing every installed skill and runtime file hash + +#### Scenario: Project-scope setup writes manifest +- **WHEN** the user runs `omv setup --scope project` +- **THEN** the CLI writes the install manifest under project-local `.omv/` and records the project scope + +### Requirement: Doctor detects stale or modified installs +`omv doctor` SHALL compare installed skill files with the install manifest and the current package runtime assets. + +#### Scenario: Missing installed runtime file fails +- **WHEN** a required installed `SKILL.md`, reference, script, eval, or contract file is missing +- **THEN** doctor returns FAIL and suggests `omv setup --force` + +#### Scenario: Modified installed runtime file warns +- **WHEN** an installed runtime file hash differs from the manifest but the file still exists +- **THEN** doctor returns WARN and identifies the modified file + +#### Scenario: Stale installed skill warns +- **WHEN** the manifest package version or registry version differs from the current package +- **THEN** doctor returns WARN and suggests rerunning setup + +### Requirement: Generated asset drift is checked before release +Release validation SHALL fail if generated README metadata, skill-local registry, skill-local shared references, or skill-local contracts differ from canonical sources. + +#### Scenario: Skill-local contract is stale +- **WHEN** `contracts/evidence.v1.yaml` changes but a skill-local `contracts/evidence.v1.yaml` copy is not synchronized +- **THEN** `python3 scripts/release_check.py` fails and names the stale file + +#### Scenario: README skill table is stale +- **WHEN** `registry.yaml` changes installable skills but README generated markers are not updated +- **THEN** metadata sync check fails and names `README.md` + +### Requirement: Version source of truth is explicit +The release tooling SHALL treat `package.json` as the version source of truth and synchronize registry and lockfile versions from it. + +#### Scenario: Registry version is manually bumped +- **WHEN** `registry.yaml` has a version different from `package.json` +- **THEN** release validation fails and instructs the developer to run metadata sync or update the package version intentionally + +### Requirement: Pattern and eval manifests are packaged runtime assets +The npm package SHALL include canonical PatternPack manifests, the stable eval manifest, and the unified runner, while each consuming skill package SHALL include the PatternPack manifests and references declared for it. + +#### Scenario: npm dry-run package inspection +- **WHEN** release checks inspect `npm pack --json --dry-run` +- **THEN** required manifest and runner paths exist and no private `.omv` or cache files are included + +#### Scenario: Generated PatternPack copy drifts +- **WHEN** a skill-local manifest or reference differs from its canonical source +- **THEN** asset sync check fails and names the stale target + diff --git a/openspec/specs/source-provenance/spec.md b/openspec/specs/source-provenance/spec.md new file mode 100644 index 0000000..8ccc2db --- /dev/null +++ b/openspec/specs/source-provenance/spec.md @@ -0,0 +1,61 @@ +# source-provenance Specification + +## Purpose +TBD - created by archiving change add-source-provenance. Update Purpose after archive. +## Requirements +### Requirement: SourceRef sidecars have a closed local contract +The system SHALL store optional SourceRef.v1 artifacts at `.omv/sources/.yaml` with schema version 1, a matching finding id, the current Evidence.v1 SHA-256, an ISO timestamp, and closed-schema source records containing kind, locator, revision, path, and SHA-256 fields. + +#### Scenario: Initialize from known Evidence facts +- **WHEN** a finding contains a repository URL or registry identity and the user runs `omv sources init ` +- **THEN** the CLI writes a SourceRef.v1 sidecar containing only those known facts and explicit unknown values + +#### Scenario: Evidence has no known source identity +- **WHEN** source identity fields are empty or unknown +- **THEN** initialization writes a valid empty source list with a warning instead of inventing a locator + +#### Scenario: Closed schema validation +- **WHEN** a SourceRef contains an undeclared field, unsafe id, invalid timestamp, malformed hash, or filename/body identity mismatch +- **THEN** validation fails with field-specific errors + +### Requirement: SourceRef validation reports Evidence freshness +The CLI SHALL compare `finding_sha256` with the current finding bytes and report stale state without rewriting either file. + +#### Scenario: Evidence changes after capture +- **WHEN** Evidence.v1 bytes differ from the hash recorded in SourceRef.v1 +- **THEN** `omv sources validate ` succeeds structurally but reports `stale: true` and an actionable warning + +#### Scenario: SourceRef is missing +- **WHEN** `show` or `validate` targets a finding without a SourceRef sidecar +- **THEN** the CLI exits non-zero and names the expected path + +### Requirement: Report provenance manifests hash deterministic inputs +The system SHALL write `.omv/reports//provenance.json` only when a non-empty report artifact exists and SHALL record the finding, report artifacts, and every existing optional SourceRef, ThreatMap, Verification, or declared reproduction dependency with SHA-256 hashes. + +#### Scenario: Create complete local manifest +- **WHEN** a finding has a report artifact plus available sidecars and reproduction files +- **THEN** `omv report provenance ` writes one deterministic manifest whose inputs use project-relative paths when possible + +#### Scenario: Manifest does not count as a report +- **WHEN** the report directory contains only `provenance.json` +- **THEN** report artifact checking still reports that no non-empty report artifact exists + +#### Scenario: Existing manifest protection +- **WHEN** a manifest exists and `--force` is absent +- **THEN** provenance creation preserves the existing bytes and exits non-zero + +### Requirement: Report artifact checks are provenance-aware and backward compatible +The report artifact checker SHALL add manifest validation and freshness data without removing existing result fields. A missing manifest SHALL be a warning; a present malformed, missing-dependency, or hash-stale manifest SHALL be a warning for candidate findings and an error for confirmed findings. + +#### Scenario: Legacy report directory +- **WHEN** non-empty report artifacts exist without `provenance.json` +- **THEN** `omv report artifacts ` retains its prior success semantics and adds a missing-provenance warning + +#### Scenario: Confirmed report is stale +- **WHEN** a confirmed finding or hashed dependency changes after manifest creation +- **THEN** report artifact checking exits non-zero and identifies each stale or missing input + +#### Scenario: Candidate report is stale +- **WHEN** the same stale manifest belongs to a candidate finding +- **THEN** the checker reports the freshness problem as a warning without turning the candidate artifact check into a hard error + diff --git a/openspec/specs/threat-map-artifacts/spec.md b/openspec/specs/threat-map-artifacts/spec.md new file mode 100644 index 0000000..7c6407b --- /dev/null +++ b/openspec/specs/threat-map-artifacts/spec.md @@ -0,0 +1,45 @@ +# threat-map-artifacts Specification + +## Purpose +TBD - created by archiving change advance-intelligence-disclosure-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: ThreatMap.v1 artifact storage +The system SHALL store optional ThreatMap.v1 artifacts at `.omv/threatmaps/.yaml` using the existing contract and SHALL validate them when explicitly requested or when included in readiness checks. + +#### Scenario: Threat map is written for audited finding +- **WHEN** audit identifies source, transform, sink, and guard relationships +- **THEN** the workflow can write a ThreatMap.v1 artifact linked to the finding id + +#### Scenario: Threat map validates successfully +- **WHEN** a threat map contains a matching finding id, package metadata, at least one path, source, sink, guard, and confidence fields +- **THEN** `omv threat-map validate ` reports OK + +### Requirement: Graph-based evidence path +ThreatMap.v1 SHALL represent source-to-sink evidence as graph nodes and directed edges with guard annotations. + +#### Scenario: Missing guard is explicit +- **WHEN** a sink edge has no allowlist, sanitizer, authorization check, or other mitigation +- **THEN** the edge records the missing guard explicitly rather than leaving the guard unknown + +#### Scenario: Graph path includes transforms +- **WHEN** user-controlled input passes through parser, decoder, normalizer, validator, or authorization steps before the sink +- **THEN** the graph records each step as an ordered transform node + +### Requirement: Backward-compatible Evidence integration +Threat maps SHALL augment, not replace, existing Evidence.v1 `evidence.source`, `evidence.sink`, and `evidence.guard` fields, and validation SHALL warn when a present threat map contradicts those summary fields. + +#### Scenario: Finding without threat map remains valid +- **WHEN** a valid Evidence.v1 finding has no `.omv/threatmaps/.yaml` +- **THEN** validation does not fail solely because the threat map is absent + +#### Scenario: Threat map contradicts Evidence summary +- **WHEN** Evidence.v1 says the sink is `lib/a.js:10` but ThreatMap.v1 records only `lib/b.js:20` +- **THEN** readiness validation warns that graph evidence and Evidence summary may be inconsistent + +### Requirement: ASCII threat map rendering +`omv findings show ` SHALL render a compact ASCII graph when a valid ThreatMap.v1 artifact exists. + +#### Scenario: Show renders path +- **WHEN** a finding has a threat map containing an HTTP body source, parser node, HTTP client sink, and missing allowlist guard +- **THEN** the command displays a path equivalent to `[HTTP body] -> parseURL() -> http.Get() x no-allowlist` + diff --git a/openspec/specs/unified-eval-runner/spec.md b/openspec/specs/unified-eval-runner/spec.md new file mode 100644 index 0000000..3aafa24 --- /dev/null +++ b/openspec/specs/unified-eval-runner/spec.md @@ -0,0 +1,42 @@ +# unified-eval-runner Specification + +## Purpose +TBD - created by archiving change add-pattern-pack-eval-runner. Update Purpose after archive. +## Requirements +### Requirement: One runner executes stable and targeted eval checks +The stdlib-only Python eval runner SHALL execute every case from the stable eval manifest by default and SHALL support a targeted skill, eval id, and output path while reusing the skill's existing checker script. + +#### Scenario: Stable suite passes +- **WHEN** all checked-in golden outputs satisfy their existing checkers +- **THEN** the runner exits zero and reports every case as passed + +#### Scenario: Targeted check fails +- **WHEN** a targeted output violates its selected eval assertions +- **THEN** the runner exits non-zero and preserves the checker failure detail in the case result + +#### Scenario: Unsafe targeted input +- **WHEN** a skill name or manifest path attempts path traversal +- **THEN** the runner rejects it before starting a checker subprocess + +### Requirement: Eval results support human, JSON, and JUnit formats +The runner SHALL emit a deterministic summary in human mode, one parseable JSON result document in JSON mode, and valid JUnit XML in JUnit mode; all formats SHALL preserve the same pass/fail counts and exit semantics. + +#### Scenario: JSON output +- **WHEN** the runner is invoked with `--format json` +- **THEN** stdout contains only one JSON document with schema version, totals, status, and per-case results + +#### Scenario: JUnit output +- **WHEN** the runner is invoked with `--format junit` +- **THEN** stdout parses as one testsuite document with a testcase per eval and failure elements for failed cases + +### Requirement: CLI delegates eval execution without policy duplication +`omv eval` SHALL locate the packaged runner, forward stable or targeted arguments, preserve output and exit status, and contain no assertion or stable-case registry. + +#### Scenario: CLI stable JSON run +- **WHEN** the user runs `omv eval --json` +- **THEN** the CLI emits the runner's single JSON document and exits zero only when every stable case passes + +#### Scenario: Python runtime missing +- **WHEN** neither `OMV_PYTHON` nor `python3` can start the runner +- **THEN** the CLI exits non-zero with an actionable runtime error + diff --git a/openspec/specs/verification-artifacts/spec.md b/openspec/specs/verification-artifacts/spec.md new file mode 100644 index 0000000..5b85b4b --- /dev/null +++ b/openspec/specs/verification-artifacts/spec.md @@ -0,0 +1,40 @@ +# verification-artifacts Specification + +## Purpose +TBD - created by archiving change add-evidence-graph-verification. Update Purpose after archive. +## Requirements +### Requirement: Verification.v1 sidecar storage +The system SHALL store adversarial verifier reviews as Verification.v1 artifacts at `.omv/verifications/.yaml`. + +#### Scenario: Verification sidecar is initialized +- **WHEN** the user runs `omv verification init ` +- **THEN** the CLI writes `.omv/verifications/.yaml` linked to the existing Evidence.v1 finding id + +### Requirement: Verification review records +Verification.v1 SHALL record one or more review entries with reviewer identity, target path, agreement, disagreements, required changes, confidence, and timestamp. + +#### Scenario: Verifier disagrees with a path +- **WHEN** a verifier finds that `threatmap.paths[0]` has a developer-controlled source +- **THEN** the sidecar records `agrees: false`, the disagreement text, and a required change describing the downgrade + +### Requirement: Verification validation +`omv verification validate ` SHALL validate Verification.v1 structure and report pass, fail, or needs-human-review status. + +#### Scenario: Required review fields are missing +- **WHEN** a verification sidecar omits reviewer, target, agreement, confidence, or decision status +- **THEN** validation fails with field-specific errors + +### Requirement: Stale verification detection +Verification.v1 SHALL include the reviewed finding hash and validation SHALL warn when the current Evidence.v1 file hash differs. + +#### Scenario: Evidence changed after verification +- **WHEN** `.omv/findings/.yaml` changes after verifier review +- **THEN** `omv verification validate ` warns that the verification is stale + +### Requirement: Strict confirmed verification gate +The CLI SHALL support a strict mode where a confirmed/report-ready finding requires a valid Verification.v1 sidecar with `decision.status: pass`. + +#### Scenario: Strict gate blocks unverified confirmed finding +- **WHEN** strict verification is enabled and a confirmed finding lacks a passing verification sidecar +- **THEN** readiness guidance does not recommend `/omv-report` and points to `omv verification init ` + diff --git a/openspec/specs/vulnerability-pattern-registry/spec.md b/openspec/specs/vulnerability-pattern-registry/spec.md new file mode 100644 index 0000000..5c2377a --- /dev/null +++ b/openspec/specs/vulnerability-pattern-registry/spec.md @@ -0,0 +1,47 @@ +# vulnerability-pattern-registry Specification + +## Purpose +TBD - created by archiving change advance-intelligence-disclosure-lifecycle. Update Purpose after archive. +## Requirements +### Requirement: Ecosystem-specific pattern files +The project SHALL split sink guidance into ecosystem-specific registry files under `shared/references/patterns/` for all fourteen Evidence ecosystems: npm, Python, Go, Rust, Java, Ruby, PHP, C#, Swift, Dart, Elixir, Perl, R, and Lua. + +#### Scenario: Supported ecosystem registry exists +- **WHEN** release validation loads the PatternPack manifest set +- **THEN** every supported ecosystem has a Markdown registry file with at least one complete sink entry + +### Requirement: Sink entry structure +Each sink registry entry SHALL include a sink signature, common misuse pattern, typical guard, and CWE mapping. + +#### Scenario: Sink entry is complete +- **WHEN** a checker parses a sink entry +- **THEN** it can identify the signature, misuse, guard, and CWE fields + +### Requirement: Runtime references stay lazy-loaded +Skills SHALL load only relevant ecosystem pattern files for the current request. + +#### Scenario: npm audit request uses npm patterns +- **WHEN** `/omv-audit` analyzes an npm finding +- **THEN** the skill references npm pattern guidance and does not require loading unrelated Java or Ruby pattern files + +### Requirement: Pattern sync to installed skills +Required pattern registry files and PatternPack manifests SHALL be copied into self-contained skill directories according to each manifest's declared consumers. + +#### Scenario: Skill package contains needed patterns +- **WHEN** `scripts/package_skill.sh` packages a skill declared as a PatternPack consumer +- **THEN** the package contains every declared manifest and referenced pattern file with no unresolved shared-reference path + +### Requirement: Pattern entries use methodology structure +Each vulnerability pattern registry entry SHALL use a method-oriented structure: source pattern, sink signature, common misuse, expected guard, evidence criteria, false-positive checks, and CWE mapping. + +#### Scenario: Registry entry is reviewed +- **WHEN** a pattern file entry is inspected +- **THEN** it can be used to reason about arbitrary packages without relying on a named real vulnerable package + +### Requirement: Pattern registries avoid concrete package examples +Pattern registry files SHALL NOT include named real vulnerable packages, real exploit writeups, or real CVE walkthroughs as primary guidance. + +#### Scenario: Entry needs an example +- **WHEN** an example is needed for clarity +- **THEN** it uses synthetic names such as `demo-package`, `example-service`, or `fixture-parser` + diff --git a/openspec/specs/workflow-command-orchestration/spec.md b/openspec/specs/workflow-command-orchestration/spec.md new file mode 100644 index 0000000..11dddb4 --- /dev/null +++ b/openspec/specs/workflow-command-orchestration/spec.md @@ -0,0 +1,80 @@ +# workflow-command-orchestration Specification + +## Purpose +TBD - created by archiving change local-first-findings-manager. Update Purpose after archive. +## Requirements +### Requirement: omv skill presents the full local-first workflow +The `/omv` skill SHALL present campaign initialization and optional candidate seeding followed by discover, audit, reproduce, review, report, and archive as the primary local-first interaction model, while retaining `omv review ` as the default pre-report readiness check. + +#### Scenario: User asks how to begin +- **WHEN** the user asks how to begin a target-focused research effort without naming an alias +- **THEN** the agent runs canonical `omv campaign init` and reports the generated local artifacts + +#### Scenario: User explicitly invokes first alias +- **WHEN** the user invokes `/omv first` +- **THEN** the agent delegates to the `omv first` alias and reports the generated local artifacts + +#### Scenario: User asks for campaigns +- **WHEN** the user invokes `/omv campaign` or asks for existing research campaigns +- **THEN** the agent runs `omv campaign list` and summarizes the campaign-level next actions + +#### Scenario: User asks for project status +- **WHEN** the user invokes `/omv status` +- **THEN** the agent runs `omv workspace status` and summarizes active next actions + +#### Scenario: User asks what to do next +- **WHEN** the user invokes `/omv next` +- **THEN** the agent runs `omv findings workflow` and recommends the highest-priority next command from the CLI output + +#### Scenario: User asks whether a finding can be reported +- **WHEN** the user asks `/omv` whether finding `demo` is ready to submit +- **THEN** the agent runs `omv review demo` and uses the verdict to recommend the next stage + +### Requirement: Stage skills hand off through CLI-verified state +The find, audit, repro, critic, and report skills SHALL end with CLI-backed next-step guidance. + +#### Scenario: Discovery creates candidates +- **WHEN** `/omv-find` produces candidate findings +- **THEN** the agent tells the user to run `omv findings workflow` or `/omv next` to choose the next audit target + +#### Scenario: Audit or reproduction completes +- **WHEN** `/omv-audit ` or `/omv-repro ` updates a finding +- **THEN** the agent suggests `omv review ` as the next readiness check + +#### Scenario: Report generation completes +- **WHEN** `/omv-report ` produces a report artifact for a confirmed finding +- **THEN** the agent suggests `omv findings archive --reason reported` after validation succeeds + +### Requirement: Agents do not manually mutate lifecycle state +Agents SHALL delegate campaign, workspace, workflow, archive, and restore actions to CLI commands. + +#### Scenario: Campaign creation requested through skill +- **WHEN** the user asks `/omv first` to initialize a target campaign +- **THEN** the agent runs `omv first` or `omv campaign init` and displays the CLI result instead of writing Campaign YAML directly + +#### Scenario: Campaign seeding requested through skill +- **WHEN** the user asks `/omv campaign seed demo` +- **THEN** the agent runs `omv campaign seed demo` instead of writing Evidence files directly + +#### Scenario: Archive requested through skill +- **WHEN** the user asks `/omv archive demo --reason reported` +- **THEN** the agent runs `omv findings archive demo --reason reported` and displays the CLI result + +### Requirement: omv skill keeps campaign seeding conservative +The `/omv` skill SHALL describe campaign lanes as unproven hypotheses and SHALL delegate seeding to the CLI without claiming that analysis or reproduction has occurred. + +#### Scenario: User seeds a campaign +- **WHEN** the user invokes `/omv campaign seed demo` +- **THEN** the agent runs `omv campaign seed demo`, reports created and skipped candidate findings, and recommends finding-level audit as a separate next step + +### Requirement: Report guidance records local provenance without overstating trust +The `/omv` and `/omv-report` skills SHALL recommend SourceRef and report provenance commands as local traceability steps and SHALL NOT claim that a recorded locator or hash proves remote source authenticity. + +#### Scenario: Preparing report artifacts +- **WHEN** a user has generated a report artifact for a finding +- **THEN** guidance recommends `omv report provenance ` followed by `omv report artifacts ` before archive + +#### Scenario: Source identity is unknown +- **WHEN** SourceRef initialization cannot derive a repository or registry locator +- **THEN** guidance preserves the unknown state and asks for human source confirmation rather than inventing a URL + diff --git a/package.json b/package.json index f3c66a8..eccab9a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "omv-mcp": "dist/cli/omv-mcp.js" }, "scripts": { - "build": "tsc && node -e \"import('fs').then(fs => { fs.chmodSync('dist/cli/omv.js', 0o755); fs.chmodSync('dist/cli/omv-mcp.js', 0o755); })\"", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "build": "npm run clean && tsc && node -e \"import('fs').then(fs => { fs.chmodSync('dist/cli/omv.js', 0o755); fs.chmodSync('dist/cli/omv-mcp.js', 0o755); })\"", "dev": "tsc --watch", "typecheck": "tsc --noEmit", "test": "node --test dist/**/__tests__/*.test.js", @@ -30,6 +31,10 @@ "dist/cli/*.d.ts.map", "dist/cli/*.js", "dist/cli/*.js.map", + "dist/cli/commands/*.d.ts", + "dist/cli/commands/*.d.ts.map", + "dist/cli/commands/*.js", + "dist/cli/commands/*.js.map", "dist/index.d.ts", "dist/index.d.ts.map", "dist/index.js", diff --git a/registry.yaml b/registry.yaml index c323624..255d379 100644 --- a/registry.yaml +++ b/registry.yaml @@ -4,7 +4,7 @@ name: oh-my-vul version: "0.9.0" platform: claude-code -updated: "2026-05-08" +updated: "2026-07-10" skills: - name: omv @@ -12,8 +12,11 @@ skills: path: skills/omv invocation: /omv status: stable - description: Local-first project manager — shows workspace status, active finding next actions, archive state, and installed skills - produces: [] + description: Local-first project manager — creates research campaigns, shows workspace status, and delegates finding lifecycle actions + produces: + - Campaign.v1 + - Evidence.v1 # candidate hypotheses from explicit campaign seed + - SourceRef.v1 # optional local source identity sidecar consumes: [] - name: omv-find @@ -62,10 +65,12 @@ skills: - GHSA advisory - OSV JSON - Markdown advisory + - ReportProvenance.v1 consumes: - Evidence.v1 # writes cvss and dedup subfields - ThreatMap.v1 - Verification.v1 + - SourceRef.v1 - name: omv-radar category: intelligence @@ -158,10 +163,22 @@ agents: description: Adversarial verification agent — independently refutes a candidate audit conclusion; bias toward "wrong" contracts: + - name: Campaign.v1 + path: contracts/campaign.v1.yaml + description: Local research campaign target, scope, priorities, and candidate lanes + - name: Evidence.v1 path: contracts/evidence.v1.yaml description: Finding object — the typed boundary between omv-find and omv-report + - name: SourceRef.v1 + path: contracts/source-ref.v1.yaml + description: Optional local source identity and Evidence hash sidecar + + - name: ReportProvenance.v1 + path: contracts/report-provenance.v1.yaml + description: Generated SHA-256 manifest for reports and local dependencies + - name: CandidateList.v1 path: contracts/candidate-list.v1.yaml description: Candidate table entry schema produced by omv-find @@ -189,6 +206,29 @@ shared: - shared/references/patterns/rust.md - shared/references/patterns/java.md - shared/references/patterns/ruby.md + - shared/references/patterns/php.md + - shared/references/patterns/csharp.md + - shared/references/patterns/swift.md + - shared/references/patterns/dart.md + - shared/references/patterns/elixir.md + - shared/references/patterns/perl.md + - shared/references/patterns/r.md + - shared/references/patterns/lua.md + - shared/pattern-packs/npm.json + - shared/pattern-packs/python.json + - shared/pattern-packs/go.json + - shared/pattern-packs/rust.json + - shared/pattern-packs/java.json + - shared/pattern-packs/ruby.json + - shared/pattern-packs/php.json + - shared/pattern-packs/csharp.json + - shared/pattern-packs/swift.json + - shared/pattern-packs/dart.json + - shared/pattern-packs/elixir.json + - shared/pattern-packs/perl.json + - shared/pattern-packs/r.json + - shared/pattern-packs/lua.json scripts: - shared/scripts/collect_metadata.py - shared/scripts/estimate_loc.sh + - shared/scripts/run_evals.py diff --git a/scripts/check_npm_pack.py b/scripts/check_npm_pack.py index 68eab99..d58f0f6 100644 --- a/scripts/check_npm_pack.py +++ b/scripts/check_npm_pack.py @@ -8,6 +8,11 @@ import sys from pathlib import Path +try: + from .pattern_packs import load_pattern_packs +except ImportError: + from pattern_packs import load_pattern_packs + REPO_ROOT = Path(__file__).resolve().parents[1] @@ -25,6 +30,9 @@ "dist/index.js", "registry.yaml", "contracts/evidence.v1.yaml", + "contracts/campaign.v1.yaml", + "contracts/source-ref.v1.yaml", + "contracts/report-provenance.v1.yaml", "contracts/candidate-list.v1.yaml", "contracts/submission.v1.yaml", "contracts/threat-map.v1.yaml", @@ -42,6 +50,8 @@ "shared/scripts/estimate_loc.sh", "shared/scripts/http_client.py", "shared/scripts/resolve_source_path.py", + "shared/scripts/run_evals.py", + "shared/evals/stable.json", "shared/references/patterns/npm.md", "shared/references/research-radar.md", "shared/references/pattern-packs.md", @@ -68,16 +78,22 @@ ".codex/", ".agents/", ".github/", + "dist/cli/__tests__/", "node_modules/", "src/", "scripts/", } FORBIDDEN_SUFFIXES = { + ".pyc", + ".pyd", + ".pyo", ".skill", ".tgz", } +COMPILED_SUFFIXES = (".d.ts.map", ".d.ts", ".js.map", ".js") + def fail(message: str) -> None: print(f"FAIL: {message}", file=sys.stderr) @@ -98,6 +114,47 @@ def npm_pack() -> dict[str, object]: return parsed[0] +def expected_command_files() -> set[str]: + command_sources = (REPO_ROOT / "src" / "cli" / "commands").glob("*.ts") + return { + f"dist/cli/commands/{source.stem}{suffix}" + for source in command_sources + for suffix in COMPILED_SUFFIXES + } + + +def expected_pattern_pack_files() -> set[str]: + expected: set[str] = set() + for pack in load_pattern_packs(REPO_ROOT): + expected.add(pack.manifest_path.relative_to(REPO_ROOT).as_posix()) + expected.add(f"shared/{pack.reference}") + for consumer in pack.consumers: + expected.add(f"skills/{consumer}/references/pattern-packs/{pack.ecosystem}.json") + expected.add(f"skills/{consumer}/references/patterns/{pack.ecosystem}.md") + return expected + + +def stale_cli_files(paths: set[str]) -> list[str]: + source_stems_by_output_dir = { + "dist/cli": {source.stem for source in (REPO_ROOT / "src" / "cli").glob("*.ts")}, + "dist/cli/commands": { + source.stem for source in (REPO_ROOT / "src" / "cli" / "commands").glob("*.ts") + }, + } + stale: list[str] = [] + for path in paths: + compiled = Path(path) + source_stems = source_stems_by_output_dir.get(compiled.parent.as_posix()) + if source_stems is None: + continue + for suffix in COMPILED_SUFFIXES: + if compiled.name.endswith(suffix): + if compiled.name.removesuffix(suffix) not in source_stems: + stale.append(path) + break + return sorted(stale) + + def main() -> None: package = npm_pack() files = package.get("files", []) @@ -109,6 +166,14 @@ def main() -> None: if missing: fail(f"missing required npm files: {', '.join(missing)}") + missing_patterns = sorted(expected_pattern_pack_files() - paths) + if missing_patterns: + fail(f"missing PatternPack npm files: {', '.join(missing_patterns[:12])}") + + missing_commands = sorted(expected_command_files() - paths) + if missing_commands: + fail(f"missing compiled command modules: {', '.join(missing_commands)}") + for prefix in sorted(REQUIRED_PREFIXES): if not any(path.startswith(prefix) for path in paths): fail(f"missing required npm directory prefix: {prefix}") @@ -121,6 +186,10 @@ def main() -> None: if forbidden: fail(f"forbidden files in npm package: {', '.join(forbidden[:12])}") + stale = stale_cli_files(paths) + if stale: + fail(f"compiled CLI files without matching sources: {', '.join(stale[:12])}") + print( json.dumps( { diff --git a/scripts/pattern_packs.py b/scripts/pattern_packs.py new file mode 100644 index 0000000..4255cfb --- /dev/null +++ b/scripts/pattern_packs.py @@ -0,0 +1,180 @@ +"""Load and validate canonical PatternPack.v1 manifests.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[1] +EXPECTED_ECOSYSTEMS = ( + "npm", + "python", + "go", + "rust", + "java", + "ruby", + "php", + "csharp", + "swift", + "dart", + "elixir", + "perl", + "r", + "lua", +) +MANIFEST_KEYS = { + "schema_version", + "id", + "ecosystem", + "aliases", + "reference", + "vulnerability_classes", + "consumers", +} +SAFE_ID = re.compile(r"^[a-z][a-z0-9-]*$") + + +class PatternPackError(ValueError): + """Raised when a PatternPack manifest is invalid.""" + + +@dataclass(frozen=True) +class PatternPack: + id: str + ecosystem: str + aliases: tuple[str, ...] + reference: str + vulnerability_classes: tuple[str, ...] + consumers: tuple[str, ...] + manifest_path: Path + + +def load_pattern_packs(root: Path = REPO_ROOT) -> list[PatternPack]: + manifest_dir = root / "shared" / "pattern-packs" + paths = sorted(manifest_dir.glob("*.json")) if manifest_dir.is_dir() else [] + stems = {path.stem for path in paths} + expected = set(EXPECTED_ECOSYSTEMS) + missing = sorted(expected - stems) + extra = sorted(stems - expected) + if missing or extra: + details = [] + if missing: + details.append(f"missing ecosystems: {', '.join(missing)}") + if extra: + details.append(f"unsupported ecosystems: {', '.join(extra)}") + raise PatternPackError("PatternPack manifest set is incomplete: " + "; ".join(details)) + + by_ecosystem: dict[str, PatternPack] = {} + ids: set[str] = set() + for path in paths: + pack = _load_one(path, root) + if pack.id in ids: + raise PatternPackError(f"{path.name}: duplicate PatternPack id {pack.id}") + ids.add(pack.id) + if pack.ecosystem in by_ecosystem: + raise PatternPackError(f"{path.name}: duplicate ecosystem {pack.ecosystem}") + by_ecosystem[pack.ecosystem] = pack + return [by_ecosystem[ecosystem] for ecosystem in EXPECTED_ECOSYSTEMS] + + +def pattern_asset_mappings(root: Path = REPO_ROOT) -> list[tuple[Path, Path]]: + mappings: list[tuple[Path, Path]] = [] + for pack in load_pattern_packs(root): + reference = root / "shared" / pack.reference + for consumer in pack.consumers: + skill_dir = root / "skills" / consumer + mappings.append( + (reference, skill_dir / "references" / "patterns" / f"{pack.ecosystem}.md") + ) + mappings.append( + ( + pack.manifest_path, + skill_dir / "references" / "pattern-packs" / f"{pack.ecosystem}.json", + ) + ) + return sorted(mappings, key=lambda item: (item[1].as_posix(), item[0].as_posix())) + + +def _load_one(path: Path, root: Path) -> PatternPack: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise PatternPackError(f"{path.name}: invalid JSON: {error}") from error + if not isinstance(value, dict): + raise PatternPackError(f"{path.name}: manifest must be an object") + unknown = sorted(set(value) - MANIFEST_KEYS) + if unknown: + raise PatternPackError(f"{path.name}: unknown fields: {', '.join(unknown)}") + missing = sorted(MANIFEST_KEYS - set(value)) + if missing: + raise PatternPackError(f"{path.name}: missing fields: {', '.join(missing)}") + if value["schema_version"] != "1": + raise PatternPackError(f"{path.name}: schema_version must be 1") + + pack_id = _safe_id(value["id"], path, "id") + ecosystem = _safe_id(value["ecosystem"], path, "ecosystem") + if pack_id != path.stem: + raise PatternPackError(f"{path.name}: id must match filename {path.stem}") + if ecosystem != path.stem: + raise PatternPackError(f"{path.name}: ecosystem must match filename {path.stem}") + + reference = _safe_reference(value["reference"], path) + reference_path = root / "shared" / reference + if not reference_path.is_file(): + raise PatternPackError(f"{path.name}: reference does not exist: {reference}") + aliases = _string_tuple(value["aliases"], path, "aliases", safe_slug=False) + vulnerability_classes = _string_tuple( + value["vulnerability_classes"], path, "vulnerability_classes", safe_slug=True + ) + consumers = _string_tuple(value["consumers"], path, "consumers", safe_slug=True) + if tuple(sorted(consumers)) != consumers: + raise PatternPackError(f"{path.name}: consumers must be sorted") + for consumer in consumers: + if not (root / "skills" / consumer / "SKILL.md").is_file(): + raise PatternPackError(f"{path.name}: consumer skill does not exist: {consumer}") + + return PatternPack( + id=pack_id, + ecosystem=ecosystem, + aliases=aliases, + reference=reference, + vulnerability_classes=vulnerability_classes, + consumers=consumers, + manifest_path=path, + ) + + +def _safe_id(value: Any, path: Path, field: str) -> str: + if not isinstance(value, str) or not SAFE_ID.fullmatch(value): + raise PatternPackError(f"{path.name}: {field} must be a lowercase ASCII slug") + return value + + +def _safe_reference(value: Any, path: Path) -> str: + if not isinstance(value, str) or not value or "\\" in value: + raise PatternPackError(f"{path.name}: reference must be a safe relative POSIX path") + parsed = PurePosixPath(value) + if parsed.is_absolute() or ".." in parsed.parts or parsed.parts[:2] != ("references", "patterns"): + raise PatternPackError(f"{path.name}: reference must stay under references/patterns") + if parsed.name != f"{path.stem}.md": + raise PatternPackError(f"{path.name}: reference filename must match ecosystem {path.stem}") + return value + + +def _string_tuple(value: Any, path: Path, field: str, *, safe_slug: bool) -> tuple[str, ...]: + if not isinstance(value, list) or not value: + raise PatternPackError(f"{path.name}: {field} must be a non-empty list") + items: list[str] = [] + for index, item in enumerate(value): + if not isinstance(item, str) or not item or item != item.strip() or any(ord(char) < 32 for char in item): + raise PatternPackError(f"{path.name}: {field}[{index}] must be canonical text") + if safe_slug and not SAFE_ID.fullmatch(item): + raise PatternPackError(f"{path.name}: {field}[{index}] must be a lowercase ASCII slug") + if item in items: + raise PatternPackError(f"{path.name}: {field}[{index}] must be unique") + items.append(item) + return tuple(items) diff --git a/scripts/release_check.py b/scripts/release_check.py index b27c228..31a9d11 100644 --- a/scripts/release_check.py +++ b/scripts/release_check.py @@ -11,6 +11,11 @@ import tempfile from pathlib import Path +try: + from .pattern_packs import load_pattern_packs +except ImportError: + from pattern_packs import load_pattern_packs + REPO_ROOT = Path(__file__).resolve().parents[1] PACKAGE_SCRIPT = REPO_ROOT / "scripts" / "package_skill.sh" @@ -18,21 +23,6 @@ SYNC_SCRIPT = REPO_ROOT / "scripts" / "sync_skill_assets.py" SYNC_METADATA_SCRIPT = REPO_ROOT / "scripts" / "sync_metadata.py" METHODOLOGY_SCRIPT = REPO_ROOT / "scripts" / "check_methodology_guidance.py" -STABLE_EVAL_CHECKS = [ - ("skills/omv/scripts/check_output.py", "0", "skills/omv/evals/golden/next-workflow.md"), - ("skills/omv/scripts/check_output.py", "1", "skills/omv/evals/golden/archive-delegation.md"), - ("skills/omv-audit/scripts/check_output.py", "0", "skills/omv-audit/evals/golden/incomplete-observed-result.md"), - ("skills/omv-audit/scripts/check_output.py", "1", "skills/omv-audit/evals/golden/duplicate-blocked.md"), - ("skills/omv-audit/scripts/check_output.py", "2", "skills/omv-audit/evals/golden/confirmed-complete.md"), - ("skills/omv-repro/scripts/check_output.py", "0", "skills/omv-repro/evals/golden/no-agent-execution.md"), - ("skills/omv-repro/scripts/check_output.py", "1", "skills/omv-repro/evals/golden/read-only-reproducer.md"), - ("skills/omv-repro/scripts/check_output.py", "2", "skills/omv-repro/evals/golden/blocked-repro-failure.md"), - ("skills/omv-radar/scripts/check_output.py", "0", "skills/omv-radar/evals/golden/radar-dry-run.md"), - ("skills/omv-dedup/scripts/check_output.py", "0", "skills/omv-dedup/evals/golden/known-duplicate.md"), - ("skills/omv-disclose/scripts/check_output.py", "0", "skills/omv-disclose/evals/golden/timeline.md"), - ("skills/omv-critic/scripts/check_output.py", "0", "skills/omv-critic/evals/golden/high-risk.md"), -] - RENDERER_FIXTURE = "skills/omv-report/evals/fixtures/confirmed-prototype-pollution.yaml" RENDERER_FORMATS = ["vuldb", "ghsa", "osv", "md"] @@ -110,18 +100,10 @@ def validate_versions() -> None: def validate_stable_evals() -> None: - for script, eval_id, output in STABLE_EVAL_CHECKS: - run([ - sys.executable, - str(REPO_ROOT / script), - "--eval-id", - eval_id, - "--output", - str(REPO_ROOT / output), - ]) - - -def validate_pattern_registry() -> None: + run([sys.executable, str(REPO_ROOT / "shared" / "scripts" / "run_evals.py")]) + + +def validate_pattern_registry(root: Path = REPO_ROOT) -> None: required = [ "Source pattern:", "Sink signature:", @@ -131,16 +113,14 @@ def validate_pattern_registry() -> None: "False-positive checks:", "CWE:", ] - root = REPO_ROOT / "shared" / "references" / "patterns" - for ecosystem in ["npm", "python", "go", "rust", "java", "ruby", "php", "csharp", "swift", "dart", "elixir", "perl"]: - path = root / f"{ecosystem}.md" - if not path.exists(): - raise SystemExit(f"missing pattern registry: {path.relative_to(REPO_ROOT)}") + packs = load_pattern_packs(root) + for pack in packs: + path = root / "shared" / pack.reference text = path.read_text(encoding="utf-8") for marker in required: if marker not in text: - raise SystemExit(f"{path.relative_to(REPO_ROOT)} missing {marker}") - print("OK: pattern registries", flush=True) + raise SystemExit(f"{path.relative_to(root)} missing {marker}") + print(f"OK: {len(packs)} PatternPack registries", flush=True) def validate_renderer() -> None: diff --git a/scripts/sync_skill_assets.py b/scripts/sync_skill_assets.py index 90ad550..16b5103 100755 --- a/scripts/sync_skill_assets.py +++ b/scripts/sync_skill_assets.py @@ -9,6 +9,11 @@ import sys from pathlib import Path +try: + from .pattern_packs import pattern_asset_mappings +except ImportError: + from pattern_packs import pattern_asset_mappings + REPO_ROOT = Path(__file__).resolve().parents[1] @@ -18,12 +23,6 @@ ("shared/references/vuln-patterns.md", "skills/omv-find/references/shared/vuln-patterns.md"), ("shared/references/research-radar.md", "skills/omv-find/references/research-radar.md"), ("shared/references/pattern-packs.md", "skills/omv-find/references/pattern-packs.md"), - ("shared/references/patterns/npm.md", "skills/omv-find/references/patterns/npm.md"), - ("shared/references/patterns/python.md", "skills/omv-find/references/patterns/python.md"), - ("shared/references/patterns/go.md", "skills/omv-find/references/patterns/go.md"), - ("shared/references/patterns/rust.md", "skills/omv-find/references/patterns/rust.md"), - ("shared/references/patterns/java.md", "skills/omv-find/references/patterns/java.md"), - ("shared/references/patterns/ruby.md", "skills/omv-find/references/patterns/ruby.md"), ("shared/scripts/http_client.py", "skills/omv-find/scripts/http_client.py"), ("shared/scripts/collect_metadata.py", "skills/omv-find/scripts/collect_metadata.py"), ("shared/scripts/resolve_source_path.py", "skills/omv-find/scripts/resolve_source_path.py"), @@ -31,18 +30,14 @@ ("contracts/evidence.v1.yaml", "skills/omv-find/contracts/evidence.v1.yaml"), ("contracts/candidate-list.v1.yaml", "skills/omv-find/contracts/candidate-list.v1.yaml"), ("shared/references/cvss-builder.md", "skills/omv-audit/references/shared/cvss-builder.md"), - ("shared/references/patterns/npm.md", "skills/omv-audit/references/patterns/npm.md"), - ("shared/references/patterns/python.md", "skills/omv-audit/references/patterns/python.md"), - ("shared/references/patterns/go.md", "skills/omv-audit/references/patterns/go.md"), - ("shared/references/patterns/rust.md", "skills/omv-audit/references/patterns/rust.md"), - ("shared/references/patterns/java.md", "skills/omv-audit/references/patterns/java.md"), - ("shared/references/patterns/ruby.md", "skills/omv-audit/references/patterns/ruby.md"), ("contracts/evidence.v1.yaml", "skills/omv-audit/contracts/evidence.v1.yaml"), ("contracts/threat-map.v1.yaml", "skills/omv-audit/contracts/threat-map.v1.yaml"), ("contracts/evidence.v1.yaml", "skills/omv-repro/contracts/evidence.v1.yaml"), ("shared/references/cvss-builder.md", "skills/omv-report/references/shared/cvss-builder.md"), ("contracts/evidence.v1.yaml", "skills/omv-report/contracts/evidence.v1.yaml"), ("contracts/verification.v1.yaml", "skills/omv-report/contracts/verification.v1.yaml"), + ("contracts/source-ref.v1.yaml", "skills/omv-report/contracts/source-ref.v1.yaml"), + ("contracts/report-provenance.v1.yaml", "skills/omv-report/contracts/report-provenance.v1.yaml"), ("contracts/evidence.v1.yaml", "skills/omv-dedup/contracts/evidence.v1.yaml"), ("contracts/evidence.v1.yaml", "skills/omv-disclose/contracts/evidence.v1.yaml"), ("contracts/submission.v1.yaml", "skills/omv-disclose/contracts/submission.v1.yaml"), @@ -59,7 +54,8 @@ def fail(message: str) -> None: def mappings() -> list[tuple[Path, Path]]: - return [(REPO_ROOT / src, REPO_ROOT / dest) for src, dest in ASSET_MAPPINGS] + static = [(REPO_ROOT / src, REPO_ROOT / dest) for src, dest in ASSET_MAPPINGS] + return static + pattern_asset_mappings(REPO_ROOT) def check() -> None: diff --git a/scripts/test_pattern_packs.py b/scripts/test_pattern_packs.py new file mode 100644 index 0000000..cb591e2 --- /dev/null +++ b/scripts/test_pattern_packs.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from .pattern_packs import ( + EXPECTED_ECOSYSTEMS, + PatternPackError, + load_pattern_packs, + pattern_asset_mappings, +) +from .release_check import validate_pattern_registry +from .sync_skill_assets import mappings as sync_mappings + + +class PatternPackTests(unittest.TestCase): + def test_repository_has_fourteen_complete_unique_packs(self) -> None: + root = Path(__file__).resolve().parents[1] + packs = load_pattern_packs(root) + + self.assertEqual([pack.ecosystem for pack in packs], list(EXPECTED_ECOSYSTEMS)) + self.assertEqual(len({pack.id for pack in packs}), 14) + for pack in packs: + self.assertTrue((root / "shared" / pack.reference).is_file()) + self.assertTrue(pack.vulnerability_classes) + self.assertEqual(pack.consumers, ("omv-audit", "omv-find")) + + def test_asset_mappings_are_derived_for_every_declared_consumer(self) -> None: + root = Path(__file__).resolve().parents[1] + mappings = { + (source.relative_to(root).as_posix(), destination.relative_to(root).as_posix()) + for source, destination in pattern_asset_mappings(root) + } + + self.assertEqual(len(mappings), 14 * 2 * 2) + for ecosystem in EXPECTED_ECOSYSTEMS: + for consumer in ("omv-audit", "omv-find"): + self.assertIn( + ( + f"shared/references/patterns/{ecosystem}.md", + f"skills/{consumer}/references/patterns/{ecosystem}.md", + ), + mappings, + ) + + def test_skill_sync_includes_every_manifest_derived_mapping(self) -> None: + root = Path(__file__).resolve().parents[1] + expected = set(pattern_asset_mappings(root)) + self.assertTrue(expected.issubset(set(sync_mappings()))) + + def test_skill_local_manifests_resolve_their_runtime_references(self) -> None: + root = Path(__file__).resolve().parents[1] + for pack in load_pattern_packs(root): + for consumer in pack.consumers: + skill_root = root / "skills" / consumer + manifest_path = skill_root / "references" / "pattern-packs" / f"{pack.ecosystem}.json" + value = json.loads(manifest_path.read_text(encoding="utf-8")) + self.assertTrue( + (skill_root / value["reference"]).is_file(), + f"{manifest_path} has unresolved reference {value['reference']}", + ) + + def test_release_pattern_validation_loads_manifests_from_the_selected_root(self) -> None: + with tempfile.TemporaryDirectory(prefix="omv-pattern-packs-") as tmp: + root = Path(tmp) + self._write_complete_fixture(root) + with self.assertRaisesRegex(SystemExit, r"npm\.md missing Source pattern"): + validate_pattern_registry(root) + self.assertIn( + ( + f"shared/pattern-packs/{ecosystem}.json", + f"skills/{consumer}/references/pattern-packs/{ecosystem}.json", + ), + mappings, + ) + + def test_loader_rejects_unknown_keys_and_unsafe_reference_paths(self) -> None: + with tempfile.TemporaryDirectory(prefix="omv-pattern-packs-") as tmp: + root = Path(tmp) + self._write_complete_fixture(root) + path = root / "shared/pattern-packs/npm.json" + value = json.loads(path.read_text(encoding="utf-8")) + value["invented"] = True + path.write_text(json.dumps(value), encoding="utf-8") + with self.assertRaisesRegex(PatternPackError, r"npm\.json.*unknown.*invented"): + load_pattern_packs(root) + + del value["invented"] + value["reference"] = "../outside.md" + path.write_text(json.dumps(value), encoding="utf-8") + with self.assertRaisesRegex(PatternPackError, r"npm\.json.*reference"): + load_pattern_packs(root) + + def test_loader_rejects_duplicate_ids_and_incomplete_ecosystem_sets(self) -> None: + with tempfile.TemporaryDirectory(prefix="omv-pattern-packs-") as tmp: + root = Path(tmp) + self._write_complete_fixture(root) + java_path = root / "shared/pattern-packs/java.json" + java = json.loads(java_path.read_text(encoding="utf-8")) + java["id"] = "npm" + java_path.write_text(json.dumps(java), encoding="utf-8") + with self.assertRaisesRegex(PatternPackError, r"duplicate.*npm|id.*java"): + load_pattern_packs(root) + + java_path.unlink() + with self.assertRaisesRegex(PatternPackError, r"missing.*java"): + load_pattern_packs(root) + + @staticmethod + def _write_complete_fixture(root: Path) -> None: + for consumer in ("omv-audit", "omv-find"): + skill_dir = root / "skills" / consumer + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text(f"---\nname: {consumer}\n---\n", encoding="utf-8") + for ecosystem in EXPECTED_ECOSYSTEMS: + reference = root / "shared" / "references" / "patterns" / f"{ecosystem}.md" + reference.parent.mkdir(parents=True, exist_ok=True) + reference.write_text("# fixture\n", encoding="utf-8") + manifest = root / "shared" / "pattern-packs" / f"{ecosystem}.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + json.dumps( + { + "schema_version": "1", + "id": ecosystem, + "ecosystem": ecosystem, + "aliases": [ecosystem], + "reference": f"references/patterns/{ecosystem}.md", + "vulnerability_classes": ["path-traversal"], + "consumers": ["omv-audit", "omv-find"], + } + ), + encoding="utf-8", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/shared/.npmignore b/shared/.npmignore new file mode 100644 index 0000000..a6c5492 --- /dev/null +++ b/shared/.npmignore @@ -0,0 +1,5 @@ +**/__pycache__/ +**/*.pyc +**/*.pyd +**/*.pyo +**/test_*.py diff --git a/shared/evals/stable.json b/shared/evals/stable.json new file mode 100644 index 0000000..8ad4f13 --- /dev/null +++ b/shared/evals/stable.json @@ -0,0 +1,117 @@ +{ + "schema_version": "1", + "cases": [ + { + "id": "omv-next-workflow", + "skill": "omv", + "eval_id": 0, + "checker": "skills/omv/scripts/check_output.py", + "output": "skills/omv/evals/golden/next-workflow.md" + }, + { + "id": "omv-archive-delegation", + "skill": "omv", + "eval_id": 1, + "checker": "skills/omv/scripts/check_output.py", + "output": "skills/omv/evals/golden/archive-delegation.md" + }, + { + "id": "omv-audit-incomplete-observation", + "skill": "omv-audit", + "eval_id": 0, + "checker": "skills/omv-audit/scripts/check_output.py", + "output": "skills/omv-audit/evals/golden/incomplete-observed-result.md" + }, + { + "id": "omv-audit-duplicate-blocked", + "skill": "omv-audit", + "eval_id": 1, + "checker": "skills/omv-audit/scripts/check_output.py", + "output": "skills/omv-audit/evals/golden/duplicate-blocked.md" + }, + { + "id": "omv-audit-confirmed-complete", + "skill": "omv-audit", + "eval_id": 2, + "checker": "skills/omv-audit/scripts/check_output.py", + "output": "skills/omv-audit/evals/golden/confirmed-complete.md" + }, + { + "id": "omv-repro-no-agent-execution", + "skill": "omv-repro", + "eval_id": 0, + "checker": "skills/omv-repro/scripts/check_output.py", + "output": "skills/omv-repro/evals/golden/no-agent-execution.md" + }, + { + "id": "omv-repro-read-only-reproducer", + "skill": "omv-repro", + "eval_id": 1, + "checker": "skills/omv-repro/scripts/check_output.py", + "output": "skills/omv-repro/evals/golden/read-only-reproducer.md" + }, + { + "id": "omv-repro-blocked-failure", + "skill": "omv-repro", + "eval_id": 2, + "checker": "skills/omv-repro/scripts/check_output.py", + "output": "skills/omv-repro/evals/golden/blocked-repro-failure.md" + }, + { + "id": "omv-radar-dry-run", + "skill": "omv-radar", + "eval_id": 0, + "checker": "skills/omv-radar/scripts/check_output.py", + "output": "skills/omv-radar/evals/golden/radar-dry-run.md" + }, + { + "id": "omv-dedup-known-duplicate", + "skill": "omv-dedup", + "eval_id": 0, + "checker": "skills/omv-dedup/scripts/check_output.py", + "output": "skills/omv-dedup/evals/golden/known-duplicate.md" + }, + { + "id": "omv-disclose-timeline", + "skill": "omv-disclose", + "eval_id": 0, + "checker": "skills/omv-disclose/scripts/check_output.py", + "output": "skills/omv-disclose/evals/golden/timeline.md" + }, + { + "id": "omv-critic-high-risk", + "skill": "omv-critic", + "eval_id": 0, + "checker": "skills/omv-critic/scripts/check_output.py", + "output": "skills/omv-critic/evals/golden/high-risk.md" + }, + { + "id": "omv-find-invalid-flags", + "skill": "omv-find", + "eval_id": 26, + "checker": "skills/omv-find/scripts/check_output.py", + "output": "skills/omv-find/evals/golden/invalid-flags.md" + }, + { + "id": "omv-report-blocked-handoff", + "skill": "omv-report", + "eval_id": 4, + "checker": "skills/omv-report/scripts/check_output.py", + "output": "skills/omv-report/evals/golden/blocked-handoff.md" + }, + { + "id": "omv-report-osv-prototype-pollution", + "skill": "omv-report", + "eval_id": 5, + "checker": "skills/omv-report/scripts/check_output.py", + "output": "skills/omv-report/evals/golden/osv-prototype-pollution.json" + }, + { + "id": "omv-report-duplicate-cna-warning", + "skill": "omv-report", + "eval_id": 7, + "checker": "skills/omv-report/scripts/check_output.py", + "output": "skills/omv-report/evals/golden/duplicate-cna-warning.md" + } + ] +} diff --git a/shared/pattern-packs/csharp.json b/shared/pattern-packs/csharp.json new file mode 100644 index 0000000..52835f4 --- /dev/null +++ b/shared/pattern-packs/csharp.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "csharp", + "ecosystem": "csharp", + "aliases": ["csharp", "c#", "dotnet", "nuget"], + "reference": "references/patterns/csharp.md", + "vulnerability_classes": ["unsafe-deserialization", "path-traversal", "ssrf"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/dart.json b/shared/pattern-packs/dart.json new file mode 100644 index 0000000..c293b93 --- /dev/null +++ b/shared/pattern-packs/dart.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "dart", + "ecosystem": "dart", + "aliases": ["dart", "flutter", "pub"], + "reference": "references/patterns/dart.md", + "vulnerability_classes": ["path-traversal", "ssrf", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/elixir.json b/shared/pattern-packs/elixir.json new file mode 100644 index 0000000..e063484 --- /dev/null +++ b/shared/pattern-packs/elixir.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "elixir", + "ecosystem": "elixir", + "aliases": ["elixir", "hex"], + "reference": "references/patterns/elixir.md", + "vulnerability_classes": ["code-injection", "resource-exhaustion", "sql-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/go.json b/shared/pattern-packs/go.json new file mode 100644 index 0000000..b578727 --- /dev/null +++ b/shared/pattern-packs/go.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "go", + "ecosystem": "go", + "aliases": ["go", "golang"], + "reference": "references/patterns/go.md", + "vulnerability_classes": ["ssrf", "path-traversal", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/java.json b/shared/pattern-packs/java.json new file mode 100644 index 0000000..bfea7fc --- /dev/null +++ b/shared/pattern-packs/java.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "java", + "ecosystem": "java", + "aliases": ["java", "maven", "gradle"], + "reference": "references/patterns/java.md", + "vulnerability_classes": ["ssrf", "xxe", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/lua.json b/shared/pattern-packs/lua.json new file mode 100644 index 0000000..88b0708 --- /dev/null +++ b/shared/pattern-packs/lua.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "lua", + "ecosystem": "lua", + "aliases": ["lua", "luarocks"], + "reference": "references/patterns/lua.md", + "vulnerability_classes": ["command-injection", "path-traversal", "code-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/npm.json b/shared/pattern-packs/npm.json new file mode 100644 index 0000000..6d035e0 --- /dev/null +++ b/shared/pattern-packs/npm.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "npm", + "ecosystem": "npm", + "aliases": ["npm", "node", "nodejs", "javascript", "typescript"], + "reference": "references/patterns/npm.md", + "vulnerability_classes": ["ssrf", "path-traversal", "prototype-pollution"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/perl.json b/shared/pattern-packs/perl.json new file mode 100644 index 0000000..a45d29a --- /dev/null +++ b/shared/pattern-packs/perl.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "perl", + "ecosystem": "perl", + "aliases": ["perl", "cpan"], + "reference": "references/patterns/perl.md", + "vulnerability_classes": ["command-injection", "path-traversal", "redos"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/php.json b/shared/pattern-packs/php.json new file mode 100644 index 0000000..b2e0216 --- /dev/null +++ b/shared/pattern-packs/php.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "php", + "ecosystem": "php", + "aliases": ["php", "composer", "packagist"], + "reference": "references/patterns/php.md", + "vulnerability_classes": ["unsafe-deserialization", "sql-injection", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/python.json b/shared/pattern-packs/python.json new file mode 100644 index 0000000..f89c10b --- /dev/null +++ b/shared/pattern-packs/python.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "python", + "ecosystem": "python", + "aliases": ["python", "pypi", "pip"], + "reference": "references/patterns/python.md", + "vulnerability_classes": ["ssrf", "unsafe-yaml", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/r.json b/shared/pattern-packs/r.json new file mode 100644 index 0000000..9e5ffa6 --- /dev/null +++ b/shared/pattern-packs/r.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "r", + "ecosystem": "r", + "aliases": ["r", "cran"], + "reference": "references/patterns/r.md", + "vulnerability_classes": ["command-injection", "path-traversal", "unsafe-deserialization"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/ruby.json b/shared/pattern-packs/ruby.json new file mode 100644 index 0000000..a311ad4 --- /dev/null +++ b/shared/pattern-packs/ruby.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "ruby", + "ecosystem": "ruby", + "aliases": ["ruby", "rubygems", "gem"], + "reference": "references/patterns/ruby.md", + "vulnerability_classes": ["ssrf", "unsafe-deserialization", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/rust.json b/shared/pattern-packs/rust.json new file mode 100644 index 0000000..3eea629 --- /dev/null +++ b/shared/pattern-packs/rust.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "rust", + "ecosystem": "rust", + "aliases": ["rust", "cargo", "crates.io"], + "reference": "references/patterns/rust.md", + "vulnerability_classes": ["ssrf", "path-traversal", "unsafe-deserialization"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/pattern-packs/swift.json b/shared/pattern-packs/swift.json new file mode 100644 index 0000000..fa2d552 --- /dev/null +++ b/shared/pattern-packs/swift.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "swift", + "ecosystem": "swift", + "aliases": ["swift", "spm", "cocoapods"], + "reference": "references/patterns/swift.md", + "vulnerability_classes": ["path-traversal", "insecure-tls", "sql-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/shared/references/patterns/lua.md b/shared/references/patterns/lua.md new file mode 100644 index 0000000..3e0320a --- /dev/null +++ b/shared/references/patterns/lua.md @@ -0,0 +1,31 @@ +# Lua Vulnerability Patterns + +## Command injection: os.execute and io.popen + +- Source pattern: HTTP parameters, game/plugin messages, configuration, filenames, or CLI values reach an operating-system command string. +- Sink signature: `os.execute(command)` or `io.popen(command, mode)` with attacker-controlled text. +- Common misuse: Concatenating an untrusted value into a shell command or allowing the value to select command options. +- Expected guard: Avoid the shell, select fixed commands, validate each argument against a strict allowlist, and reject metacharacters and option injection. +- Evidence criteria: Trace an external value to the command boundary and show that a meaningful command fragment remains attacker-controlled. +- False-positive checks: Confirm the call is reachable in the deployed host and is not limited to trusted build or administrator scripts. +- CWE: CWE-78 + +## Path traversal: io.open and filesystem helpers + +- Source pattern: Request paths, uploaded filenames, archive entries, plugin data, or configuration controls a local path. +- Sink signature: `io.open(path, mode)`, `os.remove(path)`, `os.rename(old, new)`, or LuaFileSystem operations on an untrusted path. +- Common misuse: Prefixing a base directory without canonicalization or accepting absolute and parent-relative segments. +- Expected guard: Normalize the final path, reject absolute and parent-relative input, and verify containment under the intended base directory. +- Evidence criteria: Show source-to-sink propagation and a normalized path that escapes the authorized root or reaches a sensitive file. +- False-positive checks: Check allowlists, chroot/container boundaries, read-only modes, and whether only trusted local configuration reaches the sink. +- CWE: CWE-22 + +## Code injection: load and loadstring + +- Source pattern: Network input, templates, plugin content, configuration, or saved state reaches dynamic Lua compilation. +- Sink signature: `load(chunk)`, `loadstring(code)`, or `dofile(filename)` with attacker-controlled code or path. +- Common misuse: Evaluating expressions or plugins from untrusted input with the default global environment. +- Expected guard: Do not compile untrusted text; otherwise use a narrowly constructed environment, strict grammar, and explicit capability allowlist. +- Evidence criteria: Prove control of compiled text or loaded file and identify which sensitive globals or capabilities remain accessible. +- False-positive checks: Confirm text is not a fixed internal script and verify that a restricted environment actually blocks filesystem, process, and network access. +- CWE: CWE-94 diff --git a/shared/references/patterns/r.md b/shared/references/patterns/r.md new file mode 100644 index 0000000..3f43b01 --- /dev/null +++ b/shared/references/patterns/r.md @@ -0,0 +1,31 @@ +# R Vulnerability Patterns + +## Command injection: system and shell + +- Source pattern: HTTP parameters, Shiny inputs, imported table values, command-line arguments, or configuration reaches an operating-system command. +- Sink signature: `system(command)`, `system2(command, args)`, `shell(command)`, or `pipe(description)` with attacker-controlled text. +- Common misuse: Concatenating a filename, URL, format option, or user expression into one shell string. +- Expected guard: Fixed executable selection, argument arrays, strict allowlists, and no shell interpretation of untrusted text. +- Evidence criteria: Trace the untrusted value into the executable or argument boundary and show that validation does not exclude shell metacharacters or option injection. +- False-positive checks: Confirm the value is externally controlled, the call is reachable, and the command is not a fixed developer-only maintenance script. +- CWE: CWE-78 + +## Path traversal: file and archive paths + +- Source pattern: Request data, Shiny upload names, imported metadata, or package configuration controls a filesystem or archive member path. +- Sink signature: `file(path)`, `readLines(path)`, `file.copy(from, to)`, `unzip(zipfile, files, exdir)`, or `untar(tarfile, files)`. +- Common misuse: Joining an untrusted relative path to a base directory without containment checks, or extracting archive members with traversal segments. +- Expected guard: Canonicalize the destination, reject absolute and parent-relative paths, and verify containment below the intended base. +- Evidence criteria: Show the source-to-sink path and demonstrate that canonicalized output can escape the authorized directory. +- False-positive checks: Check archive-library defaults, explicit member filters, sandboxing, and whether only trusted local files reach the sink. +- CWE: CWE-22 + +## Unsafe deserialization: unserialize + +- Source pattern: Uploaded RDS data, cache entries, message payloads, or network responses reach R object deserialization. +- Sink signature: `unserialize(connection)`, `readRDS(file)`, or `load(file)` on attacker-controlled bytes. +- Common misuse: Treating serialized R objects from an untrusted source as inert data without validating origin or allowed object shape. +- Expected guard: Accept only trusted artifacts, authenticate content, use a constrained interchange format, and isolate unavoidable parsing. +- Evidence criteria: Prove the attacker controls serialized bytes and identify a reachable behavior or resource impact caused during or after object loading. +- False-positive checks: Confirm signatures or checksums are not verified and avoid claiming code execution from sink presence alone. +- CWE: CWE-502 diff --git a/shared/scripts/run_evals.py b/shared/scripts/run_evals.py new file mode 100644 index 0000000..ea1a9be --- /dev/null +++ b/shared/scripts/run_evals.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Run existing skill eval checkers through one deterministic interface.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from dataclasses import asdict, dataclass +from pathlib import Path, PurePosixPath +from typing import Any, Sequence + + +REPO_ROOT = Path(__file__).resolve().parents[2] +SAFE_SKILL = re.compile(r"^[a-z0-9][a-z0-9-]*$") +MANIFEST_KEYS = {"schema_version", "cases"} +CASE_KEYS = {"id", "skill", "eval_id", "checker", "output"} + + +class EvalConfigurationError(ValueError): + """Raised when eval configuration is incomplete or unsafe.""" + + +@dataclass(frozen=True) +class EvalCase: + id: str + skill: str + eval_id: int + checker: Path + output: Path + root: Path + + +@dataclass(frozen=True) +class EvalResult: + id: str + skill: str + eval_id: int + passed: bool + duration_ms: int + stdout: str + stderr: str + + +def load_stable_cases(root: Path = REPO_ROOT, manifest_path: Path | None = None) -> list[EvalCase]: + root = root.resolve() + path = (manifest_path or root / "shared" / "evals" / "stable.json").resolve() + _require_below_root(path, root, "manifest") + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise EvalConfigurationError(f"manifest cannot be read: {path}: {error}") from error + if not isinstance(value, dict): + raise EvalConfigurationError("manifest must be an object") + _reject_unknown(value, MANIFEST_KEYS, "manifest") + if value.get("schema_version") != "1": + raise EvalConfigurationError("manifest schema_version must be 1") + raw_cases = value.get("cases") + if not isinstance(raw_cases, list) or not raw_cases: + raise EvalConfigurationError("manifest cases must be a non-empty list") + + cases: list[EvalCase] = [] + ids: set[str] = set() + for index, raw in enumerate(raw_cases): + if not isinstance(raw, dict): + raise EvalConfigurationError(f"cases[{index}] must be an object") + _reject_unknown(raw, CASE_KEYS, f"cases[{index}]") + missing = sorted(CASE_KEYS - set(raw)) + if missing: + raise EvalConfigurationError(f"cases[{index}] missing fields: {', '.join(missing)}") + case_id = _canonical_text(raw["id"], f"cases[{index}].id") + if case_id in ids: + raise EvalConfigurationError(f"duplicate eval case id: {case_id}") + ids.add(case_id) + skill = _safe_skill(raw["skill"]) + eval_id = _eval_id(raw["eval_id"]) + checker = _manifest_file(root, raw["checker"], f"cases[{index}].checker") + output = _manifest_file(root, raw["output"], f"cases[{index}].output") + expected_prefix = (root / "skills" / skill / "scripts").resolve() + _require_below_root(checker, expected_prefix, f"cases[{index}].checker") + cases.append(EvalCase(case_id, skill, eval_id, checker, output, root)) + return cases + + +def build_targeted_case(root: Path, skill: str, eval_id: int, output: Path) -> EvalCase: + root = root.resolve() + safe_skill = _safe_skill(skill) + normalized_id = _eval_id(eval_id) + checker = root / "skills" / safe_skill / "scripts" / "check_output.py" + if not checker.is_file(): + raise EvalConfigurationError(f"skill checker does not exist: {checker}") + output_path = output.expanduser().resolve() + if not output_path.is_file(): + raise EvalConfigurationError(f"eval output does not exist: {output_path}") + return EvalCase( + id=f"{safe_skill}-{normalized_id}", + skill=safe_skill, + eval_id=normalized_id, + checker=checker, + output=output_path, + root=root, + ) + + +def run_cases(cases: Sequence[EvalCase]) -> list[EvalResult]: + results: list[EvalResult] = [] + for case in cases: + started = time.perf_counter() + process = subprocess.run( + [ + sys.executable, + str(case.checker), + "--eval-id", + str(case.eval_id), + "--output", + str(case.output), + ], + cwd=case.root, + capture_output=True, + text=True, + check=False, + ) + duration_ms = max(0, round((time.perf_counter() - started) * 1000)) + results.append( + EvalResult( + id=case.id, + skill=case.skill, + eval_id=case.eval_id, + passed=process.returncode == 0, + duration_ms=duration_ms, + stdout=process.stdout, + stderr=process.stderr, + ) + ) + return results + + +def summarize(results: Sequence[EvalResult]) -> dict[str, Any]: + passed = sum(1 for result in results if result.passed) + failed = len(results) - passed + return { + "schema_version": "1", + "ok": failed == 0, + "total": len(results), + "passed": passed, + "failed": failed, + "duration_ms": sum(result.duration_ms for result in results), + } + + +def render_json(results: Sequence[EvalResult]) -> str: + payload = summarize(results) + payload["results"] = [asdict(result) for result in results] + return json.dumps(payload, indent=2, sort_keys=True) + "\n" + + +def render_junit(results: Sequence[EvalResult]) -> str: + summary = summarize(results) + suite = ET.Element( + "testsuite", + { + "name": "oh-my-vul-evals", + "tests": str(summary["total"]), + "failures": str(summary["failed"]), + "errors": "0", + "time": f"{int(summary['duration_ms']) / 1000:.3f}", + }, + ) + for result in results: + case = ET.SubElement( + suite, + "testcase", + { + "classname": result.skill, + "name": result.id, + "time": f"{result.duration_ms / 1000:.3f}", + }, + ) + if not result.passed: + failure = ET.SubElement(case, "failure", {"message": "eval checker failed"}) + failure.text = (result.stderr or result.stdout).strip() + if result.stdout: + ET.SubElement(case, "system-out").text = result.stdout + if result.stderr: + ET.SubElement(case, "system-err").text = result.stderr + return ET.tostring(suite, encoding="unicode", xml_declaration=True) + "\n" + + +def render_human(results: Sequence[EvalResult]) -> str: + lines = [] + for result in results: + state = "PASS" if result.passed else "FAIL" + lines.append(f"[{state}] {result.id} ({result.duration_ms} ms)") + if not result.passed: + detail = (result.stderr or result.stdout).strip() + if detail: + lines.extend(f" {line}" for line in detail.splitlines()) + summary = summarize(results) + lines.append( + f"evals: {summary['passed']}/{summary['total']} passed, " + f"{summary['failed']} failed ({summary['duration_ms']} ms)" + ) + return "\n".join(lines) + "\n" + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Run oh-my-vul skill eval checks") + parser.add_argument("--format", choices=["human", "json", "junit"], default="human") + parser.add_argument("--manifest", type=Path) + parser.add_argument("--skill") + parser.add_argument("--eval-id", type=int) + parser.add_argument("--output", type=Path) + args = parser.parse_args(argv) + targeted = [args.skill is not None, args.eval_id is not None, args.output is not None] + try: + if any(targeted) and not all(targeted): + raise EvalConfigurationError("--skill, --eval-id, and --output must be supplied together") + if all(targeted): + if args.manifest is not None: + raise EvalConfigurationError("--manifest cannot be combined with targeted options") + cases = [build_targeted_case(REPO_ROOT, args.skill, args.eval_id, args.output)] + else: + cases = load_stable_cases(REPO_ROOT, args.manifest) + results = run_cases(cases) + except EvalConfigurationError as error: + print(f"ERROR: {error}", file=sys.stderr) + return 2 + + if args.format == "json": + sys.stdout.write(render_json(results)) + elif args.format == "junit": + sys.stdout.write(render_junit(results)) + else: + sys.stdout.write(render_human(results)) + return 0 if all(result.passed for result in results) else 1 + + +def _manifest_file(root: Path, value: Any, field: str) -> Path: + text = _canonical_text(value, field) + path = PurePosixPath(text) + if path.is_absolute() or ".." in path.parts or "\\" in text: + raise EvalConfigurationError(f"{field} must be a safe package-relative path") + resolved = (root / text).resolve() + _require_below_root(resolved, root, field) + if not resolved.is_file(): + raise EvalConfigurationError(f"{field} does not exist: {text}") + return resolved + + +def _require_below_root(path: Path, root: Path, field: str) -> None: + try: + path.resolve().relative_to(root.resolve()) + except ValueError as error: + raise EvalConfigurationError(f"{field} path escapes the package root: {path}") from error + + +def _safe_skill(value: Any) -> str: + if not isinstance(value, str) or not SAFE_SKILL.fullmatch(value): + raise EvalConfigurationError("skill must be a lowercase package name") + return value + + +def _eval_id(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise EvalConfigurationError("eval_id must be a non-negative integer") + return value + + +def _canonical_text(value: Any, field: str) -> str: + if not isinstance(value, str) or not value or value != value.strip() or any(ord(char) < 32 for char in value): + raise EvalConfigurationError(f"{field} must be canonical single-line text") + return value + + +def _reject_unknown(value: dict[str, Any], allowed: set[str], field: str) -> None: + unknown = sorted(set(value) - allowed) + if unknown: + raise EvalConfigurationError(f"{field} has unknown fields: {', '.join(unknown)}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shared/scripts/test_run_evals.py b/shared/scripts/test_run_evals.py new file mode 100644 index 0000000..68da9f1 --- /dev/null +++ b/shared/scripts/test_run_evals.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import json +import tempfile +import unittest +import xml.etree.ElementTree as ET +from pathlib import Path + +from .run_evals import ( + EvalConfigurationError, + build_targeted_case, + load_stable_cases, + render_json, + render_junit, + run_cases, + summarize, +) + + +CHECKER = """#!/usr/bin/env python3 +import argparse +from pathlib import Path + +parser = argparse.ArgumentParser() +parser.add_argument('--eval-id', required=True) +parser.add_argument('--output', type=Path, required=True) +args = parser.parse_args() +text = args.output.read_text(encoding='utf-8') +if 'PASS' not in text: + raise SystemExit('fixture assertion failed') +print(f'OK eval {args.eval_id}') +""" + + +class EvalRunnerTests(unittest.TestCase): + def test_stable_run_captures_passes_failures_and_summary(self) -> None: + with tempfile.TemporaryDirectory(prefix="omv-evals-") as tmp: + root = Path(tmp) + self._write_fixture(root) + cases = load_stable_cases(root) + results = run_cases(cases) + summary = summarize(results) + + self.assertEqual([case.id for case in cases], ["demo-pass", "demo-fail"]) + self.assertEqual(summary["total"], 2) + self.assertEqual(summary["passed"], 1) + self.assertEqual(summary["failed"], 1) + self.assertFalse(summary["ok"]) + self.assertEqual(results[0].stdout.strip(), "OK eval 1") + self.assertIn("fixture assertion failed", results[1].stderr) + + def test_json_and_junit_preserve_the_same_result_counts(self) -> None: + with tempfile.TemporaryDirectory(prefix="omv-evals-") as tmp: + root = Path(tmp) + self._write_fixture(root) + results = run_cases(load_stable_cases(root)) + + payload = json.loads(render_json(results)) + self.assertEqual(payload["schema_version"], "1") + self.assertEqual((payload["total"], payload["passed"], payload["failed"]), (2, 1, 1)) + self.assertEqual(len(payload["results"]), 2) + + suite = ET.fromstring(render_junit(results)) + self.assertEqual(suite.tag, "testsuite") + self.assertEqual(suite.attrib["tests"], "2") + self.assertEqual(suite.attrib["failures"], "1") + self.assertEqual(len(suite.findall("testcase")), 2) + self.assertEqual(len(suite.findall("testcase/failure")), 1) + + def test_targeted_case_uses_existing_checker_and_explicit_output(self) -> None: + with tempfile.TemporaryDirectory(prefix="omv-evals-") as tmp: + root = Path(tmp) + self._write_fixture(root) + output = root / "pass.md" + case = build_targeted_case(root, "demo", 7, output) + result = run_cases([case])[0] + + self.assertEqual(case.id, "demo-7") + self.assertTrue(result.passed) + + def test_runner_rejects_unsafe_skill_and_manifest_paths(self) -> None: + with tempfile.TemporaryDirectory(prefix="omv-evals-") as tmp: + root = Path(tmp) + self._write_fixture(root) + with self.assertRaisesRegex(EvalConfigurationError, "skill"): + build_targeted_case(root, "../demo", 1, root / "pass.md") + + outside = root.parent / "outside-evals.json" + outside.write_text('{"schema_version":"1","cases":[]}', encoding="utf-8") + try: + with self.assertRaisesRegex(EvalConfigurationError, "manifest"): + load_stable_cases(root, outside) + finally: + outside.unlink(missing_ok=True) + + @staticmethod + def _write_fixture(root: Path) -> None: + checker = root / "skills" / "demo" / "scripts" / "check_output.py" + checker.parent.mkdir(parents=True, exist_ok=True) + checker.write_text(CHECKER, encoding="utf-8") + (root / "pass.md").write_text("PASS\n", encoding="utf-8") + (root / "fail.md").write_text("FAIL\n", encoding="utf-8") + manifest = root / "shared" / "evals" / "stable.json" + manifest.parent.mkdir(parents=True, exist_ok=True) + manifest.write_text( + json.dumps( + { + "schema_version": "1", + "cases": [ + { + "id": "demo-pass", + "skill": "demo", + "eval_id": 1, + "checker": "skills/demo/scripts/check_output.py", + "output": "pass.md", + }, + { + "id": "demo-fail", + "skill": "demo", + "eval_id": 2, + "checker": "skills/demo/scripts/check_output.py", + "output": "fail.md", + }, + ], + } + ), + encoding="utf-8", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/skills/.npmignore b/skills/.npmignore new file mode 100644 index 0000000..a6c5492 --- /dev/null +++ b/skills/.npmignore @@ -0,0 +1,5 @@ +**/__pycache__/ +**/*.pyc +**/*.pyd +**/*.pyo +**/test_*.py diff --git a/skills/omv-audit/SKILL.md b/skills/omv-audit/SKILL.md index ff1fffd..21044c9 100644 --- a/skills/omv-audit/SKILL.md +++ b/skills/omv-audit/SKILL.md @@ -25,7 +25,7 @@ Stay in passive research mode: read public source code only. Do not send request - 审计方法论与置信度框架:`references/audit-playbook.md` - CVSS v3.1 度量决策表:`references/shared/cvss-builder.md` -- 生态系统 sink registry(npm/Python/Go/Rust/Java/Ruby):`references/patterns/npm.md` 等同目录文件 +- 14 个生态的 PatternPack manifest 位于 `references/pattern-packs/`,对应 Markdown registry 位于 `references/patterns/` - Evidence.v1 字段定义与 evidence/submission 评分规则:`contracts/evidence.v1.yaml` - ThreatMap.v1 图证据字段:`contracts/threat-map.v1.yaml` - Verification.v1 对抗复核 sidecar:`contracts/verification.v1.yaml` @@ -47,7 +47,7 @@ Stay in passive research mode: read public source code only. Do not send request **如何达到目标,由你自主决定。** 根据 finding 的实际情况——漏洞类别、已有线索、代码结构——自主选择切入点、阅读哪些文件、花多少精力在每个环节。参考 `references/audit-playbook.md` 获取思维框架,但不要把它当作执行脚本。 -如果 finding 属于 npm、Python、Go、Rust、Java 或 Ruby,按需加载 `references/patterns/` 下对应生态文件,用其中的 source pattern、sink signature、expected guard、evidence criteria、false-positive checks 和 CWE 映射辅助判断。Pattern registry 是方法论,不是真实漏洞案例库;不要加载无关生态 registry。 +如果 finding 属于任一受支持生态,先加载 `references/pattern-packs/` 下对应 JSON,再按需加载 `references/patterns/` 下对应 Markdown registry,用其中的 source pattern、sink signature、expected guard、evidence criteria、false-positive checks 和 CWE 映射辅助判断。Pattern registry 是方法论,不是真实漏洞案例库;不要加载无关生态 registry。 当证据链足够清楚时,生成可选 ThreatMap.v1 sidecar,记录 source → transform → sink 的 dataflow 路径: @@ -110,15 +110,6 @@ stage 6: synthesize → 汇聚后写 Evidence.v1 + ThreatMap.v1 + Verifi 每个 subagent 的定义文件在 `.claude/agents/` 目录,frontmatter 声明了 tools 白名单、model、以及行为描述。Claude Code 根据描述自动将自然语言委托请求路由到正确的 subagent。 -以下是硬约束,不可逾越: - -1. **不攻击线上服务** — 所有分析基于公开源代码和本地环境 -2. **不自动执行 PoC** — `evidence.reproducer` 只写步骤描述,不自动运行 -3. **submission score < 75 时不得升为 confirmed** — 低分时保留 `candidate` 状态并列出缺失项 -4. **blocked 必须填写 `blockers` 列表** — 每条 blocker 说明具体原因 -5. **不伪造证据** — 无法验证的字段保留 `unknown`,在 `provenance.unverified_fields` 中列出 -6. **CLI validation 是硬门槛** — 只有 `omv findings validate ` 返回 OK 时,才允许把结论作为 confirmed 交给 `/omv-report` - ## 结论规则 审计结束后根据证据完整性选择结论: diff --git a/skills/omv-audit/references/pattern-packs/csharp.json b/skills/omv-audit/references/pattern-packs/csharp.json new file mode 100644 index 0000000..52835f4 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/csharp.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "csharp", + "ecosystem": "csharp", + "aliases": ["csharp", "c#", "dotnet", "nuget"], + "reference": "references/patterns/csharp.md", + "vulnerability_classes": ["unsafe-deserialization", "path-traversal", "ssrf"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/dart.json b/skills/omv-audit/references/pattern-packs/dart.json new file mode 100644 index 0000000..c293b93 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/dart.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "dart", + "ecosystem": "dart", + "aliases": ["dart", "flutter", "pub"], + "reference": "references/patterns/dart.md", + "vulnerability_classes": ["path-traversal", "ssrf", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/elixir.json b/skills/omv-audit/references/pattern-packs/elixir.json new file mode 100644 index 0000000..e063484 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/elixir.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "elixir", + "ecosystem": "elixir", + "aliases": ["elixir", "hex"], + "reference": "references/patterns/elixir.md", + "vulnerability_classes": ["code-injection", "resource-exhaustion", "sql-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/go.json b/skills/omv-audit/references/pattern-packs/go.json new file mode 100644 index 0000000..b578727 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/go.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "go", + "ecosystem": "go", + "aliases": ["go", "golang"], + "reference": "references/patterns/go.md", + "vulnerability_classes": ["ssrf", "path-traversal", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/java.json b/skills/omv-audit/references/pattern-packs/java.json new file mode 100644 index 0000000..bfea7fc --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/java.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "java", + "ecosystem": "java", + "aliases": ["java", "maven", "gradle"], + "reference": "references/patterns/java.md", + "vulnerability_classes": ["ssrf", "xxe", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/lua.json b/skills/omv-audit/references/pattern-packs/lua.json new file mode 100644 index 0000000..88b0708 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/lua.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "lua", + "ecosystem": "lua", + "aliases": ["lua", "luarocks"], + "reference": "references/patterns/lua.md", + "vulnerability_classes": ["command-injection", "path-traversal", "code-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/npm.json b/skills/omv-audit/references/pattern-packs/npm.json new file mode 100644 index 0000000..6d035e0 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/npm.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "npm", + "ecosystem": "npm", + "aliases": ["npm", "node", "nodejs", "javascript", "typescript"], + "reference": "references/patterns/npm.md", + "vulnerability_classes": ["ssrf", "path-traversal", "prototype-pollution"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/perl.json b/skills/omv-audit/references/pattern-packs/perl.json new file mode 100644 index 0000000..a45d29a --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/perl.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "perl", + "ecosystem": "perl", + "aliases": ["perl", "cpan"], + "reference": "references/patterns/perl.md", + "vulnerability_classes": ["command-injection", "path-traversal", "redos"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/php.json b/skills/omv-audit/references/pattern-packs/php.json new file mode 100644 index 0000000..b2e0216 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/php.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "php", + "ecosystem": "php", + "aliases": ["php", "composer", "packagist"], + "reference": "references/patterns/php.md", + "vulnerability_classes": ["unsafe-deserialization", "sql-injection", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/python.json b/skills/omv-audit/references/pattern-packs/python.json new file mode 100644 index 0000000..f89c10b --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/python.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "python", + "ecosystem": "python", + "aliases": ["python", "pypi", "pip"], + "reference": "references/patterns/python.md", + "vulnerability_classes": ["ssrf", "unsafe-yaml", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/r.json b/skills/omv-audit/references/pattern-packs/r.json new file mode 100644 index 0000000..9e5ffa6 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/r.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "r", + "ecosystem": "r", + "aliases": ["r", "cran"], + "reference": "references/patterns/r.md", + "vulnerability_classes": ["command-injection", "path-traversal", "unsafe-deserialization"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/ruby.json b/skills/omv-audit/references/pattern-packs/ruby.json new file mode 100644 index 0000000..a311ad4 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/ruby.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "ruby", + "ecosystem": "ruby", + "aliases": ["ruby", "rubygems", "gem"], + "reference": "references/patterns/ruby.md", + "vulnerability_classes": ["ssrf", "unsafe-deserialization", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/rust.json b/skills/omv-audit/references/pattern-packs/rust.json new file mode 100644 index 0000000..3eea629 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/rust.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "rust", + "ecosystem": "rust", + "aliases": ["rust", "cargo", "crates.io"], + "reference": "references/patterns/rust.md", + "vulnerability_classes": ["ssrf", "path-traversal", "unsafe-deserialization"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/pattern-packs/swift.json b/skills/omv-audit/references/pattern-packs/swift.json new file mode 100644 index 0000000..fa2d552 --- /dev/null +++ b/skills/omv-audit/references/pattern-packs/swift.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "swift", + "ecosystem": "swift", + "aliases": ["swift", "spm", "cocoapods"], + "reference": "references/patterns/swift.md", + "vulnerability_classes": ["path-traversal", "insecure-tls", "sql-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-audit/references/patterns/lua.md b/skills/omv-audit/references/patterns/lua.md new file mode 100644 index 0000000..3e0320a --- /dev/null +++ b/skills/omv-audit/references/patterns/lua.md @@ -0,0 +1,31 @@ +# Lua Vulnerability Patterns + +## Command injection: os.execute and io.popen + +- Source pattern: HTTP parameters, game/plugin messages, configuration, filenames, or CLI values reach an operating-system command string. +- Sink signature: `os.execute(command)` or `io.popen(command, mode)` with attacker-controlled text. +- Common misuse: Concatenating an untrusted value into a shell command or allowing the value to select command options. +- Expected guard: Avoid the shell, select fixed commands, validate each argument against a strict allowlist, and reject metacharacters and option injection. +- Evidence criteria: Trace an external value to the command boundary and show that a meaningful command fragment remains attacker-controlled. +- False-positive checks: Confirm the call is reachable in the deployed host and is not limited to trusted build or administrator scripts. +- CWE: CWE-78 + +## Path traversal: io.open and filesystem helpers + +- Source pattern: Request paths, uploaded filenames, archive entries, plugin data, or configuration controls a local path. +- Sink signature: `io.open(path, mode)`, `os.remove(path)`, `os.rename(old, new)`, or LuaFileSystem operations on an untrusted path. +- Common misuse: Prefixing a base directory without canonicalization or accepting absolute and parent-relative segments. +- Expected guard: Normalize the final path, reject absolute and parent-relative input, and verify containment under the intended base directory. +- Evidence criteria: Show source-to-sink propagation and a normalized path that escapes the authorized root or reaches a sensitive file. +- False-positive checks: Check allowlists, chroot/container boundaries, read-only modes, and whether only trusted local configuration reaches the sink. +- CWE: CWE-22 + +## Code injection: load and loadstring + +- Source pattern: Network input, templates, plugin content, configuration, or saved state reaches dynamic Lua compilation. +- Sink signature: `load(chunk)`, `loadstring(code)`, or `dofile(filename)` with attacker-controlled code or path. +- Common misuse: Evaluating expressions or plugins from untrusted input with the default global environment. +- Expected guard: Do not compile untrusted text; otherwise use a narrowly constructed environment, strict grammar, and explicit capability allowlist. +- Evidence criteria: Prove control of compiled text or loaded file and identify which sensitive globals or capabilities remain accessible. +- False-positive checks: Confirm text is not a fixed internal script and verify that a restricted environment actually blocks filesystem, process, and network access. +- CWE: CWE-94 diff --git a/skills/omv-audit/references/patterns/r.md b/skills/omv-audit/references/patterns/r.md new file mode 100644 index 0000000..3f43b01 --- /dev/null +++ b/skills/omv-audit/references/patterns/r.md @@ -0,0 +1,31 @@ +# R Vulnerability Patterns + +## Command injection: system and shell + +- Source pattern: HTTP parameters, Shiny inputs, imported table values, command-line arguments, or configuration reaches an operating-system command. +- Sink signature: `system(command)`, `system2(command, args)`, `shell(command)`, or `pipe(description)` with attacker-controlled text. +- Common misuse: Concatenating a filename, URL, format option, or user expression into one shell string. +- Expected guard: Fixed executable selection, argument arrays, strict allowlists, and no shell interpretation of untrusted text. +- Evidence criteria: Trace the untrusted value into the executable or argument boundary and show that validation does not exclude shell metacharacters or option injection. +- False-positive checks: Confirm the value is externally controlled, the call is reachable, and the command is not a fixed developer-only maintenance script. +- CWE: CWE-78 + +## Path traversal: file and archive paths + +- Source pattern: Request data, Shiny upload names, imported metadata, or package configuration controls a filesystem or archive member path. +- Sink signature: `file(path)`, `readLines(path)`, `file.copy(from, to)`, `unzip(zipfile, files, exdir)`, or `untar(tarfile, files)`. +- Common misuse: Joining an untrusted relative path to a base directory without containment checks, or extracting archive members with traversal segments. +- Expected guard: Canonicalize the destination, reject absolute and parent-relative paths, and verify containment below the intended base. +- Evidence criteria: Show the source-to-sink path and demonstrate that canonicalized output can escape the authorized directory. +- False-positive checks: Check archive-library defaults, explicit member filters, sandboxing, and whether only trusted local files reach the sink. +- CWE: CWE-22 + +## Unsafe deserialization: unserialize + +- Source pattern: Uploaded RDS data, cache entries, message payloads, or network responses reach R object deserialization. +- Sink signature: `unserialize(connection)`, `readRDS(file)`, or `load(file)` on attacker-controlled bytes. +- Common misuse: Treating serialized R objects from an untrusted source as inert data without validating origin or allowed object shape. +- Expected guard: Accept only trusted artifacts, authenticate content, use a constrained interchange format, and isolate unavoidable parsing. +- Evidence criteria: Prove the attacker controls serialized bytes and identify a reachable behavior or resource impact caused during or after object loading. +- False-positive checks: Confirm signatures or checksums are not verified and avoid claiming code execution from sink presence alone. +- CWE: CWE-502 diff --git a/skills/omv-find/SKILL.md b/skills/omv-find/SKILL.md index 9301583..bbb857e 100644 --- a/skills/omv-find/SKILL.md +++ b/skills/omv-find/SKILL.md @@ -36,7 +36,7 @@ Load only the files needed for the request: - Vulnerability source/sink/guard patterns: `references/shared/vuln-patterns.md` - Research-radar lanes, diff signals, novelty, duplicate risk, audit-readiness fields: `references/research-radar.md` - Pattern-pack discovery for archive extractors, renderers, template engines, config loaders, media tools, webhook clients, and upload handlers: `references/pattern-packs.md` -- Ecosystem sink registry for core lanes: references under `references/patterns/npm.md` +- Ecosystem PatternPack manifests live under `references/pattern-packs/`; load the matching Markdown registry under `references/patterns/`. - Scoring, filtering, confidence rules: `references/scoring.md` - Required final table, audit tips, freshness notes, invalid-request template: `references/output-contract.md` - Candidate-list schema for structured finder outputs: `contracts/candidate-list.v1.yaml` @@ -44,7 +44,7 @@ Load only the files needed for the request: For narrow requests, use `rg` inside the relevant reference to read only the needed ecosystem or vulnerability section. For `--vuln all`, inspect the project type first, then load the most likely vulnerability sections. -For npm, Python, Go, Rust, Java, and Ruby requests, load the matching file under `references/patterns/` when forming source -> sink -> guard hypotheses. Pattern files are methodology registries, not concrete vulnerability examples; do not load unrelated ecosystem registries. +For any of the 14 supported ecosystems, load the matching JSON file under `references/pattern-packs/` and the matching Markdown file under `references/patterns/` when forming source -> sink -> guard hypotheses. Pattern files are methodology registries, not concrete vulnerability examples; do not load unrelated ecosystem registries. Load `references/research-radar.md` only when the user asks for creative/radar/portfolio ideas, recent changes, novelty, duplicate resistance, or audit readiness. Load `references/pattern-packs.md` only when the user describes package types or playbooks such as archive extractors, renderers, config loaders, media processors, webhook clients, or upload handlers. diff --git a/skills/omv-find/references/pattern-packs/csharp.json b/skills/omv-find/references/pattern-packs/csharp.json new file mode 100644 index 0000000..52835f4 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/csharp.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "csharp", + "ecosystem": "csharp", + "aliases": ["csharp", "c#", "dotnet", "nuget"], + "reference": "references/patterns/csharp.md", + "vulnerability_classes": ["unsafe-deserialization", "path-traversal", "ssrf"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/dart.json b/skills/omv-find/references/pattern-packs/dart.json new file mode 100644 index 0000000..c293b93 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/dart.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "dart", + "ecosystem": "dart", + "aliases": ["dart", "flutter", "pub"], + "reference": "references/patterns/dart.md", + "vulnerability_classes": ["path-traversal", "ssrf", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/elixir.json b/skills/omv-find/references/pattern-packs/elixir.json new file mode 100644 index 0000000..e063484 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/elixir.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "elixir", + "ecosystem": "elixir", + "aliases": ["elixir", "hex"], + "reference": "references/patterns/elixir.md", + "vulnerability_classes": ["code-injection", "resource-exhaustion", "sql-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/go.json b/skills/omv-find/references/pattern-packs/go.json new file mode 100644 index 0000000..b578727 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/go.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "go", + "ecosystem": "go", + "aliases": ["go", "golang"], + "reference": "references/patterns/go.md", + "vulnerability_classes": ["ssrf", "path-traversal", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/java.json b/skills/omv-find/references/pattern-packs/java.json new file mode 100644 index 0000000..bfea7fc --- /dev/null +++ b/skills/omv-find/references/pattern-packs/java.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "java", + "ecosystem": "java", + "aliases": ["java", "maven", "gradle"], + "reference": "references/patterns/java.md", + "vulnerability_classes": ["ssrf", "xxe", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/lua.json b/skills/omv-find/references/pattern-packs/lua.json new file mode 100644 index 0000000..88b0708 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/lua.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "lua", + "ecosystem": "lua", + "aliases": ["lua", "luarocks"], + "reference": "references/patterns/lua.md", + "vulnerability_classes": ["command-injection", "path-traversal", "code-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/npm.json b/skills/omv-find/references/pattern-packs/npm.json new file mode 100644 index 0000000..6d035e0 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/npm.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "npm", + "ecosystem": "npm", + "aliases": ["npm", "node", "nodejs", "javascript", "typescript"], + "reference": "references/patterns/npm.md", + "vulnerability_classes": ["ssrf", "path-traversal", "prototype-pollution"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/perl.json b/skills/omv-find/references/pattern-packs/perl.json new file mode 100644 index 0000000..a45d29a --- /dev/null +++ b/skills/omv-find/references/pattern-packs/perl.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "perl", + "ecosystem": "perl", + "aliases": ["perl", "cpan"], + "reference": "references/patterns/perl.md", + "vulnerability_classes": ["command-injection", "path-traversal", "redos"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/php.json b/skills/omv-find/references/pattern-packs/php.json new file mode 100644 index 0000000..b2e0216 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/php.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "php", + "ecosystem": "php", + "aliases": ["php", "composer", "packagist"], + "reference": "references/patterns/php.md", + "vulnerability_classes": ["unsafe-deserialization", "sql-injection", "command-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/python.json b/skills/omv-find/references/pattern-packs/python.json new file mode 100644 index 0000000..f89c10b --- /dev/null +++ b/skills/omv-find/references/pattern-packs/python.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "python", + "ecosystem": "python", + "aliases": ["python", "pypi", "pip"], + "reference": "references/patterns/python.md", + "vulnerability_classes": ["ssrf", "unsafe-yaml", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/r.json b/skills/omv-find/references/pattern-packs/r.json new file mode 100644 index 0000000..9e5ffa6 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/r.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "r", + "ecosystem": "r", + "aliases": ["r", "cran"], + "reference": "references/patterns/r.md", + "vulnerability_classes": ["command-injection", "path-traversal", "unsafe-deserialization"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/ruby.json b/skills/omv-find/references/pattern-packs/ruby.json new file mode 100644 index 0000000..a311ad4 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/ruby.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "ruby", + "ecosystem": "ruby", + "aliases": ["ruby", "rubygems", "gem"], + "reference": "references/patterns/ruby.md", + "vulnerability_classes": ["ssrf", "unsafe-deserialization", "path-traversal"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/rust.json b/skills/omv-find/references/pattern-packs/rust.json new file mode 100644 index 0000000..3eea629 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/rust.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "rust", + "ecosystem": "rust", + "aliases": ["rust", "cargo", "crates.io"], + "reference": "references/patterns/rust.md", + "vulnerability_classes": ["ssrf", "path-traversal", "unsafe-deserialization"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/pattern-packs/swift.json b/skills/omv-find/references/pattern-packs/swift.json new file mode 100644 index 0000000..fa2d552 --- /dev/null +++ b/skills/omv-find/references/pattern-packs/swift.json @@ -0,0 +1,9 @@ +{ + "schema_version": "1", + "id": "swift", + "ecosystem": "swift", + "aliases": ["swift", "spm", "cocoapods"], + "reference": "references/patterns/swift.md", + "vulnerability_classes": ["path-traversal", "insecure-tls", "sql-injection"], + "consumers": ["omv-audit", "omv-find"] +} diff --git a/skills/omv-find/references/patterns/lua.md b/skills/omv-find/references/patterns/lua.md new file mode 100644 index 0000000..3e0320a --- /dev/null +++ b/skills/omv-find/references/patterns/lua.md @@ -0,0 +1,31 @@ +# Lua Vulnerability Patterns + +## Command injection: os.execute and io.popen + +- Source pattern: HTTP parameters, game/plugin messages, configuration, filenames, or CLI values reach an operating-system command string. +- Sink signature: `os.execute(command)` or `io.popen(command, mode)` with attacker-controlled text. +- Common misuse: Concatenating an untrusted value into a shell command or allowing the value to select command options. +- Expected guard: Avoid the shell, select fixed commands, validate each argument against a strict allowlist, and reject metacharacters and option injection. +- Evidence criteria: Trace an external value to the command boundary and show that a meaningful command fragment remains attacker-controlled. +- False-positive checks: Confirm the call is reachable in the deployed host and is not limited to trusted build or administrator scripts. +- CWE: CWE-78 + +## Path traversal: io.open and filesystem helpers + +- Source pattern: Request paths, uploaded filenames, archive entries, plugin data, or configuration controls a local path. +- Sink signature: `io.open(path, mode)`, `os.remove(path)`, `os.rename(old, new)`, or LuaFileSystem operations on an untrusted path. +- Common misuse: Prefixing a base directory without canonicalization or accepting absolute and parent-relative segments. +- Expected guard: Normalize the final path, reject absolute and parent-relative input, and verify containment under the intended base directory. +- Evidence criteria: Show source-to-sink propagation and a normalized path that escapes the authorized root or reaches a sensitive file. +- False-positive checks: Check allowlists, chroot/container boundaries, read-only modes, and whether only trusted local configuration reaches the sink. +- CWE: CWE-22 + +## Code injection: load and loadstring + +- Source pattern: Network input, templates, plugin content, configuration, or saved state reaches dynamic Lua compilation. +- Sink signature: `load(chunk)`, `loadstring(code)`, or `dofile(filename)` with attacker-controlled code or path. +- Common misuse: Evaluating expressions or plugins from untrusted input with the default global environment. +- Expected guard: Do not compile untrusted text; otherwise use a narrowly constructed environment, strict grammar, and explicit capability allowlist. +- Evidence criteria: Prove control of compiled text or loaded file and identify which sensitive globals or capabilities remain accessible. +- False-positive checks: Confirm text is not a fixed internal script and verify that a restricted environment actually blocks filesystem, process, and network access. +- CWE: CWE-94 diff --git a/skills/omv-find/references/patterns/r.md b/skills/omv-find/references/patterns/r.md new file mode 100644 index 0000000..3f43b01 --- /dev/null +++ b/skills/omv-find/references/patterns/r.md @@ -0,0 +1,31 @@ +# R Vulnerability Patterns + +## Command injection: system and shell + +- Source pattern: HTTP parameters, Shiny inputs, imported table values, command-line arguments, or configuration reaches an operating-system command. +- Sink signature: `system(command)`, `system2(command, args)`, `shell(command)`, or `pipe(description)` with attacker-controlled text. +- Common misuse: Concatenating a filename, URL, format option, or user expression into one shell string. +- Expected guard: Fixed executable selection, argument arrays, strict allowlists, and no shell interpretation of untrusted text. +- Evidence criteria: Trace the untrusted value into the executable or argument boundary and show that validation does not exclude shell metacharacters or option injection. +- False-positive checks: Confirm the value is externally controlled, the call is reachable, and the command is not a fixed developer-only maintenance script. +- CWE: CWE-78 + +## Path traversal: file and archive paths + +- Source pattern: Request data, Shiny upload names, imported metadata, or package configuration controls a filesystem or archive member path. +- Sink signature: `file(path)`, `readLines(path)`, `file.copy(from, to)`, `unzip(zipfile, files, exdir)`, or `untar(tarfile, files)`. +- Common misuse: Joining an untrusted relative path to a base directory without containment checks, or extracting archive members with traversal segments. +- Expected guard: Canonicalize the destination, reject absolute and parent-relative paths, and verify containment below the intended base. +- Evidence criteria: Show the source-to-sink path and demonstrate that canonicalized output can escape the authorized directory. +- False-positive checks: Check archive-library defaults, explicit member filters, sandboxing, and whether only trusted local files reach the sink. +- CWE: CWE-22 + +## Unsafe deserialization: unserialize + +- Source pattern: Uploaded RDS data, cache entries, message payloads, or network responses reach R object deserialization. +- Sink signature: `unserialize(connection)`, `readRDS(file)`, or `load(file)` on attacker-controlled bytes. +- Common misuse: Treating serialized R objects from an untrusted source as inert data without validating origin or allowed object shape. +- Expected guard: Accept only trusted artifacts, authenticate content, use a constrained interchange format, and isolate unavoidable parsing. +- Evidence criteria: Prove the attacker controls serialized bytes and identify a reachable behavior or resource impact caused during or after object loading. +- False-positive checks: Confirm signatures or checksums are not verified and avoid claiming code execution from sink presence alone. +- CWE: CWE-502 diff --git a/skills/omv-report/SKILL.md b/skills/omv-report/SKILL.md index f3ab427..f626361 100644 --- a/skills/omv-report/SKILL.md +++ b/skills/omv-report/SKILL.md @@ -13,6 +13,8 @@ Load these when needed — do not load all at once: - **`references/shared/cvss-builder.md`** — metric-by-metric CVSS v3.1 decision table with common vector combinations. Read when computing the CVSS score. - **`contracts/evidence.v1.yaml`** — structured input contract from `omv-find`; read when the user provides a handoff packet or asks to continue from finder results. - **`contracts/verification.v1.yaml`** — adversarial review sidecar. Read when `.omv/verifications/.yaml` exists or the user asks for a high-confidence report. +- **`contracts/source-ref.v1.yaml`** — optional local source identity sidecar. Read when `.omv/sources/.yaml` exists; a locator is recorded input, not proof of remote authenticity. +- **`contracts/report-provenance.v1.yaml`** — generated report input manifest shape used by `omv report provenance`. - **`references/report-templates.md`** — reusable VulDB, GHSA, OSV, and standalone Markdown advisory templates. Read when the user requests a specific report format. - **`references/examples/xss-npm.md`** — complete filled report for a click-triggered XSS in an npm package. - **`references/examples/path-traversal-go.md`** — complete filled report for an unauthenticated path traversal in a Go module. @@ -51,7 +53,8 @@ Before writing any submission-ready report from finder output, consume the local 3. Run or ask for `omv findings validate --json` when CLI tools are available. 4. If `.omv/threatmaps/.yaml` exists, run or ask for `omv threat-map validate --json`. 5. If `.omv/verifications/.yaml` exists or the user wants a strict pre-submission gate, run or ask for `omv verification validate --json` and `omv findings doctor --strict-verification --json`. -6. If CLI tools are unavailable, validate manually against `contracts/evidence.v1.yaml` and `contracts/verification.v1.yaml` when relevant, and say that deterministic validation was not run. +6. If `.omv/sources/.yaml` exists, run or ask for `omv sources validate --json`; treat a stale hash as a traceability warning, not as proof or disproof of the vulnerability. +7. If CLI tools are unavailable, validate manually against `contracts/evidence.v1.yaml`, `contracts/verification.v1.yaml`, and `contracts/source-ref.v1.yaml` when relevant, and say that deterministic validation was not run. Use the validation result to choose output mode: @@ -82,7 +85,17 @@ python3 ~/.claude/skills/omv-report/scripts/render_template.py \ The renderer fills all structural fields (package, versions, CVSS, CWE, source→sink→guard, reproducer, dedup checklist) and leaves `[DRAFT: ...]` markers for prose sections. Fill in every `[DRAFT: ...]` before submitting. Do not submit placeholders. -After producing a submission-ready report for a confirmed finding, suggest removing it from the active local queue: +After producing a submission-ready report for a confirmed finding, record and check its local inputs when the report was written under `.omv/reports//`: + +```bash +omv sources init # optional; derives only known Evidence source facts +omv report provenance # run after report files are written +omv report artifacts +``` + +SourceRef and report provenance are local hash records. Do not claim they verify remote repository authenticity. A legacy report without `provenance.json` remains usable but receives a warning until a manifest is created. + +Then suggest removing it from the active local queue: ```bash omv findings archive --reason reported diff --git a/skills/omv-report/contracts/report-provenance.v1.yaml b/skills/omv-report/contracts/report-provenance.v1.yaml new file mode 100644 index 0000000..1291ab3 --- /dev/null +++ b/skills/omv-report/contracts/report-provenance.v1.yaml @@ -0,0 +1,11 @@ +# ReportProvenance.v1 — generated report input manifest +# JSON instances are stored at: .omv/reports//provenance.json + +schema_version: "1" +finding_id: "" +generated_at: "" + +inputs: [] +# - role: evidence # evidence | report | source-ref | threat-map | verification | reproduction +# path: .omv/findings/example.yaml +# sha256: "" diff --git a/skills/omv-report/contracts/source-ref.v1.yaml b/skills/omv-report/contracts/source-ref.v1.yaml new file mode 100644 index 0000000..d19451a --- /dev/null +++ b/skills/omv-report/contracts/source-ref.v1.yaml @@ -0,0 +1,15 @@ +# SourceRef.v1 — local source identity sidecar +# Stored at: .omv/sources/.yaml +# This records local research inputs; it does not prove remote authenticity. + +schema_version: "1" +finding_id: "" +finding_sha256: "" +captured_at: "" + +sources: [] +# - kind: repository # repository | registry | archive | file | advisory | other +# locator: "" +# revision: unknown +# path: unknown +# sha256: unknown # lowercase SHA-256 or unknown diff --git a/skills/omv/SKILL.md b/skills/omv/SKILL.md index 8e270f3..087b239 100644 --- a/skills/omv/SKILL.md +++ b/skills/omv/SKILL.md @@ -12,6 +12,11 @@ oh-my-vul local-first vulnerability research project manager for Claude Code. ```text /omv list — list all installed omv-* skills with one-line descriptions /omv dashboard — show workspace, active workflow queue, and recent activity +/omv eval — run deterministic stable skill eval checks +/omv first [flags] — initialize a Campaign.v1 first-mile research plan +/omv campaign — list local research campaigns +/omv campaign show — show Campaign scope, lanes, and next action +/omv campaign seed — create candidate Evidence hypotheses for unseeded lanes /omv status — show local .omv workspace status (delegates to omv CLI) /omv log — show local workspace activity log (delegates to omv CLI) /omv next — show active findings and recommended next actions @@ -20,6 +25,9 @@ oh-my-vul local-first vulnerability research project manager for Claude Code. /omv repro init — create .omv/repro// artifact scaffold /omv review — review report readiness and recommend the next step /omv report artifacts — check report and reproduction artifacts +/omv report provenance — hash report inputs into a local provenance manifest +/omv sources init — capture SourceRef.v1 from known Evidence source facts +/omv sources validate — check SourceRef.v1 and Evidence hash freshness /omv verification init — create .omv/verifications/.yaml adversarial review scaffold /omv verification show — show adversarial verification status /omv verification validate @@ -55,21 +63,27 @@ Collection metadata lives in `references/registry.yaml`. Read it to show current ## State Directory -`.omv/` at the repository root stores findings, archive metadata, and the rebuildable local workspace index. It is private local research state and should be gitignored. Active findings live under `.omv/findings/`; inactive findings live under `.omv/archive/findings/`. +`.omv/` at the repository root stores campaigns, findings, source references, report provenance, archive metadata, and the rebuildable local workspace index. It is private local research state and should be gitignored. Campaigns live under `.omv/campaigns/`; active findings live under `.omv/findings/`; SourceRef.v1 sidecars live under `.omv/sources/`; inactive findings live under `.omv/archive/findings/`. ## CLI Delegation -When the user invokes workspace, lifecycle, repro scaffold, artifact check, archive, or restore commands, **run the matching `omv` CLI command via `Bash` and display its output. Do not implement the behavior manually** (do not `mkdir`, do not move files, do not write YAML directly). +When the user invokes campaign, workspace, lifecycle, repro scaffold, artifact check, archive, or restore commands, **run the matching `omv` CLI command via `Bash` and display its output. Do not implement the behavior manually** (do not `mkdir`, do not move files, do not write YAML directly). Use `omv help`, `omv help review`, `omv help findings`, `omv help repro`, or `omv help report` as the source of truth for exact CLI signatures. For direct aliases: - `/omv dashboard` -> `omv dashboard` +- `/omv eval ...` -> `omv eval ...` +- `/omv first ...` -> `omv first ...` +- `/omv campaign ...` -> `omv campaign ...` - `/omv status` -> `omv workspace status` - `/omv log` -> `omv workspace log` - `/omv next` -> `omv findings workflow` - `/omv repro init ` -> `omv repro init ` - `/omv review ` -> `omv review ` - `/omv report artifacts ` -> `omv report artifacts ` +- `/omv report provenance ` -> `omv report provenance ` +- `/omv sources init ` -> `omv sources init ` +- `/omv sources validate ` -> `omv sources validate ` - `/omv verification init ` -> `omv verification init ` - `/omv verification show ` -> `omv verification show ` - `/omv verification validate ` -> `omv verification validate ` @@ -81,7 +95,11 @@ Use `omv help`, `omv help review`, `omv help findings`, `omv help repro`, or `om ### Subcommand reference +- **first / campaign init** — creates `.omv/campaigns/.yaml` and a deterministic Markdown runbook. Use canonical `omv campaign init` when the user asks how to begin; preserve `/omv first` when explicitly invoked. +- **campaign list/show** — reads Campaign.v1 files directly and reports generic hypothesis lanes. +- **campaign seed ``** — creates only candidate Evidence.v1 hypotheses for unseeded lanes. Existing `.yaml`/`.yml` findings are never overwritten. Never claim seed audited, reproduced, verified, or proved a vulnerability, and never create ThreatMap, repro, verification, report, or PoC artifacts manually. - **dashboard** — prints workspace status, active workflow queue, and recent activity in one view. +- **eval** — runs checked-in deterministic checker/golden pairs through the unified runner. It does not invoke a model or make network requests; `--json` and `--junit` are available for automation. - **workspace status** — prints workspace path, active/archive counts, status counts, and privacy warnings. - **workspace log** — prints the local activity trail for workspace init, finding init, promotion, archive, and restore. - **init ``** — creates `.omv/findings/.yaml` from the Evidence.v1 template; default `--status candidate`. If file exists, CLI errors — suggest `--force`. @@ -95,6 +113,8 @@ Use `omv help`, `omv help review`, `omv help findings`, `omv help repro`, or `om - **promote ` --status `** — updates the `status` field and re-validates. Valid statuses: `candidate`, `confirmed`, `blocked`. - **repro init ``** — creates `.omv/repro//` with standard reproduction artifact files and records suggested `evidence.repro_artifacts`. - **report artifacts ``** — checks `.omv/reports//` and Evidence.v1 reproduction artifact references before final archive. +- **sources init/validate ``** — records only source facts already present in Evidence.v1 and checks whether its hash is stale. Never describe SourceRef as proof that a remote source is authoritative. +- **report provenance ``** — hashes Evidence, non-empty report files, and available SourceRef/ThreatMap/Verification/reproduction dependencies into `.omv/reports//provenance.json`. - **verification init ``** — creates `.omv/verifications/.yaml` with the current Evidence.v1 SHA-256 for adversarial verifier review. - **verification show ``** — summarizes Verification.v1 decision, disagreements, required changes, and stale-hash state. - **verification validate ``** — validates Verification.v1 structure and warns when Evidence.v1 changed after review. @@ -105,6 +125,9 @@ Use `omv help`, `omv help review`, `omv help findings`, `omv help repro`, or `om ## Workflow Overview ``` +omv campaign init → records target, scope, priorities, and generic lanes +omv campaign seed → optional candidate Evidence.v1 hypotheses only + ↓ /omv-find → identifies candidates writes .omv/findings/.yaml (status: candidate) ↓ @@ -120,6 +143,7 @@ Use `omv help`, `omv help review`, `omv help findings`, `omv help repro`, or `om returns ready | needs-repro | needs-audit | needs-verification | blocked ↓ /omv-report → reads confirmed finding, generates VulDB/CVE/GHSA/OSV report + then records/checks local provenance with omv report provenance/artifacts ↓ archive → omv findings archive --reason reported ``` diff --git a/skills/omv/references/registry.yaml b/skills/omv/references/registry.yaml index c323624..255d379 100644 --- a/skills/omv/references/registry.yaml +++ b/skills/omv/references/registry.yaml @@ -4,7 +4,7 @@ name: oh-my-vul version: "0.9.0" platform: claude-code -updated: "2026-05-08" +updated: "2026-07-10" skills: - name: omv @@ -12,8 +12,11 @@ skills: path: skills/omv invocation: /omv status: stable - description: Local-first project manager — shows workspace status, active finding next actions, archive state, and installed skills - produces: [] + description: Local-first project manager — creates research campaigns, shows workspace status, and delegates finding lifecycle actions + produces: + - Campaign.v1 + - Evidence.v1 # candidate hypotheses from explicit campaign seed + - SourceRef.v1 # optional local source identity sidecar consumes: [] - name: omv-find @@ -62,10 +65,12 @@ skills: - GHSA advisory - OSV JSON - Markdown advisory + - ReportProvenance.v1 consumes: - Evidence.v1 # writes cvss and dedup subfields - ThreatMap.v1 - Verification.v1 + - SourceRef.v1 - name: omv-radar category: intelligence @@ -158,10 +163,22 @@ agents: description: Adversarial verification agent — independently refutes a candidate audit conclusion; bias toward "wrong" contracts: + - name: Campaign.v1 + path: contracts/campaign.v1.yaml + description: Local research campaign target, scope, priorities, and candidate lanes + - name: Evidence.v1 path: contracts/evidence.v1.yaml description: Finding object — the typed boundary between omv-find and omv-report + - name: SourceRef.v1 + path: contracts/source-ref.v1.yaml + description: Optional local source identity and Evidence hash sidecar + + - name: ReportProvenance.v1 + path: contracts/report-provenance.v1.yaml + description: Generated SHA-256 manifest for reports and local dependencies + - name: CandidateList.v1 path: contracts/candidate-list.v1.yaml description: Candidate table entry schema produced by omv-find @@ -189,6 +206,29 @@ shared: - shared/references/patterns/rust.md - shared/references/patterns/java.md - shared/references/patterns/ruby.md + - shared/references/patterns/php.md + - shared/references/patterns/csharp.md + - shared/references/patterns/swift.md + - shared/references/patterns/dart.md + - shared/references/patterns/elixir.md + - shared/references/patterns/perl.md + - shared/references/patterns/r.md + - shared/references/patterns/lua.md + - shared/pattern-packs/npm.json + - shared/pattern-packs/python.json + - shared/pattern-packs/go.json + - shared/pattern-packs/rust.json + - shared/pattern-packs/java.json + - shared/pattern-packs/ruby.json + - shared/pattern-packs/php.json + - shared/pattern-packs/csharp.json + - shared/pattern-packs/swift.json + - shared/pattern-packs/dart.json + - shared/pattern-packs/elixir.json + - shared/pattern-packs/perl.json + - shared/pattern-packs/r.json + - shared/pattern-packs/lua.json scripts: - shared/scripts/collect_metadata.py - shared/scripts/estimate_loc.sh + - shared/scripts/run_evals.py diff --git a/src/cli/__tests__/args.test.ts b/src/cli/__tests__/args.test.ts index b2d4f87..c32b9ea 100644 --- a/src/cli/__tests__/args.test.ts +++ b/src/cli/__tests__/args.test.ts @@ -94,11 +94,19 @@ test("CLI argument validation accepts UX flags and command help", () => { assert.equal(validateArgs(["doctor", "--strict"]).ok, true); assert.equal(validateArgs(["dashboard"]).ok, true); assert.equal(validateArgs(["dashboard", "--json"]).ok, true); + assert.equal(validateArgs(["eval"]).ok, true); + assert.equal(validateArgs(["eval", "--json"]).ok, true); + assert.equal(validateArgs(["eval", "--junit"]).ok, true); + assert.equal(validateArgs(["eval", "--skill", "omv-find", "--eval-id", "26", "--output", "result.md", "--json"]).ok, true); assert.equal(validateArgs(["review", "demo", "--strict", "--json"]).ok, true); assert.equal(validateArgs(["repro", "init", "demo"]).ok, true); assert.equal(validateArgs(["repro", "init", "demo", "--force", "--json"]).ok, true); assert.equal(validateArgs(["report", "artifacts", "demo"]).ok, true); assert.equal(validateArgs(["report", "artifacts", "demo", "--json"]).ok, true); + assert.equal(validateArgs(["report", "provenance", "demo", "--force", "--json"]).ok, true); + assert.equal(validateArgs(["sources", "init", "demo", "--force", "--json"]).ok, true); + assert.equal(validateArgs(["sources", "show", "demo", "--json"]).ok, true); + assert.equal(validateArgs(["sources", "validate", "demo", "--json"]).ok, true); assert.equal(validateArgs(["findings", "doctor", "demo"]).ok, true); assert.equal(validateArgs(["findings", "doctor", "demo", "--json"]).ok, true); assert.equal(validateArgs(["findings", "validate", "--strict"]).ok, true); @@ -106,3 +114,69 @@ test("CLI argument validation accepts UX flags and command help", () => { assert.equal(validateArgs(["findings", "validate", "--help"]).ok, true); assert.equal(validateArgs(["help", "findings", "validate"]).ok, true); }); + +test("CLI argument validation enforces eval target and output format rules", () => { + for (const command of [ + ["eval", "extra"], + ["eval", "--json", "--junit"], + ["eval", "--skill", "omv-find"], + ["eval", "--skill", "omv-find", "--eval-id", "26"], + ["eval", "--eval-id", "26", "--output", "result.md"], + ["eval", "--skill", "../find", "--eval-id", "26", "--output", "result.md"], + ["eval", "--skill", "omv-find", "--eval-id", "-1", "--output", "result.md"], + ["eval", "--skill", "omv-find", "--eval-id", "x", "--output", "result.md"], + ]) { + assert.equal(validateArgs(command).ok, false, command.join(" ")); + } +}); + +test("CLI argument validation enforces SourceRef and report provenance grammar", () => { + for (const command of [ + ["sources", "init"], + ["sources", "init", "demo", "extra"], + ["sources", "show"], + ["sources", "show", "demo", "--force"], + ["sources", "validate", "demo", "extra"], + ["sources", "unknown", "demo"], + ["report", "provenance"], + ["report", "provenance", "demo", "extra"], + ]) { + assert.equal(validateArgs(command).ok, false, command.join(" ")); + } +}); + +test("CLI argument validation covers Campaign commands and first aliases", () => { + const initFlags = [ + "--target", "acme", "--version", "1.2", "--source", "/tmp/acme", + "--ecosystem", "npm", "--mode", "passive", "--goal", "research-notes", + "--budget", "standard", "--vuln", "xss,auth", "--local-lab", "unknown", + "--id", "demo", "--force", "--no-interactive", "--json", + ]; + assert.equal(validateArgs(["campaign"]).ok, true); + assert.equal(validateArgs(["campaign", "init", ...initFlags]).ok, true); + assert.equal(validateArgs(["first", ...initFlags]).ok, true); + assert.equal(validateArgs(["first", "init", ...initFlags]).ok, true); + assert.equal(validateArgs(["campaign", "list", "--json"]).ok, true); + assert.equal(validateArgs(["first", "list", "--json"]).ok, true); + assert.equal(validateArgs(["campaign", "show", "demo", "--json"]).ok, true); + assert.equal(validateArgs(["first", "show", "demo", "--json"]).ok, true); + assert.equal(validateArgs(["campaign", "seed", "demo", "--json"]).ok, true); + assert.equal(validateArgs(["first", "seed", "demo", "--json"]).ok, true); + + for (const command of [ + ["campaign", "seed", "demo", "--force"], + ["first", "seed", "demo", "--force"], + ["campaign", "show"], + ["campaign", "show", "demo", "extra"], + ["campaign", "seed"], + ["campaign", "list", "extra"], + ["campaign", "init", "extra"], + ["campaign", "init", "--mode", "live"], + ["campaign", "init", "--goal", "pdf"], + ["campaign", "init", "--budget", "forever"], + ["campaign", "init", "--local-lab", "maybe"], + ["campaign", "init", "--ecosystem", "other"], + ]) { + assert.equal(validateArgs(command).ok, false, command.join(" ")); + } +}); diff --git a/src/cli/__tests__/campaign-seed.test.ts b/src/cli/__tests__/campaign-seed.test.ts new file mode 100644 index 0000000..c597e29 --- /dev/null +++ b/src/cli/__tests__/campaign-seed.test.ts @@ -0,0 +1,146 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { initCampaign } from "../campaign.js"; +import { seedCampaign } from "../campaign-seed.js"; +import { createFindingTemplate, validateFinding } from "../findings.js"; +import { + findingReportsDir, + findingReproDir, + findingsDir, + threatMapPath, + verificationPath, +} from "../paths.js"; + +const fixedNow = (): Date => new Date("2026-07-10T00:00:00.000Z"); + +test("campaign seed values create a conservative valid candidate Evidence template", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-seed-")); + try { + const result = await createFindingTemplate("demo-xss", { + projectRoot, + seed: { + researcherGoal: "triage", + product: "Acme", + ecosystem: "npm", + vulnerabilityClass: "xss", + }, + }); + const data = parseYaml(await readFile(result.path, "utf-8")) as Record; + + assert.equal(data.status, "candidate"); + assert.equal(data.researcher_goal, "triage"); + assert.equal(data.package.product, "Acme"); + assert.equal(data.package.ecosystem, "npm"); + assert.equal(data.package.registry_name, ""); + assert.equal(data.versions.tested, "unknown"); + assert.equal(data.vulnerability.class, "xss"); + for (const field of ["source", "sink", "guard", "reproducer", "observed_result"]) { + assert.equal(data.evidence[field], "unknown"); + assert.ok(data.provenance.unverified_fields.includes(`evidence.${field}`)); + } + assert.ok(data.provenance.unverified_fields.includes("versions.tested")); + assert.equal("campaign_id" in data, false); + assert.equal((await validateFinding("demo-xss", projectRoot)).ok, true); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign seed creates one candidate per lane and no proof sidecars", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-seed-")); + try { + await initCampaign( + { id: "demo", target: "Acme", ecosystem: "npm", output: "cve", vulnerabilities: ["xss", "auth"] }, + { projectRoot, now: fixedNow }, + ); + const result = await seedCampaign("demo", projectRoot); + + assert.deepEqual(result.created.map((item) => item.id), ["demo-xss", "demo-auth"]); + assert.deepEqual(result.skipped, []); + assert.deepEqual(result.failed, []); + for (const id of ["demo-xss", "demo-auth"]) { + const evidence = parseYaml(await readFile(join(findingsDir(projectRoot), `${id}.yaml`), "utf-8")) as Record; + assert.equal(evidence.researcher_goal, "CVE"); + assert.equal(evidence.status, "candidate"); + assert.equal(evidence.versions.tested, "unknown"); + assert.equal("campaign_id" in evidence, false); + assert.equal(existsSync(threatMapPath(id, projectRoot)), false); + assert.equal(existsSync(findingReproDir(id, projectRoot)), false); + assert.equal(existsSync(verificationPath(id, projectRoot)), false); + assert.equal(existsSync(findingReportsDir(id, projectRoot)), false); + } + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign seed preserves existing YAML and YML findings byte-for-byte", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-seed-")); + try { + await initCampaign( + { id: "demo", target: "Acme", ecosystem: "npm", vulnerabilities: ["xss", "auth"] }, + { projectRoot, now: fixedNow }, + ); + await mkdir(findingsDir(projectRoot), { recursive: true }); + const yamlPath = join(findingsDir(projectRoot), "demo-xss.yaml"); + const ymlPath = join(findingsDir(projectRoot), "demo-auth.yml"); + const yamlBytes = Buffer.from("preserve yaml\r\n"); + const ymlBytes = Buffer.from("preserve yml\r\n"); + await writeFile(yamlPath, yamlBytes); + await writeFile(ymlPath, ymlBytes); + + const result = await seedCampaign("demo", projectRoot); + assert.deepEqual(result.created, []); + assert.deepEqual(result.skipped.map((item) => item.id), ["demo-xss", "demo-auth"]); + assert.deepEqual(await readFile(yamlPath), yamlBytes); + assert.deepEqual(await readFile(ymlPath), ymlBytes); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign seed rejects unknown ecosystems before creating findings", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-seed-")); + try { + await initCampaign( + { id: "demo", target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + await assert.rejects(() => seedCampaign("demo", projectRoot), /ecosystem.*supported/i); + assert.equal(existsSync(join(findingsDir(projectRoot), "demo-xss.yaml")), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign seed reports partial failures and remains idempotent on retry", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-seed-")); + try { + await initCampaign( + { id: "demo", target: "Acme", ecosystem: "npm", vulnerabilities: ["xss", "auth"] }, + { projectRoot, now: fixedNow }, + ); + const partial = await seedCampaign("demo", projectRoot, { + createFinding: async (id, options) => { + if (id === "demo-auth") { + throw new Error("injected write failure"); + } + return createFindingTemplate(id, options); + }, + }); + assert.deepEqual(partial.created.map((item) => item.id), ["demo-xss"]); + assert.deepEqual(partial.failed, [{ id: "demo-auth", message: "injected write failure" }]); + + const retried = await seedCampaign("demo", projectRoot); + assert.deepEqual(retried.skipped.map((item) => item.id), ["demo-xss"]); + assert.deepEqual(retried.created.map((item) => item.id), ["demo-auth"]); + assert.deepEqual(retried.failed, []); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/src/cli/__tests__/campaign.test.ts b/src/cli/__tests__/campaign.test.ts new file mode 100644 index 0000000..f2ecb16 --- /dev/null +++ b/src/cli/__tests__/campaign.test.ts @@ -0,0 +1,1268 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { existsSync, mkdirSync } from "node:fs"; +import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough } from "node:stream"; +import { stringify as stringifyYaml } from "yaml"; +import { + CAMPAIGN_DEPTHS, + CAMPAIGN_ECOSYSTEMS, + CAMPAIGN_LOCAL_REPRODUCTIONS, + CAMPAIGN_MODES, + CAMPAIGN_OUTPUTS, + buildCampaign, + initCampaign, + listCampaigns, + normalizeCampaignId, + normalizeVulnerabilityClasses, + parseCampaignYaml, + renderCampaignRunbook, + resolveCampaignInput, + showCampaign, + validateCampaign, + type Campaign, + type CampaignPromptAdapter, +} from "../campaign.js"; +import { ReadlineCampaignPrompt } from "../campaign-prompt.js"; +import { + campaignPath, + campaignRunbookPath, + campaignsDir, + workspaceActivityLogPath, + workspaceIndexPath, +} from "../paths.js"; +import { initWorkspace, readWorkspaceActivity } from "../workspace.js"; + +const FIXED_ISO = "2026-07-10T00:00:00.000Z"; +const fixedNow = (): Date => new Date(FIXED_ISO); + +class RecordingCampaignPrompt implements CampaignPromptAdapter { + readonly calls: string[] = []; + closeCalls = 0; + + constructor( + private readonly targetAnswer: string, + private readonly vulnerabilityAnswer: string, + ) {} + + async askTarget(): Promise { + this.calls.push("target"); + return this.targetAnswer; + } + + async askVulnerabilities(): Promise { + this.calls.push("vulnerabilities"); + return this.vulnerabilityAnswer; + } + + close(): void { + this.closeCalls += 1; + } +} + +function closeCampaignPrompt(prompt: CampaignPromptAdapter): void { + prompt.close(); +} + +function validCampaign(): Campaign { + return buildCampaign( + { + target: "Acme", + version: "1.2", + vulnerabilities: ["xss", "auth-z"], + }, + fixedNow, + ); +} + +test("Campaign construction normalizes input and applies the complete safe shape", () => { + const campaign = buildCampaign( + { + target: " Acme ", + version: " 1.2 ", + vulnerabilities: [" XSS ", "auth z", "xss"], + }, + fixedNow, + ); + + assert.deepEqual(campaign, { + schema_version: "1", + id: "acme-1-2", + title: "Acme 1.2 research campaign", + status: "active", + profile: "generic", + created_at: FIXED_ISO, + updated_at: FIXED_ISO, + target: { + name: "Acme", + version: "1.2", + source: "unknown", + ecosystem: "unknown", + }, + scope: { + mode: "passive", + local_reproduction: "unknown", + boundaries: [ + "local or explicitly authorized assets only", + "no live third-party testing", + "no automatic exploitation", + ], + }, + goal: { output: "research-notes" }, + budget: { depth: "standard" }, + priorities: { vulnerability_classes: ["xss", "auth-z"] }, + lanes: [ + { + id: "xss", + title: "Review xss hypotheses", + vulnerability_class: "xss", + finding_id: "acme-1-2-xss", + }, + { + id: "auth-z", + title: "Review auth-z hypotheses", + vulnerability_class: "auth-z", + finding_id: "acme-1-2-auth-z", + }, + ], + }); +}); + +test("Campaign construction exports the documented enum values", () => { + assert.deepEqual(CAMPAIGN_MODES, ["whitebox", "graybox", "local-lab", "passive", "mixed"]); + assert.deepEqual(CAMPAIGN_OUTPUTS, [ + "course-report", + "cve", + "vuldb", + "internal-report", + "research-notes", + ]); + assert.deepEqual(CAMPAIGN_DEPTHS, ["quick", "standard", "deep"]); + assert.deepEqual(CAMPAIGN_LOCAL_REPRODUCTIONS, ["yes", "no", "unknown"]); + assert.deepEqual(CAMPAIGN_ECOSYSTEMS, [ + "unknown", + "npm", + "python", + "go", + "rust", + "java", + "ruby", + "php", + "csharp", + "swift", + "dart", + "elixir", + "perl", + "r", + "lua", + ]); +}); + +test("Campaign construction omits omitted and explicit unknown versions from generated identity", () => { + const omitted = buildCampaign({ target: " Acme ", vulnerabilities: ["XSS"] }, fixedNow); + const explicit = buildCampaign( + { + target: " Acme ", + version: " UNKNOWN ", + source: " Unknown ", + ecosystem: " UNKNOWN ", + vulnerabilities: ["XSS"], + }, + fixedNow, + ); + + assert.equal(omitted.id, "acme"); + assert.equal(omitted.title, "Acme research campaign"); + assert.equal(explicit.id, "acme"); + assert.equal(explicit.title, "Acme research campaign"); + assert.deepEqual(explicit.target, { + name: "Acme", + version: "unknown", + source: "unknown", + ecosystem: "unknown", + }); +}); + +test("Campaign construction never silently omits a known version that cannot form an ASCII id segment", () => { + assert.throws( + () => buildCampaign({ target: "Acme", version: "\u5b89\u5168", vulnerabilities: ["xss"] }, fixedNow), + /version.*ASCII/i, + ); +}); + +test("Campaign construction accepts non-ASCII target facts when a safe explicit id avoids derivation", () => { + const campaign = buildCampaign( + { id: "explicit-id", target: "\u9879\u76ee", version: "\u5b89\u5168", vulnerabilities: ["xss"] }, + fixedNow, + ); + assert.equal(campaign.id, "explicit-id"); + assert.equal(campaign.target.name, "\u9879\u76ee"); + assert.equal(campaign.target.version, "\u5b89\u5168"); +}); + +test("Campaign construction normalizes and deduplicates lowercase ASCII class slugs", () => { + assert.deepEqual( + normalizeVulnerabilityClasses([" Auth Z ", "auth_z", "AUTH--Z", "CWE.79"]), + ["auth-z", "cwe-79"], + ); + assert.throws(() => normalizeVulnerabilityClasses([" ", "---", "\u5b89\u5168"]), /vulnerability/i); +}); + +test("Campaign construction trims safe explicit ids but never repairs unsafe ids", () => { + assert.equal(normalizeCampaignId(" Demo_1.2 "), "Demo_1.2"); + assert.equal( + buildCampaign({ id: " Demo_1.2 ", target: "Acme", vulnerabilities: ["xss"] }, fixedNow).id, + "Demo_1.2", + ); + assert.throws( + () => buildCampaign({ id: "../demo", target: "Acme", vulnerabilities: ["xss"] }, fixedNow), + /campaign id.*letters, numbers, dots, underscores, or hyphens/i, + ); +}); + +test("Campaign construction requires target and usable vulnerability classes", () => { + assert.throws(() => buildCampaign({ vulnerabilities: ["xss"] }, fixedNow), /target.*required/i); + assert.throws(() => buildCampaign({ target: " ", vulnerabilities: ["xss"] }, fixedNow), /target.*required/i); + assert.throws(() => buildCampaign({ target: "Acme", vulnerabilities: [] }, fixedNow), /vulnerability/i); + assert.throws( + () => buildCampaign({ target: "Acme", vulnerabilities: ["---", "\u5b89\u5168"] }, fixedNow), + /vulnerability/i, + ); +}); + +test("Campaign construction rejects unsupported enum input before returning a campaign", () => { + const base = { target: "Acme", vulnerabilities: ["xss"] }; + assert.throws(() => buildCampaign({ ...base, mode: "active" as never }, fixedNow), /scope\.mode/); + assert.throws(() => buildCampaign({ ...base, output: "pdf" as never }, fixedNow), /goal\.output/); + assert.throws(() => buildCampaign({ ...base, depth: "unbounded" as never }, fixedNow), /budget\.depth/); + assert.throws( + () => buildCampaign({ ...base, localReproduction: "maybe" as never }, fixedNow), + /scope\.local_reproduction/, + ); + assert.throws(() => buildCampaign({ ...base, ecosystem: "other" }, fixedNow), /target\.ecosystem/); +}); + +test("Campaign construction has no target-name profiles or built-in Zimbra lanes", () => { + const campaign = buildCampaign({ target: "Zimbra", vulnerabilities: ["xss"] }, fixedNow); + + assert.equal(campaign.profile, "generic"); + assert.deepEqual(campaign.priorities.vulnerability_classes, ["xss"]); + assert.deepEqual(campaign.lanes, [ + { + id: "xss", + title: "Review xss hypotheses", + vulnerability_class: "xss", + finding_id: "zimbra-xss", + }, + ]); + assert.doesNotMatch(JSON.stringify(campaign), /soap|mailbox|attachment|proxy/i); +}); + +test("Campaign validation accepts a complete valid Campaign object", () => { + const campaign = validCampaign(); + assert.deepEqual(validateCampaign(campaign), campaign); + assert.deepEqual(parseCampaignYaml(stringifyYaml(campaign)), campaign); +}); + +test("Campaign validation reports required mapping, list, enum, id, and class paths", () => { + const invalid = structuredClone(validCampaign()) as unknown as Record; + invalid.id = "../unsafe"; + invalid.status = "paused"; + invalid.profile = "target-specific"; + invalid.target = { name: "", version: "", source: 3, ecosystem: "other" }; + invalid.scope = { mode: "active", local_reproduction: "maybe", boundaries: "none" }; + invalid.goal = { output: "pdf" }; + invalid.budget = { depth: "unbounded" }; + invalid.priorities = { vulnerability_classes: ["XSS", ""] }; + invalid.lanes = "not-a-list"; + + assert.throws( + () => validateCampaign(invalid), + (error: unknown) => { + assert.ok(error instanceof Error); + for (const path of [ + "id", + "status", + "profile", + "target.name", + "target.version", + "target.source", + "target.ecosystem", + "scope.mode", + "scope.local_reproduction", + "scope.boundaries", + "goal.output", + "budget.depth", + "priorities.vulnerability_classes[0]", + "lanes", + ]) { + assert.ok(error.message.includes(path), `expected validation error to include ${path}`); + } + return true; + }, + ); +}); + +test("Campaign validation rejects missing required mappings and fields", () => { + const requiredCases: Array<[string, (value: Record) => void]> = [ + ["schema_version", (value) => delete value.schema_version], + ["title", (value) => delete value.title], + ["created_at", (value) => delete value.created_at], + ["updated_at", (value) => delete value.updated_at], + ["target", (value) => delete value.target], + ["scope", (value) => delete value.scope], + ["goal", (value) => delete value.goal], + ["budget", (value) => delete value.budget], + ["priorities", (value) => delete value.priorities], + ["lanes", (value) => delete value.lanes], + ]; + + for (const [path, mutate] of requiredCases) { + const invalid = structuredClone(validCampaign()) as unknown as Record; + mutate(invalid); + assert.throws(() => validateCampaign(invalid), new RegExp(path)); + } +}); + +test("Campaign validation enforces ISO timestamps, exact lane correspondence, and unique finding ids", () => { + const invalid = structuredClone(validCampaign()); + invalid.created_at = "next Thursday"; + invalid.updated_at = "2026-07-10"; + invalid.lanes[0].title = "A target-specific claim"; + invalid.lanes[1].vulnerability_class = "xss"; + invalid.lanes[1].finding_id = invalid.lanes[0].finding_id; + + assert.throws( + () => validateCampaign(invalid), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /created_at.*ISO/); + assert.match(error.message, /updated_at.*ISO/); + assert.match(error.message, /lanes\[0\]\.title/); + assert.match(error.message, /lanes\[1\]\.vulnerability_class/); + assert.match(error.message, /lanes\[1\]\.finding_id/); + assert.match(error.message, /unique/i); + return true; + }, + ); +}); + +test("Campaign calendar-strict ISO validation rejects rollover dates and invalid clock components", () => { + for (const timestamp of [ + "2026-02-30T00:00:00.000Z", + "2026-13-01T00:00:00.000Z", + "2026-07-10T24:00:00.000Z", + ]) { + const invalid = validCampaign(); + invalid.created_at = timestamp; + assert.throws(() => validateCampaign(invalid), /created_at.*ISO 8601/); + } +}); + +test("Campaign calendar-strict ISO validation accepts real leap days and supported offsets", () => { + const campaign = validCampaign(); + campaign.created_at = "2024-02-29T00:00:00.000Z"; + campaign.updated_at = "2024-02-29T23:59:59+05:30"; + + assert.deepEqual(validateCampaign(campaign), campaign); +}); + +test("Campaign timestamp validation rejects trailing line breaks and reversed chronology", () => { + const trailingLineBreak = validCampaign(); + trailingLineBreak.created_at = `${FIXED_ISO}\n`; + assert.throws(() => validateCampaign(trailingLineBreak), /created_at.*ISO 8601/); + + const reversed = validCampaign(); + reversed.created_at = "2026-07-11T00:00:00.000Z"; + reversed.updated_at = "2026-07-10T00:00:00.000Z"; + assert.throws(() => validateCampaign(reversed), /updated_at.*earlier than created_at/i); +}); + +test("Campaign validation rejects malformed YAML with its source", () => { + assert.throws( + () => parseCampaignYaml("target: [unterminated", "/tmp/broken.yaml"), + /\/tmp\/broken\.yaml.*YAML.*parse/i, + ); +}); + +test("Campaign validation rejects filename and body id mismatches", () => { + assert.throws( + () => parseCampaignYaml(stringifyYaml(validCampaign()), "/tmp/not-acme.yaml"), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /filename id/i); + assert.match(error.message, /not-acme/); + assert.match(error.message, /acme-1-2/); + return true; + }, + ); +}); + +test("Campaign closed schema rejects extra keys at every mapping level without mutating input", () => { + const cases: Array<[string, (campaign: Campaign) => void]> = [ + ["unexpected", (campaign) => { (campaign as unknown as Record).unexpected = true; }], + ["target.unexpected", (campaign) => { (campaign.target as unknown as Record).unexpected = true; }], + ["scope.unexpected", (campaign) => { (campaign.scope as unknown as Record).unexpected = true; }], + ["goal.unexpected", (campaign) => { (campaign.goal as unknown as Record).unexpected = true; }], + ["budget.unexpected", (campaign) => { (campaign.budget as unknown as Record).unexpected = true; }], + ["priorities.unexpected", (campaign) => { + (campaign.priorities as unknown as Record).unexpected = true; + }], + ["lanes[0].unexpected", (campaign) => { + (campaign.lanes[0] as unknown as Record).unexpected = true; + }], + ]; + + for (const [path, mutate] of cases) { + const invalid = validCampaign(); + mutate(invalid); + const before = structuredClone(invalid); + assert.throws( + () => validateCampaign(invalid), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, new RegExp(path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.match(error.message, /not allowed/i); + return true; + }, + ); + assert.deepEqual(invalid, before); + } +}); + +test("Campaign closed schema rejects noncanonical text and uppercase UNKNOWN values", () => { + const cases: Array<[string, (campaign: Campaign) => void]> = [ + ["title", (campaign) => { campaign.title = ` ${campaign.title}`; }], + ["target.name", (campaign) => { campaign.target.name = " Acme"; }], + ["target.version", (campaign) => { campaign.target.version = "1.2 "; }], + ["target.source", (campaign) => { campaign.target.source = " unknown "; }], + ["scope.boundaries[0]", (campaign) => { campaign.scope.boundaries[0] = ` ${campaign.scope.boundaries[0]}`; }], + ["target.version", (campaign) => { campaign.target.version = "UNKNOWN"; }], + ["target.source", (campaign) => { campaign.target.source = "UNKNOWN"; }], + ["target.ecosystem", (campaign) => { campaign.target.ecosystem = "UNKNOWN" as never; }], + ]; + + for (const [path, mutate] of cases) { + const invalid = validCampaign(); + mutate(invalid); + assert.throws( + () => validateCampaign(invalid), + (error: unknown) => error instanceof Error && error.message.includes(path), + ); + } +}); + +test("Campaign closed schema requires the exact derived title for known and unknown versions", () => { + const known = validCampaign(); + known.title = "A user supplied title"; + assert.throws(() => validateCampaign(known), /title.*Acme 1\.2 research campaign/); + + const unknown = buildCampaign({ target: "Acme", vulnerabilities: ["xss"] }, fixedNow); + unknown.title = "Acme unknown research campaign"; + assert.throws(() => validateCampaign(unknown), /title.*Acme research campaign/); +}); + +test("Campaign closed schema requires all baseline boundaries and permits normalized additions", () => { + const campaign = validCampaign(); + for (const boundary of campaign.scope.boundaries) { + const invalid = structuredClone(campaign); + invalid.scope.boundaries = invalid.scope.boundaries.filter((item) => item !== boundary); + assert.throws( + () => validateCampaign(invalid), + (error: unknown) => error instanceof Error + && error.message.includes("scope.boundaries") + && error.message.includes(boundary), + ); + } + + const extended = structuredClone(campaign); + extended.scope.boundaries.push("read-only review"); + assert.deepEqual(validateCampaign(extended), extended); +}); + +test("Campaign closed schema is enforced when listing and showing files", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const campaign = buildCampaign({ id: "demo", target: "Acme", vulnerabilities: ["xss"] }, fixedNow); + (campaign.target as unknown as Record).unexpected = "value"; + await writeFile(join(dir, "demo.yaml"), stringifyYaml(campaign), "utf-8"); + + await assert.rejects(() => listCampaigns(projectRoot), /target\.unexpected.*not allowed/i); + await assert.rejects(() => showCampaign("demo", projectRoot), /target\.unexpected.*not allowed/i); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign runbook is deterministic, generic, and conservative", () => { + const campaign = validCampaign(); + const first = renderCampaignRunbook(campaign); + const second = renderCampaignRunbook(structuredClone(campaign)); + + assert.equal(first, second); + assert.match(first, /^# Acme 1\.2 research campaign/m); + assert.match(first, /Target: Acme/); + assert.match(first, /Version: 1\.2/); + assert.match(first, /Source: unknown/); + assert.match(first, /Ecosystem: unknown/); + for (const boundary of campaign.scope.boundaries) { + assert.match(first, new RegExp(boundary)); + } + assert.match(first, /unproven candidate hypotheses/i); + assert.match(first, /Vulnerability class: xss/); + assert.match(first, /Finding ID: acme-1-2-xss/); + assert.match(first, /Vulnerability class: auth-z/); + assert.match(first, /Finding ID: acme-1-2-auth-z/); + assert.match(first, /omv campaign show acme-1-2/); + assert.match(first, /Set `target\.ecosystem` to a supported value/); + assert.match(first, /omv campaign seed acme-1-2/); + assert.ok(first.indexOf("Set `target.ecosystem`") < first.indexOf("omv campaign seed acme-1-2")); + assert.match(first, /\/omv-audit acme-1-2-xss/); + assert.doesNotMatch( + first, + /\.omv\/notes|notes file|ThreatMap|\/omv-repro|omv repro|verification|proof[- ]of[- ]concept|PoC/i, + ); + assert.doesNotMatch(first, /SOAP|mailbox|attachment|proxy/i); +}); + +test("Campaign rejects control characters and escapes Markdown in user facts", () => { + assert.throws( + () => buildCampaign({ target: "Acme\n# injected", vulnerabilities: ["xss"] }, fixedNow), + /target\.name.*single-line/i, + ); + + const invalidBoundary = validCampaign(); + invalidBoundary.scope.boundaries.push("review locally\n# injected"); + assert.throws(() => validateCampaign(invalidBoundary), /scope\.boundaries.*single-line/i); + + const campaign = buildCampaign( + { + target: "Acme *Suite* [docs]", + version: "1_2", + source: "https://example.test/a_[b]#fragment", + vulnerabilities: ["xss"], + }, + fixedNow, + ); + campaign.scope.boundaries.push("review [local] *only* #safe"); + const runbook = renderCampaignRunbook(campaign); + + assert.ok(runbook.includes("Acme \\*Suite\\* \\[docs\\]")); + assert.ok(runbook.includes("1\\_2")); + assert.ok(runbook.includes("a\\_\\[b\\]\\#fragment")); + assert.ok(runbook.includes("review \\[local\\] \\*only\\* \\#safe")); + assert.doesNotMatch(runbook, /^# injected$/m); +}); + +test("Campaign init validates before creating directories or artifacts", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + await assert.rejects( + () => initCampaign({ target: "Acme", vulnerabilities: [] }, { projectRoot, now: fixedNow }), + /vulnerability/i, + ); + assert.equal(existsSync(campaignsDir(projectRoot)), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init serializes concurrent no-force writers with an exclusive lock", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const input = { id: "concurrent", target: "Acme", ecosystem: "npm", vulnerabilities: ["xss"] }; + const results = await Promise.allSettled([ + initCampaign(input, { projectRoot, now: fixedNow }), + initCampaign(input, { projectRoot, now: fixedNow }), + ]); + + assert.equal(results.filter((result) => result.status === "fulfilled").length, 1); + assert.equal(results.filter((result) => result.status === "rejected").length, 1); + const rejection = results.find((result) => result.status === "rejected"); + assert.match(String(rejection?.reason), /busy|already exists/i); + + const yamlPath = campaignPath("concurrent", projectRoot); + const runbookPath = campaignRunbookPath("concurrent", projectRoot); + const campaign = parseCampaignYaml(await readFile(yamlPath, "utf-8"), yamlPath); + assert.equal(await readFile(runbookPath, "utf-8"), renderCampaignRunbook(campaign)); + assert.equal((await readdir(campaignsDir(projectRoot))).some((name) => name.includes(".lock")), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init reports existing regular and symlink locks as busy without removing them", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const regularLock = join(dir, "regular.lock"); + const symlinkLock = join(dir, "linked.lock"); + await writeFile(regularLock, "held\n", "utf-8"); + await symlink(join(projectRoot, "missing-lock-target"), symlinkLock); + + for (const id of ["regular", "linked"]) { + await assert.rejects( + () => initCampaign({ id, target: "Acme", vulnerabilities: ["xss"] }, { projectRoot, now: fixedNow }), + /busy.*lock/i, + ); + } + assert.equal(await readFile(regularLock, "utf-8"), "held\n"); + assert.equal((await readdir(dir)).includes("linked.lock"), true); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init treats dangling artifact symlinks as collisions without following them", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const yamlPath = campaignPath("dangling", projectRoot); + const runbookPath = campaignRunbookPath("dangling", projectRoot); + const externalYaml = join(projectRoot, "external-missing.yaml"); + const externalRunbook = join(projectRoot, "external-missing.md"); + await symlink(externalYaml, yamlPath); + await symlink(externalRunbook, runbookPath); + + await assert.rejects( + () => initCampaign( + { id: "dangling", target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ), + /already exists.*--force/i, + ); + assert.equal((await lstat(yamlPath)).isSymbolicLink(), true); + assert.equal((await lstat(runbookPath)).isSymbolicLink(), true); + assert.equal(existsSync(externalYaml), false); + assert.equal(existsSync(externalRunbook), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init force replaces external artifact symlinks without changing their targets", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const yamlPath = campaignPath("linked", projectRoot); + const runbookPath = campaignRunbookPath("linked", projectRoot); + const externalYaml = join(projectRoot, "external.yaml"); + const externalRunbook = join(projectRoot, "external.md"); + const yamlBytes = Buffer.from("external YAML bytes\r\n"); + const runbookBytes = Buffer.from("external Markdown bytes\r\n"); + await writeFile(externalYaml, yamlBytes); + await writeFile(externalRunbook, runbookBytes); + await symlink(externalYaml, yamlPath); + await symlink(externalRunbook, runbookPath); + + const result = await initCampaign( + { id: "linked", target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow, force: true }, + ); + + assert.equal((await lstat(yamlPath)).isFile(), true); + assert.equal((await lstat(runbookPath)).isFile(), true); + assert.deepEqual(await readFile(externalYaml), yamlBytes); + assert.deepEqual(await readFile(externalRunbook), runbookBytes); + assert.deepEqual(parseCampaignYaml(await readFile(yamlPath, "utf-8"), yamlPath), result.campaign); + assert.equal(await readFile(runbookPath, "utf-8"), renderCampaignRunbook(result.campaign)); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init rejects directory artifact destinations even with force", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const yamlPath = campaignPath("directory", projectRoot); + const runbookPath = campaignRunbookPath("directory", projectRoot); + await mkdir(yamlPath); + const originalRunbook = Buffer.from("# Preserve me\r\n"); + await writeFile(runbookPath, originalRunbook); + + await assert.rejects( + () => initCampaign( + { id: "directory", target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow, force: true }, + ), + /artifact.*directory.*not supported/i, + ); + assert.equal((await lstat(yamlPath)).isDirectory(), true); + assert.deepEqual(await readFile(runbookPath), originalRunbook); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init force failure preserves the original pair and leaves no transaction residue", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const first = await initCampaign( + { id: "rollback", target: "Original", ecosystem: "npm", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + const originalYaml = await readFile(first.yamlPath); + const originalRunbook = await readFile(first.runbookPath); + const lateConflict = join(campaignsDir(projectRoot), "rollback.yml"); + let injected = false; + const options = { + projectRoot, + now: fixedNow, + get force(): boolean { + if (!injected) { + mkdirSync(lateConflict); + injected = true; + } + return true; + }, + }; + + await assert.rejects( + () => initCampaign( + { id: "rollback", target: "Replacement", ecosystem: "npm", vulnerabilities: ["auth"] }, + options, + ), + /artifact.*directory.*not supported/i, + ); + + assert.deepEqual(await readFile(first.yamlPath), originalYaml); + assert.deepEqual(await readFile(first.runbookPath), originalRunbook); + assert.equal((await lstat(lateConflict)).isDirectory(), true); + const residue = (await readdir(campaignsDir(projectRoot))).filter((name) => + name.includes(".lock") || name.includes("transaction") || name.includes("backup")); + assert.deepEqual(residue, []); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init keeps committed artifacts when activity recording fails", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + await mkdir(workspaceActivityLogPath(projectRoot), { recursive: true }); + + const result = await initCampaign( + { id: "activity-warning", target: "Acme", ecosystem: "npm", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + + assert.equal(result.warnings.length, 1); + assert.match(result.warnings[0], /activity/i); + assert.ok(result.warnings[0].includes(workspaceActivityLogPath(projectRoot))); + assert.deepEqual( + parseCampaignYaml(await readFile(result.yamlPath, "utf-8"), result.yamlPath), + result.campaign, + ); + assert.equal(await readFile(result.runbookPath, "utf-8"), renderCampaignRunbook(result.campaign)); + const residue = (await readdir(campaignsDir(projectRoot))).filter((name) => + name.includes(".lock") || name.includes("transaction") || name.includes("backup")); + assert.deepEqual(residue, []); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init writes a validated pair, records activity, and leaves the workspace index unchanged", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + await initWorkspace(projectRoot); + const indexBefore = await readFile(workspaceIndexPath(projectRoot)); + const result = await initCampaign( + { target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + + assert.equal(result.yamlPath, campaignPath("acme", projectRoot)); + assert.equal(result.runbookPath, campaignRunbookPath("acme", projectRoot)); + assert.equal(result.overwritten, false); + assert.equal( + result.nextAction, + "Set target.ecosystem to a supported value before running omv campaign seed acme", + ); + assert.deepEqual( + parseCampaignYaml(await readFile(result.yamlPath, "utf-8"), result.yamlPath), + result.campaign, + ); + assert.equal(await readFile(result.runbookPath, "utf-8"), renderCampaignRunbook(result.campaign)); + assert.deepEqual(await readFile(workspaceIndexPath(projectRoot)), indexBefore); + + const activities = await readWorkspaceActivity(projectRoot); + assert.equal(activities.at(-1)?.action, "campaign.init"); + assert.equal(activities.at(-1)?.id, "acme"); + assert.equal(activities.at(-1)?.path, result.yamlPath); + const index = JSON.parse(indexBefore.toString("utf-8")) as Record; + assert.equal("campaigns" in index, false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init protects an existing YAML artifact without creating the missing runbook", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const first = await initCampaign( + { target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + await unlink(first.runbookPath); + const original = Buffer.from("preserve YAML bytes\r\n"); + await writeFile(first.yamlPath, original); + + await assert.rejects( + () => initCampaign({ target: "Acme", vulnerabilities: ["auth"] }, { projectRoot, now: fixedNow }), + /already exists.*--force/i, + ); + assert.deepEqual(await readFile(first.yamlPath), original); + assert.equal(existsSync(first.runbookPath), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init protects an existing runbook without creating the missing YAML", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const first = await initCampaign( + { target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + await unlink(first.yamlPath); + const original = Buffer.from("# Preserve runbook bytes\r\n"); + await writeFile(first.runbookPath, original); + + await assert.rejects( + () => initCampaign({ target: "Acme", vulnerabilities: ["auth"] }, { projectRoot, now: fixedNow }), + /already exists.*--force/i, + ); + assert.deepEqual(await readFile(first.runbookPath), original); + assert.equal(existsSync(first.yamlPath), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init treats an existing YML file as a protected YAML artifact", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const ymlPath = join(dir, "demo.yml"); + const original = Buffer.from("preserve alternate YAML bytes\r\n"); + await writeFile(ymlPath, original); + + await assert.rejects( + () => initCampaign( + { id: "demo", target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ), + /already exists.*--force/i, + ); + assert.deepEqual(await readFile(ymlPath), original); + assert.equal(existsSync(campaignPath("demo", projectRoot)), false); + assert.equal(existsSync(campaignRunbookPath("demo", projectRoot)), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init force replaces an existing YML source with the canonical artifact pair", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const ymlPath = join(dir, "demo.yml"); + await writeFile(ymlPath, "old: YAML\n", "utf-8"); + await writeFile(campaignRunbookPath("demo", projectRoot), "# Old\n", "utf-8"); + + const result = await initCampaign( + { id: "demo", target: "Acme", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow, force: true }, + ); + + assert.equal(result.overwritten, true); + assert.equal(result.yamlPath, campaignPath("demo", projectRoot)); + assert.equal(existsSync(ymlPath), false); + assert.equal(parseCampaignYaml(await readFile(result.yamlPath, "utf-8"), result.yamlPath).id, "demo"); + assert.equal(await readFile(result.runbookPath, "utf-8"), renderCampaignRunbook(result.campaign)); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign init force replaces both artifacts from one newly normalized object", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const first = await initCampaign( + { id: "demo", target: "Old Target", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + const replacement = await initCampaign( + { id: "demo", target: "New Target", version: "2.0", vulnerabilities: ["auth z"] }, + { projectRoot, now: fixedNow, force: true }, + ); + + assert.equal(replacement.overwritten, true); + assert.equal(replacement.campaign.target.name, "New Target"); + assert.equal(replacement.campaign.target.version, "2.0"); + assert.deepEqual(replacement.campaign.priorities.vulnerability_classes, ["auth-z"]); + assert.deepEqual( + parseCampaignYaml(await readFile(first.yamlPath, "utf-8"), first.yamlPath), + replacement.campaign, + ); + assert.equal(await readFile(first.runbookPath, "utf-8"), renderCampaignRunbook(replacement.campaign)); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign list directly scans YAML and YML files into stable sorted summaries", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(join(dir, "nested"), { recursive: true }); + const alpha = buildCampaign({ id: "a", target: "Alpha", vulnerabilities: ["xss"] }, fixedNow); + const beta = buildCampaign( + { id: "b", target: "Beta", version: "2", ecosystem: "npm", vulnerabilities: ["ssrf", "auth"] }, + fixedNow, + ); + await writeFile(join(dir, "b.yml"), stringifyYaml(beta), "utf-8"); + await writeFile(join(dir, "a.yaml"), stringifyYaml(alpha), "utf-8"); + await writeFile(join(dir, "ignored.txt"), stringifyYaml(alpha), "utf-8"); + await writeFile(join(dir, "nested", "nested.yaml"), stringifyYaml(alpha), "utf-8"); + + assert.deepEqual(await listCampaigns(projectRoot), [ + { + id: "a", + title: "Alpha research campaign", + status: "active", + target: "Alpha", + version: "unknown", + laneCount: 1, + nextAction: "Set target.ecosystem to a supported value before running omv campaign seed a", + }, + { + id: "b", + title: "Beta 2 research campaign", + status: "active", + target: "Beta", + version: "2", + laneCount: 2, + nextAction: "omv campaign seed b", + }, + ]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign list returns empty for a missing directory without mutating the filesystem", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + assert.equal(existsSync(campaignsDir(projectRoot)), false); + assert.deepEqual(await listCampaigns(projectRoot), []); + assert.equal(existsSync(campaignsDir(projectRoot)), false); + assert.equal(existsSync(workspaceIndexPath(projectRoot)), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign show resolves either YAML extension when the source is unambiguous", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const primary = buildCampaign({ id: "demo", target: "Primary", vulnerabilities: ["xss"] }, fixedNow); + const fallback = buildCampaign({ id: "demo", target: "Fallback", vulnerabilities: ["ssrf"] }, fixedNow); + const yamlPath = join(dir, "demo.yaml"); + const ymlPath = join(dir, "demo.yml"); + const runbookPath = campaignRunbookPath("demo", projectRoot); + await writeFile(yamlPath, stringifyYaml(primary), "utf-8"); + await writeFile(runbookPath, "# Existing runbook\n", "utf-8"); + + const shown = await showCampaign("demo", projectRoot); + assert.equal(shown.campaign.target.name, "Primary"); + assert.equal(shown.yamlPath, yamlPath); + assert.equal(shown.runbookPath, runbookPath); + assert.equal(shown.runbookExists, true); + assert.equal( + shown.nextAction, + "Set target.ecosystem to a supported value before running omv campaign seed demo", + ); + + await unlink(yamlPath); + await writeFile(ymlPath, stringifyYaml(fallback), "utf-8"); + const fallbackShown = await showCampaign("demo", projectRoot); + assert.equal(fallbackShown.campaign.target.name, "Fallback"); + assert.equal(fallbackShown.yamlPath, ymlPath); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign duplicate source pairs are rejected by list and show", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const campaign = buildCampaign({ id: "demo", target: "Acme", vulnerabilities: ["xss"] }, fixedNow); + const yamlPath = join(dir, "demo.yaml"); + const ymlPath = join(dir, "demo.yml"); + await writeFile(yamlPath, stringifyYaml(campaign), "utf-8"); + await writeFile(ymlPath, stringifyYaml(campaign), "utf-8"); + + for (const operation of [ + () => listCampaigns(projectRoot), + () => showCampaign("demo", projectRoot), + ]) { + await assert.rejects( + operation, + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /duplicate Campaign sources/i); + assert.ok(error.message.includes(yamlPath)); + assert.ok(error.message.includes(ymlPath)); + return true; + }, + ); + } + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign show rejects unsafe ids before resolving files", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + await assert.rejects(() => showCampaign("../demo", projectRoot), /campaign id/i); + assert.equal(existsSync(campaignsDir(projectRoot)), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign list and show reject malformed files and filename/body id mismatches", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const path = join(dir, "broken.yaml"); + await writeFile(path, "target: [unterminated", "utf-8"); + await assert.rejects(() => listCampaigns(projectRoot), /broken\.yaml.*YAML.*parse/i); + await assert.rejects(() => showCampaign("broken", projectRoot), /broken\.yaml.*YAML.*parse/i); + + await writeFile(path, stringifyYaml(validCampaign()), "utf-8"); + await assert.rejects(() => listCampaigns(projectRoot), /filename id broken/i); + await assert.rejects(() => showCampaign("broken", projectRoot), /filename id broken/i); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign list and show leave workspace index bytes unchanged", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + await initWorkspace(projectRoot); + const campaign = buildCampaign({ id: "demo", target: "Acme", vulnerabilities: ["xss"] }, fixedNow); + await writeFile(campaignPath("demo", projectRoot), stringifyYaml(campaign), "utf-8"); + const before = await readFile(workspaceIndexPath(projectRoot)); + + await listCampaigns(projectRoot); + await showCampaign("demo", projectRoot); + + assert.deepEqual(await readFile(workspaceIndexPath(projectRoot)), before); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign ecosystem-aware next actions gate unknown targets before seed", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-campaign-")); + + try { + const unknown = await initCampaign( + { id: "unknown-target", target: "Unknown Target", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + const known = await initCampaign( + { id: "known-target", target: "Known Target", ecosystem: "npm", vulnerabilities: ["xss"] }, + { projectRoot, now: fixedNow }, + ); + const unknownAction = "Set target.ecosystem to a supported value before running omv campaign seed unknown-target"; + + assert.equal(unknown.nextAction, unknownAction); + assert.equal(known.nextAction, "omv campaign seed known-target"); + + const listed = await listCampaigns(projectRoot); + assert.equal(listed.find((item) => item.id === "unknown-target")?.nextAction, unknownAction); + assert.equal( + listed.find((item) => item.id === "known-target")?.nextAction, + "omv campaign seed known-target", + ); + assert.equal((await showCampaign("unknown-target", projectRoot)).nextAction, unknownAction); + assert.equal( + (await showCampaign("known-target", projectRoot)).nextAction, + "omv campaign seed known-target", + ); + + const unknownRunbook = await readFile(unknown.runbookPath, "utf-8"); + const ecosystemInstruction = unknownRunbook.indexOf("Set `target.ecosystem` to a supported value"); + const seedCommand = unknownRunbook.indexOf("omv campaign seed unknown-target"); + assert.ok(ecosystemInstruction >= 0); + assert.ok(seedCommand > ecosystemInstruction); + assert.doesNotMatch(unknownRunbook, /^2\..*omv campaign seed/m); + + const knownRunbook = await readFile(known.runbookPath, "utf-8"); + assert.match(knownRunbook, /^2\..*`omv campaign seed known-target`/m); + assert.doesNotMatch(knownRunbook, /Set `target\.ecosystem`/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("Campaign prompt resolution asks for both missing required values and splits comma classes", async () => { + const prompt = new RecordingCampaignPrompt(" Acme ", " XSS, auth z, ssrf "); + + const completed = await resolveCampaignInput({}, { interactive: true, prompt }); + + assert.deepEqual(prompt.calls, ["target", "vulnerabilities"]); + assert.deepEqual(completed, { + target: "Acme", + vulnerabilities: ["XSS", "auth z", "ssrf"], + }); + assert.equal("version" in completed, false); + assert.equal("mode" in completed, false); +}); + +test("Campaign prompt resolution asks only for the required value missing from partial input", async () => { + const vulnerabilityPrompt = new RecordingCampaignPrompt("unused", " xss, auth "); + const withTarget = await resolveCampaignInput( + { target: "Acme" }, + { interactive: true, prompt: vulnerabilityPrompt }, + ); + assert.deepEqual(vulnerabilityPrompt.calls, ["vulnerabilities"]); + assert.deepEqual(withTarget.vulnerabilities, ["xss", "auth"]); + + const targetPrompt = new RecordingCampaignPrompt(" Beta ", "unused"); + const withVulnerabilities = await resolveCampaignInput( + { vulnerabilities: ["ssrf"] }, + { interactive: true, prompt: targetPrompt }, + ); + assert.deepEqual(targetPrompt.calls, ["target"]); + assert.equal(withVulnerabilities.target, "Beta"); + assert.deepEqual(withVulnerabilities.vulnerabilities, ["ssrf"]); +}); + +test("Campaign prompt resolution never calls an adapter in non-interactive mode and reports all missing fields", async () => { + for (const reason of ["non-TTY", "--no-interactive", "--json"]) { + const prompt = new RecordingCampaignPrompt("Acme", "xss"); + await assert.rejects( + () => resolveCampaignInput({}, { interactive: false, prompt }), + (error: unknown) => { + assert.ok(error instanceof Error, reason); + assert.match(error.message, /target/i, reason); + assert.match(error.message, /vulnerabilit/i, reason); + return true; + }, + ); + assert.deepEqual(prompt.calls, [], reason); + } +}); + +test("Campaign prompt resolution rejects blank required responses after asking every missing field", async () => { + const prompt = new RecordingCampaignPrompt(" ", " , , "); + + await assert.rejects( + () => resolveCampaignInput({}, { interactive: true, prompt }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /target/i); + assert.match(error.message, /vulnerabilit/i); + return true; + }, + ); + assert.deepEqual(prompt.calls, ["target", "vulnerabilities"]); +}); + +test("Campaign prompt resolution never prompts for complete supplied input", async () => { + const prompt = new RecordingCampaignPrompt("unused", "unused"); + const input = { + target: " Acme ", + vulnerabilities: [" xss, auth ", " XSS "], + source: " /tmp/source ", + }; + + const completed = await resolveCampaignInput(input, { interactive: true, prompt }); + + assert.deepEqual(prompt.calls, []); + assert.deepEqual(completed, { + target: "Acme", + vulnerabilities: ["xss", "auth", "XSS"], + source: " /tmp/source ", + }); +}); + +test("Campaign prompt resolution requires an adapter only when interactive input is incomplete", async () => { + await assert.rejects( + () => resolveCampaignInput({ target: "Acme" }, { interactive: true }), + /prompt adapter.*required/i, + ); + assert.deepEqual( + await resolveCampaignInput( + { target: "Acme", vulnerabilities: ["xss"] }, + { interactive: true }, + ), + { target: "Acme", vulnerabilities: ["xss"] }, + ); +}); + +test("Campaign prompt production adapter routes questions and releases stream listeners", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let rendered = ""; + output.setEncoding("utf-8"); + output.on("data", (chunk: string) => { + rendered += chunk; + }); + + const prompt: CampaignPromptAdapter = new ReadlineCampaignPrompt(input, output); + const answer = prompt.askTarget(); + input.write("Acme\n"); + + assert.equal(await answer, "Acme"); + assert.match(rendered, /Target:/); + assert.ok(input.listenerCount("data") > 0); + closeCampaignPrompt(prompt); + assert.equal(input.listenerCount("data"), 0); +}); diff --git a/src/cli/__tests__/commands.test.ts b/src/cli/__tests__/commands.test.ts new file mode 100644 index 0000000..0003da6 --- /dev/null +++ b/src/cli/__tests__/commands.test.ts @@ -0,0 +1,203 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const cliPath = fileURLToPath(new URL("../omv.js", import.meta.url)); +const evidenceFixturePath = fileURLToPath( + new URL("../../../skills/omv-report/evals/fixtures/confirmed-prototype-pollution.yaml", import.meta.url), +); +const packageJsonPath = fileURLToPath(new URL("../../../package.json", import.meta.url)); + +test("compiled CLI entrypoint emits complete version JSON", async () => { + const result = runCli(["version", "--json"]); + const pkg = JSON.parse(await readFile(packageJsonPath, "utf-8")) as { version: string }; + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + const output = JSON.parse(result.stdout) as Record; + assert.equal(output.package, "oh-my-vul"); + assert.equal(output.version, pkg.version); + assert.equal(typeof output.registryVersion, "string"); + assert.equal(typeof output.platform, "string"); +}); + +test("compiled CLI renders help through the command router", () => { + const result = runCli(["--help"]); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /oh-my-vul/); + assert.match(result.stdout, /Usage:/); + assert.match(result.stdout, /omv findings workflow/); +}); + +test("compiled CLI rejects an unknown command with actionable text and a non-zero exit", () => { + const result = runCli(["not-a-command"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /Unknown command: not-a-command/); + assert.match(result.stderr, /Valid commands:/); + assert.match(result.stdout, /Usage:/); +}); + +test("dashboard human output uses the canonical workflow columns", async () => { + const projectRoot = await projectWithFinding(); + + try { + const result = runCli(["dashboard"], projectRoot); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /\bverdict\b/); + assert.match(result.stdout, /\bblocker\b/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("dashboard JSON output is one parseable document with stable core fields", async () => { + const projectRoot = await projectWithFinding(); + + try { + const result = runCli(["dashboard", "--json"], projectRoot); + + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout) as { + status?: { root?: string; activeCount?: number }; + workflow?: unknown[]; + activity?: unknown[]; + }; + assert.equal(output.status?.root, await realpath(join(projectRoot, ".omv"))); + assert.equal(output.status?.activeCount, 1); + assert.equal(Array.isArray(output.workflow), true); + assert.equal(Array.isArray(output.activity), true); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("compiled CLI runs the canonical Campaign workflow with stable JSON", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-commands-campaign-")); + try { + const initialized = runCli([ + "campaign", "init", "--id", "demo", "--target", "Acme", "--ecosystem", "npm", + "--vuln", "xss,auth", "--no-interactive", "--json", + ], projectRoot); + assert.equal(initialized.status, 0, initialized.stderr); + const initJson = JSON.parse(initialized.stdout) as { campaign: { id: string }; yamlPath: string; runbookPath: string }; + assert.equal(initJson.campaign.id, "demo"); + assert.equal(existsSync(initJson.yamlPath), true); + assert.equal(existsSync(initJson.runbookPath), true); + + const listed = runCli(["campaign", "--json"], projectRoot); + assert.equal(listed.status, 0, listed.stderr); + assert.deepEqual((JSON.parse(listed.stdout) as Array<{ id: string }>).map((item) => item.id), ["demo"]); + + const shown = runCli(["first", "show", "demo", "--json"], projectRoot); + assert.equal(shown.status, 0, shown.stderr); + assert.equal((JSON.parse(shown.stdout) as { campaign: { id: string } }).campaign.id, "demo"); + + const seeded = runCli(["campaign", "seed", "demo", "--json"], projectRoot); + assert.equal(seeded.status, 0, seeded.stderr); + assert.deepEqual((JSON.parse(seeded.stdout) as { created: Array<{ id: string }> }).created.map((item) => item.id), [ + "demo-xss", "demo-auth", + ]); + const repeated = runCli(["first", "seed", "demo", "--json"], projectRoot); + assert.equal(repeated.status, 0, repeated.stderr); + assert.equal((JSON.parse(repeated.stdout) as { skipped: unknown[] }).skipped.length, 2); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("compiled first alias initializes and JSON never prompts for missing values", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-commands-campaign-")); + try { + const alias = runCli([ + "first", "--id", "alias", "--target", "Alias", "--ecosystem", "npm", + "--vuln", "xss", "--no-interactive", "--json", + ], projectRoot); + assert.equal(alias.status, 0, alias.stderr); + assert.equal((JSON.parse(alias.stdout) as { campaign: { id: string } }).campaign.id, "alias"); + + const missing = runCli(["first", "--json"], projectRoot); + assert.equal(missing.status, 1); + assert.match(missing.stderr, /missing required fields/i); + assert.doesNotMatch(missing.stdout, /Target:|Vulnerability classes/); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("compiled CLI runs the SourceRef and report provenance workflow with stable JSON", async () => { + const projectRoot = await projectWithFinding(); + try { + const initialized = runCli(["sources", "init", "dashboard-fixture", "--json"], projectRoot); + assert.equal(initialized.status, 0, initialized.stderr); + const initJson = JSON.parse(initialized.stdout) as { + sourceRef: { finding_id: string; sources: unknown[] }; + path: string; + }; + assert.equal(initJson.sourceRef.finding_id, "dashboard-fixture"); + assert.equal(initJson.sourceRef.sources.length > 0, true); + assert.equal(existsSync(initJson.path), true); + + const shown = runCli(["sources", "show", "dashboard-fixture", "--json"], projectRoot); + assert.equal(shown.status, 0, shown.stderr); + assert.equal((JSON.parse(shown.stdout) as { stale: boolean }).stale, false); + + const reportDir = join(projectRoot, ".omv", "reports", "dashboard-fixture"); + const declaredReproDir = join(projectRoot, ".omv", "repro", "demo-merge-pp"); + await mkdir(reportDir, { recursive: true }); + await mkdir(join(projectRoot, ".omv", "repro", "dashboard-fixture"), { recursive: true }); + await mkdir(declaredReproDir, { recursive: true }); + await writeFile(join(reportDir, "advisory.md"), "# Advisory\n", "utf-8"); + await writeFile(join(declaredReproDir, "commands.sh"), "node repro.js\n", "utf-8"); + await writeFile(join(declaredReproDir, "observed.txt"), "observed locally\n", "utf-8"); + const provenance = runCli(["report", "provenance", "dashboard-fixture", "--json"], projectRoot); + assert.equal(provenance.status, 0, provenance.stderr); + const provenanceJson = JSON.parse(provenance.stdout) as { + manifest: { finding_id: string; inputs: unknown[] }; + path: string; + }; + assert.equal(provenanceJson.manifest.finding_id, "dashboard-fixture"); + assert.equal(provenanceJson.manifest.inputs.length >= 3, true); + assert.equal(existsSync(provenanceJson.path), true); + + const artifacts = runCli(["report", "artifacts", "dashboard-fixture", "--json"], projectRoot); + assert.equal(artifacts.status, 0, artifacts.stderr); + const artifactJson = JSON.parse(artifacts.stdout) as { + provenanceManifestExists: boolean; + provenanceFresh: boolean; + reportArtifactPaths: string[]; + }; + assert.equal(artifactJson.provenanceManifestExists, true); + assert.equal(artifactJson.provenanceFresh, true); + assert.equal(artifactJson.reportArtifactPaths.length, 1); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +function runCli(args: string[], cwd = process.cwd()) { + return spawnSync(process.execPath, [cliPath, ...args], { + cwd, + encoding: "utf-8", + env: { ...process.env, NO_COLOR: "1" }, + }); +} + +async function projectWithFinding(): Promise { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-commands-")); + const findingsDir = join(projectRoot, ".omv", "findings"); + await mkdir(findingsDir, { recursive: true }); + await writeFile( + join(findingsDir, "dashboard-fixture.yaml"), + await readFile(evidenceFixturePath, "utf-8"), + "utf-8", + ); + return projectRoot; +} diff --git a/src/cli/__tests__/eval.test.ts b/src/cli/__tests__/eval.test.ts new file mode 100644 index 0000000..a5b69a1 --- /dev/null +++ b/src/cli/__tests__/eval.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const cliPath = fileURLToPath(new URL("../omv.js", import.meta.url)); +const targetedOutput = fileURLToPath( + new URL("../../../skills/omv-find/evals/golden/invalid-flags.md", import.meta.url), +); + +test("compiled eval command runs the stable manifest as one JSON document", () => { + const result = runEval(["--json"]); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + const output = JSON.parse(result.stdout) as { + schema_version: string; + ok: boolean; + total: number; + passed: number; + failed: number; + results: unknown[]; + }; + assert.equal(output.schema_version, "1"); + assert.equal(output.ok, true); + assert.equal(output.total, 16); + assert.equal(output.passed, 16); + assert.equal(output.failed, 0); + assert.equal(output.results.length, 16); +}); + +test("compiled eval command emits parseable JUnit suite counts", () => { + const result = runEval(["--junit"]); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /^<\?xml version=['"]1\.0['"]/); + assert.match(result.stdout, /]*tests="16"/); + assert.match(result.stdout, /failures="0"/); + assert.equal((result.stdout.match(/ { + const result = runEval([ + "--skill", "omv-find", "--eval-id", "26", "--output", targetedOutput, "--json", + ]); + + assert.equal(result.status, 0, result.stderr); + const output = JSON.parse(result.stdout) as { + total: number; + passed: number; + results: Array<{ id: string; eval_id: number }>; + }; + assert.equal(output.total, 1); + assert.equal(output.passed, 1); + assert.equal(output.results[0].id, "omv-find-26"); + assert.equal(output.results[0].eval_id, 26); +}); + +test("compiled eval command reports a missing Python runtime", () => { + const result = runEval(["--json"], { OMV_PYTHON: "definitely-not-a-python-runtime" }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /unable to start.*python|python runtime/i); + assert.equal(result.stdout, ""); +}); + +function runEval(args: string[], extraEnv: Record = {}) { + return spawnSync(process.execPath, [cliPath, "eval", ...args], { + cwd: process.cwd(), + encoding: "utf-8", + env: { ...process.env, ...extraEnv, NO_COLOR: "1" }, + }); +} diff --git a/src/cli/__tests__/findings.test.ts b/src/cli/__tests__/findings.test.ts index d4a91da..9cbbdcc 100644 --- a/src/cli/__tests__/findings.test.ts +++ b/src/cli/__tests__/findings.test.ts @@ -26,6 +26,12 @@ import { recordSubmission } from "../submissions.js"; import { validateThreatMap } from "../threatmap.js"; import { initVerification, validateVerification } from "../verification.js"; import { reviewFinding } from "../review.js"; +import { + isReportReady, + isSubmissionScoreReady, + resolveDoctorNextAction, + SUBMISSION_READY_THRESHOLD, +} from "../workflow.js"; import { readWorkspaceActivity } from "../workspace.js"; const BASE_FINDING = `schema_version: "1" @@ -299,6 +305,32 @@ test("finding workflow recommends audit, repro, report, and archive next actions } }); +test("confirmed findings below the submission threshold stay out of the report queue", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-findings-")); + + try { + const dir = await ensureFindingsDir(projectRoot); + const lowConfidence = `${BASE_FINDING.replace("status: candidate", "status: confirmed")} +verdict: + exploitability: plausible + confidence: low + reason: incomplete confidence +`; + await writeFile(join(dir, "low-confidence.yaml"), lowConfidence, "utf-8"); + + const validation = await validateFinding("low-confidence", projectRoot); + assert.equal(validation.ok, true); + assert.ok(validation.submissionScore < 75); + + const [finding] = await listFindingWorkflow(projectRoot); + assert.notEqual(finding.nextAction, "/omv-report low-confidence"); + assert.match(finding.nextAction, /^\/omv-(?:audit|repro) /); + assert.ok(finding.priority < 100); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + test("workflow separates evidence completeness from submission readiness", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "omv-findings-")); @@ -590,6 +622,61 @@ provenance: } }); +test("readiness helpers share one report-ready policy", () => { + assert.equal(SUBMISSION_READY_THRESHOLD, 75); + assert.equal(isSubmissionScoreReady("confirmed", true, 75), true); + assert.equal(isSubmissionScoreReady("confirmed", true, 74), false); + assert.equal(isSubmissionScoreReady("candidate", true, 90), false); + + assert.equal( + isReportReady({ status: "confirmed", validationOk: true, submissionScore: 80 }), + true, + ); + assert.equal( + isReportReady({ + status: "confirmed", + validationOk: true, + submissionScore: 80, + threatMapOk: false, + }), + false, + ); + assert.equal( + isReportReady({ + status: "confirmed", + validationOk: true, + submissionScore: 80, + verificationOk: false, + }), + false, + ); + + assert.equal( + resolveDoctorNextAction("demo", true, "/omv-audit demo"), + "/omv-report demo", + ); + assert.equal( + resolveDoctorNextAction("demo", false, "/omv-audit demo", { + strictVerification: true, + verificationReady: false, + verificationExists: false, + }), + "omv verification init demo", + ); + assert.equal( + resolveDoctorNextAction("demo", false, "/omv-audit demo", { + strictVerification: true, + verificationReady: false, + verificationExists: true, + }), + "omv verification validate demo", + ); + assert.equal( + resolveDoctorNextAction("demo", false, "/omv-repro demo"), + "/omv-repro demo", + ); +}); + test("reviewFinding classifies report readiness verdicts", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "omv-review-")); diff --git a/src/cli/__tests__/render.test.ts b/src/cli/__tests__/render.test.ts new file mode 100644 index 0000000..f131a17 --- /dev/null +++ b/src/cli/__tests__/render.test.ts @@ -0,0 +1,127 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + printCampaignDetail, + printCampaignInitResult, + printCampaignSeedResult, + printCampaignSummaries, + printReportArtifacts, + printReproInitResult, + printUninstallResult, +} from "../render.js"; +import { buildCampaign } from "../campaign.js"; + +test("canonical uninstall renderer summarizes removed skills and manifest state", () => { + const output = captureOutput(() => printUninstallResult({ + scope: "user", + skillsDir: "/tmp/skills", + agentsDir: "/tmp/agents", + removed: ["omv"], + agentsRemoved: [], + notFound: ["omv-find"], + errors: [], + manifestRemoved: true, + setupScopeRemoved: false, + })); + + assert.match(output, /oh-my-vul uninstall/); + assert.match(output, /1\/2 skill\(s\) removed/); + assert.match(output, /manifest\s+removed/); +}); + +test("canonical repro renderer owns evidence and next-action output", () => { + const output = captureOutput(() => printReproInitResult({ + id: "demo", + path: "/tmp/repro/demo", + findingPath: "/tmp/findings/demo.yaml", + artifacts: ["/tmp/repro/demo/repro.md"], + written: ["/tmp/repro/demo/repro.md"], + skipped: [], + updatedFinding: true, + })); + + assert.match(output, /repro artifacts/); + assert.match(output, /evidence\s+updated evidence\.repro_artifacts/); + assert.match(output, /\/omv-repro demo/); +}); + +test("canonical report renderer owns artifact and reproduction summaries", () => { + const output = captureOutput(() => printReportArtifacts({ + id: "demo", + status: "confirmed", + reportsDir: "/tmp/reports/demo", + reproDir: "/tmp/repro/demo", + reportArtifactPaths: ["/tmp/reports/demo/vuldb.md"], + emptyReportArtifactPaths: [], + listedReproArtifacts: ["repro.md"], + existingReproArtifacts: ["repro.md"], + missingReproArtifacts: [], + errors: [], + warnings: [], + })); + + assert.match(output, /report files\s+1/); + assert.match(output, /repro refs\s+1\/1/); + assert.match(output, /vuldb\.md/); +}); + +test("canonical Campaign renderers own init, list, show, and seed output", () => { + const campaign = buildCampaign( + { id: "demo", target: "Acme", ecosystem: "npm", vulnerabilities: ["xss"] }, + () => new Date("2026-07-10T00:00:00.000Z"), + ); + const init = captureOutput(() => printCampaignInitResult({ + campaign, + yamlPath: "/tmp/.omv/campaigns/demo.yaml", + runbookPath: "/tmp/.omv/campaigns/demo.md", + overwritten: false, + nextAction: "omv campaign seed demo", + warnings: [], + })); + const list = captureOutput(() => printCampaignSummaries([{ + id: "demo", + title: campaign.title, + status: "active", + target: "Acme", + version: "unknown", + laneCount: 1, + nextAction: "omv campaign seed demo", + }])); + const detail = captureOutput(() => printCampaignDetail({ + campaign, + yamlPath: "/tmp/.omv/campaigns/demo.yaml", + runbookPath: "/tmp/.omv/campaigns/demo.md", + runbookExists: true, + nextAction: "omv campaign seed demo", + })); + const seed = captureOutput(() => printCampaignSeedResult({ + campaignId: "demo", + campaignPath: "/tmp/.omv/campaigns/demo.yaml", + created: [{ id: "demo-xss", path: "/tmp/.omv/findings/demo-xss.yaml", status: "candidate", created: true }], + skipped: [], + failed: [], + nextAction: "omv findings workflow", + })); + + assert.match(init, /campaign created/i); + assert.match(init, /demo\.yaml/); + assert.match(list, /lanes/i); + assert.match(list, /Acme/); + assert.match(detail, /demo-xss|xss/); + assert.match(seed, /created\s+1/); + assert.match(seed, /omv findings workflow/); +}); + +function captureOutput(render: () => void): string { + const originalLog = console.log; + const lines: string[] = []; + console.log = (...values: unknown[]) => { + lines.push(values.map(String).join(" ")); + }; + try { + render(); + } finally { + console.log = originalLog; + } + return lines.join("\n"); +} diff --git a/src/cli/__tests__/report-provenance.test.ts b/src/cli/__tests__/report-provenance.test.ts new file mode 100644 index 0000000..029b44f --- /dev/null +++ b/src/cli/__tests__/report-provenance.test.ts @@ -0,0 +1,170 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseDocument } from "yaml"; +import { checkReportArtifacts, createFindingTemplate } from "../findings.js"; +import { sha256File } from "../install-manifest.js"; +import { + findingReportsDir, + findingReproDir, + reportProvenancePath, + threatMapPath, + verificationPath, +} from "../paths.js"; +import { initSourceRef } from "../source-ref.js"; +import { + createReportProvenance, + validateReportProvenance, +} from "../report-provenance.js"; + +async function createFinding(projectRoot: string, id: string, status = "candidate"): Promise { + const result = await createFindingTemplate(id, { projectRoot }); + const doc = parseDocument(await readFile(result.path, "utf-8")); + doc.set("status", status); + doc.setIn(["package", "ecosystem"], "npm"); + doc.setIn(["package", "registry_name"], "demo-package"); + doc.setIn(["package", "repository_url"], "https://github.com/example/demo-package"); + await writeFile(result.path, String(doc), "utf-8"); + return result.path; +} + +test("report provenance hashes Evidence, reports, and available optional dependencies", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-report-provenance-")); + try { + const findingPath = await createFinding(projectRoot, "demo"); + const reportDir = findingReportsDir("demo", projectRoot); + const reproDir = findingReproDir("demo", projectRoot); + await mkdir(reportDir, { recursive: true }); + await mkdir(reproDir, { recursive: true }); + const reportPath = join(reportDir, "advisory.md"); + const reproPath = join(reproDir, "observed.txt"); + await writeFile(reportPath, "# Advisory\n", "utf-8"); + await writeFile(reproPath, "observed locally\n", "utf-8"); + await writeFile(threatMapPath("demo", projectRoot), "threat map bytes\n", "utf-8"); + await mkdir(join(projectRoot, ".omv", "verifications"), { recursive: true }); + await writeFile(verificationPath("demo", projectRoot), "verification bytes\n", "utf-8"); + await initSourceRef("demo", projectRoot); + + const doc = parseDocument(await readFile(findingPath, "utf-8")); + doc.setIn(["evidence", "repro_artifacts"], [".omv/repro/demo/observed.txt"]); + await writeFile(findingPath, String(doc), "utf-8"); + await initSourceRef("demo", projectRoot, { force: true }); + + const result = await createReportProvenance("demo", projectRoot, { + now: () => new Date("2026-07-10T02:03:04.000Z"), + }); + assert.equal(result.path, reportProvenancePath("demo", projectRoot)); + assert.equal(result.manifest.generated_at, "2026-07-10T02:03:04.000Z"); + assert.deepEqual( + result.manifest.inputs.map((input) => input.role), + ["evidence", "report", "source-ref", "threat-map", "verification", "reproduction"], + ); + for (const input of result.manifest.inputs) { + assert.match(input.sha256, /^[a-f0-9]{64}$/); + assert.equal(input.path.startsWith(projectRoot), false); + } + assert.equal( + result.manifest.inputs.find((input) => input.role === "evidence")?.sha256, + await sha256File(findingPath), + ); + assert.equal( + result.manifest.inputs.find((input) => input.role === "report")?.sha256, + await sha256File(reportPath), + ); + + const validation = await validateReportProvenance("demo", projectRoot); + assert.equal(validation.ok, true); + assert.equal(validation.fresh, true); + assert.deepEqual(validation.staleInputs, []); + assert.deepEqual(validation.missingInputs, []); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("report provenance protects an existing manifest and rejects manifest-only report directories", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-report-provenance-")); + try { + await createFinding(projectRoot, "demo"); + const reportDir = findingReportsDir("demo", projectRoot); + await mkdir(reportDir, { recursive: true }); + const manifestPath = reportProvenancePath("demo", projectRoot); + await writeFile(manifestPath, "{}\n", "utf-8"); + + await assert.rejects( + () => createReportProvenance("demo", projectRoot), + /no non-empty report artifacts/i, + ); + + await writeFile(join(reportDir, "report.md"), "report\n", "utf-8"); + const original = Buffer.from("preserve manifest bytes\r\n"); + await writeFile(manifestPath, original); + await assert.rejects( + () => createReportProvenance("demo", projectRoot), + /already exists.*--force/i, + ); + assert.deepEqual(await readFile(manifestPath), original); + + const replaced = await createReportProvenance("demo", projectRoot, { force: true }); + assert.equal(replaced.overwritten, true); + assert.equal(JSON.parse(await readFile(manifestPath, "utf-8")).finding_id, "demo"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("report artifact checks keep legacy manifests optional and exclude provenance from report counts", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-report-provenance-")); + try { + await createFinding(projectRoot, "legacy"); + const reportDir = findingReportsDir("legacy", projectRoot); + await mkdir(reportDir, { recursive: true }); + await writeFile(join(reportDir, "report.md"), "report\n", "utf-8"); + + const legacy = await checkReportArtifacts("legacy", projectRoot); + assert.equal(legacy.errors.length, 0); + assert.match(legacy.warnings.join("\n"), /provenance.*missing/i); + assert.equal(legacy.provenanceManifestExists, false); + + await createReportProvenance("legacy", projectRoot); + const checked = await checkReportArtifacts("legacy", projectRoot); + assert.equal(checked.reportArtifactPaths.length, 1); + assert.equal(checked.reportArtifactPaths[0].endsWith("report.md"), true); + assert.equal(checked.provenanceManifestExists, true); + assert.equal(checked.provenanceFresh, true); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("stale report manifests warn for candidates and error for confirmed findings", async () => { + for (const status of ["candidate", "confirmed"]) { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-report-provenance-")); + try { + const findingPath = await createFinding(projectRoot, "demo", status); + const reportDir = findingReportsDir("demo", projectRoot); + await mkdir(reportDir, { recursive: true }); + await writeFile(join(reportDir, "report.md"), "report\n", "utf-8"); + await createReportProvenance("demo", projectRoot); + await writeFile(findingPath, `${await readFile(findingPath, "utf-8")}# changed\n`, "utf-8"); + + const validation = await validateReportProvenance("demo", projectRoot); + assert.equal(validation.ok, true); + assert.equal(validation.fresh, false); + assert.deepEqual(validation.staleInputs, [".omv/findings/demo.yaml"]); + + const artifacts = await checkReportArtifacts("demo", projectRoot); + assert.equal(artifacts.provenanceFresh, false); + if (status === "confirmed") { + assert.match(artifacts.errors.join("\n"), /provenance.*stale/i); + } else { + assert.equal(artifacts.errors.length, 0); + assert.match(artifacts.warnings.join("\n"), /provenance.*stale/i); + } + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + } +}); diff --git a/src/cli/__tests__/request.test.ts b/src/cli/__tests__/request.test.ts index 99e13ed..ecd5714 100644 --- a/src/cli/__tests__/request.test.ts +++ b/src/cli/__tests__/request.test.ts @@ -1,10 +1,453 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { mkdtemp, rm } from "fs/promises"; +import { mkdtemp, readFile, rm } from "fs/promises"; import { join } from "path"; import { tmpdir } from "os"; import { requestFetch } from "../request.js"; +type TestResolver = (hostname: string) => Promise>; + +const publicResolver: TestResolver = async () => [{ address: "93.184.216.34", family: 4 }]; + +test("request broker rejects credentials and non-public literal destinations before fetch", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let calls = 0; + try { + globalThis.fetch = async () => { + calls += 1; + return new Response("unexpected"); + }; + + const urls = [ + "https://user:secret@example.test/metadata", + "http://localhost/metadata", + "http://127.0.0.1/metadata", + "http://10.0.0.5/metadata", + "http://169.254.10.20/metadata", + "http://[::1]/metadata", + "http://[fc00::1]/metadata", + "http://[fe80::1]/metadata", + "http://[fec0::1]/metadata", + "http://[2001:20::1]/metadata", + "http://[3fff::1]/metadata", + ]; + for (const url of urls) { + const options = { + projectRoot, + refresh: true, + retries: 0, + resolver: publicResolver, + }; + const result = await requestFetch(url, options); + assert.equal(result.ok, false, url); + assert.equal(result.failure?.reason, "unsafe_destination", url); + } + assert.equal(calls, 0); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker rejects a hostname when any resolved address is non-public", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let calls = 0; + const resolver: TestResolver = async (hostname) => { + assert.equal(hostname, "metadata.example.test"); + return [ + { address: "93.184.216.34", family: 4 }, + { address: "192.168.1.25", family: 4 }, + ]; + }; + try { + globalThis.fetch = async () => { + calls += 1; + return new Response("unexpected"); + }; + const options = { projectRoot, refresh: true, retries: 0, resolver }; + const result = await requestFetch("https://metadata.example.test/package", options); + + assert.equal(result.ok, false); + assert.equal(result.failure?.reason, "unsafe_destination"); + assert.equal(calls, 0); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker fetches a public destination after resolving all addresses", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + const resolved: string[] = []; + let calls = 0; + const resolver: TestResolver = async (hostname) => { + resolved.push(hostname); + return [ + { address: "93.184.216.34", family: 4 }, + { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, + ]; + }; + try { + globalThis.fetch = async () => { + calls += 1; + return new Response("public", { status: 200, headers: { "content-type": "text/plain" } }); + }; + const options = { projectRoot, refresh: true, retries: 0, resolver }; + const result = await requestFetch("https://metadata.example.test/package", options); + + assert.equal(result.ok, true); + assert.deepEqual(resolved, ["metadata.example.test"]); + assert.equal(calls, 1); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker follows relative redirects manually", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + const calls: string[] = []; + try { + globalThis.fetch = async (input, init) => { + calls.push(String(input)); + assert.equal(init?.redirect, "manual"); + if (String(input) === "https://metadata.example.test/start") { + return new Response(null, { status: 302, headers: { location: "/final" } }); + } + return new Response("done", { status: 200, headers: { "content-type": "text/plain" } }); + }; + const options = { projectRoot, refresh: true, retries: 0, resolver: publicResolver }; + const result = await requestFetch("https://metadata.example.test/start", options); + + assert.equal(result.ok, true); + assert.equal(result.bodyPreview, "done"); + assert.deepEqual(calls, [ + "https://metadata.example.test/start", + "https://metadata.example.test/final", + ]); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker rejects a redirect to a private destination", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + const calls: string[] = []; + try { + globalThis.fetch = async (input) => { + calls.push(String(input)); + return new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } }); + }; + const options = { projectRoot, refresh: true, retries: 0, resolver: publicResolver }; + const result = await requestFetch("https://metadata.example.test/start", options); + + assert.equal(result.ok, false); + assert.equal(result.failure?.reason, "unsafe_destination"); + assert.deepEqual(calls, ["https://metadata.example.test/start"]); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker stops after five redirect hops", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let calls = 0; + try { + globalThis.fetch = async () => { + calls += 1; + return new Response(null, { status: 302, headers: { location: `/hop-${calls}` } }); + }; + const options = { projectRoot, refresh: true, retries: 0, resolver: publicResolver }; + const result = await requestFetch("https://metadata.example.test/start", options); + + assert.equal(result.ok, false); + assert.equal(result.failure?.reason, "too_many_redirects"); + assert.equal(calls, 6); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker drops GitHub credentials on a cross-host redirect", async () => { + const originalFetch = globalThis.fetch; + const originalToken = process.env.GITHUB_TOKEN; + const originalGhToken = process.env.GH_TOKEN; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + const sentHeaders: Headers[] = []; + try { + process.env.GITHUB_TOKEN = "test-token"; + delete process.env.GH_TOKEN; + globalThis.fetch = async (input, init) => { + sentHeaders.push(new Headers(init?.headers)); + if (String(input).startsWith("https://api.github.com/")) { + return new Response(null, { + status: 302, + headers: { location: "https://downloads.example.test/archive.tgz" }, + }); + } + return new Response("archive", { status: 200, headers: { "content-type": "application/octet-stream" } }); + }; + const options = { projectRoot, refresh: true, retries: 0, resolver: publicResolver }; + const result = await requestFetch("https://api.github.com/repos/example/demo/tarball", options); + + assert.equal(result.ok, true); + assert.equal(sentHeaders.length, 2); + assert.equal(sentHeaders[0]?.get("authorization"), "Bearer test-token"); + assert.equal(sentHeaders[1]?.get("authorization"), null); + assert.equal(sentHeaders[1]?.get("x-github-api-version"), null); + } finally { + globalThis.fetch = originalFetch; + if (originalToken === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = originalToken; + if (originalGhToken === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = originalGhToken; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker rejects a declared response length above the configured limit", async () => { + const originalFetch = globalThis.fetch; + const originalLimit = process.env.OMV_HTTP_MAX_BODY_BYTES; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + try { + process.env.OMV_HTTP_MAX_BODY_BYTES = "4"; + globalThis.fetch = async () => new Response("tiny", { + status: 200, + headers: { "content-length": "100", "content-type": "text/plain" }, + }); + const options = { projectRoot, refresh: true, retries: 0, resolver: publicResolver }; + const result = await requestFetch("https://metadata.example.test/large", options); + + assert.equal(result.ok, false); + assert.equal(result.failure?.reason, "response_too_large"); + assert.equal(result.bodyBytes, 0); + } finally { + globalThis.fetch = originalFetch; + if (originalLimit === undefined) delete process.env.OMV_HTTP_MAX_BODY_BYTES; + else process.env.OMV_HTTP_MAX_BODY_BYTES = originalLimit; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker cancels a streamed response after it exceeds the configured limit", async () => { + const originalFetch = globalThis.fetch; + const originalLimit = process.env.OMV_HTTP_MAX_BODY_BYTES; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let cancelled = false; + let closeTimer: ReturnType | undefined; + try { + process.env.OMV_HTTP_MAX_BODY_BYTES = "4"; + globalThis.fetch = async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("abc")); + controller.enqueue(new TextEncoder().encode("abc")); + closeTimer = setTimeout(() => controller.close(), 50); + }, + cancel() { + if (closeTimer) clearTimeout(closeTimer); + cancelled = true; + }, + }), { status: 200, headers: { "content-type": "text/plain" } }); + const options = { projectRoot, refresh: true, retries: 0, resolver: publicResolver }; + const result = await requestFetch("https://metadata.example.test/stream", options); + + assert.equal(result.ok, false); + assert.equal(result.failure?.reason, "response_too_large"); + assert.equal(cancelled, true); + } finally { + globalThis.fetch = originalFetch; + if (originalLimit === undefined) delete process.env.OMV_HTTP_MAX_BODY_BYTES; + else process.env.OMV_HTTP_MAX_BODY_BYTES = originalLimit; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker preserves the size failure when stream cancellation rejects", async () => { + const originalFetch = globalThis.fetch; + const originalLimit = process.env.OMV_HTTP_MAX_BODY_BYTES; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let closeTimer: ReturnType | undefined; + try { + process.env.OMV_HTTP_MAX_BODY_BYTES = "4"; + globalThis.fetch = async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("abc")); + controller.enqueue(new TextEncoder().encode("abc")); + closeTimer = setTimeout(() => controller.close(), 50); + }, + cancel() { + if (closeTimer) clearTimeout(closeTimer); + return Promise.reject(new Error("cancel failed")); + }, + }), { status: 200, headers: { "content-type": "text/plain" } }); + const options = { projectRoot, refresh: true, retries: 0, resolver: publicResolver }; + const result = await requestFetch("https://metadata.example.test/stream", options); + + assert.equal(result.ok, false); + assert.equal(result.failure?.reason, "response_too_large"); + } finally { + globalThis.fetch = originalFetch; + if (originalLimit === undefined) delete process.env.OMV_HTTP_MAX_BODY_BYTES; + else process.env.OMV_HTTP_MAX_BODY_BYTES = originalLimit; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("bounded responses retain metadata and use the package version in the default User-Agent", async () => { + const originalFetch = globalThis.fetch; + const originalUserAgent = process.env.OMV_USER_AGENT; + const originalLimit = process.env.OMV_HTTP_MAX_BODY_BYTES; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let sentUserAgent = ""; + try { + delete process.env.OMV_USER_AGENT; + process.env.OMV_HTTP_MAX_BODY_BYTES = "64"; + globalThis.fetch = async (_input, init) => { + sentUserAgent = new Headers(init?.headers).get("user-agent") ?? ""; + return new Response("hello", { + status: 200, + headers: { "content-type": "text/plain", "set-cookie": "secret=value" }, + }); + }; + const options = { projectRoot, refresh: true, retries: 0, resolver: publicResolver }; + const result = await requestFetch("https://metadata.example.test/small", options); + const pkg = JSON.parse(await readFile(join(process.cwd(), "package.json"), "utf-8")) as { version: string }; + + assert.equal(result.ok, true); + assert.equal(result.bodyBytes, 5); + assert.equal(result.bodySha256, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"); + assert.equal(result.bodyPreview, "hello"); + assert.equal(result.headers["set-cookie"], undefined); + assert.match(sentUserAgent, new RegExp(`/${pkg.version.replaceAll(".", "\\.")}(?:\\s|$)`)); + } finally { + globalThis.fetch = originalFetch; + if (originalUserAgent === undefined) delete process.env.OMV_USER_AGENT; + else process.env.OMV_USER_AGENT = originalUserAgent; + if (originalLimit === undefined) delete process.env.OMV_HTTP_MAX_BODY_BYTES; + else process.env.OMV_HTTP_MAX_BODY_BYTES = originalLimit; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker treats invalid retry counts as zero retries", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let calls = 0; + try { + globalThis.fetch = async () => { + calls += 1; + return new Response("retryable", { status: 503, headers: { "content-type": "text/plain" } }); + }; + for (const retries of [Number.NaN, Number.POSITIVE_INFINITY, -1, 1.5]) { + const options = { projectRoot, refresh: true, retries, resolver: publicResolver }; + const result = await requestFetch(`https://metadata.example.test/retries-${calls}`, options); + assert.equal(result.status, 503); + assert.equal(result.failure?.reason, "upstream_error"); + } + assert.equal(calls, 4); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker times out a stalled initial DNS resolver", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let calls = 0; + const resolver: TestResolver = async () => new Promise(() => undefined); + try { + globalThis.fetch = async () => { + calls += 1; + return new Response("unexpected"); + }; + const options = { projectRoot, refresh: true, retries: 0, timeoutMs: 10, resolver }; + const result = await Promise.race([ + requestFetch("https://metadata.example.test/stalled", options), + rejectAfter(150, "initial resolver did not honor timeout"), + ]); + + assert.equal(result.ok, false); + assert.equal(result.failure?.reason, "network_timeout"); + assert.equal(calls, 0); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker times out a stalled redirect DNS resolver", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let calls = 0; + const resolver: TestResolver = async (hostname) => hostname === "metadata.example.test" + ? [{ address: "93.184.216.34", family: 4 }] + : new Promise(() => undefined); + try { + globalThis.fetch = async () => { + calls += 1; + return new Response(null, { + status: 302, + headers: { location: "https://stalled.example.test/final" }, + }); + }; + const options = { projectRoot, refresh: true, retries: 0, timeoutMs: 10, resolver }; + const result = await Promise.race([ + requestFetch("https://metadata.example.test/start", options), + rejectAfter(150, "redirect resolver did not honor timeout"), + ]); + + assert.equal(result.ok, false); + assert.equal(result.failure?.reason, "network_timeout"); + assert.equal(calls, 1); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("request broker retries an initial DNS timeout before fetching", async () => { + const originalFetch = globalThis.fetch; + const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); + let resolverCalls = 0; + let fetchCalls = 0; + const resolver: TestResolver = async () => { + resolverCalls += 1; + if (resolverCalls === 1) { + return new Promise((resolve) => { + const timer = setTimeout(() => resolve([{ address: "93.184.216.34", family: 4 }]), 50); + timer.unref(); + }); + } + return [{ address: "93.184.216.34", family: 4 }]; + }; + try { + globalThis.fetch = async () => { + fetchCalls += 1; + return new Response("recovered", { status: 200, headers: { "content-type": "text/plain" } }); + }; + const options = { projectRoot, refresh: true, retries: 1, timeoutMs: 10, resolver }; + const result = await requestFetch("https://metadata.example.test/recovered", options); + + assert.equal(result.ok, true); + assert.equal(result.bodyPreview, "recovered"); + assert.equal(resolverCalls, 2); + assert.equal(fetchCalls, 1); + } finally { + globalThis.fetch = originalFetch; + await rm(projectRoot, { recursive: true, force: true }); + } +}); + test("request broker classifies GitHub API rate limits", async () => { const originalFetch = globalThis.fetch; const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); @@ -22,11 +465,13 @@ test("request broker classifies GitHub API rate limits", async () => { }, ); - const result = await requestFetch("https://api.github.com/repos/example/demo", { + const options = { projectRoot, refresh: true, retries: 0, - }); + resolver: publicResolver, + }; + const result = await requestFetch("https://api.github.com/repos/example/demo", options); assert.equal(result.ok, false); assert.equal(result.status, 403); @@ -40,6 +485,13 @@ test("request broker classifies GitHub API rate limits", async () => { } }); +function rejectAfter(ms: number, message: string): Promise { + return new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error(message)), ms); + timer.unref(); + }); +} + test("request broker reuses fresh cached responses", async () => { const originalFetch = globalThis.fetch; const projectRoot = await mkdtemp(join(tmpdir(), "omv-request-")); @@ -56,16 +508,14 @@ test("request broker reuses fresh cached responses", async () => { }); }; - const first = await requestFetch("https://registry.npmjs.org/-/ping", { + const options = { projectRoot, accept: "application/json", retries: 0, - }); - const second = await requestFetch("https://registry.npmjs.org/-/ping", { - projectRoot, - accept: "application/json", - retries: 0, - }); + resolver: publicResolver, + }; + const first = await requestFetch("https://registry.npmjs.org/-/ping", options); + const second = await requestFetch("https://registry.npmjs.org/-/ping", options); assert.equal(first.ok, true); assert.equal(first.cached, false); diff --git a/src/cli/__tests__/source-ref.test.ts b/src/cli/__tests__/source-ref.test.ts new file mode 100644 index 0000000..ea7a77c --- /dev/null +++ b/src/cli/__tests__/source-ref.test.ts @@ -0,0 +1,152 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseDocument } from "yaml"; +import { createFindingTemplate } from "../findings.js"; +import { sha256File } from "../install-manifest.js"; +import { sourceRefPath } from "../paths.js"; +import { + initSourceRef, + parseSourceRefYaml, + showSourceRef, + validateSourceRef, +} from "../source-ref.js"; + +async function createFinding(projectRoot: string, id: string, withSources = true): Promise { + const result = await createFindingTemplate(id, { projectRoot }); + const doc = parseDocument(await readFile(result.path, "utf-8")); + doc.setIn(["package", "ecosystem"], "npm"); + doc.setIn(["package", "registry_name"], withSources ? "demo-package" : ""); + doc.setIn( + ["package", "repository_url"], + withSources ? "https://github.com/example/demo-package" : "", + ); + await writeFile(result.path, String(doc), "utf-8"); + return result.path; +} + +test("SourceRef init records only known Evidence source facts and the current finding hash", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-source-ref-")); + try { + const findingPath = await createFinding(projectRoot, "demo"); + const result = await initSourceRef("demo", projectRoot, { + now: () => new Date("2026-07-10T01:02:03.000Z"), + }); + + assert.equal(result.path, sourceRefPath("demo", projectRoot)); + assert.equal(result.overwritten, false); + assert.equal(result.sourceRef.finding_id, "demo"); + assert.equal(result.sourceRef.finding_sha256, await sha256File(findingPath)); + assert.equal(result.sourceRef.captured_at, "2026-07-10T01:02:03.000Z"); + assert.deepEqual(result.sourceRef.sources, [ + { + kind: "repository", + locator: "https://github.com/example/demo-package", + revision: "unknown", + path: "unknown", + sha256: "unknown", + }, + { + kind: "registry", + locator: "npm:demo-package", + revision: "unknown", + path: "unknown", + sha256: "unknown", + }, + ]); + assert.deepEqual(result.warnings, []); + assert.deepEqual( + parseSourceRefYaml(await readFile(result.path, "utf-8"), result.path), + result.sourceRef, + ); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("SourceRef init preserves unknown source identity without inventing a locator", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-source-ref-")); + try { + await createFinding(projectRoot, "unknown-source", false); + const result = await initSourceRef("unknown-source", projectRoot); + + assert.deepEqual(result.sourceRef.sources, []); + assert.match(result.warnings.join("\n"), /no known source/i); + assert.doesNotMatch(await readFile(result.path, "utf-8"), /github\.com|npm:/i); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("SourceRef validation is closed-schema and enforces filename identity, timestamps, and hashes", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-source-ref-")); + try { + await createFinding(projectRoot, "demo"); + const valid = await initSourceRef("demo", projectRoot); + const base = await readFile(valid.path, "utf-8"); + + assert.throws( + () => parseSourceRefYaml(`${base}invented: true\n`, valid.path), + /unknown field.*invented/i, + ); + assert.throws( + () => parseSourceRefYaml(base.replace("finding_id: demo", "finding_id: other"), valid.path), + /id must match filename/i, + ); + assert.throws( + () => parseSourceRefYaml(base.replace(/captured_at: .+/, "captured_at: 2026-02-30T00:00:00Z"), valid.path), + /captured_at/i, + ); + assert.throws( + () => parseSourceRefYaml(base.replace(/[a-f0-9]{64}/, "not-a-hash"), valid.path), + /finding_sha256/i, + ); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("SourceRef init protects existing bytes and force replaces the sidecar", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-source-ref-")); + try { + await createFinding(projectRoot, "demo"); + const first = await initSourceRef("demo", projectRoot); + const original = Buffer.from("preserve source bytes\r\n"); + await writeFile(first.path, original); + + await assert.rejects(() => initSourceRef("demo", projectRoot), /already exists.*--force/i); + assert.deepEqual(await readFile(first.path), original); + + const replaced = await initSourceRef("demo", projectRoot, { force: true }); + assert.equal(replaced.overwritten, true); + assert.equal(parseSourceRefYaml(await readFile(first.path, "utf-8"), first.path).finding_id, "demo"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("SourceRef validation reports staleness after Evidence bytes change", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-source-ref-")); + try { + const findingPath = await createFinding(projectRoot, "demo"); + await initSourceRef("demo", projectRoot); + + const fresh = await validateSourceRef("demo", projectRoot); + assert.equal(fresh.ok, true); + assert.equal(fresh.stale, false); + assert.deepEqual((await showSourceRef("demo", projectRoot)).sourceRef, fresh.sourceRef); + + await writeFile(findingPath, `${await readFile(findingPath, "utf-8")}# changed\n`, "utf-8"); + const stale = await validateSourceRef("demo", projectRoot); + assert.equal(stale.ok, true); + assert.equal(stale.stale, true); + assert.match(stale.warnings.join("\n"), /Evidence.*changed|stale/i); + + await assert.rejects(() => validateSourceRef("missing", projectRoot), /does not exist/i); + await assert.rejects(() => validateSourceRef("../unsafe", projectRoot), /source id must start/i); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/src/cli/__tests__/workspace.test.ts b/src/cli/__tests__/workspace.test.ts index 9f53cf5..c884748 100644 --- a/src/cli/__tests__/workspace.test.ts +++ b/src/cli/__tests__/workspace.test.ts @@ -1,11 +1,25 @@ import test from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { archivedFindingsDir, findingsDir, reproDir, workspaceIndexPath } from "../paths.js"; -import { initWorkspace, readWorkspaceActivity, workspaceStatus } from "../workspace.js"; +import { + archivedFindingsDir, + campaignPath, + campaignRunbookPath, + campaignsDir, + findingsDir, + reproDir, + sourceRefPath, + sourcesDir, + workspaceIndexPath, +} from "../paths.js"; +import { + initWorkspace, + readWorkspaceActivity, + workspaceStatus, +} from "../workspace.js"; test("workspace init creates local state and is idempotent", async () => { const projectRoot = await mkdtemp(join(tmpdir(), "omv-workspace-")); @@ -13,17 +27,50 @@ test("workspace init creates local state and is idempotent", async () => { try { const result = await initWorkspace(projectRoot); assert.equal(existsSync(findingsDir(projectRoot)), true); + assert.equal(existsSync(campaignsDir(projectRoot)), true); + assert.equal(existsSync(sourcesDir(projectRoot)), true); assert.equal(existsSync(reproDir(projectRoot)), true); assert.equal(existsSync(archivedFindingsDir(projectRoot)), true); assert.equal(existsSync(workspaceIndexPath(projectRoot)), true); + assert.equal( + campaignPath("demo", projectRoot), + join(projectRoot, ".omv", "campaigns", "demo.yaml"), + ); + assert.equal( + campaignRunbookPath("demo", projectRoot), + join(projectRoot, ".omv", "campaigns", "demo.md"), + ); + assert.equal( + sourceRefPath("demo", projectRoot), + join(projectRoot, ".omv", "sources", "demo.yaml"), + ); assert.equal(result.activeCount, 0); assert.equal(result.archivedCount, 0); assert.equal((await readWorkspaceActivity(projectRoot))[0].action, "workspace.init"); + const campaignId = "campaign-only"; + const yamlPath = campaignPath(campaignId, projectRoot); + const runbookPath = campaignRunbookPath(campaignId, projectRoot); + const campaignYaml = Buffer.from('schema_version: "1"\r\nid: campaign-only\r\n'); + const campaignRunbook = Buffer.from("# Campaign only\r\n\r\nPreserve these bytes.\r\n"); + await writeFile(yamlPath, campaignYaml); + await writeFile(runbookPath, campaignRunbook); + const sourcePath = sourceRefPath("demo", projectRoot); + const sourceBytes = Buffer.from('schema_version: "1"\r\nfinding_id: demo\r\n'); + await writeFile(sourcePath, sourceBytes); await writeFile(join(findingsDir(projectRoot), "demo.yaml"), "status: candidate\n", "utf-8"); const second = await initWorkspace(projectRoot); + assert.deepEqual(await readFile(yamlPath), campaignYaml); + assert.deepEqual(await readFile(runbookPath), campaignRunbook); + assert.deepEqual(await readFile(sourcePath), sourceBytes); assert.equal(second.activeCount, 1); assert.deepEqual(second.statusCounts, { candidate: 1 }); + + const index = JSON.parse(await readFile(workspaceIndexPath(projectRoot), "utf-8")) as { + findings: Array<{ id: string }>; + }; + assert.deepEqual(Object.keys(index).sort(), ["findings", "generatedAt", "version"]); + assert.equal(index.findings.some((entry) => entry.id === campaignId), false); } finally { await rm(projectRoot, { recursive: true, force: true }); } @@ -49,3 +96,65 @@ test("workspace status rebuilds stale index and reports gitignore privacy warnin await rm(projectRoot, { recursive: true, force: true }); } }); + +test("workspace init advises ignoring private state without mutating gitignore", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-workspace-")); + + try { + const result = await initWorkspace(projectRoot); + + assert.equal(existsSync(join(projectRoot, ".gitignore")), false); + assert.match(result.warnings.join("\n"), /\.omv\//); + assert.doesNotMatch(result.warnings.join("\n"), /Keep tracked|\.omv\/findings\//); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("workspace init --gitignore creates a missing gitignore", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-workspace-")); + + try { + const result = await initWorkspace(projectRoot, { gitignore: true }); + + assert.equal(await readFile(join(projectRoot, ".gitignore"), "utf-8"), ".omv/\n"); + assert.equal(result.warnings.some((warning) => warning.includes("add .omv/")), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("workspace init --gitignore appends the private directory idempotently", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-workspace-")); + + try { + const gitignorePath = join(projectRoot, ".gitignore"); + await writeFile(gitignorePath, "node_modules/", "utf-8"); + + const first = await initWorkspace(projectRoot, { gitignore: true }); + const second = await initWorkspace(projectRoot, { gitignore: true }); + + assert.equal(await readFile(gitignorePath, "utf-8"), "node_modules/\n.omv/\n"); + assert.equal(first.warnings.some((warning) => warning.includes("add .omv/")), false); + assert.equal(second.warnings.some((warning) => warning.includes("add .omv/")), false); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); + +test("workspace init --gitignore preserves an equivalent rooted ignore entry", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "omv-workspace-")); + + try { + const gitignorePath = join(projectRoot, ".gitignore"); + const original = "node_modules/\n/.omv/\n"; + await writeFile(gitignorePath, original, "utf-8"); + + const result = await initWorkspace(projectRoot, { gitignore: true }); + + assert.equal(await readFile(gitignorePath, "utf-8"), original); + assert.deepEqual(result.warnings, []); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/src/cli/args.ts b/src/cli/args.ts index 55e8c05..c48b1d2 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,3 +1,11 @@ +import { + CAMPAIGN_DEPTHS, + CAMPAIGN_ECOSYSTEMS, + CAMPAIGN_LOCAL_REPRODUCTIONS, + CAMPAIGN_MODES, + CAMPAIGN_OUTPUTS, +} from "./campaign.js"; + export interface ArgsValidation { ok: boolean; error?: string; @@ -58,6 +66,8 @@ export function validateArgs(args: string[]): ArgsValidation { minPositionals: 0, maxPositionals: 0, }); + case "eval": + return validateEvalArgs(args.slice(1)); case "review": return validateOptions(args.slice(1), { command: "review", @@ -66,10 +76,16 @@ export function validateArgs(args: string[]): ArgsValidation { minPositionals: 1, maxPositionals: 1, }); + case "campaign": + return validateCampaignArgs(args.slice(1), false); + case "first": + return validateCampaignArgs(args.slice(1), true); case "repro": return validateReproArgs(args.slice(1)); case "report": return validateReportArgs(args.slice(1)); + case "sources": + return validateSourcesArgs(args.slice(1)); case "threat-map": return validateThreatMapArgs(args.slice(1)); case "verification": @@ -96,7 +112,99 @@ export function validateArgs(args: string[]): ArgsValidation { case "submissions": return validateSubmissionsArgs(args.slice(1)); default: - return fail(`Unknown command: ${command}. Valid commands: version, setup, uninstall, config, doctor, dashboard, review, workspace, findings, radar, request, dedup, disclose, submissions, repro, report, threat-map, verification, help`); + return fail(`Unknown command: ${command}. Valid commands: version, setup, uninstall, config, doctor, dashboard, eval, campaign, first, review, workspace, findings, sources, radar, request, dedup, disclose, submissions, repro, report, threat-map, verification, help`); + } +} + +function validateEvalArgs(args: string[]): ArgsValidation { + const validated = validateOptions(args, { + command: "eval", + flags: new Set(["--json", "--junit", ...HELP_FLAGS]), + options: new Map(), + freeOptions: new Set(["--skill", "--eval-id", "--output"]), + minPositionals: 0, + maxPositionals: 0, + }); + if (!validated.ok) return validated; + if (args.includes("--json") && args.includes("--junit")) { + return fail("eval accepts only one output format: --json or --junit"); + } + const targeted = ["--skill", "--eval-id", "--output"].map((option) => args.includes(option)); + if (targeted.some(Boolean) && !targeted.every(Boolean)) { + return fail("eval targeted mode requires --skill, --eval-id, and --output together"); + } + if (targeted.every(Boolean)) { + const skill = optionValue(args, "--skill"); + const evalId = optionValue(args, "--eval-id"); + if (!skill || !/^[a-z0-9][a-z0-9-]*$/.test(skill)) { + return fail("--skill must be a lowercase package name"); + } + if (!evalId || !/^\d+$/.test(evalId)) { + return fail("--eval-id must be a non-negative integer"); + } + } + return ok(); +} + +function optionValue(args: string[], option: string): string | undefined { + const index = args.indexOf(option); + return index === -1 ? undefined : args[index + 1]; +} + +function validateCampaignArgs(args: string[], firstAlias: boolean): ArgsValidation { + const leading = args[0]; + let subcommand: string; + let rest: string[]; + if (firstAlias && (leading === undefined || leading.startsWith("-"))) { + subcommand = "init"; + rest = args; + } else if (!firstAlias && (leading === undefined || leading.startsWith("-"))) { + subcommand = "list"; + rest = args; + } else { + subcommand = leading; + rest = args.slice(1); + } + + switch (subcommand) { + case "init": + return validateOptions(rest, { + command: `${firstAlias ? "first" : "campaign"} init`, + flags: new Set(["--force", "--no-interactive", "--json", ...HELP_FLAGS]), + options: new Map>([ + ["--ecosystem", new Set(CAMPAIGN_ECOSYSTEMS)], + ["--mode", new Set(CAMPAIGN_MODES)], + ["--goal", new Set(CAMPAIGN_OUTPUTS)], + ["--budget", new Set(CAMPAIGN_DEPTHS)], + ["--local-lab", new Set(CAMPAIGN_LOCAL_REPRODUCTIONS)], + ]), + freeOptions: new Set(["--target", "--version", "--source", "--vuln", "--id"]), + minPositionals: 0, + maxPositionals: 0, + }); + case "list": + return validateOptions(rest, { + command: `${firstAlias ? "first" : "campaign"} list`, + flags: new Set(["--json", ...HELP_FLAGS]), + options: new Map(), + minPositionals: 0, + maxPositionals: 0, + }); + case "show": + case "seed": + return validateOptions(rest, { + command: `${firstAlias ? "first" : "campaign"} ${subcommand}`, + flags: new Set(["--json", ...HELP_FLAGS]), + options: new Map(), + minPositionals: 1, + maxPositionals: 1, + }); + case "help": + case "--help": + case "-h": + return rest.length <= 1 ? ok() : fail(`${firstAlias ? "first" : "campaign"} help accepts at most one topic`); + default: + return fail(`Unknown ${firstAlias ? "first" : "campaign"} command: ${subcommand}. Valid commands: init, list, show, seed, help`); } } @@ -192,12 +300,50 @@ function validateReportArgs(args: string[]): ArgsValidation { minPositionals: 1, maxPositionals: 1, }); + case "provenance": + return validateOptions(rest, { + command: "report provenance", + flags: new Set(["--force", "--json", ...HELP_FLAGS]), + options: new Map(), + minPositionals: 1, + maxPositionals: 1, + }); case "help": case "--help": case "-h": return rest.length === 0 ? ok() : fail(`report ${subcommand} accepts no arguments`); default: - return fail(`Unknown report command: ${subcommand ?? ""}. Valid commands: artifacts, help`); + return fail(`Unknown report command: ${subcommand ?? ""}. Valid commands: artifacts, provenance, help`); + } +} + +function validateSourcesArgs(args: string[]): ArgsValidation { + const subcommand = args[0]; + const rest = args.slice(1); + switch (subcommand) { + case "init": + return validateOptions(rest, { + command: "sources init", + flags: new Set(["--force", "--json", ...HELP_FLAGS]), + options: new Map(), + minPositionals: 1, + maxPositionals: 1, + }); + case "show": + case "validate": + return validateOptions(rest, { + command: `sources ${subcommand}`, + flags: new Set(["--json", ...HELP_FLAGS]), + options: new Map(), + minPositionals: 1, + maxPositionals: 1, + }); + case "help": + case "--help": + case "-h": + return rest.length === 0 ? ok() : fail(`sources ${subcommand} accepts no arguments`); + default: + return fail(`Unknown sources command: ${subcommand ?? ""}. Valid commands: init, show, validate, help`); } } diff --git a/src/cli/campaign-prompt.ts b/src/cli/campaign-prompt.ts new file mode 100644 index 0000000..2a63ebb --- /dev/null +++ b/src/cli/campaign-prompt.ts @@ -0,0 +1,25 @@ +import { createInterface } from "node:readline/promises"; +import type { CampaignPromptAdapter } from "./campaign.js"; + +export class ReadlineCampaignPrompt implements CampaignPromptAdapter { + private readonly readline; + + constructor( + input: NodeJS.ReadableStream = process.stdin, + output: NodeJS.WritableStream = process.stdout, + ) { + this.readline = createInterface({ input, output }); + } + + askTarget(): Promise { + return this.readline.question("Target: "); + } + + askVulnerabilities(): Promise { + return this.readline.question("Vulnerability classes (comma-separated): "); + } + + close(): void { + this.readline.close(); + } +} diff --git a/src/cli/campaign-seed.ts b/src/cli/campaign-seed.ts new file mode 100644 index 0000000..15e12a7 --- /dev/null +++ b/src/cli/campaign-seed.ts @@ -0,0 +1,128 @@ +import { lstat } from "node:fs/promises"; +import { join } from "node:path"; +import { showCampaign } from "./campaign.js"; +import { + EVIDENCE_ECOSYSTEMS, + createFindingTemplate, + type CreateFindingTemplateOptions, + type EvidenceEcosystem, + type EvidenceResearcherGoal, + type FindingTemplateResult, +} from "./findings.js"; +import { findingsDir } from "./paths.js"; + +export interface CampaignSeedSkipped { + id: string; + path: string; + reason: "already exists"; +} + +export interface CampaignSeedFailure { + id: string; + message: string; +} + +export interface CampaignSeedResult { + campaignId: string; + campaignPath: string; + created: FindingTemplateResult[]; + skipped: CampaignSeedSkipped[]; + failed: CampaignSeedFailure[]; + nextAction: string; +} + +export type CampaignFindingCreator = ( + id: string, + options: CreateFindingTemplateOptions, +) => Promise; + +export interface SeedCampaignDependencies { + createFinding?: CampaignFindingCreator; +} + +export async function seedCampaign( + id: string, + projectRoot = process.cwd(), + dependencies: SeedCampaignDependencies = {}, +): Promise { + const detail = await showCampaign(id, projectRoot); + const ecosystem = detail.campaign.target.ecosystem; + if (ecosystem === "unknown" || !EVIDENCE_ECOSYSTEMS.includes(ecosystem as EvidenceEcosystem)) { + throw new Error("target.ecosystem must be a supported Evidence ecosystem before seeding"); + } + + const createFinding = dependencies.createFinding ?? createFindingTemplate; + const created: FindingTemplateResult[] = []; + const skipped: CampaignSeedSkipped[] = []; + const failed: CampaignSeedFailure[] = []; + + for (const lane of detail.campaign.lanes) { + const existing = await existingFindingPath(lane.finding_id, projectRoot); + if (existing) { + skipped.push({ id: lane.finding_id, path: existing, reason: "already exists" }); + continue; + } + + try { + created.push(await createFinding(lane.finding_id, { + projectRoot, + seed: { + researcherGoal: evidenceGoal(detail.campaign.goal.output), + product: detail.campaign.target.name, + ecosystem: ecosystem as EvidenceEcosystem, + vulnerabilityClass: lane.vulnerability_class, + }, + })); + } catch (error) { + const raced = await existingFindingPath(lane.finding_id, projectRoot); + if (raced) { + skipped.push({ id: lane.finding_id, path: raced, reason: "already exists" }); + } else { + failed.push({ + id: lane.finding_id, + message: error instanceof Error ? error.message : String(error), + }); + } + } + } + + return { + campaignId: detail.campaign.id, + campaignPath: detail.yamlPath, + created, + skipped, + failed, + nextAction: failed.length > 0 + ? `omv campaign seed ${detail.campaign.id}` + : "omv findings workflow", + }; +} + +function evidenceGoal(output: string): EvidenceResearcherGoal { + switch (output) { + case "cve": + return "CVE"; + case "vuldb": + return "VulDB"; + case "course-report": + case "internal-report": + return "advisory"; + default: + return "triage"; + } +} + +async function existingFindingPath(id: string, projectRoot: string): Promise { + for (const suffix of [".yaml", ".yml"]) { + const path = join(findingsDir(projectRoot), `${id}${suffix}`); + try { + await lstat(path); + return path; + } catch (error) { + if (!(error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT")) { + throw error; + } + } + } + return undefined; +} diff --git a/src/cli/campaign.ts b/src/cli/campaign.ts new file mode 100644 index 0000000..cec79b1 --- /dev/null +++ b/src/cli/campaign.ts @@ -0,0 +1,1056 @@ +import { existsSync } from "fs"; +import { link, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, unlink, writeFile } from "fs/promises"; +import { basename, join } from "path"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { campaignPath, campaignRunbookPath, campaignsDir, workspaceActivityLogPath } from "./paths.js"; +import { appendWorkspaceActivity } from "./workspace.js"; + +export const CAMPAIGN_MODES = ["whitebox", "graybox", "local-lab", "passive", "mixed"] as const; +export const CAMPAIGN_OUTPUTS = [ + "course-report", + "cve", + "vuldb", + "internal-report", + "research-notes", +] as const; +export const CAMPAIGN_DEPTHS = ["quick", "standard", "deep"] as const; +export const CAMPAIGN_LOCAL_REPRODUCTIONS = ["yes", "no", "unknown"] as const; +export const CAMPAIGN_ECOSYSTEMS = [ + "unknown", + "npm", + "python", + "go", + "rust", + "java", + "ruby", + "php", + "csharp", + "swift", + "dart", + "elixir", + "perl", + "r", + "lua", +] as const; + +export type CampaignMode = (typeof CAMPAIGN_MODES)[number]; +export type CampaignOutput = (typeof CAMPAIGN_OUTPUTS)[number]; +export type CampaignDepth = (typeof CAMPAIGN_DEPTHS)[number]; +export type CampaignLocalReproduction = (typeof CAMPAIGN_LOCAL_REPRODUCTIONS)[number]; +export type CampaignEcosystem = (typeof CAMPAIGN_ECOSYSTEMS)[number]; +export type CampaignStatus = "active"; +export type CampaignProfile = "generic"; + +export interface CampaignTarget { + name: string; + version: string; + source: string; + ecosystem: CampaignEcosystem; +} + +export interface CampaignScope { + mode: CampaignMode; + local_reproduction: CampaignLocalReproduction; + boundaries: string[]; +} + +export interface CampaignLane { + id: string; + title: string; + vulnerability_class: string; + finding_id: string; +} + +export interface Campaign { + schema_version: "1"; + id: string; + title: string; + status: CampaignStatus; + profile: CampaignProfile; + created_at: string; + updated_at: string; + target: CampaignTarget; + scope: CampaignScope; + goal: { + output: CampaignOutput; + }; + budget: { + depth: CampaignDepth; + }; + priorities: { + vulnerability_classes: string[]; + }; + lanes: CampaignLane[]; +} + +export interface CampaignInput { + id?: string; + target?: string; + version?: string; + source?: string; + ecosystem?: string; + mode?: CampaignMode; + output?: CampaignOutput; + depth?: CampaignDepth; + vulnerabilities?: string[]; + localReproduction?: CampaignLocalReproduction; +} + +export interface CampaignPromptAdapter { + askTarget(): Promise; + askVulnerabilities(): Promise; + close(): void; +} + +export interface InitCampaignOptions { + projectRoot?: string; + force?: boolean; + now?: CampaignClock; +} + +export interface InitCampaignResult { + campaign: Campaign; + yamlPath: string; + runbookPath: string; + overwritten: boolean; + nextAction: string; + warnings: string[]; +} + +export interface CampaignSummary { + id: string; + title: string; + status: CampaignStatus; + target: string; + version: string; + laneCount: number; + nextAction: string; +} + +export interface ShowCampaignResult { + campaign: Campaign; + yamlPath: string; + runbookPath: string; + runbookExists: boolean; + nextAction: string; +} + +export interface ResolveCampaignInputOptions { + interactive: boolean; + prompt?: CampaignPromptAdapter; +} + +export type CampaignClock = () => Date; + +export const CAMPAIGN_SAFETY_BOUNDARIES = [ + "local or explicitly authorized assets only", + "no live third-party testing", + "no automatic exploitation", +] as const; + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SAFE_CLASS = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const ISO_TIMESTAMP = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?(Z|([+-])(\d{2}):(\d{2}))$/; +const MODE_SET = new Set(CAMPAIGN_MODES); +const OUTPUT_SET = new Set(CAMPAIGN_OUTPUTS); +const DEPTH_SET = new Set(CAMPAIGN_DEPTHS); +const LOCAL_REPRODUCTION_SET = new Set(CAMPAIGN_LOCAL_REPRODUCTIONS); +const ECOSYSTEM_SET = new Set(CAMPAIGN_ECOSYSTEMS); +const CAMPAIGN_KEYS = new Set([ + "schema_version", + "id", + "title", + "status", + "profile", + "created_at", + "updated_at", + "target", + "scope", + "goal", + "budget", + "priorities", + "lanes", +]); +const TARGET_KEYS = new Set(["name", "version", "source", "ecosystem"]); +const SCOPE_KEYS = new Set(["mode", "local_reproduction", "boundaries"]); +const GOAL_KEYS = new Set(["output"]); +const BUDGET_KEYS = new Set(["depth"]); +const PRIORITIES_KEYS = new Set(["vulnerability_classes"]); +const LANE_KEYS = new Set(["id", "title", "vulnerability_class", "finding_id"]); + +export function normalizeCampaignId(id: string): string { + const normalized = typeof id === "string" ? id.trim() : ""; + if (!SAFE_ID.test(normalized)) { + throw new Error( + "campaign id must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens", + ); + } + return normalized; +} + +export function normalizeVulnerabilityClasses(values: readonly string[] | undefined): string[] { + if (!Array.isArray(values)) { + throw new Error("at least one vulnerability class is required"); + } + + const normalized: string[] = []; + const seen = new Set(); + for (const value of values) { + const slug = typeof value === "string" ? asciiSlug(value) : ""; + if (slug && !seen.has(slug)) { + seen.add(slug); + normalized.push(slug); + } + } + if (normalized.length === 0) { + throw new Error("at least one usable vulnerability class is required"); + } + return normalized; +} + +export function buildCampaign(input: CampaignInput, now: CampaignClock = () => new Date()): Campaign { + const targetName = trimText(input.target); + if (!targetName) { + throw new Error("target is required and must be non-empty"); + } + + const version = optionalText(input.version); + const source = optionalText(input.source); + const ecosystem = optionalText(input.ecosystem); + const mode = trimText(input.mode) || "passive"; + const output = trimText(input.output) || "research-notes"; + const depth = trimText(input.depth) || "standard"; + const localReproduction = optionalText(input.localReproduction); + requireAllowed(ecosystem, ECOSYSTEM_SET, "target.ecosystem", CAMPAIGN_ECOSYSTEMS); + requireAllowed(mode, MODE_SET, "scope.mode", CAMPAIGN_MODES); + requireAllowed(localReproduction, LOCAL_REPRODUCTION_SET, "scope.local_reproduction", CAMPAIGN_LOCAL_REPRODUCTIONS); + requireAllowed(output, OUTPUT_SET, "goal.output", CAMPAIGN_OUTPUTS); + requireAllowed(depth, DEPTH_SET, "budget.depth", CAMPAIGN_DEPTHS); + + const vulnerabilityClasses = normalizeVulnerabilityClasses(input.vulnerabilities); + let id: string; + if (input.id !== undefined) { + id = normalizeCampaignId(input.id); + } else { + const targetSlug = asciiSlug(targetName); + if (!targetSlug) { + throw new Error("target must produce a safe ASCII campaign id"); + } + if (version === "unknown") { + id = targetSlug; + } else { + const versionSlug = asciiSlug(version); + if (!versionSlug) { + throw new Error("known version must produce a safe ASCII campaign id segment"); + } + id = `${targetSlug}-${versionSlug}`; + } + } + + const current = now(); + if (!(current instanceof Date) || Number.isNaN(current.getTime())) { + throw new Error("now must return a valid Date"); + } + const timestamp = current.toISOString(); + const campaign: Campaign = { + schema_version: "1", + id, + title: campaignTitle(targetName, version), + status: "active", + profile: "generic", + created_at: timestamp, + updated_at: timestamp, + target: { + name: targetName, + version, + source, + ecosystem: ecosystem as CampaignEcosystem, + }, + scope: { + mode: mode as CampaignMode, + local_reproduction: localReproduction as CampaignLocalReproduction, + boundaries: [...CAMPAIGN_SAFETY_BOUNDARIES], + }, + goal: { output: output as CampaignOutput }, + budget: { depth: depth as CampaignDepth }, + priorities: { vulnerability_classes: vulnerabilityClasses }, + lanes: vulnerabilityClasses.map((vulnerabilityClass) => ({ + id: vulnerabilityClass, + title: `Review ${vulnerabilityClass} hypotheses`, + vulnerability_class: vulnerabilityClass, + finding_id: `${id}-${vulnerabilityClass}`, + })), + }; + return validateCampaign(campaign); +} + +export function validateCampaign(value: unknown): Campaign { + return validateCampaignFromSource(value, "Campaign.v1"); +} + +export function parseCampaignYaml(text: string, source = "Campaign.v1 YAML"): Campaign { + let parsed: unknown; + try { + parsed = parseYaml(text); + } catch (error) { + throw new Error(`${source}: Campaign YAML parse error: ${errorMessage(error)}`); + } + + const campaign = validateCampaignFromSource(parsed, source); + const fileId = campaignIdFromSource(source); + if (fileId && campaign.id !== fileId) { + throw new Error(`${source}: id must match filename id ${fileId}; received ${campaign.id}`); + } + return campaign; +} + +export function renderCampaignRunbook(campaign: Campaign): string { + const normalized = validateCampaign(campaign); + const lines = [ + `# ${escapeMarkdownText(normalized.title)}`, + "", + `Campaign ID: ${normalized.id}`, + "", + "This campaign records unproven candidate hypotheses. It does not claim discovery or proof.", + "", + "## Target", + "", + `- Target: ${escapeMarkdownText(normalized.target.name)}`, + `- Version: ${escapeMarkdownText(normalized.target.version)}`, + `- Source: ${escapeMarkdownText(normalized.target.source)}`, + `- Ecosystem: ${normalized.target.ecosystem}`, + "", + "## Scope", + "", + `- Mode: ${normalized.scope.mode}`, + `- Local reproduction: ${normalized.scope.local_reproduction}`, + `- Output: ${normalized.goal.output}`, + `- Depth: ${normalized.budget.depth}`, + "", + "### Safety boundaries", + "", + ...normalized.scope.boundaries.map((boundary) => `- ${escapeMarkdownText(boundary)}`), + "", + "## Candidate hypothesis lanes", + "", + ]; + + for (const lane of normalized.lanes) { + lines.push( + `### ${lane.title}`, + "", + `- Vulnerability class: ${lane.vulnerability_class}`, + `- Finding ID: ${lane.finding_id}`, + "- State: unproven candidate hypothesis", + `- Audit after seeding: \`/omv-audit ${lane.finding_id}\``, + "", + ); + } + + lines.push( + "## Next actions", + "", + `1. Review the normalized campaign: \`omv campaign show ${normalized.id}\``, + ); + if (normalized.target.ecosystem === "unknown") { + lines.push( + "2. Set `target.ecosystem` to a supported value in the Campaign YAML.", + `3. Only after setting the ecosystem, create candidate finding templates: \`omv campaign seed ${normalized.id}\``, + "4. Audit each candidate separately before making any security claim.", + "", + ); + } else { + lines.push( + `2. Create candidate finding templates: \`omv campaign seed ${normalized.id}\``, + "3. Audit each candidate separately before making any security claim.", + "", + ); + } + return `${lines.join("\n")}\n`; +} + +export async function initCampaign( + input: CampaignInput, + options: InitCampaignOptions = {}, +): Promise { + const campaign = buildCampaign(input, options.now); + const projectRoot = options.projectRoot ?? process.cwd(); + const force = options.force ?? false; + await ensureRealCampaignDirectory(projectRoot); + const result = await withCampaignLock( + campaign.id, + projectRoot, + () => commitCampaignArtifacts(campaign, projectRoot, force), + ); + try { + await appendWorkspaceActivity({ action: "campaign.init", id: campaign.id, path: result.yamlPath }, projectRoot); + return result; + } catch (error) { + return { + ...result, + warnings: [ + `Campaign artifacts committed, but activity recording failed at ${workspaceActivityLogPath(projectRoot)}: ${errorMessage(error)}`, + ], + }; + } +} + +export async function listCampaigns(projectRoot = process.cwd()): Promise { + const dir = campaignsDir(projectRoot); + if (!existsSync(dir)) { + return []; + } + + const files = (await readdir(dir, { withFileTypes: true })) + .filter((dirent) => dirent.isFile() && /\.ya?ml$/.test(dirent.name)) + .map((dirent) => dirent.name); + const summaries: CampaignSummary[] = []; + const ids = [...new Set(files.map((file) => file.replace(/\.ya?ml$/, "")))]; + for (const id of ids) { + const path = resolveCampaignSource(id, projectRoot); + if (!path) { + continue; + } + const campaign = parseCampaignYaml(await readFile(path, "utf-8"), path); + summaries.push({ + id: campaign.id, + title: campaign.title, + status: campaign.status, + target: campaign.target.name, + version: campaign.target.version, + laneCount: campaign.lanes.length, + nextAction: campaignNextAction(campaign), + }); + } + return summaries.sort((left, right) => left.id.localeCompare(right.id)); +} + +export async function showCampaign( + id: string, + projectRoot = process.cwd(), +): Promise { + const normalizedId = normalizeCampaignId(id); + const yamlPath = resolveCampaignSource(normalizedId, projectRoot); + if (!yamlPath) { + throw new Error(`${campaignPath(normalizedId, projectRoot)} does not exist`); + } + + const campaign = parseCampaignYaml(await readFile(yamlPath, "utf-8"), yamlPath); + const runbookPath = campaignRunbookPath(normalizedId, projectRoot); + return { + campaign, + yamlPath, + runbookPath, + runbookExists: existsSync(runbookPath), + nextAction: campaignNextAction(campaign), + }; +} + +export async function resolveCampaignInput( + input: CampaignInput, + options: ResolveCampaignInputOptions, +): Promise { + let target = trimText(input.target); + let vulnerabilities = splitVulnerabilityClasses(input.vulnerabilities); + const needsTarget = !target; + const needsVulnerabilities = vulnerabilities.length === 0; + + if (options.interactive && (needsTarget || needsVulnerabilities) && !options.prompt) { + throw new Error( + `Campaign prompt adapter is required for missing fields: ${missingRequiredFields(target, vulnerabilities).join(", ")}`, + ); + } + if (options.interactive && options.prompt) { + if (needsTarget) { + target = trimText(await options.prompt.askTarget()); + } + if (needsVulnerabilities) { + vulnerabilities = splitVulnerabilityClasses([await options.prompt.askVulnerabilities()]); + } + } + + const missing = missingRequiredFields(target, vulnerabilities); + if (missing.length > 0) { + throw new Error(`Campaign initialization is missing required fields: ${missing.join(", ")}`); + } + return { + ...input, + target, + vulnerabilities, + }; +} + +function validateCampaignFromSource(value: unknown, source: string): Campaign { + const errors: string[] = []; + if (!isRecord(value)) { + throw new Error(`${source}: Campaign.v1 must be a mapping`); + } + + rejectUnknownKeys(value, CAMPAIGN_KEYS, "", errors); + requireExact(value, "schema_version", "1", errors); + const id = requireText(value, "id", errors); + if (id && !SAFE_ID.test(id)) { + errors.push("id must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens"); + } + const title = requireCanonicalText(value, "title", errors); + requireExact(value, "status", "active", errors); + requireExact(value, "profile", "generic", errors); + const createdAt = requireIsoTimestamp(value, "created_at", errors); + const updatedAt = requireIsoTimestamp(value, "updated_at", errors); + if (createdAt && updatedAt && Date.parse(updatedAt) < Date.parse(createdAt)) { + errors.push("updated_at must not be earlier than created_at"); + } + + const target = requireMapping(value, "target", errors); + let targetName = ""; + let targetVersion = ""; + if (target) { + rejectUnknownKeys(target, TARGET_KEYS, "target", errors); + targetName = requireCanonicalText(target, "name", errors, "target.name"); + targetVersion = requireCanonicalText(target, "version", errors, "target.version"); + const targetSource = requireCanonicalText(target, "source", errors, "target.source"); + requireCanonicalUnknown(targetVersion, "target.version", errors); + requireCanonicalUnknown(targetSource, "target.source", errors); + requireEnum(target, "ecosystem", ECOSYSTEM_SET, CAMPAIGN_ECOSYSTEMS, errors, "target.ecosystem"); + } + if (title && targetName && targetVersion) { + const expectedTitle = campaignTitle(targetName, targetVersion); + if (title !== expectedTitle) { + errors.push(`title must equal ${expectedTitle}`); + } + } + + const scope = requireMapping(value, "scope", errors); + if (scope) { + rejectUnknownKeys(scope, SCOPE_KEYS, "scope", errors); + requireEnum(scope, "mode", MODE_SET, CAMPAIGN_MODES, errors, "scope.mode"); + requireEnum( + scope, + "local_reproduction", + LOCAL_REPRODUCTION_SET, + CAMPAIGN_LOCAL_REPRODUCTIONS, + errors, + "scope.local_reproduction", + ); + const boundaries = requireStringList(scope, "boundaries", errors, "scope.boundaries", true); + boundaries.forEach((boundary, index) => { + if (boundary && boundary !== boundary.trim()) { + errors.push(`scope.boundaries[${index}] must not contain surrounding whitespace`); + } + requireSingleLineText(boundary, `scope.boundaries[${index}]`, errors); + }); + for (const boundary of CAMPAIGN_SAFETY_BOUNDARIES) { + if (!boundaries.includes(boundary)) { + errors.push(`scope.boundaries must include baseline boundary: ${boundary}`); + } + } + } + + const goal = requireMapping(value, "goal", errors); + if (goal) { + rejectUnknownKeys(goal, GOAL_KEYS, "goal", errors); + requireEnum(goal, "output", OUTPUT_SET, CAMPAIGN_OUTPUTS, errors, "goal.output"); + } + const budget = requireMapping(value, "budget", errors); + if (budget) { + rejectUnknownKeys(budget, BUDGET_KEYS, "budget", errors); + requireEnum(budget, "depth", DEPTH_SET, CAMPAIGN_DEPTHS, errors, "budget.depth"); + } + + const priorities = requireMapping(value, "priorities", errors); + if (priorities) { + rejectUnknownKeys(priorities, PRIORITIES_KEYS, "priorities", errors); + } + const vulnerabilityClasses = priorities + ? requireStringList( + priorities, + "vulnerability_classes", + errors, + "priorities.vulnerability_classes", + true, + ) + : []; + const seenClasses = new Set(); + vulnerabilityClasses.forEach((vulnerabilityClass, index) => { + const path = `priorities.vulnerability_classes[${index}]`; + if (!SAFE_CLASS.test(vulnerabilityClass)) { + errors.push(`${path} must be a normalized lowercase ASCII slug`); + } + if (seenClasses.has(vulnerabilityClass)) { + errors.push(`${path} must be unique`); + } + seenClasses.add(vulnerabilityClass); + }); + + const lanesValue = value.lanes; + const lanes = Array.isArray(lanesValue) ? lanesValue : []; + if (!Array.isArray(lanesValue)) { + errors.push("lanes must be a list"); + } else if (lanes.length !== vulnerabilityClasses.length) { + errors.push(`lanes must contain exactly one lane per priorities.vulnerability_classes entry`); + } + const seenFindingIds = new Set(); + lanes.forEach((lane, index) => { + const prefix = `lanes[${index}]`; + if (!isRecord(lane)) { + errors.push(`${prefix} must be a mapping`); + return; + } + rejectUnknownKeys(lane, LANE_KEYS, prefix, errors); + const laneId = requireText(lane, "id", errors, `${prefix}.id`); + const title = requireText(lane, "title", errors, `${prefix}.title`); + const vulnerabilityClass = requireText( + lane, + "vulnerability_class", + errors, + `${prefix}.vulnerability_class`, + ); + const findingId = requireText(lane, "finding_id", errors, `${prefix}.finding_id`); + const expectedClass = vulnerabilityClasses[index]; + if (expectedClass !== undefined) { + if (laneId && laneId !== expectedClass) { + errors.push(`${prefix}.id must equal priorities.vulnerability_classes[${index}] (${expectedClass})`); + } + if (title && title !== `Review ${expectedClass} hypotheses`) { + errors.push(`${prefix}.title must be Review ${expectedClass} hypotheses`); + } + if (vulnerabilityClass && vulnerabilityClass !== expectedClass) { + errors.push(`${prefix}.vulnerability_class must equal ${expectedClass}`); + } + if (id && findingId && findingId !== `${id}-${expectedClass}`) { + errors.push(`${prefix}.finding_id must equal ${id}-${expectedClass}`); + } + } + if (findingId) { + if (!SAFE_ID.test(findingId)) { + errors.push(`${prefix}.finding_id must be a safe filename id`); + } + if (seenFindingIds.has(findingId)) { + errors.push(`${prefix}.finding_id must be unique`); + } + seenFindingIds.add(findingId); + } + }); + + if (errors.length > 0) { + throw new Error(`${source}: Campaign.v1 validation failed:\n- ${errors.join("\n- ")}`); + } + return value as unknown as Campaign; +} + +function requireMapping( + value: Record, + key: string, + errors: string[], +): Record | undefined { + const nested = value[key]; + if (!isRecord(nested)) { + errors.push(`${key} must be a mapping`); + return undefined; + } + return nested; +} + +function requireText( + value: Record, + key: string, + errors: string[], + path = key, +): string { + const nested = value[key]; + if (typeof nested !== "string" || !nested.trim()) { + errors.push(`${path} is required and must be a non-empty string`); + return ""; + } + return nested; +} + +function requireCanonicalText( + value: Record, + key: string, + errors: string[], + path = key, +): string { + const text = requireText(value, key, errors, path); + if (text && text !== text.trim()) { + errors.push(`${path} must not contain surrounding whitespace`); + } + requireSingleLineText(text, path, errors); + return text; +} + +function requireSingleLineText(value: string, path: string, errors: string[]): void { + if (/[\u0000-\u001f\u007f]/.test(value)) { + errors.push(`${path} must be single-line text without control characters`); + } +} + +function requireExact( + value: Record, + key: string, + expected: string, + errors: string[], +): void { + if (value[key] !== expected) { + errors.push(`${key} must be ${expected}`); + } +} + +function requireEnum( + value: Record, + key: string, + allowed: Set, + allowedValues: readonly string[], + errors: string[], + path: string, +): void { + const nested = value[key]; + if (typeof nested !== "string" || !allowed.has(nested)) { + errors.push(`${path} must be one of: ${allowedValues.join(", ")}`); + } +} + +function requireStringList( + value: Record, + key: string, + errors: string[], + path: string, + requireNonEmpty: boolean, +): string[] { + const nested = value[key]; + if (!Array.isArray(nested)) { + errors.push(`${path} must be a list`); + return []; + } + if (requireNonEmpty && nested.length === 0) { + errors.push(`${path} must contain at least one value`); + } + return nested.map((item, index) => { + if (typeof item !== "string" || !item.trim()) { + errors.push(`${path}[${index}] must be a non-empty string`); + return ""; + } + return item; + }); +} + +function requireIsoTimestamp( + value: Record, + key: string, + errors: string[], +): string | undefined { + const timestamp = value[key]; + if (typeof timestamp !== "string" || !isRealIsoTimestamp(timestamp)) { + errors.push(`${key} must be an ISO 8601 timestamp`); + return undefined; + } + return timestamp; +} + +function isRealIsoTimestamp(timestamp: string): boolean { + const match = ISO_TIMESTAMP.exec(timestamp); + if (!match || match[0] !== timestamp) { + return false; + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + if ( + month < 1 + || month > 12 + || day < 1 + || day > daysInMonth(year, month) + || hour > 23 + || minute > 59 + || second > 59 + ) { + return false; + } + if (match[8] !== "Z") { + const offsetHour = Number(match[10]); + const offsetMinute = Number(match[11]); + if (offsetHour > 14 || offsetMinute > 59 || (offsetHour === 14 && offsetMinute !== 0)) { + return false; + } + } + return !Number.isNaN(Date.parse(timestamp)); +} + +function daysInMonth(year: number, month: number): number { + if (month === 2) { + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + return leapYear ? 29 : 28; + } + return month === 4 || month === 6 || month === 9 || month === 11 ? 30 : 31; +} + +function requireCanonicalUnknown(value: string, path: string, errors: string[]): void { + if (value.toLowerCase() === "unknown" && value !== "unknown") { + errors.push(`${path} must use canonical unknown`); + } +} + +function rejectUnknownKeys( + value: Record, + allowed: ReadonlySet, + prefix: string, + errors: string[], +): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + errors.push(`${prefix ? `${prefix}.` : ""}${key} is not allowed`); + } + } +} + +function requireAllowed( + value: string, + allowed: Set, + path: string, + allowedValues: readonly string[], +): void { + if (!allowed.has(value)) { + throw new Error(`${path} must be one of: ${allowedValues.join(", ")}`); + } +} + +function trimText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function optionalText(value: unknown): string { + const normalized = trimText(value); + return !normalized || normalized.toLowerCase() === "unknown" ? "unknown" : normalized; +} + +function asciiSlug(value: string): string { + return value + .normalize("NFKD") + .replace(/\p{M}+/gu, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function campaignIdFromSource(source: string): string | undefined { + const name = basename(source); + return /\.ya?ml$/i.test(name) ? name.replace(/\.ya?ml$/i, "") : undefined; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function campaignNextAction(campaign: Campaign): string { + return campaign.target.ecosystem === "unknown" + ? `Set target.ecosystem to a supported value before running omv campaign seed ${campaign.id}` + : `omv campaign seed ${campaign.id}`; +} + +function resolveCampaignSource(id: string, projectRoot: string): string | undefined { + const candidates = [ + campaignPath(id, projectRoot), + join(campaignsDir(projectRoot), `${id}.yml`), + ]; + const existing = candidates.filter((path) => existsSync(path)); + if (existing.length > 1) { + throw new Error(`Duplicate Campaign sources for ${id}: ${existing.join(", ")}; remove one source file`); + } + return existing[0]; +} + +async function ensureRealCampaignDirectory(projectRoot: string): Promise { + const dir = campaignsDir(projectRoot); + await mkdir(dir, { recursive: true }); + const entry = await lstat(dir); + if (!entry.isDirectory() || entry.isSymbolicLink()) { + throw new Error(`Campaign directory must be a real directory: ${dir}`); + } + return dir; +} + +interface TransactionBackup { + originalPath: string; + backupPath: string; +} + +interface TransactionLink { + destinationPath: string; + stagedPath: string; +} + +async function commitCampaignArtifacts( + campaign: Campaign, + projectRoot: string, + force: boolean, +): Promise { + const dir = campaignsDir(projectRoot); + const yamlPath = campaignPath(campaign.id, projectRoot); + const alternativeYamlPath = join(dir, `${campaign.id}.yml`); + const runbookPath = campaignRunbookPath(campaign.id, projectRoot); + const transactionDir = await mkdtemp(join(dir, `.${campaign.id}.transaction-`)); + const stagedYamlPath = join(transactionDir, "campaign.yaml"); + const stagedRunbookPath = join(transactionDir, "runbook.md"); + const backups: TransactionBackup[] = []; + const createdLinks: TransactionLink[] = []; + let cleanupTransaction = true; + + try { + await writeFile(stagedYamlPath, stringifyYaml(campaign), { encoding: "utf-8", flag: "wx" }); + await writeFile(stagedRunbookPath, renderCampaignRunbook(campaign), { encoding: "utf-8", flag: "wx" }); + + const destinations = [yamlPath, alternativeYamlPath, runbookPath]; + const entries = await Promise.all(destinations.map(async (path) => ({ path, entry: await lstatIfExists(path) }))); + const existing = entries.filter((item) => item.entry !== undefined); + for (const item of existing) { + if (!item.entry?.isFile() && !item.entry?.isSymbolicLink()) { + const kind = item.entry?.isDirectory() ? "directory" : "special entry"; + throw new Error(`Campaign artifact destination is a ${kind} and is not supported: ${item.path}`); + } + } + const existingPaths = existing.map((item) => item.path); + const overwritten = existingPaths.length > 0; + if (overwritten && !force) { + throw new Error(`Campaign artifact already exists: ${existingPaths.join(", ")}; pass --force to replace both artifacts`); + } + + if (force) { + for (const [index, item] of existing.entries()) { + const backupPath = join(transactionDir, `backup-${index}`); + await rename(item.path, backupPath); + backups.push({ originalPath: item.path, backupPath }); + } + } + + await link(stagedYamlPath, yamlPath); + createdLinks.push({ destinationPath: yamlPath, stagedPath: stagedYamlPath }); + await link(stagedRunbookPath, runbookPath); + createdLinks.push({ destinationPath: runbookPath, stagedPath: stagedRunbookPath }); + + return { + campaign, + yamlPath, + runbookPath, + overwritten, + nextAction: campaignNextAction(campaign), + warnings: [], + }; + } catch (error) { + try { + await rollbackCampaignTransaction(createdLinks, backups); + } catch (rollbackError) { + cleanupTransaction = false; + throw new Error( + `${errorMessage(error)}; rollback failed: ${errorMessage(rollbackError)}; recovery data remains at ${transactionDir}`, + { cause: error }, + ); + } + throw error; + } finally { + if (cleanupTransaction) { + await rm(transactionDir, { recursive: true, force: true }); + } + } +} + +async function rollbackCampaignTransaction( + createdLinks: readonly TransactionLink[], + backups: readonly TransactionBackup[], +): Promise { + for (const created of [...createdLinks].reverse()) { + const [destination, staged] = await Promise.all([ + lstatIfExists(created.destinationPath), + lstatIfExists(created.stagedPath), + ]); + if (destination && staged && destination.dev === staged.dev && destination.ino === staged.ino) { + await unlink(created.destinationPath); + } + } + for (const backup of [...backups].reverse()) { + if (await lstatIfExists(backup.originalPath)) { + throw new Error(`cannot restore ${backup.originalPath} because the destination is occupied`); + } + await rename(backup.backupPath, backup.originalPath); + } +} + +async function withCampaignLock( + id: string, + projectRoot: string, + operation: () => Promise, +): Promise { + const lockPath = join(campaignsDir(projectRoot), `${id}.lock`); + let handle; + try { + handle = await open(lockPath, "wx"); + } catch (error) { + if (errorCode(error) === "EEXIST") { + throw new Error(`Campaign ${id} is busy: lock already exists at ${lockPath}`); + } + throw error; + } + try { + return await operation(); + } finally { + await handle.close(); + await unlink(lockPath); + } +} + +function errorCode(error: unknown): string | undefined { + return isRecord(error) && typeof error.code === "string" ? error.code : undefined; +} + +async function lstatIfExists(path: string): Promise> | undefined> { + try { + return await lstat(path); + } catch (error) { + if (errorCode(error) === "ENOENT") { + return undefined; + } + throw error; + } +} + +function campaignTitle(targetName: string, version: string): string { + return `${targetName}${version === "unknown" ? "" : ` ${version}`} research campaign`; +} + +function escapeMarkdownText(value: string): string { + const specials = new Set(["`", "*", "_", "[", "]", "<", ">", "#", "|"]); + return Array.from(value, (character) => { + if (character === "\\") { + return "\\\\"; + } + return specials.has(character) ? `\\${character}` : character; + }).join(""); +} + +function splitVulnerabilityClasses(values: readonly string[] | undefined): string[] { + if (!Array.isArray(values)) { + return []; + } + return values.flatMap((value) => + typeof value === "string" + ? value.split(",").map((part) => part.trim()).filter(Boolean) + : [], + ); +} + +function missingRequiredFields(target: string, vulnerabilities: readonly string[]): string[] { + const missing: string[] = []; + if (!target) { + missing.push("target"); + } + if (vulnerabilities.length === 0) { + missing.push("vulnerability classes"); + } + return missing; +} diff --git a/src/cli/cli-options.ts b/src/cli/cli-options.ts deleted file mode 100644 index e30ca12..0000000 --- a/src/cli/cli-options.ts +++ /dev/null @@ -1,51 +0,0 @@ -import type { EvidenceStatus } from "./findings.js"; - -export function firstPositionalAfter(args: string[], subcommand: string): string | undefined { - const start = args.indexOf(subcommand) + 1; - for (let index = start; index < args.length; index += 1) { - const value = args[index]; - if (value.startsWith("--")) { - index += optionTakesValue(value) ? 1 : 0; - continue; - } - return value; - } - return undefined; -} - -export function parseStatus(args: string[]): EvidenceStatus | undefined { - const index = args.indexOf("--status"); - const raw = index === -1 ? undefined : args[index + 1]; - if (raw === "candidate" || raw === "confirmed" || raw === "blocked") { - return raw; - } - return undefined; -} - -export function parseReason(args: string[]): string | undefined { - const index = args.indexOf("--reason"); - const raw = index === -1 ? undefined : args[index + 1]; - return raw && !raw.startsWith("--") ? raw : undefined; -} - -export function parseOptionalScopeOrExit(args: string[]): "user" | "project" | undefined { - const index = args.indexOf("--scope"); - if (index === -1) { - return undefined; - } - return parseScopeOrExit(args, "user"); -} - -export function parseScopeOrExit(args: string[], defaultScope: "user" | "project"): "user" | "project" { - const index = args.indexOf("--scope"); - const raw = index === -1 ? defaultScope : args[index + 1]; - if (raw === "user" || raw === "project") { - return raw; - } - console.error(`Invalid --scope: ${raw ?? ""}. Valid values: user, project`); - process.exit(1); -} - -function optionTakesValue(option: string): boolean { - return option === "--scope" || option === "--status" || option === "--reason"; -} diff --git a/src/cli/commands/campaign.ts b/src/cli/commands/campaign.ts new file mode 100644 index 0000000..7bc9496 --- /dev/null +++ b/src/cli/commands/campaign.ts @@ -0,0 +1,99 @@ +import { + initCampaign, + listCampaigns, + resolveCampaignInput, + showCampaign, + type CampaignDepth, + type CampaignEcosystem, + type CampaignInput, + type CampaignLocalReproduction, + type CampaignMode, + type CampaignOutput, +} from "../campaign.js"; +import { ReadlineCampaignPrompt } from "../campaign-prompt.js"; +import { seedCampaign } from "../campaign-seed.js"; +import { + printCampaignDetail, + printCampaignInitResult, + printCampaignSeedResult, + printCampaignSummaries, +} from "../render.js"; +import { campaignUsage } from "../usage.js"; +import { firstPositionalAfter, parseOption, wantsJson } from "./shared.js"; + +type CampaignSubcommand = "init" | "list" | "show" | "seed" | "help"; + +export async function run(args: string[]): Promise { + const subcommand = campaignSubcommand(args); + const json = wantsJson(args); + + switch (subcommand) { + case "init": + await runInit(args, json); + return; + case "list": { + const result = await listCampaigns(); + if (json) console.log(JSON.stringify(result, null, 2)); + else printCampaignSummaries(result); + return; + } + case "show": { + const id = firstPositionalAfter(args, "show"); + if (!id) throw new Error("Campaign show requires an id"); + const result = await showCampaign(id); + if (json) console.log(JSON.stringify(result, null, 2)); + else printCampaignDetail(result); + return; + } + case "seed": { + const id = firstPositionalAfter(args, "seed"); + if (!id) throw new Error("Campaign seed requires an id"); + const result = await seedCampaign(id); + if (json) console.log(JSON.stringify(result, null, 2)); + else printCampaignSeedResult(result); + if (result.failed.length > 0) process.exit(1); + return; + } + case "help": + campaignUsage(undefined, args[0] === "first"); + return; + } +} + +async function runInit(args: string[], json: boolean): Promise { + const input: CampaignInput = { + id: parseOption(args, "--id"), + target: parseOption(args, "--target"), + version: parseOption(args, "--version"), + source: parseOption(args, "--source"), + ecosystem: parseOption(args, "--ecosystem") as CampaignEcosystem | undefined, + mode: parseOption(args, "--mode") as CampaignMode | undefined, + output: parseOption(args, "--goal") as CampaignOutput | undefined, + depth: parseOption(args, "--budget") as CampaignDepth | undefined, + localReproduction: parseOption(args, "--local-lab") as CampaignLocalReproduction | undefined, + vulnerabilities: parseOption(args, "--vuln") ? [parseOption(args, "--vuln") as string] : undefined, + }; + const interactive = !json + && !args.includes("--no-interactive") + && Boolean(process.stdin.isTTY && process.stdout.isTTY); + const prompt = interactive ? new ReadlineCampaignPrompt() : undefined; + try { + const resolved = await resolveCampaignInput(input, { interactive, prompt }); + const result = await initCampaign(resolved, { force: args.includes("--force") }); + if (json) console.log(JSON.stringify(result, null, 2)); + else printCampaignInitResult(result); + } finally { + prompt?.close(); + } +} + +function campaignSubcommand(args: string[]): CampaignSubcommand { + const firstAlias = args[0] === "first"; + const candidate = args[1]; + if (firstAlias && (candidate === undefined || candidate.startsWith("-"))) return "init"; + if (!firstAlias && (candidate === undefined || candidate.startsWith("-"))) return "list"; + if (candidate === "init" || candidate === "list" || candidate === "show" || candidate === "seed") { + return candidate; + } + return "help"; +} diff --git a/src/cli/commands/dashboard.ts b/src/cli/commands/dashboard.ts index 909e3b3..4b6f035 100644 --- a/src/cli/commands/dashboard.ts +++ b/src/cli/commands/dashboard.ts @@ -1,10 +1,7 @@ -import { - listFindingWorkflow, - type FindingWorkflowSummary, -} from "../findings.js"; -import { readWorkspaceActivity, workspaceStatus, type WorkspaceActivityEntry, type WorkspaceStatus } from "../workspace.js"; +import { listFindingWorkflow } from "../findings.js"; +import { printDashboard } from "../render.js"; +import { readWorkspaceActivity, workspaceStatus } from "../workspace.js"; import { wantsJson } from "./shared.js"; -import { command as cmd, empty, kv, panel, readiness, statusBadge, table, title, truncate, warn } from "../tui.js"; export async function run(args: string[]): Promise { const json = wantsJson(args); @@ -20,56 +17,3 @@ export async function run(args: string[]): Promise { } printDashboard(status, workflow, activity.slice(-8)); } - -function printDashboard( - status: WorkspaceStatus, - workflow: FindingWorkflowSummary[], - activity: WorkspaceActivityEntry[], -): void { - console.log(title("oh-my-vul dashboard")); - const statuses = Object.entries(status.statusCounts) - .map(([name, count]) => `${name}=${count}`) - .join(", "); - console.log( - panel("workspace", [ - ...kv([ - ["root", status.root], - ["active", String(status.activeCount)], - ["archived", String(status.archivedCount)], - ["statuses", statuses || "none"], - ["next", workflow[0] ? cmd(workflow[0].nextAction) : cmd("omv findings init ")], - ]), - ...status.warnings.map((item) => warn(`warning ${item}`)), - ]), - ); - - if (workflow.length === 0) { - console.log(empty("No active findings. Start with omv findings init or /omv-find.")); - } else { - console.log( - table( - ["id", "status", "evidence", "submission", "next action"], - workflow.slice(0, 8).map((finding) => [ - truncate(finding.id, 30), - statusBadge(finding.status), - readiness(finding.evidenceScore), - readiness(finding.submissionScore), - cmd(truncate(finding.nextAction, 54)), - ]), - ), - ); - } - - if (activity.length > 0) { - console.log( - table( - ["time", "action", "id"], - activity.map((entry) => [ - truncate(entry.timestamp, 27), - entry.action, - truncate(entry.id ?? "-", 28), - ]), - ), - ); - } -} diff --git a/src/cli/commands/disclose.ts b/src/cli/commands/disclose.ts index ec2f530..e6df69b 100644 --- a/src/cli/commands/disclose.ts +++ b/src/cli/commands/disclose.ts @@ -7,7 +7,7 @@ export async function run(args: string[]): Promise { const json = wantsJson(args); if (subcommand !== "timeline") { console.error(`Unknown disclose command: ${subcommand}\n`); - commandUsage(args, args[0], "disclose", args[1]); + commandUsage("disclose", args[1]); process.exit(1); } const id = firstPositionalAfter(args, "timeline"); diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 8607a98..7a1842c 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -1,6 +1,6 @@ -import { doctor, type Check, type DoctorResult } from "../doctor.js"; +import { doctor } from "../doctor.js"; +import { printDoctorResult } from "../render.js"; import { resolveOptionalScope, wantsJson } from "./shared.js"; -import { command as cmd, kv, outcomeBadge, panel, section, statusIcon, table, title, truncate, warn } from "../tui.js"; export async function run(args: string[]): Promise { const json = wantsJson(args); @@ -22,56 +22,3 @@ export async function run(args: string[]): Promise { process.exit(1); } } - -function printDoctorResult(result: DoctorResult, strict: boolean): void { - const passed = result.checks.filter((item) => item.status === "pass").length; - const warned = result.checks.filter((item) => item.status === "warn").length; - const failed = result.checks.filter((item) => item.status === "fail").length; - const finalState = failed > 0 ? "fail" : strict && warned > 0 ? "fail" : warned > 0 ? "warn" : "pass"; - const next = failed > 0 - ? `omv setup --scope ${result.scope} --force` - : warned > 0 - ? `omv doctor --scope ${result.scope} --strict` - : "omv dashboard"; - - console.log(title("oh-my-vul doctor")); - console.log( - panel("health summary", [ - ...kv([ - ["scope", result.scope], - ["skills", result.skillsDir], - ["status", outcomeBadge(finalState)], - ["checks", `${passed} pass, ${warned} warn, ${failed} fail`], - ["next", cmd(next)], - ]), - ]), - ); - - console.log(section("Checks")); - console.log( - table( - ["", "check", "state", "detail"], - result.checks.map((check) => [ - statusIcon(check.status), - truncate(check.name, 30), - outcomeBadge(check.status), - truncate(check.message, 76), - ]), - ), - ); - - const warnings = result.checks.filter((item) => item.status === "warn"); - if (warnings.length > 0) { - console.log(panel("warnings", warnings.map(formatCheckDetail))); - } - const failures = result.checks.filter((item) => item.status === "fail"); - if (failures.length > 0) { - console.log(panel("failures", failures.map(formatCheckDetail))); - } else if (strict && warnings.length > 0) { - console.log(panel("strict mode", [warn("warnings are treated as failures in --strict mode")])); - } -} - -function formatCheckDetail(check: Check): string { - return `${statusIcon(check.status)} ${check.name}: ${check.message}`; -} diff --git a/src/cli/commands/eval.ts b/src/cli/commands/eval.ts new file mode 100644 index 0000000..b30d9b7 --- /dev/null +++ b/src/cli/commands/eval.ts @@ -0,0 +1,30 @@ +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { packageRoot } from "../paths.js"; +import { parseOption } from "./shared.js"; + +export async function run(args: string[]): Promise { + const python = process.env.OMV_PYTHON || "python3"; + const runner = join(packageRoot(), "shared", "scripts", "run_evals.py"); + const format = args.includes("--junit") ? "junit" : args.includes("--json") ? "json" : "human"; + const runnerArgs = [runner, "--format", format]; + const skill = parseOption(args, "--skill"); + if (skill) { + runnerArgs.push( + "--skill", skill, + "--eval-id", parseOption(args, "--eval-id") as string, + "--output", parseOption(args, "--output") as string, + ); + } + + const result = spawnSync(python, runnerArgs, { + cwd: process.cwd(), + encoding: "utf-8", + }); + if (result.error) { + throw new Error(`Unable to start Python runtime "${python}": ${result.error.message}`); + } + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.status !== 0) process.exit(result.status ?? 1); +} diff --git a/src/cli/commands/findings.ts b/src/cli/commands/findings.ts index ca183d6..220a029 100644 --- a/src/cli/commands/findings.ts +++ b/src/cli/commands/findings.ts @@ -11,16 +11,6 @@ import { deleteFinding, showFinding, doctorFinding, - type FindingTemplateResult, - type FindingSummary, - type FindingWorkflowSummary, - type FindingDetail, - type FindingValidation, - type FindingDoctorResult, - type ArchivedFindingSummary, - type FindingArchiveResult, - type FindingRestoreResult, - type FindingDeleteResult, } from "../findings.js"; import { printArchivedSummaries, @@ -36,24 +26,7 @@ import { } from "../render.js"; import { usage } from "../usage.js"; import { firstPositionalAfter, parseStatus, parseReason, wantsJson } from "./shared.js"; -import { - command as cmd, - empty, - error as tuiError, - kv, - muted, - outcomeBadge, - panel, - readiness, - section, - statusBadge, - statusIcon, - table, - title, - truncate, - validationBadge, - warn, -} from "../tui.js"; +import { command as cmd, kv, panel } from "../tui.js"; export async function run(args: string[]): Promise { const subcommand = args[1] ?? "list"; diff --git a/src/cli/commands/index.ts b/src/cli/commands/index.ts index 1a83124..aa60dbf 100644 --- a/src/cli/commands/index.ts +++ b/src/cli/commands/index.ts @@ -6,7 +6,9 @@ import * as version from "./version.js"; import * as setup from "./setup.js"; import * as doctor from "./doctor.js"; import * as dashboard from "./dashboard.js"; +import * as evalCommand from "./eval.js"; import * as review from "./review.js"; +import * as campaign from "./campaign.js"; import * as workspace from "./workspace.js"; import * as findings from "./findings.js"; import * as radar from "./radar.js"; @@ -17,6 +19,7 @@ import * as submissions from "./submissions.js"; import * as config from "./config.js"; import * as repro from "./repro.js"; import * as report from "./report.js"; +import * as sources from "./sources.js"; import * as threatMap from "./threat-map.js"; import * as verification from "./verification.js"; @@ -26,6 +29,9 @@ const REGISTRY: Record Promise> = { uninstall: setup.runUninstall, doctor: doctor.run, dashboard: dashboard.run, + eval: evalCommand.run, + campaign: campaign.run, + first: campaign.run, review: review.run, workspace: workspace.run, findings: findings.run, @@ -37,6 +43,7 @@ const REGISTRY: Record Promise> = { config: config.run, repro: repro.run, report: report.run, + sources: sources.run, "threat-map": threatMap.run, verification: verification.run, }; @@ -55,7 +62,7 @@ export async function run(): Promise { if (wantsHelp(args)) { const topic = command === "help" ? args[1] : command; const subcommand = command === "help" ? args[2] : args[1]; - commandUsage(args, command, topic, subcommand); + commandUsage(topic, subcommand); process.exit(0); } diff --git a/src/cli/commands/report.ts b/src/cli/commands/report.ts index aa0ece2..ad23bec 100644 --- a/src/cli/commands/report.ts +++ b/src/cli/commands/report.ts @@ -1,21 +1,29 @@ -import { checkReportArtifacts, type ReportArtifactsResult } from "../findings.js"; +import { checkReportArtifacts } from "../findings.js"; +import { createReportProvenance } from "../report-provenance.js"; +import { printReportArtifacts, printReportProvenanceResult } from "../render.js"; import { firstPositionalAfter, wantsJson } from "./shared.js"; -import { command as cmd, kv, muted, outcomeBadge, panel, section, statusIcon, table, title } from "../tui.js"; export async function run(args: string[]): Promise { const subcommand = args[1] ?? "artifacts"; - if (subcommand !== "artifacts") { + if (subcommand !== "artifacts" && subcommand !== "provenance") { console.error(`Unknown report command: ${subcommand}\n`); - console.error("Valid commands: artifacts, help"); + console.error("Valid commands: artifacts, provenance, help"); process.exit(1); } - const id = firstPositionalAfter(args, "artifacts"); + const id = firstPositionalAfter(args, subcommand); const json = wantsJson(args); if (!id) { console.error("Missing finding id."); process.exit(1); } + if (subcommand === "provenance") { + const result = await createReportProvenance(id, process.cwd(), { force: args.includes("--force") }); + if (json) console.log(JSON.stringify(result, null, 2)); + else printReportProvenanceResult(result); + return; + } + const result = await checkReportArtifacts(id); const ok = result.errors.length === 0; if (json) { @@ -30,43 +38,3 @@ export async function run(args: string[]): Promise { process.exit(1); } } - -function printReportArtifacts(result: ReportArtifactsResult): void { - console.log(title(`report artifacts ${result.id}`)); - console.log( - panel("report artifacts", [ - ...kv([ - ["status", outcomeBadge(result.errors.length > 0 ? "fail" : result.warnings.length > 0 ? "warn" : "pass")], - ["reports dir", result.reportsDir], - ["repro dir", result.reproDir], - ["declared", String(result.reportArtifactPaths.length)], - ["empty", String(result.emptyReportArtifactPaths.length)], - ["missing", String(result.missingReproArtifacts.length)], - ["next", cmd(`omv findings doctor ${result.id}`)], - ]), - ]), - ); - - console.log(section("Artifacts")); - const rows = result.reportArtifactPaths.map((path) => { - const empty = result.emptyReportArtifactPaths.includes(path); - return [empty ? statusIcon("warn") : statusIcon("pass"), empty ? "empty" : "present", path]; - }); - if (rows.length === 0) { - console.log(muted("No report artifacts declared.")); - } else { - console.log(table(["", "state", "path"], rows)); - } - - const printList = (heading: string, items: string[]) => { - if (items.length > 0) { - console.log(section(heading)); - for (const item of items) { - console.log(` ${item}`); - } - } - }; - printList("Missing", result.missingReproArtifacts); - printList("Errors", result.errors); - printList("Warnings", result.warnings); -} diff --git a/src/cli/commands/repro.ts b/src/cli/commands/repro.ts index c6da8d4..2c4dc75 100644 --- a/src/cli/commands/repro.ts +++ b/src/cli/commands/repro.ts @@ -1,6 +1,6 @@ -import { initReproArtifacts, type ReproInitResult } from "../findings.js"; +import { initReproArtifacts } from "../findings.js"; +import { printReproInitResult } from "../render.js"; import { firstPositionalAfter, wantsJson } from "./shared.js"; -import { command as cmd, kv, muted, panel } from "../tui.js"; export async function run(args: string[]): Promise { const id = firstPositionalAfter(args, "init"); @@ -15,22 +15,5 @@ export async function run(args: string[]): Promise { console.log(JSON.stringify(result, null, 2)); return; } - printReproInit(result); -} - -function printReproInit(result: ReproInitResult): void { - console.log( - panel("repro scaffold", [ - ...kv([ - ["id", result.id], - ["dir", result.path], - ["finding", result.findingPath], - ["written", String(result.written.length)], - ["skipped", String(result.skipped.length)], - ["finding updated", result.updatedFinding ? "yes" : "no"], - ["next", cmd(`omv findings validate ${result.id}`)], - ]), - ...(result.skipped.length > 0 ? ["", muted("skipped (already non-empty)"), ...result.skipped.map((p) => ` ${p}`)] : []), - ]), - ); + printReproInitResult(result); } diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 5c88e89..3574b0b 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -1,6 +1,6 @@ -import { setup, uninstall, type SetupResult, type UninstallResult } from "../setup.js"; +import { setup, uninstall } from "../setup.js"; +import { printSetupResult, printUninstallResult } from "../render.js"; import { resolveScope, wantsJson } from "./shared.js"; -import { command as cmd, kv, outcomeBadge, panel, statusIcon, table, title } from "../tui.js"; export async function run(args: string[]): Promise { const force = args.includes("--force"); @@ -49,75 +49,3 @@ export async function runUninstall(args: string[]): Promise { process.exit(1); } } - -function printSetupResult(result: SetupResult): void { - const total = result.installed.length + result.skipped.length + result.errors.length; - const summary = result.errors.length > 0 - ? `${result.errors.length} error(s), ${result.installed.length}/${total} skill(s) installed` - : result.installed.length === 0 - ? `${result.skipped.length}/${total} skill(s) already installed` - : `${result.installed.length}/${total} skill(s) installed`; - const next = result.errors.length > 0 - ? `omv setup --scope ${result.scope} --force` - : "omv doctor"; - const finalState = result.errors.length > 0 ? "error" : result.installed.length === 0 ? "skipped" : "installed"; - - console.log(title("oh-my-vul setup")); - console.log( - panel("install summary", [ - ...kv([ - ["scope", result.scope], - ["destination", result.destination], - ["result", outcomeBadge(finalState)], - ["skills", summary], - ["next", cmd(next)], - ]), - ]), - ); - - const rows = [ - ...result.installed.map((name) => [statusIcon("installed"), name, outcomeBadge("installed"), "copied into skills directory"]), - ...result.installedAgents.map((name) => [statusIcon("installed"), `agent:${name}`, outcomeBadge("installed"), "copied into agents directory"]), - ...result.skipped.map((name) => [statusIcon("skipped"), name, outcomeBadge("skipped"), "already installed; use --force to overwrite"]), - ...result.errors.map((message) => [statusIcon("error"), "-", outcomeBadge("error"), message]), - ]; - if (rows.length > 0) { - console.log(table(["", "skill", "state", "detail"], rows)); - } -} - -function printUninstallResult(result: UninstallResult): void { - const total = result.removed.length + result.notFound.length + result.errors.length; - const summary = result.errors.length > 0 - ? `${result.errors.length} error(s), ${result.removed.length}/${total} removed` - : `${result.removed.length}/${Math.max(result.removed.length, result.notFound.length)} skill(s) removed`; - const next = result.errors.length > 0 - ? "omv doctor" - : "omv setup"; - const finalState = result.errors.length > 0 ? "error" : result.removed.length === 0 && result.notFound.length === 0 ? "skipped" : "pass"; - - console.log(title("oh-my-vul uninstall")); - console.log( - panel("uninstall summary", [ - ...kv([ - ["scope", result.scope], - ["skills dir", result.skillsDir], - ["result", outcomeBadge(finalState)], - ["skills", summary], - ["manifest", result.manifestRemoved ? "removed" : "not found"], - ...(result.scope === "project" ? [["setup scope", result.setupScopeRemoved ? "removed" : "not found"]] as [string, string][] : []), - ["next", cmd(next)], - ]), - ]), - ); - - const rows = [ - ...result.removed.map((name) => [statusIcon("installed"), name, outcomeBadge("pass"), "removed from skills directory"]), - ...result.notFound.map((name) => [statusIcon("skipped"), name, outcomeBadge("skipped"), "not found in skills directory"]), - ...result.errors.map((message) => [statusIcon("error"), "-", outcomeBadge("error"), message]), - ]; - if (rows.length > 0) { - console.log(table(["", "skill", "state", "detail"], rows)); - } -} - diff --git a/src/cli/commands/shared.ts b/src/cli/commands/shared.ts index b7ba39a..5bd5ae1 100644 --- a/src/cli/commands/shared.ts +++ b/src/cli/commands/shared.ts @@ -16,6 +16,19 @@ const VALUE_FLAGS = new Set([ "--url", "--cve", "--accept", + "--target", + "--version", + "--source", + "--ecosystem", + "--mode", + "--goal", + "--budget", + "--vuln", + "--local-lab", + "--id", + "--skill", + "--eval-id", + "--output", ]); export function wantsHelp(args: string[]): boolean { diff --git a/src/cli/commands/sources.ts b/src/cli/commands/sources.ts new file mode 100644 index 0000000..684d10c --- /dev/null +++ b/src/cli/commands/sources.ts @@ -0,0 +1,26 @@ +import { initSourceRef, showSourceRef, validateSourceRef } from "../source-ref.js"; +import { printSourceRefDetail, printSourceRefInitResult } from "../render.js"; +import { firstPositionalAfter, wantsJson } from "./shared.js"; + +export async function run(args: string[]): Promise { + const subcommand = args[1]; + if (subcommand !== "init" && subcommand !== "show" && subcommand !== "validate") { + throw new Error(`Unknown sources command: ${subcommand ?? ""}`); + } + const id = firstPositionalAfter(args, subcommand); + if (!id) throw new Error(`sources ${subcommand} requires an id`); + const json = wantsJson(args); + + if (subcommand === "init") { + const result = await initSourceRef(id, process.cwd(), { force: args.includes("--force") }); + if (json) console.log(JSON.stringify(result, null, 2)); + else printSourceRefInitResult(result); + return; + } + + const result = subcommand === "show" + ? await showSourceRef(id) + : await validateSourceRef(id); + if (json) console.log(JSON.stringify(result, null, 2)); + else printSourceRefDetail(result); +} diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index 9acef34..3def1d2 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -2,12 +2,11 @@ import { initWorkspace, readWorkspaceActivity, workspaceStatus, - type WorkspaceActivityEntry, type WorkspaceStatus, } from "../workspace.js"; +import { printWorkspaceActivity, printWorkspaceStatus } from "../render.js"; import { workspaceUsage } from "../usage.js"; import { wantsJson } from "./shared.js"; -import { empty, kv, muted, panel, statusIcon, table, title, truncate, warn } from "../tui.js"; export async function run(args: string[]): Promise { const subcommand = args[1] ?? "status"; @@ -51,43 +50,3 @@ async function runWorkspaceLog(json: boolean): Promise { } printWorkspaceActivity(entries); } - -function printWorkspaceStatus(result: WorkspaceStatus): void { - const statuses = Object.entries(result.statusCounts) - .map(([status, count]) => `${status}=${count}`) - .join(", "); - console.log(title("oh-my-vul workspace")); - console.log( - panel("workspace", [ - ...kv([ - ["root", result.root], - ["findings", result.findingsDir], - ["archive", result.archiveDir], - ["active", String(result.activeCount)], - ["archived", String(result.archivedCount)], - ["statuses", statuses || "none"], - ["index", result.staleIndex ? "rebuilt from stale cache" : result.indexPath], - ]), - ...result.warnings.map((item) => warn(`warning ${item}`)), - ]), - ); -} - -function printWorkspaceActivity(entries: WorkspaceActivityEntry[]): void { - if (entries.length === 0) { - console.log(empty("No workspace activity yet.")); - return; - } - console.log(title("activity log")); - console.log( - table( - ["time", "action", "id", "detail"], - entries.map((entry) => [ - truncate(entry.timestamp, 27), - entry.action, - truncate(entry.id ?? "-", 26), - entry.reason ? `reason=${entry.reason}` : entry.status ? `status=${entry.status}` : entry.path ?? "", - ]), - ), - ); -} diff --git a/src/cli/findings.ts b/src/cli/findings.ts index e1247cc..c6efcd3 100644 --- a/src/cli/findings.ts +++ b/src/cli/findings.ts @@ -1,11 +1,12 @@ import { existsSync } from "fs"; import { mkdir, readFile, readdir, rename, stat, writeFile } from "fs/promises"; import { basename, isAbsolute, join, relative } from "path"; -import { parse as parseYaml, parseDocument } from "yaml"; -import { archivedFindingsDir, findingReportsDir, findingReproDir, findingsDir, packageRoot, threatMapPath, verificationPath } from "./paths.js"; +import { parse as parseYaml, parseDocument, stringify as stringifyYaml } from "yaml"; +import { archivedFindingsDir, findingReportsDir, findingReproDir, findingsDir, packageRoot, reportProvenancePath, threatMapPath, verificationPath } from "./paths.js"; import { readSubmissions, type SubmissionRecord } from "./submissions.js"; import { readThreatMap, validateThreatMap, writeThreatMap, type FindingThreatMap, type ThreatMapValidation, type ThreatMapWriteResult } from "./threatmap.js"; import { validateVerification, verificationPasses, type VerificationValidation } from "./verification.js"; +import { validateReportProvenance, type ReportProvenanceValidation } from "./report-provenance.js"; import { appendWorkspaceActivity, ensureWorkspaceDirs, @@ -21,6 +22,9 @@ import { classifyWarning, dedupeIssues, extractFieldRefs, + isReportReady, + resolveDoctorNextAction, + SUBMISSION_READY_THRESHOLD, warningNextAction, workflowBlockers, workflowMissingFields, @@ -29,7 +33,34 @@ import { workflowPriorityReason, } from "./workflow.js"; +export { SUBMISSION_READY_THRESHOLD, isReportReady } from "./workflow.js"; + export type EvidenceStatus = "candidate" | "confirmed" | "blocked"; +export const EVIDENCE_ECOSYSTEMS = [ + "npm", + "python", + "go", + "rust", + "java", + "ruby", + "php", + "csharp", + "swift", + "dart", + "elixir", + "perl", + "r", + "lua", +] as const; +export type EvidenceEcosystem = (typeof EVIDENCE_ECOSYSTEMS)[number]; +export type EvidenceResearcherGoal = "VulDB" | "CVE" | "advisory" | "triage"; + +export interface FindingTemplateSeed { + researcherGoal: EvidenceResearcherGoal; + product: string; + ecosystem: EvidenceEcosystem; + vulnerabilityClass: string; +} export interface FindingSummary { id: string; @@ -131,6 +162,10 @@ export interface ReportArtifactsResult { listedReproArtifacts: string[]; existingReproArtifacts: string[]; missingReproArtifacts: string[]; + provenanceManifestPath?: string; + provenanceManifestExists?: boolean; + provenanceFresh?: boolean | null; + provenance?: ReportProvenanceValidation; errors: string[]; warnings: string[]; } @@ -171,28 +206,14 @@ export interface CreateFindingTemplateOptions { status?: EvidenceStatus; force?: boolean; projectRoot?: string; + seed?: FindingTemplateSeed; } export { writeThreatMap, type ThreatMapWriteResult }; const VALID_STATUSES = new Set(["candidate", "confirmed", "blocked"]); const FINDING_EXTENSIONS = new Set([".yaml", ".yml"]); -const VALID_ECOSYSTEMS = new Set([ - "npm", - "python", - "go", - "rust", - "java", - "ruby", - "php", - "csharp", - "swift", - "dart", - "elixir", - "perl", - "r", - "lua", -]); +const VALID_ECOSYSTEMS = new Set(EVIDENCE_ECOSYSTEMS); const VALID_SEVERITIES = new Set(["Critical", "High", "Medium", "Low", "None", "unknown"]); const VALID_ATTACK_VECTORS = new Set(["Network", "Local", "Physical", "Adjacent", "unknown"]); const VALID_TRI_STATE = new Set(["true", "false", "unknown"]); @@ -236,7 +257,6 @@ const UNKNOWN_ACCOUNTING_FIELDS = [ "verdict.confidence", "verdict.reason", ]; -const SUBMISSION_READY_THRESHOLD = 75; const SUBMISSION_DEDUCTIONS = { missingObservedResult: 25, unresolvedBlockers: 30, @@ -506,7 +526,7 @@ export async function doctorFinding( ? await validateVerification(id, projectRoot, { requireExisting: strictVerification }) : undefined; const missingFields = workflowMissingFields(validation); - const nextAction = workflowNextAction(summary, validation, missingFields); + const fallbackNextAction = workflowNextAction(summary, validation, missingFields); const issues: FindingDoctorIssue[] = []; issues.push(...validation.errors.map((message) => ({ @@ -523,7 +543,7 @@ export async function doctorFinding( fields: extractFieldRefs(message), nextAction: warningNextAction(message, id), }))); - issues.push(...submissionDeductions(parsed, validation.status, projectRoot).map((deduction) => ({ + issues.push(...submissionDeductions(parsed, projectRoot).map((deduction) => ({ id: deduction.id, severity: deduction.points >= 25 ? "error" as const : "warning" as const, message: `${deduction.message} (-${deduction.points})`, @@ -590,14 +610,18 @@ export async function doctorFinding( const dedupedIssues = dedupeIssues(issues); const threatMapReady = !threatMap || threatMap.ok; const verificationReady = !strictVerification || verificationPasses(verification); - const reportReady = validation.ok && threatMapReady && verificationReady && validation.status === "confirmed" && validation.submissionScore >= SUBMISSION_READY_THRESHOLD; - const doctorNextAction = reportReady - ? `/omv-report ${id}` - : strictVerification && !verificationReady - ? existsSync(verificationPath(id, projectRoot)) - ? `omv verification validate ${id}` - : `omv verification init ${id}` - : nextAction; + const reportReady = isReportReady({ + status: validation.status, + validationOk: validation.ok, + submissionScore: validation.submissionScore, + threatMapOk: threatMapReady, + verificationOk: verificationReady, + }); + const doctorNextAction = resolveDoctorNextAction(id, reportReady, fallbackNextAction, { + strictVerification, + verificationReady, + verificationExists: existsSync(verificationPath(id, projectRoot)), + }); return { id, path, @@ -652,6 +676,29 @@ export async function checkReportArtifacts(id: string, projectRoot = process.cwd addMissing(`Evidence.v1 references a missing reproduction artifact: ${path}`); } + const provenanceManifestPath = reportProvenancePath(normalizedId, projectRoot); + const provenanceManifestExists = existsSync(provenanceManifestPath); + let provenance: ReportProvenanceValidation | undefined; + let provenanceFresh: boolean | null = null; + if (!provenanceManifestExists) { + warnings.push(`report provenance manifest is missing: ${provenanceManifestPath}`); + } else { + try { + provenance = await validateReportProvenance(normalizedId, projectRoot); + provenanceFresh = provenance.fresh; + if (!provenance.fresh) { + addMissing( + `report provenance is stale: ${[ + ...provenance.staleInputs.map((path) => `changed ${path}`), + ...provenance.missingInputs.map((path) => `missing ${path}`), + ].join(", ")}`, + ); + } + } catch (error) { + addMissing(`report provenance is invalid: ${error instanceof Error ? error.message : String(error)}`); + } + } + return { id: normalizedId, status, @@ -662,6 +709,10 @@ export async function checkReportArtifacts(id: string, projectRoot = process.cwd listedReproArtifacts, existingReproArtifacts, missingReproArtifacts, + provenanceManifestPath, + provenanceManifestExists, + provenanceFresh, + provenance, errors, warnings, }; @@ -675,6 +726,12 @@ export async function createFindingTemplate( if (!VALID_STATUSES.has(status)) { throw new Error("status must be candidate, confirmed, or blocked"); } + if (options.seed && status !== "candidate") { + throw new Error("seeded findings must use candidate status"); + } + if (options.seed && options.force) { + throw new Error("seeded findings never overwrite existing files"); + } const normalizedId = normalizeFindingId(id); const projectRoot = options.projectRoot ?? process.cwd(); @@ -685,8 +742,17 @@ export async function createFindingTemplate( throw new Error(`${path} already exists; use --force to overwrite`); } - const template = await readFindingTemplate(status); - await writeFile(path, template, "utf-8"); + const template = await readFindingTemplate(status, options.seed); + try { + await writeFile(path, template, options.force + ? { encoding: "utf-8" } + : { encoding: "utf-8", flag: "wx" }); + } catch (error) { + if (!options.force && isNodeError(error, "EEXIST")) { + throw new Error(`${path} already exists; use --force to overwrite`); + } + throw error; + } await touchWorkspaceFinding(normalizedId, status, projectRoot); await appendWorkspaceActivity({ action: "finding.init", id: normalizedId, status, path }, projectRoot); return { id: normalizedId, path, status, created: true }; @@ -892,10 +958,45 @@ export async function ensureFindingsDir(projectRoot = process.cwd()): Promise { +async function readFindingTemplate( + status: EvidenceStatus, + seed?: FindingTemplateSeed, +): Promise { const templatePath = join(packageRoot(), "contracts", "evidence.v1.yaml"); const text = await readFile(templatePath, "utf-8"); - return text.replace(/^status:\s*.*$/m, `status: ${status} # candidate | confirmed | blocked`); + const statusTemplate = text.replace(/^status:\s*.*$/m, `status: ${status} # candidate | confirmed | blocked`); + if (!seed) { + return statusTemplate; + } + + const parsed = parseYaml(statusTemplate); + if (!isRecord(parsed)) { + throw new Error("canonical Evidence.v1 template must be a mapping"); + } + const packageData = parsed.package; + const versions = parsed.versions; + const vulnerability = parsed.vulnerability; + const provenance = parsed.provenance; + if (!isRecord(packageData) || !isRecord(versions) || !isRecord(vulnerability) || !isRecord(provenance)) { + throw new Error("canonical Evidence.v1 template is missing required mappings"); + } + parsed.status = "candidate"; + parsed.researcher_goal = seed.researcherGoal; + packageData.ecosystem = seed.ecosystem; + packageData.registry_name = ""; + packageData.repository_url = ""; + packageData.vendor = ""; + packageData.product = seed.product; + versions.tested = "unknown"; + vulnerability.class = seed.vulnerabilityClass; + provenance.unverified_fields = UNKNOWN_ACCOUNTING_FIELDS.filter( + (path) => getString(parsed, path) === "unknown", + ); + return stringifyYaml(parsed, { lineWidth: 0 }); +} + +function isNodeError(error: unknown, code: string): boolean { + return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code; } function parseEvidenceYaml(text: string): { data: Record; errors: string[] } { @@ -939,7 +1040,7 @@ function computeEvidenceScore(data: Record): number { function computeSubmissionScore(data: Record, status: string, projectRoot: string): number { let score = computeEvidenceScore(data); - for (const deduction of submissionDeductions(data, status, projectRoot)) { + for (const deduction of submissionDeductions(data, projectRoot)) { score -= deduction.points; } score -= cvssConfidencePenalty(data); @@ -948,7 +1049,7 @@ function computeSubmissionScore(data: Record, status: string, p return Math.max(0, Math.min(100, score)); } -function submissionDeductions(data: Record, status: string, projectRoot: string): SubmissionDeduction[] { +function submissionDeductions(data: Record, projectRoot: string): SubmissionDeduction[] { const deductions: SubmissionDeduction[] = []; if (!isKnown(getString(data, "evidence.observed_result"))) { deductions.push({ @@ -1065,7 +1166,7 @@ async function collectReportArtifacts(dir: string, paths: string[]): Promise 0) { + if (dirent.isFile() && dirent.name !== "provenance.json" && (await stat(path)).size > 0) { paths.push(path); } } @@ -1087,7 +1188,7 @@ async function collectEmptyReportArtifacts(dir: string, paths: string[]): Promis await collectEmptyReportArtifacts(path, paths); continue; } - if (dirent.isFile() && (await stat(path)).size === 0) { + if (dirent.isFile() && dirent.name !== "provenance.json" && (await stat(path)).size === 0) { paths.push(path); } } @@ -1335,11 +1436,6 @@ function getValue(data: Record, path: string): unknown { return current; } -function getRecordString(data: Record, key: string): string { - const value = data[key]; - return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value); -} - function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } diff --git a/src/cli/paths.ts b/src/cli/paths.ts index f7e3882..698997b 100644 --- a/src/cli/paths.ts +++ b/src/cli/paths.ts @@ -29,6 +29,31 @@ export function omvStateDir(projectRoot = process.cwd()): string { return join(projectRoot, ".omv"); } +/** .omv/campaigns/ — project-scoped Campaign.v1 artifacts. */ +export function campaignsDir(projectRoot = process.cwd()): string { + return join(omvStateDir(projectRoot), "campaigns"); +} + +/** .omv/campaigns/.yaml — Campaign.v1 source of truth. */ +export function campaignPath(id: string, projectRoot = process.cwd()): string { + return join(campaignsDir(projectRoot), `${id}.yaml`); +} + +/** .omv/campaigns/.md — deterministic Campaign.v1 runbook. */ +export function campaignRunbookPath(id: string, projectRoot = process.cwd()): string { + return join(campaignsDir(projectRoot), `${id}.md`); +} + +/** .omv/sources/ — optional SourceRef.v1 sidecars keyed by finding id. */ +export function sourcesDir(projectRoot = process.cwd()): string { + return join(omvStateDir(projectRoot), "sources"); +} + +/** .omv/sources/.yaml — SourceRef.v1 sidecar for one finding. */ +export function sourceRefPath(id: string, projectRoot = process.cwd()): string { + return join(sourcesDir(projectRoot), `${id}.yaml`); +} + /** .omv/findings/ — project-scoped Evidence.v1 finding ledger. */ export function findingsDir(projectRoot = process.cwd()): string { return join(omvStateDir(projectRoot), "findings"); @@ -74,6 +99,11 @@ export function findingReportsDir(id: string, projectRoot = process.cwd()): stri return join(reportsDir(projectRoot), id); } +/** .omv/reports//provenance.json — generated report input hashes. */ +export function reportProvenancePath(id: string, projectRoot = process.cwd()): string { + return join(findingReportsDir(id, projectRoot), "provenance.json"); +} + /** .omv/repro/ — project-scoped local reproduction artifacts. */ export function reproDir(projectRoot = process.cwd()): string { return join(omvStateDir(projectRoot), "repro"); diff --git a/src/cli/render.ts b/src/cli/render.ts index a68985b..3d688a7 100644 --- a/src/cli/render.ts +++ b/src/cli/render.ts @@ -1,4 +1,10 @@ import type { Check, DoctorResult } from "./doctor.js"; +import type { + CampaignSummary, + InitCampaignResult, + ShowCampaignResult, +} from "./campaign.js"; +import type { CampaignSeedResult } from "./campaign-seed.js"; import type { ArchivedFindingSummary, FindingArchiveResult, @@ -14,7 +20,9 @@ import type { ReproInitResult, } from "./findings.js"; import type { FindingReview } from "./review.js"; -import type { SetupResult } from "./setup.js"; +import type { ReportProvenanceResult } from "./report-provenance.js"; +import type { SourceRefInitResult, SourceRefValidation } from "./source-ref.js"; +import type { SetupResult, UninstallResult } from "./setup.js"; import type { WorkspaceActivityEntry, WorkspaceStatus } from "./workspace.js"; import { command as cmd, @@ -35,6 +43,81 @@ import { warn, } from "./tui.js"; +export function printCampaignInitResult(result: InitCampaignResult): void { + console.log( + panel(result.overwritten ? "campaign updated" : "campaign created", [ + ...kv([ + ["id", result.campaign.id], + ["target", result.campaign.target.name], + ["yaml", result.yamlPath], + ["runbook", result.runbookPath], + ["lanes", String(result.campaign.lanes.length)], + ["next", cmd(result.nextAction)], + ]), + ...result.warnings.map((message) => warn(`warning ${message}`)), + ]), + ); +} + +export function printCampaignSummaries(campaigns: CampaignSummary[]): void { + if (campaigns.length === 0) { + console.log(empty("No campaigns yet. Run omv campaign init to create one.")); + return; + } + console.log(title("campaigns")); + console.log( + table( + ["id", "target", "version", "status", "lanes", "next"], + campaigns.map((campaign) => [ + campaign.id, + campaign.target, + campaign.version, + campaign.status, + String(campaign.laneCount), + campaign.nextAction, + ]), + ), + ); +} + +export function printCampaignDetail(result: ShowCampaignResult): void { + console.log( + panel("campaign", [ + ...kv([ + ["id", result.campaign.id], + ["target", result.campaign.target.name], + ["version", result.campaign.target.version], + ["ecosystem", result.campaign.target.ecosystem], + ["yaml", result.yamlPath], + ["runbook", result.runbookExists ? result.runbookPath : "missing"], + ["next", cmd(result.nextAction)], + ]), + "", + section("candidate lanes"), + ...result.campaign.lanes.map((lane) => ` ${lane.vulnerability_class} ${lane.finding_id}`), + ]), + ); +} + +export function printCampaignSeedResult(result: CampaignSeedResult): void { + const state = result.failed.length > 0 ? "warn" : "pass"; + console.log( + panel("campaign seed", [ + ...kv([ + ["campaign", result.campaignId], + ["result", outcomeBadge(state)], + ["created", String(result.created.length)], + ["skipped", String(result.skipped.length)], + ["failed", String(result.failed.length)], + ["next", cmd(result.nextAction)], + ]), + ...result.created.map((item) => ` created ${item.id}`), + ...result.skipped.map((item) => ` skipped ${item.id}`), + ...result.failed.map((item) => tuiError(` failed ${item.id}: ${item.message}`)), + ]), + ); +} + export function printSetupResult(result: SetupResult): void { const total = result.installed.length + result.skipped.length + result.errors.length; const summary = result.errors.length > 0 @@ -71,6 +154,45 @@ export function printSetupResult(result: SetupResult): void { } } +export function printUninstallResult(result: UninstallResult): void { + const total = result.removed.length + result.notFound.length + result.errors.length; + const summary = result.errors.length > 0 + ? `${result.errors.length} error(s), ${result.removed.length}/${total} removed` + : `${result.removed.length}/${total} skill(s) removed`; + const next = result.errors.length > 0 ? "omv doctor" : "omv setup"; + const finalState = result.errors.length > 0 + ? "error" + : result.removed.length === 0 && result.notFound.length === 0 + ? "skipped" + : "pass"; + + console.log(title("oh-my-vul uninstall")); + console.log( + panel("uninstall summary", [ + ...kv([ + ["scope", result.scope], + ["skills dir", result.skillsDir], + ["result", outcomeBadge(finalState)], + ["skills", summary], + ["manifest", result.manifestRemoved ? "removed" : "not found"], + ...(result.scope === "project" + ? [["setup scope", result.setupScopeRemoved ? "removed" : "not found"]] as [string, string][] + : []), + ["next", cmd(next)], + ]), + ]), + ); + + const rows = [ + ...result.removed.map((name) => [statusIcon("installed"), name, outcomeBadge("pass"), "removed from skills directory"]), + ...result.notFound.map((name) => [statusIcon("skipped"), name, outcomeBadge("skipped"), "not found in skills directory"]), + ...result.errors.map((message) => [statusIcon("error"), "-", outcomeBadge("error"), message]), + ]; + if (rows.length > 0) { + console.log(table(["", "skill", "state", "detail"], rows)); + } +} + export function printDoctorResult(result: DoctorResult, strict: boolean): void { const passed = result.checks.filter((item) => item.status === "pass").length; const warned = result.checks.filter((item) => item.status === "warn").length; @@ -440,6 +562,9 @@ export function printReportArtifacts(result: ReportArtifactsResult): void { ["report files", String(result.reportArtifactPaths.length)], ["repro", result.reproDir], ["repro refs", `${result.existingReproArtifacts.length}/${result.listedReproArtifacts.length}`], + ["provenance", result.provenanceManifestExists + ? result.provenanceFresh === true ? "fresh" : "stale or invalid" + : "missing"], ]); if (result.reportArtifactPaths.length > 0) { lines.push("", "report artifacts"); @@ -456,6 +581,50 @@ export function printReportArtifacts(result: ReportArtifactsResult): void { console.log(panel("report artifacts", lines)); } +export function printSourceRefInitResult(result: SourceRefInitResult): void { + console.log( + panel(result.overwritten ? "source reference updated" : "source reference created", [ + ...kv([ + ["id", result.id], + ["path", result.path], + ["finding", result.findingPath], + ["sources", String(result.sourceRef.sources.length)], + ]), + ...result.warnings.map((message) => warn(`warning ${message}`)), + ]), + ); +} + +export function printSourceRefDetail(result: SourceRefValidation): void { + console.log( + panel("source reference", [ + ...kv([ + ["id", result.id], + ["path", result.path], + ["finding", result.findingPath], + ["state", result.stale ? outcomeBadge("warn") : outcomeBadge("pass")], + ["sources", String(result.sourceRef.sources.length)], + ]), + ...result.sourceRef.sources.map((source) => ` ${source.kind} ${source.locator}`), + ...result.warnings.map((message) => warn(`warning ${message}`)), + ]), + ); +} + +export function printReportProvenanceResult(result: ReportProvenanceResult): void { + console.log( + panel(result.overwritten ? "report provenance updated" : "report provenance created", [ + ...kv([ + ["id", result.id], + ["path", result.path], + ["inputs", String(result.manifest.inputs.length)], + ["next", cmd(`omv report artifacts ${result.id}`)], + ]), + ...result.warnings.map((message) => warn(`warning ${message}`)), + ]), + ); +} + export function printFindingTemplateResult(result: FindingTemplateResult): void { console.log( panel("finding created", [ diff --git a/src/cli/report-provenance.ts b/src/cli/report-provenance.ts new file mode 100644 index 0000000..95acf5c --- /dev/null +++ b/src/cli/report-provenance.ts @@ -0,0 +1,359 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve } from "node:path"; +import { parse as parseYaml } from "yaml"; +import { sha256File } from "./install-manifest.js"; +import { + findingReportsDir, + findingsDir, + reportProvenancePath, + sourceRefPath, + threatMapPath, + verificationPath, +} from "./paths.js"; +import { appendWorkspaceActivity } from "./workspace.js"; + +export type ReportProvenanceRole = + | "evidence" + | "report" + | "source-ref" + | "threat-map" + | "verification" + | "reproduction"; + +export interface ReportProvenanceInput { + role: ReportProvenanceRole; + path: string; + sha256: string; +} + +export interface ReportProvenanceManifest { + schema_version: "1"; + finding_id: string; + generated_at: string; + inputs: ReportProvenanceInput[]; +} + +export interface CreateReportProvenanceOptions { + force?: boolean; + now?: () => Date; +} + +export interface ReportProvenanceResult { + id: string; + path: string; + manifest: ReportProvenanceManifest; + overwritten: boolean; + warnings: string[]; +} + +export interface ReportProvenanceValidation { + id: string; + path: string; + exists: true; + ok: boolean; + fresh: boolean; + manifest: ReportProvenanceManifest; + staleInputs: string[]; + missingInputs: string[]; + errors: string[]; + warnings: string[]; +} + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SHA256 = /^[a-f0-9]{64}$/; +const ROOT_KEYS = new Set(["schema_version", "finding_id", "generated_at", "inputs"]); +const INPUT_KEYS = new Set(["role", "path", "sha256"]); +const ROLES = new Set([ + "evidence", + "report", + "source-ref", + "threat-map", + "verification", + "reproduction", +]); + +export async function createReportProvenance( + target: string, + projectRoot = process.cwd(), + options: CreateReportProvenanceOptions = {}, +): Promise { + const id = normalizeId(target); + const findingPath = resolveFindingPath(id, projectRoot); + if (!existsSync(findingPath)) throw new Error(`${findingPath} does not exist`); + const reportPaths = await listReportFiles(id, projectRoot); + if (reportPaths.length === 0) { + throw new Error(`no non-empty report artifacts found under ${findingReportsDir(id, projectRoot)}`); + } + + const current = (options.now ?? (() => new Date()))(); + if (!(current instanceof Date) || Number.isNaN(current.getTime())) { + throw new Error("now must return a valid Date"); + } + const evidence = parseEvidence(await readFile(findingPath, "utf-8"), findingPath); + const warnings: string[] = []; + const inputs: ReportProvenanceInput[] = []; + await addInput(inputs, "evidence", findingPath, projectRoot); + for (const path of reportPaths) await addInput(inputs, "report", path, projectRoot); + for (const [role, path] of [ + ["source-ref", sourceRefPath(id, projectRoot)], + ["threat-map", threatMapPath(id, projectRoot)], + ["verification", verificationPath(id, projectRoot)], + ] as const) { + if (existsSync(path) && (await stat(path)).isFile()) await addInput(inputs, role, path, projectRoot); + } + const seen = new Set(inputs.map((input) => resolveInputPath(input.path, projectRoot))); + for (const declared of stringList(evidence, "evidence.repro_artifacts")) { + const path = isAbsolute(declared) ? declared : join(projectRoot, declared); + if (!existsSync(path)) { + warnings.push(`declared reproduction dependency is missing and was not hashed: ${declared}`); + continue; + } + if (!(await stat(path)).isFile()) { + warnings.push(`declared reproduction dependency is not a file and was not hashed: ${declared}`); + continue; + } + const resolved = resolve(path); + if (!seen.has(resolved)) { + await addInput(inputs, "reproduction", path, projectRoot); + seen.add(resolved); + } + } + + const manifest: ReportProvenanceManifest = { + schema_version: "1", + finding_id: id, + generated_at: current.toISOString(), + inputs, + }; + validateManifestObject(manifest, "ReportProvenance.v1"); + const path = reportProvenancePath(id, projectRoot); + const overwritten = existsSync(path); + if (overwritten && !options.force) { + throw new Error(`report provenance already exists: ${path}; pass --force to replace it`); + } + await mkdir(findingReportsDir(id, projectRoot), { recursive: true }); + await writeAtomic(path, `${JSON.stringify(manifest, null, 2)}\n`, Boolean(options.force)); + try { + await appendWorkspaceActivity({ action: "report.provenance", id, path }, projectRoot); + } catch (error) { + warnings.push(`report provenance written, but activity recording failed: ${errorMessage(error)}`); + } + return { id, path, manifest, overwritten, warnings }; +} + +export async function validateReportProvenance( + target: string, + projectRoot = process.cwd(), +): Promise { + const id = normalizeId(target); + const path = reportProvenancePath(id, projectRoot); + if (!existsSync(path)) throw new Error(`${path} does not exist`); + const manifest = parseReportProvenanceJson(await readFile(path, "utf-8"), path); + if (manifest.finding_id !== id) { + throw new Error(`${path}: finding_id must match report directory id ${id}`); + } + const staleInputs: string[] = []; + const missingInputs: string[] = []; + for (const input of manifest.inputs) { + const inputPath = resolveInputPath(input.path, projectRoot); + if (!existsSync(inputPath) || !(await stat(inputPath)).isFile()) { + missingInputs.push(input.path); + } else if (await sha256File(inputPath) !== input.sha256) { + staleInputs.push(input.path); + } + } + const fresh = staleInputs.length === 0 && missingInputs.length === 0; + return { + id, + path, + exists: true, + ok: true, + fresh, + manifest, + staleInputs, + missingInputs, + errors: [], + warnings: fresh ? [] : ["report provenance inputs are stale or missing"], + }; +} + +export function parseReportProvenanceJson( + text: string, + source = "ReportProvenance.v1 JSON", +): ReportProvenanceManifest { + let value: unknown; + try { + value = JSON.parse(text); + } catch (error) { + throw new Error(`${source}: JSON parse error: ${errorMessage(error)}`); + } + return validateManifestObject(value, source); +} + +export async function listReportFiles(id: string, projectRoot = process.cwd()): Promise { + const normalized = normalizeId(id); + const dir = findingReportsDir(normalized, projectRoot); + if (!existsSync(dir)) return []; + const paths: string[] = []; + await collectReportFiles(dir, reportProvenancePath(normalized, projectRoot), paths); + return paths.sort(); +} + +async function collectReportFiles(dir: string, manifestPath: string, paths: string[]): Promise { + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await collectReportFiles(path, manifestPath, paths); + } else if (entry.isFile() && path !== manifestPath && (await stat(path)).size > 0) { + paths.push(path); + } + } +} + +async function addInput( + inputs: ReportProvenanceInput[], + role: ReportProvenanceRole, + path: string, + projectRoot: string, +): Promise { + inputs.push({ role, path: portablePath(path, projectRoot), sha256: await sha256File(path) }); +} + +function validateManifestObject(value: unknown, source: string): ReportProvenanceManifest { + const errors: string[] = []; + if (!isRecord(value)) throw new Error(`${source}: ReportProvenance.v1 must be an object`); + rejectUnknown(value, ROOT_KEYS, "", errors); + if (value.schema_version !== "1") errors.push("schema_version must be 1"); + const id = textValue(value.finding_id); + if (!SAFE_ID.test(id)) errors.push("finding_id must be a safe filename id"); + const generatedAt = textValue(value.generated_at); + if (!isCanonicalTimestamp(generatedAt)) errors.push("generated_at must be a real ISO 8601 UTC timestamp"); + const roles: ReportProvenanceRole[] = []; + const paths = new Set(); + if (!Array.isArray(value.inputs)) { + errors.push("inputs must be a list"); + } else { + value.inputs.forEach((item, index) => { + const prefix = `inputs[${index}]`; + if (!isRecord(item)) { + errors.push(`${prefix} must be an object`); + return; + } + rejectUnknown(item, INPUT_KEYS, prefix, errors); + if (!ROLES.has(item.role as ReportProvenanceRole)) errors.push(`${prefix}.role is unsupported`); + else roles.push(item.role as ReportProvenanceRole); + const path = textValue(item.path); + if (!path || path !== path.trim() || /[\u0000-\u001f\u007f]/.test(path)) { + errors.push(`${prefix}.path must be canonical single-line text`); + } else if (paths.has(path)) errors.push(`${prefix}.path must be unique`); + paths.add(path); + if (!SHA256.test(textValue(item.sha256))) errors.push(`${prefix}.sha256 must be a lowercase SHA-256`); + }); + } + if (roles.filter((role) => role === "evidence").length !== 1) errors.push("inputs must contain exactly one evidence role"); + if (!roles.includes("report")) errors.push("inputs must contain at least one report role"); + if (errors.length > 0) { + throw new Error(`${source}: ReportProvenance.v1 validation failed:\n- ${errors.join("\n- ")}`); + } + return value as unknown as ReportProvenanceManifest; +} + +function portablePath(path: string, projectRoot: string): string { + const root = resolve(projectRoot); + const absolute = resolve(path); + const candidate = relative(root, absolute); + return candidate && candidate !== ".." && !candidate.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) + ? candidate + : absolute; +} + +function resolveInputPath(path: string, projectRoot: string): string { + return isAbsolute(path) ? path : join(projectRoot, path); +} + +function parseEvidence(text: string, source: string): Record { + try { + const value = parseYaml(text); + if (!isRecord(value)) throw new Error("Evidence YAML must be a mapping"); + return value; + } catch (error) { + throw new Error(`${source}: ${errorMessage(error)}`); + } +} + +function stringList(value: Record, path: string): string[] { + let current: unknown = value; + for (const part of path.split(".")) { + if (!isRecord(current)) return []; + current = current[part]; + } + return Array.isArray(current) + ? current.filter((item): item is string => typeof item === "string" && item.trim().length > 0) + : []; +} + +function resolveFindingPath(id: string, projectRoot: string): string { + for (const suffix of [".yaml", ".yml"]) { + const path = join(findingsDir(projectRoot), `${id}${suffix}`); + if (existsSync(path)) return path; + } + return join(findingsDir(projectRoot), `${id}.yaml`); +} + +function normalizeId(target: string): string { + const id = target.replace(/\.ya?ml$/i, ""); + if (!SAFE_ID.test(id)) { + throw new Error("finding id must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens"); + } + return id; +} + +async function writeAtomic(path: string, text: string, force: boolean): Promise { + if (!force) { + try { + await writeFile(path, text, { encoding: "utf-8", flag: "wx" }); + return; + } catch (error) { + if (errorCode(error) === "EEXIST") { + throw new Error(`report provenance already exists: ${path}; pass --force to replace it`); + } + throw error; + } + } + const temporary = `${path}.tmp-${process.pid}-${Date.now()}`; + try { + await writeFile(temporary, text, { encoding: "utf-8", flag: "wx" }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +function rejectUnknown(value: Record, allowed: Set, prefix: string, errors: string[]): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) errors.push(`unknown field ${prefix ? `${prefix}.` : ""}${key}`); + } +} + +function isCanonicalTimestamp(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false; + const parsed = new Date(value); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value; +} + +function textValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function errorCode(error: unknown): string | undefined { + return isRecord(error) && typeof error.code === "string" ? error.code : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/cli/request.ts b/src/cli/request.ts index 9ef4cdc..cd4ab3e 100644 --- a/src/cli/request.ts +++ b/src/cli/request.ts @@ -1,8 +1,10 @@ import { createHash } from "crypto"; -import { existsSync } from "fs"; +import { lookup } from "dns/promises"; +import { existsSync, readFileSync } from "fs"; import { mkdir, readFile, writeFile } from "fs/promises"; +import { BlockList, isIP } from "net"; import { dirname, join } from "path"; -import { httpCacheDir } from "./paths.js"; +import { httpCacheDir, packageRoot } from "./paths.js"; export type RequestFailureReason = | "auth_required" @@ -13,6 +15,9 @@ export type RequestFailureReason = | "network_timeout" | "network_error" | "invalid_url" + | "unsafe_destination" + | "too_many_redirects" + | "response_too_large" | "http_error"; export interface RequestFailure { @@ -53,8 +58,11 @@ export interface RequestFetchOptions { projectRoot?: string; timeoutMs?: number; retries?: number; + resolver?: RequestResolver; } +export type RequestResolver = (hostname: string) => Promise>; + export interface RequestPreflightCheck { name: string; url: string; @@ -87,17 +95,36 @@ interface CacheEntry { } const DEFAULT_ACCEPT = "text/plain,text/html,*/*"; -const DEFAULT_TIMEOUT_MS = Number(process.env.OMV_HTTP_TIMEOUT_MS ?? "20000"); -const DEFAULT_RETRIES = Number(process.env.OMV_HTTP_RETRIES ?? "1"); +const FALLBACK_TIMEOUT_MS = 20_000; +const FALLBACK_RETRIES = 1; const SUCCESS_TTL_MS = Number(process.env.OMV_HTTP_CACHE_SUCCESS_MS ?? String(24 * 60 * 60 * 1000)); const FAILURE_TTL_MS = Number(process.env.OMV_HTTP_CACHE_FAILURE_MS ?? String(5 * 60 * 1000)); const MAX_CACHE_BODY_BYTES = Number(process.env.OMV_HTTP_CACHE_MAX_BODY_BYTES ?? String(1024 * 1024)); +const DEFAULT_MAX_BODY_BYTES = 8 * 1024 * 1024; +const MAX_REDIRECTS = 5; const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); +const REDIRECT_STATUS = new Set([301, 302, 303, 307, 308]); +const LOCAL_HOST_SUFFIXES = [".localhost", ".local", ".localdomain", ".internal", ".home", ".lan"]; +const NON_PUBLIC_IPV4 = createNonPublicIpv4BlockList(); +const NON_PUBLIC_IPV6 = createNonPublicIpv6BlockList(); +const defaultResolver: RequestResolver = async (hostname) => lookup(hostname, { all: true, verbatim: true }); +const DEFAULT_USER_AGENT = `omv-cli/${installedPackageVersion()} (+https://github.com/bx33661/oh-my-vul)`; export async function requestFetch(url: string, options: RequestFetchOptions = {}): Promise { const accept = options.accept ?? DEFAULT_ACCEPT; const projectRoot = options.projectRoot ?? process.cwd(); + const timeoutMs = configuredTimeoutMs(options.timeoutMs); + const retries = configuredRetries(options.retries); const cachePath = requestCachePath(url, accept, projectRoot); + const destination = await validateInitialDestinationWithRetries( + url, + options.resolver ?? defaultResolver, + timeoutMs, + retries, + ); + if (!destination.ok) { + return failedRequestResult(url, accept, cachePath, destination.failure); + } if (!options.refresh) { const cached = await readFreshCache(cachePath); if (cached) { @@ -105,11 +132,13 @@ export async function requestFetch(url: string, options: RequestFetchOptions = { } } - const result = await fetchWithRetries(url, { + const result = await fetchWithRetries(destination.url.toString(), { accept, cachePath, - timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, - retries: options.retries ?? DEFAULT_RETRIES, + timeoutMs, + retries, + resolver: options.resolver ?? defaultResolver, + maxBodyBytes: configuredMaxBodyBytes(), }); await writeCache(cachePath, result); return result; @@ -158,29 +187,19 @@ function preflightStatus(result: RequestFetchResult): RequestPreflightCheck["sta async function fetchWithRetries( url: string, - options: { accept: string; cachePath: string; timeoutMs: number; retries: number }, + options: { + accept: string; + cachePath: string; + timeoutMs: number; + retries: number; + resolver: RequestResolver; + maxBodyBytes: number; + }, ): Promise { - const parsed = parseHttpUrl(url); - if (!parsed) { - return { - url, - accept: options.accept, - ok: false, - cached: false, - cachePath: options.cachePath, - fetchedAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + FAILURE_TTL_MS).toISOString(), - headers: {}, - bodyBytes: 0, - recommendation: "Use a valid http:// or https:// URL.", - failure: { reason: "invalid_url", message: "URL must use http or https" }, - }; - } - const attempts = Math.max(0, options.retries) + 1; let last: RequestFetchResult | undefined; for (let attempt = 0; attempt < attempts; attempt += 1) { - last = await fetchOnce(parsed.toString(), options); + last = await fetchOnce(url, options); if (last.ok || !shouldRetry(last, attempt, attempts)) { return last; } @@ -191,39 +210,84 @@ async function fetchWithRetries( async function fetchOnce( url: string, - options: { accept: string; cachePath: string; timeoutMs: number }, + options: { + accept: string; + cachePath: string; + timeoutMs: number; + resolver: RequestResolver; + maxBodyBytes: number; + }, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), options.timeoutMs); const fetchedAt = new Date().toISOString(); try { - const response = await fetch(url, { - headers: requestHeaders(url, options.accept), - signal: controller.signal, - }); - const headers = sanitizeHeaders(headersToRecord(response.headers)); - const body = Buffer.from(await response.arrayBuffer()); - const bodySha256 = sha256(body); - const failure = response.ok ? undefined : classifyHttpFailure(response.status, headers, body); - const rateLimit = parseRateLimit(headers); - const recommendation = recommendNextStep(url, response.ok, failure, rateLimit); - return { - url, - accept: options.accept, - ok: response.ok, - status: response.status, - cached: false, - cachePath: options.cachePath, - fetchedAt, - expiresAt: expiresAt(response.ok, headers, failure), - headers, - bodyBytes: body.byteLength, - bodySha256, - bodyPreview: bodyPreview(body, headers), - rateLimit, - recommendation, - failure, - }; + let currentUrl = url; + for (let redirectCount = 0; ; redirectCount += 1) { + const response = await fetch(currentUrl, { + headers: requestHeaders(currentUrl, options.accept), + redirect: "manual", + signal: controller.signal, + }); + const location = response.headers.get("location"); + if (REDIRECT_STATUS.has(response.status) && location) { + if (redirectCount >= MAX_REDIRECTS) { + await cancelBody(response); + return failedRequestResult(url, options.accept, options.cachePath, { + reason: "too_many_redirects", + status: response.status, + message: `redirect limit of ${MAX_REDIRECTS} exceeded`, + }); + } + const nextUrl = resolveRedirect(location, currentUrl); + const destination = await validateDestination(nextUrl, options.resolver, controller.signal); + if (!destination.ok) { + await cancelBody(response); + return failedRequestResult(url, options.accept, options.cachePath, destination.failure); + } + await cancelBody(response); + currentUrl = destination.url.toString(); + continue; + } + + const headers = sanitizeHeaders(headersToRecord(response.headers)); + const bodyRead = await readBoundedBody(response, options.maxBodyBytes); + if (!bodyRead.ok) { + const result = failedRequestResult(url, options.accept, options.cachePath, { + reason: "response_too_large", + status: response.status, + message: `response exceeded the ${options.maxBodyBytes} byte limit`, + }); + return { + ...result, + status: response.status, + headers, + bodyBytes: bodyRead.bodyBytes, + }; + } + const body = bodyRead.body; + const bodySha256 = sha256(body); + const failure = response.ok ? undefined : classifyHttpFailure(response.status, headers, body); + const rateLimit = parseRateLimit(headers); + const recommendation = recommendNextStep(url, response.ok, failure, rateLimit); + return { + url, + accept: options.accept, + ok: response.ok, + status: response.status, + cached: false, + cachePath: options.cachePath, + fetchedAt, + expiresAt: expiresAt(response.ok, headers, failure), + headers, + bodyBytes: body.byteLength, + bodySha256, + bodyPreview: bodyPreview(body, headers), + rateLimit, + recommendation, + failure, + }; + } } catch (error) { const reason = error instanceof Error && error.name === "AbortError" ? "network_timeout" : "network_error"; const failure = { reason, message: error instanceof Error ? error.message : String(error) } as RequestFailure; @@ -248,7 +312,7 @@ async function fetchOnce( function requestHeaders(url: string, accept: string): Record { const headers: Record = { "accept": accept, - "user-agent": process.env.OMV_USER_AGENT ?? "omv-cli/0.7 (+https://github.com/bx33661/oh-my-vul)", + "user-agent": process.env.OMV_USER_AGENT ?? DEFAULT_USER_AGENT, }; const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN; if (token && new URL(url).hostname === "api.github.com") { @@ -319,6 +383,12 @@ function recommendNextStep( return "Retry with --refresh later or use a registry/archive fallback if this blocks source inspection."; case "invalid_url": return "Use a valid http:// or https:// URL."; + case "unsafe_destination": + return "Use a public HTTP(S) endpoint without URL credentials; local and non-public network destinations are blocked."; + case "too_many_redirects": + return "Use the final public source URL directly or choose a primary-source endpoint with a bounded redirect chain."; + case "response_too_large": + return "Use a smaller public metadata endpoint or raise OMV_HTTP_MAX_BODY_BYTES only for a trusted source."; case "upstream_error": return "Retry later and keep the field unverified until a stable primary source responds."; case "http_error": @@ -384,6 +454,10 @@ function retryDelayMs(result: RequestFetchResult, attempt: number): number { if (retryAfter && /^\d+$/.test(retryAfter)) { return Math.min(Number(retryAfter) * 1000, 5000); } + return retryBackoffMs(attempt); +} + +function retryBackoffMs(attempt: number): number { return Math.min(500 * (2 ** attempt), 5000); } @@ -466,6 +540,298 @@ function sanitizeHeaders(headers: Record): Record !blocked.has(key.toLowerCase()))); } +type DestinationValidation = + | { ok: true; url: URL } + | { ok: false; failure: RequestFailure }; + +async function validateInitialDestination( + raw: string, + resolver: RequestResolver, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await validateDestination(raw, resolver, controller.signal); + } finally { + clearTimeout(timeout); + } +} + +async function validateInitialDestinationWithRetries( + raw: string, + resolver: RequestResolver, + timeoutMs: number, + retries: number, +): Promise { + const attempts = retries + 1; + let result: DestinationValidation | undefined; + for (let attempt = 0; attempt < attempts; attempt += 1) { + result = await validateInitialDestination(raw, resolver, timeoutMs); + if (result.ok || result.failure.reason !== "network_timeout" || attempt + 1 >= attempts) { + return result; + } + await sleep(retryBackoffMs(attempt)); + } + return result as DestinationValidation; +} + +async function validateDestination( + raw: string, + resolver: RequestResolver, + signal?: AbortSignal, +): Promise { + const parsed = parseHttpUrl(raw); + if (!parsed) { + return { + ok: false, + failure: { reason: "invalid_url", message: "URL must use http or https" }, + }; + } + if (parsed.username || parsed.password) { + return unsafeDestination("URL credentials are not allowed"); + } + + const hostname = stripIpv6Brackets(parsed.hostname).toLowerCase(); + if (isLocalHostname(hostname)) { + return unsafeDestination(`local hostname is not allowed: ${hostname}`); + } + const literalFamily = isIP(hostname); + if (literalFamily !== 0) { + return isPublicAddress(hostname, literalFamily) + ? { ok: true, url: parsed } + : unsafeDestination(`non-public IP address is not allowed: ${hostname}`); + } + + let addresses: Array<{ address: string; family: number }>; + try { + addresses = await resolveWithAbort(resolver, hostname, signal); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + return { + ok: false, + failure: { reason: "network_timeout", message: "request timed out during DNS resolution" }, + }; + } + const detail = error instanceof Error ? error.message : String(error); + return unsafeDestination(`hostname could not be resolved safely: ${detail}`); + } + if (addresses.length === 0) { + return unsafeDestination("hostname resolved to no addresses"); + } + for (const resolved of addresses) { + const family = isIP(resolved.address); + if (family === 0 || family !== resolved.family || !isPublicAddress(resolved.address, family)) { + return unsafeDestination(`hostname resolved to a non-public address: ${resolved.address}`); + } + } + return { ok: true, url: parsed }; +} + +function unsafeDestination(message: string): DestinationValidation { + return { ok: false, failure: { reason: "unsafe_destination", message } }; +} + +function isLocalHostname(hostname: string): boolean { + return hostname === "localhost" || LOCAL_HOST_SUFFIXES.some((suffix) => hostname.endsWith(suffix)); +} + +function isPublicAddress(address: string, family: number): boolean { + if (family === 4) return !NON_PUBLIC_IPV4.check(address, "ipv4"); + if (family === 6) return !NON_PUBLIC_IPV6.check(address, "ipv6"); + return false; +} + +function stripIpv6Brackets(hostname: string): string { + return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; +} + +function createNonPublicIpv4BlockList(): BlockList { + const blocked = new BlockList(); + for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], + ] as Array<[string, number]>) { + blocked.addSubnet(network, prefix, "ipv4"); + } + return blocked; +} + +function createNonPublicIpv6BlockList(): BlockList { + const blocked = new BlockList(); + for (const [network, prefix] of [ + ["::", 3], + ["4000::", 2], + ["8000::", 1], + ["::", 128], + ["::1", 128], + ["::ffff:0:0", 96], + ["64:ff9b:1::", 48], + ["100::", 64], + ["2001::", 23], + ["2001:2::", 48], + ["2001:db8::", 32], + ["2001:10::", 28], + ["2002::", 16], + ["3fff::", 20], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8], + ] as Array<[string, number]>) { + blocked.addSubnet(network, prefix, "ipv6"); + } + return blocked; +} + +function failedRequestResult( + url: string, + accept: string, + cachePath: string, + failure: RequestFailure, +): RequestFetchResult { + return { + url, + accept, + ok: false, + cached: false, + cachePath, + fetchedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + FAILURE_TTL_MS).toISOString(), + headers: {}, + bodyBytes: 0, + recommendation: recommendNextStep(url, false, failure), + failure, + }; +} + +function resolveRedirect(location: string, currentUrl: string): string { + try { + return new URL(location, currentUrl).toString(); + } catch { + return location; + } +} + +async function cancelBody(response: Response): Promise { + try { + await response.body?.cancel(); + } catch { + // The destination is already rejected; cancellation is best effort. + } +} + +type BodyReadResult = + | { ok: true; body: Buffer } + | { ok: false; bodyBytes: number }; + +async function readBoundedBody(response: Response, maxBodyBytes: number): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength && /^\d+$/.test(contentLength) && Number(contentLength) > maxBodyBytes) { + await cancelBody(response); + return { ok: false, bodyBytes: 0 }; + } + if (!response.body) { + return { ok: true, body: Buffer.alloc(0) }; + } + + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let bodyBytes = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + const chunk = Buffer.from(next.value); + bodyBytes += chunk.byteLength; + if (bodyBytes > maxBodyBytes) { + try { + await reader.cancel("response body limit exceeded"); + } catch { + // The size failure remains authoritative when cancellation itself fails. + } + return { ok: false, bodyBytes }; + } + chunks.push(chunk); + } + } finally { + reader.releaseLock(); + } + return { ok: true, body: Buffer.concat(chunks, bodyBytes) }; +} + +function configuredMaxBodyBytes(): number { + const configured = Number(process.env.OMV_HTTP_MAX_BODY_BYTES ?? DEFAULT_MAX_BODY_BYTES); + return Number.isSafeInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_BODY_BYTES; +} + +function configuredTimeoutMs(value: number | undefined): number { + const configured = value ?? Number(process.env.OMV_HTTP_TIMEOUT_MS ?? FALLBACK_TIMEOUT_MS); + return Number.isSafeInteger(configured) && configured > 0 ? configured : FALLBACK_TIMEOUT_MS; +} + +function configuredRetries(value: number | undefined): number { + if (value !== undefined) { + return Number.isSafeInteger(value) && value >= 0 ? value : 0; + } + const configured = Number(process.env.OMV_HTTP_RETRIES ?? FALLBACK_RETRIES); + return Number.isSafeInteger(configured) && configured >= 0 ? configured : FALLBACK_RETRIES; +} + +function resolveWithAbort( + resolver: RequestResolver, + hostname: string, + signal: AbortSignal | undefined, +): Promise> { + if (!signal) return resolver(hostname); + if (signal.aborted) return Promise.reject(abortError()); + + return new Promise((resolve, reject) => { + const onAbort = () => { + signal.removeEventListener("abort", onAbort); + reject(abortError()); + }; + signal.addEventListener("abort", onAbort, { once: true }); + resolver(hostname).then( + (addresses) => { + signal.removeEventListener("abort", onAbort); + resolve(addresses); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} + +function abortError(): Error { + const error = new Error("request timed out during DNS resolution"); + error.name = "AbortError"; + return error; +} + +function installedPackageVersion(): string { + try { + const parsed = JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf-8")) as { version?: unknown }; + return typeof parsed.version === "string" && parsed.version ? parsed.version : "unknown"; + } catch { + return "unknown"; + } +} + function parseHttpUrl(raw: string): URL | undefined { try { const parsed = new URL(raw); diff --git a/src/cli/setup.ts b/src/cli/setup.ts index d8957b5..9af8631 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -4,7 +4,6 @@ import { join } from "path"; import { claudeSkillsDir, claudeAgentsDir, projectAgentsDir, omvStateDir, packageRoot, projectSkillsDir, setupScopePath } from "./paths.js"; import { getInstallableSkills, readCatalog } from "./catalog.js"; import { buildInstallManifest, installManifestPath, readInstallManifest, writeInstallManifest } from "./install-manifest.js"; -import { readConfig } from "./config.js"; export type SetupScope = "user" | "project"; diff --git a/src/cli/source-ref.ts b/src/cli/source-ref.ts new file mode 100644 index 0000000..be56cf6 --- /dev/null +++ b/src/cli/source-ref.ts @@ -0,0 +1,332 @@ +import { existsSync } from "node:fs"; +import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { parse as parseYaml, stringify as stringifyYaml } from "yaml"; +import { sha256File } from "./install-manifest.js"; +import { findingsDir, sourceRefPath, sourcesDir } from "./paths.js"; +import { appendWorkspaceActivity } from "./workspace.js"; + +export type SourceRefKind = "repository" | "registry" | "archive" | "file" | "advisory" | "other"; + +export interface SourceRefEntry { + kind: SourceRefKind; + locator: string; + revision: string; + path: string; + sha256: string; +} + +export interface SourceRef { + schema_version: "1"; + finding_id: string; + finding_sha256: string; + captured_at: string; + sources: SourceRefEntry[]; +} + +export interface SourceRefInitOptions { + force?: boolean; + now?: () => Date; +} + +export interface SourceRefInitResult { + id: string; + path: string; + findingPath: string; + sourceRef: SourceRef; + overwritten: boolean; + warnings: string[]; +} + +export interface SourceRefValidation { + id: string; + path: string; + findingPath: string; + sourceRef: SourceRef; + ok: boolean; + stale: boolean; + errors: string[]; + warnings: string[]; +} + +export type SourceRefDetail = SourceRefValidation; + +const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +const SHA256 = /^[a-f0-9]{64}$/; +const SOURCE_KINDS = new Set([ + "repository", + "registry", + "archive", + "file", + "advisory", + "other", +]); +const ROOT_KEYS = new Set(["schema_version", "finding_id", "finding_sha256", "captured_at", "sources"]); +const SOURCE_KEYS = new Set(["kind", "locator", "revision", "path", "sha256"]); + +export async function initSourceRef( + target: string, + projectRoot = process.cwd(), + options: SourceRefInitOptions = {}, +): Promise { + const id = normalizeSourceId(target); + const findingPath = resolveFindingPath(id, projectRoot); + if (!existsSync(findingPath)) { + throw new Error(`${findingPath} does not exist`); + } + + const evidence = parseEvidence(await readFile(findingPath, "utf-8"), findingPath); + const current = (options.now ?? (() => new Date()))(); + if (!(current instanceof Date) || Number.isNaN(current.getTime())) { + throw new Error("now must return a valid Date"); + } + const sources = sourceEntriesFromEvidence(evidence); + const sourceRef: SourceRef = { + schema_version: "1", + finding_id: id, + finding_sha256: await sha256File(findingPath), + captured_at: current.toISOString(), + sources, + }; + validateSourceRefObject(sourceRef, "SourceRef.v1"); + + await mkdir(sourcesDir(projectRoot), { recursive: true }); + const path = sourceRefPath(id, projectRoot); + const overwritten = existsSync(path); + if (overwritten && !options.force) { + throw new Error(`SourceRef already exists: ${path}; pass --force to replace it`); + } + await writeAtomic(path, stringifyYaml(sourceRef), Boolean(options.force)); + const warnings = sources.length === 0 + ? ["Evidence contains no known source identity; SourceRef sources remain empty"] + : []; + try { + await appendWorkspaceActivity({ action: "source.init", id, path }, projectRoot); + } catch (error) { + warnings.push(`SourceRef written, but activity recording failed: ${errorMessage(error)}`); + } + return { + id, + path, + findingPath, + sourceRef, + overwritten, + warnings, + }; +} + +export async function validateSourceRef( + target: string, + projectRoot = process.cwd(), +): Promise { + const id = normalizeSourceId(target); + const path = sourceRefPath(id, projectRoot); + if (!existsSync(path)) { + throw new Error(`${path} does not exist`); + } + const sourceRef = parseSourceRefYaml(await readFile(path, "utf-8"), path); + const findingPath = resolveFindingPath(id, projectRoot); + const warnings: string[] = []; + let stale = false; + if (!existsSync(findingPath)) { + warnings.push(`${findingPath} does not exist; cannot check SourceRef freshness`); + } else { + stale = await sha256File(findingPath) !== sourceRef.finding_sha256; + if (stale) { + warnings.push("SourceRef finding_sha256 is stale; Evidence.v1 changed after source capture"); + } + } + return { + id, + path, + findingPath, + sourceRef, + ok: true, + stale, + errors: [], + warnings, + }; +} + +export async function showSourceRef( + target: string, + projectRoot = process.cwd(), +): Promise { + return validateSourceRef(target, projectRoot); +} + +export function parseSourceRefYaml(text: string, source = "SourceRef.v1 YAML"): SourceRef { + let value: unknown; + try { + value = parseYaml(text); + } catch (error) { + throw new Error(`${source}: SourceRef YAML parse error: ${errorMessage(error)}`); + } + const sourceRef = validateSourceRefObject(value, source); + const fileId = sourceIdFromPath(source); + if (fileId && sourceRef.finding_id !== fileId) { + throw new Error(`${source}: finding id must match filename id ${fileId}`); + } + return sourceRef; +} + +function validateSourceRefObject(value: unknown, source: string): SourceRef { + const errors: string[] = []; + if (!isRecord(value)) { + throw new Error(`${source}: SourceRef.v1 must be a mapping`); + } + rejectUnknown(value, ROOT_KEYS, "", errors); + if (value.schema_version !== "1") errors.push("schema_version must be 1"); + const findingId = textValue(value.finding_id); + if (!SAFE_ID.test(findingId)) errors.push("finding_id must be a safe filename id"); + const findingHash = textValue(value.finding_sha256); + if (!SHA256.test(findingHash)) errors.push("finding_sha256 must be a lowercase SHA-256"); + const capturedAt = textValue(value.captured_at); + if (!isCanonicalTimestamp(capturedAt)) errors.push("captured_at must be a real ISO 8601 UTC timestamp"); + if (!Array.isArray(value.sources)) { + errors.push("sources must be a list"); + } else { + value.sources.forEach((item, index) => validateSourceEntry(item, index, errors)); + } + if (errors.length > 0) { + throw new Error(`${source}: SourceRef.v1 validation failed:\n- ${errors.join("\n- ")}`); + } + return value as unknown as SourceRef; +} + +function validateSourceEntry(value: unknown, index: number, errors: string[]): void { + const prefix = `sources[${index}]`; + if (!isRecord(value)) { + errors.push(`${prefix} must be a mapping`); + return; + } + rejectUnknown(value, SOURCE_KEYS, prefix, errors); + if (!SOURCE_KINDS.has(value.kind as SourceRefKind)) { + errors.push(`${prefix}.kind must be repository, registry, archive, file, advisory, or other`); + } + for (const key of ["locator", "revision", "path"] as const) { + const text = textValue(value[key]); + if (!text || text !== text.trim() || hasControl(text)) { + errors.push(`${prefix}.${key} must be non-empty canonical single-line text`); + } + } + const hash = textValue(value.sha256); + if (hash !== "unknown" && !SHA256.test(hash)) { + errors.push(`${prefix}.sha256 must be unknown or a lowercase SHA-256`); + } +} + +function sourceEntriesFromEvidence(evidence: Record): SourceRefEntry[] { + const entries: SourceRefEntry[] = []; + const repository = nestedString(evidence, "package.repository_url"); + if (known(repository)) { + entries.push(unknownSource("repository", repository)); + } + const ecosystem = nestedString(evidence, "package.ecosystem"); + const registryName = nestedString(evidence, "package.registry_name"); + if (known(ecosystem) && known(registryName)) { + entries.push(unknownSource("registry", `${ecosystem}:${registryName}`)); + } + return entries; +} + +function unknownSource(kind: SourceRefKind, locator: string): SourceRefEntry { + return { kind, locator, revision: "unknown", path: "unknown", sha256: "unknown" }; +} + +function parseEvidence(text: string, source: string): Record { + try { + const parsed = parseYaml(text); + if (!isRecord(parsed)) throw new Error("Evidence YAML must be a mapping"); + return parsed; + } catch (error) { + throw new Error(`${source}: ${errorMessage(error)}`); + } +} + +function resolveFindingPath(id: string, projectRoot: string): string { + for (const suffix of [".yaml", ".yml"]) { + const path = join(findingsDir(projectRoot), `${id}${suffix}`); + if (existsSync(path)) return path; + } + return join(findingsDir(projectRoot), `${id}.yaml`); +} + +function normalizeSourceId(target: string): string { + const id = target.replace(/\.ya?ml$/i, ""); + if (!SAFE_ID.test(id)) { + throw new Error("source id must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens"); + } + return id; +} + +async function writeAtomic(path: string, text: string, force: boolean): Promise { + if (!force) { + try { + await writeFile(path, text, { encoding: "utf-8", flag: "wx" }); + return; + } catch (error) { + if (errorCode(error) === "EEXIST") { + throw new Error(`SourceRef already exists: ${path}; pass --force to replace it`); + } + throw error; + } + } + const temporary = `${path}.tmp-${process.pid}-${Date.now()}`; + try { + await writeFile(temporary, text, { encoding: "utf-8", flag: "wx" }); + await rename(temporary, path); + } finally { + await rm(temporary, { force: true }); + } +} + +function rejectUnknown(value: Record, allowed: Set, prefix: string, errors: string[]): void { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) errors.push(`unknown field ${prefix ? `${prefix}.` : ""}${key}`); + } +} + +function sourceIdFromPath(source: string): string | undefined { + const name = basename(source); + return /\.ya?ml$/i.test(name) ? name.replace(/\.ya?ml$/i, "") : undefined; +} + +function isCanonicalTimestamp(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value)) return false; + const parsed = new Date(value); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value; +} + +function nestedString(value: Record, path: string): string { + let current: unknown = value; + for (const part of path.split(".")) { + if (!isRecord(current)) return ""; + current = current[part]; + } + return typeof current === "string" ? current.trim() : ""; +} + +function textValue(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +function known(value: string): boolean { + return Boolean(value) && value.toLowerCase() !== "unknown"; +} + +function hasControl(value: string): boolean { + return /[\u0000-\u001f\u007f]/.test(value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function errorCode(error: unknown): string | undefined { + return isRecord(error) && typeof error.code === "string" ? error.code : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/cli/usage.ts b/src/cli/usage.ts index 3a954b4..4bf797d 100644 --- a/src/cli/usage.ts +++ b/src/cli/usage.ts @@ -9,6 +9,14 @@ Usage: omv doctor [--scope user|project] [--json] [--strict] Check installation health omv dashboard [--json] Show workspace, queue, and recent activity + omv eval [--json|--junit] Run stable skill eval checks + omv eval --skill --eval-id --output + Check one saved skill output + omv campaign [list] [--json] List local research campaigns + omv campaign init [flags] Create Campaign.v1 YAML and runbook + omv campaign show [--json] Show one campaign + omv campaign seed [--json] Seed candidate Evidence.v1 hypotheses + omv first [flags] Alias for campaign init omv review [--strict] [--json] Review a finding and recommend report readiness omv workspace init [--json] Initialize local .omv workspace @@ -39,8 +47,14 @@ Usage: omv radar brief [--json] Summarize local radar events omv repro init [--force] [--json] Scaffold .omv/repro// reproduction artifacts + omv sources init [--force] [--json] + Capture local SourceRef.v1 source identity + omv sources show|validate [--json] + Show SourceRef.v1 and Evidence hash freshness omv report artifacts [--json] Inspect report/repro artifacts and readiness + omv report provenance [--force] [--json] + Hash report inputs into provenance.json omv threat-map init [--force] [--json] Scaffold .omv/threatmaps/.yaml ThreatMap.v1 sidecar omv threat-map validate [--json] @@ -77,6 +91,8 @@ Examples: omv doctor omv doctor --json omv dashboard + omv first --target acme --ecosystem npm --vuln xss,auth --no-interactive + omv campaign list omv review demo --strict omv findings list omv findings init demo @@ -95,7 +111,7 @@ Examples: `); } -export function commandUsage(args: string[], command: string | undefined, topic: string | undefined, subcommand: string | undefined): void { +export function commandUsage(topic: string | undefined, subcommand: string | undefined): void { switch (topic) { case "setup": console.log(`Usage: omv setup [--scope user|project] [--force] [--dry-run] [--json] @@ -124,6 +140,9 @@ Show package version, registry version, platform, and registry update date.`); Show local workspace status, active workflow queue, and recent activity in one view.`); return; + case "eval": + evalUsage(); + return; case "review": console.log(`Usage: omv review [--strict] [--json] @@ -132,6 +151,12 @@ return one verdict: ready, needs-repro, needs-audit, needs-verification, or blocked. With --strict, readiness requires a passing, non-stale Verification.v1 sidecar.`); return; + case "campaign": + campaignUsage(subcommand, false); + return; + case "first": + campaignUsage(subcommand, true); + return; case "workspace": workspaceUsage(subcommand); return; @@ -162,6 +187,9 @@ sidecar.`); case "report": reportUsage(subcommand); return; + case "sources": + sourcesUsage(subcommand); + return; case "threat-map": threatMapUsage(subcommand); return; @@ -174,6 +202,47 @@ sidecar.`); } } +export function evalUsage(): void { + console.log(`Usage: + omv eval [--json | --junit] + omv eval --skill --eval-id --output [--json | --junit] + +Runs checked-in stable golden cases by default. Targeted mode reuses the selected +skill's existing check_output.py. The command performs no model or network calls.`); +} + +export function campaignUsage(subcommand: string | undefined, firstAlias = false): void { + const root = firstAlias ? "omv first" : "omv campaign"; + switch (subcommand) { + case "init": + console.log(`Usage: ${root} init --target --vuln [options] + +Options: --id, --version, --source, --ecosystem, --mode, --goal, --budget, +--local-lab, --force, --no-interactive, --json.`); + return; + case "list": + console.log(`Usage: ${root} list [--json]`); + return; + case "show": + console.log(`Usage: ${root} show [--json]`); + return; + case "seed": + console.log(`Usage: ${root} seed [--json] + +Creates candidate Evidence.v1 files only. A supported target ecosystem is required; +existing .yaml and .yml findings are never overwritten.`); + return; + default: + console.log(`Usage: + ${firstAlias ? "omv first [init flags]" : "omv campaign [list] [--json]"} + ${root} init --target --vuln [options] + ${root} list [--json] + ${root} show [--json] + ${root} seed [--json]`); + return; + } +} + export function workspaceUsage(subcommand: string | undefined): void { switch (subcommand) { case "init": @@ -351,13 +420,40 @@ export function reportUsage(subcommand: string | undefined): void { Inspect declared report artifacts under .omv/reports// and .omv/repro//, listing empty and missing artifacts. Exits non-zero on errors.`); return; + case "provenance": + console.log(`Usage: omv report provenance [--force] [--json] + +Hash Evidence.v1, report files, and available local sidecar/reproduction inputs +into .omv/reports//provenance.json. No remote source is fetched.`); + return; default: console.log(`Usage: - omv report artifacts [--json]`); + omv report artifacts [--json] + omv report provenance [--force] [--json]`); return; } } +export function sourcesUsage(subcommand: string | undefined): void { + switch (subcommand) { + case "init": + console.log(`Usage: omv sources init [--force] [--json] + +Create .omv/sources/.yaml from source facts already recorded in Evidence.v1. +This records local provenance and does not prove remote source authenticity.`); + return; + case "show": + case "validate": + console.log(`Usage: omv sources ${subcommand} [--json]`); + return; + default: + console.log(`Usage: + omv sources init [--force] [--json] + omv sources show [--json] + omv sources validate [--json]`); + } +} + export function threatMapUsage(subcommand: string | undefined): void { switch (subcommand) { case "init": diff --git a/src/cli/verification.ts b/src/cli/verification.ts index 5a780ac..3cbe747 100644 --- a/src/cli/verification.ts +++ b/src/cli/verification.ts @@ -73,7 +73,7 @@ export async function showVerification(id: string, projectRoot = process.cwd()): return { ...validation, rendered: [] }; } const parseResult = parseVerificationYaml(await readFile(path, "utf-8")); - const validation = await validateParsedVerification(normalizedId, path, findingPath, parseResult, projectRoot); + const validation = await validateParsedVerification(normalizedId, path, findingPath, parseResult); const rendered = renderVerification(parseResult.data); return { ...validation, rendered }; } @@ -100,7 +100,7 @@ export async function validateVerification( } const parseResult = parseVerificationYaml(await readFile(path, "utf-8")); - return validateParsedVerification(id, path, findingPath, parseResult, projectRoot); + return validateParsedVerification(id, path, findingPath, parseResult); } async function validateParsedVerification( @@ -108,7 +108,6 @@ async function validateParsedVerification( path: string, findingPath: string, parseResult: { data: Record; errors: string[] }, - projectRoot: string, ): Promise { const errors: string[] = []; const warnings: string[] = []; diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index a8c9c10..05c1c91 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -1,11 +1,76 @@ // workflow.ts — workflow priority and next-action logic // Extracted from findings.ts. These functions are pure helpers that // interpret validation results to produce lifecycle guidance. +// +// Readiness policy (single source of truth for report recommendation): +// report-ready when status=confirmed, validation.ok, submissionScore>=threshold, +// optional ThreatMap is ok when present, and Verification passes when strict. +// workflowNextAction is the lightweight queue hint (score + missing fields). +// doctorFinding / reviewFinding apply the full readiness policy and must remain +// the authoritative next-action for report readiness. import type { FindingSummary, FindingValidation, FindingDoctorIssue } from "./findings.js"; +/** Minimum submission score before recommending /omv-report or priority 100. */ +export const SUBMISSION_READY_THRESHOLD = 75; + +export interface ReportReadinessInput { + status: string; + validationOk: boolean; + submissionScore: number; + /** When a ThreatMap sidecar exists, it must validate. Default true if absent. */ + threatMapOk?: boolean; + /** When strict verification is required, Verification.v1 must pass. Default true. */ + verificationOk?: boolean; +} + // ── Exported helpers ──────────────────────────────────────────────────── +export function isSubmissionScoreReady( + status: string, + validationOk: boolean, + submissionScore: number, +): boolean { + return status === "confirmed" && validationOk && submissionScore >= SUBMISSION_READY_THRESHOLD; +} + +/** + * Full report-readiness gate shared by doctorFinding and reviewFinding. + * Lightweight workflow queue uses isSubmissionScoreReady only (no sidecar I/O). + */ +export function isReportReady(input: ReportReadinessInput): boolean { + return ( + isSubmissionScoreReady(input.status, input.validationOk, input.submissionScore) && + (input.threatMapOk ?? true) && + (input.verificationOk ?? true) + ); +} + +/** + * Prefer report when ready; otherwise surface verification work under strict mode, + * then fall back to the lightweight workflow next-action. + */ +export function resolveDoctorNextAction( + id: string, + reportReady: boolean, + fallbackNextAction: string, + options: { + strictVerification?: boolean; + verificationReady?: boolean; + verificationExists?: boolean; + } = {}, +): string { + if (reportReady) { + return `/omv-report ${id}`; + } + if (options.strictVerification && options.verificationReady === false) { + return options.verificationExists + ? `omv verification validate ${id}` + : `omv verification init ${id}`; + } + return fallbackNextAction; +} + export function workflowMissingFields(validation: FindingValidation): string[] { const missing = new Set(); for (const error of validation.errors) { @@ -46,7 +111,7 @@ export function workflowNextAction( if (finding.status === "blocked") { return `omv findings archive ${finding.id} --reason blocked`; } - if (finding.status === "confirmed" && validation.ok) { + if (isSubmissionScoreReady(finding.status, validation.ok, finding.submissionScore)) { return `/omv-report ${finding.id}`; } const missing = new Set(missingFields); @@ -65,7 +130,7 @@ export function workflowNextAction( if (missing.has("evidence.observed_result")) { return `/omv-repro ${finding.id}`; } - if (finding.status === "candidate" && validation.ok && finding.submissionScore >= 75) { + if (finding.status === "candidate" && validation.ok && finding.submissionScore >= SUBMISSION_READY_THRESHOLD) { return `omv findings promote ${finding.id} --status confirmed`; } return `/omv-audit ${finding.id}`; @@ -77,7 +142,7 @@ export function workflowPriority( missingFields: string[], nextAction: string, ): number { - if (finding.status === "confirmed" && validation.ok) { + if (isSubmissionScoreReady(finding.status, validation.ok, finding.submissionScore)) { return 100; } if (nextAction.includes("--status confirmed")) { diff --git a/src/cli/workspace.ts b/src/cli/workspace.ts index 69880e3..a7bdf16 100644 --- a/src/cli/workspace.ts +++ b/src/cli/workspace.ts @@ -6,11 +6,13 @@ import { archiveMetadataDir, archiveMetadataPath, archivedFindingsDir, + campaignsDir, findingsDir, notesDir, omvStateDir, radarDir, reproDir, + sourcesDir, submissionsDir, threatMapsDir, verificationsDir, @@ -51,6 +53,9 @@ export interface WorkspaceActivityEntry { timestamp: string; action: | "workspace.init" + | "campaign.init" + | "source.init" + | "report.provenance" | "finding.init" | "finding.promote" | "finding.archive" @@ -86,6 +91,8 @@ export interface InitWorkspaceOptions { gitignore?: boolean; } +const OMV_GITIGNORE_ENTRY = ".omv/"; + export async function initWorkspace( projectRoot = process.cwd(), options: InitWorkspaceOptions = {}, @@ -93,41 +100,42 @@ export async function initWorkspace( await ensureWorkspaceDirs(projectRoot); await rebuildWorkspaceIndex(projectRoot); await appendWorkspaceActivity({ action: "workspace.init" }, projectRoot); + const gitignoreAdvice = await initGitignoreAdvice(projectRoot, options.gitignore ?? false); const status = await workspaceStatus(projectRoot); - status.warnings.push(...(await initGitignoreAdvice(projectRoot, options.gitignore ?? false))); + status.warnings.push(...gitignoreAdvice); return status; } async function initGitignoreAdvice(projectRoot: string, autoAdd: boolean): Promise { const gitignorePath = join(projectRoot, ".gitignore"); - const wanted = [".omv/repro/", ".omv/reports/", ".omv/archive/"]; - const keep = ".omv/findings/"; + const wanted = OMV_GITIGNORE_ENTRY; const warnings: string[] = []; if (!existsSync(gitignorePath)) { - warnings.push( - `.gitignore not found. Suggested entries:\n ${wanted.join("\n ")}\nKeep tracked:\n ${keep}`, - ); + if (autoAdd) { + await writeFile(gitignorePath, `${wanted}\n`, "utf-8"); + return warnings; + } + warnings.push(`.gitignore not found. Suggested entry:\n ${wanted}`); return warnings; } const content = await readFile(gitignorePath, "utf-8"); - const lines = content.split(/\r?\n/); - const missing = wanted.filter((entry) => !lines.some((line) => line.trim() === entry)); + const missing = !ignoresOmvState(content); - if (missing.length === 0) { + if (!missing) { return warnings; } if (autoAdd) { const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : ""; - const append = prefix + missing.map((entry) => entry).join("\n") + "\n"; + const append = `${prefix}${wanted}\n`; await appendFile(gitignorePath, append, "utf-8"); return warnings; } warnings.push( - `Suggested .gitignore entries (run omv workspace init --gitignore to auto-add):\n ${missing.join("\n ")}`, + `Suggested .gitignore entry (run omv workspace init --gitignore to auto-add):\n ${wanted}`, ); return warnings; } @@ -159,6 +167,8 @@ export async function workspaceStatus(projectRoot = process.cwd()): Promise { await mkdir(findingsDir(projectRoot), { recursive: true }); + await mkdir(campaignsDir(projectRoot), { recursive: true }); + await mkdir(sourcesDir(projectRoot), { recursive: true }); await mkdir(reproDir(projectRoot), { recursive: true }); await mkdir(threatMapsDir(projectRoot), { recursive: true }); await mkdir(verificationsDir(projectRoot), { recursive: true }); @@ -366,12 +376,15 @@ async function workspaceWarnings(projectRoot: string): Promise { if (!existsSync(gitignorePath)) { return [".omv/ is local research state; add .omv/ to .gitignore before publishing"]; } - const gitignore = await readFile(gitignorePath, "utf-8"); - const ignored = gitignore + const ignored = ignoresOmvState(await readFile(gitignorePath, "utf-8")); + return ignored ? [] : [".omv/ is local research state; add .omv/ to .gitignore before publishing"]; +} + +function ignoresOmvState(gitignore: string): boolean { + return gitignore .split(/\r?\n/) .map((line) => line.trim()) .some((line) => line === ".omv/" || line === ".omv" || line === "/.omv/" || line === "/.omv"); - return ignored ? [] : [".omv/ is local research state; add .omv/ to .gitignore before publishing"]; } function emptyIndex(): WorkspaceIndex { diff --git a/src/index.ts b/src/index.ts index b6d3c44..60e0797 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,32 @@ export { setup } from "./cli/setup.js"; export { doctor } from "./cli/doctor.js"; export { readCatalog, getInstallableSkills, parseCatalog } from "./cli/catalog.js"; +export { + CAMPAIGN_DEPTHS, + CAMPAIGN_ECOSYSTEMS, + CAMPAIGN_LOCAL_REPRODUCTIONS, + CAMPAIGN_MODES, + CAMPAIGN_OUTPUTS, + buildCampaign, + initCampaign, + listCampaigns, + normalizeCampaignId, + normalizeVulnerabilityClasses, + parseCampaignYaml, + renderCampaignRunbook, + resolveCampaignInput, + showCampaign, + validateCampaign, +} from "./cli/campaign.js"; +export { seedCampaign } from "./cli/campaign-seed.js"; +export { ReadlineCampaignPrompt } from "./cli/campaign-prompt.js"; +export { initSourceRef, parseSourceRefYaml, showSourceRef, validateSourceRef } from "./cli/source-ref.js"; +export { + createReportProvenance, + listReportFiles, + parseReportProvenanceJson, + validateReportProvenance, +} from "./cli/report-provenance.js"; export { listFindings, validateFinding, @@ -24,12 +50,18 @@ export { projectSkillsDir, omvStateDir, findingsDir, + campaignsDir, + campaignPath, + campaignRunbookPath, + sourcesDir, + sourceRefPath, archiveDir, archivedFindingsDir, archiveMetadataDir, archiveMetadataPath, reportsDir, findingReportsDir, + reportProvenancePath, reproDir, findingReproDir, workspaceIndexPath, @@ -49,8 +81,43 @@ export { export type { SetupOptions, SetupResult } from "./cli/setup.js"; export type { DoctorResult } from "./cli/doctor.js"; export type { OmvCatalog, SkillCatalogEntry } from "./cli/catalog.js"; +export type { + Campaign, + CampaignInput, + CampaignLane, + CampaignSummary, + CampaignPromptAdapter, + InitCampaignOptions, + InitCampaignResult, + ShowCampaignResult, +} from "./cli/campaign.js"; +export type { + CampaignSeedFailure, + CampaignSeedResult, + CampaignSeedSkipped, +} from "./cli/campaign-seed.js"; +export type { + SourceRef, + SourceRefDetail, + SourceRefEntry, + SourceRefInitOptions, + SourceRefInitResult, + SourceRefKind, + SourceRefValidation, +} from "./cli/source-ref.js"; +export type { + CreateReportProvenanceOptions, + ReportProvenanceInput, + ReportProvenanceManifest, + ReportProvenanceResult, + ReportProvenanceRole, + ReportProvenanceValidation, +} from "./cli/report-provenance.js"; export type { EvidenceStatus, + EvidenceEcosystem, + EvidenceResearcherGoal, + FindingTemplateSeed, FindingSummary, FindingWorkflowSummary, FindingDetail, diff --git a/tsconfig.json b/tsconfig.json index 4b4b985..5ceb037 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,8 @@ "outDir": "dist", "rootDir": "src", "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "esModuleInterop": true, "skipLibCheck": true, "declaration": true,