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/--outputwritesreport.jsonandresults.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:
| Axis | Values | What it is | Where it shows |
|---|---|---|---|
| Priority | P1 · P2 · P3 · P4 | Draugr'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" |
| Severity | critical · high · medium · low | Normalized 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) |
| Level | error · warning · note | The 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-gateis 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. -
--evidencestates 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.jsoncarries it too, in agateblock, 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.