Draugr gates its own CI
On this page
A security tool asking you to put a gate in front of your pipeline should be able to show you its
own. Draugr's is in the open, where .draugr/self.saga.yaml
describes the repository, selfscan.yml
runs it, and every exclusion sits in a file beside them with a reason and the name of whoever
accepted it.
The scan is run by the latest published release rather than by a build of main, and Draugr's
GitHub Action downloads it and checks its signature exactly as it does for anybody else. A gate
run by the code under test would prove that the branch agrees with itself, which is not what
we promise users.
The descriptor
A Saga is the YAML file that tells Draugr what you ship and which controls to check it with. Abridged to the parts that decide the gate, this is the one Draugr declares about itself:
config:
controls:
sca:
enabled: true
secrets:
enabled: true
sast:
enabled: true
gosec:
enabled: true
iac:
enabled: true
licenses:
enabled: true
exploitability:
kev: cache
epss: cache
reachability:
analyzers: [govulncheck]
fragments:
- path: "exclusions/*.saga-fragment.yaml"
components:
- name: draugr
exposure: public
criticality: critical
repositories:
- url: .
ignore:
- test/integration/testdata/ecosystems
- test/integration/testdata/cloud-fixture
Trivy, Semgrep, gosec, Gitleaks and govulncheck do the work behind those controls. CISA's Known Exploited Vulnerabilities catalog and EPSS raise a finding that is being exploited, and govulncheck lowers one when no call path from Draugr's code reaches the vulnerable function. The two ignored directories hold test fixtures that are vulnerable on purpose, because the integration suite needs something real to find.
The component is classified public and critical explicitly rather than left to the fallback
for an unclassified component, so the gate cannot weaken if that fallback is ever re-tuned.
When it runs
| When | What it scans | Fails on |
|---|---|---|
| A pull request that changes more than documentation | The diff against the base branch | A P1 the change introduces |
Every push to main, and weekdays at 06:11 UTC | The whole tree | Any open P1 |
| Every tag, before the release is built | The tree being tagged | Any open P1 |
P1 is the band that means act now. A finding reaches it by combining the scanner's severity with the exposure and criticality its component declares, so the same CVE can be P1 on a public, business-critical service and P3 on a restricted, supporting one. Gating on P1 rather than on every finding keeps the check one that people leave switched on.
A pull request and a branch get different questions. The diff asks whether this change made things worse, which is the fair question for an author who inherited a tree they did not write. The full scan asks whether the tree is acceptable as a whole, which nothing about a diff can establish. Both use the same band, because a finding urgent enough to block the branch is urgent enough to block the change that brings it in.
The gate is the same few lines in your own workflow:
- uses: draugr-dev/draugr@v0
with:
saga: draugr.saga.yaml
tools: true
fail-on-new: P1
On a pull request the action posts the diff as one comment, updated in place, and fail-on-new
makes it fail the check on a new P1. On a push it runs a full scan instead, which fails on any open
P1 without further configuration. The GitHub Action guide covers permissions
and pinning, and gating a pull request on what it introduces
covers the diff.
A finding below the gate is reported without blocking. This is the comment, abridged, on draugr#844:
1 new (1 medium), 0 fixed, 2 unchanged
Priority Severity Rule Tool Component Location P2 medium trailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variableSemgrep OSS draugr internal/mcp/tools.go:1076
The pull request merged with the P2 reported, and draugr#1450 later recorded the decision as an inline suppression stating why the pattern is correct on that line.
When the branch goes red on its own
The pull-request gate keeps a new P1 out, and the release gate keeps a bad tree from shipping.
Neither sees the case where nothing in the repository changed and the answer did, which is why
every push to main gets a full scan and the weekday run covers the days nobody pushes.
On 2026-09-02 a push to main that changed only documentation
(draugr#953) failed the scan. An advisory,
CVE-2026-84304, had been published against google.golang.org/grpc v1.83.0, which that push did
not touch. The workflow opened draugr#954, and
its summary reads:
7 open · 20 suppressed · verdict
fail
Control Verdict Highest Open scafail P1 1
The grpc advisory was the one P1 among the seven open findings, and the other six sat below the
gate. grpc reaches Draugr indirectly, through the OpenTelemetry exporter in
internal/observability, so it is only exercised when a run exports traces or metrics over gRPC.
That narrows who could be affected, but the shipped binary still contained the affected version,
and that is what anybody scanning our release would see.
draugr#958 took 1.83.1 and merged on 2026-09-03.
The next scan passed, and the issue closed itself.
What was accepted, and by whom
A finding that cannot or should not be fixed is accepted in one of two places. An exclusion goes in
a file under .draugr/exclusions/,
one file per kind of decision, and a fragment there can add exclusions but cannot change the gate.
A suppression that only makes sense on one line stays on that line as a #nosec or nosemgrep
comment with its reason, and the commit that added it names its author. This is one of the
exclusion files in full:
# Workflows written for somebody else to copy.
config:
exclude:
- paths:
- "contrib/starter-workflow/draugr.yml"
- "examples/github-actions-code-scanning.yml"
rules:
- "yaml.github-actions.security.github-actions-mutable-action-tag.github-actions-mutable-action-tag"
reason: >-
Both files are templates a reader copies into their own repository, and each tag the rule
flags is a choice the file states. The starter workflow follows the contribution rules of
actions/starter-workflows, which keep actions from the actions and github organizations on
major-version tags and pin everything else to a SHA, as contrib/starter-workflow/README.md
records. The example uses draugr-dev/draugr@v0 so a reader who copies it receives fixes,
and its comment tells them to pin a release for reproducible CI.
Scoped to these two files and this rule. Draugr's own workflows under .github/ pin every
action to a SHA, so the rule still fails the gate there.
Each exclusion names the paths and the rule, so the same pattern anywhere else still fails. The accepted findings stay in the report marked suppressed, carrying their reason and the file and line of the exclusion, and the history of that line in this public repository names who wrote it and when. Between them they answer the question an auditor asks. They rarely want to know whether a scanner ran, and almost always want to know who decided a finding was acceptable, and why.
The descriptor, the workflow and the exclusions are all public. If you are writing your first Saga, Draugr's own is a working example of every part described here, and the quickstart takes you from install to a first scan.