The code your team writes is a small fraction of what you ship. The rest is base images, open-source libraries and their own dependencies, pulled from the internet at build time. Attackers know this, and compromising a dependency or a build system reaches thousands of victims at once. This module covers how artifacts are stored, named and versioned, and the practical controls that let you answer two questions at any time: what exactly is running, and is it known to be vulnerable?
- Explain what an artifact registry is for and how tags differ from digests
- Apply semantic versioning and an image tagging scheme that makes every deploy traceable
- Pin dependencies with lockfiles and digests so that builds are reproducible
- Scan images and dependencies for known vulnerabilities and triage the results sensibly
- Generate a software bill of materials and explain what signing and provenance add
Artifacts and registries
An artifact is the immutable output of a build: a container image, a JAR, a Python wheel, an npm package, a Helm chart. A registry (or artifact repository) stores artifacts, controls who may push and pull them, and serves them to deploy targets. It is the hand-off point between CI, which produces artifacts, and CD, which consumes them.
Container registries include Docker Hub, GitHub Container Registry, and one from every cloud provider. A full image reference has four parts:
ghcr.io / acme/orders-api : 1.4.2 @sha256:9b2c...e41f
| | | |
registry repository tag digestThe distinction between the last two is the most important idea in this module. A tag is a human-friendly, mutable label: whoever can push may point 1.4.2, and certainly latest, at a different image tomorrow. A digest is the SHA-256 hash of the image manifest. It is immutable and content-addressed: if a single byte changes, the digest changes. Pulling by digest guarantees you get precisely the image you tested.
docker pull ghcr.io/acme/orders-api:1.4.2
docker inspect --format '{{index .RepoDigests 0}}' ghcr.io/acme/orders-api:1.4.2
# ghcr.io/acme/orders-api@sha256:9b2c...
docker buildx imagetools inspect ghcr.io/acme/orders-api:1.4.2 # manifest and platformsMany registries let you mark a repository's tags as immutable, so an existing tag can never be overwritten. Turn it on for release repositories; it removes a whole class of "but it was working yesterday" incidents.
Versioning and tagging
Semantic versioning encodes compatibility in the number MAJOR.MINOR.PATCH. Increment PATCH for backwards-compatible bug fixes, MINOR for backwards-compatible new features, and MAJOR for breaking changes. It is a promise to your consumers about what upgrading will cost them, and it is what makes version ranges in package managers meaningful.
For deployable images, tag every build with the commit SHA, which is unique and traceable, and add a semantic version tag when you cut a release. Deploy by SHA tag or digest. Treat latest as a convenience for humans, never as something a production manifest refers to.
SHA="$(git rev-parse --short HEAD)"
IMAGE=ghcr.io/acme/orders-api
docker build -t "$IMAGE:$SHA" .
docker push "$IMAGE:$SHA"
# on a release, add the version tag to the image that was already built and tested
docker tag "$IMAGE:$SHA" "$IMAGE:1.4.2"
docker push "$IMAGE:1.4.2"Note that the release step tags an existing image; it does not rebuild. Registries also need housekeeping. Set a retention policy that deletes old untagged and per-commit images while keeping released versions, or storage costs grow without limit.
Pinning for reproducible builds
A build is reproducible when the same commit produces the same artifact next month. Version ranges work against that: a manifest that allows ^4.17.0 installs whatever matching version is newest on build day. A lockfile records the exact version, and usually a hash, of every direct and transitive dependency, which is a dependency of a dependency and makes up most of your tree.
| Ecosystem | Lockfile | Install exactly what it says |
|---|---|---|
| npm | package-lock.json | npm ci |
| Python (pip-tools) | requirements.txt with hashes | pip install --require-hashes -r requirements.txt |
| Go | go.sum | go mod download / go mod verify |
| Rust | Cargo.lock | cargo build --locked |
Commit the lockfile, and make CI use the strict install command so that a drifted lockfile fails the build instead of being silently updated. Apply the same thinking to base images: FROM python:3.12-slim moves as patches are released, while a digest pin does not.
# tag for humans, digest for the machine
FROM python:3.12-slim@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefPinning has a cost: pinned things do not update themselves, including for security fixes. The answer is not to stop pinning but to automate updates. Tools such as Dependabot and Renovate open pull requests that bump lockfiles and digests, and your pipeline tests each one. You get reproducibility and freshness, with every change reviewed and revertable.
Scanning for known vulnerabilities
A vulnerability scanner lists the packages in an image or lockfile and matches them against databases of published vulnerabilities, identified by CVE numbers. Open-source scanners include Trivy and Grype; registries and Git hosts often have one built in. Run a scan in the pipeline on every build, and re-scan what is already deployed on a schedule, because new CVEs are published against old images every day.
trivy image ghcr.io/acme/orders-api:1.4.2 # OS packages and language deps
trivy image --severity HIGH,CRITICAL --ignore-unfixed \
--exit-code 1 ghcr.io/acme/orders-api:1.4.2 # fail the pipeline on serious, fixable issues
trivy fs . # scan the repo's lockfiles
trivy config . # misconfigurations in Dockerfiles, IaCThe first scan of a typical image returns a discouraging list. Triage it with three questions.
- Is there a fix? If a patched version exists, upgrading is usually cheap.
--ignore-unfixedhides findings you cannot act on yet. - Is it reachable? A vulnerable library function your code never calls, in a package that is only present because the base image is large, is a lower priority than a flaw in your request-parsing path.
- Can you remove it entirely? Moving to a slim or distroless base deletes whole categories of findings, which is the security payoff of the multi-stage builds from the Docker module.
Gate on a threshold you will actually enforce, such as fixable HIGH and CRITICAL findings. A gate that fails every build gets disabled within a week. Record accepted risks in an ignore file with a reason and an expiry date, reviewed like code.
SBOMs, signing and provenance
A software bill of materials (SBOM) is a machine-readable inventory of every component in an artifact, with versions and licences. The two common formats are SPDX and CycloneDX. Its value shows on the day a major vulnerability is announced: instead of rebuilding and scanning everything, you query the SBOMs you stored and know within minutes which services contain the affected library.
syft ghcr.io/acme/orders-api:1.4.2 -o spdx-json > sbom.spdx.json
trivy image --format cyclonedx --output sbom.cdx.json ghcr.io/acme/orders-api:1.4.2
grype sbom:./sbom.spdx.json # scan the inventory later, without the imageScanning tells you what is inside. Signing tells you where it came from. The pipeline signs the image digest after building it, and the deploy target verifies the signature before running anything, so an image pushed to your registry by someone other than your pipeline is rejected. Sigstore's cosign is the common open-source tool, and it supports keyless signing tied to the pipeline's OIDC identity.
cosign sign ghcr.io/acme/orders-api@sha256:9b2c0f5e... # sign the digest, not a tag
cosign verify ghcr.io/acme/orders-api@sha256:9b2c0f5e... \
--certificate-identity-regexp 'https://github.com/acme/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comProvenance goes one step further: a signed statement of how the artifact was built, from which commit, by which workflow. The SLSA framework describes increasing levels of this assurance. You do not need all of it on day one. A sensible order is: lockfiles and pinned bases, scanning in CI, SBOMs stored per release, then signing with verification at deploy.
Common supply-chain attacks
- Typosquatting: a malicious package named almost like a popular one, waiting for a typing mistake in an install command.
- Dependency confusion: your internal package name is registered on the public registry with a higher version, and the package manager prefers it. Defend with scoped or namespaced packages and by configuring which registry serves which names.
- Compromised maintainer or build: a legitimate package publishes a malicious release. Lockfiles with hashes stop it arriving silently; reviewed update pull requests give you a chance to notice.
- Mutable references: a tag or an unpinned GitHub Action changes underneath you. Pin images by digest and third-party actions by commit SHA.
Pulling everything through a pull-through cache or private proxy registry gives you one place to enforce these rules, and it also protects your builds from upstream outages and rate limits.