π SAST Triage Agent

You turned on a SAST scanner β Semgrep, CodeQL, SonarQube β got 400 findings, and turned it off. Most were false positives; nobody had time to check.
The vendors know it β AI triage now ships in their paid tiers at $25β30 per developer per month (Semgrep Teams, GitHub Code Security, Snyk): a 50-developer shop pays $15kβ18k a year, before the "contact sales" enterprise tier.
sast-triage does the same job without the seat license β MIT, one Go binary, bring your own model (local Ollama = $0, or your Claude API key). It does what a security analyst would: read the code behind each finding, trace the taint, decide if it's real β with cited evidence. After the first run, triage costs ~$0.
How it works
graph LR
subgraph T["sast-triage agent"]
B["cache lookup"] -->|hit| V["benign | exploitable | uncertain"]
B -->|miss| E["π€ LLM reads the code<br/>(read-only tools, bounded loop)"]
E --> V
end
A["findings.sarif<br/>(generated by your SAST scanner)"] --> B
V --> G["PR check β<br/>fails on new exploitable"]
V --> H["Security tab (+ Issues, opt-in)"]
V --> P["cache review PR"]
Safety bounds:
- Read-only tools β
read_file and grep_repo only; no writes, no exec
- Token & iteration budgets β 10 iterations / 60k tokens per finding by default, plus a 50-findings cap per run; the loop always terminates
- Three-valued verdicts β
benign requires cited file:line evidence; ambiguity or budget exhaustion β uncertain, never benign
- Cache invalidation on code change β a verdict expires the moment any line it cited changes
Quick Start
One job: your scanner produces findings.sarif, sast-triage reads the code behind each finding, and the PR fails only on new exploitable ones. Pick a model β or skip CI and run it straight from your terminal:
Claude β what this repo's own CI uses
Add an ANTHROPIC_API_KEY repo secret, then drop this into .github/workflows/triage.yml:
name: Triage
on: [pull_request]
permissions:
contents: read
jobs:
triage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# β produce findings.sarif with your scanner here (opengrep example below β¬)
- name: Triage β fail only on NEW exploitable findings
uses: alexpermiakov/sast-triage@v1 # or pin a commit SHA
with:
provider: anthropic
model: claude-sonnet-5
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
The action needs no setup step: it installs Go and builds the binary from the exact revision you pinned β the ref after @ is both the action and the code that runs.
Local model (OpenAI-compatible) β no API key, no cost, nothing leaves the runner
This path is built for your own on-premise runners: the model sits next to the code and nothing crosses the fence. No GPU runners handy? The same job also works on GitHub's stock ubuntu-latest, if the model is tiny enough for its CPU:
name: Triage
on: [pull_request]
permissions:
contents: read
jobs:
triage:
runs-on: ubuntu-latest # CPU-only demo; for real verdicts: [self-hosted, gpu] + a bigger model
services:
ollama:
image: ollama/ollama:latest
ports: ["11434:11434"]
steps:
- uses: actions/checkout@v7
- run: curl -fsS http://localhost:11434/api/pull -d '{"name":"qwen2.5-coder:1.5b-instruct"}'
# β produce findings.sarif with your scanner here (opengrep example below β¬)
- name: Triage β fail only on NEW exploitable findings
uses: alexpermiakov/sast-triage@v1 # or pin a commit SHA
with:
base-url: http://localhost:11434/v1
model: qwen2.5-coder:1.5b-instruct
qwen2.5-coder:1.5b-instruct is what this repo's own local-model runs use β honest but cautious: verdicts are fail-closed, so a tiny model says uncertain a lot and confident benign rarely. On a self-hosted GPU runner (runs-on: [self-hosted, gpu]), swap in a model with real judgment β e.g. qwen3-coder:30b β and keep everything else identical. The same two inputs also point at any OpenAI-compatible endpoint β vLLM, LM Studio, or OpenAI itself (base-url: https://api.openai.com/v1 + openai-api-key: ${{ secrets.OPENAI_API_KEY }}).
The scan step β producing findings.sarif (opengrep here, but any SARIF scanner works)
Nothing downstream is opengrep-specific: any scanner that emits SARIF 2.1.0 plugs in β Semgrep, CodeQL, Snyk Code, gosec, Bandit β opengrep is just the one this repo tests against. Paste this where the workflow says "your scanner here", and swap /tmp/rules/go for your languages' rule dirs:
- name: Scan with opengrep β findings.sarif
run: |
curl -fsSLo /usr/local/bin/opengrep \
https://github.com/opengrep/opengrep/releases/download/v1.25.0/opengrep_manylinux_x86
chmod +x /usr/local/bin/opengrep
git clone --depth 1 https://github.com/opengrep/opengrep-rules /tmp/rules
opengrep scan -f /tmp/rules/go --sarif --dataflow-traces --output findings.sarif
Run it directly β one-off triage outside CI
Nothing is sent anywhere you didn't name: -base-url is always explicit, so pointing it at local Ollama keeps everything on your machine.
go install github.com/alexpermiakov/sast-triage/cmd/sast-triage@latest
# 1. Scan β anything emitting SARIF 2.1.0 works; opengrep is what's tested
git clone --depth 1 https://github.com/opengrep/opengrep-rules /tmp/rules
opengrep scan -f /tmp/rules/go --sarif --dataflow-traces --output findings.sarif
# 2. Triage β local model via Ollamaβ¦
ollama serve & # http://localhost:11434
ollama pull qwen2.5-coder:7b
sast-triage -sarif findings.sarif -repo . \
-base-url http://localhost:11434/v1 -model qwen2.5-coder:7b
# β¦or Claude
sast-triage -provider anthropic -model claude-sonnet-5 \
-sarif findings.sarif -repo .
cat triage-report.md
Outputs: triage-report.md (read this), triage-cache.json (commit this β it's the agent's memory). Exits 3 if it decided any new exploitable β that's the PR gate doing its job; -fail-on-new-exploitable=false turns it off. Requires Go 1.26+ (older toolchains β₯1.21 auto-download it).
Real output, live: this repo triages its own intentionally vulnerable demo/ β see the resulting Security alerts and issues.
For production, start from the workflow this repo runs on itself.
Flags & action inputs
The GitHub Action exposes every flag as an input of the same name, minus the leading dash β -base-url becomes base-url:, -fail-on-new-exploitable becomes fail-on-new-exploitable: β with identical defaults:
| Flag |
Default |
Purpose |
-provider |
openai |
openai (any OpenAI-compatible endpoint β Ollama, vLLM, LM Studio, OpenAI itself) or anthropic |
-base-url |
β |
Endpoint for openai; required, no default β the tool only ever talks to the host you name |
-model |
β |
Required, no default β e.g. claude-sonnet-5 (anthropic), qwen2.5-coder:7b (openai) |
-sarif |
findings.sarif |
SARIF 2.1.0 input |
-repo |
. |
Repository root the findings refer to |
-cache |
triage-cache.json |
Verdict cache (commit it to git) |
-report |
triage-report.md |
Markdown report output |
-triaged-sarif |
β |
Verdict-annotated SARIF copy for Code Scanning upload |
-effort |
medium |
Depth: small, medium, large |
-max-findings-budget |
50 |
Max findings triaged per run (0 = unlimited) |
-parallel |
4 |
Concurrent findings |
-fail-on-new-exploitable |
on |
Exit 3 if this run finds a new exploitable; =false for runs that must not fail (push to main) |
-create-issues |
off |
File GitHub issues for exploitables (needs GITHUB_TOKEN) |
-github-repo |
$GITHUB_REPOSITORY |
owner/name for issue creation |
-link-base |
β |
E.g., https://github.com/owner/repo/blob/<sha> |
API keys come from the environment: ANTHROPIC_API_KEY for anthropic, OPENAI_API_KEY for hosted OpenAI-compatible endpoints (local ones need none). The action also takes them as inputs β anthropic-api-key: / openai-api-key: β plus github-token: for issue creation (defaults to the workflow token) and extra-args: for the long tail (e.g. extra-args: "-max-iterations 15"). Fine-tuning: -max-iterations and -token-budget override the -effort preset; -issue-label and -issue-title-prefix customize filed issues.
Effort presets (scale per-finding budgets):
| Effort |
read_file lines |
grep matches |
token budget |
iterations |
small |
100 |
25 |
30k |
6 |
medium |
200 |
50 |
60k |
10 |
large |
400 |
100 |
120k |
15 |
Cost Examples
| Scenario |
Tokens |
Cost |
First run (50 findings, medium) |
~60kβ300k |
$0.30β$1.50 |
| Second run (cache hits) |
~0 |
~$0 |
| Incremental (1 new + 49 cache) |
~6k |
$0.03 |
Typical finding: 2kβ6k tokens. Bootstrap is expensive; everything after is cheap.
Features
- β
Evidence-keyed caching β verdicts auto-expire when cited code changes
- β
Bounded loops β no runaway LLM calls; iteration & token budgets per finding
- β
Read-only tools β no code generation, no writes, no surprises
- β
PR review workflow β cache updates land in a single review PR, human-vetted
- β
CI integration β fail PR only on new exploitable findings
- β
Security tab integration β triaged SARIF uploads to GitHub Code Scanning; benign findings arrive dismissed, with the reason as justification
- β
Multi-scanner support β SARIF 2.1.0 with stable fingerprints
FAQ
What makes a PR fail, and who approves verdicts?
PRs fail only on new exploitable findings (exit 3; the gate is on by default, -fail-on-new-exploitable=false turns it off). Verdicts are cached in git (triage-cache.json), keyed to the evidence they cite, and approved by humans via the cache review PR. The committed cache is the baseline, so pre-existing backlog never blocks a PR β only what the PR itself introduces.
What about prompt injection β a comment claiming "this is safe"?
Repo content enters the prompt as evidence, never as instructions. A benign verdict requires cited file:line evidence that the tool re-verifies β prose claims don't meet the bar. The worst case for a fooled model is a wrong verdict, and the dangerous direction (false benign) demands the most proof, is human-approved in a PR, and auto-expires when any cited line changes.
Why commit the cache to git?
- Per-finding granularity (vs. ignore files and inline suppression comments)
- Non-destructive (verdicts, not deletions)
- Carries reason, evidence, timestamps
- PR diffs are audit trails
Does it only work with opengrep?
No. It consumes SARIF 2.1.0 from any scanner. opengrep and semgrep are what's tested β their matchBasedId fingerprints and dataflow traces are used directly. Anything else that speaks SARIF (CodeQL, Snyk Code, gosec, Bandit, Brakeman, SonarQube, ...) works too: when a scanner emits no stable fingerprint, a synthetic one is derived from rule + location, and scanner quirks belong in internal/sarif adapters β a parsing problem, not a prompting problem.
Which models can I use?
Any OpenAI-compatible endpoint out of the box (-provider openai, the default): Ollama, vLLM, LM Studio, or OpenAI itself β pick with -base-url and -model. Claude via -provider anthropic. Both are thin adapters over a one-method Client interface (internal/agent/client.go); a new provider is one file implementing Complete. The verdict logic is fail-closed, so a weaker local model produces more uncertain verdicts, never silent benign ones β and the cache records which model decided each verdict.
Why doesn't the agent write fixes?
Scope. Triage is a judgment task with a verifiable output contract. Write access would turn a wrong verdict into a wrong commit. Judgment only.
License: MIT | SAST findings triage with LLM agents, bounded and cached