Skip to main content

Shifting Security Left in CI/CD Pipelines

Ryan Dahlberg
Ryan Dahlberg
September 5, 2025 12 min read
Share:
Shifting Security Left in CI/CD Pipelines

Introduction

Shifting Security Left moves security practices earlier in the software development lifecycle, integrating security checks into CI/CD pipelines rather than waiting until after deployment. This proactive approach catches vulnerabilities when they’re cheapest to fix and enables faster, more secure releases.

This comprehensive guide explores implementing DevSecOps practices in CI/CD pipelines, covering static and dynamic analysis, dependency scanning, container security, infrastructure as code security, and automated security gates.

The Shift-Left Security Pipeline

Complete CI/CD Security Integration

# .github/workflows/devsecops.yml
name: DevSecOps Pipeline

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      # Secret scanning
      - name: TruffleHog Secret Scan
        uses: trufflesecurity/trufflehog@main
        with:
          path: ./
          base: ${{ github.event.repository.default_branch }}
          head: HEAD

      # Static Application Security Testing (SAST)
      - name: Run Semgrep
        uses: returntocorp/semgrep-action@v1
        with:
          config: >-
            p/security-audit
            p/secrets
            p/owasp-top-ten

      # Dependency scanning
      - name: Run Snyk
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

      # Code quality and security
      - name: SonarCloud Scan
        uses: SonarSource/sonarcloud-github-action@master
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

  container-security:
    runs-on: ubuntu-latest
    needs: security-scan
    steps:
      - uses: actions/checkout@v3

      - name: Build Docker image
        run: docker build -t myapp:${{ github.sha }} .

      # Container vulnerability scanning
      - name: Run Trivy scanner
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:${{ github.sha }}
          format: 'sarif'
          output: 'trivy-results.sarif'
          severity: 'CRITICAL,HIGH'

      - name: Upload Trivy results
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: 'trivy-results.sarif'

  iac-security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      # Infrastructure as Code scanning
      - name: Run tfsec
        uses: aquasecurity/tfsec-action@v1.0.0
        with:
          soft_fail: false

      - name: Run Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: ./terraform
          framework: terraform

  sca-analysis:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      # Software Composition Analysis
      - name: OWASP Dependency-Check
        uses: dependency-check/Dependency-Check_Action@main
        with:
          project: 'myapp'
          path: '.'
          format: 'HTML'

      - name: Upload results
        uses: actions/upload-artifact@v3
        with:
          name: dependency-check-report
          path: ${{ github.workspace }}/reports

  build-and-test:
    runs-on: ubuntu-latest
    needs: [security-scan, container-security, iac-security]
    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: '18'

      - name: Install dependencies
        run: npm ci

      - name: Run tests
        run: npm test

      - name: Build application
        run: npm run build

      # DAST after deployment to staging
  dast-scan:
    runs-on: ubuntu-latest
    needs: build-and-test
    steps:
      - name: ZAP Scan
        uses: zaproxy/action-full-scan@v0.4.0
        with:
          target: 'https://staging.example.com'
          rules_file_name: '.zap/rules.tsv'
          cmd_options: '-a'

Static Application Security Testing (SAST)

Implementing SAST Tools

// ESLint Security Plugin Configuration
// .eslintrc.js
module.exports = {
  extends: [
    'eslint:recommended',
    'plugin:security/recommended'
  ],
  plugins: ['security'],
  rules: {
    'security/detect-object-injection': 'error',
    'security/detect-non-literal-regexp': 'error',
    'security/detect-unsafe-regex': 'error',
    'security/detect-buffer-noassert': 'error',
    'security/detect-child-process': 'error',
    'security/detect-disable-mustache-escape': 'error',
    'security/detect-eval-with-expression': 'error',
    'security/detect-no-csrf-before-method-override': 'error',
    'security/detect-non-literal-fs-filename': 'error',
    'security/detect-non-literal-require': 'error',
    'security/detect-possible-timing-attacks': 'error',
    'security/detect-pseudoRandomBytes': 'error'
  }
};

// Custom security linting script
class SecurityLinter {
  constructor() {
    this.issues = [];
  }

