The Threat Model Nobody Draws
Most teams think of supply chain attacks as "someone poisons a dependency." That's one vector, but the full attack surface is much wider:
Attack Surface Map:
┌─────────────────────────────────────────────────┐
│ 1. Source Code │
│ - Compromised developer account │
│ - Malicious PR from fork (typosquatting CI) │
│ - Unsigned commits (impersonation) │
│ │
│ 2. Dependencies │
│ - Typosquatting packages │
│ - Compromised maintainer account │
│ - Dependency confusion (private/public mix) │
│ - Malicious postinstall scripts │
│ │
│ 3. Build Pipeline │
│ - Modified CI workflow files │
│ - Compromised build environment │
│ - Injected build steps │
│ - Exfiltrated secrets during build │
│ │
│ 4. Artifact Storage │
│ - Replaced artifacts in registry │
│ - Compromised registry credentials │
│ - Man-in-the-middle on artifact download │
│ │
│ 5. Deployment │
│ - Modified deployment scripts │
│ - Compromised deployment credentials │
│ - Runtime dependency injection │
└─────────────────────────────────────────────────┘
SLSA, Sigstore, and SBOMs each address different parts of this surface. The following sections walk through each one with actual implementation details.
Sigstore: Keyless Signing That Actually Works
The old model of code signing required managing long-lived keys, which created their own security problems (key storage, rotation, revocation). Sigstore replaces this with ephemeral keys tied to identity, using the same OIDC tokens your CI already has.
Here's how keyless signing works under the hood:
# 1. Install cosign
go install github.com/sigstore/cosign/v2/cmd/cosign@latest
# 2. Sign a container image (keyless, in CI)
# The --yes flag skips the interactive confirmation
cosign sign --yes ghcr.io/myorg/myapp:v1.2.3
# What happens behind the scenes:
# a) cosign requests an OIDC token from the CI provider
# (GitHub Actions: ACTIONS_ID_TOKEN_REQUEST_URL)
# b) Sends the OIDC token to Sigstore's Fulcio CA
# c) Fulcio verifies the token, issues a short-lived certificate
# (bound to the workflow identity, valid for ~10 minutes)
# d) cosign generates an ephemeral key pair
# e) Signs the image digest with the ephemeral private key
# f) Records the signature + certificate in Rekor transparency log
# g) Discards the private key (it's never stored anywhere)
Verification is where it gets powerful:
# Verify a signed image, checking identity claims
cosign verify \
--certificate-identity "https://github.com/myorg/myapp/.github/workflows/release.yml@refs/tags/v1.2.3" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp:v1.2.3
# This checks:
# 1. The signature is valid
# 2. The signing certificate was issued by Fulcio
# 3. The certificate's identity matches the expected workflow
# 4. The signing event exists in the Rekor transparency log
# 5. The certificate was valid at the time of signing
Here is a GitHub Actions workflow for signing container images:
name: Build and Sign
on:
push:
tags: ['v*']
permissions:
contents: read
packages: write
id-token: write # Required for keyless signing
jobs:
build-sign:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
- name: Sign image
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
- name: Attach SBOM
run: |
syft ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
-o cyclonedx-json > sbom.cdx.json
cosign attach sbom \
--sbom sbom.cdx.json \
ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
SLSA: Provenance That Means Something
SLSA (pronounced "salsa") gives you a framework to think about build integrity. The key concept is provenance, a verifiable record of how an artifact was built, including:
- What source code was used
- What build system produced it
- What the build steps were
- What the inputs and outputs were
The SLSA generator for GitHub Actions makes this almost turnkey:
# .github/workflows/slsa-build.yml
name: SLSA Build
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write # For signing provenance
actions: read # For reading workflow info
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.hash.outputs.digest }}
steps:
- uses: actions/checkout@v4
- name: Build
run: npm ci && npm run build
- name: Package
run: tar -czf dist.tar.gz dist/
- name: Generate hash
id: hash
run: |
DIGEST=$(sha256sum dist.tar.gz | cut -d' ' -f1)
echo "digest=sha256:${DIGEST}" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: build-artifact
path: dist.tar.gz
provenance:
needs: build
permissions:
actions: read
id-token: write
contents: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.10.0
with:
base64-subjects: |
${{ needs.build.outputs.digest }}
The provenance document looks like this (simplified):
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [{
"name": "dist.tar.gz",
"digest": { "sha256": "a1b2c3d4..." }
}],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://github.com/slsa-framework/slsa-github-generator/generic@v1",
"externalParameters": {
"source": {
"uri": "git+https://github.com/myorg/myapp@refs/tags/v1.2.3",
"digest": { "sha1": "abc123..." }
}
},
"resolvedDependencies": [
{
"uri": "git+https://github.com/actions/checkout@v4",
"digest": { "gitCommit": "..." }
}
]
},
"runDetails": {
"builder": {
"id": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v1.10.0"
},
"metadata": {
"invocationId": "https://github.com/myorg/myapp/actions/runs/12345678"
}
}
}
}
This provenance is signed with Sigstore and can be verified:
slsa-verifier verify-artifact dist.tar.gz \
--provenance-path provenance.intoto.jsonl \
--source-uri github.com/myorg/myapp \
--source-tag v1.2.3
SBOMs: Know What You Ship
An SBOM (Software Bill of Materials) is an inventory of every component in a software product. There are two dominant formats: CycloneDX and SPDX. CycloneDX tends to be the preferred choice because its tooling is more mature for application dependencies.
# Generate SBOM from a container image
syft ghcr.io/myorg/myapp:v1.2.3 -o cyclonedx-json > sbom.cdx.json
# Generate SBOM from source (npm project)
cdxgen -t node -o sbom.cdx.json .
# Generate SBOM including OS packages in container
syft ghcr.io/myorg/myapp:v1.2.3 \
--scope all-layers \
-o cyclonedx-json > sbom-full.cdx.json
The difference between a lockfile and an SBOM is important. On a representative Node.js project:
package-lock.json: 847 dependencies
SBOM (source only): 847 dependencies (same as lockfile)
SBOM (container): 1,243 dependencies (includes libc, openssl,
zlib, libcurl, ca-certificates,
node runtime, etc.)
Those extra 396 dependencies are real attack surface. When a vulnerability drops in libwebp or openssl, your lockfile tells you nothing, but your container SBOM does.
Here is how SBOM scanning integrates into CI:
# .github/workflows/sbom-scan.yml
name: SBOM Scan
on: [push, pull_request]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Generate SBOM
uses: anchore/sbom-action@v0
with:
format: cyclonedx-json
output-file: sbom.cdx.json
- name: Scan for vulnerabilities
uses: anchore/scan-action@v4
with:
sbom: sbom.cdx.json
fail-build: true
severity-cutoff: high
- name: Check component licenses
run: |
# Extract all licenses from SBOM
jq -r '.components[].licenses[]?.license.id //
.components[].licenses[]?.expression //
"UNKNOWN"' sbom.cdx.json | \
sort | uniq -c | sort -rn
# Fail on copyleft in a proprietary project
COPYLEFT=$(jq -r '.components[].licenses[]?.license.id' sbom.cdx.json | \
grep -iE '^(GPL|AGPL|SSPL)' || true)
if [ -n "$COPYLEFT" ]; then
echo "::error::Copyleft licenses detected: $COPYLEFT"
exit 1
fi
npm Provenance: One Flag to Rule Them All
Since npm 9.5, you can publish packages with cryptographic provenance in a single step:
npm publish --provenance
That's it. Behind the scenes, this:
- Generates a SLSA provenance predicate linking the package to the GitHub Actions run
- Signs it with Sigstore keyless signing
- Uploads the signed provenance to npm alongside the package
- npm displays a "published with provenance" badge
The package.json needs nothing special. The only requirement is publishing from a GitHub Actions workflow with id-token: write permission.
# .github/workflows/publish.yml
name: Publish
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Consumers can verify provenance:
npm audit signatures
# Checks all installed packages for valid Sigstore signatures
The Complete Pipeline: Putting It All Together
Here is an end-to-end secured pipeline configuration:
name: Secured Release Pipeline
on:
push:
tags: ['v*']
permissions:
contents: write
packages: write
id-token: write
actions: read
jobs:
# Step 1: Build in isolated environment
build:
runs-on: ubuntu-latest
outputs:
image-digest: ${{ steps.build.outputs.digest }}
sbom-hash: ${{ steps.sbom-hash.outputs.hash }}
steps:
- uses: actions/checkout@v4
- name: Build container
id: build
uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
provenance: true
sbom: true
- name: Generate comprehensive SBOM
run: |
syft ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
--scope all-layers \
-o cyclonedx-json > sbom.cdx.json
- name: Scan SBOM for vulnerabilities
uses: anchore/scan-action@v4
with:
sbom: sbom.cdx.json
fail-build: true
severity-cutoff: critical
- name: Hash SBOM
id: sbom-hash
run: echo "hash=$(sha256sum sbom.cdx.json | base64 -w0)" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.cdx.json
# Step 2: Sign everything
sign:
needs: build
runs-on: ubuntu-latest
steps:
- uses: sigstore/cosign-installer@v3
- name: Sign container image
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image-digest }}
- name: Attach and sign SBOM
run: |
cosign attach sbom \
--sbom sbom.cdx.json \
ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image-digest }}
cosign sign --yes --attachment sbom \
ghcr.io/${{ github.repository }}@${{ needs.build.outputs.image-digest }}
# Step 3: Generate SLSA provenance
provenance:
needs: build
permissions:
actions: read
id-token: write
packages: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v1.10.0
with:
image: ghcr.io/${{ github.repository }}
digest: ${{ needs.build.outputs.image-digest }}
secrets:
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GITHUB_TOKEN }}
What Actually Matters: A Pragmatic Prioritization
Based on implementation experience across multiple organizations, here is a pragmatic ranking of what delivers the most security value per hour of engineering effort:
- npm provenance (30 min setup, massive value), One flag, cryptographic attestation. Do this first.
- Container image signing with cosign (2 hours), Keyless signing in CI, verification in deployment.
- SBOM generation and scanning (4 hours), Know your dependencies, catch vulns in the full stack.
- SLSA Level 2 provenance (1 day), Authenticated provenance from a hosted build service.
- SLSA Level 3 (1-2 weeks), Hardened, isolated build environments. This is where it gets expensive in engineering time.
- Reproducible builds (ongoing effort), The holy grail. Practically hard for most projects.
In one documented case, after implementing items 1-4, the time-to-detect for a similar pipeline compromise dropped from 11 hours to about 4 minutes, because the signing identity mismatch would be caught at deployment time.
Supply chain security is not about achieving some perfect SLSA level. It is about making it expensive to attack a pipeline and cheap to detect when someone tries. Start with the low-hanging fruit, measure the detection gap, and work up from there.