CI/CD Pipelines

Azure Pipelines

Add a VulnCheck vulnerability gate to Azure DevOps pipelines, and post findings onto the pull request.

VulnCheck runs in Azure Pipelines through the VulnCheck CLI no extension to install from the marketplace. The pipeline below installs a pinned CLI, scans the repository, fails the run on findings that breach your policy, and publishes the results as a pipeline artifact.

Store your token

Create a VulnCheck API token from API Tokens, then add it as a secret variable, either on the pipeline itself (Edit > Variables > New variable, with Keep this value secret ticked) or in a variable group under Pipelines > Library to share it across pipelines.

Azure Pipelines does not decrypt secret variables into the environment automatically, unlike ordinary variables. Every task that needs the token must map it explicitly with an env: block, as below. Miss this and the CLI exits 3 with an auth error even though the variable is set.

Scan every run

trigger:
  - main

pr:
  - main

pool:
  vmImage: ubuntu-latest

variables:
  VC_CLI_VERSION: '1.1.0'
  VC_CVSS_THRESHOLD: '7.0'

steps:
  - bash: |
      set -euo pipefail
      ARCH="$(uname -m)"
      case "$ARCH" in x86_64) ARCH=amd64 ;; aarch64) ARCH=arm64 ;; esac
      curl -sSL "https://github.com/vulncheck-oss/cli/releases/download/v${VC_CLI_VERSION}/vulncheck_${VC_CLI_VERSION}_linux_${ARCH}.tar.gz" | tar -xz -C /tmp
      sudo install -m 0755 "/tmp/vulncheck_${VC_CLI_VERSION}_linux_${ARCH}/bin/vulncheck" /usr/local/bin/vulncheck
      vulncheck version
    displayName: Install the VulnCheck CLI

  - bash: |
      set -euo pipefail
      vulncheck scan . --json > "$(Build.ArtifactStagingDirectory)/scan.json"
      jq -r '"\((.vulnerabilities // []) | length) vulnerabilities found"' "$(Build.ArtifactStagingDirectory)/scan.json"
    displayName: Scan with VulnCheck
    env:
      VC_TOKEN: $(VC_TOKEN)

  - bash: |
      SCAN="$(Build.ArtifactStagingDirectory)/scan.json"
      jq -e --argjson max "$VC_CVSS_THRESHOLD" '
        [ (.vulnerabilities // [])[]
          | select((.cvss_base_score // 0 | tonumber? // 0) >= $max or .in_kev) ] | length == 0
      ' "$SCAN" > /dev/null || {
        echo "##vso[task.logissue type=error]Findings at or above CVSS ${VC_CVSS_THRESHOLD}, or in VulnCheck KEV"
        jq -r --argjson max "$VC_CVSS_THRESHOLD" '
          (.vulnerabilities // [])[]
          | select((.cvss_base_score // 0 | tonumber? // 0) >= $max or .in_kev)
          | "  \(.cve)  \(.name)@\(.version)  CVSS \(.cvss_base_score)  KEV \(.in_kev)  fixed in \(.fixed_versions // "n/a")"
        ' "$SCAN"
        exit 1
      }
    displayName: Gate on findings

  - task: PublishPipelineArtifact@1
    condition: always()
    inputs:
      targetPath: $(Build.ArtifactStagingDirectory)/scan.json
      artifact: vulncheck-scan
    displayName: Publish scan results

Details worth knowing:

  • The gate is a separate step on purpose. vulncheck scan exits 0 even when it finds critical vulnerabilities, so the scan step always succeeds and the policy decision stays legible in the run summary. See VulnCheck in CI/CD.
  • condition: always() publishes the artifact even when the gate failed the run you most want the evidence from.
  • curl and jq are preinstalled on Microsoft-hosted Ubuntu images. On a self-hosted agent, install them once on the host.
  • Pin VC_CLI_VERSION. Every run then scans with a known CLI, and you avoid the unauthenticated GitHub API call that install.sh makes to resolve the latest release.

Comment on the pull request

Azure Repos pull requests accept comment threads through the REST API, using the build's own OAuth token:

  - bash: |
      if [ "$BUILD_REASON" != "PullRequest" ]; then exit 0; fi
      SCAN="$(Build.ArtifactStagingDirectory)/scan.json"
      BODY=$(jq -r '
        "## VulnCheck scan\n\n" +
        (if ((.vulnerabilities // []) | length) == 0
         then "No vulnerabilities found."
         else "| CVE | Package | CVSS | KEV | Fixed in |\n|---|---|---|---|---|\n" +
              ([ (.vulnerabilities // [])[]
                 | "| \(.cve) | \(.name)@\(.version) | \(.cvss_base_score) | \(if .in_kev then "Yes" else "No" end) | \(.fixed_versions // "n/a") |" ]
               | join("\n"))
         end)' "$SCAN")
      jq -n --arg body "$BODY" '{comments: [{parentCommentId: 0, content: $body, commentType: 1}], status: 1}' > thread.json
      curl -sS --fail-with-body \
        --header "Authorization: Bearer ${SYSTEM_ACCESSTOKEN}" \
        --header "Content-Type: application/json" \
        --data @thread.json \
        "${SYSTEM_COLLECTIONURI}${SYSTEM_TEAMPROJECT}/_apis/git/repositories/${BUILD_REPOSITORY_ID}/pullRequests/${SYSTEM_PULLREQUEST_PULLREQUESTID}/threads?api-version=7.1"
    displayName: Comment on the pull request
    condition: always()
    env:
      SYSTEM_ACCESSTOKEN: $(System.AccessToken)
System.AccessToken is not exposed to scripts unless you map it, which is what the env: block above does. The identity behind it, Project Collection Build Service or the project's build service account also needs Contribute to pull requests on the repository, otherwise the API returns 403.

Warn instead of failing

To surface findings without blocking the run, log a warning and let the step succeed:

  - bash: |
      SCAN="$(Build.ArtifactStagingDirectory)/scan.json"
      jq -e --argjson max "$VC_CVSS_THRESHOLD" '
        [ (.vulnerabilities // [])[]
          | select((.cvss_base_score // 0 | tonumber? // 0) >= $max or .in_kev) ] | length == 0
      ' "$SCAN" > /dev/null || echo "##vso[task.logissue type=warning]VulnCheck policy breach — see the vulncheck-scan artifact"
    displayName: Report findings

A common arrangement is a hard gate on pull request runs and a warning on branch builds, so a newly published CVE never blocks unrelated work — split the two with condition: eq(variables['Build.Reason'], 'PullRequest').

Windows and self-hosted agents

On Windows agents, download the windows_amd64.zip release and add the extracted bin directory to PATH, then use the same vulncheck scan and jq steps from PowerShell. Self-hosted agents need outbound HTTPS to api.vulncheck.com, and to github.com to download the CLI; where egress is restricted, mirror the release archive internally. For fully air-gapped agents, see Offline mode.

Troubleshooting

SymptomCause
Exit 3 although the secret variable is setThe task is missing the env: mapping — Azure Pipelines does not decrypt secrets into the environment on its own.
Exit 3 on pull request runs onlySecret variables are not passed to pull request builds from forks. Restrict the scan to non-fork runs with condition: eq(variables['System.PullRequest.IsFork'], 'False'), and rely on the branch build to cover the merged result.
403 from the pull request threads APIThe build service identity lacks Contribute to pull requests on the repository.
Exit 5Rate limited, usually parallel jobs sharing one token.
Cannot iterate over null from jqThe vulnerabilities key is absent on a clean scan. Use (.vulnerabilities // [])[], as above.
null (null) cannot be parsed as a number from jqA finding with no cvss_base_score. Use .cvss_base_score // 0 | tonumber? // 0, as above.