← All articles

Draugr gates its own CI

October 25, 2026 · Wilson Santos · updated October 30, 2026
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

WhenWhat it scansFails on
A pull request that changes more than documentationThe diff against the base branchA P1 the change introduces
Every push to main, and weekdays at 06:11 UTCThe whole treeAny open P1
Every tag, before the release is builtThe tree being taggedAny 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

PrioritySeverityRuleToolComponentLocation
P2mediumtrailofbits.go.invalid-usage-of-modified-variable.invalid-usage-of-modified-variableSemgrep OSSdraugrinternal/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

ControlVerdictHighestOpen
scafailP11

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.