Skip to content

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-paths that 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:

Terminal window
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.json

base-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.json

The 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:

Terminal window
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:

Terminal window
trivy plugin install github.com/luiacuaniello/perspectivegraph
trivy 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_request run none - so a fork PR cannot be analysed and fails closed as unknown. Do not reach for pull_request_target to 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 on push to 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-fail and 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.

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:

Terminal window
GITHUB_TOKEN=ghp_… REPO_ALLOWLIST=acme/payments-api make run-backend

Then 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.