Render a report and send it somewhere
On this page
A publisher is a destination, and it says what it is given:
config:
publishers:
- kind: file # the artifacts, complete
dir: ./.draugr/out
reports:
- format: html
- format: json
- kind: github-pr-comment # a short summary, for people
reports:
- format: markdown
minPriority: P2
- kind: github # the evidence, for code scanning
reports: [{format: sarif}]
A destination that can deliver only one thing renders it for itself. kind: github and
kind: draugr-api are complete instructions: the format is not a choice anybody makes, and a
descriptor that had to name it would be one that fails when somebody forgets. reports is for
narrowing those, and for file, which has no format of its own.
For local artifacts and no destination at all, -o <dir> writes report.json and results.sarif,
and --report <format> adds to them.
Each distinct report is rendered once however many destinations ask for it.
Reports are produced 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 a publisher's reports:
| Format | Purpose |
|---|---|
console | human summary on stdout (default), verdict, P1–P4 counts, "fix first" |
markdown | portable report for MR comments, wikis, Slack |
html | self-contained HTML report, searchable, filterable, and carrying its own SARIF and TSV downloads. See below |
junit | JUnit XML, surfaces findings in CI test panels (GitLab, Jenkins, Azure DevOps…) |
json | machine-readable report |
sarif | SARIF 2.1.0 for code-scanning dashboards |
vex | OpenVEX, which of these vulnerabilities actually affect your product, for the people who consume your SBOM |
gitlab-sast | GitLab's own security schema, for its Vulnerability Report. Written as a build artifact, not uploaded. GitLab has no endpoint. See below |
gitlab-dependency-scanning | the same, for vulnerable dependencies, each with the package and version GitLab requires |
gitlab-secret-detection | the same, for leaked credentials |
gitlab-container-scanning | the same, for vulnerable packages in a container image, each with the image and the operating system GitLab requires |
gitlab-codequality | GitLab Code Quality, every finding, in the merge request, on any tier |
template | custom payload from a Go text/template (inline or file), no code needed |
-o/--output <dir> always writes report.json + results.sarif regardless of --format.
When two scanners find the same flaw
Both findings stay in the report, with their own rule ids and their own severity, because the disagreement between two scanners is the reason to run two. One of them is counted and the others are evidence, and every document says which is which:
$ jq -r '.runs[].results[] | select(.properties.correlation) |
"\(.properties.tool) \(.ruleId) \(.properties.correlation.countedUnder // "counted")"' results.sarif
trivy CVE-2018-1000656 counted
grype CVE-2018-1000656-flask trivyproperties.correlation.countedUnder names the scanner whose finding this one is counted under,
and is what makes it evidence rather than a count. The finding that is counted carries
alsoFoundBy instead, with each other tool's own rule id and rating.
A consumer counting findings should skip the ones with countedUnder. Otherwise enabling a
second matcher doubles the number with nothing new wrong, which is the arithmetic buyers are told
to test for: point several scanners at one target and count the tickets. Draugr's own counts,
its gate and draugr diff's gate all skip them.
SARIF has suppressions for the excused case and no field for this one, so it travels in the
property bag beside priority and the rest.
A gate reading report.json should check more than verdict: a scanner that never started and a
run that found nothing both produce findings-shaped output. The fields that tell them apart are in
the report schema.
What produced the run
report.json records what a scan found. Two blocks record what produced it and two record what it
was asked to do, and all four exist only in the process that ran the scan. Nothing downstream can
recover them.
draugr and scanners are the software that did the scanning, as opposed to release, which is
the version of the software being scanned:
"draugr": { "version": "0.121.1", "commit": "273db6e" },
"scanners": [
{ "name": "gitleaks", "version": "8.30.1" },
{ "name": "govulncheck", "version": "v1.7.0;db@2026-09-10 14:48:42" },
{ "name": "retirejs", "version": "5.4.3" },
{ "name": "trivy-fs", "version": "trivy@0.69.3;db@2026-09-12T13:01:09Z" }
]
A version names the tool and, where the data is what moves the answer, the data too. The same Trivy against a database a week older answers a different question, and a reader comparing two runs needs to know which of the two changed.
# Two runs a month apart disagree. Did the code change, or did the scanner?
jq -r '.scanners[] | "\(.name) \(.version)"' a/report.json b/report.json | sort -u
Only scanners that ran are listed, whether their jobs succeeded or failed. A scanner with a failed
job carries failed[], one entry per job with the control, component, target and the scanner's
error, so the version beside it names the build that produced the error. One named here having
never executed would claim coverage that did not happen, and a control that was planned and then
skipped is under notMeasured instead. A tool that would not say what version it is appears
without one, because an unreadable version is a fact about the run and a placeholder would make two
genuinely different builds look identical.
results.sarif carries the same two facts in SARIF's own fields, runs[].tool.driver.version and
a version on each entry of runs[].properties["draugr/provenance"], so a consumer reading either
document can answer the same question.
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.
A descriptor has a field for the name of an environment variable holding a credential and none
for its value. ${{ VAR }} substitution puts a variable's value into the effective descriptor, and
substitutes only the built-in CI metadata and the names $DRAUGR_SAGA_ENVS lists, so list only
variables that hold no secret. See substitution.
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.
A configuration file whose control default reached the descriptor is
listed as a source too, and descriptor.contributed names the settings it supplied.
The report schema has each
field.
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.
It also names two people, as the CI system reports them: runBy, who started the pipeline, and
commitAuthor, who wrote the commit being built. triggeredBy appears on GitHub when somebody else
re-ran the job. Each carries what the platform offers, a handle, a display name and a stable id,
and an email only when the descriptor sets config.ci.recordEmail.
runBy | commitAuthor | |
|---|---|---|
| GitHub Actions | handle, id | handle and name, on a push |
| GitLab CI | handle, name, id | name |
| Azure Pipelines | name, id | name |
| CircleCI | handle | |
| Buildkite | name | name |
A field the platform does not report is absent, and neither person is ever filled in from the other. Both are what the pipeline's own environment says, which the pipeline controls. They are a lead to follow, not proof of who acted.
# 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
descriptor and ci 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.
priorities counts what the gate judged, and suppressed counts what it did not:
"priorities": {"p1": 4, "p2": 5, "p3": 0, "p4": 0},
"suppressed": {"total": 1, "p1": 1, "p2": 0, "p3": 0, "p4": 0}
Two things are outside priorities, for the same reason the gate leaves them out. A finding a
config.exclude rule set aside is not work, and it is counted in suppressed rather than dropped,
because an exclusion keeps a finding in the report with the reason somebody gave. And a flaw two
scanners both reported is one flaw: the copy is skipped outright, so enabling a second matcher does
not double the count with nothing new wrong.
suppressed is absent when nothing was excused, so a document carrying it is a run where somebody
made a decision rather than one reporting that they did not. Each entry in findings carries the
same thing per finding: a suppressed block with the justification and the file and line that
record the decision, on the findings that have one.
gate is the policy the verdict was judged against, and the outcome of the gate on new findings
where a comparison ran. Gate in the report schema lists each
field, with a block from a real run. disabled is --no-gate: the verdict stands and the command
exits 0 anyway, so anything reading the exit code was told the opposite of what this document
says.
Between gate.threshold, controls[].threshold and controls[].highest, a consumer can name
which control crossed which band without reading the descriptor:
# What failed, and against what
jq -r '.controls[] | select(.verdict=="fail") | "\(.name): \(.highest) against \(.threshold)"' report.json
The block is absent on a document written before this existed, and on one written without a gate at all. Absent is not the same as a default gate, which is why nothing is filled in for it.
Declare formats and destinations
config:
publishers:
- kind: file # write each report to a directory
dir: ./out # → ./out/results.sarif, ./out/report.md, ./out/report.html, ./out/summary.txt
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
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
Each destination needs a format it can use, and a descriptor that names one without the other is refused when it loads rather than after the scanners have run:
$ draugr validate draugr.saga.yaml
draugr: config.publishers[0]: the file publisher has no format of its own and names none, so it
would deliver nothing. Add the formats it is for, e.g. `reports: [{format: sarif}]`The Distinguishes column is what makes a second entry of one kind a second destination. Two
file publishers writing to different directories are two destinations; two writing to the same
one are the same destination written twice, and are refused:
draugr: config.publishers[1] is the same destination as config.publishers[0]: both are file with
the same dir, so the second delivers where the first already did. Give them different dir values,
or keep one| Kind | Needs | Distinguishes | Delivers to | Config |
|---|---|---|---|---|
file | any | dir | a local directory (one file per report format) | dir |
github | sarif | repo | GitHub code scanning (uploads the sarif report; dismisses the alerts of accepted findings) | repo, commit, ref (default from the GitHub Actions env); token from $GITHUB_TOKEN (or tokenEnv) |
github-pr-comment | markdown | marker | a sticky pull-request comment (posts the markdown report) | repo, pr (default from the env); token from $GITHUB_TOKEN (or tokenEnv) |
azure-pr-comment | markdown | marker | a 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-comment | markdown | marker | a sticky GitLab merge-request comment (posts the markdown report) | repo, pr (default from the GitLab CI env); token from $GITLAB_TOKEN (or tokenEnv) |
github-issue | the run | repo, select and split | a GitHub issue that is open while the gate fails | repo (default from the env), select, split, minPriority, children, maxChildren, label, labelBy, branches, item; token from $GITHUB_TOKEN (or tokenEnv) |
gitlab-issue | the run | repo, select and split | a GitLab issue that is open while the gate fails | repo (default from the GitLab CI env), select, split, minPriority, children, maxChildren, label, labelBy, branches, item; token from $GITLAB_TOKEN (or tokenEnv) |
azure-work-item | the run | project, select and split | an Azure Boards work item that is open while the gate fails | org, project (default from the Azure Pipelines env), select, split, minPriority, children, maxChildren, label, labelBy, branches, item; token from $SYSTEM_ACCESSTOKEN (or tokenEnv) |
draugr-api | json, sarif | url | any server implementing Draugr's run-ingest API (posts the json report, uploads the sarif one) | $DRAUGR_API_URL, or a url on its origin; 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.
tokenEnv names the kind's own variable or one the pipeline lists. A tokenEnv other than the
kind's own ($GITHUB_TOKEN, $GITLAB_TOKEN, $SYSTEM_ACCESSTOKEN or $DRAUGR_API_TOKEN) is refused
unless $DRAUGR_TOKEN_ENVS lists it, comma-separated, in the pipeline's environment. The
environment reference says why the pipeline decides.
export DRAUGR_TOKEN_ENVS=GH_ISSUES_TOKEN,BOARDS_PAT
A publisher's token travels only over https, or over plain http to localhost, 127.0.0.0/8 or
::1, and a request carrying one follows a redirect only on the origin it was addressed to. A
redirect anywhere else fails the request without sending anything there.
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:
publishers:
- kind: draugr-api # renders the run report and its evidence for itself
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 $DRAUGR_API_URL points
at either and nothing else changes. Anything else that implements the 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 environment chooses the server. The token goes to $DRAUGR_API_URL, or else to
publish.apiUrl in the machine's configuration. A url: in the
descriptor is used only on that server's origin or one
$DRAUGR_API_ORIGINS lists. A url: on any other origin fails the scan before any scanner runs,
naming the two values that disagree. With a token and no $DRAUGR_API_URL, a url: the list does
not name is refused the same way.
export DRAUGR_API_ORIGINS=https://draugr-eu.acme.example # a second server one descriptor may name
The 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 that sets $DRAUGR_API_URL to it, with no
change to the descriptor. The calls carrying the token follow the rule for a
publisher's token on TLS and redirects.
1. POST /v1/runs with report.json as the body, compact JSON whatever the run wrote to disk:
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. The upload carries no
Authorization header, since the URL carries its own signature, so it may be on another origin and
may redirect.
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"}
A server that reads only reports from a newer Draugr answers 422 draugr_too_old and puts the
oldest version it accepts in minimum. Draugr leads its error with that version and the command
that installs it. A refusal with no minimum reaches the reader as draugr_too_old followed by
your own detail.
{"error": "draugr_too_old", "minimum": "0.122.0",
"detail": "this server reads runs from Draugr v0.122.0 or later, and this run came from Draugr v0.121.1. Upgrade Draugr and run the scan again"}
An organization's policy, where the server judges one. Before the scan, Draugr sends
POST /v1/ingest/policy/check with the same token and the descriptor as YAML, {"descriptor": {"effective": "…"}}. draugr doctor sends it only under --policy. Answer with the outcome
below, or 404 where the server judges no policy, which Draugr reports as not checked rather than
passed. Where $DRAUGR_API_URL is set and the descriptor has no draugr-api publisher, the policy
is reported as not checked for that reason. The answer to POST /v1/runs carries the
same outcome as policy, and a run the policy refuses is answered 422 policy_refused with it.
{"schema": 1, "project": "payments", "policyVersion": 7, "outcome": "fail", "passed": false,
"verdicts": [{"rule": "threshold", "profile": "PCI scope", "state": "violates",
"items": [{"field": "failOn", "found": "P1", "constraint": "required", "expected": "P2 or stricter", "breaks": true}],
"mode": "enforce", "enforceFrom": "2026-10-01", "response": "fail", "inForce": true, "acts": "fail"}]}
outcome is the strongest acts among the verdicts, refuse over fail over none. Draugr stops
a scan the pre-flight refuses and fails the gate of one it fails. Each item reads as <field>: <found> · <constraint>: <expected>.
The verdict on the run's findings. A verdict marked "pending": true in the answer to POST /v1/runs is decided once the server has read the run's evidence. draugr scan delivers to draugr-api
publishers before anything else, and after complete, or after an answer of duplicate or held,
asks GET /v1/ingest/runs/{id}/policy with the same token every five seconds until the answer's
pending is false or --policy-wait (five minutes by default) runs out. The answer is the
outcome above with pending always present:
{"schema": 1, "project": "payments", "policyVersion": 7, "outcome": "none", "passed": true, "pending": true,
"verdicts": [{"rule": "licenses", "profile": "Default", "state": "not_evaluated",
"detail": "the run's evidence is not expanded into findings yet", "mode": "enforce",
"enforceFrom": "2026-10-01", "response": "fail", "inForce": true, "acts": "none", "pending": true}]}
While pending is true, outcome covers only the rules decided so far. Answer 429 with
Retry-After and Draugr waits that long before asking again. Draugr reads 401, 404 and 5xx as
not checked, carrying the status, and never as a pass. The verdict then reaches the console, the
-o artifacts and every other publisher, and a run whose outcome is fail fails the gate. An
answer to POST /v1/runs with nothing pending is final, and Draugr asks nothing more.
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.waiting for the policy verdict, the scan asking for the verdict on the run's findings.policy decided after publish, a rule still undecided when the scan stopped asking for it.
Azure DevOps
In a pipeline everything defaults from the environment, so the whole configuration is:
config:
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.
An org written in the descriptor is the URL the token is sent to. It is used only on the origin of
$SYSTEM_TEAMFOUNDATIONCOLLECTIONURI or on Azure DevOps Services (https://dev.azure.com,
https://<organization>.visualstudio.com). To post to Azure DevOps Server from outside Azure
Pipelines, set $SYSTEM_TEAMFOUNDATIONCOLLECTIONURI to the collection's URL.
GitLab
Everything defaults from the runner environment, so the whole configuration is:
config:
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.
GitHub issues
A github-issue entry keeps one issue open for as long as the gate fails on the default branch:
config:
publishers:
- kind: github-issue
| Run | What happens to the issue |
|---|---|
| the gate fails and no issue is open | one is opened, with the tracking label and the priority label |
| the gate fails and the findings changed | its body is rewritten, and its fact labels follow |
| the gate fails and the findings are the same | nothing is sent |
| the gate passes | it is closed as completed, with a comment naming the branch and the job |
a pull request, or a branch outside branches | nothing is sent, and the log says why |
An unchanged run sends no write, so nobody watching the issue is notified by a scheduled scan that found nothing new. Two open issues carrying the same marker are resolved by closing the newer one as a duplicate of the older.
The priority label, draugr:priority:P1, names the highest band among the findings the issue
lists, so a board or a saved search can sort issues by it. labelBy adds the control, the exposure,
the criticality, and a label for an incomplete scan:
config:
publishers:
- kind: github-issue
labelBy: [priority, control, incomplete]
The body opens with the count of findings that fail the gate, then lists the actions that clear them, each with its findings, files and lines. Over GitHub's size limit, the lowest-priority actions lose their findings first and are then left out, and the body says how many and links the job that has the full list.
Grant the token issues: write. The workflow's GITHUB_TOKEN starts read-only in new
organizations and personal repositories:
permissions:
contents: read
issues: write
Without it GitHub answers 403, and Draugr names the permission from GitHub's
X-Accepted-GitHub-Permissions header:
draugr: policy verdict: fail (publishing also failed: publisher "github-issue": github-issue publisher: list issues in acme/shop: 403, the token needs issues=write. For GITHUB_TOKEN, grant it in the workflow with `permissions: issues: write`)Different teams, different issues. select gives an entry the part of the run it covers, and
split opens one issue per control or per component within it. Each issue closes when its own part
passes, while the run may still fail on somebody else's:
config:
publishers:
- kind: github-issue # the payments team's components
select: { labels: { team: payments } }
item: { assignees: [payments-lead] }
- kind: github-issue # license findings, for legal review
select: { controls: [licenses] }
label: draugr-licenses
- kind: github-issue # everything else, one issue per component
split: component
minPriority: P2
An issue per fix. children: actions makes each issue a parent with a sub-issue for every
action, so each fix is assigned, tracked and closed on its own. children: controls gives each
control a sub-issue holding that control's actions.
config:
publishers:
- kind: github-issue
children: actions # none (default) | actions | controls
maxChildren: 20 # 100 when unset
A child's title leads with its priority, as in P1 · Upgrade jinja2 2.10, so the forge's sub-issue
panel reads in fix order, and its body is the action's findings table. The parent lists only the
actions that have no child. A run creates children highest priority first, up to maxChildren open
under one parent, and stops early when the next child would not fit the time a run may spend
waiting on the forge. The parent then opens its list with
how many actions have a child, and the rest get one as open children close or on the next run.
A child closes when the run no longer reports its action, and when the gate passes every child closes before its parent. A child renamed by hand keeps the name until its priority changes.
When an entry sets select, draugr doctor names the components no issue entry covers:
UNTRACKED
batch
No issue publisher selects this component.The fields are in the descriptor reference.
GitLab issues
A gitlab-issue entry keeps a GitLab issue open on the same terms as a github-issue entry, and
reads the same fields:
config:
publishers:
- kind: gitlab-issue
Where GitLab differs:
| GitLab | |
|---|---|
| visibility | a new issue is confidential, visible only to project members. item.confidential: false opens it to everyone who can see the project. |
| closing | GitLab records no close reason, so the reason is posted as a comment before the issue closes. |
item.type | issue, incident or task. An incident needs the Reporter role. A task has no children, so task with children is refused. |
| children | tasks, each given its parent through the GraphQL API, which the same token reaches |
item.assignees | usernames of project members. GitLab Free keeps one. |
| labels | created by GitLab on first use, in its default color |
The token. CI_JOB_TOKEN cannot write issues, so set $GITLAB_TOKEN as a masked CI/CD variable
holding either:
- a fine-grained personal access token with Work Item: Create, Read and Update, Label: Read and Member: Read
- an access token with the
apiscope
Its user needs the Planner role or higher. A metadata value the user has no role to set is dropped by GitLab without an error, so the publisher compares the created issue with the request and fails naming what was dropped.
Azure work items
An azure-work-item entry keeps an Azure Boards work item open on the same terms as a
github-issue entry. The collection and project come from the pipeline, and item takes Azure's
fields:
config:
publishers:
- kind: azure-work-item
item:
tags: [security]
assignedTo: alex@example.com
areaPath: Payments\Platform
iterationPath: Payments\Sprint 12
priority: 1
fields:
Microsoft.VSTS.Common.Activity: Development
Where Azure differs:
| Azure DevOps | |
|---|---|
| description | Markdown, the body a github-issue entry writes. Azure DevOps Services is supported; Azure DevOps Server is untested. |
item.type | a work item type. Defaults to the Task category's default type, which is Task in every process Azure ships. With children, the parent defaults to the Requirement category's: User Story in Agile, Product Backlog Item in Scrum, Requirement in CMMI, Issue in Basic. |
| children | the Task category's default type, linked to the parent as its child. item.type and item.fields set the parent's only. |
| closing | to the state in the Completed category of the item's type: Closed in Agile and CMMI, Done in Scrum and Basic. The comment posted before it says why. |
| tags | label, item.tags and the labelBy facts are all tags. A tag holding ; or , is refused. |
item.assignedTo | an email address, or a display name the organization resolves to one person |
item.fields | other fields, by reference name. A field the publisher writes, or one another item key sets, is refused. |
| branches | Azure Pipelines names no default branch, so the publisher reads it from the repository unless branches is set. |
The token. Map System.AccessToken into the step, as for azure-pr-comment:
- script: draugr scan draugr.saga.yaml
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
The build identity, <Project> Build Service, needs View work items in this node and Edit
work items in this node on the area path where items land, set in Project settings → Boards →
Project configuration → Areas → Security, and Create tag definition the first time a tag is used.
Reading the default branch needs Read on the repository; setting branches skips the read. A
personal access token needs Work Items: Read & Write, and Code: Read unless branches is set.
The publisher compares the created work item with the request and fails naming any tag or assignee Azure did not keep.
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
POSTorPATCHnever 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. AGEThas no such cost and is retried. - An answer is not retried.
401,403,404,422are 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 UnavailableGitLab'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:
publishers:
- kind: file
dir: ./draugr-out
reports:
- format: gitlab-sast
- format: gitlab-secret-detection
- format: gitlab-codequality
# .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
| Surface | Fed by | Tier |
|---|---|---|
| Merge request Reports tab | gitlab-codequality | Free, Premium, Ultimate |
| Diff annotations, inline | gitlab-codequality | Ultimate |
| Vulnerability Report, MR security widget | gitlab-sast, gitlab-dependency-scanning, gitlab-secret-detection, gitlab-container-scanning | Ultimate |
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. The severity is the
one Draugr's own severity gate judges, after the control's minimum severity and every signal, so a
policy set to Critical blocks a CVE on KEV that its scanner scored high, as --fail-on critical
does.
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, the same one, leads the description, so nothing is lost.
| Priority | Code Quality severity |
|---|---|
| P1 | blocker |
| P2 | critical |
| P3 | major |
| P4 | minor |
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
--view 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 --view 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.
On the console, --view compact is the same idea in the same word: one line per finding, with the
explanation on the row rather than under it. markdown, html and junit ignore it, because a
row that has to carry its own explanation is a table cell with a paragraph in it.
Every property a finding and a run carry, for anything reading the SARIF or report.json
programmatically, is in the report schema.
The HTML report
One file, no external assets, safe to email, attach to a build, or open from disk.
draugr.dev/demo/cli/report serves the newest release's scan of the
demo sandbox. Each release also attaches its report as
draugr-demo-report.html.
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.
Each component gets its own verdict, in a table beside the controls: what the descriptor
declared it to be, whether its own findings pass the run's gate, how they ranked, and whether
anything went unscanned. The controls table answers whether the project is shippable; this one
answers which part of it is not, which is the answer somebody takes back to a team. A component the
scope left out is listed as not scanned rather than omitted, and a component whose scans all failed
reads ERROR rather than passing on findings that were never possible.
Narrowing the findings to a single component adds a strip above the list carrying that component's verdict, its bands and the controls it did not pass. It appears for one component and not for two, because there is no verdict that describes two.
Caveats get a tab of their own when the run has any. It lists every caveat under its component,
with the exposure, criticality and verdict the Components table gives that component, and holds the
same rows as the console's CAVEATS block. Each row names the caveat's kind, what it applies to, and
the reason. A kind listed in config.gate.failOnCaveats
marks its rows ✗ and turns the tab red when a caveat fails the run. When --allow-scan-errors
accepts the run, the tab is not red and the section's note names the flag. The markdown report lists
the same rows under Caveats.
A finding whose message is too long for its row opens to the whole of it. Such a row is marked
more; click anywhere on it, or press Enter on it, and the scanner's full message replaces the
shortened one. Links on the row, such as an advisory, keep working. It is a native
disclosure that works without JavaScript. The report's search matches the whole message, and
printing includes every message in full.
The report opens in tabs, starting on Findings. The verdict and the tab row stay pinned while a long list scrolls. Findings holds two lists: What to do, one row per action with the number of findings it clears, and All findings. Clicking an action's count opens All findings narrowed to those findings.
Each list narrows and pages. A search box matches the rule, package, file and whole message; menus narrow by priority, fix, severity, movement, component, control and scanner, each value carrying how many rows it would leave. The list shows 25, 50 or 100 rows at a time, and the choice is kept for the next report opened in the same browser.
Movement narrows by a finding's marks.
| Value | Means |
|---|---|
raised by a signal | on CISA's Known Exploited Vulnerabilities catalog, predicted exploitation at or above your EPSS threshold, or a malicious or deprecated package |
floored by a control | a leaked credential, ranked at C1 whatever its component declares |
lowered as unreachable | analysis found no path from your code to it |
raised to a minimum severity | a leaked credential, held at high whatever its scanner rated it |
ranked from severity alone | none of the others |
A value with no findings is listed with a zero. A finding carrying two marks matches both values,
so the counts can add up to more than the list, and its row shows the first of them in the table's
order. lowered as unreachable counts a verdict only where it lowered the band, so a verdict a
signal overruled, or one on a finding already at the lowest band, does not count. A raise that an
unreachable verdict then lowered counts under lowered as unreachable only. ↩ history is not a
value, because a path in a commit moves no band.
Tabs, search and paging are progressive enhancement. The page renders complete without JavaScript: every section in order, every row, and both downloads. The script adds the tabs and reveals the controls, so a reader whose viewer strips scripts sees the whole report rather than controls that do nothing. Printing also includes every section and every row.
Suppressed findings get their own tab, Accepted, 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.