# Software supply chain security

> Lock dependencies, defend against typosquatting and dependency confusion, pin CI actions by SHA, and sign and verify images with cosign.

DevSecOps Engineer roadmap · Secure the pipeline · 19 min read · https://levelcode.org/roadmaps/devsecops/supply-chain-security

Attackers have learned that it's often easier to compromise something you *trust* than to attack your application directly: a popular package, a CI action, a build server. Your supply chain is everything that turns source code into a running artifact, and every link in it can be tampered with. This lesson walks the chain from dependencies to deployment, showing the concrete control for each link: lockfiles, registry configuration, SHA-pinned actions, SLSA provenance, and keyless signatures with Sigstore.

## How supply chain attacks happen

Real incidents map onto distinct stages, and each needs a different defence:

| Stage | Real incident | What happened |
|---|---|---|
| Dependency | event-stream (2018) | A new maintainer added code to a popular npm package that stole cryptocurrency wallets from one downstream app |
| Dependency | xz-utils (2024) | A contributor spent two years gaining maintainer trust, then hid a backdoor targeting SSH in release tarballs |
| Build | SolarWinds (2020) | Attackers modified the build system so signed, official releases contained a backdoor |
| CI tooling | tj-actions/changed-files (2025) | Attackers repointed the action's version tags to a malicious commit that dumped CI secrets into build logs |
| Distribution | Codecov (2021) | A modified uploader script on the vendor's server exfiltrated environment variables from customers' CI |

The common thread: the victims ran code they didn't review because it came from a trusted name. Supply chain security replaces implicit trust in names with explicit checks on exact versions, digests and signatures.

## Lockfiles and pinning

A manifest like `"express": "^4.21.0"` describes a *range*. The version you get depends on the day you install. A **lockfile** records the exact resolved version and a cryptographic hash of every package, direct and transitive:

```json
{
  "packages": {
    "node_modules/express": {
      "version": "4.21.2",
      "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
      "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="
    }
  }
}
```

Commit lockfiles, and install from them in CI with the command that **refuses to deviate**:

| Ecosystem | Lockfile | Strict install in CI |
|---|---|---|
| npm | `package-lock.json` | `npm ci` (fails if lockfile and manifest disagree) |
| pnpm | `pnpm-lock.yaml` | `pnpm install --frozen-lockfile` |
| Go | `go.sum` | Verified automatically on every build; checksums are also checked against `sum.golang.org` |
| Python (pip-tools) | `requirements.txt` with hashes | `pip install --require-hashes -r requirements.txt` |
| Python (uv) | `uv.lock` | `uv sync --locked` |

Pinning without updating just freezes old vulnerabilities in place. Let a bot open update pull requests, so every version change goes through review and CI. A short **cooldown** avoids installing a release in the first days after publication, when most malicious versions are caught and pulled:

```yaml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    cooldown:
      default-days: 5
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
```

Install scripts are another risk: npm runs a package's `postinstall` hook with your user's permissions, CI secrets included. If your build doesn't need them, run `npm ci --ignore-scripts`.

## Typosquatting and dependency confusion

**Typosquatting** publishes a malicious package under a name one typo away from a popular one, such as `reqeusts` for `requests` or `crossenv` for `cross-env`. Defences are cultural as much as technical: copy install commands from the project's own documentation, review new dependencies in pull requests like any other code (who maintains it, how old is it, how many downloads), and let SCA flag known-malicious packages.

**Dependency confusion** is subtler. Your company has an internal package `acme-auth` on a private registry. An attacker publishes `acme-auth` version `99.0.0` to the public registry. If your installer checks both registries and picks the highest version, it installs the attacker's package. The fix is to make each name resolve from exactly one place:

```ini
# .npmrc: all @acme packages come only from the internal registry
@acme:registry=https://npm.acme.internal/
```

Scoped names (`@acme/auth`) plus a scope-to-registry mapping make confusion impossible for npm. For Python, the dangerous flag is `--extra-index-url`, which merges a second index with PyPI. Use a single `--index-url` pointing at a proxy (Artifactory, Nexus, or a cloud artifact registry) that serves internal packages and proxies public ones, and register your internal names on the public registry as empty placeholders.

## Pinning CI actions by commit SHA

`uses: some-org/some-action@v4` looks pinned, but a Git tag is a **movable pointer**. Whoever controls the repository, or whoever steals the maintainer's token, can repoint `v4` at new code. That is exactly what happened with tj-actions/changed-files: thousands of workflows referencing a version tag ran the attacker's commit without a single change on their side.

