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.