  async scanProject(directory) {
    const { ESLint } = require('eslint');

    const eslint = new ESLint({
      overrideConfigFile: './.eslintrc.js',
      extensions: ['.js', '.ts']
    });

    const results = await eslint.lintFiles([directory]);

    // Filter security issues
    const securityIssues = results.flatMap(result =>
      result.messages
        .filter(msg => msg.ruleId && msg.ruleId.startsWith('security/'))
        .map(msg => ({
          file: result.filePath,
          line: msg.line,
          column: msg.column,
          severity: msg.severity === 2 ? 'error' : 'warning',
          rule: msg.ruleId,
          message: msg.message
        }))
    );

    return {
      totalIssues: securityIssues.length,
      issues: securityIssues,
      errors: securityIssues.filter(i => i.severity === 'error').length,
      warnings: securityIssues.filter(i => i.severity === 'warning').length
    };
  }

  async generateReport(scanResults) {
    const report = {
      timestamp: new Date().toISOString(),
      summary: {
        filesScanned: scanResults.filesScanned,
        totalIssues: scanResults.totalIssues,
        errors: scanResults.errors,
        warnings: scanResults.warnings
      },
      issues: scanResults.issues,
      recommendations: this.generateRecommendations(scanResults)
    };

    return report;
  }

  generateRecommendations(results) {
    const recommendations = [];

    const ruleCount = results.issues.reduce((acc, issue) => {
      acc[issue.rule] = (acc[issue.rule] || 0) + 1;
      return acc;
    }, {});

    const topIssues = Object.entries(ruleCount)
      .sort(([, a], [, b]) => b - a)
      .slice(0, 5);

    for (const [rule, count] of topIssues) {
      recommendations.push({
        rule,
        occurrences: count,
        priority: 'high',
        action: this.getRecommendation(rule)
      });
    }

    return recommendations;
  }

  getRecommendation(rule) {
    const recommendations = {
      'security/detect-object-injection': 'Use allowlist validation for object property access',
      'security/detect-non-literal-regexp': 'Use string literals for RegExp constructors',
      'security/detect-eval-with-expression': 'Replace eval() with safer alternatives',
      'security/detect-child-process': 'Validate and sanitize all inputs to child processes',
      'security/detect-possible-timing-attacks': 'Use constant-time comparison for sensitive data'
    };

    return recommendations[rule] || 'Review and remediate this security issue';
  }
}

// Run security scan
async function runSecurityScan() {
  const linter = new SecurityLinter();
  const results = await linter.scanProject('./src');
  const report = await linter.generateReport(results);

  console.log('Security Scan Results:');
  console.log(`Total Issues: ${report.summary.totalIssues}`);
  console.log(`Errors: ${report.summary.errors}`);
  console.log(`Warnings: ${report.summary.warnings}`);

  // Fail build if errors found
  if (report.summary.errors > 0) {
    console.error('Security errors detected. Build failed.');
    process.exit(1);
  }

  return report;
}

Dependency Vulnerability Scanning

Automated Dependency Management

// dependency-check.js
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);

class DependencyScanner {
  async scanDependencies() {
    const results = {
      npm: await this.scanNpm(),
      snyk: await this.scanSnyk(),
      outdated: await this.checkOutdated()
    };

    return this.aggregateResults(results);
  }

  async scanNpm() {
    try {
      const { stdout } = await execAsync('npm audit --json');
      const audit = JSON.parse(stdout);

      return {
        vulnerabilities: audit.metadata.vulnerabilities,
        totalVulnerabilities: Object.values(audit.metadata.vulnerabilities)
          .reduce((sum, count) => sum + count, 0)
      };
    } catch (error) {
      // npm audit exits with non-zero if vulnerabilities found
      if (error.stdout) {
        const audit = JSON.parse(error.stdout);
        return {
          vulnerabilities: audit.metadata.vulnerabilities,
          totalVulnerabilities: Object.values(audit.metadata.vulnerabilities)
            .reduce((sum, count) => sum + count, 0)
        };
      }
      throw error;
    }
  }

  async scanSnyk() {
    try {
      const { stdout } = await execAsync('snyk test --json');
      const results = JSON.parse(stdout);

      return {
        vulnerabilities: results.vulnerabilities.length,
        critical: results.vulnerabilities.filter(v => v.severity === 'critical').length,
        high: results.vulnerabilities.filter(v => v.severity === 'high').length,
        medium: results.vulnerabilities.filter(v => v.severity === 'medium').length,
        low: results.vulnerabilities.filter(v => v.severity === 'low').length
      };
    } catch (error) {
      console.error('Snyk scan failed:', error.message);
      return null;
    }
  }

