Loading...
Skip to main content
Workflow

Dependency Vulnerability Scanning in GitLab CI and Jenkins (No Ultimate Tier Required)

GitLab's built-in dependency scanning is locked behind the Ultimate tier. This guide shows how to build the same PR-gate-plus-scheduled-scan pattern in .gitlab-ci.yml and Jenkinsfile using OSV-Scanner — no license upgrade required.

Sudhir P.10 min read

If your team is on GitLab Free or Premium, the built-in "Dependency Scanning" template in GitLab's Secure category is not available to you — it requires an Ultimate subscription. Jenkins has no built-in dependency scanning at all; you're expected to wire up a plugin or a CLI tool yourself.

Neither of those facts means you're stuck with unscanned dependencies. OSV-Scanner — the same open-source database GeekWala uses — runs as a standalone binary or container with no licensing tier attached. You can build the identical two-mode pattern from our GitHub Actions guide: a merge-request gate that fails fast on dangerous new dependencies, and a scheduled pipeline that catches vulnerabilities disclosed after your last deploy.

What We'll Cover

Why CI-Gate Scanning Is Platform-Agnostic

The mechanics of a dependency scanning gate don't depend on which CI platform runs it. Every implementation needs the same three pieces:

  1. A trigger that fires when a lockfile changes (merge request) or on a schedule (nightly cron).
  2. A scanner that resolves the full dependency tree — direct and transitive — against a vulnerability database.
  3. A decision rule that turns raw findings into a pass/fail signal, ideally weighted by exploitation likelihood rather than raw CVSS severity.

GitHub Actions, GitLab CI, and Jenkins all support scheduled triggers, path-filtered jobs, and shell execution. The only real differences are syntax: YAML rules:/only: blocks in GitLab CI versus YAML on: triggers in GitHub Actions versus Groovy when {} blocks in a Jenkinsfile. Once you pick a scanner that ships a portable CLI or container image — OSV-Scanner qualifies — the same architecture ports cleanly across all three.

This matters because GitLab's Ultimate-gated feature and Jenkins' lack of a native scanner both create the same false impression: that dependency scanning is either expensive or hard to set up outside GitHub. It's neither.

PlatformNative dependency scanningCost to add OSV-ScannerTrigger syntaxPath/lockfile filter
GitHub ActionsNo (community actions only)Free — google/osv-scanner-actionon: pull_request / on: schedulepaths:
GitLab CIUltimate tier onlyFree — ghcr.io/google/osv-scanner container jobrules: - if:changes:
JenkinsNone built inFree — Docker step or static binarywhen { changeRequest() } / triggers { cron() }changeset

Same scanner, same EPSS/KEV threshold logic, three different YAML/Groovy dialects. Nothing here requires an Ultimate license or a plugin marketplace subscription.

GitLab CI Pipeline Recipe

GitLab CI reads its configuration from .gitlab-ci.yml at the repo root. Here's a merge-request gate using the official OSV-Scanner container image:

# .gitlab-ci.yml
stages:
  - test
  - security

dependency-scan-mr:
  stage: security
  image: ghcr.io/google/osv-scanner:latest
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
      changes:
        - "**/package-lock.json"
        - "**/yarn.lock"
        - "**/pnpm-lock.yaml"
        - "**/requirements.txt"
        - "**/poetry.lock"
        - "**/Pipfile.lock"
        - "**/pom.xml"
        - "**/go.sum"
        - "**/Cargo.lock"
        - "**/composer.lock"
        - "**/Gemfile.lock"
  script:
    - osv-scanner --recursive --format=json --output=scan-results.json . || true
    - cat scan-results.json
  artifacts:
    when: always
    paths:
      - scan-results.json
    expire_in: 30 days
  allow_failure: false

Three details worth calling out:

changes: replicates the GitHub Actions paths: filter. The job only runs when a matching lockfile is touched in the merge request — documentation and CSS-only MRs skip the scan entirely, keeping pipeline minutes down.

|| true after the scan command prevents OSV-Scanner's own non-zero exit code (it exits 1 when it finds anything) from failing the job before you've had a chance to apply your own threshold logic. You gate on the parsed results, not on the scanner's raw exit code.

allow_failure: false makes this a real merge-request blocker once your threshold script (below) sets a non-zero exit. Flip it to true while you're first rolling this out and want visibility without blocking anyone.

For a scheduled scan against your default branch, add a pipeline schedule in CI/CD → Pipeline schedules targeting a dedicated job:

dependency-scan-nightly:
  stage: security
  image: ghcr.io/google/osv-scanner:latest
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
  script:
    - osv-scanner --recursive --format=json --output=scan-results.json . || true
  artifacts:
    when: always
    paths:
      - scan-results.json
    expire_in: 90 days

