VulnCheck runs in GitLab CI/CD through the VulnCheck CLI. There is nothing to install on your instance and no component to add from the CI/CD catalog. A single job definition in .gitlab-ci.yml scans your repository, fails the pipeline on findings that breach your policy, and keeps the results as a job artifact.
Create a VulnCheck API token from API Tokens, then add it to your project:
VC_TOKEN and paste the token as the valueThe CLI reads VC_TOKEN from the environment automatically. Set it at group level to share one token across every project.
stages:
- security
vulncheck-scan:
stage: security
image: debian:bookworm-slim
variables:
VC_CLI_VERSION: '1.1.0'
VC_CVSS_THRESHOLD: '7.0'
before_script:
- apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates curl jq
- |
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
install -m 0755 "/tmp/vulncheck_${VC_CLI_VERSION}_linux_${ARCH}/bin/vulncheck" /usr/local/bin/vulncheck
- vulncheck version
script:
- vulncheck scan . --json > scan.json
- jq -r '"\((.vulnerabilities // []) | length) vulnerabilities found"' scan.json
- |
jq -e --argjson max "$VC_CVSS_THRESHOLD" '
[ (.vulnerabilities // [])[]
| select((.cvss_base_score // 0 | tonumber? // 0) >= $max or .in_kev) ] | length == 0
' scan.json > /dev/null || {
echo "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.json
exit 1
}
artifacts:
when: always
paths:
- scan.json
expire_in: 30 days
A few details worth knowing:
vulncheck scan exits 0 even when it finds critical vulnerabilities, so without the jq -e check the job passes with findings sitting in the log. See VulnCheck in CI/CD for the full explanation and other fields you can gate on.when: always keeps scan.json even on a failed job which is exactly the run you want the artifact from.VC_CLI_VERSION. The install.sh script resolves the latest release through the unauthenticated GitHub API, which is rate limited per IP and shared by every job on GitLab.com's hosted runners. It is also glibc-only, so it fails on Alpine images. The tarball above avoids both problems; the binary itself runs fine on Alpine.Scanning every commit on every branch is often more than you need. Rename the job above to .vulncheck the leading dot makes it a hidden template that never runs on its own. Then derive as many jobs from it as you need:
vulncheck-merge-request:
extends: .vulncheck
rules:
- if: $CI_PIPELINE_SOURCE == 'merge_request_event'
vulncheck-default-branch:
extends: .vulncheck
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
allow_failure: true
Gating hard on merge requests while only warning on the default branch is a good default: nothing merges with a fresh policy breach, but a CVE published against a dependency you already shipped does not block unrelated work.
Findings are most useful in front of the reviewer. GitLab's Notes API can post them as a merge request comment:
script:
- vulncheck scan . --json > 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.json)
- |
if [ -n "$CI_MERGE_REQUEST_IID" ]; then
curl -sS --fail-with-body \
--header "PRIVATE-TOKEN: ${GITLAB_COMMENT_TOKEN}" \
--data-urlencode "body=${BODY}" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/merge_requests/${CI_MERGE_REQUEST_IID}/notes"
fi
api scope, stored as a masked variable — GITLAB_COMMENT_TOKEN above. The built-in CI_JOB_TOKEN cannot do it: on the Notes API it is limited to the GET endpoints, so it can read existing comments but not create one.The CLI produces a CycloneDX SBOM, which is worth keeping alongside the scan results:
script:
- vulncheck scan . --sbom-only -o sbom.json
artifacts:
paths:
- sbom.json
On GitLab Ultimate you can hand the SBOM to GitLab itself with artifacts:reports:cyclonedx, which populates the project's dependency list:
artifacts:
reports:
cyclonedx: sbom.json
sbom.json as a plain artifact using artifacts:paths instead. The report type is Ultimate-only in any case; artifacts:paths works on every tier.api.vulncheck.com, and to github.com to download the CLI. Where egress is restricted, mirror the release tarball internally and install it from there.vulncheck scan --offline against cached indices. See Offline mode.before_script, and keep vulncheck version in the job so the log records which build produced the results.| Symptom | Cause |
|---|---|
vulncheck: command not found after a successful install step | install.sh --sudo in a container running as root: sudo does not exist, and the script still reports success. Use the tarball install above. |
Unsupported operating system during install | install.sh on an Alpine or other musl image. The tarball install works there. |
| Job fails with exit 3 | VC_TOKEN is missing, expired, or not exposed to this job — check Protect variable against the branch you are running on. |
| Job fails with exit 5 | Rate limited, usually many 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. |
Provider resolution errors mentioning snap, docker or podman | The scan path could not be resolved, so scan tried to treat it as a container image reference. Check the path. |