  async checkOutdated() {
    try {
      const { stdout } = await execAsync('npm outdated --json');
      const outdated = JSON.parse(stdout);

      return {
        total: Object.keys(outdated).length,
        packages: Object.entries(outdated).map(([name, info]) => ({
          name,
          current: info.current,
          wanted: info.wanted,
          latest: info.latest,
          type: info.type
        }))
      };
    } catch (error) {
      return { total: 0, packages: [] };
    }
  }

  aggregateResults(results) {
    const criticalThreshold = 0;
    const highThreshold = 5;

    const report = {
      timestamp: new Date().toISOString(),
      npm: results.npm,
      snyk: results.snyk,
      outdated: results.outdated,
      passed: true,
      recommendations: []
    };

    // Check thresholds
    if (results.snyk && results.snyk.critical > criticalThreshold) {
      report.passed = false;
      report.recommendations.push({
        severity: 'critical',
        message: `${results.snyk.critical} critical vulnerabilities found. Immediate action required.`
      });
    }

    if (results.snyk && results.snyk.high > highThreshold) {
      report.passed = false;
      report.recommendations.push({
        severity: 'high',
        message: `${results.snyk.high} high-severity vulnerabilities found.`
      });
    }

    if (results.outdated.total > 20) {
      report.recommendations.push({
        severity: 'medium',
        message: `${results.outdated.total} outdated dependencies. Consider updating.`
      });
    }

    return report;
  }

  async generateSBOM() {
    // Generate Software Bill of Materials
    const { stdout } = await execAsync('npm list --json');
    const dependencies = JSON.parse(stdout);

    const sbom = {
      bomFormat: 'CycloneDX',
      specVersion: '1.4',
      version: 1,
      metadata: {
        timestamp: new Date().toISOString(),
        tools: [{ name: 'npm', version: '9.0.0' }]
      },
      components: this.extractComponents(dependencies.dependencies)
    };

    return sbom;
  }

  extractComponents(dependencies, components = []) {
    for (const [name, info] of Object.entries(dependencies || {})) {
      components.push({
        type: 'library',
        name,
        version: info.version,
        purl: `pkg:npm/${name}@${info.version}`
      });

      if (info.dependencies) {
        this.extractComponents(info.dependencies, components);
      }
    }

    return components;
  }
}

// Usage in CI pipeline
async function checkDependencies() {
  const scanner = new DependencyScanner();
  const report = await scanner.scanDependencies();

  console.log('Dependency Scan Results:');
  console.log(JSON.stringify(report, null, 2));

  if (!report.passed) {
    console.error('Dependency scan failed. Critical vulnerabilities detected.');
    process.exit(1);
  }

  // Generate SBOM
  const sbom = await scanner.generateSBOM();
  require('fs').writeFileSync('sbom.json', JSON.stringify(sbom, null, 2));

  return report;
}

Container Security Scanning

Docker Image Security

#!/bin/bash
# container-security-scan.sh

set -e

IMAGE_NAME=$1
IMAGE_TAG=$2

echo "Starting container security scan for ${IMAGE_NAME}:${IMAGE_TAG}"

# Trivy vulnerability scan
echo "Running Trivy scan..."
trivy image \
  --severity CRITICAL,HIGH \
  --exit-code 1 \
  --no-progress \
  "${IMAGE_NAME}:${IMAGE_TAG}"

# Hadolint - Dockerfile linting
echo "Running Hadolint..."
docker run --rm -i hadolint/hadolint < Dockerfile

# Dockle - Container image linter
echo "Running Dockle..."
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  goodwithtech/dockle:latest \
  --exit-code 1 \
  --exit-level warn \
  "${IMAGE_NAME}:${IMAGE_TAG}"

# Grype - Vulnerability scanner
echo "Running Grype..."
grype "${IMAGE_NAME}:${IMAGE_TAG}" \
  --fail-on high \
  --only-fixed

echo "Container security scan completed successfully"
# Secure Dockerfile practices
FROM node:18-alpine AS base

# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nodejs -u 1001

