diff --git a/.github/actions/build-graph-event/action.yml b/.github/actions/build-graph-event/action.yml index 94f0e26..8048d4d 100644 --- a/.github/actions/build-graph-event/action.yml +++ b/.github/actions/build-graph-event/action.yml @@ -18,11 +18,11 @@ # # Design: docs/architecture/observability/supply-chain-graph.md. name: build-graph-event -description: Stage a supply-chain-graph event record (ArtifactMirrored / ArtifactPromoted / ArtifactBuilt / ArtifactDeployed / TagObserved) as JSON. +description: Stage a supply-chain-graph event record (ArtifactMirrored / ArtifactPromoted / ArtifactBuilt / ArtifactDeployed / TagObserved / ReferrerObserved / ArtifactDeleted) as JSON. inputs: kind: - description: "Record kind: ArtifactMirrored, ArtifactPromoted, ArtifactBuilt, ArtifactDeployed, or TagObserved." + description: "Record kind: ArtifactMirrored, ArtifactPromoted, ArtifactBuilt, ArtifactDeployed, TagObserved, ReferrerObserved, or ArtifactDeleted." required: true source-ref: description: "Fully-qualified source name registry/repository (from occurrence). Required for ArtifactMirrored/ArtifactPromoted." @@ -86,6 +86,18 @@ inputs: description: "Helm chart version for the deployment (ArtifactDeployed, required with chart-name)." required: false default: "" + referrer-digest: + description: "Referrer artifact digest (sha256:...). Required for ReferrerObserved." + required: false + default: "" + artifact-type: + description: "Referrer artifact type. Required for ReferrerObserved; optional for ArtifactDeleted (omit for a plain image)." + required: false + default: "" + reason: + description: "Why the artifact was deleted: promoted, denied-cleanup, or manual. Required for ArtifactDeleted." + required: false + default: "" output-dir: description: "Staging directory to write the event file into." required: false @@ -126,6 +138,9 @@ runs: ENV_NAMESPACE: "${{ inputs.environment-namespace }}" CHART_NAME: "${{ inputs.chart-name }}" CHART_VERSION: "${{ inputs.chart-version }}" + REFERRER_DIGEST: "${{ inputs.referrer-digest }}" + ARTIFACT_TYPE: "${{ inputs.artifact-type }}" + REASON: "${{ inputs.reason }}" OUTPUT_DIR: "${{ inputs.output-dir }}" WORKFLOW: "${{ inputs.workflow }}" RUN_URL: "${{ inputs.run-url }}" @@ -222,6 +237,36 @@ runs: occurrence:{registry:$dreg, repository:$drepo}, tag:$tag, digest:$dig, observedAt:$now}')" ;; + ReferrerObserved) + [ -n "${DEST_DIGEST}" ] && [ -n "${REFERRER_DIGEST}" ] && [ -n "${ARTIFACT_TYPE}" ] || { + echo "::error::build-graph-event: ReferrerObserved requires dest-ref, dest-digest, referrer-digest and artifact-type."; exit 1; } + split_ref "${DEST_REF}"; d_reg="${REG}"; d_repo="${REPO}" + record="$(jq -nc \ + --argjson src "${source_json}" --arg now "${now}" \ + --arg dreg "${d_reg}" --arg drepo "${d_repo}" \ + --arg sdig "${DEST_DIGEST}" --arg tag "${TAG}" \ + --arg rdig "${REFERRER_DIGEST}" --arg atype "${ARTIFACT_TYPE}" \ + '{schemaVersion:1, kind:"ReferrerObserved", recordedAt:$now, source:$src, + occurrence:{registry:$dreg, repository:$drepo}, + subject:({digest:$sdig} + (if $tag == "" then {} else {tag:$tag} end)), + referrer:{digest:$rdig, artifactType:$atype}, + observedAt:$now}')" + ;; + ArtifactDeleted) + [ -n "${DEST_DIGEST}" ] && [ -n "${REASON}" ] || { + echo "::error::build-graph-event: ArtifactDeleted requires dest-ref, dest-digest and reason."; exit 1; } + split_ref "${DEST_REF}"; d_reg="${REG}"; d_repo="${REPO}" + record="$(jq -nc \ + --argjson src "${source_json}" --arg now "${now}" \ + --arg dreg "${d_reg}" --arg drepo "${d_repo}" \ + --arg dig "${DEST_DIGEST}" --arg tag "${TAG}" \ + --arg atype "${ARTIFACT_TYPE}" --arg reason "${REASON}" \ + '{schemaVersion:1, kind:"ArtifactDeleted", recordedAt:$now, source:$src, + occurrence:{registry:$dreg, repository:$drepo}, + digest:$dig, reason:$reason, deletedAt:$now} + + (if $atype == "" then {} else {artifactType:$atype} end) + + (if $tag == "" then {} else {tag:$tag} end)')" + ;; *) echo "::error::build-graph-event: unknown kind '${KIND}'." exit 1 diff --git a/.github/actions/stage-referrer-events/action.yml b/.github/actions/stage-referrer-events/action.yml new file mode 100644 index 0000000..a6c7c81 --- /dev/null +++ b/.github/actions/stage-referrer-events/action.yml @@ -0,0 +1,140 @@ +# Composite action: stage-referrer-events +# +# Stage one `ReferrerObserved` supply-chain-graph event per referrer of a subject +# image, for the record-graph-events collector to commit. Discovers the referrer +# closure of the subject (the image index plus each per-platform child manifest) +# recursively — so referrers-of-referrers (e.g. a signature on an SBOM) are +# recorded too — and writes one event file per (subject, referrer) edge with the +# subject digest, referrer digest and artifactType. +# +# Requires oras/crane to be logged in to the registry (callers already are for +# the copy). Best-effort: when nothing is discovered it stages nothing. The +# content id / filename match build-graph-event (cssc_graph.identity.content_id). +# +# Terminology and the action catalogue: see docs/reference/workflow-actions.md. +name: stage-referrer-events +description: Stage a ReferrerObserved event per referrer of a subject image (recursively). + +inputs: + ref: + description: "Fully-qualified subject repository without tag (registry/repository)." + required: true + digest: + description: "Subject image (index) digest (sha256:...)." + required: true + tag: + description: "Subject tag (recorded on the index subject only)." + required: false + default: "" + output-dir: + description: "Staging directory to write event files into." + required: true + workflow: + description: "Producing workflow name (source.workflow)." + required: true + run-url: + description: "URL of the producing run (source.runUrl)." + required: true + run-id: + description: "Producing run id (source.runId)." + required: true + run-attempt: + description: "Producing run attempt (source.runAttempt)." + required: true + +outputs: + staged: + description: "Number of ReferrerObserved events staged." + value: ${{ steps.stage.outputs.staged }} + +runs: + using: composite + steps: + - name: Stage referrer events for ${{ inputs.ref }} + id: stage + shell: bash + env: + REF: "${{ inputs.ref }}" + DIGEST: "${{ inputs.digest }}" + TAG: "${{ inputs.tag }}" + OUTPUT_DIR: "${{ inputs.output-dir }}" + WORKFLOW: "${{ inputs.workflow }}" + RUN_URL: "${{ inputs.run-url }}" + RUN_ID: "${{ inputs.run-id }}" + RUN_ATTEMPT: "${{ inputs.run-attempt }}" + RUNNER_TEMP_DIR: "${{ runner.temp }}" + run: | + set -euo pipefail + + outdir="${OUTPUT_DIR:-${RUNNER_TEMP_DIR}/graph-events}" + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + staged=0 + + if ! command -v oras >/dev/null 2>&1 || ! command -v crane >/dev/null 2>&1; then + echo "::notice::stage-referrer-events: oras/crane not available; nothing staged." + echo "staged=0" >> "${GITHUB_OUTPUT}" + exit 0 + fi + + # Split registry (first path component) from repository (the rest). + reg="${REF%%/*}"; repo="${REF#*/}" + if [ "${reg}" = "${REF}" ] || [ -z "${repo}" ]; then + echo "::error::stage-referrer-events: not a fully-qualified ref: '${REF}'"; exit 1 + fi + + source_json="$(jq -nc \ + --arg w "${WORKFLOW}" --arg u "${RUN_URL}" \ + --arg rid "${RUN_ID}" --arg ra "${RUN_ATTEMPT}" \ + '{type:"github-actions", workflow:$w, runUrl:$u, runId:$rid, runAttempt:$ra}')" + + stage_one() { + # $1 subject digest, $2 referrer digest, $3 artifactType + local sdig="$1" rdig="$2" atype="$3" record hex kdir dir tagexpr + if [ "${sdig}" = "${DIGEST}" ] && [ -n "${TAG}" ]; then + tagexpr="{tag:\$tag}" + else + tagexpr="{}" + fi + record="$(jq -nc \ + --argjson src "${source_json}" --arg now "${now}" \ + --arg reg "${reg}" --arg repo "${repo}" \ + --arg sdig "${sdig}" --arg tag "${TAG}" \ + --arg rdig "${rdig}" --arg atype "${atype}" \ + "{schemaVersion:1, kind:\"ReferrerObserved\", recordedAt:\$now, source:\$src, + occurrence:{registry:\$reg, repository:\$repo}, + subject:({digest:\$sdig} + ${tagexpr}), + referrer:{digest:\$rdig, artifactType:\$atype}, + observedAt:\$now}")" + hex="$(printf '%s' "${record}" | jq -S -c -j 'del(.id, .recordedAt, .source)' | sha256sum | cut -d' ' -f1)" + record="$(printf '%s' "${record}" | jq -c --arg id "sha256:${hex}" '. + {id: $id}')" + kdir="referrer-observed" + dir="${outdir}/events/${kdir}" + mkdir -p "${dir}" + printf '%s\n' "${record}" | jq . > "${dir}/${hex}.json" + staged=$((staged + 1)) + } + + # Subjects worklist: the index plus each per-platform child manifest, then + # every discovered referrer (so referrers-of-referrers are covered too). + children="$(crane manifest "${REF}@${DIGEST}" 2>/dev/null | jq -r '.manifests[]?.digest // empty' 2>/dev/null || true)" + pending="$(printf '%s\n%s\n' "${DIGEST}" "${children}" | sed '/^$/d' | sort -u)" + processed="" + while [ -n "$(printf '%s' "${pending}" | sed '/^$/d')" ]; do + subj="$(printf '%s\n' "${pending}" | sed '/^$/d' | head -n1)" + pending="$(printf '%s\n' "${pending}" | sed '/^$/d' | grep -vxF "${subj}" || true)" + printf '%s\n' "${processed}" | grep -qxF "${subj}" && continue + processed="$(printf '%s\n%s\n' "${processed}" "${subj}" | sed '/^$/d')" + # Each referrer of ${subj}: emit an edge and queue it for its own referrers. + pairs="$(oras discover --format json "${REF}@${subj}" 2>/dev/null \ + | jq -rc '[.. | objects | select(has("artifactType") and has("digest"))] | unique_by(.digest) | .[] | {digest, artifactType}' 2>/dev/null || true)" + while IFS= read -r pair; do + [ -n "${pair}" ] || continue + rdig="$(printf '%s' "${pair}" | jq -r '.digest')" + atype="$(printf '%s' "${pair}" | jq -r '.artifactType // "application/octet-stream"')" + stage_one "${subj}" "${rdig}" "${atype}" + printf '%s\n' "${processed}" | grep -qxF "${rdig}" || pending="$(printf '%s\n%s\n' "${pending}" "${rdig}" | sed '/^$/d')" + done <<< "${pairs}" + done + + echo "Staged ${staged} ReferrerObserved event(s) for ${REF}@${DIGEST}." + echo "staged=${staged}" >> "${GITHUB_OUTPUT}" diff --git a/.github/workflows/_mirror-image.yml b/.github/workflows/_mirror-image.yml index f0225c0..1a00fc2 100644 --- a/.github/workflows/_mirror-image.yml +++ b/.github/workflows/_mirror-image.yml @@ -219,6 +219,19 @@ jobs: run-id: ${{ github.run_id }} run-attempt: ${{ github.run_attempt }} + - name: Stage referrer graph events + if: ${{ steps.mirror.outputs.digest != '' }} + uses: ./.github/actions/stage-referrer-events + with: + ref: ${{ inputs.dest_image }} + digest: ${{ steps.mirror.outputs.digest }} + tag: ${{ inputs.dest_tag }} + output-dir: ${{ runner.temp }}/graph-events + workflow: ${{ github.workflow }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run-id: ${{ github.run_id }} + run-attempt: ${{ github.run_attempt }} + - name: Upload graph events if: ${{ steps.mirror.outputs.digest != '' }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/_promote-from-quarantine-sbom.yml b/.github/workflows/_promote-from-quarantine-sbom.yml index 1983358..4e50a11 100644 --- a/.github/workflows/_promote-from-quarantine-sbom.yml +++ b/.github/workflows/_promote-from-quarantine-sbom.yml @@ -285,6 +285,19 @@ jobs: run-id: ${{ github.run_id }} run-attempt: ${{ github.run_attempt }} + - name: Stage referrer graph events + if: steps.evaluate.outputs.decision == 'promote' + uses: ./.github/actions/stage-referrer-events + with: + ref: ${{ inputs.dest_repo }} + digest: ${{ steps.promote.outputs.digest }} + tag: ${{ matrix.tag }} + output-dir: ${{ runner.temp }}/graph-events + workflow: ${{ github.workflow }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run-id: ${{ github.run_id }} + run-attempt: ${{ github.run_attempt }} + - name: Upload graph events if: steps.evaluate.outputs.decision == 'promote' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/_promote-from-quarantine.yml b/.github/workflows/_promote-from-quarantine.yml index d8fc07c..c2cca76 100644 --- a/.github/workflows/_promote-from-quarantine.yml +++ b/.github/workflows/_promote-from-quarantine.yml @@ -280,6 +280,19 @@ jobs: run-id: ${{ github.run_id }} run-attempt: ${{ github.run_attempt }} + - name: Stage referrer graph events + if: steps.evaluate.outputs.decision == 'promote' + uses: ./.github/actions/stage-referrer-events + with: + ref: ${{ inputs.dest_repo }} + digest: ${{ steps.promote.outputs.digest }} + tag: ${{ matrix.tag }} + output-dir: ${{ runner.temp }}/graph-events + workflow: ${{ github.workflow }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run-id: ${{ github.run_id }} + run-attempt: ${{ github.run_attempt }} + - name: Upload graph events if: steps.evaluate.outputs.decision == 'promote' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/_promote-override.yml b/.github/workflows/_promote-override.yml index c0e522c..4a0ec13 100644 --- a/.github/workflows/_promote-override.yml +++ b/.github/workflows/_promote-override.yml @@ -217,6 +217,19 @@ jobs: run-id: ${{ github.run_id }} run-attempt: ${{ github.run_attempt }} + - name: Stage referrer graph events + if: inputs.decision == 'approve' && steps.promote.outputs.digest != '' + uses: ./.github/actions/stage-referrer-events + with: + ref: ${{ steps.meta.outputs.dest-repo }} + digest: ${{ steps.promote.outputs.digest }} + tag: ${{ steps.meta.outputs.tag }} + output-dir: ${{ runner.temp }}/graph-events + workflow: ${{ github.workflow }} + run-url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run-id: ${{ github.run_id }} + run-attempt: ${{ github.run_attempt }} + - name: Upload graph events if: inputs.decision == 'approve' && steps.promote.outputs.digest != '' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/apps/python-app/libs/cssc_graph/cssc_graph/graph.py b/apps/python-app/libs/cssc_graph/cssc_graph/graph.py index 7eb2d09..bfe7a76 100644 --- a/apps/python-app/libs/cssc_graph/cssc_graph/graph.py +++ b/apps/python-app/libs/cssc_graph/cssc_graph/graph.py @@ -18,6 +18,7 @@ "digest STRING, mediaType STRING, artifactType STRING, PRIMARY KEY(digest))", "CREATE NODE TABLE IF NOT EXISTS Occurrence(" "key STRING, registry STRING, repository STRING, digest STRING, ref STRING, " + "deletedAt STRING, deleteReason STRING, deleteRunUrl STRING, " "PRIMARY KEY(key))", "CREATE NODE TABLE IF NOT EXISTS Tag(" "key STRING, registry STRING, repository STRING, tag STRING, ref STRING, " @@ -41,6 +42,9 @@ "CREATE REL TABLE IF NOT EXISTS RUNS(" "FROM Deployment TO Occurrence, chart STRING, chartVersion STRING, " "runUrl STRING, recordedAt STRING)", + "CREATE REL TABLE IF NOT EXISTS REFERS_TO(" + "FROM Occurrence TO Occurrence, artifactType STRING, observedAt STRING, " + "runUrl STRING)", ) diff --git a/apps/python-app/libs/cssc_graph/cssc_graph/indexer.py b/apps/python-app/libs/cssc_graph/cssc_graph/indexer.py index e802e59..36fca37 100644 --- a/apps/python-app/libs/cssc_graph/cssc_graph/indexer.py +++ b/apps/python-app/libs/cssc_graph/cssc_graph/indexer.py @@ -215,6 +215,49 @@ def _index_tag_observed(self, record: Mapping[str, Any]) -> None: # -- shared edge builder ------------------------------------------------ + def _index_referrer_observed(self, record: Mapping[str, Any]) -> None: + occ = record["occurrence"] + registry, repository = occ["registry"], occ["repository"] + subject = record["subject"] + referrer = record["referrer"] + artifact_type = referrer["artifactType"] + subject_key = self._merge_occurrence(registry, repository, subject["digest"]) + referrer_key = self._merge_occurrence(registry, repository, referrer["digest"]) + # Set only the artifactType; a ReferrerObserved does not carry the + # mediaType, so leave any previously indexed value untouched. + self._store.execute( + "MERGE (a:Artifact {digest: $digest}) SET a.artifactType = $artifactType", + {"digest": referrer["digest"], "artifactType": artifact_type}, + ) + # observedAt is part of the merge key so each observation is preserved + # (append-only), mirroring POINTED_TO for tags. + self._store.execute( + "MATCH (r:Occurrence {key: $ref}), (s:Occurrence {key: $subj}) " + "MERGE (r)-[e:REFERS_TO {artifactType: $artifactType, observedAt: $observedAt}]->(s) " + "SET e.runUrl = $runUrl", + { + "ref": referrer_key, + "subj": subject_key, + "artifactType": artifact_type, + "observedAt": record["observedAt"], + "runUrl": _run_url(record), + }, + ) + + def _index_artifact_deleted(self, record: Mapping[str, Any]) -> None: + occ = record["occurrence"] + occ_key = self._merge_occurrence(occ["registry"], occ["repository"], record["digest"]) + self._store.execute( + "MATCH (o:Occurrence {key: $occ}) " + "SET o.deletedAt = $deletedAt, o.deleteReason = $reason, o.deleteRunUrl = $runUrl", + { + "occ": occ_key, + "deletedAt": record["deletedAt"], + "reason": record["reason"], + "runUrl": _run_url(record), + }, + ) + def _merge_pair_edge(self, record: Mapping[str, Any], rel: str, key_props: tuple[str, ...]) -> None: to_key = self._merge_occurrence_from(record["to"]) from_key = self._merge_occurrence_from(record["from"]) diff --git a/apps/python-app/libs/cssc_graph/tests/test_indexer.py b/apps/python-app/libs/cssc_graph/tests/test_indexer.py index 8352805..ed45ade 100644 --- a/apps/python-app/libs/cssc_graph/tests/test_indexer.py +++ b/apps/python-app/libs/cssc_graph/tests/test_indexer.py @@ -48,6 +48,32 @@ def test_edges_created(store: GraphStore): assert _count(store, "MATCH ()-[e:RUNS]->() RETURN count(e)") >= 1 +def test_referrer_observed_creates_refers_to_edge(store: GraphStore): + # The example referrer (digest 3…) refers to the golden subject (digest 2…), + # carrying its artifactType. + rows = store.query( + "MATCH (r:Occurrence)-[e:REFERS_TO]->(s:Occurrence) " + "RETURN r.digest AS ref, s.digest AS subj, e.artifactType AS at", + ) + assert any( + row["subj"] == DIGEST2 and row["at"] == "application/vnd.in-toto+json" + for row in rows + ) + + +def test_artifact_deleted_sets_tombstone(store: GraphStore): + # The example ArtifactDeleted marks the quarantine occurrence as removed. + rows = store.query( + "MATCH (o:Occurrence) WHERE o.deletedAt <> '' " + "RETURN o.repository AS repo, o.deleteReason AS reason", + ) + assert any( + row["repo"] == "toddysm/quarantine/python" and row["reason"] == "promoted" + for row in rows + ) + + + def test_built_from_targets_base_repository(store: GraphStore): n = _count( store, diff --git a/docs/architecture/observability/supply-chain-graph.md b/docs/architecture/observability/supply-chain-graph.md index 70dd96c..f0e000b 100644 --- a/docs/architecture/observability/supply-chain-graph.md +++ b/docs/architecture/observability/supply-chain-graph.md @@ -136,8 +136,8 @@ by people through pull requests, not by workflows. | Workflow (stage) | Event kind(s) | Key fields | |---|---|---| -| `mirror-*` (acquire) | `ArtifactMirrored`, `TagObserved` | source ref+digest, dest repo+digest, dest tag, run URL | -| `promote-from-quarantine*` (catalog) | `ArtifactPromoted`, `ScanRecorded`, `TagObserved` | src/dest repo+digest, tag, vuln-attestation digest, issue URL, run URL | +| `mirror-*` (acquire) | `ArtifactMirrored`, `TagObserved`, `ReferrerObserved` | source ref+digest, dest repo+digest, dest tag, referrer digests+types, run URL | +| `promote-from-quarantine*` (catalog) | `ArtifactPromoted`, `ScanRecorded`, `TagObserved`, `ReferrerObserved` | src/dest repo+digest, tag, referrer digests+types, vuln-attestation digest, issue URL, run URL | | `build-cssc-dashboard` (build) | `ArtifactBuilt`, `BaseImageObserved`, `TagObserved` | image repo+digest, base name@digest + base tag, SBOM/provenance referrer digests, source commit, run URL | | deploy (deploy/run) | `ArtifactDeployed` | image repo+digest, environment/cluster/namespace, chart+version, run URL | diff --git a/supply-chain-graph/examples/artifact-deleted.yaml b/supply-chain-graph/examples/artifact-deleted.yaml new file mode 100644 index 0000000..76a549e --- /dev/null +++ b/supply-chain-graph/examples/artifact-deleted.yaml @@ -0,0 +1,14 @@ +schemaVersion: 1 +kind: ArtifactDeleted +recordedAt: 2026-08-09T13:10:10Z +source: + type: github-actions + workflow: promote-from-quarantine-python + runUrl: https://github.com/toddysm/cssc-framework/actions/runs/123450002 +occurrence: + registry: ghcr.io + repository: toddysm/quarantine/python +digest: sha256:3333333333333333333333333333333333333333333333333333333333333333 +artifactType: application/vnd.in-toto+json +reason: promoted +deletedAt: 2026-08-09T13:10:10Z diff --git a/supply-chain-graph/examples/referrer-observed.yaml b/supply-chain-graph/examples/referrer-observed.yaml new file mode 100644 index 0000000..3290d71 --- /dev/null +++ b/supply-chain-graph/examples/referrer-observed.yaml @@ -0,0 +1,17 @@ +schemaVersion: 1 +kind: ReferrerObserved +recordedAt: 2026-08-09T13:10:05Z +source: + type: github-actions + workflow: promote-from-quarantine-python + runUrl: https://github.com/toddysm/cssc-framework/actions/runs/123450002 +occurrence: + registry: ghcr.io + repository: toddysm/golden/python +subject: + digest: sha256:2222222222222222222222222222222222222222222222222222222222222222 + tag: "3.14-slim" +referrer: + digest: sha256:3333333333333333333333333333333333333333333333333333333333333333 + artifactType: application/vnd.in-toto+json +observedAt: 2026-08-09T13:10:05Z diff --git a/supply-chain-graph/schema/artifact-deleted.schema.json b/supply-chain-graph/schema/artifact-deleted.schema.json new file mode 100644 index 0000000..267a38b --- /dev/null +++ b/supply-chain-graph/schema/artifact-deleted.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cssc.toddysm.com/schema/artifact-deleted.schema.json", + "title": "ArtifactDeleted", + "description": "Append-only record that an artifact (image or referrer) was removed from a repository occurrence.", + "type": "object", + "required": ["schemaVersion", "kind", "recordedAt", "source", "occurrence", "digest", "reason", "deletedAt"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "ArtifactDeleted" }, + "id": { "$ref": "envelope.schema.json#/$defs/contentHash" }, + "recordedAt": { "type": "string", "format": "date-time" }, + "source": { "$ref": "envelope.schema.json#/$defs/source" }, + "occurrence": { + "type": "object", + "description": "The registry + repository the artifact was deleted from.", + "required": ["registry", "repository"], + "additionalProperties": false, + "properties": { + "registry": { "$ref": "envelope.schema.json#/$defs/registry" }, + "repository": { "$ref": "envelope.schema.json#/$defs/repository" } + } + }, + "digest": { "$ref": "envelope.schema.json#/$defs/digest" }, + "artifactType": { + "type": "string", + "minLength": 1, + "description": "The referrer's artifact type. Omitted when the deleted artifact is a plain image." + }, + "tag": { + "type": "string", + "minLength": 1, + "description": "The tag the deleted artifact carried, when it was tagged." + }, + "reason": { + "type": "string", + "enum": ["promoted", "denied-cleanup", "manual"] + }, + "deletedAt": { "type": "string", "format": "date-time" } + } +} diff --git a/supply-chain-graph/schema/referrer-observed.schema.json b/supply-chain-graph/schema/referrer-observed.schema.json new file mode 100644 index 0000000..d1ef39e --- /dev/null +++ b/supply-chain-graph/schema/referrer-observed.schema.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cssc.toddysm.com/schema/referrer-observed.schema.json", + "title": "ReferrerObserved", + "description": "Append-only observation that a referrer artifact (SBOM, provenance, VEX, signature, attestation) is attached to a subject artifact at a repository occurrence.", + "type": "object", + "required": ["schemaVersion", "kind", "recordedAt", "source", "occurrence", "subject", "referrer", "observedAt"], + "additionalProperties": false, + "properties": { + "schemaVersion": { "const": 1 }, + "kind": { "const": "ReferrerObserved" }, + "id": { "$ref": "envelope.schema.json#/$defs/contentHash" }, + "recordedAt": { "type": "string", "format": "date-time" }, + "source": { "$ref": "envelope.schema.json#/$defs/source" }, + "occurrence": { + "type": "object", + "description": "The registry + repository the subject and its referrer live in.", + "required": ["registry", "repository"], + "additionalProperties": false, + "properties": { + "registry": { "$ref": "envelope.schema.json#/$defs/registry" }, + "repository": { "$ref": "envelope.schema.json#/$defs/repository" } + } + }, + "subject": { + "type": "object", + "description": "The artifact the referrer is attached to.", + "required": ["digest"], + "additionalProperties": false, + "properties": { + "digest": { "$ref": "envelope.schema.json#/$defs/digest" }, + "tag": { "type": "string", "minLength": 1 } + } + }, + "referrer": { + "type": "object", + "description": "The attached referrer artifact.", + "required": ["digest", "artifactType"], + "additionalProperties": false, + "properties": { + "digest": { "$ref": "envelope.schema.json#/$defs/digest" }, + "artifactType": { "type": "string", "minLength": 1 } + } + }, + "observedAt": { "type": "string", "format": "date-time" } + } +}