Security isn’t something you bolt on after development—it’s something you bake into every commit. This guide covers how to implement comprehensive security scanning in your CI/CD pipeline without slowing down deployments.

The Security Scanning Landscape

Modern application security requires multiple layers:

TypeWhat It ScansWhenCatches
SASTSource codeBuild timeSQL injection, XSS, hardcoded secrets
SCADependenciesBuild timeVulnerable libraries
ContainerDocker imagesBuild timeOS vulnerabilities, misconfigurations
DASTRunning appPost-deployRuntime vulnerabilities, auth issues
SecretsCode/configPre-commitAPI keys, passwords in code

Secret Detection (Pre-Commit)

Catch secrets before they enter the repository:

Gitleaks

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.1
    hooks:
      - id: gitleaks
# .gitleaks.toml
[extend]
useDefault = true

[[rules]]
id = "custom-api-key"
description = "Custom API Key Pattern"
regex = '''(?i)mycompany[_-]?api[_-]?key\s*[:=]\s*['"]?([a-zA-Z0-9]{32,})['"]?'''
secretGroup = 1

[allowlist]
paths = [
  '''\.test\.js$''',
  '''fixtures/''',
  '''__mocks__/'''
]

TruffleHog

# GitHub Actions
secret-scan:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0  # Full history for scanning
    
    - name: TruffleHog Scan
      uses: trufflesecurity/trufflehog@main
      with:
        path: ./
        base: ${{ github.event.pull_request.base.sha }}
        head: ${{ github.event.pull_request.head.sha }}
        extra_args: --only-verified

SAST (Static Application Security Testing)

Analyze source code for vulnerabilities:

Semgrep

Semgrep is fast, customizable, and has excellent language support:

# .github/workflows/sast.yml
name: SAST

on: [push, pull_request]

jobs:
  semgrep:
    runs-on: ubuntu-latest
    container:
      image: semgrep/semgrep
    steps:
      - uses: actions/checkout@v4
      
      - name: Run Semgrep
        run: semgrep ci
        env:
          SEMGREP_RULES: >-
            p/default
            p/owasp-top-ten
            p/r2c-security-audit
            p/javascript
            p/typescript
            p/python
# .semgrep.yml - Custom rules
rules:
  - id: no-exec-user-input
    pattern: exec($USER_INPUT)
    message: "Potential command injection - user input passed to exec()"
    languages: [javascript, typescript]
    severity: ERROR
    metadata:
      cwe: "CWE-78"
      owasp: "A03:2021 - Injection"

  - id: no-innerhtml-user-input
    pattern: $EL.innerHTML = $USER_INPUT
    message: "Potential XSS - user input assigned to innerHTML"
    languages: [javascript, typescript]
    severity: ERROR

  - id: sql-injection-python
    patterns:
      - pattern: |
          cursor.execute($QUERY % ...)
      - pattern: |
          cursor.execute($QUERY.format(...))
      - pattern: |
          cursor.execute(f"...")
    message: "SQL injection risk - use parameterized queries"
    languages: [python]
    severity: ERROR

CodeQL (GitHub Native)

# .github/workflows/codeql.yml
name: CodeQL

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'  # Weekly deep scan

jobs:
  analyze:
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      
    strategy:
      matrix:
        language: [javascript, python]
        
    steps:
      - uses: actions/checkout@v4
      
      - name: Initialize CodeQL
        uses: github/codeql-action/init@v3
        with:
          languages: ${{ matrix.language }}
          queries: +security-and-quality
          
      - name: Autobuild
        uses: github/codeql-action/autobuild@v3
        
      - name: Perform CodeQL Analysis
        uses: github/codeql-action/analyze@v3
        with:
          category: "/language:${{ matrix.language }}"

SonarQube

For enterprise environments:

# GitLab CI
sonarqube:
  stage: security
  image: sonarsource/sonar-scanner-cli:latest
  variables:
    SONAR_USER_HOME: "${CI_PROJECT_DIR}/.sonar"
    GIT_DEPTH: 0
  cache:
    key: "${CI_JOB_NAME}"
    paths:
      - .sonar/cache
  script:
    - sonar-scanner
      -Dsonar.projectKey=${CI_PROJECT_NAME}
      -Dsonar.sources=src
      -Dsonar.host.url=${SONAR_HOST_URL}
      -Dsonar.token=${SONAR_TOKEN}
      -Dsonar.qualitygate.wait=true