# Install security updates
RUN apk update && \
    apk upgrade && \
    apk add --no-cache dumb-init && \
    rm -rf /var/cache/apk/*

WORKDIR /app

# Copy package files
COPY --chown=nodejs:nodejs package*.json ./

# Install dependencies
FROM base AS dependencies
RUN npm ci --only=production && \
    npm cache clean --force

# Build stage
FROM base AS build
COPY --chown=nodejs:nodejs . .
RUN npm ci && \
    npm run build && \
    npm prune --production

# Production stage
FROM base AS production

# Copy built assets and dependencies
COPY --from=build --chown=nodejs:nodejs /app/dist ./dist
COPY --from=dependencies --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs package*.json ./

# Switch to non-root user
USER nodejs

# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]

# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD node healthcheck.js || exit 1

# Start application
CMD ["node", "dist/index.js"]

# Security labels
LABEL security.scan="true"
LABEL security.scanner="trivy"

Security Quality Gates

Automated Security Gates

class SecurityGate {
  constructor(config) {
    this.config = config;
    this.results = {};
  }

  async evaluate() {
    console.log('Evaluating security gates...');

    const gates = [
      this.checkVulnerabilities(),
      this.checkCodeQuality(),
      this.checkSecrets(),
      this.checkCompliance(),
      this.checkContainerSecurity()
    ];

    this.results = await Promise.all(gates);

    return this.aggregateResults();
  }

  async checkVulnerabilities() {
    const scanner = new DependencyScanner();
    const report = await scanner.scanDependencies();

    return {
      gate: 'vulnerabilities',
      passed: report.passed,
      score: this.calculateVulnerabilityScore(report),
      details: report
    };
  }

  async checkCodeQuality() {
    const linter = new SecurityLinter();
    const results = await linter.scanProject('./src');

    const passed = results.errors === 0 && results.warnings < 10;

    return {
      gate: 'code-quality',
      passed,
      score: this.calculateCodeQualityScore(results),
      details: results
    };
  }

  async checkSecrets() {
    const { exec } = require('child_process');
    const { promisify } = require('util');
    const execAsync = promisify(exec);

    try {
      await execAsync('trufflehog filesystem . --json');
      return {
        gate: 'secrets',
        passed: true,
        score: 100,
        details: { secretsFound: 0 }
      };
    } catch (error) {
      return {
        gate: 'secrets',
        passed: false,
        score: 0,
        details: { secretsFound: 1, message: 'Secrets detected in codebase' }
      };
    }
  }

  async checkCompliance() {
    // Check license compliance
    const { exec } = require('child_process');
    const { promisify } = require('util');
    const execAsync = promisify(exec);

    try {
      const { stdout } = await execAsync('license-checker --json');
      const licenses = JSON.parse(stdout);

      const forbiddenLicenses = ['GPL-3.0', 'AGPL-3.0'];
      const violations = Object.entries(licenses)
        .filter(([, info]) =>
          forbiddenLicenses.some(forbidden =>
            info.licenses?.includes(forbidden)
          )
        );

      return {
        gate: 'compliance',
        passed: violations.length === 0,
        score: violations.length === 0 ? 100 : 0,
        details: { violations }
      };
    } catch (error) {
      return {
        gate: 'compliance',
        passed: false,
        score: 0,
        details: { error: error.message }
      };
    }
  }

  async checkContainerSecurity() {
    // Placeholder for container security checks
    return {
      gate: 'container-security',
      passed: true,
      score: 100,
      details: {}
    };
  }

  calculateVulnerabilityScore(report) {
    if (!report.snyk) return 100;

    const weights = {
      critical: 25,
      high: 10,
      medium: 5,
      low: 1
    };

    const deductions =
      (report.snyk.critical || 0) * weights.critical +
      (report.snyk.high || 0) * weights.high +
      (report.snyk.medium || 0) * weights.medium +
      (report.snyk.low || 0) * weights.low;

    return Math.max(0, 100 - deductions);
  }

  calculateCodeQualityScore(results) {
    const errorPenalty = results.errors * 10;
    const warningPenalty = results.warnings * 2;

    return Math.max(0, 100 - errorPenalty - warningPenalty);
  }

  aggregateResults() {
    const allPassed = this.results.every(r => r.passed);
    const averageScore = this.results.reduce((sum, r) => sum + r.score, 0) / this.results.length;

    const failedGates = this.results.filter(r => !r.passed);

    return {
      passed: allPassed,
      overallScore: Math.round(averageScore),
      gates: this.results,
      failedGates,
      timestamp: new Date().toISOString()
    };
  }

  async generateReport() {
    const results = await this.evaluate();

    console.log('\n=== Security Gate Results ===');
    console.log(`Overall Status: ${results.passed ? 'PASSED ✓' : 'FAILED ✗'}`);
    console.log(`Overall Score: ${results.overallScore}/100`);
    console.log('\nGate Results:');

    for (const gate of results.gates) {
      const status = gate.passed ? '✓' : '✗';
      console.log(`  ${status} ${gate.gate}: ${gate.score}/100`);
    }

    if (!results.passed) {
      console.log('\nFailed Gates:');
      for (const gate of results.failedGates) {
        console.log(`  - ${gate.gate}: ${JSON.stringify(gate.details)}`);
      }

      process.exit(1);
    }

    return results;
  }
}

// Run security gates in CI
async function runSecurityGates() {
  const gate = new SecurityGate({
    minScore: 80,
    failOnWarnings: false
  });

  await gate.generateReport();
}

// Export for use in CI
if (require.main === module) {
  runSecurityGates().catch(error => {
    console.error('Security gate evaluation failed:', error);
    process.exit(1);
  });
}

module.exports = { SecurityGate };

Policy as Code

OPA Policy Enforcement

# policy/deployment.rego
package deployment

# Deny deployments with critical vulnerabilities
deny[msg] {
  input.vulnerabilities.critical > 0
  msg = sprintf("Deployment blocked: %d critical vulnerabilities found", [input.vulnerabilities.critical])
}

# Deny deployments with high vulnerabilities exceeding threshold
deny[msg] {
  input.vulnerabilities.high > 5
  msg = sprintf("Deployment blocked: %d high-severity vulnerabilities found (max: 5)", [input.vulnerabilities.high])
}

# Require security scan results
deny[msg] {
  not input.security_scan_completed
  msg = "Deployment blocked: Security scan not completed"
}

# Require container image scanning
deny[msg] {
  not input.container_scan_completed
  msg = "Deployment blocked: Container scan not completed"
}

# Enforce image signing
deny[msg] {
  not input.image_signed
  msg = "Deployment blocked: Container image not signed"
}

# Check for secrets in environment variables
deny[msg] {
  some env in input.environment
  contains(lower(env.name), "password")
  contains(lower(env.name), "secret")
  contains(lower(env.name), "key")
  msg = sprintf("Deployment blocked: Potential secret in environment variable: %s", [env.name])
}

# Require resource limits
warn[msg] {
  not input.resources.limits
  msg = "Warning: No resource limits specified"
}

# Check for latest tag
warn[msg] {
  endswith(input.image, ":latest")
  msg = "Warning: Using 'latest' tag is not recommended"
}
// policy-check.js
const { loadPolicy } = require('@open-policy-agent/opa-wasm');

class PolicyEnforcement {
  async checkDeployment(deploymentConfig) {
    const policy = await loadPolicy('./policy/deployment.rego');

    const input = {
      vulnerabilities: deploymentConfig.scanResults,
      security_scan_completed: deploymentConfig.securityScanCompleted,
      container_scan_completed: deploymentConfig.containerScanCompleted,
      image_signed: deploymentConfig.imageSigned,
      image: deploymentConfig.image,
      environment: deploymentConfig.environment,
      resources: deploymentConfig.resources
    };

    const result = policy.evaluate(input);

    if (result.deny && result.deny.length > 0) {
      console.error('Policy violations detected:');
      result.deny.forEach(violation => console.error(`  - ${violation}`));
      throw new Error('Deployment policy violations');
    }

    if (result.warn && result.warn.length > 0) {
      console.warn('Policy warnings:');
      result.warn.forEach(warning => console.warn(`  - ${warning}`));
    }

    return result;
  }
}

Conclusion

Shifting security left in CI/CD pipelines transforms security from a bottleneck into an enabler, catching vulnerabilities early when they’re cheapest to fix. By integrating automated security checks throughout the development lifecycle, teams can ship faster without compromising security.

Key takeaways:

  • Integrate security scanning at every stage of CI/CD
  • Use SAST tools to catch code-level vulnerabilities
  • Automate dependency vulnerability scanning
  • Implement container image security scanning
  • Enforce security policies as code with OPA
  • Create automated security quality gates
  • Generate Software Bill of Materials (SBOM)
  • Monitor and fail builds on critical vulnerabilities
  • Provide actionable feedback to developers
  • Continuously improve security automation

DevSecOps is not about adding more process but about embedding security seamlessly into existing workflows, making secure development the default path of least resistance.

#DevSecOps #CI/CD #Security Automation #SAST #DAST