You patched your servers. You run a WAF. You do code reviews. And then a backdoored npm package ships to production because nobody thought to question [email protected] — or worse, a malicious GitHub Action ran curl | bash during your build and exfiltrated your AWS credentials.
This is A08: Software and Data Integrity Failures. And unlike SQL injection or broken auth, it’s sneaky. It lives not in your code, but in everything your code depends on to be built, tested, and shipped.
The OWASP Top 10 A08 entry covers a broad surface: deserialization attacks, auto-update hijacking, and insecure CI/CD pipelines. This article is laser-focused on the CI/CD side — because that’s where most teams are blind, and where attackers are increasingly patient.
Why CI Is the New Attack Surface
The SolarWinds breach in 2020 wasn’t a zero-day exploit against a server. It was a tampered build system that inserted malicious code into a signed, legitimate software update. The XZ Utils backdoor in 2024 was planted over two years through a fake contributor persona — it nearly shipped in major Linux distros. These aren’t hypotheticals.
Your CI pipeline has a unique property that makes it dangerous: it runs with elevated trust by design. It has network access, secrets, write access to registries, and nobody watching it in real time. It’s a perfect target.
The typical attack vector looks like this:
- A dependency you use gets compromised (maintainer account takeover, typosquatting, dependency confusion)
- Your CI installs the new version on the next build
- The malicious package exfiltrates
$CI_TOKEN, sends data to an attacker’s server, or injects code into your artifact - Your signed, tested artifact ships to prod
Your code was never touched. Your tests passed. Your SAST found nothing.
The Threat Model for CI/CD Pipelines
Before throwing tools at the problem, you need to know what you’re protecting against.
Dependency hijacking — A legitimate package is taken over (npm maintainer reuses a password, PyPI account lacks MFA) and a malicious version is published. If you’re not pinning to exact versions with integrity hashes, you get the new version on next install.
Dependency confusion — An attacker registers a public package with the same name as your internal private package. Many package managers will prefer the public one if misconfigured. This burned Microsoft, Apple, and others in 2021.
Typosquatting — reqeusts instead of requests. Someone registers the typo and waits.
Malicious CI plugins/actions — GitHub Actions from unverified publishers, Jenkins plugins from an unmaintained fork, a Terraform provider that calls home.
Tampered build artifacts — Someone gets write access to your registry (Docker Hub, Nexus, S3). They swap the image between build and deploy. If you’re not verifying signatures, you’ll never know.
Secrets exfiltration — A build step runs a compromised tool that reads $GITHUB_TOKEN, $AWS_SECRET_ACCESS_KEY, or your signing key and sends it out over DNS to bypass egress filtering.
Step 1: Pin Everything. Everything.
The most unsexy and most impactful thing you can do. Unpinned dependencies are an open invitation.
For npm/yarn:
// package.json — never enough on its own
{
"dependencies": {
"express": "^4.18.0" // This resolves to "latest compatible" — bad
}
}
You need a lockfile (package-lock.json or yarn.lock) committed to your repo, and you need to use npm ci (not npm install) in CI. npm ci installs exactly what’s in the lockfile and fails if it doesn’t match package.json. Audit it regularly with npm audit.
For Python:
# Generate pinned requirements with hashes
pip-compile --generate-hashes requirements.in > requirements.txt
# Install in CI — will fail if hashes don't match
pip install --require-hashes -r requirements.txt
pip-tools is your friend here. Raw requirements.txt with == versions is okay, but hashes are the real protection.
For Docker base images:
# Bad — resolves to whatever is "latest" today
FROM python:3.12-slim
# Good — pinned to a specific digest
FROM python:3.12-slim@sha256:a7f8c3b2d1e4f9a0b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8
Get the digest with docker buildx imagetools inspect python:3.12-slim --format "{{json .Manifest}}" or from Docker Hub’s UI. Yes, updating this is annoying. That’s what Renovate is for — it opens PRs to update pinned digests automatically.
Step 2: Lock Down Your GitHub Actions
Third-party GitHub Actions are code that runs in your CI with full access to your secrets. Referencing uses: actions/checkout@v4 is actually safe — but uses: some-random-org/cool-action@v1 resolves to "the latest commit on the v1 tag," and tags are mutable. An attacker who compromises that repo just owns your pipeline.
Pin actions to their commit SHA:
# .github/workflows/build.yml
jobs:
build:
runs-on: ubuntu-latest
steps:
# Bad
- uses: actions/checkout@v4
# Good — pinned to immutable SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
# Also good for first-party actions — but verify the hash
- uses: docker/build-push-action@471d1dc4e07e5f6f8f4c7c7b5e5d5f6b5c4d3e2f # v6.9.0
Add a comment with the human-readable version so future-you knows what you pinned. Tools like pin-github-actions or zizmor can automate this.
Restrict permissions explicitly:
# Set minimal permissions at the workflow level
permissions:
contents: read
packages: write # Only if you need to push to registry
jobs:
build:
permissions:
contents: read # Override per-job if needed
By default GitHub Actions workflows have contents: write and can push to your repo. That’s far more than most jobs need.
Step 3: Generate and Verify SBOMs
A Software Bill of Materials is a machine-readable inventory of every component in your artifact — direct dependencies, transitive dependencies, their versions, and their licenses. Think of it as a manifest for everything that went into your build.
Without an SBOM, when a new CVE drops at 2am, you have no way to quickly answer "are we affected?" You have to check manually across every service. With an SBOM, you run one command.
Generate an SBOM in CI with Syft:
# .github/workflows/build.yml
- name: Build Docker image
run: docker build -t myapp:${{ github.sha }} .
- name: Generate SBOM
uses: anchore/sbom-action@f325610c9f6e5d3c8d2e4f1a7b9c5e3d2f1a8b6 # v0.17.2
with:
image: myapp:${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json
artifact-name: sbom.spdx.json
- name: Scan SBOM for vulnerabilities
uses: anchore/scan-action@b08523f9e4b1f5c3d2e7a9c8b5d4e3f2a1b0c9d # v5.2.0
with:
sbom: sbom.spdx.json
fail-build: true
severity-cutoff: high
Store the SBOM as a build artifact. Attach it to your container image using cosign attach sbom. When someone asks you six months from now what version of OpenSSL was in that release — you’ll have the answer in seconds.
Step 4: Sign Your Artifacts with Cosign
You’ve built a clean image. Now prove it. Artifact signing ties a specific build (with a verifiable chain back to your CI run) to the final artifact. If something tampers with the image between build and deployment, the signature breaks.
Sigstore and its cosign tool are the modern standard here. GitHub Actions has native support via the id-token permission.
Signing with keyless Sigstore in GitHub Actions:
# .github/workflows/build.yml
permissions:
contents: read
packages: write
id-token: write # Required for keyless signing
- name: Install Cosign
uses: sigstore/cosign-installer@d7d6bc7b8e3f4a1c9d5e2f8b7a3c6d1e4f9b5a2 # v3.7.0
- name: Build and push image
uses: docker/build-push-action@471d1dc4e07e5f6f8f4c7c7b5e5d5f6b5c4d3e2f # v6.9.0
id: build
with:
push: true
tags: ghcr.io/myorg/myapp:${{ github.sha }}
- name: Sign the image
run: |
cosign sign --yes ghcr.io/myorg/myapp@${{ steps.build.outputs.digest }}
Keyless signing uses GitHub’s OIDC token — no long-lived private keys to manage or leak. The signature is published to the public Rekor transparency log, providing a verifiable audit trail.
Verifying at deploy time:
# In your deployment script or admission controller
cosign verify \
--certificate-identity "https://github.com/myorg/myrepo/.github/workflows/build.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp:abc123
If the image was tampered with or signed by anything other than your specific workflow, this command fails. Bake this check into your Kubernetes admission controller (Kyverno or Sigstore Policy Controller) so unverified images literally cannot run in your cluster.
Step 5: Understand SLSA and Where You Stand
SLSA (Supply-chain Levels for Software Artifacts, pronounced "salsa") is a framework for measuring how hardened your build process is. Four levels, each building on the previous.
- Level 1 — Build process is documented and produces provenance (a record of how the artifact was built).
- Level 2 — Build is hosted on a platform (like GitHub Actions) and provenance is signed by that platform.
- Level 3 — Build is isolated (hardened runners, no persistent state between runs) and provenance is non-forgeable.
- Level 4 — Two-party review of all changes, hermetic builds (no network access during build).
Most teams are at Level 0 (nothing). Getting to Level 2 with GitHub Actions is surprisingly straightforward:
- name: Generate SLSA provenance
uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
with:
image: ghcr.io/myorg/myapp
digest: ${{ steps.build.outputs.digest }}
This generates a signed provenance attestation that says: "this specific digest was built from this specific commit, on this specific runner, at this specific time, by this specific workflow." You can verify it with slsa-verifier.
Step 6: Scan Before You Ship
Signing proves integrity. Scanning checks for known-bad components. Both are necessary.
- name: Scan image with Trivy
uses: aquasecurity/trivy-action@18f2af76b034ea8f5d3f7c1ffd6f1e58c96ffef # v0.28.0
with:
image-ref: ghcr.io/myorg/myapp:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: '1' # Fail the build on HIGH/CRITICAL
- name: Upload Trivy results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
Trivy scans your image layers for CVEs, checks your Dockerfile for misconfigurations, and can scan your IaC files too. The SARIF upload pushes findings into GitHub’s Security tab where your team can triage them properly.
For dependency scanning separate from Docker, run trivy fs . on your repo in CI before the build even starts. Catch it early.
Gotchas
Pinned digests go stale. A Docker base image pinned to a specific SHA won’t automatically get security patches. You need Renovate or Dependabot to open PRs when new digests are available. Without automation, pinning becomes a security liability within weeks.
Lockfiles aren’t immune to compromise. If an attacker can push to your repo (compromised developer credentials, malicious PR from a contributor), they can update the lockfile. Pin lockfile changes to require code owner review.
Public runners share hardware. GitHub’s hosted runners are ephemeral VMs, but there have been vulnerabilities in the isolation. For truly sensitive builds (signing keys, release artifacts), consider self-hosted runners with hardened images and ephemeral instances.
Cosign keyless logs to Rekor — permanently. Keyless signing publishes metadata to a public transparency log. This includes your workflow path and repository name. For private repos this is fine and intentional (that’s what makes it auditable), but be aware it’s public record.
npm audit fix isn’t a security strategy. It auto-applies patches that might break your app and sometimes silently can’t fix transitive vulnerabilities. Treat audit reports as a queue to work through, not a command to run blindly.
Your private registry can be a single point of failure. If you pull everything through Nexus or Artifactory, great — but if someone compromises that, they compromise everything. Apply the same signing/verification principles to artifacts pulled from your internal registry.
GitHub Actions secrets aren’t safe from compromised dependencies. If a malicious npm package runs during npm ci before your actual build step, it can read process.env and exfiltrate every secret in scope. Use step-level secret injection and don’t export secrets into the full environment.
Putting It All Together
Here’s a minimal but solid GitHub Actions workflow that covers the core A08 mitigations:
# .github/workflows/secure-build.yml
name: Secure Build
on:
push:
branches: [main]
permissions:
contents: read
packages: write
id-token: write
security-events: write
jobs:
build-and-sign:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@b5730f63b5ba68c0b7d2531da29e5d14e793e50b # v3.7.1
- name: Log in to GHCR
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Scan dependencies before build
run: |
# Fail fast on known-bad deps before wasting build time
docker run --rm \
-v ${{ github.workspace }}:/repo \
aquasec/trivy:latest fs /repo \
--severity HIGH,CRITICAL \
--exit-code 1
- name: Build and push image
uses: docker/build-push-action@471d1dc4e07e5f6f8f4c7c7b5e5d5f6b5c4d3e2f # v6.9.0
id: build
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
# Cache layers — but from a trusted source only
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Install Cosign
uses: sigstore/cosign-installer@d7d6bc7b8e3f4a1c9d5e2f8b7a3c6d1e4f9b5a2 # v3.7.0
- name: Sign the image
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
- name: Generate SBOM
uses: anchore/sbom-action@f325610c9f6e5d3c8d2e4f1a7b9c5e3d2f1a8b6 # v0.17.2
with:
image: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
- name: Attach SBOM to image
run: |
cosign attach sbom \
--sbom sbom.spdx.json \
ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
- name: Scan final image
uses: aquasecurity/trivy-action@18f2af76b034ea8f5d3f7c1ffd6f1e58c96ffef # v0.28.0
with:
image-ref: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
exit-code: '1'
- name: Upload scan results
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: trivy-results.sarif
This workflow pins every action by SHA, signs the final artifact, generates an SBOM, attaches it to the image, and scans for CVEs — all in one pass. It’s not perfect (SLSA Level 3 requires more work, and you’d want Kyverno enforcing signature verification on the cluster side), but it’s a serious baseline that most teams aren’t running today.
What to Prioritize
If you’re starting from zero, attack it in this order based on bang-for-effort ratio:
Lock your dependency files and use --frozen-lockfile or npm ci in CI. This alone closes most opportunistic attacks. Then pin your GitHub Actions to SHAs — 20 minutes of work with immediate payoff. Set up Trivy scanning with a blocking exit code on critical CVEs. Enable SBOM generation so you have a record. Then layer in artifact signing when you’re ready to enforce verification on the deploy side.
The tools are free. The Rekor transparency log is public infrastructure. GitHub Actions OIDC is built in. There’s no meaningful cost barrier here — only the inertia of not having done it yet.
Supply chain attacks are patient. They don’t need your code to be bad. They just need one gap in your build pipeline and enough time. Close the gaps.