Core concepts

Verdict & gating

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. The failure threshold is configurable (--fail-on, default high), with optional per-control overrides, plus a component-aware priority gate (--fail-on-priority). The run fails if either gate trips.

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:

Controls:
  sca  ERROR  did not run
       trivy-fs: exec: "trivy": executable file not found in $PATH

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

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.

Pass --allow-scan-errors for best-effort scanning — the run then passes on findings alone. 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 it is reported rather than enforced. Every scan says what it did not look at:

Not checked:
      api declares hosts, and headers, tls are not enabled
      api declares images, and images is not enabled
      dast is never suggested — it sends attack traffic. Enable it yourself.

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

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 Priorities: line and the order of "fix first"
Severitycritical · high · medium · lowNormalized impact. From the CVSS score when a scanner provides one (security-severity), else derived from the finding's level (error→high, warning→medium, note→low).the per-control counts and the "fix first" severity column
Levelerror · warning · noteThe raw SARIF value each scanner maps into — the lowest common denominator.the machine formats (--format json/sarif) and the gate (--fail-on)

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 the gate and 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)

Priorities:  P1 67   P2 102   P3 82   P4 18

Controls:
  iac      FAIL   4 high  5 medium  12 low
  images   FAIL   9 critical  40 high  90 medium  77 low
  sast     FAIL   7 high  6 medium
  sca      FAIL   3 critical  6 high  8 medium  1 low
  secrets  FAIL   1 high

Components:
  api       FAIL   P1 67  P2 102  P3 79  iac, images, sast, sca, secrets
  platform  FAIL   P3 3  P4 18  iac

Fix first (top 10 of 269, by priority):
  Priority  Severity  Score  Rule            Control  Scanner  Location
  P1        critical  9.8    CVE-2019-20477  sca      Trivy    app/requirements.txt:4
            PyYAML: command execution through python/object/apply constructor in FullLoader
  P1        high      8.0    KSV-0014        iac      Trivy    deploy/pod.yaml:8
            Root file system is not read-only

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/P2 on one and P3/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.

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, except licenses on critical.
  • A narrowed gate is stated in the default view. A pass under a threshold looser than the default looks exactly like a pass under the default one, and nothing else on the page distinguishes them.
  • --no-gate is stated too, 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.
  • A stricter gate says nothing until asked. It can only fail more than a reader expects, and the failure explains itself.
  • --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.

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