Attack paths in the pull request
Part of the PerspectiveGraph manual. The merge gate - GitHub Action, CLI and Trivy plugin - and what a developer sees on the pull request.
The wedge: attack paths in your pull request
Section titled “The wedge: attack paths in your pull request”This is the one to try first - everything else exists to make it accurate. A finding only changes behavior if it reaches the person who can fix it, where they already work, so PerspectiveGraph plugs straight into the PR:
- A PR comment on the change that sits on a critical path (the kill chain + the one-edge fixes).
- A merge-gate status - a GitHub commit status
perspectivegraph/attack-pathsthat goes red when the change opens an internet→sensitive-asset path and green once it no longer does. Make it a required status check in branch protection and it blocks the merge - shift-left, not a comment you can scroll past. - Remediation-as-PR -
POST /remediation/pr(or the Open fix PR button on a path) branches off the default branch, commits the generated fix (NetworkPolicy / Terraform / IAM policy), and opens a pull request. The fix arrives as something you review and merge, not copy-paste.
One GITHUB_TOKEN drives all three (dry-run - logged, not posted - until it’s set;
GitHub Enterprise via GITHUB_API_URL). The same path context the analyzer already
carries (repo_slug / pr_number / commit_sha) is what routes each action to the
right PR and commit.
The comment and the status count what the merge gate counts (below): the routes a change opened or made likelier, not every route through an asset it touched. They run after the report has landed, so the comparison is between analysis passes. A route between an entry and a sensitive asset that is new, or likelier, since the previous pass belongs to the pull-request commits on it that arrived in between - the previous pass is the estate the change found. A route that was already there gets no comment and keeps the status out of it. When a change makes a route red, the status says how many routes were already there, and the comment says whether the change opened the route or made it likelier, with both figures. Every commit on a route is judged, not only the first one found on it.
A route that appears through a commit’s assets after it arrived belongs to the pull request that arrived with it: a second request putting a load balancer in front of a rescanned image is the second request’s doing, not the rescan’s. If no pull request arrived with it - the rest of a report too large to land at once, a change made outside any pull request - it counts against the commits already on it, and the status says “since this change arrived” rather than claiming the change opened it.
This state is kept in memory on every replica, so a new leader has it. A restart loses it, and
a commit already in the graph when the engine starts is judged by the per-commit rule. The
status says so (“in the graph before the engine was watching”), as the gate does for a commit
the engine already holds. PR_ATTRIBUTION=commit (Helm prAttribution: commit) keeps the
rule before 1.22: every route through the commit counts.
REPO_ALLOWLIST is required for any of it to write. That routing context is a node
property, and node properties arrive on the ingest path - which every scanner holding
the shared HMAC key can reach. Without a bound, one planted node redirects a write to
any repository the token can reach, and a success commit status in a repository where
this check is required opens a merge gate rather than closing one. So the operator
names the destinations, as exact slugs or an owner wildcard:
REPO_ALLOWLIST=acme/payments-api,acme/*Empty means every real write is refused (dry-run still logs what it would do), and a refusal is logged with the slug it declined.
Everything below - the connectors, topology discovery, scoring, runtime confirmation, the dashboard - exists so that red check is true: a real, reachable path, not noise.
The merge gate: GitHub Action, CLI and Trivy plugin
Section titled “The merge gate: GitHub Action, CLI and Trivy plugin”What counts against the change. The gate applies this pull request’s report to a copy of
the estate - through the same normalizer the ingest path runs - and compares the critical paths
of the copy with the estate’s own. A route between an entry and a sensitive asset that did not
exist before is one the change opens; one that became likelier, it worsens. Those count.
A route that was there before does not, even when it runs through an asset the change touches -
the rescanned image, the re-rendered deployment - and the verdict reports how many there were
(preexisting) rather than hiding them. Nothing is written into the live graph.
Before 1.22 the gate counted every route through an asset stamped with the commit, and so
blocked a change for routes it did not cause: in the demo lab, nine where the change opened two.
That rule is still there, by name: attribution: commit (CLI -attribution commit). The gate
also falls back to it by itself - and says so - when it has no report to compare (poll-only), or
the engine predates the comparison. And when the engine already holds the commit (persist on
an earlier run, or another step posting the same scan to the webhook), the comparison cannot
tell its routes from older ones, so routes through it count per commit: conservative on purpose,
it can block a change the comparison would pass, never the reverse.
When none of the change’s assets can be reached from an attack seed at all, a clean verdict says so. That is fine for a service that is not deployed; for one that is, the usual cause is a scan naming the image differently from the workload that runs it.
Local mode needs no deployment. The runner reads your estate read-only, applies this pull request’s scan, and answers in-process with the same engine:
- uses: luiacuaniello/perspectivegraph@v1 with: mode: local aws-region: eu-west-1 # read-only; give the job an OIDC role with SecurityAudit report: trivy.json base-reports: | # the scan of what runs now - the base branch trivy=trivy-base.jsonbase-reports is what makes the comparison fair. Without it the estate knows nothing of the
scanned image’s findings, so every route through them counts as the change’s - the same answer
the per-commit rule gives.
An estate is not optional, and that is the point: without one there are no attack paths, only
a flat list of findings - the thing this replaces. If you collect your estate on its own
schedule, pass estate: estate.json (what perspectivegraph awscollect -json writes) instead
of aws-region.
Server mode points at a running engine, which keeps the graph across pull requests, plus
triage, history and the dashboard. The comparison is an API call (POST /gate/impact, the same
query parameters as the ingest webhook, the report as the body), so it takes the API token:
- uses: luiacuaniello/perspectivegraph@v1 with: api: https://perspectivegraph.internal token: ${{ secrets.PG_API_TOKEN }} report: trivy.jsonThe baseline is the graph as it stands - what runs now, as your main-branch pipeline and
connectors last described it. To also record the pull request’s scan in the live graph (the
engine’s own PR comments and commit status are driven by what is ingested), add
persist: true with ingest and hmac-secret; the verdict is computed first, without it. A
proxy in front of the API must pass /gate/ and let a report through, up to the 32 MiB the
backend accepts, as for /ingest. The bundled dashboard does, and so does the Helm ingress: it
raises the nginx-based controllers’ 1 MiB default (ingress.maxBodySize, rendered as the
ingress-nginx and F5 NGINX annotations; one you set yourself wins). Any other proxy needs the
same.
Both modes run the same comparison (package impact), the same normalizer, the same pathfinder
and the same triage priority. Under attribution: commit they return the same per-commit verdict
- a test asserts they agree path-for-path on identical input.
The scan is not the only thing a pull request can send: a rendered manifest set
(helm template, kustomize build) as the report with source: k8s is compared the same way,
so a change that publishes a Service fails the check the same way a vulnerable dependency does -
and one that re-renders every manifest unchanged passes. That matters because manifests are how
most routes actually open.
It has three outcomes, and the third is the point. Every two-state gate gives a pipeline
whose scanner output never arrived the same green tick as one that is genuinely clean. Here
that is unknown, and it fails the build by default:
| Verdict | Exit | Meaning |
|---|---|---|
clean |
0 | The engine analysed this change: it opens or worsens no critical path |
blocked |
1 | It opens or worsens critical attack paths - the check names each, and why |
unknown |
2 | Nobody analysed it. The scan, the ingest or the SHA is wrong |
Set allow-unknown: true while you roll the gate out. Leaving it on afterwards turns a broken
ingest back into a green check, which is the one thing this gate is for.
Without GitHub Actions, the action is a thin wrapper over one command:
perspectivegraph gate -local -aws-region eu-west-1 \ -report trivy.json -slug owner/name -sha "$COMMIT_SHA"As a Trivy plugin, the answer arrives where the CVE list does:
trivy plugin install github.com/luiacuaniello/perspectivegraphtrivy image -f json myapp:pr-42 | trivy perspectivegraph gate -local -aws-region eu-west-1 -report -Run like that, it keeps the gate’s exit codes. As an output plugin
(-o plugin=perspectivegraph --output-plugin-arg "gate ...") it works the same way, but Trivy
folds every verdict other than clean into exit 1.
Two things to settle before wiring it up.
- Fork pull requests. The gate needs secrets, and GitHub gives a fork’s
pull_requestrun none - so a fork PR cannot be analysed and fails closed asunknown. Do not reach forpull_request_targetto work around it: that event runs with your secrets against the contributor’s code, and in local mode your secrets are cloud credentials. Run the gate onpushto your own branches instead, and let fork PRs go without it. - Public repositories. When it blocks, the check prints the route - real asset names, the
CVE linking them, the sensitive asset at the end - into the job log and summary, which on a
public repository are public. Use
soft-failand post the detail somewhere private, or keep the gate on a private repository.
Full input reference in action.yml. The comparison is POST /gate/impact;
the per-commit rule’s query is prVerdict in the API schema, unchanged
for the gates built on it.
Developer feedback on the PR
Section titled “Developer feedback on the PR”When a scan is fed with PR context (the make seed demo passes
?slug=acme/payments-api&pr=42), the action layer comments on the originating
pull request - but only for findings on a verified attack path, with the
path diagram and a remediation hint. It upserts a single comment per path
(idempotent across the analyzer’s repeated passes). Without a GITHUB_TOKEN it
runs in dry-run, logging exactly what it would post. Set the token and the
allowlist that bounds where it may write (see above) to go live:
GITHUB_TOKEN=ghp_… REPO_ALLOWLIST=acme/payments-api make run-backendThen open the dashboard at http://localhost:5173 and the GraphQL playground at
http://localhost:8080/graphql. Prefer Postman? Import
docs/perspectivegraph.postman_collection.json -
health checks, every ingest webhook (with the demo payloads embedded) and all
GraphQL queries, ready to run.
Pointing it at a real environment (your own scanners, not the demo seed)?
Follow the onboarding runbook - per-source curl/CI
snippets, the identifier-correlation helper, and a “no paths?” troubleshooting
guide.