πŸ“Ž Unreleased docs. This is the next version (from main), features here may change or not be in a release yet.See the latest release β†’

Guides

Reports & publishers

Draugr separates the report (the "what", a rendered format) from the publisher (the "where", a destination). Configure both in the Saga's config.reports / config.publishers, and every rendered report is delivered to every publisher. Even on a FAIL verdict, so you always get evidence.

Report formats#

Scan results render through a pluggable Reporter, selected on the CLI with draugr scan --format or declared per format under config.reports:

FormatPurpose
consolehuman summary on stdout (default), verdict, P1–P4 counts, "fix first"
markdownportable report for MR comments, wikis, Slack
htmlself-contained HTML report, searchable, filterable, and carrying its own SARIF and TSV downloads. See below
junitJUnit XML, surfaces findings in CI test panels (GitLab, Jenkins, Azure DevOps…)
jsonmachine-readable report
sarifSARIF 2.1.0 for code-scanning dashboards
vexOpenVEX, which of these vulnerabilities actually affect your product, for the people who consume your SBOM
gitlab-sastGitLab's own security schema, for its Vulnerability Report. Written as a build artifact, not uploaded. GitLab has no endpoint. See below
gitlab-dependency-scanningthe same, for vulnerable dependencies, each with the package and version GitLab requires
gitlab-secret-detectionthe same, for leaked credentials
gitlab-container-scanningthe same, for vulnerable packages in a container image, each with the image and the operating system GitLab requires
gitlab-codequalityGitLab Code Quality, every finding, in the merge request, on any tier
templatecustom payload from a Go text/template (inline or file), no code needed

-o/--output <dir> always writes report.json + results.sarif regardless of --format.

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. Its counts then describe what the scanners that did run found, which is not the same as what is there. A control that produced nothing at all is still listed, with "verdict": "fail" and no counts.
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.

Both are omitted when there is nothing to report, so a clean run's document is unchanged.

draugr scan draugr.saga.yaml -o out/
jq -e '[.controls[].scanErrors // empty] | length == 0' out/report.json   # fail the build on a partial run

What produced the run#

report.json records what a scan found. Two blocks record what was asked for, and both exist only in the process that ran the scan. Nothing downstream can recover them.

descriptor is the Saga the run came from:

"descriptor": {
  "digest": "sha256:b32eab97bd2d…",
  "sources": [
    {"path": "draugr.saga.yaml", "digest": "sha256:1cd43153e6c0…", "root": true},
    {"path": ".draugr/exclusions.saga-fragment.yaml", "digest": "sha256:eedd6b25aed0…"}
  ]
}

descriptor.effective is the merged descriptor itself, as YAML, the same bytes the digest is taken over, so a reader can check one against the other rather than being asked to trust that a number describes a file they cannot see:

jq -r '.descriptor.effective' report.json | sha256sum   # equals descriptor.digest

It is the merged form, so it is not any file in your repository. That is what makes it worth having: it answers what did this run actually apply, including the exclusions a fragment contributed, which is the question somebody has when a finding was suppressed and the descriptor they have open does not say why.

It carries no credential. A descriptor has a field for the name of an environment variable and none for a value, and ${{ VAR }} substitution does not reach tokenEnv.

The two kinds of digest answer different questions and neither substitutes for the other. descriptor.digest is over the merged, effective descriptor with ${{ VAR }} substituted, so two runs carrying the same one were asked the same thing. It moves when a fragment changes, and does not move when a comment does. Each sources[].digest is over that file as committed, so it answers whether the text somebody reviewed is the text that ran.

A fragment fetched from another repository also carries url, revision and resolved. Pin on resolved: a branch moves, so it is the commit, not the reference, that makes the run reproducible.

ci is the job the scan ran in, system, repository, ref, workflow, job, runId, attempt and, where the platform publishes enough to build one, url. GitHub Actions, GitLab CI, Azure Pipelines, CircleCI and Buildkite are recognized; outside them the block is absent rather than guessed at.

# Two runs, one question: did anything about the descriptor change between them?
jq -r '.descriptor.digest' a/report.json b/report.json | uniq | wc -l

Both blocks are absent when there is nothing to record, a scan with no descriptor, or one run outside CI, so a document that has them is one that knows, rather than one that defaulted.

Declare formats and destinations#

