Report schema
On this page
What a Draugr report contains, field by field, for a gate or a dashboard reading it rather than a person. How to produce and deliver one is render a report and send it somewhere.
Telling a partial run from a clean one
A gate reading report.json should check more than verdict. A run where a scanner never started
and a run that found nothing both produce findings-shaped output, and the difference is what your
pipeline should do about it. One is broken infrastructure, the other is work.
| Field | Meaning |
|---|---|
controls[].scanErrors | what stopped that control, in the scanner's own words. A control with any scan error has "verdict": "fail" whatever its other scanners found, and the console marks it ERROR. Its counts cover only the scanners in controls[].reported. A control that produced nothing at all is still listed, with "verdict": "fail" and no counts. |
controls[].reported | the scanners that returned a report for a control with scanErrors, the ones its counts come from. A scanner that read one component's target and failed on another's is listed here and in scanners[].failed[]. Absent where every scanner failed. |
targets[] | each distinct target the run planned, named as draugr doctor names it: kind (repository, image, host or cluster), target (a repository's source and revision, an image's pinned reference, a host's URL, kubernetes/<cluster>), status and components, the components that declare it. status is reached when any scanner read the target, failed when every scanner that tried failed, and skipped when no scanner was run for it, because none could honor its scope or the tool was not installed, with detail saying why in the scanner's words or the skip's reason. A failed target, and one skipped for a missing tool, is a scan error, and controls[].scanErrors carries the same message. |
targets[].refusal | present on a failed image its registry declined to serve: registry, the host as docker login takes it (docker.io for Docker Hub); status, 401 when the registry asked for a credential and 403 when it declined the request it was given; and credential, whether the Docker configuration of the user who ran the scan, ~/.docker/config.json and the credential helpers it names, held a credential for that registry. credential is absent where that configuration could not be read. |
unreadChecks[] | per component, control and service, the checks a scan could not evaluate because a read they need was denied: component, control, service, checks[] and reason, such as denied compute.instances.list. A check listed here is not among the findings whatever the tool reported for it. Omitted when every check could be evaluated. |
policy | the organization's policy, checked before the scan against the draugr-api server the descriptor publishes to: server; checked, and where it could not ask, reason; the version judged against; the outcome, none, fail or refuse; and verdicts[], each with rule, profile, state, items[] of field, found, constraint and expected, and the setting's mode, enforceFrom, response and acts. Absent where the descriptor publishes to no draugr-api server and DRAUGR_API_URL is unset; with it set, present with checked: false. |
policy.run | the servers' verdict on the published run, its findings included, in the same shape as policy. pending: true where a rule was still undecided when --policy-wait ended; checked: false with the status as reason where the server answered 401, 404 or a 5xx. Absent where the run was published to no server that judges policy |
failedCaveats[] | the caveats of a kind gate.failOnCaveats lists, which fail the run as an error does: component, what, kind and detail. gate.failOnCaveats names the kinds. With --allow-scan-errors they are listed and the run passes |
notMeasured[] | a scanner that was planned and then not run because it could not answer the question its target asked, the control, scanner, component and reason. Not an error: nothing went wrong, and no scanErrors are recorded for it. |
scanners[].failed[] | each job of that scanner that returned an error, with control, component, kind, target and detail. kind and target name the target as targets[] does, and detail is the error as controls[].scanErrors carries it. A scanner whose every job failed is still in scanners[], with the version it reported; a scanner the descriptor did not configure is absent. |
dependencyFiles[] | per component and control, what the scanners that list their inputs read (read, a count), what none of them read (unread[]: repository, path and reason), and what one of them missed of a file another read (missed[]). |
dependencyFiles[].unread[], a dependency file | a dependency file no scanner read packages from. A reason is no lockfile, no pinned versions, no packages read, or not parsed: followed by the parser's error, as Trivy logs it. The packages the file declares were not checked. |
dependencyFiles[].missed[] | a dependency file one scanner read no packages from while another scanner of the control read it, one entry per file and scanner: repository, path, scanner, and reason in the words of unread[]. The file is counted in read, its packages were checked by the other scanner, and it is not a caveat: failOnCaveats does not fail on it. |
dependencyFiles[].unread[], iac | a Terraform file calling a module Trivy could not load, with a reason such as module "vpc" not loaded, or a Terraform root module directory with module calls Trivy skipped, with a reason such as 32 of 33 module calls checked; "zbad" skipped. The resources those modules define were not checked. A Helm chart's Chart.yaml where Trivy could not render the chart, with a reason such as run helm dependency build; redis not in charts/; none of the chart's templates were checked. An iac entry has read at 0. |
dependencyFiles[].unread[], secrets | a file Gitleaks skipped without reading it, with the reason name has an archive extension, or the repository root . with no files read where Gitleaks read nothing from a tree holding a file with content. A secret in it was not looked for. A secrets entry has read at 0. |
dependencyFiles[].unread[], sast | a source file Semgrep did not check in full, with a reason such as over 1 MB, not scanned or 1 rule timed out, not checked: "x". The file contents, or the rules, Semgrep did not run were not checked. A sast entry has read at 0 and appears only when a file is unread. |
dependencyFiles[].unread[], a go.mod govulncheck could not load | an sca entry for a Go module whose packages govulncheck could not load, listed whether or not another scanner read the file's packages. The reason starts govulncheck: and names the action where there is one, such as set GOFLAGS=-tags=noassets, under which cmd/infra/strelaypoolsrv/auto/noassets.go declares auto.Assets, or gives govulncheck's first diagnostic. The module's findings carry no reachability verdict, and advisories only govulncheck reports were not checked in it. |
dependencyFiles[].unread[], a requirements file read in part | a requirements file a scanner took packages from that holds requirements with no exact == version. The reason names them, such as 1 requirement not pinned: urllib3, or counts them past three, such as 29 requirements not pinned. Under Trivy's detectionPriority: comprehensive, which takes a range at its minimum version, the reason counts the requirements Trivy did not take, such as 2 requirements not read. The file is counted in read as well, and the requirements the reason covers were not checked. |
dependencyFiles[].unread[], a pom.xml read in part | a pom.xml Trivy left dependencies of unchecked, because it ran with --offline-scan and the poms it needed were not in the checkout or the local Maven repository. The reason counts the direct dependencies whose version comes from a parent POM, an imported BOM or a property one of them defines, such as 2 dependency versions not resolved, run mvn dependency:resolve; where every direct dependency has a version, it is transitive dependencies not resolved, run mvn dependency:resolve. A pom.xml grype-fs read is transitive dependencies not read by Grype, unless Trivy read the same file in part, whose reason is given instead. The file is counted in read as well. What the reason names was not checked, and under licenses neither were the licenses of the dependencies it covers. |
dependencyFiles[].unread[], a licenses file with no license | a dependency file trivy-license read packages from with no license for any of them, unread for licenses. It has an entry of its own in unread[], with one of two reasons: records no licenses for 12 packages, or licenses of 12 packages not read, run dotnet restore (…, run go mod download for a go.mod). The count leaves out the project's own root and workspace entries. Its packages were read, so read counts the file too. licenses says why each reason appears and what clears it. |
scanErrors, reported, notMeasured[] and scanners[].failed[] are omitted when there is nothing to report, so a clean run's
document is unchanged. targets[] is omitted when the run planned no target. dependencyFiles[] is present whenever a scanner that lists its inputs ran,
and an entry with no unread means every dependency file was read in full.
draugr scan draugr.saga.yaml -o out/
jq -e '[.controls[].scanErrors // empty] | length == 0' out/report.json # fail the build on a partial run
jq -e '[.dependencyFiles[]?.unread // empty] | length == 0' out/report.json # fail on a file no scanner read
jq -r '.targets[]? | select(.status != "reached") | "\(.kind) \(.target): \(.detail)"' out/report.json # what was not reached, and why
jq -r '.scanners[]? | "\(.name) \(.version // "")" as $s | .failed[]? | "\($s) \(.component // "") \(.target // ""): \(.detail)"' out/report.json # which scanner failed, at which version, and where
jq -r '[.targets[]?.refusal | select(.credential == false) | .registry] | unique[]' out/report.json # registries to log in to
Gate
gate in report.json is the policy the run was judged against, and the outcome of the gate on new
findings where a comparison ran.
The top-level verdict is the run's verdict, the one its exit code came from. On a scan given a
--base with a gate on new findings set, that is the gate on new
findings' outcome; the run also fails where the organization's policy fails or the scan could not
finish. The whole run's outcome against failOnPriority or threshold decides nothing there and is
kept in gate.wholeRun, with each control's own in controls[].verdict. On any other run, the
top-level verdict is the whole run's outcome and gate.wholeRun is absent.
| Field | Meaning |
|---|---|
gate.failOnPriority | the band a control fails at, on a run that gates on the band. The default, P1, is written out |
gate.threshold | the severity a control fails at, on a run that gates on severity. Exactly one of threshold and failOnPriority is present |
gate.perControl | the controls judged against a threshold of their own, in the gate's vocabulary |
controls[].highest | the most severe ranked severity among the control's findings, after the control's severityFloor, escalation and reachability. A severity gate compares it with controls[].threshold. Suppressed findings are left out |
controls[].highestPriority | the most urgent band among the control's findings, which a band gate compares with gate.failOnPriority. Suppressed findings and a second scanner's copy of a finding already counted are left out |
gate.disabled | true under --no-gate: the verdicts stand and the command exited 0 |
gate.failOnCaveats | the kinds of caveat that fail the run, from config.gate.failOnCaveats or --fail-on-caveats |
gate.failOnNew | the gate on new findings in force, from config.gate.failOnNew or --fail-on-new. Absent when neither set one |
gate.new | the comparison with the scan --base named, present only when one ran |
gate.new.base.commit | the revision the base read, where it read one repository. Absent where it read several |
gate.new.base.repositories[] | each repository the base read, with url and revision, as its results.sarif records them |
gate.new.introduced | the new findings by band, p1 to p4, without a second scanner's copy of a finding already counted |
gate.new.verdict | the gate on new findings' outcome, pass or fail. Absent where no failOnNew is set |
gate.wholeRun | the outcome of failOnPriority or threshold over every finding, pass or fail, on a run whose verdict the gate on new findings gave. Absent on any other run |
failOnNew with no new is a gate on new findings that was asked for and had no base to apply to.
From a scan given a --base, under failOn: critical and failOnNew: P2, of a change that
committed a token. The token is a high-severity P1, so the change fails and the whole run passes its
own gate, and the document's top-level verdict is fail:
"gate": {
"threshold": "critical",
"failOnNew": "P2",
"new": {
"base": {
"commit": "0b4a9232671686284eeb6d2dd7d6d26a503b013b",
"repositories": [
{
"url": ".",
"revision": "0b4a9232671686284eeb6d2dd7d6d26a503b013b"
}
]
},
"introduced": {
"p1": 1,
"p2": 0,
"p3": 0,
"p4": 0
},
"verdict": "fail"
},
"wholeRun": "pass"
}
jq -e '.verdict != "fail"' out/report.json # fail where the run failed, as its exit code did
jq -e '.gate.failOnNew == null or .gate.new != null' out/report.json # fail where the gate had no base
jq -r '.gate.wholeRun // .verdict' out/report.json # the whole run's outcome, whichever gate decided
What each finding carries
Draugr reports as one SARIF tool, so every finding keeps its own attribution in its property bag:
| Property | What it says |
|---|---|
control | The check that produced it, sca, sast, secrets, iac, images, licenses |
tool | The scanner that found it, trivy, semgrep, gitleaks |
component | The part of the application it belongs to |
repository | Which repository it was found in, for a component holding more than one |
priority | The band Draugr computed from that component's exposure and criticality |
exposure, criticality | That component's declared classification, the two inputs to the band that do not come from the scanner |
security-severity | The numeric score, where the scanner gave one |
draugr/rating | The scanner's own rating, set where it decided the severity: severity, detail (the line the report prints) and fields (the scanner's own names and values). Set for Semgrep (impact, likelihood, confidence, subcategory) and for an imported Bandit file (issue_severity, issue_confidence). The result's level stays the scanner's, and each entry in report.json's findings carries the same object as rating |
severityFloor | The minimum severity the finding's control holds its findings at, present only where it raised the scanner's rating, such as high on a secrets finding rated medium or low. Each entry in report.json's findings carries the same value |
escalation | Why the band is higher than the severity: the dataset, the fact, and the day it was fetched |
exploited | The KEV listing of the finding's CVE, whether or not it raised the severity: signal (kev), detail (on KEV) and asOf. Absent where KEV does not list the CVE or was not consulted. Each entry in report.json's findings carries the same object |
reachability | Whether your code can reach the vulnerable code, which analyzer decided, and how |
historical | true on a finding from a commit in the repository's history. Absent on a finding from the current tree. The location is the path the file had in that commit |
package.dev | true on a finding in a development dependency, a package the manifest declares for development only. Absent on a runtime dependency and where no scanner could tell. Each entry in report.json's findings carries it as dev, where dev: true means a development dependency. Development dependencies says which scanner marks one |
fingerprint | The finding's identity, a hash of its tool, rule, level, message, location, component and repository. report.json names findings by it in actions[].findings[], and in findings[].fingerprint when --min-priority lists them |
control and tool answer different questions, and both matter to anything grouping findings:
one rule id reported by two controls is two separate things to do.
exposure and criticality are what makes the band arithmetic checkable. Priority folds a
component's classification into the scanner's severity, so the same CVE is P1 on an internet-facing
service and P3 on a restricted one. Naming the component is not the same as stating that premise:
without these two, reconstructing a band means fetching the descriptor, and the descriptor in the
repository today is not necessarily the one that produced this finding. Both are empty for a
project-scoped finding, which belongs to no one component, and for a component that declares
neither, which Draugr reads as public and critical so that an unclassified component surfaces rather
than hides.
escalation and reachability are why a band is not what the severity alone would give. One
moves a finding up and one moves it down, and both carry the evidence rather than only the verdict,
the analyzer and method for reachability, the dataset and the date for escalation. A reader is told
to reject a reachability claim that does not say how it was reached; the same standard applies to a
claim that something is more urgent than its score.
The ranked severity is computed from these fields, in this order. Start from the score, the
draugr/rating severity, or the level (error high, warning medium, note low), in that order.
Raise it to severityFloor where present. Where escalation is present, take escalation.to, which
was raised from that floored severity. Where reachability found no path and carries rankedAs,
take rankedAs, which was lowered from the severity before it. The result is the ranked severity a
severity gate judges and controls[].highest reports.
historical: true marks a finding from the repository's commit history. Its location is the
path the file had in the commit that introduced the secret, which a file renamed or deleted since
no longer has. The credential is still readable by anyone who can clone the repository, so a
historical finding needs rotating like a tree one. A secret present in both the tree and the history
is reported once, as the tree finding. Each entry in report.json's findings carries the same
historical field.
Suppressions
A finding that something set aside stays in the report with the decision recorded. In report.json
each entry in findings carries it as suppressed. In results.sarif it is a suppressions
entry, with justification as its own field and the rest in its properties.
| Field | What it says |
|---|---|
justification | The reason given for setting it aside |
origin | Whose decision it was: saga for a rule in this project's descriptor, vex for a supplier's VEX claim this project accepted, tool for a directive written beside the line, scanner for an entry in the scanner's own ignore file, setting for a control's setting that sets a kind of finding aside |
author, asserted | For a vex suppression, who made the claim and the date they made it |
source | The descriptor file a rule was written in, where the descriptor is split across several, or the VEX document a claim came from. For a setting suppression, the key that decided, such as config.controls.sca.devDependencies |
line | For a rule in the descriptor, the line in source it starts at. With source it names the rule's entry in descriptor.exclusions[], which carries git's record of who wrote it |
statementId | For a vex suppression, the statement's @id within its document, where the supplier gave one |
digest | For a vex suppression, the sha256 of the document as Draugr read it |
To tell two revisions of one VEX document apart, compare digest. To raise a claim with its
supplier, quote statementId.
Unknown licenses
A license the scanner could not identify is reported under the rule license/unknown/<package>,
and the message quotes the text it found. deny and warn accept unknown to select every such
license.
What each descriptor source carries
descriptor.sources[] in report.json lists the files the run's descriptor was assembled from, in
order: the descriptor, its fragments as they merged, and each configuration file that supplied a
control default, least specific first. The
guide shows the block and the two digests it carries.
| Field | Meaning |
|---|---|
path | the descriptor's path as draugr scan was given it; a local fragment's path joined to the descriptor's directory, or a remote fragment's path in its repository; a configuration file's path as draugr config show names it |
digest | the sha256 of the file as read, before ${{ VAR }} substitution |
root | true for the descriptor |
url, revision, resolved | for a fragment fetched from another repository: the repository, the revision the descriptor names, and the commit it resolved to |
config | for a configuration file, where it was found: machine for ~/.draugr/config.yaml, project for ./draugr.config.yaml, or --config or DRAUGR_CONFIG for the file either one names, which is read alone |
descriptor.contributed maps each control setting that a file other than the descriptor supplied
to the files that supplied it. Absent when every setting came from the descriptor. Its keys:
- a fragment's setting:
control.option, for exampleprovenance.signers - a configuration file's setting: the path from the control's name, for example
secrets.gitleaks.history
A configuration file is listed only when one of its control defaults reached the descriptor, which
is when it changes descriptor.effective and descriptor.digest. A default the descriptor sets
itself is not listed, and neither is a file that sets no control default.
jq -r '.descriptor.sources[] | select(.config) | "\(.config) \(.path) \(.digest)"' out/report.json
jq -r '.descriptor.contributed // {} | to_entries[] | "\(.key) \(.value | join(", "))"' out/report.json
What each exclusion carries
report.json lists the merged descriptor's config.exclude
entries as descriptor.exclusions[], in the order descriptor.effective lists them. Each entry
names the file and line it is written at, and the last commit to change that line, as git blame
names it.
| Field | Meaning |
|---|---|
source | the descriptor or fragment: the descriptor's path as draugr scan was given it, a local fragment's path joined to the descriptor's directory, or <url>@<revision> <path> for a fragment fetched from another repository, with the commit the revision resolved to in parentheses after it when the two differ |
line | the line the entry starts at |
reason | the entry's reason |
gitHistory.path | the file's path in the repository that holds it. Absent where no git repository holds the file |
gitHistory.commit, gitHistory.author, gitHistory.date | the last commit to change the line, its author's name, and its author date in RFC 3339 with the author's offset. Present only where the history is reliable |
gitHistory.unavailable | why no commit is reported. Present exactly where commit is absent |
A finding an entry set aside names it by the same source and line, on the finding's
suppressed object in report.json and in its suppression's properties in
results.sarif.
The history is git's record, reported only where it is reliable. That is where the checkout holds the line's history and the last commit to change the line is not the boundary of a shallow clone. A shallow clone attributes every line it holds no history for to its boundary commit, whoever wrote the line, so a line at the boundary is reported as unavailable rather than attributed. No email address is recorded, and nothing is fetched to deepen a clone.
unavailable | What to do |
|---|---|
the line is older than the shallow clone's history; fetch full history | set the fetch depth to full history, see Full history |
the line is not committed | commit the change, and scan the commit |
the file is not committed | commit the file, and scan the commit |
the file is in no git repository | scan from inside the repository |
git is not installed | install git |
git blame failed: <git's message>, git rev-parse failed: <git's message> | fix what git's message names, such as a repository owned by another user |
git blame did not answer within 10s | retry; blame on a very large file or repository can exceed 10s |
A fragment fetched from another repository always names a revision, and Draugr clones its
repository with full history to check that revision out. Its entries carry that repository's
history, and gitHistory.path is the path in that repository.
Full history
Most CI systems check out a shallow clone by default. Full history on the scan job makes the history reliable for every entry:
| CI | Setting |
|---|---|
| GitHub Actions | fetch-depth: 0 on actions/checkout |
| GitLab CI | GIT_DEPTH: 0 in the job's variables |
| Azure Pipelines | fetchDepth: 0 on the checkout step |
jq -r '.descriptor.exclusions[] | "\(.source):\(.line) \(.gitHistory.commit // .gitHistory.unavailable)"' out/report.json
Component labels
componentLabels in report.json maps each component the descriptor declares to its
labels, after fragments are merged, with no key or value
normalized.
"componentLabels": {
"storefront": {"team": "web", "data-class": "pii"},
"worker": {}
}
Every declared component has an entry, {} where it declares no labels. A run narrowed with
--components or --labels still lists every one, and scope names the components that ran. A
missing componentLabels means the labels were not recorded, as in a report from a release that
predates the key.
jq -r '.componentLabels | to_entries[] | select(.value.team == "web") | .key' out/report.json # the components labeled team=web
What each action carries
report.json carries the fix list as actions[], most urgent
first, grouped as draugr scan --view actions groups it. An action is one change and every finding
that change clears.
| Field | Meaning |
|---|---|
id | identifies the action across runs. It changes when what the action groups on, or its target, changes; a dependency action groups on its component, package and installed version, and a sast action on its component, repository, file and line |
title, summary | what to do, and the scanner's description of the rule where the title names a rule. For code, the title is Fix followed by one scanner's description of the flaw, in quotes, or the rule's name where one rule flags the line and its description does not fit the title |
kind | the kind of work: bump (upgrade a dependency), code (change the code or its configuration, or investigate what it talks to), infrastructure (IaC, a cluster or a cloud account), credential (remove and rotate a committed secret), wait (a fix that has to come from somebody else's release, or no fixed version yet) or decide (a license, an operating system past its end of life, a finding that reports a fact). A control that could be code or infrastructure counts as code |
component | the component every finding belongs to. Absent where the findings span several, which only an action for a rule or an image can |
control, priority | the control the findings came from, and the highest band among them |
clears | how many findings the action resolves |
severity | the worst ranked severity among the findings, after the control's severityFloor, KEV, EPSS and reachability analysis, the severity the band was computed from |
severityFrom | the worst severity as the scanners reported it. Present only where a minimum severity, KEV, EPSS or reachability analysis changed the worst |
exploitation | the strongest exploitation evidence among the findings, KEV outranking EPSS. Taken from a finding's exploited where KEV lists it, else from the escalation of a finding EPSS raised. Fields: signal (kev or epss), ruleId (the CVE), detail (on KEV, EPSS 0.87) and asOf. Absent where no finding is on KEV and EPSS raised none |
upstream | true where the unit of work is something somebody else publishes, such as an image |
ecosystem, package, from | the package's ecosystem, its name and the version installed, for an action that upgrades or replaces a dependency |
versioning | the ecosystem whose rules order the package's versions, in lower case, such as debian, red hat, maven, pypi or npm. ecosystem is the scanner's own name, which can differ, as pip does from pypi |
target | the lowest release that clears every finding, by the ecosystem's own version order. Absent where no one release can be named |
policy | the component's fixes.upgrade, patch or minor, on an upgrade it applies to. Absent for the default, major |
withinPolicy | true for an upgrade within policy, false for the step past it, which carries only the findings it alone clears |
after | on the step past the policy, the id of the step within it, which this one presumes has been taken |
beyond | on a step past the policy, its size, minor or major, measured from the release the after step reaches, or from the installed version when there is no after. With policy it makes the label major · beyond policy minor |
policyApplies | false where the versions are not semantic, such as a distribution's packages, so the action was not split. Absent wherever withinPolicy is present |
fixedVersions | the release each advisory names as its fix, in its own words |
locations[] | every place the findings are, then each other file holding a second report of one of them, such as a vendored copy or the same advisory in a second Go module. A second report is a result results.sarif marks with countedUnder, and it adds its file here and nothing to clears. Each place has repository, path, line and kind. kind is manifest, lockfile, vendored for a copy of the dependency committed to the tree, image, or file |
where, ruleIds | the first five locations as text, then and N more, and the first five rule identifiers. A location names its repository when the action spans several, and a code action's names it once the list does. A code action's ruleIds start with the rule its title quotes, then follow the console's order, by scanner |
findings[] | the fingerprint of each finding the action clears, most urgent first. Each matches the fingerprint property of one result in results.sarif |
priority, exploitation, severity and clears are the keys the list is ordered
by, so two actions can be compared from
their own fields. The two steps of an upgrade split by policy rank together, by the worst band
and every finding either step clears:
exploitation,severityandseverityFromare the package's, the same on both steps;priorityandclearsare each step's own.
The MCP server's fix_list returns the same objects without findings[], in one list per kind.
jq -r '.actions[] | select(.package) | "\(.component) \(.package) \(.from) → \(.target // "no single release")"' out/report.json
jq -r '.actions[].locations[] | select(.kind == "vendored") | .path' out/report.json # copies a manifest bump leaves as they were
jq -s '.[0].actions[0].findings as $ids | .[1].runs[].results[] | select(.properties.fingerprint | IN($ids[])) | .ruleId' out/report.json out/results.sarif # what the first action clears
What the cache contributed
A finding can come from a cache rather than from a scan, and stats.cache says what that rests on.
"cache": {
"enabled": true,
"dir": "/home/runner/.cache/draugr",
"ttl": "24h0m0s",
"readOnly": false,
"hits": 12,
"oldestHit": "2026-08-31T04:11:02Z",
"unpinned": ["ghcr.io/acme/api:1.4.2"]
}
| Field | What it says |
|---|---|
enabled | whether a cache was in use at all. Always present, because a run told not to cache and a run whose every entry expired both report zero hits, and a missing key is not an answer |
dir | the directory as it was given, neither resolved nor probed. Enough to tell a CI cache from a laptop's; what restored an archive into it is the pipeline's fact rather than the scan's |
ttl | how long an entry stays usable. Absent when there is no expiry, which --cache-ttl 0 selects and which means a hit has no upper bound on its age |
readOnly | a cache this run served from and did not write to |
hits | the same number as cacheHits, beside the caveats that qualify it |
oldestHit | when the oldest reused entry was written. An instant rather than an age, because a duration is true only at the moment it is computed and a kept report is read later |
unpinned | targets whose reused result could not be content-addressed, today images named by a tag alone. Such a hit is right about its key and possibly wrong about the image: the tag can have been rebuilt since, and nothing in the reused report says so |
A gate that re-runs when results are too old has everything it needs here:
jq -e '.stats.cache.enabled and (.stats.cache.unpinned // [] | length == 0)' out/report.json
Tool memory
stats.peakToolMemoryBytes is the largest resident memory, in bytes, that any single process
reached among the tools Draugr ran and the processes those tools started and waited for, such as
the compile processes of a Go build. It is the largest one process, not the sum of the processes
that ran at the same time. The operating system keeps the figure for the life of the Draugr
process, so under draugr mcp, which runs several scans in one process, it covers every scan since
the server started. Absent where the operating system does not report it, which is the case on
Windows. A figure near the memory of the machine or container sets the bound too high for it; a
lower GOMAXPROCS, and -p in GOFLAGS, set the bound for the next run, as the CLI
reference describes under Each tool's own parallelism.
jq '.stats.peakToolMemoryBytes / 1073741824' out/report.json # in GiB
Effects
effects records the scanner effects the run
accepted (allowed), the ones its descriptor declares (declared), and the ones its scanners had
(used). allowed differs from declared only by what --allow-effects added.
| Field | Meaning |
|---|---|
declared | config.allowEffects in the descriptor the run read, fragments included |
allowed | the effects the run accepted, declared and --allow-effects together |
used[] | one entry per effect of a scanner that executed in this run, with kind and detail (the scanner's statement of what happens to the target). A result reused from the cache does not count, because nothing was sent to produce it |
All three lists are present in every report, empty when nothing applies.
network and disclosure run without being accepted, so used can name a kind allowed does not
hold. Only mutate and privilege in used are bounded by allowed.
The block below is from a run whose descriptor declares allowEffects: [mutate], given
--allow-effects privilege, with a headers check against one host.
"effects": {
"declared": ["mutate"],
"allowed": ["mutate", "privilege"],
"used": [
{
"kind": "network",
"detail": "sends an HTTP GET to the endpoint and follows its redirects"
}
]
}
jq -e '.effects.allowed - .effects.declared | length == 0' out/report.json # fail when a flag accepted an effect the descriptor does not
SBOMs
sboms records each SBOM document the run wrote and each it
failed to produce.
| Field | Meaning |
|---|---|
documents[] | each document the run wrote, with scope, component, target, format, file and digest |
documents[].scope | component for the inventory of one repository or image, project for the document assembled from them, which has no component or target |
documents[].target | the repository's source or the image's pinned reference. A repository is named without the @<revision> that targets[].target adds |
documents[].file | the file's name in the -o directory, and the name every publisher delivers it under |
documents[].digest | the SHA-256 of the file, as sha256:<hex> |
failed[] | each document the run did not produce, with scope, component, target, format and reason (the generator's error). target is absent on a project document that could not be assembled, and on a failure that belongs to no one target, such as a run with no SBOM generator or a canceled run |
Each failed[] entry is also one of the scanErrors of the (sbom) entry in controls[], so the
run is incomplete unless --allow-scan-errors accepts it. targets[] accounts for the controls'
scans and not for SBOMs, so a repository whose inventory failed can be reached there, or absent
when no control scans it.
Under scope: project a target listed in failed[] is missing from the assembled document.
sboms is absent when the run wrote no document and recorded no failure, which includes every run
with config.sbom off.
The block below is from a run of one component holding two repositories, the second of which could not be cloned.
"sboms": {
"documents": [
{
"scope": "component",
"component": "storefront",
"target": "https://github.com/draugr-dev/draugr-demo",
"format": "cyclonedx-json",
"file": "sbom-storefront-https-github-com-draugr-dev-draugr-demo.cdx.json",
"digest": "sha256:b9ffb8fd666d3a4437eabf3cb82c006f794a44dec2aaf136bea48b37b524b209"
}
],
"failed": [
{
"scope": "component",
"component": "storefront",
"target": "https://github.com/draugr-dev/storefront-theme",
"format": "cyclonedx-json",
"reason": "checkout https://github.com/draugr-dev/storefront-theme: git clone: exit status 128: remote: Repository not found.\nfatal: repository 'https://github.com/draugr-dev/storefront-theme/' not found\n"
}
]
}
jq -r '.sboms.failed[]? | "\(.target // .scope): \(.reason)"' out/report.json # which inventories are missing, and why
(cd out && jq -r '.sboms.documents[]? | "\(.digest | ltrimstr("sha256:")) \(.file)"' report.json | sha256sum -c) # the files are the ones recorded
What the run carries
Some statements are about the scan rather than about any one finding, and those live in the run's own property bag:
| Property | What it says |
|---|---|
draugr/provenance | What each scanner said about its own run, the standard applied, the scope, how much of it could be decided. An entry stating coverage carries component, the component whose tree it describes |
decided | The classifications this run settled, whether or not a finding resulted |
consulted | The datasets the run ranked with: the exploitability feeds it had loaded, and dependency-health when its lookup answered. Each entry gives the signal, the day the copy was obtained, whether it was older than the run's maxAge, how many records it held, the EPSS threshold, and what set it |
decided and consulted both exist to separate "looked and found nothing" from "never
looked." A scanner that reports nothing about a control has either examined it and been
satisfied or never examined it; escalation appears on a finding only when a signal raised it,
so its absence covers a CVE that is not listed, one that is listed but was already at the top
band, and a dataset nobody loaded at all. exploited is on every finding KEV lists, raised or not,
which tells the second case from the other two.
Without consulted, anything explaining a priority, a dashboard, a pull-request comment, a person
reading the file, cannot tell not on KEV from KEV was not consulted, and silence reads as the
second. asOf is empty when you supplied a feed file by hand, which has no fetch to record;
entries is there because a dataset that loaded and turned out to be empty answers every lookup
with "not listed" and looks exactly like one that is working. stale is true when the copy was
older than the run's maxAge when it was read, and absent otherwise; the run is the only reader
that knew the limit.
thresholdFrom names what set the EPSS threshold, the default,
config.exploitability.epssThreshold, or --epss-threshold. The number alone is the one input to
an escalation that arrives anonymous: the dataset names itself and the day its copy was obtained,
while the line a score was measured against does not say who drew it. Whether a band is a policy or
an accident depends on the answer.
A run that loaded no exploitability data and whose dependency-health lookup did not answer writes no consulted block. A lookup that failed, or that knew none of the run's packages, leaves no dependency-health entry. Its entries is the number of packages it knew, and the fields that describe a feed, stale and the threshold, are absent from it.
The sarif report is also what your editor reads. See see findings in your
editor for inline diagnostics in VS Code and JetBrains.
For the exact schema of config.publishers, see the
Saga schema; for the full
catalog of reporters and publishers, see the
integrations catalog.