# sonar-project.properties
sonar.projectKey=my-project
sonar.sources=src
sonar.tests=tests
sonar.exclusions=**/node_modules/**,**/dist/**
sonar.test.inclusions=**/*.test.js,**/*.spec.js
sonar.javascript.lcov.reportPaths=coverage/lcov.info

Dependency Scanning (SCA)

Snyk

# GitHub Actions
dependency-scan:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    
    - name: Run Snyk
      uses: snyk/actions/node@master
      env:
        SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
      with:
        args: --severity-threshold=high --fail-on=upgradable
        
    - name: Upload results to GitHub
      uses: github/codeql-action/upload-sarif@v3
      with:
        sarif_file: snyk.sarif

OWASP Dependency-Check

# GitLab CI
dependency-check:
  stage: security
  image: owasp/dependency-check:latest
  script:
    - /usr/share/dependency-check/bin/dependency-check.sh
      --project "$CI_PROJECT_NAME"
      --scan .
      --format HTML
      --format JSON
      --out reports/
      --failOnCVSS 7
  artifacts:
    paths:
      - reports/
    when: always

npm/yarn Audit

npm-audit:
  stage: security
  script:
    - npm audit --audit-level=high
    - npm audit --json > npm-audit.json || true
  artifacts:
    paths:
      - npm-audit.json
  allow_failure: true

Trivy for Filesystems

trivy-fs:
  stage: security
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  script:
    - trivy fs --exit-code 1 --severity HIGH,CRITICAL .

Container Security Scanning

Trivy

# GitHub Actions
container-scan:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    
    - name: Build image
      run: docker build -t myapp:${{ github.sha }} .
    
    - name: Scan image
      uses: aquasecurity/trivy-action@master
      with:
        image-ref: myapp:${{ github.sha }}
        format: 'sarif'
        output: 'trivy-results.sarif'
        severity: 'CRITICAL,HIGH'
        exit-code: '1'
        
    - name: Upload Trivy scan results
      uses: github/codeql-action/upload-sarif@v3
      with:
        sarif_file: 'trivy-results.sarif'

Grype

grype-scan:
  runs-on: ubuntu-latest
  steps:
    - name: Scan image
      uses: anchore/scan-action@v3
      with:
        image: myapp:latest
        fail-build: true
        severity-cutoff: high
        output-format: sarif
        
    - name: Upload SARIF
      uses: github/codeql-action/upload-sarif@v3
      with:
        sarif_file: results.sarif

Docker Scout

# Native Docker scanning
docker-scout:
  stage: security
  image: docker:latest
  services:
    - docker:dind
  script:
    - docker login -u $DOCKER_USER -p $DOCKER_TOKEN
    - docker build -t myapp:$CI_COMMIT_SHA .
    - docker scout cves myapp:$CI_COMMIT_SHA --exit-code --only-severity critical,high

DAST (Dynamic Application Security Testing)

OWASP ZAP

# GitHub Actions
dast:
  runs-on: ubuntu-latest
  needs: [deploy-staging]
  steps:
    - name: ZAP Baseline Scan
      uses: zaproxy/[email protected]
      with:
        target: 'https://staging.example.com'
        rules_file_name: '.zap/rules.tsv'
        
    - name: Upload Report
      uses: actions/upload-artifact@v4
      with:
        name: zap-report
        path: report_html.html
# Full ZAP scan for scheduled runs
dast-full:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    
    - name: ZAP Full Scan
      uses: zaproxy/[email protected]
      with:
        target: 'https://staging.example.com'
        docker_name: 'ghcr.io/zaproxy/zaproxy:stable'
        cmd_options: '-a -j -l WARN -z "-config api.disablekey=true"'
# .zap/rules.tsv - Customize alert thresholds
10015	IGNORE	(Incomplete or No Cache-control and Pragma HTTP Header Set)
10020	WARN	(X-Frame-Options Header Not Set)
10021	FAIL	(X-Content-Type-Options Header Missing)
10038	FAIL	(Content Security Policy Header Not Set)
40012	FAIL	(Cross Site Scripting (Reflected))
40014	FAIL	(Cross Site Scripting (Persistent))

Nuclei

nuclei-scan:
  runs-on: ubuntu-latest
  steps:
    - name: Nuclei Scan
      uses: projectdiscovery/nuclei-action@main
      with:
        target: https://staging.example.com
        templates: cves,vulnerabilities,misconfiguration
        output: nuclei-results.txt
        sarif-export: nuclei-results.sarif
        
    - name: Upload SARIF
      uses: github/codeql-action/upload-sarif@v3
      with:
        sarif_file: nuclei-results.sarif

Complete Pipeline Example

# .github/workflows/security.yml
name: Security Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 6 * * 1'  # Weekly comprehensive scan

env:
  IMAGE_NAME: myapp

jobs:
  # Pre-commit style checks
  secrets:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - name: Gitleaks
        uses: gitleaks/gitleaks-action@v2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

  # Static analysis
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Semgrep
        uses: semgrep/semgrep-action@v1
        with:
          config: >-
            p/default
            p/security-audit
            p/owasp-top-ten
        env:
          SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}

  # Dependency vulnerabilities
  dependencies:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Node
        uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - name: Snyk
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

  # Container scanning
  container:
    runs-on: ubuntu-latest
    needs: [sast, dependencies]
    steps:
      - uses: actions/checkout@v4
      
      - name: Build image
        run: docker build -t $IMAGE_NAME:${{ github.sha }} .
      
      - name: Trivy vulnerability scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: ${{ env.IMAGE_NAME }}:${{ github.sha }}
          format: 'sarif'
          output: 'trivy.sarif'
          severity: 'CRITICAL,HIGH'
          
      - name: Upload Trivy results
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: 'trivy.sarif'
          
      - name: Push image
        if: github.ref == 'refs/heads/main'
        run: |
          docker tag $IMAGE_NAME:${{ github.sha }} $REGISTRY/$IMAGE_NAME:${{ github.sha }}
          docker push $REGISTRY/$IMAGE_NAME:${{ github.sha }}

  # Deploy to staging
  deploy-staging:
    runs-on: ubuntu-latest
    needs: [container]
    if: github.ref == 'refs/heads/main'
    environment: staging
    steps:
      - name: Deploy
        run: |
          kubectl set image deployment/myapp myapp=$REGISTRY/$IMAGE_NAME:${{ github.sha }}
          kubectl rollout status deployment/myapp

  # Dynamic testing
  dast:
    runs-on: ubuntu-latest
    needs: [deploy-staging]
    steps:
      - uses: actions/checkout@v4
      
      - name: ZAP Scan
        uses: zaproxy/[email protected]
        with:
          target: 'https://staging.example.com'
          
      - name: Upload Report
        uses: actions/upload-artifact@v4
        with:
          name: zap-report
          path: report_html.html

  # Gate for production
  security-gate:
    runs-on: ubuntu-latest
    needs: [secrets, sast, dependencies, container, dast]
    steps:
      - name: Security Gate Passed
        run: echo "All security checks passed!"

Handling False Positives

Semgrep Ignores

// nosemgrep: javascript.express.security.injection.tainted-sql-string
const query = `SELECT * FROM users WHERE id = ${userId}`;

// Or inline
const result = exec(userInput); // nosemgrep

Trivy Ignore File

# .trivyignore.yaml
vulnerabilities:
  - id: CVE-2023-12345
    statement: "Not exploitable in our context - we don't use affected feature"
    expires: 2024-06-01
  
  - id: CVE-2023-67890
    statement: "Waiting for upstream fix"
    expires: 2024-03-15

Snyk Ignore

# Ignore for 30 days with reason
snyk ignore --id=SNYK-JS-LODASH-1234567 \
  --expiry=2024-03-01 \
  --reason="No upgrade path available, not using vulnerable function"

Metrics and Reporting

Security Dashboard

# Generate consolidated report
security-report:
  runs-on: ubuntu-latest
  needs: [sast, dependencies, container]
  steps:
    - name: Download all artifacts
      uses: actions/download-artifact@v4
      
    - name: Generate report
      run: |
        python scripts/consolidate-security-report.py \
          --semgrep semgrep-results.json \
          --snyk snyk-results.json \
          --trivy trivy-results.json \
          --output security-report.html
          
    - name: Post to Slack
      uses: slack/action@v1
      with:
        payload-file: security-summary.json
// scripts/track-metrics.js
const metrics = {
  timestamp: new Date().toISOString(),
  commit: process.env.GITHUB_SHA,
  vulnerabilities: {
    critical: parsedResults.filter(v => v.severity === 'CRITICAL').length,
    high: parsedResults.filter(v => v.severity === 'HIGH').length,
    medium: parsedResults.filter(v => v.severity === 'MEDIUM').length,
  },
  newVulnerabilities: calculateDelta(previousRun, parsedResults),
  meanTimeToRemediate: calculateMTTR(resolvedVulnerabilities),
};

// Send to your metrics backend
await fetch(process.env.METRICS_ENDPOINT, {
  method: 'POST',
  body: JSON.stringify(metrics),
});

When NOT to Block

Not every finding should block deployment:

# Allow informational/low severity
sast:
  allow_failure: false
  script:
    - semgrep ci --severity=ERROR  # Only fail on ERROR

# Don't block on DAST (too many false positives)
dast:
  allow_failure: true
  
# Block on criticals, warn on high
container-scan:
  script:
    - trivy image --exit-code 1 --severity CRITICAL myapp:latest
    - trivy image --exit-code 0 --severity HIGH myapp:latest

Key Takeaways

  1. Layer your scanning—no single tool catches everything
  2. Shift left—catch secrets before commit, SAST before merge
  3. Automate everything—manual security reviews don’t scale
  4. Manage false positives—document suppressions with expiration dates
  5. Don’t block on everything—informational findings shouldn’t stop deploys
  6. Track metrics—measure vulnerability trends over time
  7. Schedule deep scans—daily quick scans, weekly comprehensive ones

Security scanning isn’t about achieving zero vulnerabilities—it’s about systematically reducing risk while maintaining development velocity.