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

Reference

CLI reference

On this page

All commands accept these global flags:

FlagDefaultDescription
--log-levelinfotrace, debug, info, warn, error (or log.level in draugr.config.yaml)
--log-formatconsoleconsole (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
--offlinefalseMake 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=903ms

When 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_ENDPOINTboth signals, e.g. http://localhost:4318
OTEL_EXPORTER_OTLP_TRACES_ENDPOINTtraces alone, where they go somewhere else
OTEL_EXPORTER_OTLP_METRICS_ENDPOINTmetrics 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_CONFIGthe machine or organization settings file to read, instead of the discovered ones
DRAUGR_OFFLINEmake no network calls. Equivalent to --offline
DRAUGR_NO_UPDATE_CHECKskip the check for a newer release, without disabling the rest of the network
DRAUGR_NO_TIPSsuppress the console's contextual tips
NO_COLORrender 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_TOKENdraugr survey github repos; the github, github-pr-comment and github-issue publishers; draugr tools outdated, against GitHub's rate limit
GITLAB_TOKENdraugr survey gitlab projects; the gitlab-mr-comment and gitlab-issue publishers
AZURE_DEVOPS_EXT_PAT, else AZURE_DEVOPS_TOKENdraugr survey azure repos
SYSTEM_ACCESSTOKENthe azure-pr-comment and azure-work-item publishers
URLHAUS_AUTH_KEYthe urlhaus scanner, the threats default. Free from https://auth.abuse.ch/
VIRUSTOTAL_API_KEYthe virustotal scanner, opt-in under threats
DRAUGR_API_TOKEN, DRAUGR_API_URLthe 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_ENVSvariables 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_ORIGINSorigins 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_ENVSvariables 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_URLGitHub Enterprise Server's API, including /api/v3, for survey github repos and the GitHub publishers
GITLAB_URLa self-managed GitLab, for survey gitlab projects
AZURE_DEVOPS_URLAzure 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 treeWritten
go.mod, including one that requires nothingsast.gosec Β· config.reachability.analyzers: [govulncheck]
Copied JavaScript: *.min.js, a file named for its release, anything under vendor/sca.retirejs
setup.py, pdm.locksca.grypeFs, since Trivy reads neither
A requirements file named other than requirements.txt: requirements-dev.txt, requirements/test.txtsca.trivyFs.filePatterns reaching it
Terraform, Helm, Kubernetes, a Dockerfilenamed in the iac comment
A Dockerfilea commented images control and image entry
An OpenAPI or Swagger documenta commented host with spec: path: pointing at it
A dependency file with no lockfile, or with no exact versiona 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.

FlagDefaultDescription
-o, --output[dir]/draugr.saga.yamlPath to write, relative to the working directory rather than [dir] (- for stdout). With --fragment, the default is [dir]/draugr.saga-fragment.yaml
-f, --forcefalseOverwrite an existing file
--per-directoryfalseWrite a component for each directory that holds its own dependency file
--fragmentfalseWrite 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:

TipShown when
--fail-on P2The gate is on severity, the run passed, and it carries P1 or P2 findings
builtBy: upstreamThe 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 controlsA 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]
AnswerDoes
Enter, y or yesinstalls them, at the version the config pins if it pins one, then scans
n, or anything elsescans 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
neverscans 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 9m0s

The 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 scanned

Components 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 treeTurned on
a go.modsast.gosec, and govulncheck as the reachability analyzer
copied JavaScript, such as jquery.min.jssca.retirejs
a requirements file Trivy opens only by pattern, such as requirements-dev.txtsca.trivyFs.filePatterns
a file only Grype reads, such as setup.py or pdm.locksca.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

FlagDefaultDescription
--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-treefalseScan local repositories as they are on disk, uncommitted work included. See below

What fails the build

FlagDefaultDescription
--fail-onP1What 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-gatefalseReport 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-errorsfalseTreat 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

FlagDefaultDescription
--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-threshold0.5EPSS probability (0–1) that triggers a severity bump. Overrides config.exploitability.epssThreshold

Output

FlagDefaultDescription
--formatconsoleWhat 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
--reportjson,sarifFormats 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
--viewfindingsWhat the report shows: findings, actions or compact
--group-Deprecated. --group action is --view actions, --group none is --view findings
--evidencefalseConsole: 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
--top10, or 3 under --view actionsConsole: 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
--compactfalseDeprecated. Use --view compact
--template-Inline Go text/template (with --format template)
--template-file-Go text/template file (with --format template)
--no-tipsfalseSuppress 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; in json and sarif output, 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

FlagDefaultDescription
--cache-dir-Enable content-hash caching in this directory
--cache-ttl24hCache entry lifetime (0 = no expiry)
--cache-read-onlyfalseRead the cache but never write it, for a run whose results the next run should not reuse
--cache-require-digestfalseDo 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

