Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

GitLab CI: secure the supply chain with SBOMs and attestations

By updated

Improve this page

A pipeline that builds a Docker image without signing it or documenting its components provides no integrity guarantee. An attacker who compromises the registry, the runner or an include can replace the artifact silently. This guide shows you how to add three layers of protection, SBOM, signature and attestation, to your GitLab CI pipelines, with open source tools that you can test right away on your KVM instance.

What you will learn

By the end of this guide, you will know how to:

  • Generate an SBOM (Software Bill of Materials) in SPDX or CycloneDX format with Syft and Trivy
  • Sign an OCI image with cosign (Sigstore) and store the signature in the registry
  • Produce a SLSA attestation of provenance attached to the image
  • Verify the signature and the attestation in a deployment pipeline
  • Use the SBOM to detect known vulnerabilities, keep the report of a failed job and document the exceptions

Prerequisites

This lesson assumes that you know what a container image and its digest are, that you have already built and pushed an image to a GitLab project's registry, and that you are comfortable with protected and masked variables. The Developer role is enough to run a pipeline and read its artifacts, but not to set everything up: the cosign private key must be stored as a protected and masked CI variable in Settings > CI/CD > Variables, which requires the Maintainer role on the project (a Developer gets a 403 Forbidden on those variables). The container registry must be enabled on the project and accept OCI artifacts, otherwise the signature and the attestation have nowhere to be stored.

On the tooling side, the jobs install Syft, cosign and crane themselves, while Trivy comes from a pinned image. On your workstation you need cosign (same version as in the jobs) to generate the key pair, plus jq and base64 to read the attestations. The final lab also requires DSOXLab. Upstream lessons:

In which context?

Supply chain security for CI/CD is not a theoretical exercise. You need it when:

  • You deploy Docker images to production and must prove their integrity to an auditor or a customer
  • Your organization must comply with SLSA Level 2+, the NIST SSDF or the NIS2 directive
  • You use third-party base images and need to know exactly which components they embed
  • A supply chain incident (compromised dependency, tampered registry) forces you to trace what was deployed
  • You run an internal registry and want to block unsigned images

The three layers of the CI supply chain

Supply chain security relies on three complementary mechanisms:

LayerQuestionToolFormat
SBOMWhich components does my image contain?Syft, TrivySPDX, CycloneDX
SignatureHas the image been modified?cosign (Sigstore)OCI signature
AttestationWho built it, when, how?cosign attestIn-toto / SLSA

Each layer answers a different question. An SBOM without a signature proves nothing. A signature without an attestation does not say where the artifact comes from. The three together form a verifiable chain of trust.

Generate an SBOM in the pipeline

Two tools produce the SBOM of an image in a GitLab job: Syft, specialized in inventory, and Trivy, which can also scan for vulnerabilities. Both read the image from the project registry, right after the build.

With Syft

Syft (Anchore) analyzes an image and produces a detailed SBOM. Its official image, anchore/syft, contains no shell: GitLab cannot run script: in it, even with entrypoint: [""] (measured on v1.54.1: sh not found). The job therefore starts from a pinned Alpine and installs Syft from the official release, verifying its checksum:

.gitlab-ci.yml
sbom-syft:
stage: test
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
variables:
SYFT_VERSION: "1.54.1"
SYFT_REGISTRY_AUTH_AUTHORITY: $CI_REGISTRY
SYFT_REGISTRY_AUTH_USERNAME: $CI_REGISTRY_USER
SYFT_REGISTRY_AUTH_PASSWORD: $CI_REGISTRY_PASSWORD
before_script:
- apk add --no-cache curl tar
- base="https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}"
- curl -sSfLO "${base}/syft_${SYFT_VERSION}_linux_amd64.tar.gz"
- curl -sSfLO "${base}/syft_${SYFT_VERSION}_checksums.txt"
- grep "syft_${SYFT_VERSION}_linux_amd64.tar.gz" "syft_${SYFT_VERSION}_checksums.txt" | sha256sum -c -
- tar -C /usr/local/bin -xzf "syft_${SYFT_VERSION}_linux_amd64.tar.gz" syft
script:
- syft "registry:${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}"
-o spdx-json=sbom-spdx.json -o cyclonedx-json=sbom-cdx.json
artifacts:
paths:
- sbom-spdx.json
- sbom-cdx.json
expire_in: 90 days

