Pipeline security and supply chain
In this series (10 parts)
Prerequisite: Artifact management.
A CI/CD pipeline has write access to production. It pulls dependencies from the internet, runs arbitrary build scripts, and pushes artifacts to registries. If an attacker compromises any step, they own your deployment. The SolarWinds and event-stream incidents proved this is not theoretical.
This article adds security gates to every stage of your pipeline. See also: supply chain security for a broader treatment.
Security gates in the pipeline
graph LR CODE["Source Code"] --> DEP["Dependency Scan<br/>Dependabot / Renovate"] DEP --> SAST["SAST<br/>Semgrep"] SAST --> BUILD["Build"] BUILD --> IMG["Image Scan<br/>Trivy"] IMG --> SBOM["SBOM Generation<br/>Syft"] SBOM --> SIGN["Sign + Attest<br/>Cosign / SLSA"] SIGN --> DEPLOY["Deploy"] style CODE fill:#64748b,color:#fff style DEP fill:#3b82f6,color:#fff style SAST fill:#8b5cf6,color:#fff style BUILD fill:#f59e0b,color:#000 style IMG fill:#ef4444,color:#fff style SBOM fill:#ec4899,color:#fff style SIGN fill:#22c55e,color:#000 style DEPLOY fill:#10b981,color:#000
Security gates span the entire pipeline. Each gate can block the deployment if it finds a critical issue.
Dependency scanning
Your application is mostly third-party dependencies. Scanning them for known vulnerabilities is the lowest-effort, highest-impact security measure you can add.
Dependabot (GitHub native)
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
Dependabot opens PRs when it finds patched versions. Pin GitHub Actions to full commit SHAs, not mutable tags:
# Bad: mutable tag
- uses: actions/checkout@v4
# Good: pinned to SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
Renovate (self-hosted option)
Renovate offers more control: grouping updates, auto-merge for patches, and custom regex managers.
{
"extends": ["config:recommended"],
"packageRules": [
{
"matchUpdateTypes": ["patch"],
"automerge": true
}
],
"vulnerabilityAlerts": {
"enabled": true,
"labels": ["security"]
}
}
Static Application Security Testing (SAST)
SAST tools analyze source code without executing it. They find SQL injection, path traversal, hardcoded secrets, and insecure cryptographic usage. Semgrep is open-source, fast, and supports custom rules.
sast:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Semgrep
uses: semgrep/semgrep-action@v1
with:
config: >-
p/default
p/owasp-top-ten
p/nodejs
generateSarif: true
- name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
The SARIF upload integrates findings into the GitHub Security tab. Developers see vulnerabilities inline on their pull requests.
Custom Semgrep rules
When your codebase has specific patterns to ban, write a custom rule:
# .semgrep/no-raw-sql.yml
rules:
- id: no-raw-sql-queries
patterns:
- pattern: db.query($SQL, ...)
- pattern-not: db.query($SQL, $PARAMS, ...)
message: >
Raw SQL without parameterized inputs. Use parameterized queries
to prevent SQL injection.
severity: ERROR
languages: [javascript, typescript]
Container image scanning
Your container image inherits every vulnerability in its base image. Alpine has fewer packages and therefore fewer CVEs than Ubuntu, but it still needs scanning.
Trivy
image-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t myapp:${{ github.sha }} .
- name: Scan with Trivy
uses: aquasecurity/trivy-action@master
with:
image-ref: myapp:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGH
ignore-unfixed: true
Setting exit-code: 1 fails the pipeline on critical or high vulnerabilities. The ignore-unfixed flag skips vulnerabilities that have no available patch, reducing noise.
Slim base images
Reduce your attack surface by choosing minimal base images:
# Multi-stage build: build in full image, run in distroless
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM gcr.io/distroless/nodejs20-debian12
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
CMD ["dist/index.js"]
Distroless images contain only your application and its runtime. No shell, no package manager, no curl. An attacker who gains code execution inside the container has far fewer tools available.
SBOM generation
A Software Bill of Materials (SBOM) lists every component in your artifact. When a new CVE drops, you check your SBOMs to see if you are affected. Without an SBOM, you are guessing.
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
image: myapp:${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json
- name: Attest SBOM
uses: actions/attest-sbom@v2
with:
subject-name: ghcr.io/myorg/myapp
subject-digest: ${{ steps.build.outputs.digest }}
sbom-path: sbom.spdx.json
push-to-registry: true
The attestation links the SBOM to a specific image digest. Anyone pulling the image can verify what is inside it.
Signing builds with Sigstore
Sigstore provides three components:
- Cosign: signs and verifies container images and other artifacts.
- Fulcio: issues short-lived certificates tied to OIDC identities (like a GitHub Actions workflow).
- Rekor: a transparency log that records all signing events.
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Sign the image
run: cosign sign --yes ghcr.io/myorg/myapp@${{ steps.build.outputs.digest }}
env:
COSIGN_EXPERIMENTAL: "true"
No keys to manage. The GitHub Actions OIDC token proves the identity of the workflow. The signature is recorded in Rekor. Anyone can verify it.
Admission control
Kubernetes can enforce that only signed images run in your cluster:
# Kyverno policy
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-image-signature
spec:
validationFailureAction: Enforce
rules:
- name: verify-cosign-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "ghcr.io/myorg/*"
attestors:
- entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subject: "https://github.com/myorg/*"
If someone pushes an unsigned image or an image signed by an unexpected identity, Kubernetes rejects the pod.
The SLSA framework
Supply-chain Levels for Software Artifacts (SLSA, pronounced “salsa”) defines four levels of supply chain security:
| Level | Requirement |
|---|---|
| SLSA 1 | Build process is documented and produces provenance |
| SLSA 2 | Build runs on a hosted service with signed provenance |
| SLSA 3 | Build environment is hardened and provenance is non-forgeable |
| SLSA 4 | Hermetic, reproducible build with two-party review |
Most teams should target SLSA 2 as a practical starting point. GitHub Actions natively supports SLSA provenance generation.
- name: SLSA Provenance
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/myorg/myapp
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
The provenance attestation records the repository, workflow file, commit SHA, builder identity, and build parameters. This creates an auditable chain from source to artifact.
Layered defense
No single tool catches everything. Layer your defenses:
- Dependency scanning catches known CVEs in libraries before they enter your build.
- SAST finds vulnerabilities in your own code.
- Image scanning catches vulnerabilities in base images and OS packages.
- SBOM lets you respond to new CVEs after deployment.
- Signing and provenance ensure the artifact in production is the one your pipeline built.
Each layer is imperfect. Together they make an attacker’s job significantly harder.
What comes next
A secure pipeline delivers artifacts safely. But delivering everything to everyone at once is risky. Progressive delivery introduces feature flags, canary deployments, and automated rollback triggers to reduce blast radius.