Guides

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.

ParameterDefault
sagadraugr.saga.yamlthe descriptor to scan
modeautoauto (scan on push, diff on a pull request), scan, or diff
versionlatestpin a release. do this for real pipelines
toolstrueprovision the scanners the controls need
failOnNewPriorityP1fail 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
diffViewfindingswhat the pull-request comment says: a row per finding, or actions grouped into the things somebody would do
publishResultstrueTests 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.

  1. 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.
  2. Contribute to pull requests for <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-gate on both scans. A scan exits non-zero on FAIL, which under set -e kills the step before the diff runs, and the base scan fails on any repository with a backlog. --no-gate suppresses the verdict's exit code only; a scan that could not run still fails. || true cannot tell those apart.
  • $BUILD_SOURCEVERSION to get back. A pull-request build checks out refs/pull/N/merge, which is on no branch, so git checkout - cannot find its way home.
  • Priority, not severity. --fail-on-new takes 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.