Gate PRs on new findings
On this page
draugr diff compares two scans and classifies every finding as new, unaccepted,
accepted, fixed, moved or unchanged, the security delta of a change, typically a PR's head vs its base
branch. This lets you gate a PR only on the findings it introduces, not the pre-existing backlog,
so the gate stays adoptable where a whole-backlog gate would block every PR.
How it works
Draugr stores nothing. There is no baseline kept on a server, no history of previous runs, no
"last known state of main". draugr diff takes two SARIF files that you hand it and compares
them:
draugr diff base/results.sarif head/results.sarif
So "the result from main" is a file you produced by scanning main. In the same pipeline run,
or stored as an artifact by the last build of main. Nothing is fetched.
That is deliberate. A CLI running in someone's pipeline should not be a service with memory of previous runs, because then the answer depends on state you cannot see and cannot reproduce. Two files in, one answer out, the same answer forever.
What counts as "the same finding"
A finding in base and a finding in head are the same finding when either of two keys matches:
| Key | Made of | Survives |
|---|---|---|
| content | tool, rule, file, a hash of the lines around the finding, component, repository | the finding moving within the file, a scanner rewording its message |
| identity | tool, rule, file, message, component, repository | the finding moving within the file |
The content key is tried first, and only findings that carry a content hash have one. Either key matching pairs the two findings, so a finding whose surrounding lines were edited still matches on its identity.
Findings pair one to one. A file can hold several findings with the same identity, one rule
reporting the same message on three lines, and each base finding pairs with at most one head
finding. Three in head against two in base is two unchanged and one new; one in head against
two in base is one unchanged and one fixed. Within one identity, findings pair in line order.
Neither key includes the line number or the severity. Code moves, and a finding that slid down twelve lines is not a fix plus a new problem. A CVE that gets re-scored is still the same CVE.
The component and the repository are part of both keys. The same flaw in two components, or the
same file in two repositories, is two findings. A repository is compared by its identity rather
than as written, so https://…/api, https://…/api.git and git@…:api.git are one repository.
A finding matched by neither key is then tried against the other side with the component left out of both keys. A pair found this way is moved: the same tool, rule, file and repository, now reported under a different component, which is what splitting or renaming a component does to every finding it held. A pair whose suppression differs is not a move, because an exclusion that stopped covering the finding, or started to, is a decision to review; it is reported as unaccepted or accepted.
The message is part of the identity key, which is why both sides have to come from the same Draugr: a release that rewords what a scanner reported changes the key for any finding without a content hash. See do not pay for the base scan twice.
What each kind of change does, from scans of one repository holding a leaked AWS key, each change a
commit scanned as head against the original as base:
Change between base and head | draugr diff reports | Why |
|---|---|---|
| twelve lines added above the finding | unchanged | both keys still match |
| the line directly above the finding edited | unchanged | the content hash changed; the identity key matched |
| the file renamed | new and fixed | the file is part of both keys |
| the credential removed | fixed | |
| a second component scanning the same repository | new for the second component, unchanged for the first | the component is part of both keys |
the component split in two, paths dividing the repository | moved | the tool, rule, file and repository match under another component |
A rename is the case to recognize, because nothing about the finding changed except where it lives:
$ draugr diff out-base/results.sarif out-renamed/results.sarif
DRAUGR DIFF 1 new 1 fixed 0 unchanged
new 1 P1 0 P2 0 P3 0 P4
CHANGED 2, by priority
Change Priority Severity Rule Scanner Location
+ new P1 high aws-access-token gitleaks settings.go:8
aws-access-token has detected secret for file settings.go.
- fixed P1 high aws-access-token gitleaks config.go:8
aws-access-token has detected secret for file config.go.
TRY
--view compact one line each, to see how much there is
--view actions the same findings as a list of things to do
--fail-on-new P1 no gate was set; this makes the diff decide the exit code
--format markdown the comment a pull request getsBoth rows are one credential, moved from config.go to settings.go, and a gate on new findings
(--fail-on-new) fails on it, because at head the credential is still in the code.
Whatever is in head and not in base is new; in base and not in head is fixed; in
both is unchanged.
Three more, for the findings somebody decided about rather than changed:
- accepted, suppressed in
headand not inbase. Somebody added an exclusion, or a finding arrived that an existing rule already covers. Accepting a risk is not fixing it, and this is the line most worth a reviewer's attention: nothing was removed, somebody chose to live with it. - unaccepted, suppressed in
baseand counting again inhead. An exclusion was removed, or it reached itsexpiresdate. Nobody introduced this finding and nothing about it was ever fixed, which is why it is not called reopened: a decision about it ended, and that is the part that needs acting on. - still accepted, suppressed in
baseand still suppressed inhead. The acceptance predates the change and the change left it alone. It counts toward the headline's unchanged total and is never listed as a change.
A change that does all of it at once, in a repository holding leaked AWS keys: it adds
billing.go with a new key, deletes old.go, removes the exclusion covering legacy.go and adds
one covering fixtures/, and leaves config.go and the already excluded vendor_client.go as
they were. Everything the change touched is one table, ranked by priority, with what happened in
its own column:
$ draugr diff out-base/results.sarif out-head/results.sarif --fail-on-new P1
DRAUGR DIFF FAIL 1 new 1 unaccepted 1 accepted 1 fixed 2 unchanged
new 1 P1 0 P2 0 P3 0 P4
unaccepted 1 P1 0 P2 0 P3 0 P4
accepted 1 P1 0 P2 0 P3 0 P4
unchanged 1 P1 0 P2 0 P3 0 P4
still accepted 1 P1 0 P2 0 P3 0 P4
Gate: fails on any P1 this change introduces.
CHANGED 4, by priority
Change Priority Severity Rule Scanner Location
+ new P1 high aws-access-token gitleaks billing.go:3
aws-access-token has detected secret for file billing.go.
! unaccepted P1 high aws-access-token gitleaks legacy.go:3
aws-access-token has detected secret for file legacy.go.
~ accepted P1 high aws-access-token gitleaks fixtures/seed.go:3
aws-access-token has detected secret for file fixtures/seed.go.
- fixed P1 high aws-access-token gitleaks old.go:3
aws-access-token has detected secret for file old.go.
TRY
--view compact one line each, to see how much there is
--view actions the same findings as a list of things to do
--format markdown the comment a pull request gets
draugr: differential gate: 1 new finding at or above the thresholdThe strips under the headline count the findings in head by band, one strip per state. Every
finding in head is in exactly one of them, so together they are the head scan; fixed has no
strip because a fixed finding is not in head. The unchanged strip is the work this change
inherited and the still accepted strip is the risk it inherited, and the gate asks about
neither. A state that did not happen is not named, in the headline, the strips or the table, so a
diff with nothing accepted does not make anybody read past a zero.
--format markdown on the same pair opens the pull-request comment with the same headline and the
same strips, in the same order:
❌ **FAIL** · 🔺 1 new · ⚠️ 1 unaccepted · 🤝 1 accepted · ✅ 1 fixed · 2 unchanged
_new_ · **1 P1** · 0 P2 · 0 P3 · 0 P4<br>
_unaccepted_ · **1 P1** · 0 P2 · 0 P3 · 0 P4<br>
_accepted_ · **1 P1** · 0 P2 · 0 P3 · 0 P4<br>
_unchanged_ · **1 P1** · 0 P2 · 0 P3 · 0 P4<br>
_still accepted_ · **1 P1** · 0 P2 · 0 P3 · 0 P4
_Gate: fails on any P1 this change introduces._
--view compact is the same table one line each, and --view actions groups it into the things
somebody would do. --top caps the listing, and is 0 by default because a diff is already only
what one change did.
Moved findings
A moved finding is one that changed component and nothing else. The gate does not read moved findings, as it does not read accepted ones, so a pull request that divides a repository into components passes a gate on new findings whatever backlog the repository carries.
A move lists one row per pair of components, with how many findings moved and how their priorities
changed. A finding whose priority changed is listed again on its own row, because the component it
now belongs to declares a different exposure or criticality, and that declaration is the part of
the pull request to check. Here one component, shop, is split into api, checkout and
storefront with paths, and no code changes:
$ draugr diff out-base/results.sarif out-head/results.sarif --fail-on-new P1
DRAUGR DIFF pass 45 moved 0 unchanged
moved 20 P1 24 P2 1 P3 0 P4
Gate: fails on any P1 this change introduces.
Nothing is new or fixed.
MOVED 45
From To Findings Priority
shop api 32 unchanged
shop checkout 6 4 P1 → P2
shop storefront 7 2 P1 → P2
PRIORITY CHANGED 6, by priority
Priority Severity Rule Scanner Component Location
P1 → P2 high CVE-2020-14040 trivy shop → checkout checkout/go.mod:5
possibility to trigger an infinite loop in encoding/unicode could lead to crash
P1 → P2 high CVE-2021-38561 trivy shop → checkout checkout/go.mod:5
golang: out-of-bounds read in golang.org/x/text/language leads to DoS
P1 → P2 high CVE-2022-32149 trivy shop → checkout checkout/go.mod:5
golang: golang.org/x/text/language: ParseAcceptLanguage takes a long time to parse complex tags
P1 → P2 high CVE-2026-56852 trivy shop → checkout checkout/go.mod:5
Denial of Service via invalid UTF-8 input
P1 → P2 high NSWG-ECO-328 trivy shop → storefront web/package-lock.json:12
Cross-Site Scripting (XSS)
P1 → P2 high NSWG-ECO-329 trivy shop → storefront web/package-lock.json:12
XSS via improper selector detection
TRY
--view compact one line each, to see how much there is
--format markdown the comment a pull request getscheckout declares exposure: internal, so its findings rank lower than they did under shop,
which declared public. --top caps the priority-changed rows; the pairs are always listed.
--format markdown carries the same two tables under ### Moved and ### Priority changed, and
--format json lists each move under moved with the component and priority it had in base.
Where the base comes from
| How | What it costs | |
|---|---|---|
| The GitHub Action | mode: auto scans both sides for you | nothing to wire; two scans per pull request |
| The GitLab template | gitlab-ci/draugr.yml does it on every merge request | nothing to wire; two scans per merge request |
| The Azure template | azure-pipelines/draugr.yml with mode: auto | nothing to wire; two scans per pull request |
| Scan both in one job | check out the base, scan, check out head, scan | two scans, on any CI system |
| A stored artifact | the last build of main published its results.sarif | one scan, and a base that is not the merge base |
The three templates gate a merge request on new findings with nothing to set up, GitLab's at P1
by default. Scanning both sides by hand is where to start anywhere else. What to do about the second scan is
the next section, because the answer is usually not the artifact.
When the base has no descriptor
The pull request that adds a repository's first descriptor has nothing at its base to scan. Given
the head report alone, draugr diff compares it against no findings, so every finding is new and
the gate applies to them:
$ draugr diff head/results.sarif --fail-on-new P1 --view compact
DRAUGR DIFF FAIL 4 new
new 3 P1 0 P2 1 P3 0 P4
Gate: fails on any P1 this change introduces.
Base: none, so every finding is new.
CHANGED 4, by priority
Change Priority Severity Rule Scanner Component Location
+ new P1 high aws-access-token gitleaks api services/api/deploy/aws.env:1
+ new P1 high aws-access-token gitleaks web services/web/deploy/aws.env:1
+ new P1 high draugr-fixture-eval Semgrep OSS api services/api/src/index.js:4
+ new P3 high draugr-fixture-eval Semgrep OSS web services/web/src/index.js:4
TRY
--view actions the same findings as a list of things to do
--format markdown the comment a pull request getsThe three templates diff the head alone when the base commit has no descriptor. A pull request with
no descriptor at the head fails, because there is nothing to scan: the GitHub Action with a message
naming the missing descriptor, and the GitLab and Azure DevOps templates with the scan's own error,
draugr.saga.yaml does not exist.
When the release refuses the base's descriptor
A release can refuse a descriptor an earlier release accepted, as 0.164.0 refuses
config.exclude[].acceptedBy. The three templates validate the base's descriptor before scanning
the base, and scan a refused one with the head's copy, with a warning that quotes the refusal. The
pull request that removes the key is gated like any other, and both trees are read with the head's
descriptor.
Do not pay for the base scan twice
Scanning the base on every pull request is the honest way to get a comparable pair, and it is also half the pipeline's time. There are two ways to stop paying for it, and they are not equally safe.
Cache the scan, which keeps the pair honest
--cache-dir keys each job on the scanner, its data version, the target's identity and the
effective config, so a base scan whose inputs have not moved is answered from the cache instead of
re-run. The base of one pull request is the base of every other pull request opened that day, so
the first one pays and the rest do not:
- uses: actions/cache@v4
with:
path: .draugr/cache
key: draugr-${{ github.sha }}
restore-keys: draugr-
- uses: draugr-dev/draugr@v0
with: { saga: draugr.saga.yaml, cache-dir: .draugr/cache, fail-on-new: P1 }
That snippet is GitHub's. GitLab and Azure cache
the same directories with a cache: key and a Cache@2 task. None of the three templates wires
one for you, because where a cache is kept is a property of the runner rather than of the scan.
The scan still runs, so the base is still the actual merge base and both sides still come from the same Draugr. Nothing about the comparison changes; only the work does. A cache that expired or was never written costs time and cannot change the answer. Read what a hit does and does not promise before sharing a cache across a trust boundary: an entry is a pass, and anybody who can write one can hand you a verdict nobody earned.
Store the base scan as an artifact, and know what you traded
Publishing results.sarif from the default branch's build and fetching it on each pull request
removes the base scan entirely. It buys the most and costs two things worth stating plainly.
The base is whatever last ran on the default branch, not the merge base. A finding somebody
fixed on main after that build reads as fixed by your change, and one they introduced reads as
new in yours. On a busy repository that is a comment nobody believes by Wednesday.
Both sides must come from the same Draugr. Findings are matched on what the scanners said, and a release that rewords a message rewords it on one side only when the stored base predates it:
DRAUGR DIFF FAIL 31 new 31 fixed 1 unchangedThat is the same tree on both sides, diffed across two Draugr versions. Every finding arrives as
fixed and new, --fail-on-new fails the pull request on findings nobody introduced, and nothing
in the output says why. The tell is the symmetry, the same count on both sides with the
unchanged number near zero.
Pin the version in both places if you take this route:
- uses: draugr-dev/draugr@v0
with: { version: v0.120.0 } # the same version that produced the stored base
and re-scan the base whenever you move it. The cache above avoids the whole question, which is why it is the one to reach for first.
In CI: let the action do it
On GitHub, you don't wire this up by hand. The first-party action's default mode: auto runs a
diff on pull_request events. It scans the base and head for you and posts one sticky new/fixed
comment, and a full scan on push. One workflow, one Saga:
on: [push, pull_request]
permissions:
contents: read
security-events: write # push: code scanning
pull-requests: write # PR: the diff comment
jobs:
draugr:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # diff needs the base commit
- uses: draugr-dev/draugr@v0
with:
saga: draugr.saga.yaml
tools: true
fail-on-new: high # gate only on findings this PR introduces
diff-view: actions changes what the comment says, grouping the change into the things somebody
would do rather than listing every finding. GitLab's template takes DRAUGR_DIFF_VIEW, and Azure's
takes diffView. It belongs in the template because which shape a team wants is a property of how
they review rather than of the change.
See the GitHub Action guide for all inputs and modes. The rest of this page
covers running draugr diff directly, for other CI systems, or to understand what the action does
under the hood.
Produce the two SARIF files
diff consumes the results.sarif files that draugr scan -o
writes (SARIF is the complete, structured result set). A typical setup scans main on push and
stores results.sarif as an artifact, then scans the PR:
draugr scan draugr.saga.yaml --no-gate -o base/ # on the base branch
draugr scan draugr.saga.yaml --no-gate -o head/ # on the PR head
--no-gate on both. These two scans exist to produce reports; the diff is the gate. Without it
a FAIL verdict on the base, which any repository with a backlog will produce, exits non-zero and
takes the whole step with it under set -e. It suppresses the verdict's exit code only: a scan that
could not run still fails, so a missing report never reaches the diff disguised as "no new
findings".
For a complete pipeline, see Azure Pipelines or
GitLab CI; on GitHub the action's
mode: auto does all of this for you.
Each scan clones the repository before reading it, so a results.sarif always describes a
committed revision, which is what makes the two comparable, and what a reader needs in order to
reproduce either side. It also means the pair above only differs if the two scans ran against
different commits: iterating locally with scan → edit → scan compares HEAD with itself and
reports no change. Commit between the two, or set revision on the repository to name each revision
explicitly. See URLs and
paths.
Diff and gate
draugr diff base/results.sarif head/results.sarif # console delta
draugr diff base/results.sarif head/results.sarif --format markdown # MR comment
draugr diff base/results.sarif head/results.sarif --fail-on-new P1
draugr diff base/results.sarif head/results.sarif --publish # sticky PR comment (in CI)
draugr diff base/results.sarif head/results.sarif --format sarif # only the new findings, for code scanning
draugr diff base/results.sarif head/results.sarif --format sarif --min-priority P1
--format sarif writes the new findings and only those, which is what a pull request's review
comments should carry: an upload of the whole repository annotates a reviewer with hundreds of
findings the branch did not cause. Fixed and unchanged are deliberately absent. A fixed finding is
no longer there to annotate, and an unchanged one is the pre-existing noise this removes. The GitHub
Action does this for you; see code-scanning.
--min-priority narrows the new findings it reports, in any format, leaving fixed and
unchanged counts alone. Narrow the diff rather than the scans it came from: a diff computed on
filtered inputs reads every finding the filter removed as fixed.
--fail-on-new fails the command (non-zero exit) only for new
findings at or above the given severity / priority, matched as described in what counts as "the
same finding".
The same gate can be written in the descriptor as
config.gate.failOnNew, where it is reviewed with the rest
of the policy, and --fail-on-new overrides it for one run.
draugr scan --base main/results.sarif applies it within the scan, by the comparison draugr diff
makes, and the scan is then judged as the diff judges a pair. Its verdict and exit code come from the
gate on new findings, its console prints the change in the layout draugr diff prints, and its last
line on a failure names the gate, the new findings that met it and the base's revision, as
draugr: fails on <band>: <n> new findings since <revision>. The whole run's outcome against
--fail-on decides nothing, and the report.json the scan writes and publishes records it as
gate.wholeRun beside the gate, the base and the verdict. See gating on new
findings.
Post the delta as a PR comment
--publish posts the diff as a sticky pull-request comment, updated in place on each push,
and no-ops off a pull request:
draugr diff base/results.sarif head/results.sarif --publish
It picks the publisher from the CI system it is running on, github-pr-comment under GitHub Actions
with $GITHUB_TOKEN, azure-pr-comment under Azure Pipelines with $SYSTEM_ACCESSTOKEN,
gitlab-mr-comment under GitLab CI with $GITLAB_TOKEN. Azure needs that variable mapped into the
step; see Azure Pipelines. GitLab needs a token with api
scope, because the CI_JOB_TOKEN in every job cannot post notes; see reports &
publishers.
The diff keeps its own sticky comment, separate from the one a Saga's PR-comment publisher maintains. A pipeline can run both, the state of the branch, and what this pull request changed, and get two comments rather than one overwriting the other.
Severity in a diff
A diff reports the same critical / high / medium / low bands the scan report uses, because it is read next to that report and the two have to agree.
Those bands are Draugr's own, normalized across every control so a dependency CVE, a leaked secret
and an IaC misconfiguration can share one ordered list, a CVSS-style score decides the band when a
scanner publishes one, and the SARIF level decides it when none is published. The error /
warning / note values you will see inside a results.sarif file are SARIF's wire vocabulary,
not a severity: SARIF has three of them, and they cannot express the difference between a 7.0 and a
9.8.
--fail-on-new takes either vocabulary, the same as the scan gate: a priority band (P1–P4)
or a severity (critical / high / medium / low). The SARIF levels error, warning and
note are still accepted and mean high, medium and low.
Which one you write decides the question. P1–P4 fold in the component's declared exposure and
criticality, which is why a high on an internet-facing component outranks a critical on
something nothing can reach; a severity is what the scanner called the flaw on its own terms. A run
asks one of the two, so writing both is refused. See
prioritization.
A finding a second scanner reported for a flaw the first already found does not trip this gate. It arrives as a new result the day somebody enables a second matcher, and failing a pull request over copies of vulnerabilities that were already there is failing it for improving coverage.
See the CLI reference for every diff
flag, and reports & publishers for both publishers.