Threat modelling for pull request diffs. threatdiff reads a git diff, works out which
threats the change touches, and hands the reviewer a checklist of questions with the code
already quoted next to each one.
It is not another vulnerability scanner. A scanner reads the file that results from a change and tells you what is wrong with it. threatdiff reads the change itself and tells you what to ask about it — including the things a scanner structurally cannot see, like a deleted authorization decorator, a dropped tenant filter, or a CSRF exemption that was added last week.
threatdiff risk 89/100 CRITICAL
22 findings across 6 files +59/-25 lines in 6 changed files 88 rules
Threat categories
Elevation of privilege ██████████████████ 173.6 6 findings
Tampering █████████········· 88.8 4 findings
Information disclosure █████████········· 85.8 4 findings
Supply chain ██████············ 62.0 4 findings
Spoofing █████············· 52.0 2 findings
Secrets exposure ███··············· 27.6 2 findings
api/invoices.py
CRITICAL Authorization annotation or decorator removed
line 15 (removed from old line 8) · control.authz-annotation-removed · +52.0
- @login_required
? Which endpoints or handlers stop requiring authorization because of this
change, and is that intentional?
→ @org/appsec-authz
- Zero dependencies. Pure Go standard library. An AppSec tool that runs in your pipeline should not be the largest supply-chain surface in it.
- Runs anywhere. One static binary, or a composite GitHub Action, or a pre-commit hook.
- Four outputs. Terminal for the author, a sticky Markdown comment for the reviewer, SARIF for GitHub code scanning, JSON for whatever your platform team builds next.
- 88 built-in rules, every one of them carrying the question a reviewer should answer and the guidance to answer it. Add your own in YAML.
- Why this exists
- Install
- Quick start
- In CI
- What a finding looks like
- The risk score
- Suppressions and baselines
- Writing your own rules
- Reviewer routing
- Configuration
- Command reference
- How it works
- What it deliberately does not do
- Contributing
Three observations, from the ordinary practice of application security:
1. The interesting security signal in a change is often a deletion.
-@login_required
@bp.route("/invoices/<invoice_id>")
def get_invoice(invoice_id):
- return Invoice.query.filter_by(id=invoice_id, tenant_id=current_user.tenant_id).first()
+ return Invoice.query.filter_by(id=invoice_id).first()Every SAST tool in the world analyses the resulting file. In the resulting file there is no missing decorator and no missing tenant filter — there is just a route handler that does a lookup. The finding is in the diff, and only in the diff.
2. Nobody threat models a pull request. Threat modelling happens at design time, on a whiteboard, for a system that then changes 400 times before it ships. The change is where the risk enters, and the change is exactly where nobody has the time to do the exercise.
3. Findings without questions get muted. "Potential SQL injection at line 42" invites a reviewer to guess whether the tool is right. "Is every value in this statement bound as a parameter, or is any of it concatenated from caller-controlled input?" invites them to answer. The second one is what a security engineer would actually have asked, and it is answerable in thirty seconds by the person who wrote the line.
threatdiff is those three observations turned into a tool. Every rule is a question. Findings are grouped by STRIDE category, so the output reads like a threat model rather than a lint log. And roughly a third of the rule set fires on removed lines, which is the part no other tool in your pipeline is looking at.
# Go 1.22+
go install github.com/ggeorgeazevedo/threatdiff/cmd/threatdiff@latest
# or from source
git clone https://github.com/ggeorgeazevedo/threatdiff && cd threatdiff && make build
# -> bin/threatdiffRelease binaries for linux/darwin/windows on amd64 and arm64 are attached to each tagged
release, with checksums.txt.
There is nothing to configure to get started: the rule packs are embedded in the binary.
# your working tree against the default branch
threatdiff scan
# an explicit range, the way a pull request sees it (three-dot: head vs merge base)
threatdiff scan --base main --head HEAD
# a patch from anywhere
git diff main... | threatdiff scan --diff -
threatdiff scan --diff some.patch
# only what you have staged - useful as a pre-commit hook
threatdiff scan --staged
# gate a build
threatdiff scan --fail-on high # exit 1 if a high or critical finding appears
threatdiff scan --fail-score 60 # exit 1 if the risk score reaches 60Exit codes are part of the interface:
| Code | Meaning |
|---|---|
0 |
Analysis completed, gate not triggered |
1 |
Analysis completed, gate triggered |
2 |
Could not run (bad flags, unreadable rules, git failure) |
A run with no gate configured never fails a build. That is deliberate: a tool that starts blocking merges on day one gets removed on day two.
.pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: threatdiff
name: threatdiff
entry: threatdiff scan --staged --fail-on critical
language: system
pass_filenames: falseThe GitHub Action posts a sticky review comment, writes the job summary, produces SARIF for code scanning, and optionally fails the job.
name: threatdiff
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
pull-requests: write # to post the checklist
security-events: write # to upload SARIF
jobs:
threat-model:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # the merge base must exist locally
- id: threatdiff
uses: ggeorgeazevedo/threatdiff@v0 # pin to a SHA in production
with:
fail-on: "" # start with no gate; tighten later
- uses: github/codeql-action/upload-sarif@v3
if: always() && steps.threatdiff.outputs.sarif-file != ''
with:
sarif_file: ${{ steps.threatdiff.outputs.sarif-file }}
category: threatdiffA fuller example, including labelling high-risk pull requests, is in
examples/workflow-pull-request.yml.
Action inputs: version, base-ref, head-ref, config, rules, baseline, fail-on,
fail-score, min-confidence, comment, sarif, sarif-file, working-directory,
github-token.
Action outputs: score, band, findings, failed, sarif-file, markdown-file.
Not on GitHub? Everything the Action does is available from the CLI:
threatdiff scan \
--base "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" \
--format json --out threatdiff.json \
--markdown-out threatdiff.md \
--sarif-out threatdiff.sarif \
--fail-on critical--base is auto-detected from GITHUB_BASE_REF, CI_MERGE_REQUEST_TARGET_BRANCH_NAME or
CHANGE_TARGET, then from origin/HEAD, then main/master, then HEAD~1.
Every finding carries a location, the changed line, a question, guidance, a STRIDE category, a severity, a confidence level, a CWE mapping, and a stable fingerprint.
In the Markdown comment it renders as a checklist item:
Query filter on tenant, account or owner removed —
api/invoices.py:15CRITICAL- Invoice.query.filter_by(id=invoice_id, tenant_id=current_user.tenant_id)Ask: Does this query still return only rows belonging to the caller's tenant?
How to answer it
Cross-tenant data leaks are the most expensive class of bug in a multi-tenant product and they look exactly like this in a diff. If the filter moved into a shared scope, a base repository or a row-level security policy, name it. …
rule
authz.tenant-scope-dropped-from-query· low confidence · CWE-639 · @org/appsec-authz
Severity is how bad it is if the answer is "yes, that is a problem". Confidence is how often the rule is right when it fires. They are separate on purpose: a low-confidence critical rule is still worth asking about, it just should not dominate the score.
Repeated hits of one rule in one file are folded into a single item with an occurrence count. Four adjacent lines turning off four halves of the same S3 public-access block is one decision, and reporting it four times trains people to skim.
Rules that match credentials are marked redact, and their matched text is masked everywhere —
terminal, comment, SARIF and JSON. A tool that quotes the secret it just found has published it
a second time, this time somewhere indexed.
One number, 0–100, with a band. It exists so a pull request can be gated and so a security team can see at a glance which changes are worth a human. Three properties, all deliberate:
The worst finding sets a floor; everything else fills the headroom above it. The single
largest contribution is mapped through 1-exp(-x/k) to a floor, and the sum of the rest closes
the gap to the ceiling through the same curve. This is what stops eight mediums from outranking
one critical. A plain sum would mean a large refactor scores higher than a targeted change to
authentication, and a gate that behaves that way teaches people to write bigger pull requests. A
genuinely large pile of mediums still gets there — thirty of them really is a high-risk change —
it just takes thirty, not eight.
Removed controls weigh 1.3× more than added risk. A deleted authorization check is a regression in a property the system already had. New risky code at least arrives with a reviewer's attention on it.
Blast radius contributes up to 15 points, saturating. Bigger diffs get reviewed worse. The difference between a 40-file and a 400-file change is small, because both are past the point where anyone reads every line.
| Band | Score |
|---|---|
critical |
≥ 75 |
high |
≥ 45 |
medium |
≥ 20 |
low |
≥ 5 |
minimal |
< 5 |
Every number is explainable: each finding prints its own contribution, and the report shows the
split between signal and blast radius. A gate nobody can argue with is a gate that gets bypassed
rather than fixed. Every weight is overridable in scoring: — see
docs/configuration.md.
Inline, on the matched line or the line above:
api_key = "example-value-not-real" # threatdiff:ignore[secret.generic-credential-assignment] docs placeholder
h = hashlib.md5(url).hexdigest() # threatdiff:ignore -- cache key, not security
resp = requests.get(url) # threatdiff:ignore[injection.*] whole family, host is a constantBaseline, for adopting threatdiff in a repository that already has history:
threatdiff baseline write \
--base "$(git rev-list --max-parents=0 HEAD)" \
--out .threatdiff-baseline.json \
--reason "pre-existing at adoption"Baseline entries are keyed by a fingerprint of the rule, the path and the normalised line — not the line number — so reformatting does not silently re-open or re-suppress anything. Every entry is dated, because a baseline is a debt register and debts should be reviewed.
Suppressed findings are always reported, in their own section, with the reason. Findings suppressed without a reason are called out. An invisible suppression is how a gate rots.
Rules are YAML. Reusing a built-in rule's id overrides it, which is how you retune the
defaults without forking the pack.
version: 1
name: house-rules
rules:
- id: house.legacy-auth-helper
title: Deprecated authentication helper used
category: elevation-of-privilege # STRIDE, plus supply-chain and secrets
severity: high # critical | high | medium | low | info
confidence: medium # high | medium | low
on: added # added | removed | any | file
languages: [python, go]
paths: ["services/**"]
exclude_paths: ["**/*_test.go"]
patterns:
- '(?i)legacy_auth\.(check|verify)\s*\('
exclude_patterns:
- '(?i)#\s*migrated'
near: # the constraint that removes the noise
absent:
- '(?i)(new_auth|authz_v2)'
window: 8
scope: hunk # hunk | file
question: >
Why is this call still on the old helper, and what does the new one enforce
that the old one does not?
guidance: |
legacy_auth.check() only validates the session; it does not evaluate the
tenant policy. Anything under services/ needs authz_v2.authorize().
cwe: ["CWE-863"]
references:
- "https://internal.example.com/docs/authz-v2"threatdiff rules validate .threatdiff/rules # errors are line-numbered
threatdiff scan --rules .threatdiff/rules
threatdiff rules show house.legacy-auth-helper
threatdiff rules docs --out docs/rules.md # generate a reference tableTwo things the schema enforces, both on purpose:
questionis mandatory. A rule that cannot tell a reviewer what to check is noise.- Unknown fields are rejected, with a line number. A typo in a rule file should fail loudly, not silently disable a check.
Patterns are Go RE2: no lookaround, no backreferences, linear time. That last property matters here — a rule pack is user input, and a backtracking engine would make a rule file into a denial of service vector against your own CI.
The near constraint is what separates a useful rule from a noisy one. authz.new-endpoint-without-auth
fires on a new route handler only when nothing resembling an authorization check appears within
ten lines. That single condition is the difference between a rule you keep and a rule you turn off.
Full reference: docs/writing-rules.md. All 88 built-in rules: docs/rules.md.
Detection is rarely the bottleneck in application security. Getting the right pair of eyes onto the right ten lines is.
review:
default: ["@org/appsec"]
routes:
- name: access control
categories: [elevation-of-privilege, spoofing]
min_severity: high
reviewers: ["@org/appsec-authz"]
- name: infrastructure
paths: ["**/*.tf", ".github/workflows/**", "**/Dockerfile*"]
reviewers: ["@org/platform-security"]
- name: crypto
rules: ["crypto.*"]
reviewers: ["@alice"]Routes are additive — a finding that matches two routes gets both sets of reviewers. When no
route matches, threatdiff falls back to your CODEOWNERS file (GitHub semantics: last match
wins), and then to default. So a repository with a CODEOWNERS file gets useful routing with no
threatdiff configuration at all.
.threatdiff.yaml in the repository root. Generate a commented starter with threatdiff init.
version: 1
fail_on: critical # severity that fails the run; empty means never
fail_score: 0 # or gate on the score instead
min_confidence: low # drop findings below this confidence
exclude_paths:
- "vendor/**"
- "node_modules/**"
- "**/*.generated.*"
baseline: .threatdiff-baseline.json
rule_paths: [.threatdiff/rules]
rules:
disable: [dos.retry-without-backoff]
severity:
crypto.weak-hash-for-security: low
entropy: # the built-in high-entropy secret detector
enabled: true
min_entropy: 3.6
min_length: 20
scoring: # every weight in the model is yours to change
removal_multiplier: 1.3
saturation: 70
blast_radius_weight: 15
review:
default: ["@org/appsec"]Flags override the file. Full reference: docs/configuration.md.
threatdiff scan Analyse a diff and report the threats it introduces
threatdiff rules Inspect, validate and document the active rule set
rules list [--category C] [--severity S] [--json]
rules show <rule-id>
rules validate <path>
rules docs [--out FILE]
rules languages | rules categories
threatdiff baseline write [--out FILE] [--reason TEXT]
threatdiff init Write a starter .threatdiff.yaml
threatdiff version
Key scan flags:
| Flag | Purpose |
|---|---|
--base, --head |
The range. Compared three-dot, like a pull request. |
--staged |
Analyse the index instead of the working tree. |
--diff FILE|- |
Read a patch instead of invoking git. |
--format |
pretty (default), markdown, sarif, json. |
--out, --sarif-out, --markdown-out, --json-out |
Write several formats in one run. |
--fail-on, --fail-score |
The gate. |
--min-confidence |
Quieten the statistical rules while you calibrate. |
--only, --disable |
Run a single rule, or turn one off. |
--rules, --config, --baseline |
Inputs. |
--github-output |
Append score=/band=/findings=/failed= to $GITHUB_OUTPUT. |
--context |
Context lines to request from git; more makes near more accurate. |
git diff ──▶ diff parser ──▶ rule engine ──▶ scorer ──▶ reporters
│ │ │ ├─ pretty
│ │ │ ├─ markdown
│ │ │ ├─ sarif
│ │ │ └─ json
│ │ │
old + new line numbers, │ floor from the worst
hunk section headings, │ finding + saturating
add / remove / context │ tail + blast radius
│
patterns · path & language filters
proximity constraints · entropy detector
suppressions · baseline · repeat folding
| Package | Responsibility |
|---|---|
internal/diff |
Unified diff parser that keeps both line numbers and hunk section headings |
internal/config |
A YAML subset parser (no dependencies) and the config schema |
internal/rules |
The rule model, compiler, globs, language detection, embedded packs |
internal/analyze |
Matching, proximity, entropy detection, redaction, suppression, baselines |
internal/score |
The risk model and the gate |
internal/owners |
Reviewer routing and a CODEOWNERS parser |
internal/report |
The four renderers |
Two design notes worth calling out:
Removed lines get anchored to a surviving line. A deleted line has no line number in the new file, but a review UI — and SARIF — can only annotate lines that still exist. threatdiff anchors a removal to the nearest surviving line above it, and says so in the finding. Without this, the most valuable half of the rule set would have nowhere to render.
The YAML parser is deliberately small. It implements block mappings, block sequences, flow collections, quoted and block scalars, and comments — and rejects anchors, aliases, tags and multi-document streams with a line-numbered error. For a security tool, "I do not understand this rule file" is a much better outcome than "I guessed".
Being clear about this is part of the tool working.
- It does not prove anything. There is no data-flow analysis, no call graph, no type information. A finding is a question, and the answer might well be "no, that is fine". The output is written to make that answer cheap to give.
- It does not replace SAST, SCA or secret scanning. It is the layer none of those cover: the delta, and specifically the deletions. Run it alongside them.
- It does not see whole-repository context. A rule that fires on a new route cannot know
about the middleware three files away.
nearwithscope: filewidens the window, and a reviewer's "that is handled in router.go" is a perfectly good answer that closes the finding. - A clean run is not a guarantee. It means no rule matched. Rules only see patterns somebody thought to write down. threatdiff says this in its own output, every time.
Turning this on in an existing repository, in the order that works:
- Week 1–2 — no gate.
fail-on: "". The comment appears; nobody is blocked. Watch which rules fire and whether the questions are the ones you would have asked. - Then — baseline and calibrate.
threatdiff baseline writefor the existing code. Turn down or disable the rules your codebase makes noisy; raise the ones it makes important.severity:overrides and a house rule pack both belong here. - Then — gate on critical.
fail-on: critical. At this point the gate only fires on things the team has already agreed are worth stopping for. - Later — route, then tighten. Add
review:routes so findings reach the people who can answer them, then move the gate tohighif the numbers support it.
More detail, including how to run this across many repositories: docs/adoption.md.
New rules are the most valuable contribution, and the bar is specific: a rule must ask a question a reviewer can answer, and it must be one you would be willing to see fire on your own pull requests. See CONTRIBUTING.md.
make check # vet + gofmt + tests, the same set CI runs
make demo # run against the bundled example pull request
make docs # regenerate docs/rules.mdMIT. See LICENSE.