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.
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.
env: block, as below. Miss this and the CLI exits 3 with an auth error even though the variable is set.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:
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.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.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.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').
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.
| Symptom | Cause |
|---|---|
| Exit 3 although the secret variable is set | The task is missing the env: mapping — Azure Pipelines does not decrypt secrets into the environment on its own. |
| Exit 3 on pull request runs only | Secret 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 API | The build service identity lacks Contribute to pull requests on the repository. |
| Exit 5 | Rate limited, usually parallel jobs sharing one token. |
Cannot iterate over null from jq | The vulnerabilities key is absent on a clean scan. Use (.vulnerabilities // [])[], as above. |
null (null) cannot be parsed as a number from jq | A finding with no cvss_base_score. Use .cvss_base_score // 0 | tonumber? // 0, as above. |