From 861d09f32c48621ea6fdce86eb28e5b3eb71b024 Mon Sep 17 00:00:00 2001 From: Toddy Mladenov Date: Wed, 8 Jul 2026 13:24:18 -0700 Subject: [PATCH 1/2] feat(acquire): attach acquisition-provenance referrer on mirror Add an OCI 1.1 in-toto referrer recording where each image was acquired from and how, attached to freshly-mirrored quarantine images. - New composite action attach-acquisition-provenance: builds an in-toto Statement (predicate type https://toddysm.com/acquisition-provenance/v0.1) per subject and oras-attaches it to the index/tag and each per-platform child manifest, with com.toddysm.acquisition.* discovery annotations. - _mirror-image.yml: new record_acquisition_provenance input (default true), widened setup-oras condition, and a gated attach step (only on external -> quarantine acquisition, only when copied == true). Promotion unaffected. - Docs: design doc (docs/architecture/acquire/acquisition-provenance.md), reference (docs/reference/acquisition-provenance.md), action catalogue + terminology rows, mirror-workflows Implemented/Not-implemented updates. Closes #141 Closes #142 Closes #143 Part of #140 --- .../attach-acquisition-provenance/action.yml | 189 +++++++++++++++ .github/workflows/_mirror-image.yml | 27 ++- docs/architecture/acquire/README.md | 6 + .../acquire/acquisition-provenance.md | 223 ++++++++++++++++++ .../acquire/image-mirror-workflows.md | 16 +- docs/reference/README.md | 1 + docs/reference/acquisition-provenance.md | 72 ++++++ docs/reference/workflow-actions.md | 21 ++ 8 files changed, 548 insertions(+), 7 deletions(-) create mode 100644 .github/actions/attach-acquisition-provenance/action.yml create mode 100644 docs/architecture/acquire/acquisition-provenance.md create mode 100644 docs/reference/acquisition-provenance.md diff --git a/.github/actions/attach-acquisition-provenance/action.yml b/.github/actions/attach-acquisition-provenance/action.yml new file mode 100644 index 0000000..a1b679d --- /dev/null +++ b/.github/actions/attach-acquisition-provenance/action.yml @@ -0,0 +1,189 @@ +# Composite action: attach-acquisition-provenance +# +# Attach an acquisition-provenance OCI 1.1 referrer to a freshly-mirrored image. +# For every image acquired from an external registry into quarantine, this +# records where the image came from (source registry/repository/tag/digest), +# where it landed, when it was acquired, and by which workflow run. +# +# The referrer is an in-toto Statement (artifact type +# application/vnd.in-toto+json, predicate type +# https://toddysm.com/acquisition-provenance/v0.1). One statement is attached to +# the index/tag manifest and one to each per-platform child manifest; the +# per-platform statements additionally record the platform source digest. +# +# This action is intended to run only on the external -> quarantine acquisition +# (from _mirror-image.yml), not on quarantine -> golden promotion. +# +# Terminology and the action catalogue: see docs/reference/workflow-actions.md. +name: attach-acquisition-provenance +description: Attach an acquisition-provenance in-toto referrer to a mirrored image. + +inputs: + source-image: + description: "Fully qualified source image without tag (e.g. docker.io/library/python)." + required: true + source-tag: + description: "Source image tag that was acquired (e.g. 3.14-slim)." + required: true + dest-image: + description: "Fully qualified destination image without tag (e.g. ghcr.io/owner/quarantine/python)." + required: true + dest-tag: + description: "Destination image tag." + required: true + acquired-digest: + description: "Digest of the acquired image at the destination (the mirror-image 'digest' output)." + required: true + copy-referrers: + description: "Whether the mirror copied OCI referrers (selects the copy method oras vs crane)." + required: false + default: "false" + source-authenticated: + description: "'true' when the mirror logged in to the source registry (e.g. dhi.io) instead of an anonymous pull." + required: false + default: "false" + +runs: + using: composite + steps: + - name: Attach acquisition provenance to ${{ inputs.dest-image }}:${{ inputs.dest-tag }} + shell: bash + env: + SOURCE_IMAGE: "${{ inputs.source-image }}" + SOURCE_TAG: "${{ inputs.source-tag }}" + DEST_IMAGE: "${{ inputs.dest-image }}" + DEST_TAG: "${{ inputs.dest-tag }}" + ACQUIRED_DIGEST: "${{ inputs.acquired-digest }}" + COPY_REFERRERS: "${{ inputs.copy-referrers }}" + SOURCE_AUTHENTICATED: "${{ inputs.source-authenticated }}" + ACTOR: "${{ github.actor }}" + WORKFLOW: "${{ github.workflow }}" + RUN_ID: "${{ github.run_id }}" + RUN_ATTEMPT: "${{ github.run_attempt }}" + SERVER_URL: "${{ github.server_url }}" + REPOSITORY: "${{ github.repository }}" + run: | + set -euo pipefail + + PREDICATE_TYPE="https://toddysm.com/acquisition-provenance/v0.1" + + source_ref="${SOURCE_IMAGE}:${SOURCE_TAG}" + dest_ref="${DEST_IMAGE}:${DEST_TAG}" + # Digests are preserved during the copy, so the acquired digest is also + # the source digest. + source_digest="${ACQUIRED_DIGEST}" + + # Split the source image into registry + repository. The registry is the + # first path segment; everything after it is the repository. + source_registry="${SOURCE_IMAGE%%/*}" + source_repository="${SOURCE_IMAGE#*/}" + + if [ "${COPY_REFERRERS}" = "true" ]; then + copy_method="oras" + else + copy_method="crane" + fi + + acquired_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + run_url="${SERVER_URL}/${REPOSITORY}/actions/runs/${RUN_ID}" + + work="$(mktemp -d)" + + # Build one in-toto Statement. Arguments: + # $1 = subject digest (sha256:...) + # $2 = output file + # $3 = platform source digest ("" for the index-level statement) + build_statement() { + local subject_digest="$1" out="$2" platform_digest="$3" + local subject_sha="${subject_digest#sha256:}" + jq -n \ + --arg predicate_type "${PREDICATE_TYPE}" \ + --arg dest_image "${DEST_IMAGE}" \ + --arg subject_sha "${subject_sha}" \ + --arg source_ref "${source_ref}" \ + --arg source_registry "${source_registry}" \ + --arg source_repository "${source_repository}" \ + --arg source_tag "${SOURCE_TAG}" \ + --arg source_digest "${source_digest}" \ + --arg dest_ref "${dest_ref}" \ + --arg dest_digest "${ACQUIRED_DIGEST}" \ + --arg acquired_at "${acquired_at}" \ + --arg run_url "${run_url}" \ + --arg actor "${ACTOR}" \ + --arg workflow "${WORKFLOW}" \ + --arg run_id "${RUN_ID}" \ + --arg run_attempt "${RUN_ATTEMPT}" \ + --arg copy_method "${copy_method}" \ + --argjson copy_referrers "${COPY_REFERRERS}" \ + --argjson source_authenticated "${SOURCE_AUTHENTICATED}" \ + --arg platform_digest "${platform_digest}" \ + '{ + _type: "https://in-toto.io/Statement/v1", + predicateType: $predicate_type, + subject: [ { name: $dest_image, digest: { "sha256": $subject_sha } } ], + predicate: ({ + source: { + reference: $source_ref, + registry: $source_registry, + repository: $source_repository, + tag: $source_tag, + digest: $source_digest + }, + destination: { + reference: $dest_ref, + digest: $dest_digest + }, + acquiredAt: $acquired_at, + runUrl: $run_url, + actor: $actor, + workflow: { name: $workflow, runId: $run_id, runAttempt: $run_attempt }, + copyMethod: $copy_method, + copyReferrers: $copy_referrers, + sourceAuthenticated: $source_authenticated + } + (if $platform_digest == "" then {} else { platformSourceDigest: $platform_digest } end)) + }' > "${out}" + } + + # Attach a statement file to a subject as an OCI 1.1 referrer. + # $1 = subject ref (image@digest) + # $2 = statement file + # $3 = title annotation value + attach_statement() { + local subject_ref="$1" file="$2" title="$3" + oras attach \ + --artifact-type application/vnd.in-toto+json \ + --annotation "in-toto.io/predicate-type=${PREDICATE_TYPE}" \ + --annotation "org.opencontainers.image.title=${title}" \ + --annotation "com.toddysm.acquisition.source=${source_ref}" \ + --annotation "com.toddysm.acquisition.source-digest=${source_digest}" \ + --annotation "com.toddysm.acquisition.timestamp=${acquired_at}" \ + --annotation "com.toddysm.acquisition.run-url=${run_url}" \ + --disable-path-validation \ + "${subject_ref}" \ + "${file}:application/vnd.in-toto+json" + } + + # Index / tag level statement (whole acquired image). + index_file="${work}/acquisition-provenance.json" + build_statement "${ACQUIRED_DIGEST}" "${index_file}" "" + echo "Attaching index-level acquisition provenance to ${DEST_IMAGE}@${ACQUIRED_DIGEST}" + attach_statement "${DEST_IMAGE}@${ACQUIRED_DIGEST}" "${index_file}" "acquisition-provenance.json" + + # Per-platform statements. Enumerate real platform child manifests (skip + # attestation/unknown entries), matching the scan-sbom platform filter. + child_digests="$(crane manifest "${DEST_IMAGE}@${ACQUIRED_DIGEST}" \ + | jq -r '.manifests[]? + | select((.platform.os // "") != "" and (.platform.os // "") != "unknown") + | [.digest, (.platform.os + "-" + .platform.architecture)] | @tsv' || true)" + + if [ -n "${child_digests}" ]; then + while IFS=$'\t' read -r digest platform; do + [ -n "${digest}" ] || continue + file="${work}/acquisition-provenance-${platform}.json" + build_statement "${digest}" "${file}" "${digest}" + echo "Attaching ${platform} acquisition provenance to ${DEST_IMAGE}@${digest}" + attach_statement "${DEST_IMAGE}@${digest}" "${file}" "acquisition-provenance-${platform}.json" + done <<< "${child_digests}" + else + echo "No per-platform child manifests found; index-level provenance only." + fi diff --git a/.github/workflows/_mirror-image.yml b/.github/workflows/_mirror-image.yml index 6fc1aee..1e3ab07 100644 --- a/.github/workflows/_mirror-image.yml +++ b/.github/workflows/_mirror-image.yml @@ -47,6 +47,11 @@ on: required: false default: false type: boolean + record_acquisition_provenance: + description: "Attach an acquisition-provenance in-toto referrer to the mirrored image after a successful copy (source, digest, timestamp, workflow run). Records external -> quarantine acquisitions." + required: false + default: true + type: boolean secrets: source_registry_username: description: "Username for source_login_registry. Required only when source_login_registry is set." @@ -69,9 +74,9 @@ jobs: uses: imjasonh/setup-crane@31b88efe9de28ae0ffa220711af4b60be9435f6e # v0.4 - name: Set up oras - # Only needed when copying referrers; the default crane-only path keeps - # existing anonymous mirrors lightweight. - if: ${{ inputs.copy_referrers }} + # Needed when copying referrers or when attaching the acquisition + # provenance referrer; the plain crane-only path stays lightweight. + if: ${{ inputs.copy_referrers || inputs.record_acquisition_provenance }} uses: oras-project/setup-oras@38de303aac69abb66f3e6255b7198bff35f323e3 # v2.0.0 with: version: 1.3.1 @@ -107,6 +112,22 @@ jobs: force: ${{ inputs.force }} copy-referrers: ${{ inputs.copy_referrers }} + - name: Attach acquisition provenance + # Record where this image was acquired from, but only for external -> + # quarantine acquisitions (this reusable workflow) and only when a copy + # actually happened. Promotion uses the promote-from-quarantine + # workflows, which do not call this step. + if: ${{ inputs.record_acquisition_provenance && steps.mirror.outputs.copied == 'true' }} + uses: ./.github/actions/attach-acquisition-provenance + with: + source-image: ${{ inputs.source_image }} + source-tag: ${{ inputs.source_tag }} + dest-image: ${{ inputs.dest_image }} + dest-tag: ${{ inputs.dest_tag }} + acquired-digest: ${{ steps.mirror.outputs.digest }} + copy-referrers: ${{ inputs.copy_referrers }} + source-authenticated: ${{ inputs.source_login_registry != '' }} + - name: Write job summary env: SOURCE_REF: "${{ inputs.source_image }}:${{ inputs.source_tag }}" diff --git a/docs/architecture/acquire/README.md b/docs/architecture/acquire/README.md index 8e18f52..b2b5332 100644 --- a/docs/architecture/acquire/README.md +++ b/docs/architecture/acquire/README.md @@ -8,3 +8,9 @@ controlled namespace before they are used. - [Image mirror workflows](image-mirror-workflows.md) — how the Docker Hub → GHCR mirroring actions are structured, the tooling they use, and what they do and deliberately do not do. + +## Supply Chain Observability + +- [Acquisition provenance referrer](acquisition-provenance.md) — an OCI 1.1 + referrer attached to each mirrored image recording where it was acquired from + and how (source registry, tag, digest, timestamp, workflow run). *Proposed.* diff --git a/docs/architecture/acquire/acquisition-provenance.md b/docs/architecture/acquire/acquisition-provenance.md new file mode 100644 index 0000000..9ea1287 --- /dev/null +++ b/docs/architecture/acquire/acquisition-provenance.md @@ -0,0 +1,223 @@ +# Acquisition provenance referrer + +> **Status: implemented.** Tracking issue: #140. See the +> [attach-acquisition-provenance action](../../../.github/actions/attach-acquisition-provenance/action.yml) +> and [`_mirror-image.yml`](../../../.github/workflows/_mirror-image.yml). + +When an image is **acquired** — mirrored from an external registry into the +owner-controlled `quarantine/` namespace — the framework has no +first-party record of *where the image came from* or *how it was pulled*. Once +an image sits in quarantine, the acquisition context (source registry, tag, +digest, timestamp, workflow run) is lost. + +This design adds a first-party **OCI 1.1 Referrers-API artifact** that records +the acquisition details and travels with the mirrored image in GHCR, so the +question "where did this image come from, and when did we pull it?" can be +answered from the artifact itself — portably and registry-natively. + +## Goals + +- Record the source of every acquired image (registry, repository, tag, digest). +- Record how and when it was acquired (timestamp, workflow run, copy method). +- Store the record as a standards-based, discoverable OCI 1.1 referrer that + survives copies and is readable with common tooling (`oras discover`). +- Keep the acquisition (mirror) action small and single-purpose. + +## Non-goals + +- **Promotion provenance.** The `mirror-image` action also backs the + quarantine → golden promotion (a mirror with `force=true`). Acquisition + provenance is recorded **only** on the external → quarantine acquisition, not + on promotion. Promotion provenance, if wanted, is a separate future design. +- **Signing / verification.** The referrer is not signed here, and existing + upstream referrers are not re-verified (unchanged from today's mirror scope). +- **Retention / dedup of historical referrers.** See + [Idempotency and history](#idempotency-and-history). + +## Artifact format + +The referrer is an **in-toto Statement**, consistent with the SBOM and +provenance referrers this repo already publishes (see +[image attestations](../../reference/image-attestations.md)): + +- **artifact type:** `application/vnd.in-toto+json` +- **predicate type:** `https://toddysm.com/acquisition-provenance/v0.1` + +Using in-toto keeps the acquisition record in the same shape as the other +attestations and makes it discoverable with the same tooling. + +### Predicate schema + +The in-toto Statement's `subject` names the acquired manifest (see +[Subjects](#subjects)); the `predicate` carries the acquisition details: + +```jsonc +{ + "_type": "https://in-toto.io/Statement/v1", + "predicateType": "https://toddysm.com/acquisition-provenance/v0.1", + "subject": [ + { "name": "ghcr.io//quarantine/python", + "digest": { "sha256": "" } } + ], + "predicate": { + // --- core (always present) --- + "source": { + "reference": "docker.io/library/python:3.14-slim", + "registry": "docker.io", + "repository": "library/python", + "tag": "3.14-slim", + "digest": "sha256:" + }, + "destination": { + "reference": "ghcr.io//quarantine/python:3.14-slim", + "digest": "sha256:" + }, + "acquiredAt": "2026-07-08T06:00:00Z", // RFC 3339 UTC + "runUrl": "https://github.com//cssc-framework/actions/runs/", + + // --- optional (recorded per design decision) --- + "actor": "", + "workflow": { + "name": "mirror / quarantine/python", + "runId": "", + "runAttempt": "" + }, + "copyMethod": "crane", // "crane" | "oras" + "copyReferrers": false, // whether referrers were copied + "sourceAuthenticated": false, // true when a source login was used + "platformSourceDigest": "sha256:<...>" // only in per-platform statements + } +} +``` + +Notes: + +- `platformSourceDigest` appears **only** in the per-platform statements; it + records the specific platform manifest digest that statement describes. +- `sourceAuthenticated` is `true` when the mirror logged in to a source registry + (e.g. `dhi.io`) rather than pulling anonymously. It does **not** record any + credential. + +### Discovery annotations + +Key fields are duplicated as annotations on the referrer manifest so +`oras discover` and registry UIs can surface them without pulling the blob: + +| Annotation | Example | +| ---------- | ------- | +| `in-toto.io/predicate-type` | `https://toddysm.com/acquisition-provenance/v0.1` | +| `org.opencontainers.image.title` | `acquisition-provenance.json` | +| `com.toddysm.acquisition.source` | `docker.io/library/python:3.14-slim` | +| `com.toddysm.acquisition.source-digest` | `sha256:` | +| `com.toddysm.acquisition.timestamp` | `2026-07-08T06:00:00Z` | +| `com.toddysm.acquisition.run-url` | `https://github.com/.../runs/` | + +The `com.toddysm.*` namespace and dashed keys match the existing image +annotation convention (see [image annotations](../../reference/image-annotations.md)). + +## Subjects + +The referrer is attached to **both** the tag/index manifest **and** each +per-platform child manifest of the acquired image: + +- **Index / tag** — one statement describing the acquired image as a whole; its + subject digest is the index digest. +- **Per-platform** — one statement per child manifest; its subject digest is the + platform manifest digest, and it additionally carries `platformSourceDigest`. + +Because `crane`/`oras` preserve digests during the copy, the acquired +(destination) digests equal the source digests, so every statement's subject +link is valid against the copied image. Attaching at both levels mirrors how the +existing SBOM/provenance referrers attach per-platform while also giving a +single index-level record for the whole image. + +## When it runs + +The referrer is created **only when a copy actually happened** — i.e. when the +`mirror-image` action reports `copied == true`. When the destination is already +up to date, no new referrer is written. This ties each acquisition referrer to a +distinct acquired digest and avoids churn on unchanged images. + +## Where it lives + +A new single-purpose composite action **`attach-acquisition-provenance`** under +[`.github/actions/`](../../../.github/actions/) builds the predicate and +attaches it with `oras attach`. It is orchestrated by the reusable +[`_mirror-image.yml`](../../../.github/workflows/_mirror-image.yml) workflow — +**not** by the promotion workflows — which is what scopes the feature to +external acquisition only. + +```text +.github/ +├── actions/ +│ ├── mirror-image/ # copies the image (unchanged) +│ └── attach-acquisition-provenance/ # NEW — attaches the referrer +└── workflows/ + └── _mirror-image.yml # calls mirror-image, then (on copied) the new action +``` + +### Reusable-workflow wiring + +`_mirror-image.yml` gains one input: + +| Input | Required | Default | Description | +| ----- | -------- | ------- | ----------- | +| `record_acquisition_provenance` | no | `true` | Attach an acquisition-provenance referrer to the mirrored image after a successful copy. | + +New/changed steps in the `mirror` job: + +1. **Set up oras** — the condition widens from `copy_referrers == true` to + `copy_referrers == true || record_acquisition_provenance == true`, because + `oras attach` needs the CLI. +2. **Mirror image** (`mirror-image`) — unchanged; still outputs `copied`, + `digest`, `previous-digest`. +3. **Attach acquisition provenance** (`attach-acquisition-provenance`) — runs + `if: record_acquisition_provenance == true && steps.mirror.outputs.copied == 'true'`. + Receives the source ref/digest, destination ref/digest, and the copy context. + +The action: + +- resolves the acquired index digest and enumerates per-platform child digests + (via `crane manifest` / `oras manifest fetch`), +- writes one predicate JSON per subject, +- attaches each with + `oras attach --artifact-type application/vnd.in-toto+json + --annotation in-toto.io/predicate-type=https://toddysm.com/acquisition-provenance/v0.1 + --annotation ... --disable-path-validation :application/vnd.in-toto+json`. + +`--disable-path-validation` is required due to a known `oras attach` regression +with absolute temp-file paths (see #139). GHCR auth is already established by the +existing "Log in to GHCR" step, so no new secrets are needed. + +## Idempotency and history + +`oras attach` **adds** a referrer; it does not replace existing ones. Because the +action only runs when `copied == true`, each acquisition changes the destination +digest (for a non-referrer copy the digests differed by definition), so each new +acquisition referrer attaches to a **new** subject digest. Re-pulling the same +unchanged tag does not create duplicates. Multiple acquisition referrers on the +*same* subject digest are therefore not expected in normal operation; pruning of +historical referrers is out of scope for this design. + +## Retrieval + +```bash +# Index-level acquisition provenance (subject = the tag/index): +oras discover --format tree ghcr.io//quarantine/python:3.14-slim + +# Pull the in-toto statement (referrer digest from the tree above): +oras pull -o acq ghcr.io//quarantine/python@ + +# Per-platform: resolve platform digests, then discover on each: +crane manifest ghcr.io//quarantine/python:3.14-slim \ + | jq -r '.manifests[] | select(.platform.os != "unknown") + | "\(.platform.os)/\(.platform.architecture)\t\(.digest)"' +oras discover --format tree ghcr.io//quarantine/python@ +``` + +## Open questions + +- Should the index-level statement enumerate all platform subject digests in its + predicate (a manifest of manifests), or is the per-platform statement set + sufficient? (Leaning: per-platform set is sufficient.) +- Versioning policy for the `v0.1` predicate type as fields evolve. diff --git a/docs/architecture/acquire/image-mirror-workflows.md b/docs/architecture/acquire/image-mirror-workflows.md index 9db5f5c..d6cf202 100644 --- a/docs/architecture/acquire/image-mirror-workflows.md +++ b/docs/architecture/acquire/image-mirror-workflows.md @@ -161,6 +161,13 @@ Key tooling characteristics: per-platform child manifest. This is what lets downstream SBOM-based scanning read attestations straight from quarantine. It works for any image that has referrers, not just Docker Hardened Images. +- **Acquisition provenance.** After a successful copy, an + [acquisition-provenance referrer](../../reference/acquisition-provenance.md) is + attached to the mirrored image (index and each per-platform manifest) recording + the source registry/repository/tag/digest, the acquisition timestamp, and the + workflow run. It is an in-toto statement discoverable with `oras discover`, + written only for external → quarantine acquisitions and only when a copy + happened. Controlled by the `record_acquisition_provenance` input (default on). - **Authenticated sources.** Setting `source_login_registry` (plus the `source_registry_username` / `source_registry_password` secrets) lets the mirror pull from private or non–Docker Hub upstreams such as `dhi.io`. Public @@ -184,10 +191,11 @@ Key tooling characteristics: - **No image scanning or vulnerability gating.** Despite the `quarantine/` naming, the workflows do not scan images or block copying based on CVEs, policy, or signatures — they perform a straight mirror. -- **No signing or attestation generation.** Mirrored images are not signed (e.g. - cosign) and no new provenance/SBOM attestations are produced or verified. When - `copy_referrers` is enabled, existing referrers (including the upstream's - SBOMs and signatures) are copied verbatim but are not re-verified. +- **No signing, SBOM, or verification.** Mirrored images are not signed (e.g. + cosign), no SBOMs are generated, and existing referrers are not re-verified. + When `copy_referrers` is enabled, upstream referrers (SBOMs and signatures) are + copied verbatim but not re-verified. The one attestation the mirror *does* + produce is the first-party acquisition-provenance referrer described above. - **No automatic tag discovery.** Each workflow mirrors one explicitly pinned source tag (e.g. `python:3.14-slim`). New tags or floating/`latest` tags are not discovered or tracked automatically; updating a tag is a manual edit to the diff --git a/docs/reference/README.md b/docs/reference/README.md index 159df76..71aa519 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -7,3 +7,4 @@ Reference material and conventions for the `cssc-framework` repository. nouns), and how the reusable workflows compose the actions. - [Image annotations](image-annotations.md) — OCI manifest annotations carried by the CSSC Dashboard images. - [Image attestations](image-attestations.md) — SBOM and provenance attestations published as OCI 1.1 referrers on the CSSC Dashboard images. +- [Acquisition provenance](acquisition-provenance.md) — the OCI 1.1 referrer attached to mirrored images recording where they were acquired from. diff --git a/docs/reference/acquisition-provenance.md b/docs/reference/acquisition-provenance.md new file mode 100644 index 0000000..6886d4c --- /dev/null +++ b/docs/reference/acquisition-provenance.md @@ -0,0 +1,72 @@ +# Acquisition provenance referrer + +Every image mirrored from an external registry into `quarantine/` carries +an **acquisition-provenance** OCI 1.1 Referrers-API artifact that records where +the image came from and how it was acquired. It is attached by the +[`attach-acquisition-provenance`](workflow-actions.md#attach-acquisition-provenance) +action from the [`_mirror-image.yml`](../../.github/workflows/_mirror-image.yml) +workflow, only on external → quarantine acquisitions (never on promotion), and +only when a copy actually happened. + +The architecture and rationale are in +[docs/architecture/acquire/acquisition-provenance.md](../architecture/acquire/acquisition-provenance.md). + +## Format + +- **artifact type:** `application/vnd.in-toto+json` +- **predicate type:** `https://toddysm.com/acquisition-provenance/v0.1` + +The referrer is attached to **both** the index/tag manifest and each +per-platform child manifest. The per-platform statements additionally record +`platformSourceDigest`. + +## Predicate fields + +| Field | Description | +| ----- | ----------- | +| `source.reference` | Full source reference (e.g. `docker.io/library/python:3.14-slim`). | +| `source.registry` | Source registry host (e.g. `docker.io`). | +| `source.repository` | Source repository (e.g. `library/python`). | +| `source.tag` | Source tag that was acquired. | +| `source.digest` | Source digest at acquisition time. | +| `destination.reference` | Destination reference in GHCR. | +| `destination.digest` | Acquired digest at the destination. | +| `acquiredAt` | RFC 3339 UTC acquisition timestamp. | +| `runUrl` | URL of the acquiring workflow run. | +| `actor` | Actor that triggered the run. | +| `workflow` | `{ name, runId, runAttempt }` of the acquiring run. | +| `copyMethod` | `crane` or `oras` (the latter when referrers were copied). | +| `copyReferrers` | Whether the mirror copied the image's referrers. | +| `sourceAuthenticated` | `true` when the mirror logged in to the source registry. | +| `platformSourceDigest` | Platform manifest digest (per-platform statements only). | + +## Discovery annotations + +Key fields are duplicated as annotations on the referrer manifest so they are +visible via `oras discover` without pulling the blob: + +| Annotation | Example | +| ---------- | ------- | +| `in-toto.io/predicate-type` | `https://toddysm.com/acquisition-provenance/v0.1` | +| `org.opencontainers.image.title` | `acquisition-provenance.json` | +| `com.toddysm.acquisition.source` | `docker.io/library/python:3.14-slim` | +| `com.toddysm.acquisition.source-digest` | `sha256:` | +| `com.toddysm.acquisition.timestamp` | `2026-07-08T06:00:00Z` | +| `com.toddysm.acquisition.run-url` | `https://github.com//cssc-framework/actions/runs/` | + +## Retrieve + +```bash +# Index-level acquisition provenance (subject = the tag/index): +oras discover --format tree ghcr.io//quarantine/python:3.14-slim + +# Pull the in-toto statement (referrer digest from the tree above): +oras pull -o acq ghcr.io//quarantine/python@ + +# Per-platform: resolve platform digests, then discover on each: +crane manifest ghcr.io//quarantine/python:3.14-slim \ + | jq -r '.manifests[] + | select((.platform.os // "") != "" and (.platform.os // "") != "unknown") + | "\(.platform.os)/\(.platform.architecture)\t\(.digest)"' +oras discover --format tree ghcr.io//quarantine/python@ +``` diff --git a/docs/reference/workflow-actions.md b/docs/reference/workflow-actions.md index ba33d72..c6dabfc 100644 --- a/docs/reference/workflow-actions.md +++ b/docs/reference/workflow-actions.md @@ -31,6 +31,7 @@ phrasings it replaces. | __scan-sbom__ | Scan one image's per-platform SBOM attestations with `trivy sbom`. | "scan platform SBOMs" | | __evaluate-findings__ | Apply the severity threshold + CVE exceptions to produce a gate decision. | "gate on scan findings" | | __attach-scan-report__ | Attach the OCI scan-report referrer to a promoted image. | "attach scan-report attestation" | +| __attach-acquisition-provenance__ | Attach the acquisition-provenance in-toto referrer to a mirrored image. | "acquisition provenance" | | __delete-image__ | Delete one tag from a GHCR repository via the Packages API. | "delete promoted tags from quarantine" | Standard nouns: @@ -90,6 +91,26 @@ promotion (promotion is a mirror with `force: true`). Outputs: `copied`, `digest`, `previous-digest`, `referrers-note`. +### attach-acquisition-provenance + +Attach an acquisition-provenance in-toto referrer to a freshly-mirrored image, +recording where it was acquired from (source registry/repository/tag/digest), +when, and by which workflow run. Attached to the index/tag manifest and each +per-platform child manifest (artifact type `application/vnd.in-toto+json`, +predicate type `https://toddysm.com/acquisition-provenance/v0.1`). Runs only on +external → quarantine acquisition, and only when a copy actually happened. See +[acquisition provenance](acquisition-provenance.md). + +| Input | Required | Default | Description | +| ----- | -------- | ------- | ----------- | +| `source-image` | yes | — | Source image without tag. | +| `source-tag` | yes | — | Source tag that was acquired. | +| `dest-image` | yes | — | Destination image without tag. | +| `dest-tag` | yes | — | Destination tag. | +| `acquired-digest` | yes | — | Digest of the acquired image (the `mirror-image` `digest` output). | +| `copy-referrers` | no | `false` | Whether the mirror copied referrers (records the copy method). | +| `source-authenticated` | no | `false` | `true` when the mirror logged in to the source registry. | + ### scan-image Scan one image filesystem with `trivy image`. From d590352c5995511da3efbc48a178acf1db06b6c0 Mon Sep 17 00:00:00 2001 From: Toddy Mladenov Date: Wed, 8 Jul 2026 13:43:40 -0700 Subject: [PATCH 2/2] fix(acquire): address Copilot review on acquisition provenance - Gate the attach step on an actual digest change (copied==true AND digest != previous-digest) so the copy_referrers re-sync path (which reports copied=true on an unchanged digest) no longer attaches a duplicate referrer to the same subject. - Log in oras to GHCR when record_acquisition_provenance is enabled, not only when copy_referrers is set (oras attach needs the login). - Parse the digest algorithm generically and fail fast on non-digest input instead of hard-coding sha256. - Include platform.variant in per-platform titles/file names (arm/v6 vs v7), matching scan-sbom. - Docs: drop the stale "Proposed" label, and align the design/reference wording with the digest-change guard. --- .../attach-acquisition-provenance/action.yml | 23 ++++++++++++++---- .github/workflows/_mirror-image.yml | 14 +++++++---- docs/architecture/acquire/README.md | 2 +- .../acquire/acquisition-provenance.md | 24 +++++++++++-------- .../acquire/image-mirror-workflows.md | 5 ++-- docs/reference/acquisition-provenance.md | 2 +- docs/reference/workflow-actions.md | 2 +- 7 files changed, 48 insertions(+), 24 deletions(-) diff --git a/.github/actions/attach-acquisition-provenance/action.yml b/.github/actions/attach-acquisition-provenance/action.yml index a1b679d..f9a84a7 100644 --- a/.github/actions/attach-acquisition-provenance/action.yml +++ b/.github/actions/attach-acquisition-provenance/action.yml @@ -95,11 +95,23 @@ runs: # $3 = platform source digest ("" for the index-level statement) build_statement() { local subject_digest="$1" out="$2" platform_digest="$3" - local subject_sha="${subject_digest#sha256:}" + # Digests are : (e.g. sha256:...). Parse the algorithm + # generically rather than assuming sha256, and fail fast on anything + # that does not look like a digest. + case "${subject_digest}" in + ?*:?*) : ;; + *) + echo "::error::Unexpected digest '${subject_digest}' (expected :)." + return 1 + ;; + esac + local subject_algo="${subject_digest%%:*}" + local subject_hex="${subject_digest#*:}" jq -n \ --arg predicate_type "${PREDICATE_TYPE}" \ --arg dest_image "${DEST_IMAGE}" \ - --arg subject_sha "${subject_sha}" \ + --arg subject_algo "${subject_algo}" \ + --arg subject_hex "${subject_hex}" \ --arg source_ref "${source_ref}" \ --arg source_registry "${source_registry}" \ --arg source_repository "${source_repository}" \ @@ -120,7 +132,7 @@ runs: '{ _type: "https://in-toto.io/Statement/v1", predicateType: $predicate_type, - subject: [ { name: $dest_image, digest: { "sha256": $subject_sha } } ], + subject: [ { name: $dest_image, digest: { ($subject_algo): $subject_hex } } ], predicate: ({ source: { reference: $source_ref, @@ -174,7 +186,10 @@ runs: child_digests="$(crane manifest "${DEST_IMAGE}@${ACQUIRED_DIGEST}" \ | jq -r '.manifests[]? | select((.platform.os // "") != "" and (.platform.os // "") != "unknown") - | [.digest, (.platform.os + "-" + .platform.architecture)] | @tsv' || true)" + | [ .digest, + (.platform.os + "-" + .platform.architecture + + (if (.platform.variant // "") != "" then "-" + .platform.variant else "" end)) ] + | @tsv' || true)" if [ -n "${child_digests}" ]; then while IFS=$'\t' read -r digest platform; do diff --git a/.github/workflows/_mirror-image.yml b/.github/workflows/_mirror-image.yml index 1e3ab07..3b7319e 100644 --- a/.github/workflows/_mirror-image.yml +++ b/.github/workflows/_mirror-image.yml @@ -99,7 +99,7 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - with-oras: ${{ inputs.copy_referrers }} + with-oras: ${{ inputs.copy_referrers || inputs.record_acquisition_provenance }} - name: Mirror image id: mirror @@ -114,10 +114,14 @@ jobs: - name: Attach acquisition provenance # Record where this image was acquired from, but only for external -> - # quarantine acquisitions (this reusable workflow) and only when a copy - # actually happened. Promotion uses the promote-from-quarantine - # workflows, which do not call this step. - if: ${{ inputs.record_acquisition_provenance && steps.mirror.outputs.copied == 'true' }} + # quarantine acquisitions (this reusable workflow) and only when the + # acquired digest actually changed. The digest guard matters because + # with copy_referrers the mirror reports copied=true even when the + # image digest is unchanged (it re-syncs referrers), and we do not want + # to attach a duplicate acquisition referrer to the same subject digest. + # Promotion uses the promote-from-quarantine workflows, which do not + # call this step. + if: ${{ inputs.record_acquisition_provenance && steps.mirror.outputs.copied == 'true' && steps.mirror.outputs.digest != steps.mirror.outputs.previous-digest }} uses: ./.github/actions/attach-acquisition-provenance with: source-image: ${{ inputs.source_image }} diff --git a/docs/architecture/acquire/README.md b/docs/architecture/acquire/README.md index b2b5332..1161a77 100644 --- a/docs/architecture/acquire/README.md +++ b/docs/architecture/acquire/README.md @@ -13,4 +13,4 @@ controlled namespace before they are used. - [Acquisition provenance referrer](acquisition-provenance.md) — an OCI 1.1 referrer attached to each mirrored image recording where it was acquired from - and how (source registry, tag, digest, timestamp, workflow run). *Proposed.* + and how (source registry, tag, digest, timestamp, workflow run). diff --git a/docs/architecture/acquire/acquisition-provenance.md b/docs/architecture/acquire/acquisition-provenance.md index 9ea1287..047a072 100644 --- a/docs/architecture/acquire/acquisition-provenance.md +++ b/docs/architecture/acquire/acquisition-provenance.md @@ -133,10 +133,14 @@ single index-level record for the whole image. ## When it runs -The referrer is created **only when a copy actually happened** — i.e. when the -`mirror-image` action reports `copied == true`. When the destination is already -up to date, no new referrer is written. This ties each acquisition referrer to a -distinct acquired digest and avoids churn on unchanged images. +The referrer is created **only when the acquired digest actually changed** — +`mirror-image` reports `copied == true` **and** its `digest` output differs from +`previous-digest`. The digest guard matters because with `copy_referrers` the +mirror reports `copied == true` even when the image digest is unchanged (it +re-syncs referrers); without the guard that would attach a duplicate acquisition +referrer to the same subject digest on every run. When the destination is +already up to date, no new referrer is written. This ties each acquisition +referrer to a distinct acquired digest and avoids churn on unchanged images. ## Where it lives @@ -192,12 +196,12 @@ existing "Log in to GHCR" step, so no new secrets are needed. ## Idempotency and history `oras attach` **adds** a referrer; it does not replace existing ones. Because the -action only runs when `copied == true`, each acquisition changes the destination -digest (for a non-referrer copy the digests differed by definition), so each new -acquisition referrer attaches to a **new** subject digest. Re-pulling the same -unchanged tag does not create duplicates. Multiple acquisition referrers on the -*same* subject digest are therefore not expected in normal operation; pruning of -historical referrers is out of scope for this design. +action only runs when the acquired digest changed (see +[When it runs](#when-it-runs)), each new acquisition referrer attaches to a +**new** subject digest. Re-pulling the same unchanged tag — including the +`copy_referrers` re-sync path — does not create duplicates. Multiple acquisition +referrers on the *same* subject digest are therefore not expected in normal +operation; pruning of historical referrers is out of scope for this design. ## Retrieval diff --git a/docs/architecture/acquire/image-mirror-workflows.md b/docs/architecture/acquire/image-mirror-workflows.md index d6cf202..6e07a80 100644 --- a/docs/architecture/acquire/image-mirror-workflows.md +++ b/docs/architecture/acquire/image-mirror-workflows.md @@ -166,8 +166,9 @@ Key tooling characteristics: attached to the mirrored image (index and each per-platform manifest) recording the source registry/repository/tag/digest, the acquisition timestamp, and the workflow run. It is an in-toto statement discoverable with `oras discover`, - written only for external → quarantine acquisitions and only when a copy - happened. Controlled by the `record_acquisition_provenance` input (default on). + written only for external → quarantine acquisitions and only when the + acquired digest changed. Controlled by the `record_acquisition_provenance` + input (default on). - **Authenticated sources.** Setting `source_login_registry` (plus the `source_registry_username` / `source_registry_password` secrets) lets the mirror pull from private or non–Docker Hub upstreams such as `dhi.io`. Public diff --git a/docs/reference/acquisition-provenance.md b/docs/reference/acquisition-provenance.md index 6886d4c..83e3df6 100644 --- a/docs/reference/acquisition-provenance.md +++ b/docs/reference/acquisition-provenance.md @@ -6,7 +6,7 @@ the image came from and how it was acquired. It is attached by the [`attach-acquisition-provenance`](workflow-actions.md#attach-acquisition-provenance) action from the [`_mirror-image.yml`](../../.github/workflows/_mirror-image.yml) workflow, only on external → quarantine acquisitions (never on promotion), and -only when a copy actually happened. +only when the acquired digest changed. The architecture and rationale are in [docs/architecture/acquire/acquisition-provenance.md](../architecture/acquire/acquisition-provenance.md). diff --git a/docs/reference/workflow-actions.md b/docs/reference/workflow-actions.md index c6dabfc..1a8e1f5 100644 --- a/docs/reference/workflow-actions.md +++ b/docs/reference/workflow-actions.md @@ -98,7 +98,7 @@ recording where it was acquired from (source registry/repository/tag/digest), when, and by which workflow run. Attached to the index/tag manifest and each per-platform child manifest (artifact type `application/vnd.in-toto+json`, predicate type `https://toddysm.com/acquisition-provenance/v0.1`). Runs only on -external → quarantine acquisition, and only when a copy actually happened. See +external → quarantine acquisition, and only when the acquired digest changed. See [acquisition provenance](acquisition-provenance.md). | Input | Required | Default | Description |