CI/CD Pipelines

GitLab CI/CD

Add a VulnCheck vulnerability gate to your GitLab pipelines, on GitLab.com or self-managed.

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.

Store your token

Create a VulnCheck API token from API Tokens, then add it to your project:

The CLI reads VC_TOKEN from the environment automatically. Set it at group level to share one token across every project.

Scan every pipeline

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:

  • The gate is not optional. 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.
  • Pin 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.

Scan only merge requests

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.

Comment on the merge request

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
This needs a project or group access token with the 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.

Publish the SBOM

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
GitLab documents this report against the CycloneDX 1.4 specification, and the CLI emits 1.7. Check that your instance accepts the report — if the dependency list does not populate, publish 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.

Self-managed runners

  • Runners need outbound HTTPS to 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.
  • For fully air-gapped runners, use vulncheck scan --offline against cached indices. See Offline mode.
  • On a shell executor, install the CLI once on the host rather than in before_script, and keep vulncheck version in the job so the log records which build produced the results.

Troubleshooting

SymptomCause
vulncheck: command not found after a successful install stepinstall.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 installinstall.sh on an Alpine or other musl image. The tarball install works there.
Job fails with exit 3VC_TOKEN is missing, expired, or not exposed to this job — check Protect variable against the branch you are running on.
Job fails with exit 5Rate limited, usually many 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.
Provider resolution errors mentioning snap, docker or podmanThe scan path could not be resolved, so scan tried to treat it as a container image reference. Check the path.