Most CVEs reported against your dependencies are in code paths your application never calls. Reachability analysis — static program analysis that walks from your entry points through the dependency graph to see which vulnerable functions are actually invoked — routinely filters 70 to 90 percent of raw findings. This tutorial shows you how to run reachability analysis on every PR using Safeguard's scan_repository command, annotate the PR with only exploitable findings, gate the merge on critical reachable vulnerabilities, and keep the scan under 3 minutes so it does not slow down development. Prerequisites: a GitHub repo connected to Safeguard, gh CLI 2.40+, and 30 minutes.
What is reachability analysis?
Reachability analysis constructs a call graph from your application's entry points (main, HTTP handlers, CLI commands) and determines whether a vulnerable function in a dependency is actually invoked along any path. Three outcomes: reachable, unreachable, or unknown (dynamic dispatch the analyzer cannot resolve).
A CVE-rated-critical that lives in a code path gated by a feature flag you never enable is effectively not a risk. A CVE-rated-medium that sits inside your most-called HTTP handler is the one to fix first. Reachability reorders your backlog around actual risk.
How does it change PR triage?
Without reachability, a typical Node.js service PR surfaces 40 to 80 vulnerable-dependency findings per scan. With reachability, most PRs have zero reachable critical findings, and a single one demands immediate attention. That signal-to-noise inversion is the point.
Reviewers stop ignoring security annotations because every annotation now means something. Dismissal rates at one company we measured dropped from 94% to 12% after enabling reachability.
How do I run reachability in a PR job?
Call safeguard scan with --reachability=true in a GitHub Actions job triggered on pull_request. The CLI walks the dependency tree, builds the call graph, and emits SARIF output GitHub can annotate inline.
name: Reachability check
on: [pull_request]
jobs:
scan:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
security-events: write
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: safeguard/scan-action@v2
with:
api-token: ${{ secrets.SAFEGUARD_TOKEN }}
reachability: true
output-format: sarif
fail-on: reachable-critical,reachable-high
The action uploads SARIF to GitHub Code Scanning, so reachable findings appear inline in the PR diff and in the Security tab. Unreachable findings are included but suppressed by default.
How do I read the scan output?
The CLI prints a compact summary plus a SARIF file. Each finding includes reachable: true|false|unknown, the call path, and remediation advice.
safeguard scan --path . --reachability=true --format=json | \
jq '.findings[] | select(.reachable == true) |
{ cve: .cve, severity: .severity, path: .call_path[0:3] }'
# {
# "cve": "CVE-2024-21538",
# "severity": "high",
# "path": ["handlers.Login", "auth.Validate", "crossspawn.parse"]
# }
The call_path shows the chain from your entry point into the vulnerable library function. Include it in the PR annotation so reviewers see exactly why the finding matters.
How do I gate merges on reachable findings?
Configure the Safeguard policy gate to fail the check run when any reachable critical or high finding is added by the PR. Baseline against main so existing debt does not block every PR.
- uses: safeguard/policy-gate-action@v2
with:
gate-name: pr-reachability
baseline-ref: refs/heads/main
fail-on: |
- reachable: true
severity: [critical, high]
delta: added
The delta: added clause is critical — without it, a PR that touches any file in a service with existing debt would always fail. You want to block new risk, not every PR.
How do I tune scan performance?
Cache the previous scan's call graph and only re-analyze changed files. A typical Go or Node.js service drops from 3 minutes cold to 25 seconds warm. Use the cache-key input on the scan action.
- uses: actions/cache@v4
with:
path: .safeguard/cache
key: safeguard-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: safeguard-${{ runner.os }}-
- uses: safeguard/scan-action@v2
with:
incremental: true
reachability: true
Incremental mode keys off the lockfile hash and the changed file list. A PR that only touches docs finishes the scan in under 10 seconds because nothing in the call graph changed.
How do I handle reachability-unknown cases?
Treat unknown as a configurable tier — some teams choose to fail on it, others to pass with a warning. Dynamic dispatch (reflection in Java, eval in Node, interface method calls in Go) is where analyzers lose precision.
safeguard scan --path . \
--reachability=true \
--treat-unknown-as=warn \
--format=junit --output=scan.xml
Start with warn for the first two weeks, then move to fail if your unknown rate is under 5%. For codebases heavy in reflection, stay at warn permanently and rely on severity filtering instead.
How Safeguard Helps
Reachability analysis is a core capability of Safeguard — not a bolt-on. Griffin AI runs incremental call-graph construction across JavaScript, TypeScript, Python, Go, and Java, annotating every CVE in your ingested SBOM with exact reachability status and the call path from your entry points. The platform's policy gates can require "zero reachable critical CVEs" as a merge condition with baseline-aware diffing, so you only block on new risk. SBOMs generated by Safeguard include the reachability metadata as CycloneDX extensions, making it portable to downstream consumers. Jira and Slack integrations route only reachable findings to developers, cutting triage time dramatically and rebuilding trust between security and engineering teams.