|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +usage() { |
| 5 | + cat >&2 <<EOF |
| 6 | +Usage: $0 <image[:tag]> [--raw] [--debug] |
| 7 | + --raw Print full attestation JSON (can repeat for multiple) |
| 8 | + --debug Show crane commands executed |
| 9 | +Defaults to :latest if no tag provided. |
| 10 | +EOF |
| 11 | +} |
| 12 | + |
| 13 | +if ! command -v crane >/dev/null 2>&1; then |
| 14 | + echo "crane not found in PATH" >&2; exit 1; fi |
| 15 | +if ! command -v jq >/dev/null 2>&1; then |
| 16 | + echo "jq not found in PATH" >&2; exit 1; fi |
| 17 | + |
| 18 | +[ $# -ge 1 ] || { usage; exit 1; } |
| 19 | + |
| 20 | +IMAGE="$1"; shift || true |
| 21 | +RAW="false"; DEBUG="false" |
| 22 | +while [ $# -gt 0 ]; do |
| 23 | + case "$1" in |
| 24 | + --raw) RAW="true";; |
| 25 | + --debug) DEBUG="true";; |
| 26 | + -h|--help) usage; exit 0;; |
| 27 | + *) echo "Unknown arg: $1" >&2; usage; exit 1;; |
| 28 | + esac |
| 29 | + shift |
| 30 | +done |
| 31 | + |
| 32 | +# Add :latest if no explicit tag or digest |
| 33 | +if [[ "$IMAGE" != *:@* && "$IMAGE" != *:*/*:* && "$IMAGE" != *:*:* && "$IMAGE" != *@sha256:* ]]; then |
| 34 | + # has no :tag part after last slash |
| 35 | + if [[ "$IMAGE" != *:* ]]; then |
| 36 | + IMAGE+=":latest" |
| 37 | + fi |
| 38 | +fi |
| 39 | + |
| 40 | +echo "Inspecting provenance for $IMAGE" >&2 |
| 41 | + |
| 42 | +# Obtain manifest list (or single manifest) JSON |
| 43 | +if ! MANIFEST_JSON=$(crane manifest "$IMAGE" 2>/dev/null); then |
| 44 | + echo "Failed to fetch manifest for $IMAGE" >&2; exit 1; |
| 45 | +fi |
| 46 | + |
| 47 | +# If it's a single-platform manifest, wrap to unify processing |
| 48 | +if ! echo "$MANIFEST_JSON" | jq -e '.manifests' >/dev/null 2>&1; then |
| 49 | + MANIFEST_LIST_JSON='{"manifests":[]}' |
| 50 | +else |
| 51 | + MANIFEST_LIST_JSON="$MANIFEST_JSON" |
| 52 | +fi |
| 53 | + |
| 54 | +UNKNOWN_DIGESTS=$(echo "$MANIFEST_LIST_JSON" | jq -r '.manifests[]? | select(.platform.os=="unknown" and .platform.architecture=="unknown") | .digest') |
| 55 | +if [ -z "$UNKNOWN_DIGESTS" ]; then |
| 56 | + echo "No unknown/unknown platform manifests (attestations) found." >&2 |
| 57 | + echo "Hint: ensure builds set provenance and sbom (buildkit) or attest step." >&2 |
| 58 | + exit 2 |
| 59 | +fi |
| 60 | + |
| 61 | +FOUND=0 |
| 62 | +REGISTRY="${IMAGE%%/*}" # crude but ok for ghcr.io/owner/name:tag |
| 63 | +REPO_TAG=${IMAGE#*/} |
| 64 | +# Split repo and tag/digest |
| 65 | +if [[ "$REPO_TAG" == *"@sha256:"* ]]; then |
| 66 | + REPO="${REPO_TAG%@sha256:*}"; REF="${REPO_TAG#*@}"; REF_TYPE=digest |
| 67 | +else |
| 68 | + REPO="${REPO_TAG%%:*}"; REF="${REPO_TAG##*:}"; REF_TYPE=tag |
| 69 | +fi |
| 70 | + |
| 71 | +IMAGE_PATH=${IMAGE#*/} # remove registry |
| 72 | +REPO_PATH=${IMAGE_PATH%%@*} # drop @digest if any |
| 73 | +REPO_PATH=${REPO_PATH%%:*} # drop :tag |
| 74 | + |
| 75 | +[ "$DEBUG" = "true" ] && echo "+ crane manifest $IMAGE # top-level" >&2 |
| 76 | + |
| 77 | +for DGST in $UNKNOWN_DIGESTS; do |
| 78 | + BASE_REF=${IMAGE%%@*}; BASE_REF=${BASE_REF%%:*} # registry/owner/name |
| 79 | + SUB_JSON=$(crane manifest "${BASE_REF}@${DGST}" 2>/dev/null) || continue |
| 80 | + [ "$DEBUG" = "true" ] && echo "+ crane manifest ${BASE_REF}@${DGST}" >&2 |
| 81 | + LAYER_DIGESTS=$(echo "$SUB_JSON" | jq -r '.layers[]? | select(.mediaType | test("in-toto")) | .digest') |
| 82 | + [ -z "$LAYER_DIGESTS" ] && continue |
| 83 | + for LD in $LAYER_DIGESTS; do |
| 84 | + FOUND=1 |
| 85 | + [ "$DEBUG" = "true" ] && echo "Sub-manifest digest: $DGST" >&2 && echo "In-toto layer digest: $LD" >&2 |
| 86 | + # Retrieve attestation layer (handle crane versions expecting single arg) |
| 87 | + [ "$DEBUG" = "true" ] && echo "+ crane blob ${BASE_REF}@${LD}" >&2 |
| 88 | + ATTESTATION=$(crane blob "${BASE_REF}@${LD}" 2>/dev/null || crane blob "${IMAGE%@*}@${LD}" 2>/dev/null || true) |
| 89 | + [ -z "$ATTESTATION" ] && continue |
| 90 | + if [ "$RAW" = "true" ]; then |
| 91 | + echo "$ATTESTATION" | jq '.' |
| 92 | + continue |
| 93 | + fi |
| 94 | + echo "--- Attestation layer $LD (sub-manifest $DGST) ---" |
| 95 | + JQ_SUMMARY='def dockerfiles: [ (.. | objects | to_entries[]? | select(.key|test("dockerfile";"i")) | .value) ] | flatten | map(tostring) | unique | .; |
| 96 | + def mats: (.materials // .predicate.materials // []); |
| 97 | + def norm(u; d): |
| 98 | + if (u|startswith("docker-image://")) then |
| 99 | + (u | sub("^docker-image://";"")) as $ref | |
| 100 | + if (d|length>0) and ($ref|test("@sha256:" )|not) then ($ref|split("@")|.[0]) + "@sha256:" + d else $ref end |
| 101 | + elif (u|startswith("pkg:docker/")) then |
| 102 | + (u | sub("^pkg:docker/";"") | split("?") | .[0]) as $ref | |
| 103 | + if (d|length>0) and ($ref|test("@sha256:" )|not) then ($ref|split("@")|.[0]) + "@sha256:" + d else $ref end |
| 104 | + else |
| 105 | + if (d|length>0) and (u|test("@sha256:" )|not) then (u + "@sha256:" + d) else u end |
| 106 | + end; |
| 107 | + def base_images: mats | map( ( .uri // .uri_ // empty ) as $u | ( .digest.sha256? // "" ) as $d | select($u != "") | norm($u; $d) ) | unique; |
| 108 | + def bkmeta: .predicate.metadata["https://mobyproject.org/buildkit@v1#metadata"].vcs? // {}; |
| 109 | + def guess_source: (bkmeta.source // .predicate.invocation.environment.GIT_URL? // .predicate.buildConfig.sourceProvenance.resolvedRepoSource.repoUrl? // empty); |
| 110 | + def guess_revision: (bkmeta.revision // .predicate.invocation.environment.GITHUB_SHA? // .predicate.invocation.environment.GIT_COMMIT_SHA? // empty); |
| 111 | + ["Dockerfiles:"] + (dockerfiles| if length==0 then ["(none found)"] else . end) + |
| 112 | + ["Base images (materials):"] + (base_images | if length==0 then ["(none found)"] else . end) + |
| 113 | + ["VCS source:", (guess_source // "(unknown)"), |
| 114 | + "VCS revision:", (guess_revision // "(unknown)"), |
| 115 | + "Build started:", (.predicate.metadata.buildStartedOn? // "(unknown)"), |
| 116 | + "Build finished:", (.predicate.metadata.buildFinishedOn? // "(unknown)")] | .[]' |
| 117 | + [ "$DEBUG" = "true" ] && echo "+ jq -r <summary_program>" >&2 && echo "$JQ_SUMMARY" | sed 's/^/| /' >&2 |
| 118 | + SUMMARY=$(echo "$ATTESTATION" | jq -r "$JQ_SUMMARY") |
| 119 | + if [ -z "${PREV_LAST:-}" ]; then |
| 120 | + echo "$SUMMARY" |
| 121 | + else |
| 122 | + DIFF_PRINTED=false |
| 123 | + while IFS= read -r line; do |
| 124 | + if ! printf '%s\n' "$PREV_LAST" | grep -Fxq "$line"; then |
| 125 | + [ "$DIFF_PRINTED" = false ] && echo "(diff from previous attestation)" && DIFF_PRINTED=true |
| 126 | + echo "$line" |
| 127 | + fi |
| 128 | + done <<< "$SUMMARY" |
| 129 | + [ "$DIFF_PRINTED" = false ] && echo "(no diff from previous attestation)" |
| 130 | + fi |
| 131 | + PREV_LAST="$SUMMARY" |
| 132 | + done |
| 133 | +done |
| 134 | + |
| 135 | +if [ $FOUND -eq 0 ]; then |
| 136 | + echo "No attestation (in-toto) layers found in unknown/unknown manifests." >&2 |
| 137 | + exit 3 |
| 138 | +fi |
0 commit comments