Reference

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.

FieldMeaning
controls[].scanErrorswhat 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[].reportedthe 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[].refusalpresent 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.
policythe 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.runthe 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 filea 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[], iaca 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[], secretsa 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[], sasta 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 loadan 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 parta 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 parta 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 licensea 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.

FieldMeaning
gate.failOnPrioritythe band a control fails at, on a run that gates on the band. The default, P1, is written out
gate.thresholdthe severity a control fails at, on a run that gates on severity. Exactly one of threshold and failOnPriority is present
gate.perControlthe controls judged against a threshold of their own, in the gate's vocabulary
controls[].highestthe 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[].highestPrioritythe 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.disabledtrue under --no-gate: the verdicts stand and the command exited 0
gate.failOnCaveatsthe kinds of caveat that fail the run, from config.gate.failOnCaveats or --fail-on-caveats
gate.failOnNewthe gate on new findings in force, from config.gate.failOnNew or --fail-on-new. Absent when neither set one
gate.newthe comparison with the scan --base named, present only when one ran
gate.new.base.committhe 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.introducedthe new findings by band, p1 to p4, without a second scanner's copy of a finding already counted
gate.new.verdictthe gate on new findings' outcome, pass or fail. Absent where no failOnNew is set
gate.wholeRunthe 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:

PropertyWhat it says
controlThe check that produced it, sca, sast, secrets, iac, images, licenses
toolThe scanner that found it, trivy, semgrep, gitleaks
componentThe part of the application it belongs to
repositoryWhich repository it was found in, for a component holding more than one
priorityThe band Draugr computed from that component's exposure and criticality
exposure, criticalityThat component's declared classification, the two inputs to the band that do not come from the scanner
security-severityThe numeric score, where the scanner gave one
draugr/ratingThe 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
severityFloorThe 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
escalationWhy the band is higher than the severity: the dataset, the fact, and the day it was fetched
exploitedThe 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
reachabilityWhether your code can reach the vulnerable code, which analyzer decided, and how
historicaltrue 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.devtrue 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
fingerprintThe 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.

FieldWhat it says
justificationThe reason given for setting it aside
originWhose 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, assertedFor a vex suppression, who made the claim and the date they made it
sourceThe 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
lineFor 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
statementIdFor a vex suppression, the statement's @id within its document, where the supplier gave one
digestFor 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.

FieldMeaning
paththe 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
digestthe sha256 of the file as read, before ${{ VAR }} substitution
roottrue for the descriptor
url, revision, resolvedfor a fragment fetched from another repository: the repository, the revision the descriptor names, and the commit it resolved to
configfor 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 example provenance.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.

FieldMeaning
sourcethe 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
linethe line the entry starts at
reasonthe entry's reason
gitHistory.paththe file's path in the repository that holds it. Absent where no git repository holds the file
gitHistory.commit, gitHistory.author, gitHistory.datethe 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.unavailablewhy 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.

unavailableWhat to do
the line is older than the shallow clone's history; fetch full historyset the fetch depth to full history, see Full history
the line is not committedcommit the change, and scan the commit
the file is not committedcommit the file, and scan the commit
the file is in no git repositoryscan from inside the repository
git is not installedinstall 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 10sretry; 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:

CISetting
GitHub Actionsfetch-depth: 0 on actions/checkout
GitLab CIGIT_DEPTH: 0 in the job's variables
Azure PipelinesfetchDepth: 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.

FieldMeaning
ididentifies 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, summarywhat 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
kindthe 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
componentthe component every finding belongs to. Absent where the findings span several, which only an action for a rule or an image can
control, prioritythe control the findings came from, and the highest band among them
clearshow many findings the action resolves
severitythe worst ranked severity among the findings, after the control's severityFloor, KEV, EPSS and reachability analysis, the severity the band was computed from
severityFromthe worst severity as the scanners reported it. Present only where a minimum severity, KEV, EPSS or reachability analysis changed the worst
exploitationthe 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
upstreamtrue where the unit of work is something somebody else publishes, such as an image
ecosystem, package, fromthe package's ecosystem, its name and the version installed, for an action that upgrades or replaces a dependency
versioningthe 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
targetthe lowest release that clears every finding, by the ecosystem's own version order. Absent where no one release can be named
policythe component's fixes.upgrade, patch or minor, on an upgrade it applies to. Absent for the default, major
withinPolicytrue for an upgrade within policy, false for the step past it, which carries only the findings it alone clears
afteron the step past the policy, the id of the step within it, which this one presumes has been taken
beyondon 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
policyAppliesfalse where the versions are not semantic, such as a distribution's packages, so the action was not split. Absent wherever withinPolicy is present
fixedVersionsthe 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, ruleIdsthe 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, severity and severityFrom are the package's, the same on both steps;
  • priority and clears are 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"]
}
FieldWhat it says
enabledwhether 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
dirthe 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
ttlhow 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
readOnlya cache this run served from and did not write to
hitsthe same number as cacheHits, beside the caveats that qualify it
oldestHitwhen 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
unpinnedtargets 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.

FieldMeaning
declaredconfig.allowEffects in the descriptor the run read, fragments included
allowedthe 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.

FieldMeaning
documents[]each document the run wrote, with scope, component, target, format, file and digest
documents[].scopecomponent for the inventory of one repository or image, project for the document assembled from them, which has no component or target
documents[].targetthe repository's source or the image's pinned reference. A repository is named without the @<revision> that targets[].target adds
documents[].filethe file's name in the -o directory, and the name every publisher delivers it under
documents[].digestthe 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:

PropertyWhat it says
draugr/provenanceWhat 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
decidedThe classifications this run settled, whether or not a finding resulted
consultedThe 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.