Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 47 additions & 2 deletions .github/actions/build-graph-event/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}"
Expand Down Expand Up @@ -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
Expand Down
140 changes: 140 additions & 0 deletions .github/actions/stage-referrer-events/action.yml
Original file line number Diff line number Diff line change
@@ -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}"
13 changes: 13 additions & 0 deletions .github/workflows/_mirror-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/_promote-from-quarantine-sbom.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/_promote-from-quarantine.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions .github/workflows/_promote-override.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions apps/python-app/libs/cssc_graph/cssc_graph/graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "
Expand All @@ -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)",
)


Expand Down
43 changes: 43 additions & 0 deletions apps/python-app/libs/cssc_graph/cssc_graph/indexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
)
Comment thread
toddysm marked this conversation as resolved.
Comment thread
toddysm marked this conversation as resolved.

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"])
Expand Down
Loading
Loading