config:
  reports:
    - format: sarif        # for code scanning / dashboards
    - format: markdown     # a portable report (MR comment, wiki)
    - format: html         # a shareable, browser-viewable artifact
    - format: template     # custom payload from a Go text/template
      template: "{{.Verdict}}: P1={{.Priorities.P1}} P2={{.Priorities.P2}}\n"
      filename: summary.txt   # optional; overrides the default output filename
  publishers:
    - kind: file           # write each report to a directory
      dir: ./out           # β†’ ./out/results.sarif, ./out/report.md, ./out/report.html, ./out/summary.txt

The template format renders a Go text/template against a stable view of the scan, .Release, .Verdict, .Pass, .Priorities.{P1..P4}, .Controls, and .Findings (each with .Priority .Level .Score .Control .Tool .RuleID .Message .Location). Use it for a bespoke summary line, a Slack payload, or any custom text without writing code.

Built-in publishers#

KindDelivers toConfig
filea local directory (one file per report format)dir
githubGitHub code scanning (uploads the sarif report to the Security tab)repo, commit, ref (default from the GitHub Actions env); token from $GITHUB_TOKEN (or tokenEnv)
github-pr-commenta sticky pull-request comment (posts the markdown report)repo, pr (default from the env); token from $GITHUB_TOKEN (or tokenEnv)
azure-pr-commenta sticky Azure DevOps pull-request comment (posts the markdown report)org, project, repo, pr (default from the Azure Pipelines env); token from $SYSTEM_ACCESSTOKEN (or tokenEnv)
gitlab-mr-commenta sticky GitLab merge-request comment (posts the markdown report)repo, pr (default from the GitLab CI env); token from $GITLAB_TOKEN (or tokenEnv)
draugr-apiany server implementing Draugr's run-ingest API (posts the json report, uploads the sarif one)url (or $DRAUGR_API_URL); token from $DRAUGR_API_TOKEN (or tokenEnv)

No publisher stores a secret in the Saga. Every token comes from an environment variable, and each no-ops outside its own context (not in CI, or no PR) so the same Saga still runs locally. Every comment publisher upserts one sticky comment, updated in place on each push, and pairs with draugr diff --publish for a PR security delta. The github publisher pairs with code scanning.

A server that keeps your runs#

draugr-api sends the run somewhere it can be compared with the ones before it. The terminal shows one scan; a server that keeps them shows the same findings across an organization and over time, which are new since last week, which have been there for months, which somebody accepted and who. That is the part a screenshot cannot show.

config:
  reports:
    - format: json      # the run
    - format: sarif     # its evidence
  publishers:
    - kind: draugr-api
export DRAUGR_API_URL=https://draugr.acme.example
export DRAUGR_API_TOKEN=drgr_ci_…      # write-only, scoped to one project

Named for the protocol, not for a product. Draugr Server implements it, hosted and as an install you run yourself, the same artifact either way, so url points at either and nothing else changes. Anything else that implements the three calls below works identically. The publisher does not know or care which it is talking to.

With neither variable set it skips, so the descriptor a pipeline uses still runs on a laptop. Setting one without the other fails the scan, because a publish that silently did not happen is one somebody believes did.

The three calls

Written down so the endpoint is an interface rather than a private arrangement. A server that implements these receives Draugr runs from any pipeline, with no change to the descriptor beyond its URL.

1. POST /v1/runs with report.json as the body:

Authorization: Bearer <token>
Idempotency-Key: <a CI job id, or the digest of the report>
X-Draugr-Evidence-Sha256: <hex>     ← both or neither
X-Draugr-Evidence-Bytes: <n>

Answer 201 with a run id, or 200 and "duplicate": true if that key already made one, CI retries, and a retry must not become a second run.

{"run": "…", "duplicate": false,
 "evidence": {"held": false, "upload": "https://…", "complete": "/v1/runs/…/complete"}}

Answer "held": true and no URL when you already have an object with that digest. Draugr then uploads nothing, which is what makes a re-run that found the same things almost free.

2. PUT to the URL you returned, with results.sarif as the body. It goes straight there and never through the API. At roughly 2.5 KB of SARIF per finding, a descriptor covering twenty images is around 20 MB before anything unusual happens, and a request body is the wrong place for it.

A presigned object-storage URL is the obvious implementation; a URL pointing back at your own server is equally valid, and is how an install with no object store works.

3. POST /v1/runs/{id}/complete once the upload lands.

Failures answer a stable error code and a short human detail, and Draugr reports both, a build log saying 400 Bad Request tells the reader nothing they can act on.

