@@ -17,7 +17,14 @@
# CONTAINER_AUTH_CONFIG - registry auth file staged into the guest
# CONTAINER_COSIGN_KEY - cosign private key path; when set, each
# successfully pushed image is signed with
-# cosign (otherwise signing is skipped)
+# cosign (otherwise signing is skipped). Also
+# gates SPDX SBOM attestation: each pushed image
+# gets a 'cosign attest --type spdxjson'
+# attestation of its SPDX SBOM (per-arch on the
+# multi-arch path, top manifest on single-arch).
+# CONTAINER_COSIGN_PUB - cosign public key path; when set, each
+# attestation is verified with
+# 'cosign verify-attestation' after it is made
#
import subprocess
@@ -59,6 +66,9 @@ if not auth_config:
# Signing is conditional: only when CONTAINER_COSIGN_KEY points at a cosign
# private key. When unset, no cosign sysroot is prepared and no signing runs.
cosign_key = utils.getconfigvar("CONTAINER_COSIGN_KEY", ourconfig, args.target, args.stepnum)
+# Public key for verifying attestations; when unset, attestations are made but
+# not verified.
+cosign_pub = utils.getconfigvar("CONTAINER_COSIGN_PUB", ourconfig, args.target, args.stepnum)
utils.printheader("Pushing container images %s" % list(container_images.keys()))
@@ -193,6 +203,13 @@ sys.stdout.write(base64.b64decode(t).decode() if t else "")' "%s" "$_host")
login_block,
" _COSIGN_READY=1",
"}",
+ # Resolve the lone manifest digest of a single-arch reference. Correct
+ # only when there is no manifest list (single-arch / child manifest);
+ # the multi-arch path passes explicit digests instead. oe-run-native
+ # prints 'Getting sysroot...' to stdout, so grep the digest out.
+ "resolve_digest() {",
+ " oe-run-native skopeo-native skopeo inspect --authfile %s --format '{{.Digest}}' docker://$1 | grep -oE 'sha256:[0-9a-f]{64}' | tail -n1" % auth_config,
+ "}",
# Track digests already signed in this run so the same artefact is
# signed only once (see sign_image).
"_SIGNED_REFS=\"\"",
@@ -205,7 +222,7 @@ sys.stdout.write(base64.b64decode(t).decode() if t else "")' "%s" "$_host")
" prepare_skopeo",
" prepare_cosign",
" _SIG_DIGEST=\"$2\"",
- " if [ -z \"$_SIG_DIGEST\" ]; then _SIG_DIGEST=$(oe-run-native skopeo-native skopeo inspect --authfile %s --format '{{.Digest}}' docker://$1 | grep -oE 'sha256:[0-9a-f]{64}' | tail -n1); fi" % auth_config,
+ " if [ -z \"$_SIG_DIGEST\" ]; then _SIG_DIGEST=$(resolve_digest $1); fi",
" if [ -z \"$_SIG_DIGEST\" ]; then echo \"WARNING: could not resolve digest for $1, skipping cosign sign\"; return 0; fi",
" _SIG_REF=\"${1%:*}@${_SIG_DIGEST}\"",
" case \" $_SIGNED_REFS \" in *\" $_SIG_REF \"*) return 0 ;; esac",
@@ -222,6 +239,46 @@ sys.stdout.write(base64.b64decode(t).decode() if t else "")' "%s" "$_host")
" }",
" echo \"$_sign_out\"",
"}",
+ # Make sure an image's SPDX SBOM exists before we attest it. $1 is the
+ # expected spdx.json path, $2 the bitbake target that produces it
+ # (mc:<mc>:<recipe> on the multi-arch path, plain <recipe> single-arch).
+ # create_image_sbom_spdx runs by default for single-arch builds, so the
+ # file is normally already there; the multi-arch index recipe doesn't
+ # pull in the per-arch SBOM tasks, so we generate them on demand.
+ "ensure_spdx() {",
+ " if [ ! -e \"$1\" ]; then",
+ " echo \"SPDX SBOM $1 missing, generating: bitbake -c create_image_sbom_spdx $2\"",
+ " bitbake -c create_image_sbom_spdx $2 || true",
+ " fi",
+ " if [ ! -e \"$1\" ]; then echo \"WARNING: SPDX SBOM $1 still missing, skipping attestation\"; return 1; fi",
+ "}",
+ # Track refs already attested so the same digest is attested once.
+ "_ATTESTED_REFS=\"\"",
+ # Attest an SPDX SBOM against a digest-pinned image ref. $1 =
+ # <registry>/<image>@sha256:...; $2 = spdx.json path. Idempotent: a
+ # transparency-log conflict on rebuilds is treated as success. When a
+ # public key is configured the attestation is verified afterwards.
+ "attest_image() {",
+ " prepare_skopeo",
+ " prepare_cosign",
+ " case \" $_ATTESTED_REFS \" in *\" $1 \"*) return 0 ;; esac",
+ " _ATTESTED_REFS=\"$_ATTESTED_REFS $1\"",
+ " _att_out=$(oe-run-native cosign-native cosign attest --key %s --type spdxjson --predicate \"$2\" \"$1\" 2>&1) || {" % cosign_key,
+ " case \"$_att_out\" in",
+ " *\"already exists\"*|*createLogEntryConflict*) echo \"cosign: attestation for $1 already in transparency log\" ;;",
+ " *) echo \"$_att_out\" >&2; return 1 ;;",
+ " esac",
+ " }",
+ " echo \"$_att_out\"",
+ ]
+ if cosign_pub:
+ script += [
+ " oe-run-native cosign-native cosign verify-attestation --key %s --type spdxjson \"$1\" >/dev/null \\" % cosign_pub,
+ " && echo \"cosign: verified attestation for $1\" \\",
+ " || { echo \"ERROR: attestation verification failed for $1\" >&2; return 1; }",
+ ]
+ script += [
+ "}",
]
tag_cmds = utils.getconfiglist("CONTAINER_TAG_CMDS", ourconfig, args.target, args.stepnum)
@@ -259,6 +316,13 @@ for recipe, image in container_images.items():
" *) _DISTRO_VERSION=\"$_DISTRO_VERSION_RAW\" ;;",
"esac",
"_DEPLOY_DIR_IMAGE=$(echo \"$_BBENV\" | awk -F'\"' '/^DEPLOY_DIR_IMAGE=/{ print $2; exit }')",
+ # MACHINE locates the single-arch SPDX SBOM (<recipe>-<MACHINE>.rootfs.spdx.json).
+ "_MACHINE=$(echo \"$_BBENV\" | awk -F'\"' '/^MACHINE=/{ print $2; exit }')",
+ # TOPDIR and the oci-multiarch plain vars drive per-arch SBOM
+ # attestation below; the latter two are empty for non-multiarch recipes.
+ "_TOPDIR=$(echo \"$_BBENV\" | awk -F'\"' '/^TOPDIR=/{ print $2; exit }')",
+ "_OCI_MULTIARCH_RECIPE=$(echo \"$_BBENV\" | awk -F'\"' '/^OCI_MULTIARCH_RECIPE=/{ print $2; exit }')",
+ "_OCI_MULTIARCH_PLATFORMS=$(echo \"$_BBENV\" | awk -F'\"' '/^OCI_MULTIARCH_PLATFORMS=/{ print $2; exit }')",
# Only set (non-empty) when the recipe inherits
# oci-multiarch; doubles as the multiarch marker and
# the OCI layout path for skopeo below.
@@ -318,6 +382,34 @@ for recipe, image in container_images.items():
if cosign_key:
script.append(" sign_image %s/%s:${_tag} \"$(cat \"$_DGSTFILE\")\"" % (registry, image))
script.append(" done")
+ if cosign_key:
+ # Attest each child manifest with that arch's SPDX SBOM. The arch ->
+ # (multiconfig, machine) mapping lives in oci-multiarch.bbclass flags
+ # (OCI_MULTIARCH_MC/MACHINE[<arch>]); query them per platform with
+ # bitbake-getvar so an overridden mapping is still honoured. The child
+ # manifest digest is the per-arch source image's manifest digest, which
+ # the class copies verbatim into the index and skopeo copy --all pushes
+ # unchanged — so we attest <image>@<child-digest>.
+ script.append(" for _plat in $_OCI_MULTIARCH_PLATFORMS; do")
+ script.append(" _mc=$(bitbake-getvar -q -r %s --value -f $_plat OCI_MULTIARCH_MC)" % recipe)
+ script.append(" _machine=$(bitbake-getvar -q -r %s --value -f $_plat OCI_MULTIARCH_MACHINE)" % recipe)
+ script.append(" if [ -z \"$_mc\" ] || [ -z \"$_machine\" ]; then echo \"WARNING: no mc/machine for platform $_plat, skipping attestation\"; continue; fi")
+ script.append(" _pdir=${_TOPDIR}/tmp-${_mc}/deploy/images/${_machine}")
+ script.append(" _mspdx=${_pdir}/${_OCI_MULTIARCH_RECIPE}-${_machine}.rootfs.spdx.json")
+ # Locate the per-arch source OCI layout (same name patterns the class
+ # searches) and read its single manifest digest = the child digest.
+ script.append(" _mdig=\"\"")
+ script.append(" for _oci in ${_OCI_MULTIARCH_RECIPE}-latest-oci ${_OCI_MULTIARCH_RECIPE}-${_machine}-latest-oci ${_OCI_MULTIARCH_RECIPE}-oci; do")
+ script.append(" if [ -e \"${_pdir}/${_oci}/index.json\" ]; then")
+ script.append(" _mdig=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[\"manifests\"][0][\"digest\"])' \"${_pdir}/${_oci}/index.json\")")
+ script.append(" break")
+ script.append(" fi")
+ script.append(" done")
+ script.append(" if [ -z \"$_mdig\" ]; then echo \"WARNING: no source OCI index for $_plat under $_pdir, skipping attestation\"; continue; fi")
+ script.append(" ensure_spdx \"$_mspdx\" \"mc:${_mc}:${_OCI_MULTIARCH_RECIPE}\" || continue")
+ for registry in registries:
+ script.append(" attest_image %s/%s@${_mdig} \"$_mspdx\"" % (registry, image))
+ script.append(" done")
script.append(" fi")
script.append("else")
# Single-arch: import the ${recipe}-latest-oci artefact into the memres VM
@@ -327,6 +419,12 @@ for recipe, image in container_images.items():
script.append(" echo \"WARNING: %s did not build (no OCI image at $_OCI_IMAGE), skipping push\"" % recipe)
script.append(" else")
script.append(" start_vm")
+ if cosign_key:
+ # create_image_sbom_spdx runs by default for single-arch image builds,
+ # so the SBOM is normally already deployed; ensure_spdx regenerates it
+ # only if absent. Single manifest -> attest the digest the tag resolves
+ # to (resolve_digest, also used by sign_image).
+ script.append(" _SPDX=${_DEPLOY_DIR_IMAGE}/%s-${_MACHINE}.rootfs.spdx.json" % recipe)
for registry in registries:
script += [
" for _tag in $_TAGS; do",
@@ -335,6 +433,10 @@ for recipe, image in container_images.items():
]
if cosign_key:
script.append(" sign_image %s/%s:${_tag}" % (registry, image))
+ script.append(" if ensure_spdx \"$_SPDX\" \"%s\"; then" % recipe)
+ script.append(" _ADIG=$(resolve_digest %s/%s:${_tag})" % (registry, image))
+ script.append(" [ -n \"$_ADIG\" ] && attest_image %s/%s@${_ADIG} \"$_SPDX\"" % (registry, image))
+ script.append(" fi")
script.append(" done")
script.append(" fi")
script.append("fi")
Attach a *.rootfs.spdx.json as an in-toto attestation to either the top-level container if it is single-arch or to each arch container image if it is multi-arch. Introduce CONTAINER_COSIGN_PUB which is the cosign public key path; when set, each attestation is verified with 'cosign verify-attestation' after it is attached Signed-off-by: Tim Orling <tim.orling@konsulko.com> --- scripts/run-push-containers | 106 +++++++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 2 deletions(-)