Guides

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:

KeyMade ofSurvives
contenttool, rule, file, a hash of the lines around the finding, component, repositorythe finding moving within the file, a scanner rewording its message
identitytool, rule, file, message, component, repositorythe 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 headdraugr diff reportsWhy
twelve lines added above the findingunchangedboth keys still match
the line directly above the finding editedunchangedthe content hash changed; the identity key matched
the file renamednew and fixedthe file is part of both keys
the credential removedfixed
a second component scanning the same repositorynew for the second component, unchanged for the firstthe component is part of both keys
the component split in two, paths dividing the repositorymovedthe 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 gets

Both 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 head and not in base. 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 base and counting again in head. An exclusion was removed, or it reached its expires date. 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 base and still suppressed in head. 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 threshold

The 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 gets

checkout 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

HowWhat it costs
The GitHub Actionmode: auto scans both sides for younothing to wire; two scans per pull request
The GitLab templategitlab-ci/draugr.yml does it on every merge requestnothing to wire; two scans per merge request
The Azure templateazure-pipelines/draugr.yml with mode: autonothing to wire; two scans per pull request
Scan both in one jobcheck out the base, scan, check out head, scantwo scans, on any CI system
A stored artifactthe last build of main published its results.sarifone 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 gets

The 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 unchanged

That 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.