The problem

Take an online shop organised as a monorepo. The Symfony services order, stock, shipping and billing sit next to Vue.js fronts, and moon drives the tasks. On every commit, the CI builds one image per service, pushes it to a registry, and each environment deploys it by digest.

This pipeline runs a lot of code the team did not write: third-party actions, base images, a vulnerability scanner, Composer and npm packages. That code runs with the pipeline's rights: registry token, signing identity, sometimes access to staging. This is the software supply chain.

The symptoms look alike from one repository to the next:

  • uses: vendor/scan-action@v1: the v1 tag may point to another commit tomorrow;
  • FROM php:8.4-fpm-alpine: two builds of the same commit do not necessarily start from the same image;
  • curl -sSfL https://... | sh installs the latest version of a tool, with no check;
  • nobody can say which library versions run in the order image in production.

This risk has materialised. On 19 March 2026, an attacker holding compromised credentials published a malicious release of Trivy, Aqua Security's scanner (CVE-2026-33634). They also redirected 76 of the 77 tags of aquasecurity/trivy-action, and all those of aquasecurity/setup-trivy, to code that stole secrets.

According to the security advisory, the workflows that referenced these actions by tag during the exposure window ran that code. Those that pinned them to the SHA of a clean commit were not affected, nor were Trivy images referenced by digest. The tool in charge of security had become the way in.

The options

Follow tags and latest versions

  • What it solves: nothing to maintain, fixes arrive on their own.
  • What it costs: the code that runs changes without review. A moved tag or a malicious release enters the pipeline on the next build.

Pin version numbers

  • What it solves: every update becomes a decision, visible in the repository.
  • What it costs: a Git tag or an image tag can still be changed by whoever controls the publisher's repository. The displayed version does not guarantee the content.

Pin hashes and automate their updates

  • What it solves: a commit SHA, an image digest or a checksum identify exact content. A tag rewritten by the publisher no longer changes anything on your side.
  • What it costs: references that are unreadable without a comment, and a steady flow of update pull requests to review.

Rebuild everything in isolation

  • What it solves: tools compiled from source, dependencies served by internal mirrors, no public download during the build.
  • What it costs: heavy operations. Mirrors must be maintained, tools recompiled, and security advisories followed in-house.

Our recommendation

Pin everything the CI runs by hash, route dependencies through a proxy, and ship every image signed, with its SBOM.

The pin fixes what runs. A bot proposes the updates, code review accepts them: every version change becomes a reviewed diff.

What the CI runs Pinned by What the pin prevents
Actions (uses:) full commit SHA a tag moved to other code
Base and test images sha256: digest an image republished under the same tag
Command-line tools version and checksum a binary replaced on the publisher's side
Composer and npm packages lockfile, installed through a proxy an unplanned version, an untracked download

The SBOM (Software Bill of Materials) is the inventory of an image's components, in a standard format. It answers the question "does this vulnerable library run in production?" without rebuilding the image. The signature, for its part, lets the deployment refuse an image the CI did not produce.

Everything the CI runs is pinned; the image ships signed, with its SBOM Pinned inputs Pipeline Delivery Third-party actions uses: actions/checkout@3d3c42e… prevents: a moved tag Base and test images FROM php:8.4-fpm-alpine@sha256:… prevents: a republished image Tools: scan, SBOM, signature version and SHA-256 checksum prevents: a replaced binary Composer and npm dependencies lockfile, installed through a proxy prevents: an unplanned version CI one image per service and per commit dependency audit image build CycloneDX SBOM digest signature Registry shop/order @sha256:… signature SBOM attestation Deployment by digest checks the signature and the attestation Avoid: references that move @v1 · :latest · curl … | sh · composer update

CycloneDX or SPDX

Two SBOM formats coexist. CycloneDX, an OWASP project standardised by Ecma International (ECMA-424), is at version 1.7. SPDX, a Linux Foundation project, is an ISO standard in its version 2.2.1 (ISO/IEC 5962:2021); its current version is 3.0.1.

Syft and Trivy produce both formats. The choice matters less than consistency: one format, produced by the same tool on every build, to compare two versions of an image.

The trade-offs we accept

