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: thev1tag 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://... | shinstalls the latest version of a tool, with no check; -
nobody can say which library versions run in the
orderimage 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.
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_TOKENis read-only by default. -
Every
FROMand 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 auditandnpm auditrun 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
- Trivy incident: advisory GHSA-69fq-xp46-6x23, CVE-2026-33634 record, Aqua Security announcement.
- GitHub: Secure use reference, SHA pinning policy, 2026 security roadmap, gh-actions-lock, repository Actions settings.
-
Dependabot:
options
(
directories,groups,cooldown), version comments, workflow images; Renovate, GitHub Actions. - Docker, Pin base image versions.
- Formats and tools: CycloneDX, SPDX, Syft, Trivy.
- Sigstore: signing containers, keyless signing, cosign attest, cosign-installer.
- Composer: audit, policy; npm: audit, ci.
- Proxies: Nexus Repository, Artifactory, Verdaccio, Private Packagist.
- Other platforms: GitLab CI/CD components, Azure Pipelines tasks, proto configuration.