DevOpsil
Security
85%
Fresh

Automated Dependency Vulnerability Scanning in CI: Stop Shipping Known CVEs

Amara OkaforAmara Okafor8 min read

You're Shipping Vulnerabilities You Already Know About

In March 2024, CVE-2024-3094 — the xz-utils backdoor — demonstrated that even foundational dependencies can be weaponized. But here's what should concern you more than sophisticated supply chain attacks: most breaches exploit vulnerabilities that have been publicly known for months or years. The 2017 Equifax breach (CVE-2017-5638) used an Apache Struts vulnerability that had a patch available two months before the attack. 147 million records. Two months of inaction.

I audit CI pipelines regularly. The pattern is consistent: teams run unit tests, integration tests, linting, formatting checks — and skip dependency scanning entirely. They deploy applications with dozens of known critical CVEs because nobody checks. The vulnerability database knows. The CVE has been published. The patch exists. But the pipeline doesn't look.

Automated dependency scanning in CI is not optional. It's the lowest-effort, highest-impact security control you can add to a pipeline. Here's how to do it properly.

The Tools Landscape

There are three categories of dependency scanners worth considering:

ToolStrengthsBest For
TrivyMulti-target (OS, language, IaC, containers), fast, offline DBAll-in-one scanning
GrypeExcellent vulnerability matching, SBOM-nativeSBOM-first workflows
OSV-ScannerUses OSV.dev (Google's open-source vulnerability database)Go, Python, Rust ecosystems
SnykDeveloper-friendly, fix PRs, commercial supportTeams wanting managed solution
GitHub DependabotNative GitHub integration, automatic PRsGitHub-only workflows

For this guide, I'll focus on Trivy and Grype — they're open source, work in any CI system, and cover the widest range of ecosystems.

Trivy: The All-in-One Scanner

Trivy scans container images, filesystem dependencies, IaC files, and Kubernetes manifests. One tool, multiple attack surfaces.

Basic Filesystem Scan

Scan your project dependencies against the NVD and other advisory databases:

# Install
curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sh -s -- -b /usr/local/bin

# Scan the current project
trivy filesystem --severity HIGH,CRITICAL --exit-code 1 .

The --exit-code 1 flag is critical — it makes Trivy return a non-zero exit code when vulnerabilities at the specified severity are found, which fails your CI pipeline.

GitHub Actions Integration

# .github/workflows/security-scan.yml
name: Dependency Security Scan
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  trivy-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run Trivy filesystem scan
        uses: aquasecurity/trivy-action@0.28.0
        with:
          scan-type: fs
          scan-ref: .
          severity: HIGH,CRITICAL
          exit-code: 1
          format: table

      - name: Run Trivy filesystem scan (SARIF)
        uses: aquasecurity/trivy-action@0.28.0
        if: always()
        with:
          scan-type: fs
          scan-ref: .
          severity: HIGH,CRITICAL
          format: sarif
          output: trivy-results.sarif

      - name: Upload SARIF to GitHub Security
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: trivy-results.sarif

This runs two scans: one that fails the pipeline on HIGH/CRITICAL findings, and one that uploads SARIF results to GitHub's Security tab regardless of the outcome. You get pipeline enforcement and a persistent security dashboard.

Container Image Scanning

Don't just scan your source dependencies — scan the built image. Base images carry their own vulnerabilities:

  image-scan:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Scan container image
        uses: aquasecurity/trivy-action@0.28.0
        with:
          scan-type: image
          image-ref: ghcr.io/${{ github.repository }}:${{ github.sha }}
          severity: CRITICAL
          exit-code: 1
          ignore-unfixed: true
          format: table

The --ignore-unfixed flag is important. It suppresses vulnerabilities that have no available fix — these are still worth tracking, but you shouldn't block deployments on issues you can't resolve yet.

Grype: SBOM-Native Scanning

Grype pairs with Syft (its companion SBOM generator) for a scan-from-SBOM workflow. This is more precise than filesystem scanning because the SBOM captures exactly what's packaged, not just what's in your source tree.

Generate SBOM, Then Scan

# Install Syft and Grype
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin

# Generate SBOM from your container image
syft ghcr.io/your-org/your-app:latest -o spdx-json > sbom.spdx.json

# Scan the SBOM for vulnerabilities
grype sbom:sbom.spdx.json --fail-on critical

GitLab CI Integration

# .gitlab-ci.yml
dependency-scan:
  stage: test
  image: alpine:3.20
  before_script:
    - apk add --no-cache curl
    - curl -sSfL https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin
  script:
    - grype dir:. --fail-on high --output table
    - grype dir:. --output json > gl-dependency-scanning-report.json
  artifacts:
    reports:
      dependency_scanning: gl-dependency-scanning-report.json
    when: always
  allow_failure: false

Handling False Positives and Exceptions

Not every CVE finding requires immediate action. Some vulnerabilities are in code paths your application doesn't use. Some are disputed. Some are in dev dependencies that never ship to production. You need a structured exception process.

Trivy Ignore File

# .trivyignore.yaml
vulnerabilities:
  - id: CVE-2023-44487    # HTTP/2 rapid reset — mitigated at load balancer
    statement: "Not exploitable. All HTTP/2 traffic terminates at the ALB which has mitigation applied."
    expires: 2026-06-22

  - id: CVE-2024-21626     # runc container breakout — patched in node AMI
    statement: "Nodes running containerd 1.7.13+ which includes the fix."
    expires: 2026-04-22

  - id: GHSA-example-1234  # Dev dependency only, not in production image
    statement: "Only present in devDependencies, not included in production build."
    expires: 2026-05-22

Every exception must have:

  • A statement explaining why it's acceptable
  • An expiration date that forces periodic review
  • A human who approved it (track this in your PR)

Exceptions without expiration dates are tech debt that never gets paid.

Grype Ignore Rules

# .grype.yaml
ignore:
  - vulnerability: CVE-2023-44487
    reason: "Mitigated at infrastructure level"
  - package:
      type: npm
      name: "dev-only-package"
    reason: "Dev dependency, not shipped"

Setting Severity Thresholds That Make Sense

A common mistake is setting the threshold too aggressively on day one. If you block on everything, developers will ignore the scanner or add blanket exceptions. Start practical and tighten over time:

# Week 1-2: Block only CRITICAL, report HIGH
trivy filesystem --severity CRITICAL --exit-code 1 .
trivy filesystem --severity HIGH,CRITICAL --exit-code 0 .

# Week 3-4: Block HIGH and CRITICAL
trivy filesystem --severity HIGH,CRITICAL --exit-code 1 .

# Ongoing: Track MEDIUM for awareness
trivy filesystem --severity MEDIUM,HIGH,CRITICAL --exit-code 0 --format json > full-report.json

Continuous Monitoring: Don't Just Scan at Build Time

New CVEs are published daily. A container image that was clean when you built it last Tuesday might have three critical vulnerabilities by Friday. Scanning only at build time creates a window of exposure between builds.

Scheduled Rescans

# .github/workflows/scheduled-scan.yml
name: Scheduled Vulnerability Scan
on:
  schedule:
    - cron: '0 6 * * *'  # Daily at 6 AM UTC

jobs:
  scan-deployed-images:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        image:
          - ghcr.io/your-org/api-service:production
          - ghcr.io/your-org/web-frontend:production
          - ghcr.io/your-org/worker:production
    steps:
      - name: Scan ${{ matrix.image }}
        uses: aquasecurity/trivy-action@0.28.0
        with:
          scan-type: image
          image-ref: ${{ matrix.image }}
          severity: HIGH,CRITICAL
          ignore-unfixed: true
          format: json
          output: scan-result.json

      - name: Notify on new findings
        if: failure()
        uses: slackapi/slack-github-action@v2.1.0
        with:
          webhook: ${{ secrets.SECURITY_SLACK_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "text": "New vulnerabilities found in ${{ matrix.image }}. Check the scan results."
            }

Generating an SBOM for Every Release

Every production artifact should have a corresponding Software Bill of Materials. When the next Log4Shell drops, you need to answer "are we affected?" in minutes, not days.

  generate-sbom:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Generate SBOM
        uses: anchore/sbom-action@v0
        with:
          image: ghcr.io/${{ github.repository }}:${{ github.sha }}
          format: spdx-json
          output-file: sbom.spdx.json

      - name: Attach SBOM to release
        uses: softprops/action-gh-release@v2
        if: startsWith(github.ref, 'refs/tags/')
        with:
          files: sbom.spdx.json

      - name: Store SBOM as artifact
        uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.spdx.json
          retention-days: 365

The Full Pipeline: Putting It Together

Here's what a complete dependency security stage looks like in a CI pipeline:

  security:
    runs-on: ubuntu-latest
    needs: build
    steps:
      # 1. Scan source dependencies
      - uses: actions/checkout@v4
      - name: Scan filesystem dependencies
        run: trivy filesystem --severity HIGH,CRITICAL --exit-code 1 --ignorefile .trivyignore.yaml .

      # 2. Scan the built container image
      - name: Scan container image
        run: trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed $IMAGE_REF

      # 3. Generate SBOM for the release artifact
      - name: Generate SBOM
        run: syft $IMAGE_REF -o spdx-json > sbom.spdx.json

      # 4. Scan SBOM with a second scanner for coverage
      - name: Cross-check with Grype
        run: grype sbom:sbom.spdx.json --fail-on critical

      # 5. Upload results
      - name: Upload scan results
        if: always()
        run: trivy image --format sarif -o results.sarif $IMAGE_REF

Two scanners, two databases, one SBOM. If both agree a vulnerability exists, you can be confident it's real. If one catches something the other missed, you've gotten value from the overlap.

Stop Negotiating With Known Vulnerabilities

Every day you ship an application with a known critical CVE, you're accepting a risk that someone else has already quantified and published a fix for. The vulnerability database is a gift — it tells you exactly what's wrong and how to fix it before an attacker uses it.

Dependency scanning isn't a nice-to-have. It's the baseline. Get it into your pipeline this week. Start with CRITICAL only, expand to HIGH, build the exception process, and run scheduled rescans. The window between CVE publication and exploitation is shrinking. In 2023, CISA reported that 42% of exploited vulnerabilities were weaponized within 48 hours of disclosure.

Your CI pipeline already gates on test failures. Start gating on known vulnerabilities. The cost of scanning is minutes of build time. The cost of not scanning is an incident response at 2 AM.

Share:
Amara Okafor
Amara Okafor

DevSecOps Lead

Security-first mindset in everything I ship. From zero-trust architectures to supply chain security, I make sure your pipeline doesn't become your weakest link.

Related Articles