GitLab pipeline schedules are configured in the UI (or via the Pipeline Schedules API) rather than in the YAML cron: syntax GitHub Actions uses — set the cadence there, then point it at a branch and let this job's rules: block pick it up.

Jenkins Pipeline Recipe

Jenkins has no equivalent to GitLab's changes: path filter or GitHub Actions' paths: trigger built into the pipeline syntax itself, so the lockfile-changed check happens inside the stage using changeset conditions. Here's a declarative Jenkinsfile with both a PR-triggered gate and a nightly cron stage:

// Jenkinsfile
pipeline {
    agent any

    triggers {
        cron('H 2 * * *') // nightly, Jenkins staggers the exact minute
    }

    stages {
        stage('Dependency Scan (PR gate)') {
            when {
                allOf {
                    changeRequest()
                    anyOf {
                        changeset "**/package-lock.json"
                        changeset "**/requirements.txt"
                        changeset "**/go.sum"
                        changeset "**/Cargo.lock"
                        changeset "**/composer.lock"
                        changeset "**/Gemfile.lock"
                        changeset "**/pom.xml"
                    }
                }
            }
            steps {
                sh '''
                    docker run --rm -v "$PWD":/src -w /src \
                        ghcr.io/google/osv-scanner:latest \
                        --recursive --format=json --output=/src/scan-results.json . || true
                '''
                archiveArtifacts artifacts: 'scan-results.json', allowEmptyArchive: true
                script {
                    def exitCode = sh(script: './scripts/check-dependency-thresholds.sh scan-results.json', returnStatus: true)
                    if (exitCode != 0) {
                        error('Dependency scan found findings above the configured EPSS/KEV threshold. See scan-results.json.')
                    }
                }
            }
        }

        stage('Dependency Scan (nightly)') {
            when {
                triggeredBy 'TimerTrigger'
            }
            steps {
                sh '''
                    docker run --rm -v "$PWD":/src -w /src \
                        ghcr.io/google/osv-scanner:latest \
                        --recursive --format=json --output=/src/scan-results.json . || true
                '''
                archiveArtifacts artifacts: 'scan-results.json', allowEmptyArchive: true
            }
        }
    }

    post {
        always {
            sh 'echo "Dependency scan artifacts archived — review scan-results.json in Jenkins build artifacts."'
        }
    }
}

Two Jenkins-specific notes:

changeRequest() + changeset is the Jenkins equivalent of GitLab's changes: or GitHub's paths: — it restricts the PR-gate stage to builds triggered by a pull/merge request that actually touched a lockfile. Both conditions are part of Jenkins' standard Declarative Pipeline syntax; verify plugin requirements against your specific Jenkins install's Pipeline Syntax reference since plugin bundling varies across LTS versions.

triggeredBy 'TimerTrigger' scopes the nightly stage to cron-triggered builds only, so a developer manually re-running the pipeline doesn't accidentally re-trigger the "nightly" stage on every retry.

If your Jenkins agents don't have Docker available, install the osv-scanner binary directly on the agent image instead — the OSV-Scanner releases page publishes static binaries for Linux, macOS, and Windows, and the sh steps above become osv-scanner --recursive ... without the docker run wrapper.

Failing Builds on EPSS/KEV Thresholds, Not Raw CVE Counts

Both recipes above write scan output to scan-results.json, but neither one fails the build directly on OSV-Scanner's raw output. That's deliberate. OSV-Scanner reports every match against the OSV database — including CVSS 9.8 findings that have never been exploited in the wild and CVSS 4.0 findings that are actively being weaponized right now.

Raw OSV-Scanner output (unfiltered):        After EPSS/KEV filtering:
──────────────────────────────────           ──────────────────────────────
47 findings, sorted by CVSS                   3 findings, sorted by exploitation risk
├── 12 Critical (CVSS 9.0+)                   ├── 1 on CISA KEV — patch today
├── 19 High (CVSS 7.0-8.9)                    ├── 2 EPSS > 0.3 — patch this sprint
├── 11 Moderate (CVSS 4.0-6.9)                └── (44 findings logged, not blocking)
└── 5 Low (CVSS < 4.0)

A blocking gate that fires on "any CVSS High or above" will fail nearly every merge request in a typical dependency tree — 80% of the average project's vulnerabilities live in transitive dependencies you didn't choose directly, and most of them are theoretical. Teams that ship a gate this noisy end up disabling it within a month.

The fix is the same 3-Signal Triage Method used in the GitHub Actions gate: enrich raw findings with EPSS exploitation probability and CISA KEV status, then gate on that instead of CVSS alone.

#!/usr/bin/env bash
# scripts/check-dependency-thresholds.sh
# Usage: ./check-dependency-thresholds.sh scan-results.json
set -euo pipefail

RESULTS_FILE="${1:?Usage: $0 <osv-scanner-json-output>}"