A continuous flow of updates. Every pin becomes a pull request when the publisher releases. Group them by ecosystem and keep a cadence, otherwise the pile grows and nobody reads them anymore. A pin that is never updated ends up freezing a vulnerable version.

The SHA only covers the direct reference. A composite action can call other actions by tag. In the Trivy incident, a trivy-action pinned to a commit older than 9 April 2025 still fetched setup-trivy by tag, and remained exposed.

GitHub's documentation does not say whether the pinning policy covers actions called by a composite action: check it. Nor does the policy see a tool the action downloads itself: so we read the action.yml of the actions we keep. GitHub is preparing a lock file for these transitive dependencies, in technical preview at the time of writing.

The proxy becomes a critical component. If it goes down, no build passes: monitor it and back it up like the CI. It tracks downloads and lets you block a version, but it replaces neither the audit nor the review of updates.

Keyless signing publishes the workflow's identity. In keyless mode, Sigstore records each signature in its public transparency log, Rekor, with a certificate that names the repository and the workflow. For a private repository or a self-hosted forge in an isolated zone, sign with a key managed in a KMS instead (cosign sign --key). With cosign 3, --tlog-upload=false is rejected: pass --signing-config a configuration without a log, created by cosign signing-config create.

The signal that should make us revisit these choices: bypassed audits, updates piling up, a signature check disabled "temporarily". These are the signs of a setup too noisy to be kept up.

Implementation

The examples use GitHub Actions. The principle applies elsewhere: GitLab CI accepts a commit SHA for its components and a digest for its images. Azure Pipelines references its tasks by version: checking tools by checksum is up to you.

1. Take stock

List what the CI downloads and runs:

grep -rn "uses:" .github
grep -rn "^FROM" --include=Dockerfile services fronts
grep -rn -e "curl" -e "wget" .github/workflows tools

Every reference to a tag, to latest or to a URL without a checksum needs pinning.

2. Pin actions by SHA

Each uses: designates the full SHA of a commit, followed by the version in a comment: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1. Check that this SHA comes from the action's repository, not from a fork. The complete workflow is in step 6.

Then enable the "Require actions to be pinned to a full-length commit SHA" policy, at organisation or repository level. A workflow that references an action by tag then fails at startup. Also declare permissions: contents: read at the top of the workflow: each job widens the GITHUB_TOKEN only for its own needs.

3. Pin images by digest

# services/order/Dockerfile (excerpt)
FROM composer:2.10@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac AS composer

FROM php:8.4-fpm-alpine@sha256:78cd8de9970a9cd6ff4d98860a94eb5bf37dd2d5776b4785562dbfad01c29d5a
COPY --from=composer /usr/bin/composer /usr/bin/composer

The images of test services, such as PostgreSQL in a job, are pinned the same way. Dependabot does not update them in a workflow; Renovate does.

Dependabot keeps the SHAs, the Dockerfile digests and the lockfiles up to date. It updates the version comment along with the SHA, and groups updates by ecosystem:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    groups:
      actions:
        patterns: ['*']
  - package-ecosystem: docker
    directories: ['/services/*', '/fronts/*']
    schedule:
      interval: weekly

The composer and npm blocks follow the same model.

By default, Dependabot waits three days before proposing a new version (the cooldown option), except for security updates. This delay lowers the risk of adopting a malicious release that is withdrawn shortly after publication. With Renovate, the helpers:pinGitHubActionDigests preset pins actions the same way.

4. Install tools with their checksum

#!/usr/bin/env sh
# tools/install-syft.sh: version and checksum versioned in the repository
set -eu
SYFT_VERSION=1.52.0
SYFT_SHA256=caeedb81fb0491615f1ebd1761e4145d41ee86dd2cc7bf80669f9f5ad9d6133d

curl -sSfLo syft.tar.gz \
  "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz"
echo "${SYFT_SHA256}  syft.tar.gz" | sha256sum --check
tar -xzf syft.tar.gz syft

The checksum comes from the release's syft_1.52.0_checksums.txt file. That file is signed with Sigstore: verify its signature (cosign verify-blob) when you record the checksum, not on every build. For tools installed by proto, .prototools already fixes the version; its lock file, which adds the checksums, is still marked unstable.

5. Proxy, lockfiles and dependency audit

