Skip to content

Add install vulnerability gate#99

Draft
juangaitanv wants to merge 29 commits into
mainfrom
install-vuln-gate
Draft

Add install vulnerability gate#99
juangaitanv wants to merge 29 commits into
mainfrom
install-vuln-gate

Conversation

@juangaitanv

Copy link
Copy Markdown
Contributor

Summary

Adds install-time vulnerability gating for package-manager installs.

  • Adds corgea pip|npm|yarn|pnpm|uv install wrappers that parse requested targets, enforce recency thresholds, and call the vuln API before executing package manager commands.
  • Adds dry-run tree resolution for the full would-install set, including npm and Python registry resolution plus lock/report parsing.
  • Adds the vuln-api client and local vuln-api-stub fixture server for clean, vulnerable, malware, unknown, and advisory responses.
  • Blocks vulnerable, malware, unknown, and unverifiable verdicts before install, with safe-version guidance when fix data is available.
  • Extends CLI tests, fixtures, and the Corgea skill docs for install gating, tree resolution, verdict handling, and remediation.

Test plan

  • git push -u origin install-vuln-gate
  • ./harness check completed clippy fix, format, and strict clippy successfully
  • ./harness check passes end-to-end
    • Current local run fails in authorize::tests::find_available_port_skips_ports_that_are_in_use with attempt to add with overflow at src/authorize.rs:97, outside this PR diff.
  • Added unit/integration coverage for install parsing, dry-run tree findings, vuln API verdicts, stub fixtures, and remediation suggestions

Port the vuln-api client from #89 onto main and reconcile its types
against the authoritative Cloudflare Worker contract.

- src/vuln_api/mod.rs: standalone reqwest::blocking client for
  /v1/.../check and /v1/advisories/:id with per-call auth (JWT->Bearer,
  else CORGEA-TOKEN), 404->clean, 401/403/429/5xx handling, a single
  429 retry honoring Retry-After, and confused-deputy/identity guards.
- Fix AdvisoryResponse field mapping: the server emits 'summary' and
  'severity', so title/severity_level now carry #[serde(rename)].
  VulnCheckResponse/VulnMatch already match toCheckMatch, unchanged.
- Wire the module into the library crate (pub mod vuln_api) with a
  private 'mod log;' facade so crate::log::debug resolves there.
- Add five committed fixtures (clean, unknown, vulnerable CVE, malware,
  advisory detail) built to the server serialization, with
  deserialization tests proving every /check variant the gate depends on.
Run package managers through a Corgea gate: install commands with named
targets are resolved against the public registry first, and a resolved
version published within --threshold (default 2d) blocks the install
(exit 1) before the package manager runs. --no-fail demotes the block
to a warning; --json emits a per-target report. Non-install
subcommands, requirements files, bare installs, and unverifiable specs
(git/URL/path) pass through with a printed note and the package
manager's own exit code. No token required.

Ported from PR #89 (cursor/dependency-freshness-check-89d1), slimmed
to the named-target path: src/precheck (arg parsing + gate + exec) and
src/verify_deps (threshold parsing + npm/PyPI resolvers) live in the
library crate with no lockfile verification, check-only mode, or
terminal-color dependency. Resolution errors warn but never block;
fail-closed semantics arrive with the vuln-api verdict chunk.

Also lands Config.vuln_api_url + get_vuln_api_url() (env -> config ->
default) for the upcoming vuln-api chunk, hermetic integration tests
driven by CORGEA_PYPI_REGISTRY/CORGEA_NPM_REGISTRY stub overrides, a
shared tests/common harness, and SKILL.md docs for the new commands.
Wire vuln_api::check_package_version into the install wrappers: after
the recency pass, each resolved name@version gets a verdict. is_vulnerable
blocks; any API failure (network/timeout/5xx/auth) blocks fail-closed as
unverifiable. --force proceeds despite all findings (still printed, manager
exit code propagated); --no-fail keeps demoting recency only. Tokenless
runs degrade to recency-only with a corgea login prompt. JSON output gains
per-result verdict objects, vulnerable/unverifiable counts, and a top-level
verdict_mode.