A full commit SHA is immutable. Look it up and keep the human-readable version as a comment:

```bash
git ls-remote --tags https://github.com/actions/checkout refs/tags/v7.0.1
```

```text
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1
```

```yaml
steps:
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
    with:
      persist-credentials: false
```

If a tag is *annotated*, that SHA is the tag object, not the commit: also ask for `'refs/tags/v7.0.1^{}'` and use the SHA on that dereferenced line, which is the commit itself. Dependabot and Renovate understand the `@<sha> # vX.Y.Z` convention and update both together, so pinning doesn't mean falling behind. GitHub organisations can also enforce SHA pinning through the allowed-actions policy, which makes the rule impossible to forget.

The same principle applies to container images: `FROM node:22` is a tag, `FROM node:22@sha256:…` is a digest. Pin base images by digest in Dockerfiles and let the update bot move them. GitLab's equivalent risk is `include:` of remote templates and components; pin those to a commit SHA too.

> [!IMPORTANT]
> Pinning protects against *changed* code, not *bad* code. Before adding a new third-party action, read what it does, prefer actions from verified publishers, and give the job only the `permissions:` it needs. A pinned malicious action is still malicious.

## SLSA and build provenance

**SLSA** (Supply-chain Levels for Software Artifacts, pronounced "salsa") is a framework for answering one question about an artifact: *can I trust that it was built from the source I think, by the process I think?* Its Build track defines levels:

| Level | Requirement | What it defends against |
|---|---|---|
| Build L0 | Nothing | — |
| Build L1 | Provenance exists, describing how the artifact was built | Mistakes; gives you an audit trail |
| Build L2 | Builds run on a hosted platform that generates and **signs** the provenance | Tampering after the build; forged provenance |
| Build L3 | The build platform is hardened: builds are isolated from each other and signing keys are unreachable from build steps | A compromised build step forging provenance for a different artifact |

**Provenance** is a signed statement in the in-toto format: "artifact with digest `sha256:ab12…` was built by workflow `release.yml` at commit `9f3e…` in repository `acme/orders-api`, triggered by a push to `main`." Consumers verify the signature and check those fields before trusting the artifact.

On GitHub Actions, `actions/attest` produces signed SLSA provenance using a short-lived Sigstore certificate:

```yaml
permissions:
  contents: read
  packages: write          # push the image to GHCR
  id-token: write          # request an OIDC token for signing
  attestations: write      # store the attestation
  artifact-metadata: write
steps:
  # ... build and push the image, capturing its digest in steps.push ...
  - uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
    with:
      subject-name: ghcr.io/acme/orders-api
      subject-digest: ${{ steps.push.outputs.digest }}
      push-to-registry: true
```

Anyone can then verify where an image came from:

```bash
gh attestation verify oci://ghcr.io/acme/orders-api@sha256:4c1e... --owner acme
```

Hosted GitHub runners with this action meet Build L2; running the build in a reusable workflow, isolated from the calling repository's own steps, is the usual route to L3.

## Signing and verifying images with cosign

Signing proves an image came from you and hasn't changed since. Traditional signing means managing a long-lived private key, which then becomes the thing attackers steal. **Sigstore** removes the key:

1. The signer (your CI job) gets an **OIDC token** proving its identity, e.g. "workflow `release.yml` in `acme/orders-api` on `refs/heads/main`."
2. **Fulcio**, Sigstore's certificate authority, exchanges the token for a signing certificate valid for about ten minutes, bound to that identity.
3. cosign signs the image digest with an ephemeral key and records the signature in **Rekor**, a public append-only transparency log.
4. The ephemeral private key is discarded. There is nothing left to steal.

```yaml
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write          # required for keyless signing
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          push: true
          tags: ghcr.io/acme/orders-api:${{ github.sha }}
      - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      - name: Sign by digest
        run: cosign sign --yes "ghcr.io/acme/orders-api@${{ steps.push.outputs.digest }}"
```

Always sign the **digest**, never a tag: a tag can be moved to a different image after you sign it.

A signature is worthless unless something checks it, and *checking that a signature exists* is not enough, because anyone can get a valid Sigstore signature for their own identity. Verification must pin **who** signed:

```bash
cosign verify ghcr.io/acme/orders-api@sha256:4c1e... \
  --certificate-identity "https://github.com/acme/orders-api/.github/workflows/release.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
```

```text
Verification for ghcr.io/acme/orders-api@sha256:4c1e... --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The code-signing certificate was verified using trusted certificate authority certificates
```