A dependency proxy is the single crossing point between the CI and the public registries. Nexus Repository Pro or Cloud and Artifactory handle Composer and npm, Verdaccio is limited to npm, Private Packagist serves as a Composer mirror. Every download leaves a trace there, and a suspicious version is blocked in one place.

# services/order: Composer goes through the proxy, no longer through packagist.org
composer config repositories.proxy composer https://proxy.example.com/composer/
composer config repo.packagist.org false

# fronts/shop: npm goes through the proxy
npm config set registry https://proxy.example.com/npm/ --location=project

The CI installs from the lockfiles, with composer install and npm ci, never composer update or npm install. The package-lock.json records the hash (integrity) of each archive.

Then each project declares a moon audit task, which moon ci runs with the other checks:

# services/order/moon.yml (excerpt)
tasks:
  audit:
    command: 'composer audit --locked'
    inputs:
      - 'composer.lock'
    options:
      # The advisory database changes while the lockfile does not:
      # no cache, and a run on every build.
      cache: false
      runInCI: 'always'

With Composer 2.10, composer audit exits with code 1 when a package breaks a policy: security advisory, abandoned package or flagged malware. On the front side, npm audit fails on the first vulnerability, --audit-level sets a threshold, and npm audit signatures checks the registry signatures. Both tools query the configured repositories: check that your proxy relays the advisories and the signing keys.

6. Produce the SBOM, sign and attest the image

The workflow builds each service's image, then produces its SBOM, signs it and attaches the SBOM as a signed attestation. The matrix is fixed to stay readable; in practice, it reuses the affected projects computed by moon in the CI workflow.

# .github/workflows/images.yml
name: images
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  image:
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        service: [order, stock, shipping, billing]
    permissions:
      contents: read
      id-token: write # keyless signing: OIDC token for Sigstore
    env:
      IMAGE: registry.example.com/shop/${{ matrix.service }}
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: registry.example.com
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_TOKEN }}
      - id: build
        uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
        with:
          context: .
          file: services/${{ matrix.service }}/Dockerfile
          push: true
          tags: ${{ env.IMAGE }}:${{ github.sha }}
      - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
      - run: sh tools/install-syft.sh
      - name: SBOM, signature and attestation of the digest
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          ./syft scan "registry:${IMAGE}@${DIGEST}" -o cyclonedx-json=sbom.cdx.json
          cosign sign --yes "${IMAGE}@${DIGEST}"
          cosign attest --yes --type cyclonedx --predicate sbom.cdx.json "${IMAGE}@${DIGEST}"

We sign the digest, never the tag, as cosign asks: a tag can change target between the build and the signature. The cosign-installer action verifies the integrity of the binary it installs. Trivy produces an equivalent document with trivy image --format cyclonedx --output sbom.cdx.json "${IMAGE}@${DIGEST}".

7. Verify at deployment, then rescan

A signature nobody verifies brings nothing. Before applying a new digest, the deployment agent of the target environment checks the signature and the attestation:

# In the target environment, before deploying the digest
cosign verify-attestation --type cyclonedx \
  --certificate-identity "https://github.com/ORG/REPO/.github/workflows/images.yml@refs/heads/main" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  "registry.example.com/shop/order@${DIGEST}"

cosign verify, with the same options, checks the image signature. In an isolated zone, give cosign Sigstore's trusted root (--trusted-root). On Kubernetes, an admission controller can enforce this rule across the whole cluster.

The SBOM is still useful after delivery. A scheduled task rereads with trivy sbom the SBOM extracted from the attestations of step 7: a CVE published this morning surfaces without rebuilding the image.

Checklist

  • Every uses: points to a full commit SHA, with the version in a comment.
  • The SHA pinning policy is enabled, and the GITHUB_TOKEN is read-only by default.
  • Every FROM and every test service image carry a digest.
  • Every downloaded tool is checked against its checksum.
  • The composite actions you keep have been read, dependencies included.
  • Dependabot or Renovate covers actions, images, Composer and npm, with a cadence that is kept.
  • Composer and npm go through a proxy; the CI installs from the lockfiles.
  • composer audit and npm audit run on every build, without cache.
  • Every image is signed and carries an SBOM attestation, on its digest.
  • The deployment refuses an unsigned image or one without an attestation.
  • The SBOMs of the images in production are rescanned regularly.

Sources