Skip to content

K2: scan a pull request's diff and comment the band-aid - #16

Merged
henleda merged 2 commits into
mainfrom
feat-k2-github-action
Jul 30, 2026
Merged

K2: scan a pull request's diff and comment the band-aid#16
henleda merged 2 commits into
mainfrom
feat-k2-github-action

Conversation

@henleda

@henleda henleda commented Jul 30, 2026

Copy link
Copy Markdown
Owner

The shift left: a developer sees the F5 XC virtual patch that would hold their new hole closed in the review where they introduced it.

.github/actions/vpcopilot-scan/action.yml   the action
.github/workflows/pr-review.yml             an example workflow
vpcopilot ci-review                          the CLI it shells out to
docs/CI.md                                   the reference

Acceptance, as met

One comment carrying the policy ✅ · a PR with nothing above the threshold posts nothing ✅ · 76 s on the Nimbus repo against a three-minute budget ✅ · never writes to XC ✅, structurally.

Live: a deliberately vulnerable /api/refund route → three findings (critical negative-amount, critical SQLi, high missing-authorization) and four policies across service_policy, waf, malicious_user, rate_limit.

The acceptance criterion contradicted itself

It asked for one comment carrying the policy and the simulation result while also requiring never writes to XC from CI. Both cannot hold: G2's blast-radius measurement creates a throwaway policy, attaches it to a load balancer, replays traffic through it and deletes it — three tenant writes — and there is no offline evaluator, because G1 was deliberately deferred.

So a would-block count is not computed in CI. The comment reports blast radius only from a real tenant run's simulation.json (passed via the simulation-json input), and otherwise says so plainly and names what it would take. Neither silence nor 0% would do: both read as measured and safe. Same precedent as G2's own rewritten criterion, G4's reproducibility, and I2's conflict criterion.

Never writes to XC, by construction

ci.py imports no xc, no apply, no refiner, no simulate — there is no code path from CI to a load balancer, pinned by a test that reads the module's own source, which is the only version of that guarantee that survives someone adding a convenient import later. The action declares no XC inputs, and ci-review says so out loud if it finds an XC credential in its environment.

The defect that would have shipped a silent all-clear

git diff --name-only answers relative to the repository root; collect_files matches relative to repo-path; this project's app fixture lives eight levels down. Compared directly they never match — zero files scanned, no findings, PR told it is clean. Catching it means noticing the absence of an error. rebase_onto() translates the paths and returns a count of changed files outside the scanned directory, which the comment discloses.

Adversarial review before shipping