Run that verification where it counts: as a gate in the deploy job, and in the cluster through an admission controller (Sigstore policy-controller or Kyverno's `verifyImages` rule) so that unsigned images, or images signed by any other workflow, are rejected before they run.

> [!WARNING]
> `--certificate-identity-regexp '.*'` makes verification pass for any signer on the internet. Match the exact workflow path and branch, or at minimum anchor the regex to your organisation: `^https://github\.com/acme/`.

## Practice

You inherit this release workflow. Harden it: pin every action by SHA, apply least-privilege permissions, sign the pushed image keylessly, and add a verify step that would fail if the image was signed by a different workflow.

```yaml
on:
  push:
    branches: [main]
permissions: write-all
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v7
        with:
          push: true
          tags: ghcr.io/acme/orders-api:latest
```

**Solution**

```yaml
on:
  push:
    branches: [main]
permissions: {}                # nothing by default; grant per job
jobs:
  release:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: push
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          push: true
          tags: ghcr.io/acme/orders-api:${{ github.sha }}
      - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      - name: Sign
        env:
          IMAGE: ghcr.io/acme/orders-api@${{ steps.push.outputs.digest }}
        run: cosign sign --yes "$IMAGE"
      - name: Verify
        env:
          IMAGE: ghcr.io/acme/orders-api@${{ steps.push.outputs.digest }}
        run: |
          cosign verify "$IMAGE" \
            --certificate-identity "https://github.com/${{ github.workflow_ref }}" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com"
```

What changed and why:

- `permissions: write-all` gave every step write access to the repository, issues and releases. Now the default is nothing and the job holds only what it uses.
- Each action is pinned to the commit its tag pointed to when reviewed; the comment keeps it readable and lets Dependabot update it.
- `persist-credentials: false` stops checkout leaving a token in `.git/config` for later steps to find.
- The tag `latest` is replaced by the commit SHA, and signing targets the immutable digest.
- `github.workflow_ref` expands to `acme/orders-api/.github/workflows/release.yml@refs/heads/main`, exactly the identity in the Fulcio certificate, so the verify step fails for a signature made by any other workflow or branch.

The digest is passed through `env` rather than interpolated into the script, the same injection-safe habit from the CI lesson.

## Key takeaways

- Supply chain attacks exploit trust in names; replace that trust with checks on exact versions, digests and signatures.
- Commit lockfiles, install with `npm ci` or equivalent, and let a bot propose reviewed updates with a short cooldown.
- Prevent dependency confusion by resolving each package name from exactly one registry.
- Pin third-party CI actions and base images by full SHA or digest; tags can be moved.
- SLSA Build levels measure how trustworthy your provenance is; `actions/attest` gives signed provenance on hosted runners.
- Sign image digests keylessly with cosign, and verify the exact signer identity at deploy and admission time.

## Quiz

1. Your workflow uses `some-org/scan-action@v3`. The maintainer's account is compromised and v3 is repointed to malicious code. What would have protected you?

   A. Running the workflow only on pull requests
   B. Pinning the action to a full commit SHA
   C. Using `@v3.1` instead of `@v3`
   D. Setting `continue-on-error: true` on the step

   **Answer: B.** Any tag, including a full semver tag like v3.1.0, can be moved. A 40-character commit SHA cannot be changed to point at different code.

2. What makes Sigstore signing 'keyless'?

   A. Signatures are not cryptographic, just log entries
   B. The private key is stored in GitHub secrets
   C. The image is hashed but not signed
   D. An ephemeral key is certified against the signer's OIDC identity for a few minutes, logged in Rekor, then discarded

   **Answer: D.** There is still a key pair, but it lives only for the signing operation. Trust comes from the short-lived Fulcio certificate binding it to an identity and from the public transparency log.

3. Why is `cosign verify` without `--certificate-identity` (or with a regexp of `.*`) insufficient?

   A. Anyone can obtain a valid Sigstore signature for their own identity, so you must check who signed
   B. Verification only works with a key file
   C. It would verify the tag instead of the digest
   D. It skips the transparency log check

   **Answer: A.** An attacker can push an image and sign it keylessly with their own GitHub workflow. Only pinning the expected identity and issuer distinguishes your signature from theirs.

4. An attacker publishes `acme-billing@99.0.0` to the public npm registry, matching your private package's name. Which control prevents it being installed?

   A. Running npm audit
   B. Using `--extra-index-url` for the private registry
   C. A scoped name such as @acme/billing mapped to the internal registry in .npmrc
   D. Signing your container images

   **Answer: C.** Dependency confusion relies on the installer considering both registries. A scope mapped to one registry means that name can only ever resolve there.