The registry: prefix reads the image directly from the GitLab registry, without a Docker daemon, with the credentials that GitLab provides to the job. The two -o options produce both formats in a single analysis. The SBOM lists every package, library and dependency with its exact version. The installation and the analysis were replayed in an Alpine 3.24.2 container: checksum OK, two SBOMs produced.

With Trivy

Trivy can produce an SBOM and scan for vulnerabilities in a single pass:

.gitlab-ci.yml
sbom-trivy:
stage: test
image:
name: aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
entrypoint: [""]
variables:
TRIVY_USERNAME: $CI_REGISTRY_USER
TRIVY_PASSWORD: $CI_REGISTRY_PASSWORD
script:
- trivy image --format spdx-json --output sbom-trivy.json
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
- trivy sbom sbom-trivy.json --severity HIGH,CRITICAL --exit-code 1
artifacts:
paths:
- sbom-trivy.json
expire_in: 90 days

The TRIVY_USERNAME and TRIVY_PASSWORD variables give Trivy access to the project registry. The second command uses the SBOM as input to detect CVEs: replayed on nginx:1.29.2-alpine, it finds 3 CRITICAL and 42 HIGH and exits with 1, which fails the job. Trivy reads its own SPDX without difficulty, unlike the one produced by Syft (see the complete pipeline).

Check the content of the SBOM

An SBOM is checked before being signed: an empty or incomplete inventory would be attested as is. Three jq queries are enough to check the number of packages, their versions and the presence of a sensitive library:

Terminal window
# Count the components
jq '.packages | length' sbom-spdx.json
# List the components with their version
jq -r '.packages[] | "\(.name) \(.versionInfo)"' sbom-spdx.json | head -20
# Look for a specific package (on Alpine, OpenSSL ships as libssl3)
jq -c '.packages[] | select(.name | contains("ssl")) | {name, versionInfo}' sbom-spdx.json

Result measured on the nginx:1.29.2-alpine image with Syft 1.54.1 (the SPDX document also counts the image itself among its packages):

71
alpine-baselayout 3.7.0-r0
alpine-baselayout-data 3.7.0-r0
alpine-keys 2.5-r0
alpine-release 3.22.2-r0
...
{"name":"libssl3","versionInfo":"3.5.4-r0"}
{"name":"ssl_client","versionInfo":"1.37.0-r19"}

Sign an image with cosign

Install cosign

cosign is part of the Sigstore project. It signs OCI artifacts and stores the signature in the registry, next to the image. This guide uses cosign v3.1.3, installed from its official release and verified by checksum, rather than the Alpine package: Alpine 3.20 still ships v2.2.1, Alpine 3.24 ships v3.0.6. A hidden job .cosign carries the installation and the registry login; the other jobs extend it with extends: in the same .gitlab-ci.yml.

.gitlab-ci.yml
.cosign:
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
variables:
COSIGN_VERSION: "3.1.3"
COSIGN_PASSWORD: $COSIGN_KEY_PASSWORD
before_script:
- apk add --no-cache curl jq crane
- base="https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}"
- curl -sSfLO "${base}/cosign-linux-amd64"
- curl -sSfLO "${base}/cosign_checksums.txt"
- grep ' cosign-linux-amd64$' cosign_checksums.txt | sha256sum -c -
- install -m 0755 cosign-linux-amd64 /usr/local/bin/cosign
- echo "$CI_REGISTRY_PASSWORD" | cosign login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin
- echo "$CI_REGISTRY_PASSWORD" | crane auth login "$CI_REGISTRY" -u "$CI_REGISTRY_USER" --password-stdin

crane is then used, in each job, to resolve the tag to a digest: IMAGE_REF designates the exact content of the image, and not a label that another push could move. The rest of the guide assumes this hidden job.

Sign with a key, without a public log