Ports the PR #89 vuln-api test stub into the crate (corgea::vuln_api_stub
plus a standalone vuln-api-stub bin) and pins the behavior with hermetic
e2e tests in tests/cli_verdict.rs.
Expand corgea pip|npm install from named-target verification to every
package the install would pull in: pip resolves via
'pip install --dry-run --quiet --report - --only-binary :all:' (the
--only-binary guard keeps sdist build backends from running before the
verdict, pypa/pip#13091); npm resolves in a throwaway temp dir via
'npm install --package-lock-only --ignore-scripts' and parses the
generated lockfile (upstream --dry-run --json emits only counts,
npm/cli#6558). Verdicts for the deduped tree + named union run through
a bounded std::thread::scope pool (--concurrency, default 8); fail-closed
and --force semantics extend to transitive findings unchanged.

When resolution is unavailable (yarn/pnpm/uv have no safe dry-run) or
fails, the gate verifies named targets only and prints a mandatory
"transitive dependencies not checked" warning; --json carries the same
fact in a new additive `tree` object (null in recency-only mode).

PyPI name matching now shares deps' PEP 503 normalizer via
PackageManager::normalize_name. The vuln-api test stub now sends
Connection: close — it serves one response per connection, and without
the header reqwest's pooled sockets raced the close once the gate began
making several requests per run.
On a flagged package, annotate each advisory line with its fix
(— fixed in X / — no fixed version known) and print a
'→ safe version: name@X' steer when every advisory has a fix —
single distinct value as-is, several distinct picked by lenient
semver, and no certification when any advisory lacks a fix or a
candidate doesn't parse. --json vulnerable verdicts carry a
'remediation' field (safe version or null), shared by named and
transitive entries via verdict_json.

Render-only (design D7, vuln-verdict spec 'Remediation steering'):
no client, flag, or blocking changes. normalize_for_semver widened
to pub(crate) for the cross-ecosystem version ordering. Covered by
7 safe_version unit tests + 4 hermetic e2e tests (cli_remediation).
git exports an absolute GIT_DIR to hooks in linked worktrees; tests
that spawn 'git init' in tempdirs inherited it and reinitialized the
shared gitdir (setting core.bare=true, breaking every checkout).
Unset GIT_* in cmd_pre_commit so the test suite is hermetic.
The install gate never consults GET /v1/advisories/:id — verdicts come
entirely from the package-check route. Drop AdvisoryResponse,
get_advisory, the stub's advisories map/route, and the fixture.
When `which::which("pip")` fails, exec resolution retries `pip3` — pip
is the one manager with a conventional alias. The missing-binary error
now names the binary and the fallback tried:

    error: 'pip' not found on PATH (also tried 'pip3')

Exit 127 unchanged. The post-resolution exec-failure message names the
resolved path instead of the requested binary so it stays accurate when
the fallback was taken.

New hermetic e2e suite (tests/cli_exec_fallback.rs): a controlled PATH
with only a fake pip3 runs the install through it; a PATH with neither
exits 127 with the message; npm's error names the binary without a
fallback hint.
Add a 'Testing the gate' subsection to the corgea skill: staging
vuln-api URL, known-vulnerable npm/PyPI targets with their fixes, a
verified copy-paste command with real observed output, and the
recent-CVEs-only caveat for the staging PyPI seed.
Zero-spec `corgea npm install` with a package.json and a token now runs
the existing tree pass: the lockfile-resolved set (0 named, N transitive)
is verdicted, a vulnerable lockfile blocks fail-closed, --force escapes,
and a resolution failure degrades to the named-only warning + exec as
before. Bare yarn/pnpm/uv install-shaped commands print one stderr line
(`note: bare '<pm> <sub>' is not gated …`) instead of silently running
unchecked. The gated report header no longer renders a trailing space
when the arg list is empty. SKILL.md offline-inputs sentence rewritten
to match.
Tree-pass findings were all labeled (transitive), which misled when the
flagged package came from the user's own requirements file or was already
a direct dep of the project.

- TreePackage carries pip's per-item "requested" report flag (npm: false).
- New TreeOrigin on TreeOutcome: Requested / PreExisting / Transitive.
  Requested = pip-requested leftovers (-r files); PreExisting = npm
  leftovers named in the project package.json's direct deps (all four
  dep groups), read from cwd; Transitive otherwise.
- Text labels: (from requirements), (already in package.json),
  (transitive). JSON tree entries gain an "origin" field.
- PreExisting findings with an advertised fix get a hint:
  fix with: corgea npm install <name>@<fix> (advertised fix).
  advertised_fix() takes the max parseable fixed_version across matches,
  ignoring fixless matches — deliberately weaker than safe_version's
  certification and independent of it.

should_block_install semantics unchanged. New hermetic e2e coverage in
tests/cli_provenance.rs (fake PM + registry stub + vuln-api stub).
start_port + 50 panics in debug builds when the start port is above
65485 — the port-search test binds an ephemeral port and trips this
whenever the OS hands out one near u16::MAX. Saturate instead.
Re-verdict each proposed '→ safe version' against vuln-api through the
existing verdict_pool before any output. A clean re-check prints the
steer; a flagged one prints '→ advertised fix {v} is also flagged — no
safe version to suggest'; a failed re-check suppresses the steer quietly
and never moves counts or exit codes. JSON 'remediation' now emits only
on a Verified steer. Proposals dedup by normalized (name, version) and
requests fire only when a vulnerable verdict exists with a token
configured.
When every vulnerable finding sits in the resolved tree beyond the named
targets (and no named target is unverifiable), the refusal now says the
existing dependency tree is the problem instead of implying the package
the user typed is at fault. The text summary line gains a
"(N from existing tree)" parenthetical on the vulnerable/unverifiable
counts when the tree contributed findings. Messaging only —
should_block_install semantics are unchanged.
…d outage noise

- Tokenless note becomes a warning that states the consequence:
  known-vulnerable packages will NOT be blocked (recency-only).
- verdict_pool prints 'checking N packages against Corgea vuln-api…'
  to stderr when more than 8 jobs run, so big tree passes don't look hung.
- print_text collapses >3 unverifiable findings sharing an error-prefix
  (the vuln-api outage case) into one line; counts and exit codes unchanged.
resolve_tree now returns TreeResolution{packages, audit}: for npm, the
dry-run temp dir moves into a detached thread that runs `npm audit --json
--package-lock-only` (5s deadline, kill on timeout; stdout parsed
regardless of exit code — audit exits 1 when it finds advisories).
run_tree_pass collects the summary via recv_timeout(2s) after the
verdict pool so the two overlap; any failure is a silent skip.

The signal is supplementary only: should_block_install never consults
it. When total>0 a note prints to stderr; --json carries an `npm_audit`
object (or null) in the tree arm. CORGEA_NO_NPM_AUDIT=1 disables.
…tall-vuln-gate

# Conflicts:
#	src/precheck/mod.rs
…ntegration

# Conflicts:
#	src/precheck/mod.rs
…vg-integration

# Conflicts:
#	src/precheck/mod.rs
…plumbing

Post-merge cleanup across the eight install-gate units:

- Consolidate safe_version/advertised_fix into one highest_fix core
  (strict vs lenient parsing); the pre-existing fix hint's hedge now
  follows the steer re-check: Verified drops '(advertised fix)',
  Rejected suppresses the hint, Unverified keeps the hedge.
- The existing-tree refusal only fires when every vulnerable finding
  predates the command: Requested (pip -r) findings and transitives
  pulled in by named targets get the generic refusal. Summary
  parenthetical reworded to 'from resolved tree'.
- CORGEA_NO_NPM_AUDIT moves out of tree.rs into PrecheckOptions.npm_audit,
  read in main like the registry overrides; env semantics unchanged.
- Audit collect window tightened to 1s: the warn-only signal never
  changes the outcome, so a finished gate shouldn't stall for it.
Two fixes from the PR #108 review:

- refusal_blames_existing_tree filtered tree findings to Vulnerable
  before the origin test, but should_block_install refuses on
  Unverifiable too — a command-added unverifiable transitive alongside
  a pre-existing vulnerable dep printed 'none were added by this
  command'. Every blocking tree finding now passes the origin test.

- The audit thread was detached with the Child trapped inside it; a
  fast gate exit orphaned a slow 'npm audit' past the CLI's lifetime.
  spawn_audit now returns an AuditHandle whose collect() does the 1s
  recv, then kills whatever is left in the shared child slot and joins
  the thread — no orphan, TempDir drops deterministically, happy-path
  latency unchanged. The hang test's fake now execs its sleep (a
  grandchild would dodge the kill) and asserts the child is dead after
  the CLI exits.
A requirements-only install (pip install -r reqs.txt) has no named
outcomes — exactly like a bare install — so outcomes.is_empty()
wrongly let a vulnerable transitive of a clean requirements entry
blame the existing tree. PrecheckReport now carries bare_install
(no CLI targets AND no requirements files), set from the parsed
command, and the Transitive arm tests that instead of inferring
from outcomes. Regressions: requirements-only e2e + full
origin/named/bare unit matrix.
The bin existed for live e2e dogfooding, now done. Every consumer in the
repo (7 integration test files + precheck unit tests) uses the in-process
vuln_api_stub::spawn_with_statuses, so the bin-only surface goes with it:
the fixtures.rs file loader, spawn_from_file, VulnApiStub::block, and the
StubFixtures/spawn_on_port indirection (the ephemeral-port bind now lives
directly in spawn_with_statuses).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant