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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 47 additions & 36 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,42 +282,53 @@ Input today is a source repo. The vulnerabilities most customers lose sleep over
dependencies they do not own, where the code cure is a version bump someone else has to
ship. That is the case virtual patching exists for, and the pipeline cannot see it.

- [ ] **H1** CVE and advisory input path. (M, P1)
Accept a CVE ID or GHSA identifier instead of a repo. An agent resolves the advisory into
an exploitation profile of affected paths, parameters, headers, and request shapes, and
that profile enters the existing triage and generate stages unchanged.
- Acceptance: a known path-traversal CVE in a web framework produces a `waf` or
`service_policy` band-aid; an advisory with no network-observable exploitation pattern
routes to `no_bandaid` with residual risk stated; `remediate` recommends the fixed
version rather than drafting a patch to vendor code; the ledger seeds `found` the same
way a repo finding does.
- Surfaces: `src/vpcopilot/inputs/cve.py`, a `resolve` agent in `agents/`,
`vpcopilot scan --cve CVE-YYYY-NNNNN`.
- **Advisory source (decided 2026-07-27): OSV.dev primary, GHSA for enrichment.** OSV needs no
auth, spans ecosystems on one schema, and returns affected ranges **and the fixed version** —
which is exactly what the acceptance needs for "recommend the fixed version rather than
drafting a patch to vendor code". It also keeps H1 runnable with no credentials, matching
`scan`'s "safe to run anywhere". GHSA (reusing the existing `GITHUB_TOKEN`) only for advisory
prose an agent reasons over; NVD is rejected — slow, rate-limited, imprecise version data.
Note what OSV does **not** give: the network-observable exploitation pattern (paths, params,
request shapes). Deriving that is the agent's job, and it is what makes the `no_bandaid`
branch of the acceptance meaningful.
- **Reconciled:** `src/vpcopilot/inputs/` does not exist — every module is flat under
`src/vpcopilot/` except `agents/` and `console/`. Creating a package is a new convention;
decide it deliberately or use `src/vpcopilot/input_cve.py`. A new `resolve` agent must also
be added to `config.AGENT_NAMES`, or it will be absent from `run.json` provenance, the
console's agent list, and the report's model chips. **The agent name is duplicated in three
places** — `config.AGENT_NAMES` (`config.py:16`, feeds `run.json` only), `AGENT_ROLES`
(`console/app.py:198`, drives `GET /api/agents`) and a hardcoded list in `report.py:251`
(drives the report's model chips) — all three need the same change.
- **Also touches an existing signature:** `scan`'s `repo` is a required positional
(`cli.py:36`) flowing into `run_pipeline(repo_path)` which does `Path(repo_path)`
(`pipeline.py:49-61`). `--cve` means making `repo` optional with mutual exclusion, an
alternate `run_pipeline` entry that does not walk a filesystem root, and the same optionality
on `ScanReq` / `POST /api/scan`. `RemediationPlan` (`schemas.py:122-131`) also *requires*
`file`, `diff` and `patched_content`, so "recommend the fixed version" needs either an
advisory-shaped remediation artifact or optional fields plus a `pr.py` branch that skips
`update_file`.
- [x] **H1** CVE and advisory input path. (M, P1) — **DONE:** `vpcopilot scan --cve CVE-2024-23334`.
`inputs/osv.py` fetches the advisory, the new `resolve` agent derives its HTTP exploitation
profile (or declines), and the result enters triage and generate unchanged. Verified live against
api.osv.dev and a real model.
- **Acceptance, as met (all four checked live):** CVE-2024-23334 (aiohttp path traversal) →
**both** a `waf` and a `service_policy` band-aid; GHSA-8r6j-v8pm-fqw3 (fsevents supply-chain) →
`no_bandaid` with the residual risk naming why a load balancer cannot see it; the cure reads
`upgrade aiohttp to 3.9.2` with `patched_content` empty; the ledger seeds `found` with severity,
band-aids and `has_cure` exactly as a repo finding does.
- **What querying OSV for real changed.** Three behaviours are not visible from the schema and
each silently degrades the answer: (a) asking for a **CVE id usually returns the GIT-range
record** — no package, and `fixed` values that are commit SHAs; the installable `PyPI/aiohttp
3.9.2` only exists on the `GHSA-5h86-8mv2-jq9f` alias, so the client follows aliases (2 of 4
advisories tested needed the hop); (b) a commit SHA is never offered as an upgrade target —
"upgrade to 24a6d649…" is not a recommendation; (c) `summary` is frequently empty and OS-level
CVEs have no package at all, only a CPE, with the human versions hidden in
`database_specific.extracted_events`.
- **Declining is the load-bearing behaviour.** An agent that invents a plausible path for every
CVE would make this input path worse than useless — confident band-aids that block nothing
while a real vulnerability hides behind a green check. So `network_observable=false` is a
first-class answer with a required, min-length `reason`, the agent is forbidden from choosing a
control or guessing a version, and its paths are cleared in code if it declines and lists them
anyway. The `no_bandaid` routing is **deterministic**, not delegated to triage.
- **The fixed version is never model-generated.** `remediate` is not called on this path at all;
`inputs/cve.py` builds the `RemediationPlan` from OSV. Drafting a patch against vendor code is
structurally impossible rather than merely discouraged.
- **Decisions:** `inputs/` **is** a package (H1/H2/H3 are three siblings of one shape and share
the OSV client — the same criterion that justifies `agents/`), with a one-directional rule that
nothing under it imports `pipeline`. `VulnClass` is **not** widened — a CWE→class table covers
the common cases and `other` plus a concrete `exploit_sketch` is honest; widening ripples into
every agent prompt and golden. No sentinel in `file`.
- **Identity, not a fake path.** New optional `Finding.source` (`osv:CVE-…`) carries what `file`
carries for a code finding. Two real bugs it fixes: `coverage_key` returned the plausible-
looking `service_policy:` for every file-less finding, so all but the first were logged
"already covered" and got **no band-aid at all**; and the dedup key `("", class, "L0")` merged
distinct advisories of the same class. Both take a defaulted `identity` fallback, so the repo
path is structurally unreachable and byte-identical.
- **Registration is four places, not the three the roadmap said** — `config.AGENT_NAMES`,
`console.AGENT_ROLES`, `report.py`, and **`bench_model.AGENTS`**, plus a `resolve:` block in
all four `config/agents*.yaml` (an unlisted agent silently falls back to the default model and
`run.json` records that as fact).
- **Fixed en route (pre-existing `pr.py` bugs):** the no-patch check sat *above* the dry-run
branch, so `--dry-run` raised identically to a live run and could preview nothing; and an empty
`file` would have reached `repo.get_contents("")` as a directory listing rather than erroring.
- **Deliberate consequence:** an advisory finding has no cure PR, so `reconcile` holds its
band-aid and escalates at TTL. Someone still has to ship the upgrade — documented, not a
surprise.

- [ ] **H2** Dependency manifest input. (M, P2) Depends on H1.
Parse `requirements.txt`, `package-lock.json`, and `pom.xml`, resolve advisories, and run
Expand Down
1 change: 1 addition & 0 deletions config/agents.dgx.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ defaults:
mode: json

agents:
resolve: {}
discover: { model: openai/qwen3-coder:30b-a3b-q8_0 } # high-signal reading
verify: { model: openai/qwen3-coder:30b-a3b-q8_0 } # adversarial refute
triage: { model: openai/qwen3-coder:30b-a3b-q8_0 } # route to the strongest control
Expand Down
1 change: 1 addition & 0 deletions config/agents.gemini.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ defaults:
timeout: 180

agents:
resolve: {}
discover: { model: gemini/gemini-3.1-pro-preview }
verify: { model: gemini/gemini-3.1-pro-preview }
triage: { model: gemini/gemini-3.1-pro-preview }
Expand Down
1 change: 1 addition & 0 deletions config/agents.openai.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ defaults:
timeout: 180

agents:
resolve: {}
discover: { model: openai/gpt-4.1 }
verify: { model: openai/gpt-4.1 }
triage: { model: openai/gpt-4.1 }
Expand Down
2 changes: 2 additions & 0 deletions config/agents.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ defaults:
max_retries: 3

agents:
resolve:
model: anthropic/claude-opus-4-8 # high-signal reading — keep this strong
discover:
model: anthropic/claude-opus-4-8 # high-signal reading — keep this strong
verify:
Expand Down
45 changes: 45 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,51 @@ Runs `discover → verify → triage → generate → remediate` and writes to `
`findings.json`, `triage.json`, `policies/*.json` (XC specs), `remediations/*.patch|.pr.md`
(code fixes), `correlations.json`, `ledger.json`, `summary.json`. No XC/GitHub writes.

### Scan a CVE instead of a repo (H1)

The vulnerabilities most people lose sleep over live in dependencies they do not own, where the
code cure is a version bump someone else has to ship and they then have to deploy. That gap is what
virtual patching is for.

```sh
vpcopilot scan --cve CVE-2024-23334 --out out # or GHSA-…, PYSEC-…, GO-…, RUSTSEC-…
```

The advisory is resolved from **OSV.dev** (no credentials — `scan` stays safe to run anywhere), the
`resolve` agent derives its HTTP exploitation profile, and the result enters the same triage and
generate stages as a code finding. `--cve` and a repo path are mutually exclusive.

**The agent is expected to decline.** Many advisories cannot be virtually patched at a load
balancer — a malicious build-time dependency, a bug reachable only from a local file, memory
corruption with no request signature. Those route to `no_bandaid` with the residual risk stated,
and that routing is decided **in code**, not asked of the model: a hard requirement should not
depend on a prompt being honoured. An agent that obligingly invented a plausible path for every CVE
would be worse than no advisory input at all — it would produce confident band-aids that block
nothing while hiding a real vulnerability behind a green check.

**The cure is a version bump, never a patch.** `remediate` is not called on this path. The fixed
version is copied from OSV by code — it is the one string an operator acts on directly, so no model
goes near it — and `vpcopilot pr` reports the upgrade and opens nothing:

```
advisory: upgrade aiohttp to 3.9.2 — no PR to open (the fix is upstream, not in this repo)
```

Because no cure PR exists, the band-aid is never auto-retired and **`reconcile` escalates it at TTL
expiry**. That is deliberate: someone still has to ship the upgrade.

Three things about OSV worth knowing, each found by querying it:

- Asking for a **CVE id often returns the git-range record** — no package, and `fixed` values that
are commit SHAs. The installable version lives on the GHSA/PYSEC alias, so the client follows
aliases. Without that, `CVE-2024-23334` recommends "upgrade to 24a6d649…".
- When there genuinely is no released fix, it says so rather than offering a commit.
- `summary` is often empty and OS-level CVEs have no package at all; the prose in `details` is the
real payload, and the CPE is the fallback identity.

Set `VPCOPILOT_ADVISORY_CACHE=<dir>` to cache advisories on disk so a demo does not depend on the
network.

## 4. Apply a band-aid (mutates XC — gated + reversible)
```sh
vpcopilot apply --from-scan out/policies/<artifact>.json --lb <lb> --url <host> --dry-run # preview
Expand Down
76 changes: 76 additions & 0 deletions src/vpcopilot/agents/resolve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Resolve agent — turn a security advisory into an exploitation profile, or decline.

The facts (package, affected range, fixed version, CVSS, CWE) are fetched from OSV by code and
handed to this agent already assembled; it never touches the network and never produces a version
number. What it contributes is the one thing OSV does not carry: **what this vulnerability looks
like in an HTTP request** — which paths, which parameters, which headers. That is the input the
existing triage and generate stages need in order to propose a virtual patch.

The most important thing this agent does is refuse. A great many advisories cannot be virtually
patched at a load balancer — a deserialization bug reachable only from a local file, a malicious
build-time dependency, a memory-corruption issue with no request signature. For those the honest
answer is `network_observable=false`, and the pipeline routes them to `no_bandaid` with the
residual risk stated. An agent that obligingly invents a plausible path for every CVE would make
the whole input path worse than useless: it would produce confident band-aids that block nothing
and hide vulnerabilities behind a green check."""
from __future__ import annotations

import json

from ..harness import Harness
from ..schemas import ExploitationProfile

SYSTEM = """You are a vulnerability analyst. Given a security advisory, describe how the
vulnerability manifests IN AN HTTP REQUEST — or state plainly that it cannot be seen in one.

Set network_observable=true ONLY when the advisory describes something a proxy could identify
in a request: a specific URL path or pattern, a parameter, a header, a body shape, a method.
Then fill paths / http_methods / parameters / headers / example_requests with what the
advisory ACTUALLY SUPPORTS.

Set network_observable=false — and this is a correct, expected, frequent answer — when:
- the advisory describes the flaw only in terms of internal functions, classes or config
- exploitation needs local file access, a malicious package at build time, or a crafted file
- it is memory corruption, a crypto weakness, or a denial of service with no request signature
- the text is too vague to name a path, parameter or header
- you are simply not sure

HARD PROHIBITIONS. Violating any of these makes the output actively harmful:
- Do NOT invent a path, parameter or header the advisory does not name or clearly imply.
- Do NOT infer an exploitation pattern from what the package generally does. "It is a web
framework, so probably /admin" is exactly the reasoning that produces a band-aid which
blocks nothing while a real vulnerability stays open.
- Do NOT state or guess version numbers. Those are supplied to you and are not yours to edit.
- Do NOT choose a mitigating control. Never say WAF, service policy, rate limit or schema —
a later stage decides that. Describe the exploit only.
- Do NOT fabricate a file path, line number or code snippet. There is no source code here.

`reason` is required and must justify your network_observable verdict by pointing at what the
advisory does or does not say. "Not enough information" with nothing further is not acceptable;
say WHAT is missing.

`confidence` is your calibrated confidence in the profile: 0.9+ = the advisory names the request
shape explicitly; ~0.5 = you inferred it from a clear description; <0.3 = weak. When
network_observable is false, confidence expresses how sure you are that it CANNOT be observed.

Paths are app-relative and contain no scheme or host (e.g. /static/../../etc/passwd)."""


def run(h: Harness, advisory: dict) -> ExploitationProfile:
"""`advisory` is the normalized OSV record from `inputs.osv.resolve` — already fetched, so the
agent reasons over facts rather than retrieving them."""
facts = {k: advisory.get(k) for k in
("id", "aliases", "summary", "details", "cwe_ids", "cvss", "affected", "references")}
user = (
f"ADVISORY (from OSV.dev, verbatim):\n{json.dumps(facts, indent=2)}\n\n"
"Describe how this is exploited over HTTP, or state that it cannot be observed in a "
"request. Remember that declining is a correct answer."
)
prof = h.run("resolve", SYSTEM, user, ExploitationProfile)
prof.advisory_id = advisory.get("id") or prof.advisory_id # authoritative, as probe.py does
if not prof.network_observable:
# Belt and braces: the prompt forbids it, but a model that says "cannot be observed" and
# then lists paths anyway must not have those paths reach `generate`.
prof.paths, prof.http_methods = [], []
prof.parameters, prof.headers, prof.example_requests = [], [], []
return prof
2 changes: 1 addition & 1 deletion src/vpcopilot/bench_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from pathlib import Path

SEV = ("critical", "high", "medium", "low")
AGENTS = ("discover", "verify", "triage", "generate", "remediate", "probe", "refine")
AGENTS = ("resolve", "discover", "verify", "triage", "generate", "remediate", "probe", "refine")


def _rj(out_dir: str, name: str, default):
Expand Down
13 changes: 10 additions & 3 deletions src/vpcopilot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ def _root(

@app.command()
def scan(
repo: str = typer.Argument(..., help="path to the target application repo"),
repo: str = typer.Argument(None, help="path to the target application repo (omit when using --cve)"),
cve: str = typer.Option(None, "--cve", help="scan a security advisory instead of a repo: CVE-YYYY-NNNNN, GHSA-xxxx-xxxx-xxxx, PYSEC-YYYY-NN, GO-… or RUSTSEC-…"),
out: str = typer.Option("out", help="output directory for findings/policies/PRs"),
config: str = typer.Option(None, "--config", help="path to agents.yaml"),
min_confidence: float = typer.Option(0.5, "--min-confidence", help="drop verified findings below this confidence"),
Expand All @@ -45,12 +46,18 @@ def scan(
help="also draft the code-fix PRs (default: on; env VPCOPILOT_SCAN_REMEDIATE=0 to default off). "
"--no-code-fixes = band-aids only, saves ~half the tokens (use for band-aid benchmarks)"),
):
"""Discover -> verify -> triage -> generate policies + code-fix PRs (read-only)."""
"""Discover -> verify -> triage -> generate policies + code-fix PRs (read-only).

With --cve the input is a security advisory instead of a repo: the advisory is resolved from
OSV.dev, an agent derives its HTTP exploitation profile (or says there isn't one), and the
result enters the same triage and generate stages."""
if bool(repo) == bool(cve):
raise typer.BadParameter("pass a repo path or --cve, not " + ("both" if repo else "neither"))
if code_fixes is None: # match the console default (app.py /api/defaults) so headless == UI
code_fixes = os.environ.get("VPCOPILOT_SCAN_REMEDIATE", "1").lower() not in ("0", "false", "no")
summary = run_pipeline(repo, out_dir=out, config_path=config, min_confidence=min_confidence,
concurrency=concurrency, max_files=max_files, max_bytes=max_bytes,
draft_code_fixes=code_fixes,
draft_code_fixes=code_fixes, advisory=cve,
log=lambda m: rprint(f"[dim]{m}[/dim]"))
rprint(Panel.fit(
"\n".join(f"[bold]{k}[/bold]: {v}" for k, v in summary.items()),
Expand Down
2 changes: 1 addition & 1 deletion src/vpcopilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

# Every agent in the pipeline, in lifecycle order. Recorded per run so an audit export can say which
# model produced each finding, band-aid and cure.
AGENT_NAMES = ("discover", "verify", "triage", "generate", "remediate", "probe", "refine")
AGENT_NAMES = ("resolve", "discover", "verify", "triage", "generate", "remediate", "probe", "refine")


@dataclass
Expand Down
Loading
Loading