With a key pair, cosign v3 follows by default the signing configuration published by Sigstore and records every signature in Rekor, the public transparency log. For a private registry, that means publishing the name and digest of your images. As in the SBOM guide, the job therefore creates an empty signing configuration, which calls no public service:

.gitlab-ci.yml
sign-image:
extends: .cosign
stage: deploy
script:
- IMAGE_REF="${CI_REGISTRY_IMAGE}@$(crane digest "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}")"
- echo "$COSIGN_PRIVATE_KEY" > /tmp/cosign.key
- cosign signing-config create --out signing-config.json
- cosign sign --key /tmp/cosign.key --signing-config signing-config.json --yes "$IMAGE_REF"
after_script:
- rm -f /tmp/cosign.key

cosign signing-config create with no option writes {"mediaType":"application/vnd.dev.sigstore.signingconfig.v0.2+json","rekorTlogConfig":{},"tsaConfig":{}}: neither Rekor nor timestamping. The price is paid at verification, which must state explicitly that no log is expected. If your images are public and you want traceability in Rekor, simply remove --signing-config: cosign then uses the public services.

Generate the key pair

The pair is generated only once, on your workstation, with the same version of cosign as the jobs. The private key is encrypted with the password you type, which becomes the COSIGN_KEY_PASSWORD variable:

Terminal window
cosign generate-key-pair

Result measured with cosign 3.1.3:

Enter password for private key:
Enter password for private key again:
Private key written to cosign.key
Public key written to cosign.pub

Store cosign.key in a protected and masked CI variable. Version cosign.pub in the repository for verification.

Keyless mode with Sigstore

Keyless mode eliminates key management: cosign obtains an ephemeral certificate from Fulcio, authenticated by the GitLab OIDC token, and records the signature in Rekor. In v3, the service URLs come from the configuration published by Sigstore: the old --fulcio-url, --rekor-url and --oidc-issuer options are deprecated, and cosign 3.1.3 refuses to sign when they are passed (cannot specify service URLs and use signing config).

.gitlab-ci.yml
sign-keyless:
extends: .cosign
stage: deploy
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
script:
- IMAGE_REF="${CI_REGISTRY_IMAGE}@$(crane digest "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}")"
- cosign sign --identity-token="$SIGSTORE_ID_TOKEN" --yes "$IMAGE_REF"

Verify a signature

Verification uses the public key versioned in the repository, or, in keyless mode, requires the expected pipeline identity and OIDC issuer. A signature made with the empty configuration has no entry in a transparency log: the verification must know it, hence --insecure-ignore-tlog=true.

Terminal window
# With a key, signature made without a public log
cosign verify --key cosign.pub --insecure-ignore-tlog=true "$IMAGE_REF"
# Keyless: verify the pipeline identity (project on gitlab.com)
cosign verify \
--certificate-identity-regexp="^https://gitlab.com/my-group/my-project//" \
--certificate-oidc-issuer=https://gitlab.com \
"$IMAGE_REF"

Output measured with cosign 3.1.3 against a local registry, after signing with a key:

WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Verification for localhost:15002/demo/app@sha256:b03ccb7431a2e3172f5cbae96d82bd792935f33ecb88fbf2940559e475745c4e --
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 signatures were verified against the specified public key

Three measured behaviors to know. Without --insecure-ignore-tlog=true, verifying a signature made without a log fails with not enough verified log entries from transparency log: 0 < 1. The "verified offline" line is displayed anyway: it proves nothing here, it is the warning that tells the truth. Finally, on an image that was never signed, cosign v3 answers no signatures found.

Produce a SLSA attestation

A provenance attestation binds the artifact to its build context: who, when, how, with what.

Attach the SBOM as an attestation

The job wraps the SPDX SBOM produced by sbom-syft in an in-toto attestation signed by the same key as the image, then pushes it to the registry next to it:

.gitlab-ci.yml
attest-sbom:
extends: .cosign
stage: deploy
needs:
- sbom-syft
- sign-image
script:
- IMAGE_REF="${CI_REGISTRY_IMAGE}@$(crane digest "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}")"
- echo "$COSIGN_PRIVATE_KEY" > /tmp/cosign.key
- cosign signing-config create --out signing-config.json
- cosign attest --key /tmp/cosign.key --signing-config signing-config.json --yes
--predicate sbom-spdx.json --type spdxjson "$IMAGE_REF"
after_script:
- rm -f /tmp/cosign.key