FlagDefaultDescription
-j, --jobs0 (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-publishfalseSkip the descriptor's configured publishers (still writes -o artifacts and stdout)
--policyfalseRequire the organization's policy check
--policy-wait5mTotal 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 bandFrom CVSS--fail-on critical--fail-on high--fail-on medium--fail-on low
critical9.0–10.0failsfailsfailsfails
high7.0–8.9passesfailsfailsfails
medium4.0–6.9passespassesfailsfails
low0.1–3.9passespassespassesfails

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:

LevelWhat it means
pinnedInstalled at the version Draugr ships, matching a checksum recorded in this build
signedInstalled at another version, matching checksums signed by the upstream's Sigstore identity
checksumInstalled, matching an unsigned checksums file fetched from the upstream
unverifiedInstalled, with nothing published to check it against
externalNot 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:

KeyHolds
tool, datathe tool and the data source, as the row names them
versionthe 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, downloadedeach an RFC 3339 time in UTC, absent where the tool records none
tooOldthe 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 186eb88ac44b

A 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
              reproducible

The 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:

OutputNarrowed by --min-priority?
Console, markdown, HTML, JUnit, whether printed or written with --reportYes, with a note saying how many were hidden
--format json / --format sarif on stdoutYes
-o <dir>/report.jsonIts findings list only. The priority counts still describe the whole run
-o <dir>/results.sarifNo, always complete
Configured publishers, including github code scanningNo. 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, or minPriority: P1 on a report in config.publishers, narrows the written SARIF and JSON.
  • draugr diff --format sarif emits 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-gate exits 0, and report.json still states each outcome.
  • Without a gate on new findings, --base changes no verdict. The run is judged on every finding and prints what a run without --base prints; report.json records 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 diff refuses 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 --base is reported rather than applied. report.json states 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 thatDoes
refuses, and is in forcestops the run before any scanner starts, and the command exits non-zero
fails the gate, and is in forceruns the scan, then fails the gate naming the rule, whatever the findings say
observes, or is not in force yetis 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 Β· observed

A 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 a url: with no token and no draugr login for 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 a url: 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_URL set and no draugr-api publisher;
  • run with --policy to ask the server, from draugr doctor without --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 checked and the status, where the server answered the request for the run's verdict with 401, 404 or a 5xx.

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.

FlagDefaultDescription
-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
…
FlagDefaultDescription
--formatconsoleOutput format: console, json, markdown, sarif. sarif emits the new findings only, for code scanning on a pull request
--viewfindingsConsole: 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
--top0Console: 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
--publishfalsePost 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:

CommandDiscovers
draugr survey k8s imagesunique container images running in a cluster, with their digests
draugr survey k8s clusterthe cluster itself, as a component's kubernetes: entry
draugr survey github reposrepositories in a GitHub organization
draugr survey gitlab projectsprojects in a GitLab group, subgroups included
draugr survey azure reposGit repositories in an Azure DevOps organization or project
draugr survey provenancewho signs the images the descriptor already declares

--k8s-images, --k8s-namespace and --github-org are not flags; use the subcommands above.

FlagApplies toDefaultDescription
--contextk8s images, k8s clusterthe current contextKubeconfig context to survey
--namespacek8s imagesevery namespaceDescribe only this namespace; repeat for several
--namespacek8s cluster-The component owns this namespace rather than the whole cluster; repeat for several
--no-exposurek8s imagesfalseLeave each component's exposure unset rather than proposing it
--orggithub repos-The GitHub organization to discover repositories in. Required
--groupgitlab projects-The GitLab group to discover projects in, subgroups included. Required
--orgazure repos-The Azure DevOps organization, or on Azure DevOps Server the collection, to discover repositories in. Required
--projectazure repos-Limit discovery to one project
--trust-rootprovenance-A Sigstore trusted-root file, for an organization running its own Sigstore

Shared by all of them:

FlagDefaultDescription
-o, --outputstdoutWrite the descriptor here, adding to the one already at that path
--replacefalseOverwrite the descriptor at --output instead of adding to it
--fragmentfalseWrite 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.

ForgeTokenSelf-hosted instance
GitHubGITHUB_TOKENGITHUB_API_URL, including Enterprise Server's /api/v3. GitHub Actions sets it on a GHES runner
GitLabGITLAB_TOKENCI_API_V4_URL, which a runner sets, then GITLAB_URL, then CI_SERVER_URL
Azure DevOpsAZURE_DEVOPS_EXT_PAT, else AZURE_DEVOPS_TOKEN. Needs the Code (read) scopeAZURE_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
FlagDefaultDescription
--allfalseRe-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 invalid

The 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-api publisher has an endpoint with neither a token nor a login, or a token without an endpoint, or a tokenEnv that $DRAUGR_TOKEN_ENVS does not list;
  • --strict, --policy or --fail-on-uncovered applies, 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:

KindCheckPasses when
repositoryresolves the revision; a full commit name is looked up in the repositorygit resolves it
pathsreads the tree at that commitevery entry of the component's paths is committed there
imagethe local Docker daemon, then the registry's manifest, with the credential docker login storedeither one has the image
hostopens a TCP connection, and completes the TLS handshake for httpsthe connection opens
clusterresolves the kubeconfig context the cluster names, or the current one, and asks its API server for its versionthe API server answers
accountasks Google Cloud whether the Application Default Credentials can read the projectthey hold resourcemanager.projects.get on it
filereads each file in components[].importsit 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.io

The 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                                   observed

A 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 UTC

A 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 name

before 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-17

Doctor 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.

FlagDefaultDescription
--jsonfalseEmit 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-uncoveredfalseExit non-zero when the descriptor declares a surface no enabled control looks at. Also the behavior when config.gate.failOnCaveats lists not-checked
--strictfalseExit 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
--policyfalseAsk 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
--offlinefalseMake 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` again

The 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 revoked

The 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.

FlagDefaultDescription
--jsonfalseWrite 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.

FlagDefaultDescription
-y, --yesfalseSkip the confirmation prompt
--dry-runfalsePrint the install plan and exit
--forcefalseReinstall 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
--allfalseInstall 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:

VerifyMeaning
sha256the pinned build, against a SHA-256 recorded in Draugr
sha256 + cosignthe same, and the upstream's cosign signature over its checksums file, when cosign is installed
upstream cosignanother version, against checksums the upstream signed, when cosign is installed
upstream sha256another version, against checksums the upstream published
unverifiedanother 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.

FlagDefaultDescription
--allfalseRemove every tool Draugr installed
--cachesfalseAlso remove each tool's own cache
-y, --yesfalseSkip the confirmation prompt
--dry-runfalsePrint 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 on PATH.

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.

FeedDatasetRead by
kevCISA's Known Exploited Vulnerabilities catalogexploitability
epssFIRST's EPSS scoresexploitability
govulndbthe Go vulnerability databasegovulncheck

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.

FlagDefaultDescription
--forcefalseFetch even if the cached copy is current
--saganoneDescriptor, 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:

ValueBehavior
a pathread that file; never touches the cache or the network, the air-gapped route
cacheread ~/.draugr/feeds; never fetches. Errors if nothing is cached
autoread 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
KeyAcceptsDefaultWhat it does
cache.dira directorynone, caching offThe directory results are cached in. Unset leaves caching off. Overridden by --cache-dir.
cache.readOnlytrue or falsefalseServe 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.requireDigesttrue or falsefalseRefuse 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.ttla duration such as 12h; 0s, no expiry24hHow 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.formatconsole, json or textconsoleHow log records are written. Overridden by --log-format.
log.leveltrace, debug, info, warn or errorinfoThe least severe level logged. Overridden by --log-level.
offlinetrue or falsefalseMake 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.evidencetrue or falsefalseAlso 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.offerInstalltrue or falsetrueWhether draugr scan on a terminal offers to install the scanners it is missing. Unset means it offers.
output.tipstrue or falsetrueWhether 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.topa whole number of 0 or more10 for scan, every row for diffHow 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.viewfindings, actions or compactfindingsWhat a console report shows. Overridden by --view.
publish.apiUrla URLnoneWhere 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.jobsa whole number of 0 or more; 0, one per CPU0How many scan jobs run at once. 0 means one per CPU. Overridden by --jobs.
tools.<tool>.versiona release, such as 0.69.3Draugr's pinThe 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_CONFIGthat file alone
./draugr.config.yamlthis project
~/.draugr/config.yamlthis 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/latest

Commands

draugr config showwhat 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 initwrite 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 editopen 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.yaml

What 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 key

unset 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 validate
draugr 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
FlagDefaultDescription
--optionsfalseList 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.

ToolPurpose
list_controlsWhich controls exist, what each checks, which scanner backs it, and the options each scanner accepts
get_saga_schemaThe Saga schema this build enforces
validate_sagaValidate a descriptor, by path or by content
propose_sagaReturn 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_toolsReport which scanners are installed and what to run if any are missing; given a descriptor, the tools draugr doctor requires for it
feeds_statusReport 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_ruleWhat a finding means and how to fix it, from the scan's own report
fix_listA 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_surveyorsThe surveyors this build has and what each discovers
surveyDiscover what an application is made of and return a descriptor for it; writes nothing
diff_reportsCompare two scans, with failOnNew to learn whether a pull-request gate would fail
summarize_reportRank an existing results.sarif by priority, with accepted findings and their decisions listed apart
scanRun a scan and return the verdict, the scope it covered, and where the descriptor's publishers delivered it (not offered with --scan=off)
FlagDefaultDescription
--scaneffectsWhether the assistant may start scans, and when you approve one first
--scanThe scan tool
effectsAsks before a scan in which a planned scanner declares an effect, config.dependencyHealth is enabled, or a publisher other than file delivers the report
askAsks before every scan
alwaysNever asks, for sandboxes and CI
offIs 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.

FlagDefaultDescription
--versionlatestTarget release to install (e.g. 0.16.0 or v0.16.0)
--checkfalseReport current vs latest available; make no changes
-y, --yesfalseSkip 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.

FlagDefaultDescription
--jsonfalseEmit 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).

FlagDefaultDescription
-o, --outputstdoutWrite to this file instead, and print the modeline that references it
--fragmentfalsePrint the Saga fragment schema instead
--config-filefalsePrint 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.