# Extract vulnerability IDs from OSV-Scanner's JSON output, then look up
# EPSS score and KEV status for each one (GeekWala's API, FIRST.org's EPSS
# API, or CISA's KEV JSON feed all work here — swap in your enrichment
# source of choice).
BLOCKING_COUNT=$(jq '[.results[].packages[]?.vulnerabilities[]? |
  select(.id != null)] | length' "$RESULTS_FILE")

if [ "$BLOCKING_COUNT" -gt 0 ]; then
  echo "Found $BLOCKING_COUNT vulnerabilities. Cross-reference against EPSS/KEV before failing the build."
  # Replace this with a real EPSS/KEV lookup + threshold comparison.
  # Exit 1 only for findings on CISA KEV or with EPSS above your threshold.
fi

exit 0

Start with a permissive threshold and tighten it over the same four-week rollout described in the GitHub Actions guide: KEV-only in week one, EPSS > 0.3 by week four. The goal is a gate that blocks rarely but means something every time it fires — regardless of whether it's running in GitHub, GitLab, or Jenkins.

When CI Gates Aren't Enough: Scheduled Scans

A merge-request gate only sees dependencies at the moment someone opens an MR or PR. It has no way to catch a CVE disclosed against a package that's already merged and sitting in your default branch — that's what the nightly job in both recipes above is for.

But even a nightly scan leaves a detection gap: the OSV database updates continuously throughout the day, so a critical CVE disclosed at 9 AM won't surface in your pipeline until the next scheduled run. For most teams, closing that gap to hours instead of a full day means layering continuous monitoring on top of CI gates — a service that watches your registered dependency manifests and alerts in near real time when a new CVE lands, independent of your GitLab or Jenkins schedule. See automated dependency scanning for what that layer looks like in practice.

The practical takeaway for GitLab and Jenkins teams: you don't need the Ultimate tier or a Jenkins plugin marketplace subscription to get PR-gate-plus-nightly-scan coverage equivalent to GitHub's. OSV-Scanner plus a threshold script gets you there in both .gitlab-ci.yml and a Jenkinsfile with the same architecture, the same EPSS/KEV filtering logic, and the same 20-minute setup cost.

Does GitLab Free include any dependency scanning at all?

No. GitLab's native "Dependency Scanning" analyzer — the one integrated into the Security Dashboard and merge request widget — is part of the Ultimate tier only. GitLab Free and Premium do not include it. You can still run OSV-Scanner (or any other open-source SCA tool) as a plain CI job on any tier; you just won't get GitLab's built-in Security Dashboard visualization unless you're on Ultimate.

Can I get GitLab's Security Dashboard UI without the Ultimate tier?

Not the native one — the Security Dashboard widget that visualizes dependency findings inline on merge requests is an Ultimate-only feature regardless of which scanner produces the underlying data. You can approximate it with the artifacts: reports: mechanism on lower tiers for some report types, but full Dependency Scanning dashboard integration requires Ultimate. The OSV-Scanner job in this guide surfaces findings as a pipeline artifact and a failed job instead — less polished, but functionally equivalent for blocking bad merges.

Does Jenkins have an official dependency scanning plugin?

Not a single canonical one. The Jenkins plugin ecosystem includes options like the OWASP Dependency-Check plugin, but adoption and maintenance vary by plugin. Running a scanner CLI (OSV-Scanner, Trivy, or similar) directly inside a pipeline sh step — as shown above — avoids plugin-compatibility churn across Jenkins upgrades and works identically whether you're on Jenkins LTS or a bleeding-edge release.

Will scanning slow down my GitLab or Jenkins pipeline?

Scanning a typical lockfile with OSV-Scanner takes 15-30 seconds, similar to the GitHub Actions numbers. The changes:/changeset filters in both recipes above mean the scan job only runs when a lockfile actually changed, so most merge requests and PRs skip it entirely. The Docker image pull in the Jenkins recipe adds a few seconds on a cold agent; caching the image on your Jenkins agents (or switching to a static binary) eliminates that cost after the first run.

Can I reuse this pattern for other CI platforms besides GitLab and Jenkins?

Yes — that's the point of the architecture in this guide. Any CI platform that supports path-filtered triggers, scheduled jobs, and shell execution (CircleCI, Bitbucket Pipelines, Azure Pipelines, Buildkite) can run the same OSV-Scanner-plus-threshold-script pattern with platform-specific YAML or configuration syntax swapped in. The scanner and the threshold logic don't change — only the trigger syntax does.


See dependency findings ranked by what's actually exploitable — not just how bad they sound.

Scan your dependencies now → — GeekWala enriches every finding with CVSS severity, EPSS exploitation probability, and CISA KEV status, whether your pipeline lives in GitHub Actions, GitLab CI, or Jenkins. No account needed.