📎 Unreleased docs. This is the next version (from main), features here may change or not be in a release yet.See the latest release →

Core concepts

Verdict & gating

On this page

A Draugr run is a pipeline that turns your descriptor (your draugr.saga.yaml) into a pass/fail verdict plus audit-ready evidence. This page covers how the run flows and how the verdict gates a pipeline; for the stage-by-stage internals see the pipeline deep-dive.

The pipeline

Describe ─► Plan ─► Scan ─► Aggregate ─► Judge ─► Report
 (Saga)   (jobs) (SARIF)  (per control) (gate)  (verdict)
  • Plan, expand enabled controllers × components into scan jobs (deterministic order).
  • Scan. Run jobs with bounded concurrency; results normalized to SARIF.
  • Aggregate, merge and deduplicate each control's findings, then correlate: a flaw two scanners both found is counted once, with both accounts kept.
  • Judge (the gate). Apply policy thresholds to produce a pass/fail verdict per control and overall.
  • Report. Render the run: a human summary to stdout (console by default, or markdown), plus machine formats (json, sarif); -o/--output writes report.json and results.sarif.

Verdict & exit code

The gate produces pass / fail. draugr scan exits non-zero on fail, so it gates a pipeline directly.

A run has one gate, and there are two questions it could ask. By default it asks what band a finding landed in for the component it was found in, failing on P1. That band folds in the exposure and criticality the descriptor declares, which is context no scanner can compute.

--fail-on (or config.gate.failOn) asks the other question instead: what severity the flaw has, with optional per-control overrides. Setting both is refused rather than combined, because a verdict with two possible reasons cannot be read back to the rule that produced it.

A severity gate reads a finding's ranked severity: the scanner's rating held at the control's minimum severity and then moved by every signal, the severity the report prints for the finding. A high on KEV is printed critical ↑ KEV and fails --fail-on critical. An unreachable critical is printed high ↓ unreachable, passes --fail-on critical and fails --fail-on high. In report.json, controls[].highest is the severity each control was judged on.

Which of the two catches more depends on the component, so neither is the stricter one. On a component that declares nothing, P1 means critical or high, the same findings --fail-on high catches, arrived at by asking the other question. On a component declared restricted and supporting, nothing reaches P1 at all, and the descriptor is saying that a flaw there does not stop a release.

A scan that checked nothing is not a pass

A descriptor that enables no control, or none whose surface its components carry, plans no work. Every stage after that behaves exactly as it would for a spotless application, no findings, no failures, PASS, and the two are indistinguishable in the output.

The wrong reading is the likelier one: a descriptor reaches that state by being unfinished, or by being generated with draugr survey, which describes a surface without enabling anything to check it. So the run reports that nothing ran, and the verdict fails.

A descriptor that asks only for an SBOM is exempt. It enables no control by design, and still produces the evidence it was asked for.

A control that couldn't run is not a pass

If a scanner is missing, exits badly, or a control can't be planned, that control checked less than it was asked to, so an empty report from it isn't evidence of anything. Draugr fails the run and says which control it was:

$ draugr scan . --controls sca
…
CONTROLS
  sca  ERROR  trivy not installed
  draugr tools install trivy  installs what sca needs

No findings. A control marked ERROR checked less than it was asked to.

draugr: scan incomplete: sca could not run (use --allow-scan-errors to accept partial results)

A run with no findings and at least one caveat closes on No findings. The run did not check what its caveats name. in place of No findings. ✓. With an ERROR and a caveat, the ERROR line is printed.

When one of a control's scanners fails beside another that reported, the control is still ERROR and fails in report.json, and controls[].reported lists the scanners that reported.

A missing tool that draugr tools install fetches is named on its control's row, and one command installs all of them. A tool it does not fetch keeps its full error, which ends by pointing to draugr doctor for where to get it.

This matters most in CI, where a scanner failing to provision is the common failure and a warning in the log goes unread. A green build from a check that never ran is the one outcome a gate must not produce.

A target no scanner could read fails the run the same way: a repository that does not exist, an image the registry does not have, a host that does not resolve, a cluster context that is not in the kubeconfig. The report names each target once, under Errors, with the components it leaves unscanned, and each of those components reads ERROR whatever else it found:

ERRORS  4 of 5 targets not reached
  Target                                         Components  Why
  repository https://github.com/draugr-dev/gone  api         Repository not found
  image ghcr.io/draugr-dev/gone:1                billing     manifest unknown
  host https://shop.invalid                      web         no such host
  cluster kubernetes/legacy                      reporting   context "legacy" does not exist

