Use in CI with Azure Pipelines
On this page
There is no extension to install and no service connection to create. Draugr is a single binary.
Set it up
Copy azure-pipelines/draugr.yml
into your repository as .azure/draugr.yml, then:
# azure-pipelines.yml
trigger:
branches:
include: [main]
pr: none # Azure Repos ignores this. See Pull requests below
pool:
vmImage: ubuntu-latest
steps:
- template: .azure/draugr.yml
parameters:
saga: draugr.saga.yaml
That installs Draugr, provisions the scanners, scans, gates a pull request on what it introduced, and publishes findings to the Tests tab.
A pull request that adds the repository's first descriptor has none on the target branch, and is compared against no findings instead: every finding is new and the gate applies to them.
| Parameter | Default | |
|---|---|---|
saga | draugr.saga.yaml | the descriptor to scan |
mode | auto | auto (scan on push, diff on a pull request), scan, or diff |
version | latest | pin a release. do this for real pipelines |
tools | true | provision the scanners the controls need |
failOnNewPriority | P1 | fail a pull request on a new finding at or above this priority. Set it empty to apply the descriptor's config.gate.failOnNew instead, which a release without draugr diff --saga ignores |
diffView | findings | what the pull-request comment says: a row per finding, or actions grouped into the things somebody would do |
publishResults | true | Tests tab and build artifacts |
Copy it rather than referencing it remotely. Azure can pull a template from a GitHub
repository resource, but that needs a service connection and means your pipeline changes when we
change the template. A copied file is one curl to update and pinned by your own git history.
Two one-time settings
Neither can come from a descriptor or a template, and both are needed before Draugr can comment on a pull request. Skip them and everything else still works.
- Build validation policy. Without one there are no pull-request builds at all.
Project settings → Repositories → your repo → Policies → your branch → Build Validation →
+. Start it optional, not required. Contribute to pull requestsfor<Project> Build Service, Project settings → Repositories → your repo → Security. Without it the comment gets a 403 while the token is perfectly valid.
Draugr names both in its error messages, so a failure points at the fix.
Pull requests
Azure Repos ignores pr: in YAML. Silently, no build, no warning. Pull-request builds come from
the build validation policy above. (pr: works only when the pipeline's source is GitHub or
Bitbucket, which is why the key exists.)
A sticky comment
Add the publisher to the Saga and the Markdown report goes on the pull request, updated in place on each push rather than stacking a copy per run:
config:
publishers:
- kind: azure-pr-comment
Everything else defaults from the pipeline environment, and off a pull request it does nothing, so
the same descriptor works on push builds and on a laptop. SYSTEM_ACCESSTOKEN must be mapped into
the step (the template does this); Azure does not expose it to scripts by default.
config.publishers is what a destination delivers; --report is what gets written to disk. Different
destinations, so the markdown entry above is what the comment needs regardless of --report.
If a branch policy requires all comments resolved before merging, someone has to resolve Draugr's thread. It is created active, like any other.
Gating on new findings
mode: auto does this for you. Written out, it is two scans and a comparison:
- script: |
set -euo pipefail
target="${SYSTEM_PULLREQUEST_TARGETBRANCH#refs/heads/}"
git checkout -q --detach "origin/$target"
draugr scan draugr.saga.yaml --no-gate --report sarif -o "$(Pipeline.Workspace)/base"
git checkout -q --detach "$BUILD_SOURCEVERSION"
draugr scan draugr.saga.yaml --no-gate --report sarif -o "$(Pipeline.Workspace)/head"
draugr diff "$(Pipeline.Workspace)/base/results.sarif" \
"$(Pipeline.Workspace)/head/results.sarif" \
--publish --fail-on-new P1 --view actions
displayName: Gate on new findings
condition: eq(variables['Build.Reason'], 'PullRequest')
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
--view actions decides what the comment says. It groups the change into the things somebody would
do, so six advisories in one library are one upgrade; drop it for a row per finding. The reusable
template takes it as diffView, which also handles an older pinned version that does not have
the flag.
Three details that are easy to get wrong:
--no-gateon both scans. A scan exits non-zero onFAIL, which underset -ekills the step before the diff runs, and the base scan fails on any repository with a backlog.--no-gatesuppresses the verdict's exit code only; a scan that could not run still fails.|| truecannot tell those apart.$BUILD_SOURCEVERSIONto get back. A pull-request build checks outrefs/pull/N/merge, which is on no branch, sogit checkout -cannot find its way home.- Priority, not severity.
--fail-on-newtakes a band, which folds in the component's exposure and criticality, so it blocks a critical finding in something exposed and lets one in a sandbox through.
The diff keeps its own comment, so a pipeline running both leaves one for the branch and one for the delta. See gate PRs on new findings for how findings are matched across scans.
Findings in the Tests tab
--report junit writes report.junit.xml, and PublishTestResults@2 renders each finding as a
failed test, control and scanner as the suite, rule and location as the name. Each failure carries
the description and a link to the advisory, so a CVE number is something you can follow.
Two conditions matter:
condition: succeededOrFailed()on the publishing tasks. the run that failed is the one whose evidence you want.failTaskOnFailedTests: false. The scan has already decided; failing here too reports one problem as two.
Azure has no native SARIF ingestion, so results.sarif goes to the build artifacts, where another
tool or your editor can read it (findings in your editor).
The gate itself needs no wiring: draugr scan exits non-zero on FAIL, which fails the build.
What the template does
The template is
readable and commented; the two things worth knowing are that install.sh puts Draugr in
~/.local/bin and Draugr puts provisioned scanners in ~/.draugr/bin, so both need
prependpath on a hosted agent, and that fetchDepth: 0 is required, since a diff needs the base
branch's commit.
Several repositories
A Saga's url takes a local path, so let the agent check them out. With more than one
checkout, Azure puts each repository in a directory named after it under
$(Build.SourcesDirectory), and steps run in $(Build.SourcesDirectory). A relative url
resolves against where Draugr runs, so each one names its checkout's directory. Here the pipeline's
own repository is storefront:
# azure-pipelines.yml
resources:
repositories:
- repository: payments
type: git
name: integration/payments
steps:
- checkout: self # $(Build.SourcesDirectory)/storefront
- checkout: payments # $(Build.SourcesDirectory)/payments
- template: .azure/draugr.yml
parameters:
saga: storefront/draugr.saga.yaml
mode: scan
# storefront/draugr.saga.yaml
components:
- name: storefront
repositories:
- url: storefront
- name: payments
repositories:
- url: payments
mode: scan, because the pull-request diff checks out the base branch in the working directory,
which here is no repository.
A remote url: also works, Draugr shells out to git, so it behaves exactly as git clone would
on that agent. Letting checkout: do it is simpler and keeps Azure's credentials in play.
Make the second scan cheap
A pull request scans the base as well as the head, which is what makes the two comparable. Caching Trivy's databases and Draugr's own results makes the pair cost about what one scan does, and a cache that expired or was never written costs time rather than correctness: the base is still scanned and both sides still come from the same binary.
Two Cache@2 tasks ahead of the template do it. See caching and
performance for the keys and for what a hit does
and does not promise, which is worth reading before sharing a cache with pull-request pipelines.
Verifying the images you sign
Azure Pipelines signs container images with the Notation@0 task and a certificate in Azure Key
Vault, which pushes the signature into Azure Container Registry. That is the Notary Project trust
model rather than the Sigstore one: a certificate chaining to roots you hold, rather than a
short-lived identity tied to a workload.
The provenance control checks it. Declare the roots and the subject, and Draugr
runs notation against each image:
config:
controls:
provenance:
enabled: true
signers:
- name: acme-pki
images: ["acme.azurecr.io/*"]
x509:
trustStore: .draugr/truststore/acme-ca.pem
subject: "C=US, ST=WA, O=Acme, CN=Acme Release Signing"
trustStore is a PEM file of root certificates, committed beside the descriptor or fetched by the
pipeline before the scan. subject is the signing certificate's subject as a comma-separated
distinguished name; run notation inspect <image> against something you have signed and copy the
issued to: line verbatim, because it has to match exactly.
Draugr writes the trust policy itself, scoped to the image being verified. There is no
trustpolicy.json to maintain beside the descriptor, which would be a second place stating who may
sign.
Nothing here contacts a transparency log, so this path works on an agent whose only egress is the registry.
Air-gapped and self-hosted agents
Running air-gapped applies unchanged: DRAUGR_OFFLINE=1, a pre-provisioned
~/.draugr/bin, and feeds refreshed as their own step. A self-hosted agent with a warm scanner
cache is the fastest way to run Draugr in Azure. The scan itself is usually seconds.