21 raised across four dimensions; 12 verified — 2 confirmed, 10 refuted, again mostly because they had already been fixed while the review ran. Almost every real finding was one shape: a failure rendering as an all-clear.

  • Refuted candidates were being reported as findings. findings.json is the discover contract — every candidate, including ones verify refuted — and the filter compared that set against itself, so it was a no-op. Now built from findings that have a triage decision. Showing developers refuted false positives with a band-aid attached is the fastest way to get a bot ignored.
  • A diff with nothing scannable produced an empty comment, so no comment file was written and the step summary fell through to "no findings at or above the threshold" — a clean bill of health for a diff never analysed, and reachable by default (the workflow triggers on any **/*.py change while scanning one fixture directory).
  • Truncating an oversized comment deleted exactly the disclosures — GitHub caps a body at 65,536 chars, and cutting from the end removed the "N files were not scanned" lines. The finding list is cut instead.
  • A crash exited 1, which the action reads as "findings reported" — so a review that never completed looked like a completed one. Now exit 2, and the summary consults the step outcome.
  • Three more ways a meaningless blast-radius number read as safe, each now carried through from G2: enforcement not confirmed (G2's own first live defect — an unenforced policy blocked 0 of 200 and looked harmless), zero requests evaluated, and a sample replayed against XC access logs which carry no request bodies, so a body_matcher policy matches nothing and scores a perfect 0%.
  • Plus: git quotes non-ASCII paths ("caf\303\251.py"), so the suffix read as .py" and the file vanished — neither scanned nor counted as outside (-z fixes it); base: origin/main became origin/origin/main; action inputs reached bash by ${{ }} text substitution rather than the environment; --min-severity was unvalidated and silently fell through to high; the header's denominator used the post-filter count, so a fourteen-file diff read as "1 of 1".

Notes for review

  • The fixture is in bench/fixtures/ci/, deliberately not in the Nimbus app. bench scans bench/fixtures/nimbus-vuln-lab/app/src/app/api against answer_key.yaml, and a vulnerable route there produces findings in neither expected nor bonus — which bench.py scores as noise, silently degrading G4's committed scorecard and BASELINE.md. A benchmark regression caused by a test fixture. Pinned by a test, with the reason in the fixture README.
  • pull_request, never pull_request_target — the latter runs trusted workflow code with secrets against untrusted head code. The consequence, that fork PRs get no review, is documented rather than discovered.
  • fail-on-findings defaults to false. The comment is the deliverable; a red check on a finding the team has accepted is how a useful bot gets switched off.
  • The Nimbus fixture tree is byte-identical to main (verified), so no benchmark numbers move.

Verification

  • 735 tests pass offline, ruff clean, coverage 79%; ci.py at 87%.
  • Live end-to-end twice, before and after the review fixes: 77 s and 76 s, exit 1, three findings, blast radius honestly unmeasured, out-of-scope files disclosed.

Roadmap now 12 done / 5 open.

🤖 Generated with Claude Code

henleda and others added 2 commits July 29, 2026 21:41
`.github/actions/vpcopilot-scan/` (composite action), `.github/workflows/pr-review.yml`,
`src/vpcopilot/ci.py`, `vpcopilot ci-review`, and `docs/CI.md`. Scans a PR's diff
against the merge base and leaves one comment carrying, per finding above a
severity threshold, the F5 XC control triage routed it to and the generated
policy name. A PR with nothing above the threshold posts nothing.

Measured live on the Nimbus fixture: a deliberately vulnerable /api/refund route
produced three findings (critical negative-amount, critical SQLi, high missing
authorization) and four policies across service_policy, waf, malicious_user and
rate_limit, in 76 s against a three-minute budget.

The acceptance criterion contradicted itself and this resolves it honestly. It
asked for one comment carrying the policy AND the simulation result while also
requiring never writes to XC from CI. G2's blast-radius measurement creates a
throwaway policy, attaches it to a load balancer, replays traffic through it and
deletes it — three tenant writes — and G1, an offline evaluator, was deliberately
deferred. So a would-block count is not computed in CI. The comment reports blast
radius only from a real tenant run's simulation.json, and otherwise says that no
measurement was made and what it would take. Neither silence nor 0% would do:
both read as measured-and-safe.

Never writes to XC by construction: ci.py imports no xc, apply, refiner or
simulate, pinned by a test that reads the module's own source. The action declares
no XC inputs.

The defect that would have shipped a silent all-clear: `git diff` answers relative
to the repository root while the scanner matches relative to `repo-path`, and this
project's app fixture lives eight levels down — so nothing matched, nothing was
scanned, and the PR was told it was clean. `rebase_onto` translates the paths and
the comment discloses what fell outside.

Additive plumbing only: `collect_files(..., only=)` and
`run_pipeline(..., only_files=)` filter the existing walk, so the existing call
path is unchanged and a changed file that is vendored, unsupported or oversized is
still excluded and reported exactly as in a full scan.

735 tests pass offline, ruff clean, coverage 79%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fix the action manifest

Two defects the PR's own CI found that a local run could not.

1. The moved blast-radius gate needed XC credentials to refuse. Both apply paths
   constructed XC() before reaching it, and XC.__init__ raises when XC_API_URL /
   XC_API_TOKEN are unset — so on a runner without credentials "this policy is too
   broad" came back as "XC_API_URL not set". The test passed locally only because
   the developer's .env happened to have them, which is a test depending on
   developer-local state. Every refusal that needs no tenant now precedes XC(),
   which is also better behaviour: an over-broad policy should be refused whether
   or not a tenant is reachable. Pinned by tests that unset the variables.

2. A comment inside the action broke the action. The runner parses Actions
   expression syntax anywhere in a run block, comments included, so a comment
   spelling out an empty expression while explaining the script-injection hazard
   failed the manifest to load with "An expression was expected". pyyaml cannot
   see it and the guard test skipped comment lines — exactly where the text was.
   The test no longer skips them.

Also, found while fixing those: the repo has no secrets configured, so
secrets.ANTHROPIC_API_KEY is empty, and `required: true` on an action input is not
enforced against an empty value. The action would have scanned nothing and read as
clean — an open review finding. It now fails loudly and names the cause, the
workflow skips with an explicit "not a clean bill of health" summary rather than
going permanently red, and the paths filter is scoped to the directory the action
actually scans instead of every code file in the repo.

738 tests pass with XC_API_URL/XC_API_TOKEN/XC_NAMESPACE unset and no .env.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@henleda
henleda merged commit b581f2b into main Jul 30, 2026
5 checks passed
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