<!-- Canonical: https://lucioduran.com/blog/supply-chain-security-sigstore-slsa-sbom
Language: en
Content-SHA256: 496080e4800eb9eaa44b179311506810eb3a461e37d45d7d4af4cd63401c4e68
-->

Security

# Supply Chain Security: Sigstore, SLSA, SBOMs, and Securing the Build Pipeline End-to-End

2025-12-319 min read

Lucio Durán
Engineering Manager & AI Solutions Architect

Also available in: [Español](<https://lucioduran.com/es/blog/supply-chain-security-sigstore-slsa-sbom>)

## 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](<https://www.sigstore.dev/>) 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:

```bash
# 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:

```bash
# 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:

```yaml
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:

```yaml
# .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):

```json
{
 "_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:

```bash
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.

```bash
# 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:

```yaml
# .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:

```bash
npm publish --provenance
```

That's it. Behind the scenes, this:

1. Generates a SLSA provenance predicate linking the package to the GitHub Actions run
2. Signs it with Sigstore keyless signing
3. Uploads the signed provenance to npm alongside the package
4. 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.

```yaml
# .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:

```bash
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:

```yaml
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:

1. **npm provenance** (30 min setup, massive value), One flag, cryptographic attestation. Do this first.
2. **Container image signing with cosign** (2 hours), Keyless signing in CI, verification in deployment.
3. **SBOM generation and scanning** (4 hours), Know your dependencies, catch vulns in the full stack.
4. **SLSA Level 2 provenance** (1 day), Authenticated provenance from a hosted build service.
5. **SLSA Level 3** (1-2 weeks), Hardened, isolated build environments. This is where it gets expensive in engineering time.
6. **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.

## Frequently asked questions

### What is Sigstore and how does keyless signing work?

Sigstore is an open-source project that provides free code signing infrastructure. Keyless signing works by using short-lived certificates bound to an OIDC identity (like your GitHub Actions workflow identity) instead of long-lived signing keys. The signature, certificate, and signing event are recorded in a transparency log (Rekor) for public auditability.

### What are the SLSA levels and what do they require?

SLSA (Supply-chain Levels for Software Artifacts) defines four levels of increasing build integrity. Level 1 requires build provenance documentation. Level 2 requires a hosted build service and authenticated provenance. Level 3 requires hardened build environments with non-falsifiable provenance. Level 4 requires hermetic and reproducible builds with two-party review.

### Why do I need an SBOM if I already have a lockfile?

Lockfiles only capture direct and transitive dependencies managed by the package manager. SBOMs capture the complete dependency graph including native libraries, OS packages, build tools, and runtime dependencies that lockfiles miss. They are also machine-readable in standard formats (CycloneDX, SPDX) that vulnerability scanners and compliance tools can consume.

## Tips

1. **Tip:** Start with ` npm publish --provenance ` today. It is literally one flag and it gives consumers a cryptographic link between the published package and the exact GitHub Actions run that built it. Zero excuse not to do it.
2. **Tip:** SLSA Level 3 requires an isolated build environment that the developer cannot influence. If a CI pipeline lets developers modify the build script via PR *and* the build runs in the same PR context, that is not Level 3, that is cosplaying Level 3.
3. **Tip:** Generate SBOMs at build time, not as an afterthought. Running ` syft ` or ` cdxgen ` against a container image catches dependencies that the lockfile misses, native libraries, OS packages, the whole runtime.
4. **Tip:** Sigstore's keyless signing with ` cosign sign --yes ` uses OIDC identity tokens from the CI provider. This means the signing identity is the CI workflow, not a long-lived key sitting in a secret. The Rekor transparency log makes it auditable forever.

- supply-chain
- sigstore
- cosign
- slsa
- sbom
- npm-provenance
- security
- ci-cd
- build-pipeline

[Back to Blog](<https://lucioduran.com/blog>)