{"error": "invalid_field", "detail": "verdict: required; post report.json, not results.sarif"}

Two lines worth recognizing in a build log:

  • evidence already held. A re-run produced the same findings, so there was nothing to upload.
  • run already recorded, a retried job, recognized as the same run rather than counted twice.

Azure DevOps#

In a pipeline everything defaults from the environment, so the whole configuration is:

config:
  reports:
    - format: markdown
  publishers:
    - kind: azure-pr-comment

Two things Azure requires that nothing in the Saga can do for you:

Map the access token into the step. SYSTEM_ACCESSTOKEN is the one pipeline variable not exposed to scripts by default:

- script: draugr scan draugr.saga.yaml
  env:
    SYSTEM_ACCESSTOKEN: $(System.AccessToken)

Let the build identity write. In Project settings β†’ Repositories β†’ your repo β†’ Security, grant <Project> Build Service the Contribute to pull requests permission. Without it the API answers 403 with the token perfectly valid, so Draugr names the permission in the error rather than leaving you re-checking the token.

And a pull-request build has to exist in the first place: Azure Repos ignores a pr: trigger, so that comes from a build validation branch policy. See Azure Pipelines.

Azure models a PR comment as a thread, so the sticky comment is the first comment of the thread carrying Draugr's marker. The marker is matched on that first comment only, a reviewer who quotes the report in a reply gets their words left alone.

The thread is created active. If your branch policy requires all comments to be resolved before merging, someone has to resolve Draugr's thread; set a distinct marker per pipeline if you run more than one Draugr scan against the same pull request.

GitLab#

Everything defaults from the runner environment, so the whole configuration is:

config:
  reports:
    - format: markdown
  publishers:
    - kind: gitlab-mr-comment

CI_JOB_TOKEN cannot post the comment. GitLab puts it in every job, and it is read-only on the notes API. It can list notes and not write one. So the publisher reads GITLAB_TOKEN, which has to be a project or group access token with api scope and at least Developer role, added under Settings β†’ CI/CD β†’ Variables as a masked variable:

draugr:
  variables:
    GITLAB_TOKEN: $DRAUGR_GITLAB_TOKEN

Draugr names this in the error rather than letting the job token produce an unexplained 401.

A merge-request pipeline has to exist for there to be anything to comment on. Branch pipelines carry no CI_MERGE_REQUEST_IID, and the publisher no-ops there, so one Saga serves both.

For the pipeline itself, see use in CI with GitLab.

The project is taken from CI_PROJECT_ID. Setting repo to a full path instead works too, groups and all. group/subgroup/project is encoded for you.

Draugr's own notes are told apart from GitLab's by the marker, and system notes ("added 3 commits", "marked as draft") are never candidates for the edit. The note list is read in full rather than a first page, so a long discussion cannot push the marker out of sight and turn the sticky comment into a new one each run.

When a forge is having a bad minute#

A publisher that a forge refuses, 429, 502, 503, 504, is tried again, up to three times, with a short backoff. If the response names a Retry-After, that is used instead, capped so a maintenance window measured in minutes cannot hold a CI runner open for a comment.

Two things it deliberately does not do:

  • A write that vanished is not repeated. When a POST or PATCH never comes back at all, the forge may have created the comment and lost the reply. Sending it again risks two comments on one pull request, and a sticky comment exists so a reader sees one current verdict. A GET has no such cost and is retried.
  • An answer is not retried. 401, 403, 404, 422 are the forge telling you something, usually a token or a permission. Retrying delays the message that would have explained it.

If delivery still fails, the run exits non-zero: a flag either does something or says why it did not. The message names which of the two things happened, because they are the same color in a checks list and only one is about the code under review:

draugr: the gate passed, but publishing failed: post PR comment failed: 503 Service Unavailable

GitLab's own report formats#

GitLab does not read SARIF. It reads its own schema, and there is no endpoint to upload one to, a job declares artifacts: reports: and the runner collects the file. So GitLab's equivalent of pushing to code scanning is a report format, not a publisher, and it composes with -o and the file publisher you already have.

config:
  reports:
    - format: gitlab-sast
    - format: gitlab-secret-detection
    - format: gitlab-codequality
  publishers:
    - kind: file
      dir: ./draugr-out
# .gitlab-ci.yml
artifacts:
  reports:
    sast: draugr-out/gl-sast-report.json
    secret_detection: draugr-out/gl-secret-detection-report.json
    codequality: draugr-out/gl-code-quality-report.json