SLSA provenance attestation

For a SLSA-compliant attestation, use the slsaprovenance predicate:

.gitlab-ci.yml
attest-provenance:
extends: .cosign
stage: deploy
script:
- IMAGE_REF="${CI_REGISTRY_IMAGE}@$(crane digest "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}")"
- echo "$COSIGN_PRIVATE_KEY" > /tmp/cosign.key
- |
cat > provenance.json <<EOF
{
"buildType": "https://gitlab.com/gitlab-ci",
"builder": {
"id": "$CI_RUNNER_ID"
},
"invocation": {
"configSource": {
"uri": "$CI_PROJECT_URL",
"digest": {"sha1": "$CI_COMMIT_SHA"},
"entryPoint": ".gitlab-ci.yml"
}
},
"metadata": {
"buildStartedOn": "$CI_JOB_STARTED_AT",
"completeness": {
"parameters": true,
"environment": true,
"materials": false
}
},
"materials": [
{
"uri": "$CI_REGISTRY_IMAGE",
"digest": {"sha256": "${IMAGE_REF##*sha256:}"}
}
]
}
EOF
- cosign signing-config create --out signing-config.json
- cosign attest --key /tmp/cosign.key --signing-config signing-config.json --yes
--predicate provenance.json --type slsaprovenance "$IMAGE_REF"
after_script:
- rm -f /tmp/cosign.key

Verify an attestation

cosign verify-attestation checks the signature of the attestation then returns its content encoded in base64: the final jq decodes the payload to read the attested predicate.

Terminal window
# Verify the SBOM attestation
cosign verify-attestation --key cosign.pub --insecure-ignore-tlog=true \
--type spdxjson "$IMAGE_REF" | jq -r '.payload' | base64 -d | jq .
# Verify the provenance attestation
cosign verify-attestation --key cosign.pub --insecure-ignore-tlog=true \
--type slsaprovenance "$IMAGE_REF" | jq -r '.payload' | base64 -d | jq .

Expected result (provenance):

{
"buildType": "https://gitlab.com/gitlab-ci",
"builder": {
"id": "42"
},
"invocation": {
"configSource": {
"uri": "https://gitlab.example.com/my-group/my-project",
"digest": {"sha1": "<commit SHA>"},
"entryPoint": ".gitlab-ci.yml"
}
}
}

Complete pipeline: build → SBOM → sign → attest → verify

Here is a pipeline that chains all the steps:

.gitlab-ci.yml
stages:
- build
- test
- sign
- verify
variables:
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
build:
stage: build
image:
name: ghcr.io/osscontainertools/kaniko:v1.28.6-debug@sha256:7ab44d0131c3051b6dea128067e258c7afb5c4815082a93b9cf6c624f2140bf0
entrypoint: [""]
before_script:
- mkdir -p /kaniko/.docker
- echo "{\"auths\":{\"${CI_REGISTRY}\":{\"auth\":\"$(printf "%s:%s" "${CI_REGISTRY_USER}" "${CI_REGISTRY_PASSWORD}" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
script:
- /kaniko/executor
--context $CI_PROJECT_DIR
--dockerfile Dockerfile
--destination $IMAGE
--digest-file $CI_PROJECT_DIR/image-digest
artifacts:
paths:
- image-digest
sbom:
stage: test
image: alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
variables:
SYFT_VERSION: "1.54.1"
SYFT_REGISTRY_AUTH_AUTHORITY: $CI_REGISTRY
SYFT_REGISTRY_AUTH_USERNAME: $CI_REGISTRY_USER
SYFT_REGISTRY_AUTH_PASSWORD: $CI_REGISTRY_PASSWORD
before_script:
- apk add --no-cache curl tar
- base="https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}"
- curl -sSfLO "${base}/syft_${SYFT_VERSION}_linux_amd64.tar.gz"
- curl -sSfLO "${base}/syft_${SYFT_VERSION}_checksums.txt"
- grep "syft_${SYFT_VERSION}_linux_amd64.tar.gz" "syft_${SYFT_VERSION}_checksums.txt" | sha256sum -c -
- tar -C /usr/local/bin -xzf "syft_${SYFT_VERSION}_linux_amd64.tar.gz" syft
script:
- syft "registry:${IMAGE}" -o spdx-json=sbom.json -o cyclonedx-json=sbom.cdx.json
artifacts:
paths:
- sbom.json
- sbom.cdx.json
vuln-scan:
stage: test
image:
name: aquasec/trivy:0.75.0@sha256:af6acf9a6b85dfe389a1941505c0ce9efef52a4719635e1a962f022a3d855daa
entrypoint: [""]
script:
- trivy sbom sbom.cdx.json --severity HIGH,CRITICAL --exit-code 1
needs:
- sbom
sign-and-attest:
extends: .cosign
stage: sign
needs:
- build
- sbom
script:
- IMAGE_REF="${CI_REGISTRY_IMAGE}@$(crane digest "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}")"
- echo "$COSIGN_PRIVATE_KEY" > /tmp/cosign.key
- cosign signing-config create --out signing-config.json
- cosign sign --key /tmp/cosign.key --signing-config signing-config.json --yes "$IMAGE_REF"
- cosign attest --key /tmp/cosign.key --signing-config signing-config.json --yes
--predicate sbom.json --type spdxjson "$IMAGE_REF"
after_script:
- rm -f /tmp/cosign.key
verify-before-deploy:
extends: .cosign
stage: verify
script:
- IMAGE_REF="${CI_REGISTRY_IMAGE}@$(crane digest "${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHORT_SHA}")"
- cosign verify --key cosign.pub --insecure-ignore-tlog=true "$IMAGE_REF"
- cosign verify-attestation --key cosign.pub --insecure-ignore-tlog=true
--type spdxjson "$IMAGE_REF"
allow_failure: false

This pipeline assumes the hidden job .cosign defined above in the same file. Each job resolves IMAGE_REF to a digest from the tag pushed by build; kaniko also writes this digest into the image-digest artifact.

The vuln-scan job reads the CycloneDX SBOM, not the SPDX. Measured with Syft 1.54.1 and Trivy 0.75.0 on an Alpine image: trivy sbom on Syft's SPDX displays Unsupported os family="none", scans nothing and exits with 0, so a --exit-code 1 gate turns green without having checked anything. On the CycloneDX of the same analysis, it finds the vulnerabilities (1 CRITICAL, 11 HIGH) and exits with 1. The SPDX remains the one that is attested.

Keep the report, and waive only what was triaged

The vuln-scan job is meant to fail, and that is what makes its evidence disappear: with the default artifacts:when: on_success setting, GitLab does not keep the artifacts of a failed job. The report that explains the red pipeline is therefore lost at the very moment it is needed. A --format json --output combined with when: always keeps it readable in the forge in both cases:

script:
- trivy sbom sbom.cdx.json --severity HIGH,CRITICAL --exit-code 1
--format json --output trivy-report.json
artifacts:
when: always
paths:
- trivy-report.json

Then comes the case of a vulnerability the security team has reviewed and decided not to fix right away. Trivy reads those decisions from a .trivyignore file at the repository root, one identifier per line. Two disciplines apply, because a silent exception passes the review and survives for years without anyone knowing why: the reason is written as a comment right above the identifier, and only the triaged finding belongs there.

.trivyignore
# Package present in the image but never loaded by the application.
# Security review of 2026-10-20, to be lifted at the next base change.
CVE-2024-21538

Waiving the other critical vulnerabilities to make the pipeline pass amounts to removing the scanner while keeping its job: the dashboard turns green and the delivery is no safer. A pipeline that stays red after a justified exception is doing its job, and the real fix is to change the image base.

Block unsigned images

To prevent the deployment of unverified images, add a mandatory verification job to the deployment pipeline. Like the other cosign jobs, it logs in to the registry before reading the signature:

.gitlab-ci.yml: deployment pipeline
stages:
- pre-deploy
- deploy
verify-signature:
extends: .cosign
stage: pre-deploy
script:
- |
if ! cosign verify --key cosign.pub --insecure-ignore-tlog=true "$DEPLOY_IMAGE" 2>/dev/null; then
echo "ERROR: unsigned image or invalid signature"
echo "Image: $DEPLOY_IMAGE"
exit 1
fi
echo "Signature verified"
allow_failure: false

For Kubernetes, Kyverno and Connaisseur can block unsigned images at the cluster level.

Troubleshooting

Most failures come from a missing registry authentication, a key that does not match, or an SBOM format that the next tool does not read. The table starts from the symptom displayed by the job.

SymptomLikely causeFix
no signatures found or no matching signaturesImage not signed or wrong keyCheck that cosign.pub matches the key used to sign
not enough verified log entries from transparency log: 0 < 1Signature made without Rekor (empty configuration)Add --insecure-ignore-tlog=true to the verification
cannot specify service URLs and use signing configcosign v2 options (--fulcio-url, --rekor-url, --oidc-issuer) on a cosign v3Remove them: v3 reads the Sigstore configuration, or a configuration created by cosign signing-config create
UNAUTHORIZED: authentication requireddocker login not doneAdd cosign login to before_script with CI_REGISTRY_*
Error: signing [image]: getting remote imageImage not foundCheck that the $CI_COMMIT_SHORT_SHA tag exists in the registry
Empty SBOM (0 packages)scratch image with no package databaseScan the Dockerfile or the sources. A distroless image is not empty: Syft reads the dpkg database there (5 packages on distroless/static-debian12)
trivy sbom gate always green, Unsupported os in the logSyft's SPDX SBOM not recognized by TrivyScan the CycloneDX SBOM
MANIFEST_UNKNOWN on the attestationThe registry does not support OCI artifactsUpgrade the registry or use --attachment-tag-prefix
cosign keyless failsOIDC misconfiguredCheck id_tokens in the .gitlab-ci.yml and /.well-known/openid-configuration
Trivy SBOM scan timeoutLarge imageAdd --timeout 15m and use the Trivy DB cache
vuln-scan job red, but no report in the artifactsartifacts:when defaults to on_success: nothing is kept when the job failsAdd when: always to the artifacts: block

Hands-on lab

Two labs chain the two halves of this guide on the same image, the one the pipeline of the course's running-example project publishes to the registry of its forge. The first adds the scan and its threshold: validation reads from the forge API a Trivy JSON report kept as an artifact, covering the image at the commit tag, a pipeline that really failed on a critical vulnerability with no deployment at all, and a .trivyignore that waives the single triaged finding, reason included. The second adds the inventory and the proof: a CycloneDX SBOM as an artifact, a signature and an attestation that cosign verifies itself, and a pipeline that did refuse an unsigned image before the deployment.

Key takeaways

Supply chain security in CI relies on three complementary layers, which three practices make useful:

  1. SBOM: document what each image contains. Syft or Trivy generate a complete inventory in a standard format (SPDX, CycloneDX). Without an SBOM, you cannot answer the question "does this CVE affect us?"
  2. Signature: prove integrity. cosign signs the image and stores the signature in the registry. Any modification invalidates the signature. With a key, an empty signing configuration avoids publishing your images in Rekor; keyless mode removes the key but requires an issuer recognized by Fulcio, such as gitlab.com.
  3. Attestation: prove provenance. The SLSA attestation binds the artifact to the pipeline that built it. Without an attestation, a signature proves integrity but not origin.
  4. Verification: the critical link. Signing without verifying is useless. The verify-before-deploy job must be mandatory and blocking in the deployment pipeline.
  5. Automation: every step must be in the pipeline, not in a manual script. What is not automated will not be done.
  6. Vulnerability triage: keep the report even when the job fails (artifacts:when: always), and waive in .trivyignore only the finding that was reviewed, with its reason as a comment. An exception without a reason is a vulnerability accepted by oversight.

Sources

Test your knowledge

Knowledge check

Check what you have learned with this interactive quiz

10 questions
5 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn