sast-triage

module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT

README ΒΆ

πŸ” SAST Triage Agent

CI Triage Go version

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

Directories ΒΆ

Path Synopsis
cmd
sast-triage command
Command sast-triage triages SAST findings with a bounded LLM agent and an evidence-keyed suppression cache.
Command sast-triage triages SAST findings with a bounded LLM agent and an evidence-keyed suppression cache.
demo
vulnapp
Package vulnapp is an intentionally vulnerable demo target.
Package vulnapp is an intentionally vulnerable demo target.
internal
agent
Package agent runs the bounded LLM triage loop.
Package agent runs the bounded LLM triage loop.
cache
Package cache manages triage-cache.json: verdicts keyed by SARIF fingerprint, invalidated by a codeHash over the flagged region plus every evidence region.
Package cache manages triage-cache.json: verdicts keyed by SARIF fingerprint, invalidated by a codeHash over the flagged region plus every evidence region.
github
Package github is a minimal Issues client for routing exploitable verdicts.
Package github is a minimal Issues client for routing exploitable verdicts.
pipeline
Package pipeline wires the deterministic stages around the one nondeterministic one: ingest β†’ cache β†’ triage β†’ report + cache delta + issues.
Package pipeline wires the deterministic stages around the one nondeterministic one: ingest β†’ cache β†’ triage β†’ report + cache delta + issues.
report
Package report renders triage-report.md and GitHub issue bodies.
Package report renders triage-report.md and GitHub issue bodies.
sarif
Package sarif parses opengrep/semgrep SARIF 2.1.0 output into triage findings.
Package sarif parses opengrep/semgrep SARIF 2.1.0 output into triage findings.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL