CLI reference
On this page
- Environment
- draugr init [dir]
- draugr scan [saga.yaml | dir]
- draugr explain <rule-id>
- draugr diff [base.sarif] <head.sarif>
- draugr survey
- draugr classify [saga.yaml | directory]
- draugr validate [saga.yaml | glob ...]
- draugr doctor [saga.yaml]
- draugr login [server]
- draugr logout [server]
- draugr tools
- draugr feeds
- draugr config
- draugr controls
- draugr mcp
- draugr self-update
- draugr version
- draugr schema
- draugr completion <shell>
All commands accept these global flags:
| Flag | Default | Description |
|---|---|---|
--log-level | info | trace, debug, info, warn, error (or log.level in draugr.config.yaml) |
--log-format | console | console (human-readable, colorized on a terminal), json, or text (or log.format in draugr.config.yaml) |
--log-file | - | Also append every record to this file, at trace level and unclamped |
--offline | false | Make no network calls (also DRAUGR_OFFLINE=1, or offline: true in draugr.config.yaml) |
--config | - | Machine/organization settings file, used instead of the discovered ones (also DRAUGR_CONFIG) |
draugr.config.yaml is read when a command starts, before it reads a descriptor, so log.level,
log.format and offline hold for every record the command writes and every network call it
makes. See precedence for what wins over the file.
--offline says once that this machine has no network, and every place Draugr would reach out
honors it. Optional fetches are skipped with a line saying so. A command whose whole purpose is to
download (feeds update, tools install, self-update) refuses and names what it would have
fetched. A scan runs against whatever each tool already has on disk, and a tool with nothing on disk
reports an error rather than a clean result.
draugr doctor lists every host a scan contacts besides its targets, per control, so a runner can
be prepared from that list rather than one failure at a time. See
running air-gapped.
Seeing what Draugr is doing. --log-level debug narrates the run: what was planned, the
exact command handed to each scanner with its working directory, duration and exit code,
cache hits, findings per control, and aggregation.
$ draugr scan . --controls iac --log-level debug
18:16:05 DEBUG planned job control=iac scanner=trivy-config target=https://github.com/draugr-dev/draugr@ target_kind=repository
18:16:06 DEBUG scanning control=iac scanner=trivy-config target_kind=repository
18:16:06 DEBUG ran external tool tool=trivy argv="trivy fs --debug --scanners misconfig --format json β¦ /tmp/draugr-repo-964636929" duration=757ms stdout_bytes=105881 dir=/tmp/draugr-repo-964636929
18:16:06 DEBUG ran external tool tool=trivy argv="trivy convert --quiet --format sarif /tmp/draugr-trivy-config-431477641.json" duration=48ms stdout_bytes=37967 dir=/tmp/draugr-repo-964636929
18:16:06 DEBUG scan complete control=iac scanner=trivy-config findings=14 duration=903msWhen a control fails. A control whose scanner exits badly is reported as an error rather than a pass, and the error carries the tool's own first line of output:
CONTROLS
dast ERROR did not run
nuclei: run nuclei: exit status 1: could not read templates: no such file or directory
That one line is usually enough. When it isn't, --log-level trace relays everything the
scanner printed, verbatim, along with the exact argv Draugr ran:
draugr scan . --log-level trace 2>trace.log
A relayed stream is printed as a block rather than escaped onto one line, so it reads the way the tool wrote it:
10:35:10 TRACE tool stdout tool=trivy
β stdout
β {
β "version": "2.1.0",
β "runs": [
β¦
βTrace is verbose, and is for when the summarized line has not answered the question. Logs go to
stderr, so 2>trace.log keeps them out of a report on stdout.
--log-file writes every record to a file at trace level, with each relayed stream whole. The
terminal keeps the --log-level asked for, and clamps a relayed stream with a note saying how much
was left out:
draugr scan . # nothing on screen but the report
draugr scan . --log-file draugr.log # β¦and the whole run in a file
The file is appended, not truncated, so a second run that reproduces a problem adds to the
first. It is written 0600 and never colored. A --log-file that cannot be opened fails the
run rather than being skipped.
Reading a dense log. The console format gives each part of a record its own weight, so the
shape of a line is legible before its content: the message strongest, because it is what you
scan for; the level colored; timestamps and attribute keys dimmed; values plain. An error
attribute and a non-zero exit_code are colored as well.
Color changes the rendering and never the text, so records stay greppable. NO_COLOR=1, a pipe
or a redirect give the same output without the escapes, and --log-format json is unaffected by
all of it.
Telemetry is opt-in and a no-op until an endpoint is set. Draugr exports OpenTelemetry traces and metrics over OTLP and reads the standard variables, so anything that already collects OTLP needs no Draugr-specific configuration:
OTEL_EXPORTER_OTLP_ENDPOINT | both signals, e.g. http://localhost:4318 |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | traces alone, where they go somewhere else |
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT | metrics alone |
Setting none of them disables both exporters. The rest of the specification's variables (headers, TLS, sampling) are read by the SDK and behave as they do everywhere else. Span attributes never carry secrets.
Environment
Every variable Draugr reads that you would set yourself. Flags win over variables, and variables win over a settings file.
Behavior
DRAUGR_CONFIG | the machine or organization settings file to read, instead of the discovered ones |
DRAUGR_OFFLINE | make no network calls. Equivalent to --offline |
DRAUGR_NO_UPDATE_CHECK | skip the check for a newer release, without disabling the rest of the network |
DRAUGR_NO_TIPS | suppress the console's contextual tips |
NO_COLOR | render without color. Honored whatever the terminal reports |
Credentials. Each is read at the moment it is needed and never written to a report, a log line, a span attribute or a cache key.
| Used by | |
|---|---|
GITHUB_TOKEN | draugr survey github repos; the github, github-pr-comment and github-issue publishers; draugr tools outdated, against GitHub's rate limit |
GITLAB_TOKEN | draugr survey gitlab projects; the gitlab-mr-comment and gitlab-issue publishers |
AZURE_DEVOPS_EXT_PAT, else AZURE_DEVOPS_TOKEN | draugr survey azure repos |
SYSTEM_ACCESSTOKEN | the azure-pr-comment and azure-work-item publishers |
URLHAUS_AUTH_KEY | the urlhaus scanner, the threats default. Free from https://auth.abuse.ch/ |
VIRUSTOTAL_API_KEY | the virustotal scanner, opt-in under threats |
DRAUGR_API_TOKEN, DRAUGR_API_URL | the draugr-api publisher. DRAUGR_API_URL is the server the token goes to |
A publisher's tokenEnv names a different variable in place of its default. A descriptor names the
variable holding a credential and never the credential, because a descriptor is committed and a
token in one is a leaked token.
Where a credential may go. A descriptor and its fragments change by pull request, and the job that runs them holds secrets their authors may not. These variables are how the pipeline, never the descriptor, decides which of the job's secrets a descriptor may use and which server a token may reach. Each is comma-separated, and a name or origin it does not list is refused before anything is sent.
DRAUGR_TOKEN_ENVS | variables a descriptor's tokenEnv may name, besides each publisher's own. A dast auth.tokenEnv has no default, so every one must be listed |
DRAUGR_API_ORIGINS | origins a draugr-api publisher's url: may name, besides the origin of DRAUGR_API_URL. An origin is a URL's scheme, host and port, https://host[:port]; two URLs on one origin reach the same server |
DRAUGR_SAGA_ENVS | variables a descriptor or fragment may substitute with ${{ VAR }}, besides the built-in CI metadata. Any other name fails the load. List only variables that hold no secret |
Self-hosted forges
GITHUB_API_URL | GitHub Enterprise Server's API, including /api/v3, for survey github repos and the GitHub publishers |
GITLAB_URL | a self-managed GitLab, for survey gitlab projects |
AZURE_DEVOPS_URL | Azure DevOps Server, without its collection, for survey azure repos |
Observability
OTEL_EXPORTER_OTLP_ENDPOINT and its per-signal variants, above.
In a pipeline Draugr also reads the CI platform's own variables to work out where it is running and which change it is looking at. You do not set those; the platform does.
draugr init [dir]
Scaffold a draugr.saga.yaml for a project (default: the current directory) from what its tree
holds. sca, secrets, sast and iac are always on; the tree adds scanners and stubs to them,
each with a comment naming the files behind it. Edit it, then draugr scan.
| Found in the tree | Written |
|---|---|
go.mod, including one that requires nothing | sast.gosec Β· config.reachability.analyzers: [govulncheck] |
Copied JavaScript: *.min.js, a file named for its release, anything under vendor/ | sca.retirejs |
setup.py, pdm.lock | sca.grypeFs, since Trivy reads neither |
A requirements file named other than requirements.txt: requirements-dev.txt, requirements/test.txt | sca.trivyFs.filePatterns reaching it |
| Terraform, Helm, Kubernetes, a Dockerfile | named in the iac comment |
| A Dockerfile | a commented images control and image entry |
| An OpenAPI or Swagger document | a commented host with spec: path: pointing at it |
| A dependency file with no lockfile, or with no exact version | a comment on the component, and a row under UNREAD |
The console lists what was found under FOUND and the dependency files no scanner can take
packages from under UNREAD.
The descriptor is written to [dir]/draugr.saga.yaml, with url: ., and its Next: commands
cd [dir] first when it is not the working directory. With -o outside [dir], or -o -, the
url is [dir] as given and the Next: commands run from the working directory.
A directory below the root that holds its own dependency file or go.mod is a part of the
repository. By default the descriptor has one component and names the parts in a comment;
--per-directory writes a component for each, scoped with paths:, and the root component
ignore:s them. A commented image entry goes on the component whose directory holds the
Dockerfile.
A JavaScript workspace member stays with its workspace root, whose lockfile resolves it. A member
is a directory the root package.json names under workspaces (npm, Yarn, Bun), as an array or as
packages: in an object, or one pnpm-workspace.yaml names under packages:. A member with a
lockfile of its own, or a dependency file of another ecosystem, is still a part.
| Flag | Default | Description |
|---|---|---|
-o, --output | [dir]/draugr.saga.yaml | Path to write, relative to the working directory rather than [dir] (- for stdout). With --fragment, the default is [dir]/draugr.saga-fragment.yaml |
-f, --force | false | Overwrite an existing file |
--per-directory | false | Write a component for each directory that holds its own dependency file |
--fragment | false | Write a Saga fragment: one component, no release and no policy |
draugr init # write draugr.saga.yaml for the current project
draugr init -o - | less # preview without writing
draugr init --per-directory # one component per directory with its own dependency file
draugr init services/api # write services/api/draugr.saga.yaml
draugr init services/payments --fragment # a Saga fragment, component named for the directory
For an instant scan with no file at all, use zero-config draugr scan . (below).
draugr scan [saga.yaml | dir]
Load a descriptor, run the applicable controls, and produce a pass/fail verdict. Prints a
human-readable console summary to stdout by default (--format for other formats).
Exits non-zero when the verdict is fail.
A descriptor in the directory wins. Point scan at a directory, or omit the argument, and it
uses the descriptor there if one exists. Everything that file declares applies: the controls chosen,
the components, and the exposure and criticality that drive prioritization.
A descriptor is any file in that directory, not below it, whose name ends in .saga.yaml or
.saga.yml:
draugr.saga.yaml web.saga.yaml .saga.yaml api.saga.yml
If the descriptor cannot be read, the scan fails rather than falling back to zero-config.
More than one descriptor stops the scan. On a terminal it asks which one to use; anywhere else it lists them and exits non-zero. Name the file to skip the question:
draugr scan ./web.saga.yaml
With more than one component, the report breaks the verdict down by component, each judged by the same policy as the run, so the parts cannot disagree with the whole. Components with nothing against them are listed as passing, and findings from project-wide controls (which belong to no component) are counted separately.
A surface with no control enabled is called out. A component that declares repositories,
images, hosts, clusters or cloud accounts with every control for that surface off gets a
not-checked row under CAVEATS, naming the controls that are off:
CAVEATS do not fail the run Β· config.gate.failOnCaveats lists the kinds that do
Component What Caveat Why
worker hosts not-checked dast, headers, tls off
A caveat fails the run only when --fail-on-caveats or
config.gate.failOnCaveats lists its kind.
Contextual tips. After a console scan, up to two rows that depend on the run may be added to
TRY, in this order of precedence. None of them affects the verdict:
| Tip | Shown when |
|---|---|
--fail-on P2 | The gate is on severity, the run passed, and it carries P1 or P2 findings |
builtBy: upstream | The images control has findings and no component or image declares builtBy |
--cache-dir <dir> | The run took a minute or more and --cache-dir is unset |
draugr controls | A declared surface has no enabled control |
draugr tools install <tool> | A scanner that ran is on PATH, was not installed by Draugr, and is one Draugr can install |
--no-tips, DRAUGR_NO_TIPS=1 or output.tips: false in draugr.config.yaml
turns them off.
Installing a missing scanner
On a terminal, a scan that needs a scanner draugr tools install fetches and cannot find asks
before it starts:
semgrep and trivy are not installed, so iac, sast and sca would not run.
Install them now with draugr tools install semgrep trivy? [Y/n/never]
| Answer | Does |
|---|---|
Enter, y or yes | installs them, at the version the config pins if it pins one, then scans |
n, or anything else | scans without them; the affected controls read <tool> not installed, and a scanner a zero-config scan chose from the tree is a not-checked caveat instead |
never | scans without them and writes output.offerInstall: false to ~/.draugr/config.yaml, or to the file --config or DRAUGR_CONFIG names |
It asks only when stdin, stdout and stderr are all terminals, and never with --offline, so a
pipeline, an assistant and a redirected report scan as before. The tools a scan needs are the ones
draugr doctor checks, narrowed by --controls. A tool Draugr does not
fetch is not offered.
While a scan is running
A scan plans one job per repository, image, host and cluster and runs them concurrently. On a terminal it redraws a progress display: how many jobs have finished, then a row per control and scanner with its finished and total jobs.
Scanning 1/2
βΈ secrets/gitleaks 1/2
A row's mark is Β· before its jobs start, βΈ while one runs, β when all have finished and β
when they have finished and one failed. A row whose oldest running job has taken two seconds or more
shows how long, and the first line adds the run's elapsed time and the count of failed jobs. While a
scanner refreshes its data before the jobs start, the first line reads Refreshing and names the
data. The display is erased before the report.
It is drawn only when stderr is a terminal, so a report on stdout stays parseable when piped and
a CI log carries no frames. --no-tips, DRAUGR_NO_TIPS=1 and output.tips: false turn it off
along with the tips.
What the run cost
Under --evidence, the run row accounts for the run:
$ draugr scan . --controls secrets --cache-dir .draugr/cache --evidence
β¦
run 1 job in 16ms Β· secrets took the most scanner time, 15ms Β· 1 from cache, oldest 9m0sThe row holds, in order, the job count and the run's wall-clock time; the concurrency limit, where the run had more jobs than it; the control with the most scanner time, summed across its jobs; how many jobs were answered from the cache and the age of the oldest entry used; time spent waiting for a tool's own cache; and scanner data refreshes of a second or more.
Scoping a run
--components and --controls narrow a run to the named components and controls without
touching the descriptor:
draugr scan --components app,frontend
draugr scan --controls sca
draugr scan --components app --controls sca --log-level debug
--labels, --exposure and --criticality select components by what they declare rather
than by name, so the selection follows the descriptor as components are added.
draugr scan --labels team=web # what the web team owns
draugr scan --labels team=web --labels tier=1 # the web team's tier-1 components
draugr scan --exposure public # everything internet-facing
draugr scan --exposure public --criticality critical
Values within one flag are alternatives and the flags narrow together, so --labels team=web --labels team=payments is either team and --labels team=web --exposure public is that team's
public components. --labels reads the component's own labels, which is the
organization's vocabulary. --exposure and --criticality are Draugr's own, so a value that is
not one of them is refused by name:
draugr: --exposure: no such exposure "pubic" (want one of: public, authenticated, internal, restricted)
A selector that matches nothing is an error, not an empty run. The error offers the values that key does have:
draugr: --labels team=nope matches no component (this descriptor uses team=platform, team=web)
draugr: --labels squad=web matches no component (no component declares label "squad")
The selector is resolved to component names before the run starts, so the verdict line, the
component list and the SARIF analysis category all describe what was covered. results.sarif also
records the selector itself.
The scope flags apply to one run and change nothing in the descriptor.
A scoped run still gates, and its verdict line names the scope:
$ draugr scan --components web --controls secrets
DRAUGR FAIL storefront 1.0 (scope: 1 of 3 components; secrets) 567ms
5 P1 0 P2 0 P3 0 P4
CONTROLS
secrets FAIL 5 P1
β¦
COMPONENTS
web FAIL 5 P1
api not scanned
worker not scannedComponents outside the scope are listed as not scanned, so the breakdown never shows a
skipped component as one that passed.
The scope travels into the artifacts too: report.json gains a scope object, and the SARIF
carries it as run provenance. Both are absent on an unscoped run.
draugr diff refuses reports of different scope, because
a finding outside the narrower scan would be reported as fixed. Two runs of the same scope compare
normally.
A component or control name that matches nothing is an error listing what the descriptor declares:
draugr: --components: this descriptor declares no component "frontnd" (it has: api, web, worker)
The scope flags change what runs, and so the verdict.
--min-priority changes only what is printed from a full run.
Zero-config. A directory with no descriptor is scanned with the descriptor
draugr init would write for it, read from the same tree: one component with
sca, secrets, sast and iac, and the scanners the files call for.
| In the tree | Turned on |
|---|---|
a go.mod | sast.gosec, and govulncheck as the reachability analyzer |
copied JavaScript, such as jquery.min.js | sca.retirejs |
a requirements file Trivy opens only by pattern, such as requirements-dev.txt | sca.trivyFs.filePatterns |
a file only Grype reads, such as setup.py or pdm.lock | sca.grypeFs |
What init writes commented out stays off: the images: entry for a Dockerfile, and the host for
an OpenAPI document. The report gains a FOUND section after CONTROLS, in the console, HTML and
markdown reports, listing each kind of file, its paths and what the run did with it; draugr init
prints the same rows. A run from a descriptor has no FOUND section.
A scanner the tree called for that cannot run on this machine, because its tool or the runtime it
runs on is missing, is turned off for the run after the
install offer. Its FOUND row writes it off, as in
sca Β· gosec off Β· govulncheck off, and CAVEATS lists it as not-checked with the files that
called for it and what is missing, such as govulncheck needs Go or
gosec not installed Β· draugr tools install gosec. The run is not marked incomplete for it. A
missing trivy, semgrep or gitleaks fails its control.
The line No *.saga.yaml here, scanning <dir>. is printed to stderr, with the directory's absolute
path, so machine formats on stdout stay clean. The project, component and repository names are the
ones draugr init writes (My.Service becomes my-service), and a scan of the working directory
records its repository url as .. draugr diff between a zero-config run and the first run after
init therefore reports shared findings as unchanged.
draugr scan # zero-config: scan the current repo
draugr scan ./service # zero-config: scan another repo directory
draugr scan draugr.saga.yaml # full control from a descriptor
The flag tables below follow the groups in draugr scan --help.
What is scanned
| Flag | Default | Description |
|---|---|---|
--components | - | Scan only these components; the verdict says what it covered |
--labels | - | Scan only components carrying these key=value labels; repeat for more, one key twice means either value |
--exposure | - | Scan only components declaring one of these exposures: public, authenticated, internal, restricted |
--criticality | - | Scan only components declaring one of these criticalities: critical, important, supporting |
--controls | - | Run only these controls; the verdict says what it covered |
--working-tree | false | Scan local repositories as they are on disk, uncommitted work included. See below |
What fails the build
| Flag | Default | Description |
|---|---|---|
--fail-on | P1 | What fails the gate, in either vocabulary: a priority band (P1βP4) or a severity (critical, high, medium, low). A severity is compared with each finding's ranked severity, after the control's floor and every signal. Overrides config.gate; P1 applies when neither sets one |
--fail-on-priority | - | Deprecated: write the band in --fail-on. Refused together with --fail-on |
--fail-on-caveats | - | Kinds of caveat that fail the run as an error does: not-checked, not-measured, unread-files, unread-checks, unbound-imports, or none. Overrides config.gate.failOnCaveats |
--base | - | The results.sarif of a scan to compare this one against, by the comparison draugr diff makes. With a gate on new findings set, the verdict and the exit code come from what this scan reports and the base does not. See below |
--fail-on-new | - | The gate on new findings: a priority band (P1βP4) or a severity (critical, high, medium, low). Overrides config.gate.failOnNew |
--no-gate | false | Report the verdict, and the outcome of the gate on new findings, but exit 0 on a fail, for producing a report to compare later, where draugr diff is the gate |
--allow-scan-errors | false | Treat a control whose scanner could not run as a warning rather than a failure. The verdict line then carries partial, with the number of targets not reached. A run that planned no jobs at all still fails |
Exploitability data
| Flag | Default | Description |
|---|---|---|
--kev | - | CISA KEV catalog: a file path, or cache/auto to read ~/.draugr/feeds. A CVE on it is escalated to critical. Overrides config.exploitability.kev |
--epss | - | FIRST EPSS scores: a file path, or cache/auto to read ~/.draugr/feeds. A CVE at/above --epss-threshold is bumped one level. Overrides config.exploitability.epss |
--epss-threshold | 0.5 | EPSS probability (0β1) that triggers a severity bump. Overrides config.exploitability.epssThreshold |
Output
| Flag | Default | Description |
|---|---|---|
--format | console | What to print: console, markdown, json, sarif, vex, template |
-o, --output | - | Directory to write reports into: report.json and results.sarif unless --report says otherwise, and one SBOM per target when config.sbom is set |
--report | json,sarif | Formats to write into -o: console, evidence, html, json, junit, markdown, sarif, vex, and the gitlab-* reports. Replaces the default rather than adding to it. See below |
--view | findings | What the report shows: findings, actions or compact |
--group | - | Deprecated. --group action is --view actions, --group none is --view findings |
--evidence | false | Console: also print what stands behind the verdict: tool provenance, what each control measured against, the scanned revision, and what the run cost. --report evidence writes the same content to a file |
--top | 10, or 3 under --view actions | Console: findings listed in the ranked table. Under --view actions, actions listed in each list. 0 = all |
--min-priority | - | List findings at or above this priority band (P1βP4). Narrows what is printed; results.sarif and publishers keep the full set. See below |
--artifact-min-priority | - | Also narrow the -o artifacts to this band, and record the band inside them. See below |
--compact | false | Deprecated. Use --view compact |
--template | - | Inline Go text/template (with --format template) |
--template-file | - | Go text/template file (with --format template) |
--no-tips | false | Suppress the console's contextual tips and progress line (also DRAUGR_NO_TIPS, or output.tips: false in draugr.config.yaml) |
--view takes:
findings: a block per finding, saying what it is, what is known about it, and where.actions: a row per thing to do, saying how many findings it clears. See what to fix first.compact: one line per finding; injsonandsarifoutput, no indentation and no rule documentation. See machine-readable output.
The -o report files carry every finding whatever the view. The ranked table's heading says
whether it is a shortlist or every finding, and --top 0 also lists every row under ERRORS and
CAVEATS, where the default is ten each.
Caching
| Flag | Default | Description |
|---|---|---|
--cache-dir | - | Enable content-hash caching in this directory |
--cache-ttl | 24h | Cache entry lifetime (0 = no expiry) |
--cache-read-only | false | Read the cache but never write it, for a run whose results the next run should not reuse |
--cache-require-digest | false | Do not cache an image identified only by a tag. Left off, such a result is still reused and the report names it. See what a hit does and does not promise |
Running the scan
| Flag | Default | Description |
|---|---|---|
-j, --jobs | 0 (auto) | Max scan jobs to run in parallel (0 = one per CPU); reported as stats.concurrency (or scan.jobs in draugr.config.yaml) |
--allow-effects | - | Accept scanner effects for this run: disclosure, mutate, network, privilege. Any other value is refused before the scan. config.allowEffects is the reviewed equivalent. See below |
--no-publish | false | Skip the descriptor's configured publishers (still writes -o artifacts and stdout) |
--policy | false | Require the organization's policy check |
--policy-wait | 5m | Total time to wait for the draugr-api servers' verdict on the published run. A verdict not decided by then is named on the POLICY line, and is an error under --policy (0 = do not wait) |
Under --policy, a descriptor publishing to no draugr-api server, a missing token, an unreachable
server or --offline is an error rather than not checked, and so is a verdict on the published
run that the server could not give or had not decided within --policy-wait. With --no-publish,
the policy check runs only under --policy.
The --cache-* flags have counterparts in draugr.config.yaml under cache:
(dir, ttl, readOnly, requireDigest); --view, --evidence, --top and --no-tips under
output: (view, evidence, top, tips); and --jobs under scan: (jobs). A flag on the
command line wins over the file.
draugr scan draugr.saga.yaml
draugr scan draugr.saga.yaml -o out/ --fail-on medium
draugr scan draugr.saga.yaml --min-priority P2 # focus on what matters now
draugr scan draugr.saga.yaml --fail-on P2 # widen the default gate from P1 to P2
draugr scan draugr.saga.yaml --fail-on critical # gate on severity instead of the band
draugr scan draugr.saga.yaml --cache-dir .draugr/cache
draugr scan draugr.saga.yaml -j 4 # cap parallelism (or -j 1 for serial)
draugr scan draugr.saga.yaml --format markdown # portable report (MR comment, wiki)
draugr scan draugr.saga.yaml --format json | jq . # machine-readable
draugr scan draugr.saga.yaml -o out/ --report html # shareable browser report
draugr scan draugr.saga.yaml -o out/ --report junit # CI test panel
draugr scan draugr.saga.yaml --format template --template '{{.Verdict}}: P1={{.Priorities.P1}}'
Output formats (--format). stdout defaults to a human console summary (verdict,
priority/severity counts, "fix first"). markdown is a portable report for MR comments or wikis;
json and sarif are the machine formats; vex is an OpenVEX document; template renders your
own Go text/template (see config.publishers for the
available fields). html, junit and the GitLab formats are files, written with --report into
-o. To render several formats and deliver them somewhere in one run, declare
config.publishers in the Saga.
Tuning parallelism (-j/--jobs). By default Draugr runs up to one scan job per CPU. Trivy and
Semgrep are multi-threaded themselves, so on a small or busy machine a lower -j can finish
sooner; -j 1 runs serially. Each tool's own threads have a bound of their own, under Each
tool's own parallelism. The run's JSON stats reports the effective concurrency alongside
jobs (total jobs), scans, cacheHits and deduped.
It also reports where the time went, in milliseconds: durationMs (wall-clock), byControlMs (each
control's job time, summed across its jobs), toolWaitsMs (time spent queueing for a tool's own
cache rather than scanning) and orderWaitsMs (per scanner, time its jobs waited before starting:
for another scanner's job on the same repository to finish, or for their turn at a Go package
load). Jobs run concurrently, so the per-control times add up to more than the wall-clock. They are
absent, not zero, when a run recorded no timings. dataRefresh lists each scanner's data refresh before the jobs started, in milliseconds. The console puts the total
beside the duration when it is five seconds or more and at least a quarter of the run, and
--evidence names each refresh of a second or more.
Each tool's own parallelism. Draugr starts every external tool with GOMAXPROCS=n and with
-p=n added after the flags already in GOFLAGS, so a Go tool runs on at most n threads and a Go
build it starts, such as govulncheck's package load, runs at most n compile processes. To use a
different bound, set GOMAXPROCS, or -p=<n> in GOFLAGS, before running draugr scan; Draugr
passes either one on unchanged. -j does not change n: each of the jobs running at once starts
its tools under n. govulncheck and gosec take turns, so only one of them is loading Go packages at
any moment across the run, on any number of repositories. Within its turn, gosec loads two
packages at once, each building with -p at half of n, rounded down, so its compile processes
also stay within n. A run with
several such loads takes longer than loads in parallel would, and each job's wait is in
stats.orderWaitsMs.
n is half the CPUs or one per 4 GiB of memory, whichever is fewer, and at least 1. The CPUs are
the ones Draugr may use after a container's CPU limit. The memory is the lower of the machine's and
any cgroup v2 memory.max on Draugr's group or a parent, such as a container's limit or a systemd
scope's MemoryMax. Where neither reports memory, n is half the CPUs. stats.peakToolMemoryBytes
records the largest resident memory any one tool process reached; see the report
schema.
Severity bands. The console reports each finding's severity band (critical, high, medium or
low), derived from its CVSS score where the scanner supplies one, and --fail-on takes the same
words:
| Severity band | From CVSS | --fail-on critical | --fail-on high | --fail-on medium | --fail-on low |
|---|---|---|---|---|---|
| critical | 9.0β10.0 | fails | fails | fails | fails |
| high | 7.0β8.9 | passes | fails | fails | fails |
| medium | 4.0β6.9 | passes | passes | fails | fails |
| low | 0.1β3.9 | passes | passes | passes | fails |
The SARIF levels error, warning and note are accepted as high, medium and low. A word
that is neither a band nor a severity is rejected:
draugr: --fail-on: "severe" is neither a priority band (P1, P2, P3, P4) nor a severity (critical, high, medium, low)
A severity gate judges each finding's ranked severity, not the SARIF level, so a
CVSS 7.8 a scanner emits as warning is high for the gate as it is in the report. A finding with
no CVSS score takes its band from the level its scanner assigned. A control may hold its findings
at a floor (a secrets finding is never below high, however the scanner scored it), and
signals then move the result, so a CVE on KEV is judged critical and an unreachable critical is
judged high. --fail-on-new judges a new finding the same way.
Priority (P1βP4) is ranked from a finding's severity and the component's declared exposure
and criticality (see the Saga reference). A component that declares neither is
ranked as public and critical, so its bands follow severity alone; when no component declares
either, the console says so. See prioritization.
Exploitability (--kev/--epss) raises a finding's severity by real-world signals. A CVE on
CISA's KEV catalog (confirmed
exploited) becomes critical; a CVE at or above the EPSS threshold
(predicted likely) is bumped one level. Both are optional and only affect findings whose rule id is
a CVE. Using the cache in a scan says where the data comes from.
Scanners that do more than read
Most scanners read an artifact and nothing else. A few send traffic to a target, tell a third party
about it, change something, or need elevated access, and each of those is declared as an effect
that Draugr shows before a scan and records afterwards. A scanner with the mutate or privilege
effect does not run until a descriptor accepts it, or --allow-effects does for one run.
See what a scan does for each effect and how to accept one.
Which build of each scanner ran
A scan runs whatever is on PATH, and the scanners row under --evidence records which build
of each tool that was:
scanners gitleaks 8.30.1, trivy 0.69.3
unverified semgrep 1.173.0 Β· not installed by Draugr
The first line lists, together, the builds at pinned or signed: in ~/.draugr/bin, in the
install record, and still hashing to what was recorded. Every other build gets its own line with
the reason. The version of a tool Draugr installed comes from its install record; a tool brought
some other way is asked, and one that does not answer is listed without a version.
Where Draugr can install a tool the scan ran from elsewhere on PATH, TRY offers the command:
TRY
draugr tools install semgrep scan on builds Draugr fetched and checked
Each build is recorded at one of these levels:
| Level | What it means |
|---|---|
pinned | Installed at the version Draugr ships, matching a checksum recorded in this build |
signed | Installed at another version, matching checksums signed by the upstream's Sigstore identity |
checksum | Installed, matching an unsigned checksums file fetched from the upstream |
unverified | Installed, with nothing published to check it against |
external | Not installed by Draugr, found on PATH |
An unsigned checksums file over HTTPS shows the download was not corrupted or truncated, without showing the upstream published it.
None of this affects the verdict.
How current each scanner's data is
Under --evidence, the data row lists each data source the run's scanners read, with the date
this machine downloaded it and, where the tool records one, the date its publisher built it:
data trivy checks bundle downloaded 2026-10-17 Β· trivy vulnerability database published
2026-10-16, downloaded 2026-10-17 Β· retire advisory database downloaded 2026-10-17
Dates are UTC. refused as too old marks a copy the tool will not use, which fails that tool's
scans. By default Grype refuses a database built more than five days ago, and
GRYPE_DB_MAX_ALLOWED_BUILT_AGE changes the limit. Refresh the copy with the tool's own update
command; on a runner without a network, copy a newer cache in, as
running air-gapped describes.
report.json carries the same entries under scannerData:
| Key | Holds |
|---|---|
tool, data | the tool and the data source, as the row names them |
version | the schema version of a Trivy or Grype database, the first twelve characters of the Trivy checks bundle's digest, or the Nuclei template release |
published, downloaded | each an RFC 3339 time in UTC, absent where the tool records none |
tooOld | the tool's own reason, for a copy it refuses |
draugr doctor lists the same sources before a scan.
DESCRIPTOR, under --evidence
A descriptor assembled from more than one file, or holding a control default from a configuration
file, gets a DESCRIPTOR section naming each file, and the descriptor evidence row summarizes it:
$ draugr scan draugr.saga.yaml --evidence
β¦
DESCRIPTOR
draugr.saga.yaml root Β· digest b2e3b3ae8794
security/exclusions.saga-fragment.yaml digest 8ba02e9af9a5
β¦
descriptor draugr.saga.yaml + 1 fragment Β· merged digest 186eb88ac44bA local file carries the digest of its content. A fragment from another repository carries
<repository>@<revision> and, where the revision is not itself a commit, commit <sha> for the
commit it resolved to.
A configuration file that supplied a control default is
listed after them as <path> config Β· digest <sha> Β· <settings>, and the descriptor row ends
Β· includes config defaults. The row's fragment count leaves configuration files out.
A descriptor that is a single file, with no configuration file behind any of its settings, gets no section.
--format prints; --report writes
--format chooses what a run prints to stdout, and --report chooses which files it writes into
-o.
--format accepts the formats a person or a pipe reads: console, markdown, json,
sarif, vex, template. A document format is refused with the flag that writes it:
draugr: html is a document, not something to print: use `--report html` with `-o <dir>`
--report names the files written into the directory -o names:
draugr scan draugr.saga.yaml -o out/ # report.json + results.sarif
draugr scan draugr.saga.yaml -o out/ --report html,junit # report.html + report.junit.xml
draugr scan draugr.saga.yaml -o out/ --report vex # openvex.json
draugr scan draugr.saga.yaml -o out/ --report html --format console
draugr scan draugr.saga.yaml -o out/ --report gitlab-codequality
html, junit, evidence and the GitLab formats (gitlab-sast, gitlab-dependency-scanning,
gitlab-secret-detection, gitlab-container-scanning, gitlab-codequality, gitlab-cyclonedx)
are --report only. evidence is a companion to a report rather than a replacement for one: it
holds what stands behind the verdict and none of the findings. A GitLab runner reads its formats
from the paths named in artifacts: reports:; see
reports & publishers for which
GitLab surface each one feeds, and on which tier.
-o on its own writes report.json and results.sarif. --report replaces that default rather
than adding to it.
A publisher in config.publishers keeps the same split: its
reports: names what to render and its kind names where to send it.
--working-tree, for the loop of fixing something
--working-tree scans each local repository as it is on disk, uncommitted work included, so
fixing a finding needs no commit between scans:
draugr scan draugr.saga.yaml --working-tree
Under --evidence, the scanned row marks the revision with + and counts the uncommitted files:
$ draugr scan . --controls secrets --working-tree --evidence
β¦
scanned github.com/draugr-dev/draugr commit 6acefbc6+ Β· working tree Β· 1 uncommitted file, not
reproducibleThe result is not reproducible: nobody else can check out what was scanned. These scans are never cached, because two runs at one revision can read different files.
It reads a copy, not your checkout, so scanners cannot write into your files and paths /
ignore scoping prunes the copy. The copy holds git ls-files -co --exclude-standard: tracked
files plus untracked ones that are not ignored, so a node_modules or a local .env is left out as
a commit would leave it out.
A remote repository is refused, naming it:
draugr: --working-tree needs a local checkout, and https://github.com/acme/api.git is a remote: scan without the flag, or point the descriptor at a path
What --min-priority narrows
--min-priority decides what a run shows you. It never decides what a run records:
| Output | Narrowed by --min-priority? |
|---|---|
Console, markdown, HTML, JUnit, whether printed or written with --report | Yes, with a note saying how many were hidden |
--format json / --format sarif on stdout | Yes |
-o <dir>/report.json | Its findings list only. The priority counts still describe the whole run |
-o <dir>/results.sarif | No, always complete |
Configured publishers, including github code scanning | No. Always complete, and the run logs that it ignored the flag |
The files stay complete because GitHub code scanning and
draugr diff both read a finding missing from a file as
fixed.
Narrowing on purpose
These narrow a file deliberately:
--artifact-min-priority P1, orminPriority: P1on a report inconfig.publishers, narrows the written SARIF and JSON.draugr diff --format sarifemits only the findings a change introduced.
Both record what they left out: a narrowed SARIF carries a draugr/min-priority provenance
entry naming the band, as a scoped run records its scope.
A comparison refuses a report narrowed to a band it would misread. A base narrowed to P2
compared with a whole head would report every P3 or P4 finding as new, and a head narrowed above its
base would report the base's lower findings as fixed. draugr diff compares two reports narrowed
alike, or a narrowed base with --min-priority at or above its band. draugr scan --base accepts
only a base written without narrowing. A report narrowed to P4 counts as written without narrowing.
An upload narrowed to P1 resolves the P2βP4 alerts for the ref and category it was uploaded
against. A pull-request run uploads against the pull request's own ref and leaves the default
branch's alerts alone, while a narrowed upload from a push to the default branch resolves the
omitted alerts there.
--min-priority alone is for reading: a terminal, or an agent asking for the short list. With
--format sarif it also drops the rules that only the omitted findings referenced.
Gating on new findings
--base names the results.sarif of an earlier scan, usually of the branch a pull request merges
into. The scan compares itself with that report by the comparison
draugr diff makes, and a finding it reports that the base does
not is new.
With a gate on new findings set, --fail-on-new or
config.gate.failOnNew, the scan is judged on its change. The verdict
and the exit code come from that gate alone, which fails the run when a new finding is at or above
it. --fail-on still judges every finding the scan reports, and report.json records that outcome
as gate.wholeRun, where it decides nothing.
draugr scan draugr.saga.yaml -o main/ --no-publish # on main, kept as an artifact
draugr scan draugr.saga.yaml -o head/ --base main/results.sarif --fail-on-new P3
The console prints the change in the layout draugr diff uses. The verdict line names the base's
revision, how many findings are new and the gate. Band counts for new and unchanged findings
follow, then for the whole run, then what changed. The policy, MEASURED AGAINST, errors and caveats
blocks are unchanged. A failed run's last line names the gate, the new findings that met it and
the base.
$ draugr scan --base base/results.sarif --fail-on-new P3 --view actions --no-publish
DRAUGR FAIL shop change since b5bcb4b Β· 15 new Β· fails on P3 3.894s
new 0 P1 0 P2 15 P3 0 P4
unchanged 0 P1 0 P2 1 P3 0 P4
whole run 0 P1 0 P2 16 P3 0 P4
WHAT TO DO 2 actions clear 15 findings
P3 Upgrade urllib3 1.26.5 β 2.8.0 control sca Β· 10 findings
requirements.txt:3 Β· CVE-2023-43804 +9
P3 Upgrade jinja2 3.1.2 β 3.1.6 control sca Β· 5 findings
requirements.txt:2 Β· CVE-2024-56201 +4
TRY
--view compact one line each, to see how much there is
--view findings the findings themselves, one row each
draugr explain <rule> what a rule means and how to fix it
draugr: fails on P3: 15 new findings since b5bcb4b- One verdict per run. The organization's policy and a scan that could not finish still fail the
run, as they fail any other.
--no-gateexits 0, andreport.jsonstill states each outcome. - Without a gate on new findings,
--basechanges no verdict. The run is judged on every finding and prints what a run without--baseprints;report.jsonrecords the comparison. - The base has to cover what the scan covers. A base whose scope differs from
the run's, or one narrowed to a band, is refused before anything is
scanned, as
draugr diffrefuses two such reports. - Both sides are committed revisions, so the base and this scan differ only where they read
different commits. See
draugr diff. - A gate on new findings with no
--baseis reported rather than applied.report.jsonstates the setting with no comparison, and stderr says that no finding was judged as new.
report.json states the comparison in its gate block: the setting,
the commit and repositories the base records, the new findings by band, and the verdict. Its
top-level verdict is the one the exit code came from.
The organization's policy
A descriptor that publishes to a draugr-api server is checked
against the organization's policy before any scanner starts, with the endpoint and token publishing
uses, or, where no token is set, the login draugr login stored. The server
judges the descriptor and words each verdict; Draugr shows what it says and acts on it:
| A broken setting that | Does |
|---|---|
| refuses, and is in force | stops the run before any scanner starts, and the command exits non-zero |
| fails the gate, and is in force | runs the scan, then fails the gate naming the rule, whatever the findings say |
| observes, or is not in force yet | is shown, and changes nothing |
The scan lists each broken rule under CONTROLS, one item per line, then the profile that set it
and what it does:
POLICY version 7 Β· https://draugr.example
threshold failOn: P1 Β· required: P2 or stricter PCI scope Β· fails the gate, since 2026-10-01
scanners sca scanners: trivy-fs Β· required: grype-fs Default Β· observedA run that could not ask says POLICY not checked and one of these reasons:
no $DRAUGR_API_URL or $DRAUGR_API_TOKEN, with neither an endpoint nor a token;no $DRAUGR_API_TOKEN, and no login for <server>; run `draugr login <server>`, for aurl:with no token and nodraugr loginfor its server;the login for <server> expired at 2026-10-28 06:12 UTC; run `draugr login <server>`, for a login past its expiry;offline, under--offline;<server> checks no organization policy, from a server that judges none;- the error, from a server that could not be reached;
the descriptor's url is not an allowed origin, for aurl:the environment does not allow;publish.apiUrl in the project's draugr.config.yaml is not an allowed origin, for the same in that file;the descriptor publishes to no draugr-api server, with$DRAUGR_API_URLset and nodraugr-apipublisher;run with --policy to ask the server, fromdraugr doctorwithout--policy.
--policy makes each of them an error.
A rule the server judges from the run's findings rather than from the descriptor is decided once the
run is published. So a scan publishes to its draugr-api servers first and waits up to
--policy-wait, across all of them, for their verdict on the run, then prints the report, writes -o and runs the
other publishers with that verdict in them. The verdict on the run replaces the pre-flight's in the
POLICY block, and a broken rule in force fails the gate as the pre-flight's does. A verdict the
scan could not get is never a pass, and the POLICY line says which:
not decided within --policy-wait, where the wait ended with a rule still undecided;run not checkedand the status, where the server answered the request for the run's verdict with401,404or a5xx.
Without --policy, neither fails the gate. report.json records the server, the policy version and
every verdict as policy, and the
verdict on the published run as policy.run. A scan with --no-publish is published nowhere, so it
asks nothing unless --policy is passed.
draugr explain <rule-id>
Print what a scan's own report holds about one rule: its description, the detail its scanner published, where the scan found it (up to five locations), and its reference link.
draugr scan draugr.saga.yaml -o out/
draugr explain kube-bench/cis/4.3.1 -r out/results.sarif
kube-bench/cis/4.3.1
Ensure that the kube-proxy metrics service is bound to localhost
DETAIL
Modify or remove any values which bind the metrics service to a non-localhost address.
The default value is 127.0.0.1:10249.
LOCATION
kubernetes/prod-cluster
REFERENCE
https://www.cisecurity.org/benchmark/kubernetes
DETAIL is left out when the scanner published nothing beyond the description.
| Flag | Default | Description |
|---|---|---|
-r, --report | - | The results.sarif to read. Without it, the first that exists of .draugr/out/results.sarif, results.sarif, draugr-out/results.sarif and .draugr-out/results.sarif |
The id can be given in full, or by its trailing /-separated segments: 4.3.1 and cis/4.3.1
both find kube-bench/cis/4.3.1, and 4.3 finds nothing. A whole id also matches regardless of
case. An abbreviation matching more than one rule lists them rather than choosing one.
A report holds only the rules its scan reported, so a rule the scan did not find is not there.
draugr diff [base.sarif] <head.sarif>
Compare two scans and classify every finding as new, unaccepted, accepted, fixed,
moved or unchanged: the security delta of a change, typically a pull request's head against
its base branch. Inputs are the results.sarif files that
draugr scan -o writes, which are always complete regardless of
--min-priority. Two reports of different scope are refused, and so are two
narrowed to different bands, unless --min-priority is at or above the
base's band.
Accepted is a finding somebody excused rather than fixed: an exclusion added, or a finding that
arrived already covered by one. Unaccepted is a finding whose exclusion was removed or reached
its expires date, so it counts again. Still accepted is a finding suppressed in base and
still suppressed in head; it counts toward the unchanged total and is never listed as a change.
Accepted findings are counted apart from fixed ones.
Moved is a finding reported under a different component in head, with the same tool, rule,
file, repository and suppression, and the same content hash or message. Splitting or renaming a
component moves every finding it held. Moves are listed in a MOVED table, one row per pair of
components, then a PRIORITY CHANGED table with a row for each finding whose priority changed with
its component.
New, unaccepted, accepted and fixed findings are listed in one CHANGED table, ranked by priority,
with what happened to each finding in its own column. Within a band, what needs somebody comes
before what does not.
--view actions lists the changes as things to do instead: one upgrade per package and one action
per line of code, titled as draugr scan --view actions titles them and listing every rule that
flags the line. A row names its component and repository when the actions span several.
Above the CHANGED table, one strip per state counts the prioritized findings in head by band:
new, unaccepted, accepted, moved, unchanged and still accepted. A strip with nothing in it is not drawn, and fixed has none,
because a fixed finding is not in head.
$ draugr diff head/results.sarif
DRAUGR DIFF 4 new
new 3 P1 1 P2 0 P3 0 P4
Base: none, so every finding is new.
CHANGED 4, by priority
Change Priority Severity Rule Scanner Location Upgrade
+ new P1 high CVE-2021-44906 trivy package-lock.json:15 minimist 1.2.5 β 1.2.6
Fixture: prototype pollution in minimist
β¦| Flag | Default | Description |
|---|---|---|
--format | console | Output format: console, json, markdown, sarif. sarif emits the new findings only, for code scanning on a pull request |
--view | findings | Console: how much of each row to show. findings adds the finding's own sentence under the row, compact is one line each, actions groups by the thing to do |
--top | 0 | Console: how many changed findings to list (0 = all), or how many actions under --view actions. Also caps the moved findings whose priority changed |
--min-priority | - | Report only new findings at or above this priority band (P1βP4), plus new findings with no priority; fixed and unchanged are unaffected. Narrows the diff, never the scans it was computed from |
--repository | - | Keep only new findings from this repository, plus those belonging to none (an image, a host). For a code-scanning upload, whose paths anchor to one checkout |
--fail-on-new | - | Fail if a new finding is at or above this: a priority band (P1βP4) or a severity (critical, high, medium, low). Overrides config.gate.failOnNew. Without either, the diff exits 0 |
--fail-on-new-priority | - | Deprecated: write the band in --fail-on-new. Refused together with --fail-on-new |
--saga | - | The descriptor, or a directory holding one, whose config.gate.failOnNew applies when --fail-on-new is not passed. Without it, the descriptor in the working directory |
--publish | false | Post the diff as a sticky pull-request comment. Picks github-pr-comment, azure-pr-comment or gitlab-mr-comment from the CI environment; no-ops off a PR |
--view and --top have counterparts in draugr.config.yaml, output.view and
output.top, read as draugr scan reads them: a flag on the command line wins over the file, and a
top of 0 in the file keeps the default of every row. A diff has no evidence section, so
output.evidence sets nothing here.
The gate reads new only. Accepted, unaccepted and moved findings do not trip it, and each is reported.
The gate in the descriptor. Without --fail-on-new, the gate is
config.gate.failOnNew in the descriptor --saga names, or in the
descriptor in the working directory. A working directory holding several is read as holding none,
and stderr says so. A descriptor that is there and cannot be read is an error.
draugr scan --base applies the same setting with the same comparison.
The head alone. The pull request that adds a repository's first descriptor has nothing at its
base to scan. draugr diff head/results.sarif, with no base report, compares the head against no
findings, so every finding in it is new and --fail-on-new gates on them, and the report says there
was no base. A base path that names no file is still an error. The GitHub Action and the GitLab and
Azure DevOps templates diff the head alone when the base commit lacks the descriptor, so the first
pull request that turns Draugr on is gated like any other.
Where no gate is asked for, the report states no verdict, and draugr diff exits 0.
draugr diff base/results.sarif head/results.sarif # console delta
draugr diff base/results.sarif head/results.sarif --format markdown # MR comment
draugr diff base/results.sarif head/results.sarif --fail-on-new P1
draugr diff base/results.sarif head/results.sarif --publish # sticky PR comment (in CI)
Differential gating. --fail-on-new fails a pull request only for findings it
introduces, not for the backlog already on the base branch, and the command exits non-zero when
the gate trips. A typical CI setup scans main on push and stores results.sarif as an artifact,
scans the pull request, then diffs the two.
Both sides are committed revisions unless a scan used
--working-tree. A repository is cloned before
it is scanned, whether it was given as a URL or a local path, so scanning, editing and re-scanning
locally produces two identical files and an empty diff. Commit between the two scans, or point
revision at each revision in turn. See
URLs and paths.
Finding identity. A finding in both reports is matched first by a hash of the lines around it, then by tool, rule, file, message, component and repository, and then by each of those again without the component, which is how a moved finding is found. The line number and the severity are never part of it, so a finding that moved within a file or was re-scored is unchanged rather than fixed and new. The diff guide has the detail.
draugr survey
Discover what an application is made of and write it into a descriptor.
Each surveyor is its own subcommand:
| Command | Discovers |
|---|---|
draugr survey k8s images | unique container images running in a cluster, with their digests |
draugr survey k8s cluster | the cluster itself, as a component's kubernetes: entry |
draugr survey github repos | repositories in a GitHub organization |
draugr survey gitlab projects | projects in a GitLab group, subgroups included |
draugr survey azure repos | Git repositories in an Azure DevOps organization or project |
draugr survey provenance | who signs the images the descriptor already declares |
--k8s-images, --k8s-namespace and --github-org are not flags; use the subcommands above.
| Flag | Applies to | Default | Description |
|---|---|---|---|
--context | k8s images, k8s cluster | the current context | Kubeconfig context to survey |
--namespace | k8s images | every namespace | Describe only this namespace; repeat for several |
--namespace | k8s cluster | - | The component owns this namespace rather than the whole cluster; repeat for several |
--no-exposure | k8s images | false | Leave each component's exposure unset rather than proposing it |
--org | github repos | - | The GitHub organization to discover repositories in. Required |
--group | gitlab projects | - | The GitLab group to discover projects in, subgroups included. Required |
--org | azure repos | - | The Azure DevOps organization, or on Azure DevOps Server the collection, to discover repositories in. Required |
--project | azure repos | - | Limit discovery to one project |
--trust-root | provenance | - | A Sigstore trusted-root file, for an organization running its own Sigstore |
Shared by all of them:
| Flag | Default | Description |
|---|---|---|
-o, --output | stdout | Write the descriptor here, adding to the one already at that path |
--replace | false | Overwrite the descriptor at --output instead of adding to it |
--fragment | false | Write a Saga fragment |
--name | - | Project name for a newly created descriptor |
--version | - | Release version for a newly created descriptor, omitted when empty |
provenance reads --output as well as writing it: the images it asks about are the ones that
descriptor already declares. A descriptor that does not exist yet, --replace, or a descriptor that
declares no images is an error.
Discovering who signs your images
survey provenance writes signers: from the identities that sign the descriptor's images
today. A job calling a reusable workflow is signed as that workflow's repository rather than as
the caller, which is the identity this reads.
draugr survey provenance -o draugr.saga.yaml
It writes the exact identity and the exact image, never a pattern, and groups images sharing an identity under one signer. An unsigned image produces no signer and is not an error.
Read what it proposes. A signer derived from what signs an image today passes the check by construction, so it catches the signature changing and does not show that today's signer is the right one. The command names each identity it adopted and the image it came from, and writes the same note beside the value in the descriptor.
An organization running its own Sigstore passes --trust-root, naming the same file
config.controls.provenance.trustRoot does.
Writing a fragment instead of a descriptor
--fragment writes a Saga fragment for a descriptor to include. It
holds components, and from survey provenance the provenance.signers a fragment may carry.
draugr survey k8s images --namespace team-a --fragment -o team-a.saga-fragment.yaml
A fragment carries no release:, so --name and --version are refused alongside it. It enables
no controls either; the descriptor that includes it decides what runs.
draugr validate and a fragments: reference both decide what a file is from its suffix, so with
--fragment an -o path must end in .saga-fragment.yaml (or .yml). The check runs before the
survey connects to anything. Without -o, the fragment goes to stdout.
Not proposing an exposure
--no-exposure leaves exposure unset rather than proposing it from cluster topology:
draugr survey k8s images --no-exposure -o draugr.saga.yaml
The topology lookups are then not made, so a credential that cannot read Ingresses, Services and
NetworkPolicies produces no warnings about them. Use it when draugr classify is where exposure
gets decided.
Auth: each forge surveyor reads a token from the environment; the Kubernetes surveyors use your
ambient kubeconfig (KUBECONFIG / ~/.kube/config / in-cluster).
Without a token, every forge answers with the public repositories only, and the survey warns that the descriptor is missing every private one.
| Forge | Token | Self-hosted instance |
|---|---|---|
| GitHub | GITHUB_TOKEN | GITHUB_API_URL, including Enterprise Server's /api/v3. GitHub Actions sets it on a GHES runner |
| GitLab | GITLAB_TOKEN | CI_API_V4_URL, which a runner sets, then GITLAB_URL, then CI_SERVER_URL |
| Azure DevOps | AZURE_DEVOPS_EXT_PAT, else AZURE_DEVOPS_TOKEN. Needs the Code (read) scope | AZURE_DEVOPS_URL, without the collection, which --org names |
draugr survey github repos --org my-org -o draugr.saga.yaml
draugr survey gitlab projects --group my-group -o draugr.saga.yaml
draugr survey azure repos --org my-org -o draugr.saga.yaml
draugr survey k8s images --namespace prod -o draugr.saga.yaml
draugr survey azure repos takes --project or leaves it out: with it, the survey covers that
project's repositories; without it, the whole organization's.
draugr survey azure repos --org my-org --project Platform -o draugr.saga.yaml
--namespace may be repeated, and each one becomes its own component with its own proposed
exposure:
draugr survey k8s images --namespace payments --namespace checkout -o draugr.saga.yaml
draugr survey k8s images --namespace payments,checkout -o draugr.saga.yaml # equivalent
With no --namespace, every namespace is described, each as its own component with its own
images and proposed exposure. --namespace limits which namespaces are described.
It says what it wrote. A survey that writes a file reports the path and what is now in it, on stderr so a descriptor sent to stdout stays a descriptor:
wrote draugr.saga.yaml Β· 12 components, 12 repositories
A survey that added to an existing descriptor says merged into <path> and what it contributed,
and one that discovered nothing says the descriptor scans nothing. The line is printed only with
-o.
The output is scannable as written. Discovery enables the controls for the surfaces it found:
repositories enable sca, secrets, sast and iac; images enable images; hosts enable
headers and tls; clusters enable kubernetes. dast is never enabled this way, because it
sends attack traffic at a live service.
A control already configured is never touched, including one set to enabled: false.
Several surveys can write one descriptor: each folds into the descriptor already at --output,
which is also how discovery is added to a descriptor maintained by hand.
Unless --no-exposure is given, k8s images proposes each component's exposure from its
namespace's topology: an Ingress or a LoadBalancer or NodePort Service β public, a
NetworkPolicy β restricted, otherwise internal. Where none of them can be read, nothing is
proposed. Each proposal is listed on stderr with the value chosen:
exposure proposed from cluster topology, not confirmed. Run `draugr classify` to set it:
payments public
A component that already carries an exposure keeps it and is not listed. Confirm the rest, and set
criticality, with draugr classify.
draugr classify [saga.yaml | directory]
A guided wizard that sets each component's exposure and criticality, the two inputs to
finding prioritization, and writes them back into the descriptor (preserving comments and
formatting). It asks a few questions per component and derives the values. By default it asks only
about components missing either one.
Finds the descriptor the same way draugr scan does: with no
argument, or with a directory, it uses the *.saga.yaml or *.saga.yml there. With more than one,
it asks which on a terminal and otherwise exits naming them.
A directory holding none has no descriptor to write the answers into. On a terminal, classify
offers to write one with draugr init in that directory, then asks its
questions about it; the default answer is no:
$ draugr classify
No *.saga.yaml in the current directory. Write draugr.saga.yaml with `draugr init`? [y/N]Declining, or a run with no terminal, writes nothing and exits naming the command that writes the descriptor:
$ draugr classify
draugr: no *.saga.yaml in the current directory. Run `draugr init` to write one
$ draugr classify ..
draugr: no *.saga.yaml in "..". Run `draugr init .. -o ../draugr.saga.yaml` to write one| Flag | Default | Description |
|---|---|---|
--all | false | Re-classify every component, not just unclassified ones |
--components | (all) | Only these components, by name. Naming one re-asks about it even if it is already classified |
A name that matches no component is an error, suggesting the closest name where one is close and otherwise listing the components that exist.
draugr classify # the descriptor in this directory
draugr classify draugr.saga.yaml # a specific file
draugr classify --components gateway,api # redo two, leave the rest alone
draugr validate [saga.yaml | glob ...]
Parse each descriptor, resolve ${{ VAR }} references, and check it against the schema and the
controls and report formats this build provides, without running any scanners. It needs no scanner
installed, so it suits a pre-commit hook, a CI lint step, or an editor; a fragment from another
repository is fetched with git. Exits non-zero if any file is invalid.
Accepts paths and globs, and with no arguments discovers every *.saga.yaml and
*.saga-fragment.yaml (and their .yml forms) beneath the current directory, useful in a repo
holding one descriptor per service. .git, node_modules, vendor and dist are skipped.
A fragment is checked as a fragment, against the fragment schema, so its missing release:
is not an error.
draugr validate # every descriptor in the repo
draugr validate draugr.saga.yaml # one file
draugr validate 'services/*/*.saga.yaml' # a glob (quote it, so the shell doesn't expand it first)
draugr validate azure.saga.yaml --resolved # print the descriptor with every fragment merged
With more than one file, each is reported on its own line, then a count:
$ draugr validate
β draugr.saga.yaml is valid
β svc-a/api.saga.yaml
parse saga: unknown field "componnets" in the top level. Check the spelling, or see https://draugr.dev/docs/latest/reference/saga-schema/
β svc-b/web.saga.yaml
parse saga: unknown field "failOnn" in config.gate. Check the spelling, or see https://draugr.dev/docs/latest/reference/saga-schema/
β svc-c/worker.saga.yaml
parse saga: release.name was removed: it named the project, which is what the top-level `project` names. Move the value there, `project: payments-api`, and a release keeps only its version
draugr: 3 of 4 Saga files invalidThe section named is the path you write, which is also the heading to look for in the
Saga schema. A key that was removed says so and where its value belongs. A single
file's error is printed on its own, as draugr: parse saga: β¦. A valid file can carry warnings,
marked !, and notes, marked Β·, on the lines under it.
A pattern that matches nothing is an error:
draugr: no files match "nothing/*.saga.yaml"
--resolved
Print the descriptor with every fragment merged in, each source
named in a comment at the top. A descriptor with no fragments says so there. Each component's
exposure, criticality, builtBy and labels carry a # from <file> comment when the descriptor
has fragments.
draugr validate azure.saga.yaml --resolved
# Resolved Saga, every fragment merged. Generated by `draugr validate --resolved`.
# Valid input: comments are the provenance, so this can be scanned as it stands.
#
# root: azure.saga.yaml
# fragment: services/payments/draugr.saga-fragment.yaml
# fragment: https://github.com/acme/platform.git@v2.4.0 (40d23df24acc) components/api/draugr.saga-fragment.yaml
Provenance is carried in comments, so the output is also a valid descriptor:
- Flatten it to cross an air gap. Resolve where there is network, scan the result where there is none. See running air-gapped.
- Pin it. Each remote fragment is recorded at the commit it resolved to, so a flattened copy behaves as a lockfile.
- Diff it in CI. Commit the resolved descriptor and fail when it stops matching, so a one-line
fragments:change is reviewed by its effect rather than its cause. - Query it.
draugr validate azure.saga.yaml --resolved | yq '.components[].name'
The fragments: block is dropped from the output, since everything it named is already merged
in. --resolved takes one descriptor at a time.
draugr doctor [saga.yaml]
Preflight the environment: report which external scanner tools are present, missing, or out of
date, with an install hint for each. Given a descriptor, it first loads it and reports a
parse or schema error, then checks only the tools needed by the scanners its enabled controls
select (plus git for repository targets and syft when config.sbom is enabled), then checks
that each target the scan would read is reachable from this machine. It refuses a
control name this build does not provide with the message
draugr validate gives. It does not check report formats;
validate does.
With a descriptor, it exits non-zero when:
- the descriptor cannot be loaded or names a control this build does not provide;
- a required tool is missing or has no data;
- a target check fails;
- under
--policy, the organization's policy would refuse or fail the scan; - a
draugr-apipublisher has an endpoint with neither a token nor a login, or a token without an endpoint, or atokenEnvthat$DRAUGR_TOKEN_ENVSdoes not list; --strict,--policyor--fail-on-uncoveredapplies, as the flag table says.
So it gates CI: draugr doctor saga.yaml && draugr scan saga.yaml.
Without a descriptor it is an inventory. It lists every tool Draugr can use and which are
present, and exits zero when tools are missing, because nothing has been selected. Tools that back
only opt-in scanners, such as kube-bench and grype, are needed only by a descriptor that enables
them. The kubernetes control's default scanner reads the Kubernetes API directly, so it needs no
binary.
A tool is also reported as unusable (β no data) when it is installed but its supporting data is
not: kube-bench without its cfg/ benchmarks, nuclei without its templates.
Versions
Doctor holds each installed tool to the build draugr tools install fetches for it: the version
Draugr tests, or the version tools.<name>.version pins where there is a pin.
Draugr's own suite runs against the tested build. A tool on PATH at another version, from a
distribution package or installed with --version, has its row say which version was tested, and
one line under the table counts them:
$ draugr doctor --offline
β¦
TOOLS
Tool Status Version Notes
β¦
trivy β found 0.69.3 /usr/bin/trivy Β· tested against 0.74.0
4 installed tools are not the version Draugr tests. `draugr tools install --force` installs the tested build.The line is a note, not a failure, unless --strict is given. An older scanner can find fewer
things than the tested build.
A tool whose version Draugr cannot read, such as a build that reports dev, shows
? version unknown instead of β found, the status draugr tools list
gives it. The line under the table does not count it, and --strict does not fail on it.
govulncheck is also held to the Go it was built with, read from the binary's build information.
A build from a Go older than the release draugr tools install builds it with cannot load a module
declaring a newer Go, so its row says built with go1.26.10, older than go1.27.3. The line under
the table counts it in a clause of its own, joined to the version clauses with and when there are
both:
1 installed tool is built with an older Go than Draugr builds it with. `draugr tools install --force` rebuilds it.
A pinned tool is held to its pin instead. Its row names the file that sets the pin, and the version when the tool is not at it. The line under the table counts the two kinds apart:
# pin.yaml
tools:
trivy: { version: "0.69.3" }
semgrep: { version: "1.177.0" }
$ draugr doctor --config pin.yaml --offline
β¦
TOOLS
Tool Status Version Notes
β¦
semgrep β found 1.177.0 /home/wilson/.draugr/bin/semgrep Β· pinned in ./pin.yaml
syft β found 1.51.1 /home/wilson/.draugr/bin/syft Β· tested against 1.55.0
trivy β found 0.74.0 /home/wilson/.draugr/bin/trivy Β· pinned to 0.69.3 in ./pin.yaml
5 installed tools are not the version Draugr tests and 1 is not at its pinned version. `draugr tools install --force` installs the pinned build where there is a pin and the tested build otherwise.--strict holds a pinned tool to its pin as well.
Targets
Given a descriptor, doctor plans the scan and checks each target the plan reads, with this machine's network and credentials:
| Kind | Check | Passes when |
|---|---|---|
repository | resolves the revision; a full commit name is looked up in the repository | git resolves it |
paths | reads the tree at that commit | every entry of the component's paths is committed there |
image | the local Docker daemon, then the registry's manifest, with the credential docker login stored | either one has the image |
host | opens a TCP connection, and completes the TLS handshake for https | the connection opens |
cluster | resolves the kubeconfig context the cluster names, or the current one, and asks its API server for its version | the API server answers |
account | asks Google Cloud whether the Application Default Credentials can read the project | they hold resourcemanager.projects.get on it |
file | reads each file in components[].imports | it opens and is SARIF 2.1.0 |
A target two components share is checked once. A certificate this machine does not trust still
passes, with the reason in the row, because the scanners that probe an endpoint do not verify it.
An image's digest is shortened to twelve hex characters, and --json keeps it whole. On a
terminal too narrow for the table, each target takes a line with its result on the line under it.
$ draugr doctor draugr.saga.yaml
TARGETS
Kind Target Result
repository https://github.com/draugr-dev/draugr-demo.git@main β resolves to a103420078f4
paths web β in the tree at a103420078f4
paths services/api β paths "services/api": not in the repository at a103420078f4
image python:3.8-slim β in the local Docker daemon
image ghcr.io/acme/api:2.1 β 403 forbidden, no credential for ghcr.ioThe scope flags draugr scan takes narrow the target checks to the components and controls they
select; the tool, network and data lists still cover the whole descriptor, and without a descriptor
the flags do nothing. --offline skips every check that needs the network and marks each
not checked: --offline, while a local repository and the local Docker daemon are still checked.
It also skips the policy check, and a descriptor with a fragment from another
repository cannot be loaded under it.
Policy
Given a descriptor that publishes to a draugr-api server and --policy, doctor asks that server
what the organization's policy makes of it, as draugr scan does, and
lists every rule that reaches the project, passing and broken, with what its setting does to a run
that breaks it:
$ draugr doctor --policy draugr.saga.yaml
POLICY payments Β· version 7 Β· https://draugr.example
Rule Profile Result If broken
threshold PCI scope β failOn: P1 Β· required: P2 or stricter fails the gate, since 2026-10-01
upgrade Default β fixes.upgrade: minor Β· required: minor or stricter fails the gate, since 2026-10-01
licenses Default β judged at publish observedA rule marked β is one the server judges from the run's findings rather than from the descriptor,
or one that asks nothing of this project. Doctor exits non-zero where a scan of the descriptor would
be refused or would fail its gate.
Without --policy, doctor sends no token and asks no server. It prints POLICY not checked Β· run with --policy to ask the server, or the reason the check could not run at all, such as the descriptor's url is not an allowed origin.
With neither an endpoint nor a token it prints
POLICY not checked Β· no $DRAUGR_API_URL or $DRAUGR_API_TOKEN, and --policy makes that an
error; with only one of the two it is an error either way. $DRAUGR_API_TOKEN is the project's
ingest token, which belongs in CI.
On your own machine, draugr login stands in for the token. A login's
check, and one made with a personal API token (drgr_api_β¦) in $DRAUGR_API_TOKEN, is made for
the project the descriptor's project: names, by any role you hold in it. Doctor names whose login
the server was asked with and when it expires:
$ draugr doctor --policy draugr.saga.yaml
POLICY payments Β· version 7 Β· https://draugr.example
login ana@acme.com Β· acme Β· until 06:12 UTCA login that has expired, been revoked, or lacks the check scope leaves the policy not checked,
and the reason names the draugr login command to run. So does a descriptor with no project:, a
project your token cannot reach, and one you hold no role in, each named in the reason.
Network
Doctor lists every host a scan contacts besides its targets, per control: the reference data each scanner fetches, and the third party a disclosing scanner tells about a target. Given a descriptor, the list holds only the scanners that scan runs, which makes it the egress allowlist for that runner. Without one, it holds every control.
$ draugr doctor draugr.saga.yaml
NETWORK (besides its targets Β· --offline stops all of it)
draugr scan
sca ghcr.io vulnerability database Β· before the scan
mirror.gcr.io vulnerability database Β· before the scan
threats urlhaus-api.abuse.ch learns each host's namebefore the scan data is fetched once per run and can be put on the machine in advance; every scan data is fetched by the tool on each invocation. Running air-gapped
covers both.
Data
Doctor lists the data each scanner reads as it stands on this machine, as a scan's
--evidence does. Given a descriptor, it lists the scanners
that scan runs; without one, every scanner:
$ draugr doctor draugr.saga.yaml
DATA
retire advisory database downloaded 2026-10-17
trivy vulnerability database published 2026-10-16, downloaded 2026-10-17
trivy checks bundle downloaded 2026-10-17Doctor reads what the tools left on disk and downloads nothing. --json carries the entries under
data, in report.json's scannerData form. A tool that reports nothing about its data has no
line, and neither does one that is not installed, which TOOLS already reports as missing.
What nothing is looking at
Doctor lists each surface a component declares with every control for it off, from the same
check that gives draugr scan its not-checked caveats:
NOT CHECKED
Component Surface Controls off
api hosts dast, headers, tls
api images images
The list is reported, not failed, unless --fail-on-uncovered is passed or
config.gate.failOnCaveats lists not-checked. A missing tool is reported first. After it, an
UNTRACKED block names any component no issue publisher selects.
| Flag | Default | Description |
|---|---|---|
--json | false | Emit the report as JSON instead of tables. A tool not at the version Draugr tests carries testedVersion; a tool not at its configured pin carries pinnedVersion and pinnedIn, the file that pins it, in its place. govulncheck carries buildGo, the Go it was built with, and testedGo where that Go is older than the one Draugr builds it with |
--fail-on-uncovered | false | Exit non-zero when the descriptor declares a surface no enabled control looks at. Also the behavior when config.gate.failOnCaveats lists not-checked |
--strict | false | Exit non-zero when a tool is not at its configured pin, or not the version Draugr tests where it has none, or is a govulncheck built with an older Go than Draugr builds it with, with or without a descriptor. With one, a missing tool's message comes first |
--policy | false | Ask the draugr-api server for the organization's policy, sending the token. Exit non-zero when it could not be checked, as well as when a scan would be refused or fail by it |
--offline | false | Make no network call: skips the release check, the policy check, remote fragments and every target check that needs the network. DRAUGR_NO_UPDATE_CHECK=1 skips only the release check |
--components | - | Check only the targets of these components |
--controls | - | Check only the targets these controls would read |
--labels, --exposure, --criticality | - | Select components as draugr scan does |
The JSON's top-level fields are draugr, descriptor, tools, missing, targets, policy,
data and uncoveredSurfaces. missing counts each required tool that is absent or has no data,
the count the exit code is decided on. An optional tool that is absent is listed and not counted.
A tool with no data carries the hint for getting it.
draugr doctor # inventory: what Draugr can use, and what you have
draugr doctor draugr.saga.yaml # check only what this descriptor needs
draugr doctor --json draugr.saga.yaml
draugr doctor --fail-on-uncovered draugr.saga.yaml # and fail if something is unexamined
draugr doctor --components web draugr.saga.yaml # only web's targets
draugr doctor --offline # no network: skip the update check and remote targets
Doctor also reports the running Draugr version and, best-effort (unless --offline /
DRAUGR_NO_UPDATE_CHECK), whether a newer release is available, nudging draugr self-update. The check has a short timeout and never blocks or fails the
command.
Doctor never installs anything; draugr tools install provisions
missing tools. The only repository data it fetches is what its target checks read and fragments
from other repositories.
draugr login [server]
Sign in to a Draugr server by OAuth 2.0 device authorization and keep the token it
issues in the OS keychain. draugr doctor --policy and the scan's
policy check send it where $DRAUGR_API_TOKEN is not set, so
checking a descriptor on your own machine needs no token in your shell.
$ draugr login https://draugr.example
LOGIN https://draugr.example
code ABCD-EFGH
open https://console.example/device
expires 15m
approved Β· ana@acme.com Β· acme Β· until 06:12 UTC (12h)Open the address under open in a browser, on this machine or another, and enter the code. The server may ask you to
sign in first, under the organization's single sign-on and second-factor rules, and the command
waits until the request is approved, denied, or the code expires. A request that is not approved
ends with one line on stderr and a non-zero exit:
draugr: denied Β· ABCD-EFGH Β· nothing stored
draugr: expired Β· ABCD-EFGH Β· not approved within 15m Β· run `draugr login https://draugr.example` againThe server defaults to $DRAUGR_API_URL. It must be https, or http to this machine.
The token checks policy and nothing else. It carries only the check scope, lasts until the
server's expiry with no refresh, and the draugr-api publisher
never sends it, so a person's login cannot publish a run. When it expires, run draugr login
again.
Where it is kept. Draugr keeps one login per server origin in the macOS Keychain, the Windows
Credential Manager, or a Secret Service such as GNOME Keyring or KWallet on Linux, and never in a
file. On a machine with none of them, draugr login fails and says so. A CI job never reads the
keychain; it uses $DRAUGR_API_TOKEN.
Where it is sent. The token goes only to the origin that issued it. The policy check uses it
for a draugr-api publisher whose server is $DRAUGR_API_URL, publish.apiUrl, or a url: with
the login's origin; a url: naming any other server is not checked. A $DRAUGR_API_TOKEN in the
environment is always used in its place.
draugr logout [server]
Tell the server to revoke the token draugr login stored, and remove it from the
keychain.
$ draugr logout https://draugr.example
logged out Β· https://draugr.example Β· ana@acme.com Β· acme Β· token revokedThe server defaults to $DRAUGR_API_URL. Where the server cannot be reached, or under
--offline, logout still removes the login and exits non-zero, because the token stays valid on
the server until it expires. With no login for the server, it prints not logged in and exits
zero.
draugr tools
Provision and inspect the external scanners Draugr runs. Installs are opt-in and verified, and a scan never installs a tool.
draugr tools outdated
Asks each tool's upstream what it publishes now and reports it beside the version this Draugr
installs. Nothing is downloaded and nothing on disk changes. Refused under --offline.
$ draugr tools outdated
Tool Pinned Upstream
cosign 3.1.3 3.1.3 current
gitleaks 8.30.1 8.30.1 current
β¦
trivy 0.74.0 0.76.0 draugr tools install trivy --version 0.76.0
6 of 12 behind the version their upstream publishes.A tool whose upstream could not be asked shows ? and could not ask: with the reason, and the
last line adds how many could not be asked. The command beside a tool that is behind installs the
upstream's version. Without --version, draugr tools install <tool> installs the pinned one.
govulncheck is also compared on the Go it is built with, against the newest stable release
https://go.dev/dl/ lists. Its Pinned and Upstream cells each name a Go, as in
1.9.0 Β· go1.27.3. It is behind when its version is newer upstream, or a newer Go minor release is
out. Its command then sets GOTOOLCHAIN to that Go, with --force where its own version has not
moved.
| Flag | Default | Description |
|---|---|---|
--json | false | Write the comparison as JSON, for a pipeline proposing a bump |
Being behind does not fail the command. It exits non-zero only when a tool could not be asked, in both output modes.
In the JSON, each tool has tool, pinned, latest and behind, and govulncheck also has
pinnedGo and latestGo. A tool that could not be asked carries error and has behind: false,
the same value a current tool has, so check error or the exit code rather than behind alone.
draugr tools install [tool...]
Download pinned tool builds, verify each against a digest recorded in Draugr (a SHA-256
taken from the upstream checksums files for a release binary), and install them into
~/.draugr/bin, which Draugr adds to PATH automatically, so scan and doctor use them with
no shell config. Refused under --offline, naming the tools it would fetch.
What gets installed. --saga installs the tools that descriptor's scan will run and nothing
else. With no arguments it reads the current directory the way draugr scan does: it installs what
the descriptor there runs, or, with no descriptor, what a zero-config scan runs, which is what
draugr init would enable for the tree, and says which on its first line. With more than one
descriptor it asks which on a terminal and otherwise exits naming them. --all installs everything
this host can have. --all, --saga and a tool list are refused in combination.
Some are built from a package, not downloaded as a binary. govulncheck needs Go 1.21 or newer,
retire needs Node 18 or newer, and semgrep needs Python 3.10 or newer, because none publishes a
release binary. With --all, a tool whose Go or Python is missing or too old, or whose npm is
missing, is skipped and named with the command to run once it is there, and the rest install:
β govulncheck: govulncheck is distributed as a Go package and no `go` is on PATH, install Go 1.21
or newer from https://go.dev/dl/, or install it yourself with `go install golang.org/x/vuln/cmd/govulncheck@v1.8.0`
3 tools skipped, this host has no runtime to build them with. Install one and run
`draugr tools install govulncheck retire semgrep`.
Each skipped tool gets its own β line. A Node older than 18 is a failure rather than a skip.
Naming a tool whose runtime is missing, as in draugr tools install govulncheck on a host without
Go, fails and exits non-zero. draugr tools list names the runtime each one needs.
govulncheck is built with a pinned Go. The install uses the go on PATH where it is at least
the pinned release, and otherwise sets GOTOOLCHAIN to the pin, which go downloads and verifies
against the Go checksum database. A GOTOOLCHAIN in the environment that names a release, such as
GOTOOLCHAIN=go1.28.0, is used instead, and one set to auto or local is replaced. A build from
a Go older than the pin is not current, so the next install replaces it.
govulncheck explains why, and has the scan-time check.
| Flag | Default | Description |
|---|---|---|
-y, --yes | false | Skip the confirmation prompt |
--dry-run | false | Print the install plan and exit |
--force | false | Reinstall even when the pinned build is already present, which also repairs a modified binary |
--version | - | Install this version instead of the one Draugr ships. Takes exactly one named tool |
--saga | - | Install only the tools that descriptor's scan will run |
--all | false | Install every tool Draugr can provision |
draugr tools install --saga draugr.saga.yaml # only what this project's scan runs
draugr tools install trivy # just one
draugr tools install --all # plan β confirm β the whole catalog, into ~/.draugr/bin
draugr tools install --dry-run # preview the plan, change nothing
draugr tools install -y # non-interactive
draugr tools install trivy --version 0.68.0 # a version other than the one Draugr ships
Pinning a version. A team wanting every pipeline to scan with the same Trivy writes it once, where it gets reviewed:
# draugr.config.yaml
tools:
trivy: { version: "0.69.3" }
gitleaks: { version: "8.30.1" }
--version overrides that for a single invocation.
Draugr installs a version it cannot vouch for, such as a fork, a release candidate or a build newer than this release, and records its verification level, which appears in every report the tool produces. It refuses a download that does not match a checksum the upstream published.
The plan's Verify column says which you are getting before anything is downloaded:
| Verify | Meaning |
|---|---|
sha256 | the pinned build, against a SHA-256 recorded in Draugr |
sha256 + cosign | the same, and the upstream's cosign signature over its checksums file, when cosign is installed |
upstream cosign | another version, against checksums the upstream signed, when cosign is installed |
upstream sha256 | another version, against checksums the upstream published |
unverified | another version, with nothing published to check it against; or semgrep or retire at a version other than the pinned one. Omit --version to install the pinned build, which is checked |
sha256 (+deps), sha512 (+deps) | semgrep and retire at the pinned version, with every dependency checked against digests recorded in Draugr |
checksum db (+deps) | govulncheck at any version, with every module checked against the Go checksum database |
- | nothing to install: the tool is already current, or Draugr cannot provision it |
Each installed tool's line repeats the check, as in sha256 + cosign verified. A weaker check adds
a note after ; saying why, for example sha256 verified; cosign not installed, skipped signature check. A line reading unverified means nothing was checked.
--saga installs what a descriptor needs, resolved the same way
draugr doctor decides what to check: the enabled controls, and the
scanners those controls will select. Where a descriptor needs something Draugr cannot provision
(kubectl, git, prowler) the plan names it before the table.
Tools already current are not downloaded again. A tool is current when Draugr's install record
in ~/.draugr/bin holds the version the install would fetch, the pinned one or the one --version
or the config names, and the file on PATH in ~/.draugr/bin still matches the checksum recorded
with it. For semgrep and retire that file is the launcher, and the environment it runs must
still exist; for a tool with data, such as kube-bench, the data directory must too. The plan marks
a current tool already at <version>, and when every planned tool is current the command prints
Everything is already current. and stops without asking. Otherwise each tool's result is printed,
and the current ones are left alone and counted:
β syft 1.51.1 β /home/you/.draugr/bin/syft (sha256 verified)
7 tools unchanged.
A copy elsewhere on PATH is not current by this test, whatever version it reports, because no
record says what it is, so the install puts Draugr's own in ~/.draugr/bin. A file that no longer
matches its record is replaced, and a changed pin also reinstalls. --force reinstalls
unconditionally.
Plan and confirmation. It first prints the plan (tool, version, category, verification,
destination). On a terminal it asks for confirmation; without one (CI, pipes) it proceeds. -y
skips the question and --dry-run only previews.
cosign is a utility Draugr uses to verify the signatures of other tools and of its own releases
in self-update. Without it, signature verification is skipped and the install says so.
draugr tools install cosign installs it, pinned by SHA-256. doctor reports it without failing
when it is absent, unless the descriptor's provenance control needs it.
Provenance. The SHA-256 pin is the integrity floor for every release binary. Trivy also
publishes a keyless cosign signature over its checksums file; with the cosign CLI installed,
Draugr verifies that signature, checking the signing certificate identity and OIDC issuer, and
confirms the archive is listed in the signed checksums. The line reads sha256 + cosign verified,
or sha256 verified; cosign not installed, skipped signature check without cosign. If cosign is
present and verification fails, that tool's install fails.
Semgrep publishes no release binary, so it is installed from PyPI into a virtual environment Draugr
owns (~/.draugr/venv/semgrep), with every artifact in the resolved tree matched against a digest
recorded in this build, dependencies included. Those digests describe the pinned version only, so
--version installs another version by name, and its line reads unverified; no digests recorded in this build for this version. retire works the same way with its pinned lockfile. When semgrep
is missing, doctor suggests draugr tools install semgrep.
Naming a tool Draugr runs but does not provision, such as kubectl or git, is refused, and
draugr doctor names where it comes from.
draugr tools uninstall [tool...]
Remove tools draugr tools install put on this machine: the binary in ~/.draugr/bin, the Python
or Node environment beside it, and the data Draugr keeps for the tool under ~/.draugr/data. A
copy installed any other way is left where it is, and the line for it says where it was found.
Each tool's own cache stays unless --caches is given: Trivy's and Grype's vulnerability
databases, Nuclei's templates, and the smaller ones Syft, Notation and cosign keep. A tool's settings
are not a cache and stay either way, such as ~/.config/nuclei and Semgrep's login in ~/.semgrep.
The caches sit at the tool's default location, or where TRIVY_CACHE_DIR or GRYPE_DB_CACHE_DIR
moves it, so removing them also empties the cache of any other install of the same tool.
~/.draugr/feeds belongs to no tool, and draugr feeds manages it.
Name the tools to remove, or pass --all; with neither, the command is an error.
| Flag | Default | Description |
|---|---|---|
--all | false | Remove every tool Draugr installed |
--caches | false | Also remove each tool's own cache |
-y, --yes | false | Skip the confirmation prompt |
--dry-run | false | Print the plan and exit |
draugr tools uninstall trivy # one tool
draugr tools uninstall --all --dry-run # what removing everything would free
draugr tools uninstall --all --caches # every tool and its cache
$ draugr tools uninstall retire semgrep trivy --dry-run
PLAN
Tool Version Size Also removes
retire 5.7.0 10M node env, data 431K
semgrep 1.177.0 347M python env
trivy 0.74.0 161M
3 tools to remove from /home/you/.draugr, 518M.
The tools' own caches stay (trivy 2.9G). `--caches` removes them too.
(dry run, nothing removed)It asks for confirmation when run interactively, proceeds without asking otherwise, and --yes
skips the prompt. It exits non-zero if any named tool was not removed; run it again to retry that
tool. A removed cache refills on the tool's next run.
draugr tools list
Show every tool Draugr knows about: its category (scanner or utility), the controls it
backs, its pinned version (- for a tool with none), the command that installs it
(draugr tools install, with the runtime it needs, or - for a tool Draugr does not install), and
its status:
β <version> (<path>): found at the pinned version, or found for a tool with no pin.~ <version> (<path>): found at a version other than the pin.? version unknown (<path>): found, and Draugr cannot read its version.β not found: no copy onPATH.
A copy of a tool Draugr installs, found outside ~/.draugr/bin, is marked not installed by Draugr
after its path, and draugr tools uninstall leaves it alone.
draugr tools list
draugr feeds
Fetch and inspect the datasets a scan reads from ~/.draugr/feeds. A scan reads the cache and
fetches a feed only when --kev or --epss (or config.exploitability) says auto.
| Feed | Dataset | Read by |
|---|---|---|
kev | CISA's Known Exploited Vulnerabilities catalog | exploitability |
epss | FIRST's EPSS scores | exploitability |
govulndb | the Go vulnerability database | govulncheck |
draugr feeds update [kev|epss|govulndb]
Download the feeds into ~/.draugr/feeds, every feed when none is named. A copy younger than the
limit is left alone unless --force is given. Refused under --offline.
Each feed gets a line saying it is current (<age> old), or fetching <url>β¦ followed by the path
and size written. A fetch that fails with a copy already cached keeps that copy, says so and exits
0; with nothing cached, the command stops at that feed and exits non-zero.
| Flag | Default | Description |
|---|---|---|
--force | false | Fetch even if the cached copy is current |
--saga | none | Descriptor, or directory holding one, to read the limit from. One that does not load is an error |
draugr feeds update # every feed
draugr feeds update epss # just the daily one
draugr feeds update --force # refetch regardless of age
EPSS is published gzipped and is decompressed on the way in, so the cache holds a CSV the scanner
can read directly. The Go vulnerability database is published as a zip and unpacked into
~/.draugr/feeds/govulndb, the directory govulncheck's -db reads; an archive whose index is
empty or unreadable is refused and the previous copy kept. Each write is atomic.
In CI, run this as its own step, so a feed outage shows in that step rather than inside the scan.
draugr feeds status
What is cached, how old it is, the digest of each copy, and where each feed comes from.
FEED FETCHED AGE SIZE DIGEST
kev 2026-09-23 22:05Z 6 hours 1.7 MiB sha256:e4988831e6d3
epss 2026-09-21 04:05Z 3 days (stale) 11.0 MiB sha256:38015ed64ff2
govulndb 2026-09-24 02:05Z 2 hours 3.2 MiB sha256:f0645ee8b56c
SOURCES (cached in /home/you/.draugr/feeds)
kev CISA Known Exploited Vulnerabilities catalog Β· https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json
epss FIRST EPSS scores Β· https://epss.empiricalsecurity.com/epss_scores-current.csv.gz
govulndb Go vulnerability database Β· https://vuln.go.dev/vulndb.zip
epss older than 24 hours. A scan uses it and marks the report stale. Refresh with `draugr feeds update`.
Stale after 24h Β· default Β· --saga applies a descriptor's config.exploitability.maxAge
A copy is (stale) here when it is older than the limit. A stale KEV or EPSS copy
does not fail a scan: it ranks findings on older data, and the report names the age. A stale
govulndb copy is not read at all; govulncheck queries vuln.go.dev instead, and with --offline
the control reports an error. --saga works as it does for feeds update.
Staleness
The limit is 24 hours, or config.exploitability.maxAge in
the descriptor --saga names, the same limit a scan of that descriptor applies with auto.
Without --saga no descriptor is read, so the commands answer the same in every directory. Both
commands end with the limit and where it came from:
Stale after 24h Β· default Β· --saga applies a descriptor's config.exploitability.maxAge
Stale after 168h Β· config.exploitability.maxAge in draugr.saga.yaml
Never stale Β· config.exploitability.maxAge in draugr.saga.yaml
The last is maxAge: 0s.
Using the cache in a scan
Set it once in the descriptor under
config.exploitability, or pass it per run. --kev and
--epss accept a path or a keyword:
| Value | Behavior |
|---|---|
| a path | read that file; never touches the cache or the network, the air-gapped route |
cache | read ~/.draugr/feeds; never fetches. Errors if nothing is cached |
auto | read the cache, fetching when it is missing or older than config.exploitability.maxAge (default a day) |
With auto, a failed fetch falls back to a cached copy and says so. With nothing cached, it is an
error.
DRAUGR_OFFLINE=1 or --offline stops auto fetching: it reads the cache, or reports that there
is nothing to read.
A flag overrides the descriptor only when you type it. --epss-threshold 0.5 beats a
descriptor saying 0.1, although 0.5 is also the flag's default; leaving it off keeps the
descriptor's value.
See prioritization for what the signals mean and how to choose a threshold.
draugr config
Settings for a machine or an organization, read from draugr.config.yaml: scanner versions,
control defaults, cache, output, parallelism, logging, offline and the publish URL. A descriptor
can override control defaults and the publish URL, and no other key.
# draugr.config.yaml
cache: # where results are reused between runs, and for how long
dir: /var/cache/draugr
ttl: 24h
tools: # which build `draugr tools install` fetches
trivy: { version: "0.69.3" }
controls: # merged *underneath* the descriptor's own settings
sast:
semgrep:
config: p/owasp-top-ten
| Key | Accepts | Default | What it does |
|---|---|---|---|
cache.dir | a directory | none, caching off | The directory results are cached in. Unset leaves caching off. Overridden by --cache-dir. |
cache.readOnly | true or false | false | Serve cached results without writing new ones. A project file's false does not override a true set in ~/.draugr/config.yaml. Overridden by --cache-read-only. |
cache.requireDigest | true or false | false | Refuse to cache an image named only by a tag, which can be rebuilt under the same name. A project file's false does not override a true set in ~/.draugr/config.yaml. Overridden by --cache-require-digest. |
cache.ttl | a duration such as 12h; 0s, no expiry | 24h | How long a cached result stays usable, as a duration such as 24h. 0s means no expiry; unset keeps the built-in default. Overridden by --cache-ttl. |
log.format | console, json or text | console | How log records are written. Overridden by --log-format. |
log.level | trace, debug, info, warn or error | info | The least severe level logged. Overridden by --log-level. |
offline | true or false | false | Make no network calls: optional fetches are skipped, and commands that exist to download refuse. A more specific file's false does not turn it off. Overridden by --offline and $DRAUGR_OFFLINE. |
output.evidence | true or false | false | Also print what stands behind the verdict: tool provenance, what each control measured against, the scanned revision, and what the run cost. Overridden by --evidence. |
output.offerInstall | true or false | true | Whether draugr scan on a terminal offers to install the scanners it is missing. Unset means it offers. |
output.tips | true or false | true | Whether a console scan prints its contextual tips and its progress line. Unset means it prints them. Overridden by --no-tips and $DRAUGR_NO_TIPS. |
output.top | a whole number of 0 or more | 10 for scan, every row for diff | How many rows a console report lists. 0 keeps the command's own default, a cap for scan and every row for diff. Overridden by --top. |
output.view | findings, actions or compact | findings | What a console report shows. Overridden by --view. |
publish.apiUrl | a URL | none | Where the draugr-api publisher posts when $DRAUGR_API_URL is unset. In a project's draugr.config.yaml it is used only on the origin of publish.apiUrl in ~/.draugr/config.yaml, or one $DRAUGR_API_ORIGINS lists. Never holds a token. |
scan.jobs | a whole number of 0 or more; 0, one per CPU | 0 | How many scan jobs run at once. 0 means one per CPU. Overridden by --jobs. |
tools.<tool>.version | a release, such as 0.69.3 | Draugr's pin | The version to install, e.g. 0.69.3. |
controls.<control> holds one control's defaults, in the shape a descriptor's config.controls
takes, and is merged underneath it. The defaults apply to a control the descriptor turns on, at the
top level or on a component, and enabled: true in the block turns the control on for a descriptor
that leaves it out. draugr config keys controls.<control> lists one control's keys.
A flag typed on the command line always wins over the file. See caching & performance for the cache settings.
controllers: is the older spelling of controls:. It still loads and is folded into controls
when the file is read; a file holding both is merged key by key, with
controls winning where both set a key. get and unset accept a key under either name, and
show and every error report it under controls.
Where it comes from
--config <path> or DRAUGR_CONFIG | that file alone |
./draugr.config.yaml | this project |
~/.draugr/config.yaml | this machine |
Discovered files are layered, project over home. An explicit one replaces both. The project file is
read from the working directory only; run anywhere else, show names the directory it searched.
A more specific file's value replaces a less specific file's, false and 0s included, except
for the settings that only turn on across files.
show lists a false one of those outranks under Overridden, and set reports it when it
writes one.
For draugr scan, doctor, tools install and the scan and check_tools tools of
draugr mcp, the full order is component β descriptor β config β built-in,
deep-merged, so an override replaces only the keys it names.
log.level and log.format take the lowercase values the flags list. Any other value makes
config validate and every command that reads the configuration refuse the file and name the key. A command that declines to fetch under
offline names the file that set it.
$ draugr --config machine.yaml self-update --check
draugr: self-update needs the network; ./machine.yaml sets offline: true
it would have fetched https://github.com/draugr-dev/draugr/releases/latestCommands
draugr config show | what is in effect, and which file each value came from |
draugr config get <key> | one resolved value |
draugr config set <key> <value> | write a value |
draugr config unset <key> | remove one, pruning anything it empties |
draugr config init | write a commented starter with the editor modeline; --force replaces an existing file |
draugr config validate [path...] | hold the discovered files, or the files named, to every check a scan makes, reporting each file |
draugr config keys [prefix] | every key, what it accepts and its default; a prefix, or --all, lists each control's and each tool's keys |
draugr config edit | open the file in an editor, and write the result if it adds no problem |
set, unset, init and edit write the file a scan reads: the one --config or DRAUGR_CONFIG names,
or else ./draugr.config.yaml, or ~/.draugr/config.yaml with --global. --global together
with --config or DRAUGR_CONFIG is refused, because the global file is not read while either is
set.
set reads a value by its first character. A value starting with [ or { is a YAML flow list or
mapping and has to parse as one; any other value is one scalar, written as typed, so a URL or a
value holding : needs no quoting. true, false and whole numbers are written as booleans and
numbers. Quote a flow value for the shell, because [library] is also a glob pattern:
draugr config set controls.sca.trivyFs.pkgTypes '[library, os]'
A string that itself starts with [ or { cannot be written by set; edit writes it.
keys lists the keys this build accepts, and show the values in effect. Each control is one line
naming its scanners' keys, with opt-in scanners marked *. A prefix lists the keys under it, so
controls.sca lists that control's keys, and --all lists every key on a line of its own.
$ draugr config keys controls.sca
Key Accepts Default
controls.sca.enabled true or false off unless the descriptor turns it on
controls.sca.devDependencies suppress or report suppress
controls.sca.providedDependencies suppress or report suppress
controls.sca.trivyFs.enabled true or false true
controls.sca.trivyFs.dbRepository a list -
controls.sca.trivyFs.detectionPriority precise or comprehensive precise
controls.sca.trivyFs.filePatterns a list -
controls.sca.trivyFs.includeDevDeps true or false false
controls.sca.trivyFs.pkgTypes a list of os, library os, library
controls.sca.trivyFs.resolveMaven true or false false
controls.sca.grypeFs.enabled true or false false
controls.sca.grypeFs.byCve true or false true
controls.sca.mendSca.enabled true or false false
controls.sca.mendSca.productToken a string, required -
controls.sca.mendSca.project a string -
controls.sca.mendSca.resultTimeout a duration such as 90s, 15m or 1h30m 10m
controls.sca.mendSca.settings a mapping -
controls.sca.retirejs.enabled true or false false
- no default of its own. `draugr controls sca --options` says what each option does.edit opens a copy of the file in $VISUAL, else $EDITOR, else vi. The copy is named
draugr.config.yaml, so an editor that chooses a schema by file name applies the config schema. A
save is held to the check set makes. One that adds a problem is not written, and the editor
reopens with each problem as a comment line at the top, which the next save removes. Saving the file
unchanged, or empty, cancels and writes nothing. A file that does not exist opens as the starter
init writes.
$ draugr config edit
./draugr.config.yaml does not exist; editing the starter `draugr config init` writes
wrote ./draugr.config.yamlWhat the editor reopens with after a save that added two problems:
# that edit would add a problem to the file, so nothing was written:
# output: "veiw" is not a setting Draugr has, did you mean "view"?
# cache.ttl: expected a duration such as 12h, got 5
# Save again once fixed. Saving it unchanged, or empty, cancels. These lines are removed.
output:
veiw: actions
cache:
ttl: 5
Without a terminal, edit refuses:
$ draugr config edit < /dev/null
draugr: config edit needs a terminal; `draugr config set <key> <value>` writes one keyunset of a key the file does not set is refused and writes nothing, naming the file. A key
Draugr does not have is refused as get refuses it, naming the nearest key it does have:
$ draugr config get output.veiw
draugr: "output.veiw" is not a setting Draugr has, did you mean "output.view"? `draugr config keys` lists every key$ draugr config show
Files, least specific first:
~/.draugr/config.yaml
./draugr.config.yaml
In effect:
Setting Value From
controls.sast.semgrep.config p/owasp-top-ten ./draugr.config.yaml
tools.trivy.version 0.69.3 ~/.draugr/config.yaml
show names the file each value came from: relative to the working directory when it is under it,
under ~ when it is in the home directory, and in full otherwise. A duration is printed as written
in the file, such as 12h. With --config or DRAUGR_CONFIG set, the discovered files that exist
and are not read are listed under Skipped while β¦ is set:.
$ DRAUGR_CONFIG=ci/draugr.yaml draugr config show
Files, least specific first:
./ci/draugr.yaml
Skipped while DRAUGR_CONFIG is set:
./draugr.config.yaml
In effect:
Setting Value From
cache.ttl 12h ./ci/draugr.yaml
If the file breaks
A command that reads the config refuses to run rather than falling back to the defaults. That
includes scan, and covers what draugr validate refuses in a descriptor: a control or scanner this
build does not provide, a scanner option its schema rejects, an enabled that is not true or
false, a tool tools install does not provision, a value a scan would refuse, such as an
output.view that is not a view or a negative output.top, and a key written with no value.
$ draugr config validate draugr.config.yaml
β ./draugr.config.yaml
output: "veiw" is not a setting Draugr has, did you mean "view"?
controls: "sats" is not a control this build of Draugr provides, did you mean "sast"?
controls.sca: "trivy-fs" is not a scanner of the "sca" control (it has grypeFs, mendSca, retirejs or trivyFs), did you mean "trivyFs"?
run `draugr controls` to see what this build provides
`draugr config keys` lists every key
draugr: 1 of 1 file did not validatedraugr config validate # every problem in every file, by key
draugr config init --force # discard the file and start again from the built-in defaults
set and unset cannot break a file. They edit the parsed document, so comments survive, and
refuse an edit whose result has a problem the file did not have, naming that problem. An edit to a
file that already has problems is written when it adds none, and set and unset then say how
many are left, so a file with several is mended one key at a time. The document is re-indented as it
is written. Draugr does not repair a file it cannot parse.
Secrets
Keep credentials out of the file. It is read as written, with no ${{ }} substitution, and is
meant to be committed. Use the variables under Environment instead.
draugr controls
List the security controls Draugr can run, the scanners behind each, and what each checks.
Default scanners are listed first; * marks an opt-in scanner and β a reachability analyzer, each
explained under the table. PUBLISHERS names who publishes each scanner, and EFFECTS lists the
scanners that do more than read. tools list maps the other way, from each
tool to its controls.
draugr controls [control] narrows everything to one control. A name that is not a control says so
and lists the ones that are.
draugr controls
draugr controls --options # also: what each scanner accepts in its block
draugr controls sast --options # just one control
| Flag | Default | Description |
|---|---|---|
--options | false | List the options each scanner accepts in a descriptor, read from the schemas the gate enforces |
A scanner shown with no options is configured by choosing it, and any other key under its block is an error.
Enable a control in your descriptor under config.controls.<name> (or per component). A control's
scanners are configured under their own keys, config.controls.<name>.<scanner>, each with an
optional enabled flag plus that scanner's options (e.g. sast: { gosec: { enabled: true } }).
See per-scanner config.
draugr mcp
Serve Draugr to AI coding assistants over the Model Context Protocol, on stdin/stdout.
draugr mcp # scan asks first only when it does more than read
draugr mcp --scan=ask # scan asks before every call
draugr mcp --scan=always # scan never asks
draugr mcp --scan=off # read-only tools; scan is not offered
Each *.saga.yaml in the working directory and up to three levels below it is also exposed as an
MCP resource, so a client can read the descriptor without a tool call. Discovery runs once at
startup and takes at most 50 files. It skips node_modules, vendor, dist, build, target
and __pycache__, and every hidden directory except .draugr.
| Tool | Purpose |
|---|---|
list_controls | Which controls exist, what each checks, which scanner backs it, and the options each scanner accepts |
get_saga_schema | The Saga schema this build enforces |
validate_saga | Validate a descriptor, by path or by content |
propose_saga | Return the descriptor draugr init would write for path, a directory under the working directory, with perDirectory for --per-directory; writes nothing and names any descriptor already there |
check_tools | Report which scanners are installed and what to run if any are missing; given a descriptor, the tools draugr doctor requires for it |
feeds_status | Report whether each feed is cached, with its fetch date, age, size, digest and staleness, and the draugr feeds update command to run if any is missing or stale |
explain_rule | What a finding means and how to fix it, from the scan's own report |
fix_list | A report as the things to do about it, one list per kind of work, each most urgent first, each action saying how many findings it clears and what it ranks by |
list_surveyors | The surveyors this build has and what each discovers |
survey | Discover what an application is made of and return a descriptor for it; writes nothing |
diff_reports | Compare two scans, with failOnNew to learn whether a pull-request gate would fail |
summarize_report | Rank an existing results.sarif by priority, with accepted findings and their decisions listed apart |
scan | Run a scan and return the verdict, the scope it covered, and where the descriptor's publishers delivered it (not offered with --scan=off) |
| Flag | Default | Description |
|---|---|---|
--scan | effects | Whether the assistant may start scans, and when you approve one first |
--scan | The scan tool |
|---|---|
effects | Asks before a scan in which a planned scanner declares an effect, config.dependencyHealth is enabled, or a publisher other than file delivers the report |
ask | Asks before every scan |
always | Never asks, for sandboxes and CI |
off | Is not offered |
Asking needs a client that supports MCP elicitation. A client that cannot prompt is refused and
given the draugr scan command instead.
The server speaks MCP on stdin and stdout, so run by hand in a terminal it waits for a client and
prints nothing. scan and check_tools read draugr.config.yaml as
draugr scan does, from the directory the server was started in or from the file --config or
DRAUGR_CONFIG names, and fail on a file draugr scan refuses. See
use Draugr from an AI coding assistant for client setup.
draugr self-update
Update the running draugr binary in place to the latest published release (or a specific
--version), verified against the release's SHA-256 checksums (mandatory) and its keyless
cosign signature (when the cosign CLI is present). It replaces the binary that is running,
wherever it is on PATH. On a terminal it asks for confirmation first; without one it proceeds.
Refused under --offline, --check included.
| Flag | Default | Description |
|---|---|---|
--version | latest | Target release to install (e.g. 0.16.0 or v0.16.0) |
--check | false | Report current vs latest available; make no changes |
-y, --yes | false | Skip the confirmation prompt |
draugr self-update # update to the latest release, asking first on a terminal
draugr self-update --check # just report current vs latest
draugr self-update --version 0.15.0 -y
In CI, install a pinned release; do not run self-update.
draugr version
Print the version, commit, build date, and Go version.
| Flag | Default | Description |
|---|---|---|
--json | false | Emit the same fields as JSON (version, commit, built, go), for scripts and CI |
draugr version # draugr <version> (commit <sha>, built <date>, go<version>)
draugr --version # the same bytes; -v is the short form
draugr version --json # an indented JSON object
Output goes to stdout in both forms, so v=$(draugr version --json | jq -r .version) works.
draugr schema
Print the Saga JSON Schema this build enforces (it's embedded in the binary).
| Flag | Default | Description |
|---|---|---|
-o, --output | stdout | Write to this file instead, and print the modeline that references it |
--fragment | false | Print the Saga fragment schema instead |
--config-file | false | Print the draugr.config.yaml schema instead |
draugr schema # print it
draugr schema -o .saga.schema.json # pin editor validation to this exact build
draugr schema --fragment # the schema for *.saga-fragment.yaml
draugr schema --config-file # the schema for draugr.config.yaml
A fragment has its own schema (no release:, no policy); pick it with --fragment. The config
file's schema checks what draugr config validate checks. --fragment and
--config-file cannot be combined.
Editors normally fetch the schema from draugr.dev, which needs network access and follows a published version. A local copy pins validation to the installed Draugr and works offline. See editor support.
draugr completion <shell>
Generate a shell completion script for bash, zsh, fish or powershell.