TechLead
Lesson 19 of 22
5 min read
Cybersecurity

Security in CI/CD

Integrate security testing into your continuous integration and deployment pipelines for automated vulnerability detection

Why Secure Your CI/CD Pipeline?

Your CI/CD pipeline is one of the most sensitive parts of your infrastructure. It has access to your source code, secrets, production deployment credentials, and often runs with elevated privileges. A compromised CI/CD pipeline can inject malicious code into every deployment, steal secrets, or deploy backdoored applications. Securing your pipeline is not optional — it is a critical security requirement.

Security in CI/CD goes beyond just running security scanners. It includes securing the pipeline infrastructure itself, managing secrets properly, verifying the integrity of artifacts, and implementing security gates that prevent vulnerable code from reaching production.

Security Gates in Your Pipeline

// Complete security-focused GitHub Actions workflow
/*
name: Security Pipeline
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

permissions:
  contents: read  # Minimum required permissions
  security-events: write  # For uploading SARIF results

jobs:
  # Stage 1: Static Analysis (SAST)
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run ESLint security plugin
        run: |
          npm ci
          npx eslint --config .eslintrc.security.js src/ --format json > eslint-security.json

      - name: Run Semgrep
        uses: semgrep/semgrep-action@v1
        with:
          config: >-
            p/javascript
            p/typescript
            p/owasp-top-ten
            p/nodejs
          generateSarif: true

  # Stage 2: Dependency Scanning (SCA)
  dependency-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - name: npm audit
        run: npm audit --audit-level=high
      - name: License check
        run: npx license-checker --failOn 'GPL-3.0;AGPL-3.0'

  # Stage 3: Secret Scanning
  secret-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for scanning
      - name: Scan for secrets
        uses: trufflesecurity/trufflehog@main
        with:
          extra_args: --only-verified

  # Stage 4: Container Scanning (if using Docker)
  container-scan:
    runs-on: ubuntu-latest
    needs: [sast, dependency-scan]
    steps:
      - uses: actions/checkout@v4
      - name: Build image
        run: docker build -t myapp:test .
      - name: Scan container
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: myapp:test
          severity: HIGH,CRITICAL
          exit-code: 1

  # Stage 5: Deploy (only if all security checks pass)
  deploy:
    runs-on: ubuntu-latest
    needs: [sast, dependency-scan, secret-scan, container-scan]
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
      - name: Deploy
        run: echo "All security checks passed - deploying"
*/

Securing Pipeline Secrets

// Best practices for CI/CD secrets

// 1. Use GitHub's encrypted secrets (never hardcode in workflow files)
// Secrets are masked in logs automatically
// Access via: ${{ secrets.MY_SECRET }}

// 2. Use OIDC for cloud authentication (no long-lived credentials)
/*
jobs:
  deploy:
    permissions:
      id-token: write  # Required for OIDC
      contents: read
    steps:
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/github-deploy
          aws-region: us-east-1
          # No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed!
*/

// 3. Scope secrets to environments
// Production secrets only available to production deployments
// Require manual approval for production environment

// 4. Rotate secrets regularly
// Automate rotation and update CI/CD variables via API

// 5. Prevent secret leaks in logs
function maskSecret(value: string): string {
  if (value.length <= 4) return "****";
  return value.slice(0, 2) + "*".repeat(value.length - 4) + value.slice(-2);
}

// Scrub secrets from error messages before logging
function sanitizeError(error: Error, secrets: string[]): string {
  let message = error.message;
  for (const secret of secrets) {
    message = message.replaceAll(secret, "[REDACTED]");
  }
  return message;
}

Artifact Integrity

Verify the integrity of your build artifacts to ensure they have not been tampered with between build and deployment. Sign your artifacts, verify checksums, and use provenance attestation to trace artifacts back to their source code and build process.

// Artifact signing and verification
import crypto from "crypto";

// Generate checksum for build artifacts
function generateArtifactChecksum(filePath: string): string {
  const fileBuffer = fs.readFileSync(filePath);
  return crypto.createHash("sha256").update(fileBuffer).digest("hex");
}

// Sign the artifact manifest
function signManifest(manifest: Record<string, string>, privateKey: string): string {
  const data = JSON.stringify(manifest, Object.keys(manifest).sort());
  const sign = crypto.createSign("SHA256");
  sign.update(data);
  return sign.sign(privateKey, "base64");
}

// Build script that creates signed manifests
async function buildWithIntegrity() {
  // Build the application
  execSync("npm run build");

  // Generate checksums for all output files
  const manifest: Record<string, string> = {};
  const buildFiles = glob.sync("dist/**/*", { nodir: true });

  for (const file of buildFiles) {
    manifest[file] = generateArtifactChecksum(file);
  }

  // Sign the manifest
  const signature = signManifest(manifest, process.env.SIGNING_KEY!);

  // Write manifest and signature
  fs.writeFileSync("dist/manifest.json", JSON.stringify(manifest, null, 2));
  fs.writeFileSync("dist/manifest.sig", signature);

  console.log(`Build complete: ${buildFiles.length} files signed`);
}

// Deployment script that verifies integrity
async function verifyAndDeploy() {
  const manifest = JSON.parse(fs.readFileSync("dist/manifest.json", "utf-8"));
  const signature = fs.readFileSync("dist/manifest.sig", "utf-8");

  // Verify signature
  const data = JSON.stringify(manifest, Object.keys(manifest).sort());
  const verify = crypto.createVerify("SHA256");
  verify.update(data);

  if (!verify.verify(publicKey, signature, "base64")) {
    throw new SecurityError("Artifact signature verification failed!");
  }

  // Verify each file's checksum
  for (const [file, expectedHash] of Object.entries(manifest)) {
    const actualHash = generateArtifactChecksum(file);
    if (actualHash !== expectedHash) {
      throw new SecurityError(`File tampering detected: ${file}`);
    }
  }

  console.log("All integrity checks passed - deploying");
}

CI/CD Security Best Practices

  • Principle of least privilege: Give pipeline jobs only the permissions they need. Use read-only access where possible.
  • Pin action versions: Use full SHA hashes for GitHub Actions (actions/checkout@abcdef) instead of tags that can be moved.
  • Require security checks to pass: Make security scan jobs required status checks for pull requests.
  • Use OIDC for cloud auth: Avoid long-lived cloud credentials in CI. Use OIDC federation instead.
  • Separate build and deploy: Build artifacts in one job, verify and deploy in another with different permissions.
  • Audit pipeline changes: Require code review for workflow file changes just like application code.

Continue Learning