Images a registry refused are listed under that registry instead. Its answer and the login command are printed once for all of them, and each digest is shortened to twelve hex characters:

ERRORS  3 of 4 targets not reached
  contoso.azurecr.io  401 unauthorized, no credential
    Image                                               Components
    istio/install-cni:1.30.5@sha256:3392f63cfaef…       mesh
    istio/pilot:1.30.5-distroless@sha256:78052a996140…  mesh
    istio/proxyv2:1.30.5@sha256:abdd36489d1f…           ingress, mesh
  docker login contoso.azurecr.io  then scan again

no credential means the Docker configuration of the user who ran the scan, ~/.docker/config.json and the credential helpers it names, holds nothing for that registry. Run docker login as that user. Where a credential is present, 401 unauthorized keeps the login line, because a new login replaces a credential the registry rejected. 403 forbidden has none, because the registry accepted the credential and still declined the pull, and logging in again would not change its answer.

The scanner's whole message is in report.json, under targets[], with the whole digest, and the registry's answer under targets[].refusal.

Pass --allow-scan-errors for best-effort scanning. The run then passes on findings alone, and the verdict line carries partial with the number of targets not reached. The errored control is still reported either way; the flag buys a passing exit code, not silence.

A surface nobody looked at does pass, so it is reported instead

The third way a PASS can mean less than it appears is the one Draugr deliberately does not fail on. A component can declare images, or hosts, while the controls that examine them are off. Every enabled control runs, finds nothing wrong, and the verdict is a clean pass over a surface that was never opened.

Unlike the two above, this is not treated as an error, because a narrow descriptor is a legitimate thing to have: scanning a repository you do not own for committed secrets and nothing else is a real use, and a tool that failed the run would be failing a decision somebody made on purpose. A gate that objects to intent is one people learn to work around.

So by default it is reported rather than enforced. Every scan says what it did not look at, under Caveats:

CAVEATS  do not fail the run · config.gate.failOnCaveats lists the kinds that do
  Component  What    Caveat       Why
  api        hosts   not-checked  dast, headers, tls off
  api        images  not-checked  images off

A scan with no descriptor uses the same kind for a file whose scanner could not run on the machine, such as govulncheck for a go.mod without Go.

A dependency file a control did not read in full is unread: an unread-files caveat, one row per file, counted on the control's own row, as licenses pass no findings · 11 files unread. A component's files of one format whose licenses were not read share one row, as Cargo.lock ×3 beside records no licenses for 8 packages, and --evidence names each file under unread. licenses explains the reasons.

draugr doctor says the same thing before the scan, which is usually where you want to hear it, and --fail-on-uncovered turns it into a failure for a descriptor that is meant to be complete:

draugr doctor draugr.saga.yaml --fail-on-uncovered && draugr scan draugr.saga.yaml

A descriptor that is meant to be complete can say so itself, for this and for the other kinds of caveat, with config.gate.failOnCaveats. A caveat of a listed kind fails the run as an error does, and is marked where it sits:

CAVEATS  2 fail the run · config.gate.failOnCaveats: not-checked
  Component  What    Caveat         Why
  api        hosts   ✗ not-checked  dast, headers, tls off
  api        images  ✗ not-checked  images off

draugr doctor reads the same list, so the preflight fails where the scan would.

The answer comes from one place, so the scan, doctor and the MCP server cannot tell you different things about the same descriptor.

dast is never in the suggestion, and that is deliberate: it sends attack traffic at a live service, and enabling that because something noticed the service exists is not a decision Draugr gets to make for you.

Understanding the report

A finding is described on three related axes. Knowing which is which removes most confusion:

AxisValuesWhat it isWhere it shows
PriorityP1 · P2 · P3 · P4Draugr's headline ranking: severity × the component's exposure & criticality. "What to fix first."the band counts under the verdict, and the order of "fix first"
Severitycritical · high · medium · lowNormalized impact, from the first of: the CVSS score (security-severity); the scanner's own rating (Semgrep's impact and likelihood, Bandit's severity and confidence); the finding's level (error→high, warning→medium, note→low). Then held at the control's minimum severity and moved by every signal: KEV, EPSS and dependency health raise it, an unreachable verdict lowers it.the per-control counts, the "fix first" severity column, and a severity gate (--fail-on high)
Levelerror · warning · noteThe raw SARIF value each scanner maps into, the lowest common denominator.the machine formats (--format json/sarif)

So the same CVE can be critical severity but P3 priority on an internal tool, or P1 on a public, business-critical service. The human report (console/markdown/html) speaks priority + severity; level stays for machine output. The console view is color-coded on a terminal (verdict, priorities, severities) and honors NO_COLOR.

