A pipeline has access to your code, your build tools, your registry, and your cloud accounts. A compromised pipeline is one of the worst things that can happen to a software company — see SolarWinds, Codecov, and 3CX. This lesson covers the controls that prevent it.
Where Secrets Live
| Location | Use case |
|---|---|
| CI secret store (GitHub, GitLab, etc.) | Build-time tokens, API keys |
| Cloud secret manager (AWS Secrets Manager, Azure Key Vault, GCP Secret Manager) | Runtime app secrets |
| HashiCorp Vault / 1Password | Centralised secrets across CI and runtime |
| Sealed Secrets / SOPS | Encrypted secrets in Git |
Secrets in environment variables are masked in logs by GitHub Actions automatically — but only if they were passed via secrets:. Anything you echo or cat on stdout is fair game for leakage. Be careful with debug output.
OIDC: The Modern Way to Auth to Clouds
The old way: store an AWS access key and secret in CI secrets, rotate occasionally, hope nothing leaks. The new way: federation.
GitHub Actions can issue an OpenID Connect (OIDC) token signed by GitHub. AWS, Azure, and GCP can verify that token and exchange it for a short-lived role.
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # required to mint the OIDC token
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: us-east-1
- run: aws s3 cp ./dist s3://my-bucket --recursive
On the AWS side, the role's trust policy says "trust GitHub's OIDC issuer, but only for this repo, this branch":
{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}
Benefits:
- No static AWS keys to leak
- Each job gets a fresh, short-lived (≤1 hour) credential
- The trust policy can be scoped tightly — only main, only PRs, only specific environments
- Same pattern works for Azure (
azure/login) and GCP (google-github-actions/auth)
Pin Everything
Floating tags can be hijacked. Pin third-party actions, base images, and dependencies to immutable references.
# Bad — @v3 floats
- uses: someorg/action@v3
# Good — SHA is immutable
- uses: someorg/action@a1b2c3d4e5f6789... # v3.1.4
Dependabot understands SHA-pinned actions and opens PRs to update them. Your security team gets to review every upgrade.
Container base images
# Pin to digest
FROM node:20.11.0-bookworm-slim@sha256:abcd1234...
Application dependencies
Use lockfiles (package-lock.json, poetry.lock, go.sum, Cargo.lock) and commit them. CI installs from the lockfile only — no surprise upgrades mid-build.
Dependency Scanning
- Dependabot (GitHub) — opens PRs to upgrade vulnerable deps
- Snyk, Mend, JFrog Xray — commercial SCA
- Trivy, Grype — open-source CLI scanners
- OSV-Scanner — Google's, fast, multi-ecosystem
Scan in the pipeline; fail builds on critical CVEs (with overrides for known false positives). Scan registry images on a schedule too — new CVEs are disclosed every day.
SBOMs (Software Bill of Materials)
An SBOM is a machine-readable list of every dependency in your build. Two common formats: SPDX and CycloneDX. Generate one per release:
syft myimage:1.0 -o cyclonedx-json > sbom.json
trivy sbom sbom.json # scan it later for new CVEs
Customers and regulators increasingly require SBOMs (US Executive Order 14028, EU Cyber Resilience Act).
Artefact Signing
Sign every artefact you ship; verify the signature before deploying. The standard tool is cosign from the Sigstore project.
# Sign (uses keyless OIDC by default)
cosign sign ghcr.io/org/app@sha256:abc...
# Verify
cosign verify --certificate-identity-regexp 'github.com/org/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/org/app@sha256:abc...
Without signing, anyone with registry write access can swap a malicious image for a real one. With signing, you can prove an image was built by your CI.
SLSA — A Maturity Model
SLSA ("Supply-chain Levels for Software Artifacts") defines four levels of supply-chain integrity:
| Level 1 | Build is scripted; provenance is recorded |
| Level 2 | Hosted build service produces signed provenance |
| Level 3 | Build is hardened; non-falsifiable provenance |
| Level 4 | Two-party review and hermetic, reproducible builds |
GitHub Actions provides "build provenance" attestations that satisfy SLSA Level 3 with the right configuration:
- uses: actions/attest-build-provenance@v1
with:
subject-name: ghcr.io/org/app
subject-digest: sha256:abc...
push-to-registry: true
Hardening the Pipeline Itself
- Restrict who can edit workflow files (CODEOWNERS + branch protection)
- Disallow self-hosted runners on public repos (poison-PR risk)
- Use minimal permissions:
permissions: read-allat workflow level, then opt in - Forbid
pull_request_targetwith checked-out PR code without review - Audit third-party actions before allowing them at org level
- Enable artifact attestations and image signing
If a Secret Leaks
- Rotate the secret immediately at the source (cloud, registry, etc.)
- Search Git history (
git log -S, GitHub secret scanning,trufflehog) for additional exposure - Audit access logs for use during the leaked window
- Force-push to remove from history is rarely sufficient — assume the secret is permanently public
- Post-mortem and fix the leak path so it can't happen again
Fast rotation is more important than preventing every leak. Build the muscle.