Which one a reader actually sees depends on the tier#

SurfaceFed byTier
Merge request Reports tabgitlab-codequalityFree, Premium, Ultimate
Diff annotations, inlinegitlab-codequalityUltimate
Vulnerability Report, MR security widgetgitlab-sast, gitlab-dependency-scanning, gitlab-secret-detection, gitlab-container-scanningUltimate

On a Free or Premium project the security reports are produced and stored and nothing displays them. That is why gitlab-codequality carries every finding whatever its control, and the typed security reports carry only their own: the untyped one is what makes sure nothing Draugr found is invisible, whatever plan you are on.

Severity means something different in each, on purpose#

The security reports carry the flaw's severity. GitLab's merge-request approval policies gate on that field, and a Draugr priority has already folded in the component's exposure and criticality. Handing one over would have those policies apply that context a second time.

Code Quality carries the priority, because it has no policy engine behind it. It is a list a reviewer reads in order, and the useful order is the one that accounts for what the component is exposed to. The flaw's severity leads the description, so nothing is lost.

PriorityCode Quality severity
P1blocker
P2critical
P3major
P4minor

What these reports leave out#

Suppressed findings. GitLab has no notion of one somebody already accepted: it would show it as open and wait to be dismissed, asking again for a decision your Saga records with its reason and its author. They stay in Draugr's own report, marked.

container_scanning. GitLab requires an image and an operating system on every container finding, which Draugr's image findings do not yet carry as fields, and filling a required field with a guess is worse than a report that does not exist. Those reach the merge request through gitlab-codequality in the meantime.

dependency_scanning was in the same position and no longer is: a dependency finding carries its package, version and purl as fields, so the schema's requirement is met with facts.

Compact output, for tools and agents#

--compact strips what only a human reads, indentation, and the rule descriptions and remediation text Draugr relays from each scanner, while keeping the output valid SARIF:

draugr scan draugr.saga.yaml --format sarif --compact

Measured on Draugr's own repository: 17,355 β†’ 5,831 bytes, the same 8 findings, still parseable by any SARIF consumer.

Rule documentation is the bulk of a report, around 60% of it, and a consumer that can follow a link doesn't need it inlined. So helpUri survives compaction and the paragraphs don't: the pointer stays, the prose goes. The scanner tag on each rule stays too, since that's how a consumer knows which tool found what.

Use it when something acts on the report, a script, a policy engine, an AI agent paying for every byte of context. Don't use it for your editor: the descriptions it removes are exactly what a SARIF viewer shows you beside a finding. And don't use it for GitHub code scanning, which renders those same fields on an alert.

--compact has no effect on console, markdown, html or junit, making the human formats harder to read would be the opposite of the point.

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
escalationWhy the band is higher than the severity: the dataset, the fact, and the day it was fetched
reachabilityWhether your code can reach the vulnerable code, which analyzer decided, and how

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.

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
decidedThe classifications this run settled, whether or not a finding resulted
consultedThe exploitability datasets the run had loaded: the signal, the day the copy was obtained, 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.

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.

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 writes no consulted block at all.

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.reports / config.publishers, see the Saga schema; for the full catalog of reporters and publishers, see the integrations catalog.

The HTML report#

One file, no external assets, safe to email, attach to a build, or open from disk.

It carries its own data. The report embeds the full SARIF and a tab-separated export of every finding, offered as ordinary download links:

  • results.sarif, the complete report, for another tool or a code-scanning upload.
  • findings.tsv, one row per finding, ready for a spreadsheet. Tab-separated rather than comma-separated because finding messages contain commas constantly: CSV would need quoting that spreadsheet importers handle inconsistently, and several locales expect ; as the delimiter. Nothing in a finding contains a tab, so TSV needs no escaping and opens on a double-click.

The TSV covers every finding including suppressed ones, each marked with the reason it was set aside. The download is the record, and you can filter in the spreadsheet.

Search and filtering are progressive enhancement. The page renders complete without JavaScript: the full table, both downloads, and every section. The script only reveals a search box and per-priority, per-severity and per-control toggles, so a reader whose viewer strips scripts sees the whole report rather than controls that do nothing.

Suppressed findings get their own section, each with its justification, because "who decided this was acceptable, and when" is the question the report exists to answer.

The footer records when the scan ran, which version produced it, and the run's statistics.

A very large scan can produce more SARIF than is sensible to inline; past 8 MiB the report says so and points at -o instead.