A worked example:

DRAUGR  FAIL  draugr-demo 1.0  11s

 224 P1 659 P2 237 P3 18 P4

CONTROLS
  iac         FAIL   4 P1 5 P2 15 P3 16 P4
  images      FAIL   194 P1 276 P2 41 P3
  licenses    FAIL   353 P2 178 P3
  provenance  pass   no findings
  sast        FAIL   7 P1 8 P2 2 P4
  sca         FAIL   18 P1 17 P2 3 P3
  secrets     FAIL   1 P1

MEASURED AGAINST
  provenance  cosign · coverage: 1 of 2 images checked, 1 unsigned · scope: 1 signer ·
              pinning: none, all by tag
  sca         govulncheck v1.8.0;db@2026-10-08 22:31:09 · database: local copy, fetched
              2026-10-11

COMPONENTS
  api         FAIL   220 P1 655 P2 233 P3
  storefront  FAIL   4 P1 4 P2 1 P3
  platform    pass   3 P3 18 P4

SIGNALS
  KEV           2 findings listed · 2 raised
  EPSS          3 findings raised
  floor         nothing raised
  reachability  govulncheck · 2 reachable, 3 unreachable

ACCEPTED
  config.exclude  1 finding suppressed
  VEX             1 finding excused

FIX FIRST  top 10 of 1138, by priority
  P1  critical  CVE-2025-15467 · OpenSSL: Remote code execution or Denial of Service via…
      scanner trivy · libssl3 3.0.14-1~deb12u2 → 3.0.18-1~deb12u2
      python:3.8-slim

  P1  critical  CVE-2025-15467 · Remote code execution or Denial of Service via oversized…
      scanner trivy · openssl 3.0.14-1~deb12u2 → 3.0.18-1~deb12u2
      python:3.8-slim

  P1  critical  CVE-2025-6965 · sqlite: Integer Truncation in SQLite
      scanner trivy · libsqlite3-0 3.40.1-2 → 3.40.1-2+deb12u2
      python:3.8-slim

  P1  critical  CVE-2026-31789 · OpenSSL: Heap buffer overflow on 32-bit systems from large…
      scanner trivy · libssl3 3.0.14-1~deb12u2 → 3.0.19-1~deb12u2
      python:3.8-slim

  P1  critical  CVE-2026-31789 · Heap buffer overflow on 32-bit systems from large X.509…
      scanner trivy · openssl 3.0.14-1~deb12u2 → 3.0.19-1~deb12u2
      python:3.8-slim

  P1  critical  CVE-2026-42010 · gnutls: Authentication Bypass via NUL Character in Username
      scanner trivy · libgnutls30 3.7.9-2+deb12u3 → 3.7.9-2+deb12u7
      python:3.8-slim

The Components block is where the classification pays off. api and platform share the iac control and the same rules, and the same findings land at P1 and P2 on one and P3 and P4 on the other, because one is internet-facing and business-important and the other is neither. Severity did not change; the consequence of it did, and it is the reason platform passes a run the project fails.

Every block above answers in bands, which is what the gate is set in and what the fix list is ordered by. What a scanner called a flaw is on the finding's own row, where the judgment about it was made.

Observability & security posture

Structured logs (log/slog), plus OpenTelemetry traces and metrics (opt-in via OTEL_*). Logs and span attributes never carry secrets. Draugr's own CI enforces govulncheck, gosec, and golangci-lint. It meets the bar it holds others to.

The report says which gate produced the verdict

A verdict means nothing without the policy behind it, and the policy can be changed by whoever runs the scan. So the report states it:

Gate: fails on critical severity, except licenses on critical.
  • A gate somebody chose is stated in the default view. Not because it is looser, but because a pass means something different under each question, and nothing else on the page says which one was asked.

  • The default says nothing until asked. A reader who configured nothing already has it, and spending a line on it every run buries the cases that are news.

  • --no-gate is stated loudest, and says what it changes: the verdict is reported and the command still exits 0, so anything reading the exit code is told the opposite of what the report says.

  • --evidence states the gate whatever it is, including the default, because "the default" is an answer only when the report gives it rather than leaving it to be assumed.

  • report.json carries it too, in a gate block, alongside the threshold each control was actually judged against. A verdict read by a machine, or on a dashboard somewhere else, has the same claim on its reason as one read in a terminal, and the descriptor that would otherwise answer for it does not travel with the report. See reports and publishers.

This is the same discipline as suppression: a decision that changes what the verdict covers stays visible, rather than disappearing into an exit code.