Back to Blog
CI/CDDevSecOpsautomated scanningGitHub ActionsGitLab CIsecurity pipelineshift left

How to Set Up Automated Security Scanning in CI/CD Pipelines

Step-by-step guide to integrating vulnerability scanning into GitHub Actions, GitLab CI, and Jenkins pipelines. Covers container scanning, dependency checks, SAST, secrets detection, and policy gates.

Vulnios TeamMarch 11, 20265 min read

Your pipeline builds code, runs tests, and deploys to production in 8 minutes. But does it check for vulnerabilities before deploying?

If security scanning isn't in your CI/CD pipeline, vulnerabilities ship to production on every merge. Here's how to fix that — with real pipeline configs you can copy today.

What to Scan in CI/CD

A comprehensive security pipeline covers five areas:

| Scan Type | What It Catches | When to Run |

|-----------|----------------|-------------|

| Dependency scanning | Known CVEs in packages | Every PR |

| Container image scanning | OS + app vulns in images | After docker build |

| SAST (Static Analysis) | Code-level vulnerabilities | Every PR |

| Secrets detection | API keys, passwords in code | Every commit |

| IaC scanning | Cloud misconfigurations | Every PR |

GitHub Actions: Complete Pipeline

Here's a production-ready security pipeline for GitHub Actions:

Step 1: Dependency Scanning

name: Security Scan

on: [pull_request]

jobs:

dependency-scan:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- name: Scan dependencies with Trivy

uses: aquasecurity/trivy-action@master

with:

scan-type: fs

scan-ref: .

severity: CRITICAL,HIGH

exit-code: 1

This scans package-lock.json, go.sum, requirements.txt, Gemfile.lock, and any other lockfile for known CVEs.

Step 2: Container Image Scanning

  container-scan:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- name: Build image

run: docker build -t myapp:${{ github.sha }} .

- name: Scan container image

uses: aquasecurity/trivy-action@master

with:

image-ref: myapp:${{ github.sha }}

severity: CRITICAL,HIGH

exit-code: 1

format: table

Step 3: Secrets Detection

  secrets-scan:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

with:

fetch-depth: 0 # Full history for secret scanning

- name: Scan for secrets

uses: gitleaks/gitleaks-action@v2

env:

GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Step 4: SAST (Static Analysis)

  sast-scan:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- name: Run Semgrep SAST

uses: returntocorp/semgrep-action@v1

with:

config: auto

Full Pipeline (Combined)

name: Security Pipeline

on: [pull_request]

jobs:

security:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

with:

fetch-depth: 0

- name: Dependency scan

uses: aquasecurity/trivy-action@master

with:

scan-type: fs

scan-ref: .

severity: CRITICAL,HIGH

exit-code: 1

- name: Secrets scan

uses: gitleaks/gitleaks-action@v2

env:

GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: SAST scan

uses: returntocorp/semgrep-action@v1

with:

config: auto

- name: Build and scan container

run: |

docker build -t myapp:${{ github.sha }} .

- name: Container scan

uses: aquasecurity/trivy-action@master

with:

image-ref: myapp:${{ github.sha }}

severity: CRITICAL,HIGH

exit-code: 1

GitLab CI Configuration

stages:

- test

- security

- build

- deploy

dependency-scan:

stage: security

image: aquasec/trivy:latest

script:

- trivy fs --severity CRITICAL,HIGH --exit-code 1 .

allow_failure: false

container-scan:

stage: security

needs: [build]

image: aquasec/trivy:latest

script:

- trivy image --severity CRITICAL,HIGH --exit-code 1 $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

secrets-scan:

stage: security

image: zricethezav/gitleaks:latest

script:

- gitleaks detect --source . --verbose

allow_failure: false

sast:

stage: security

image: returntocorp/semgrep

script:

- semgrep --config auto .

Setting Up Policy Gates

Not every vulnerability should block deployment. Configure policies based on real risk:

Gate 1: Block on Critical + Exploitable

# Only block on critical findings with EPSS > 10%
  • name: Policy gate
  • run: |

    CRITICAL_COUNT=$(trivy image --format json $IMAGE | jq '[.Results[].Vulnerabilities[] | select(.Severity=="CRITICAL")] | length')

    if [ "$CRITICAL_COUNT" -gt 0 ]; then

    echo "::error::$CRITICAL_COUNT critical vulnerabilities found"

    exit 1

    fi

    Gate 2: Warn on High, Block on Critical

    Allow high-severity findings to pass but flag them for review:

    - name: Scan (warn on high)
    

    uses: aquasecurity/trivy-action@master

    with:

    severity: CRITICAL

    exit-code: 1 # Only block on critical

  • name: Report high findings
  • uses: aquasecurity/trivy-action@master

    with:

    severity: HIGH

    exit-code: 0 # Don't block, just report

    Gate 3: Baseline Comparison

    Only fail on NEW vulnerabilities (not pre-existing ones):

    # Generate baseline on main branch
    

    trivy image --format json main-image:latest > baseline.json

    # Compare PR image against baseline

    trivy image --format json pr-image:$SHA > current.json

    # Diff — only new findings trigger failure

    diff <(jq -r '.Results[].Vulnerabilities[].VulnerabilityID' baseline.json | sort) \

    <(jq -r '.Results[].Vulnerabilities[].VulnerabilityID' current.json | sort) \

    | grep "^>" && exit 1 || echo "No new vulnerabilities"

    Handling False Positives

    Security scan noise kills adoption. Handle false positives properly:

    Trivy Ignore File

    # .trivyignore
    

    CVE-2024-12345 # False positive: not reachable in our code path

    CVE-2024-67890 # Accepted risk: mitigated by WAF

    Semgrep Inline Suppression

    # nosemgrep: python.lang.security.deserialization.avoid-pickle
    

    data = pickle.loads(trusted_internal_data)

    Document Everything

    Every suppressed finding should have:

  • A reason (false positive, accepted risk, mitigated elsewhere)
  • A reviewer (who approved the suppression)
  • A review date (when to re-evaluate)
  • Performance Tips

    Security scanning shouldn't double your pipeline time:

  • Cache vulnerability databases — Trivy downloads its database on every run unless cached
  • Run scans in parallel — Dependency, SAST, and secrets scans are independent
  • Scan incremental changes — Don't re-scan unchanged code
  • Use slim scan modestrivy --skip-db-update with a cached DB saves  30+ seconds
  • # Cache Trivy DB
    
  • name: Cache Trivy DB
  • uses: actions/cache@v4

    with:

    path: ~/.cache/trivy

    key: trivy-db-${{ hashFiles('.trivyignore') }}

    Centralizing Results with Vulnios

    Running individual scanners in CI/CD works, but results live in pipeline logs — scattered, ephemeral, and hard to track over time.

    Vulnios centralizes everything:

  • Multi-engine scanning — 48 engines, not just one
  • Historical tracking — See vulnerability trends across releases
  • EPSS prioritization — Focus on exploitable findings, not just severity
  • Unified dashboard — One view for all repositories, all services
  • Compliance reports — Auto-generated evidence for auditors
  • ---

    Add multi-engine scanning to your pipeline at vulnios.com. 48 engines, EPSS prioritization, compliance reports — in one API call.

    Ready to secure your organization?

    Start scanning with 32 security engines — free tier available.

    Get Started Free