|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +# prepare runs before Buildx; scan runs in the composite action; publish runs |
| 5 | +# only after the scan action succeeds. Credentials remain in Docker's config. |
| 6 | +case "${1:-}" in |
| 7 | + prepare) |
| 8 | + : "${SECURITY_REPORT_DIRECTORY:?}" "${SECURITY_PUBLISH:?}" "${GITHUB_OUTPUT:?}" |
| 9 | + mkdir -p "$SECURITY_REPORT_DIRECTORY" |
| 10 | + archive="$SECURITY_REPORT_DIRECTORY.oci.tar" |
| 11 | + repository="container-security-local" |
| 12 | + : > "$SECURITY_REPORT_DIRECTORY/release-tags.txt" |
| 13 | + if [[ "$SECURITY_PUBLISH" == true ]]; then |
| 14 | + : "${SECURITY_RELEASE_TAGS:?}" |
| 15 | + repository="" |
| 16 | + while IFS= read -r tag; do |
| 17 | + [[ -z "$tag" ]] && continue |
| 18 | + [[ "$tag" =~ ^ghcr\.io/[a-zA-Z0-9._/-]+:[a-zA-Z0-9_.-]+$ ]] || { |
| 19 | + echo "Unsupported release tag: $tag" >&2; exit 2; |
| 20 | + } |
| 21 | + candidate="${tag%:*}" |
| 22 | + if [[ -n "$repository" && "$candidate" != "$repository" ]]; then |
| 23 | + echo "One build must publish to one image repository" >&2; exit 2 |
| 24 | + fi |
| 25 | + repository="$candidate" |
| 26 | + printf '%s\n' "$tag" >> "$SECURITY_REPORT_DIRECTORY/release-tags.txt" |
| 27 | + done <<< "$SECURITY_RELEASE_TAGS" |
| 28 | + [[ -s "$SECURITY_REPORT_DIRECTORY/release-tags.txt" ]] || exit 2 |
| 29 | + exporter='type=image,push-by-digest=true,name-canonical=true,push=true' |
| 30 | + elif [[ "$SECURITY_PUBLISH" == false ]]; then |
| 31 | + exporter="type=oci,dest=$archive" |
| 32 | + else |
| 33 | + echo "SECURITY_PUBLISH must be true or false" >&2; exit 2 |
| 34 | + fi |
| 35 | + printf 'repository=%s\nexporter=%s\narchive=%s\n' "$repository" "$exporter" "$archive" >> "$GITHUB_OUTPUT" |
| 36 | + ;; |
| 37 | + scan) |
| 38 | + : "${SECURITY_IMAGE:?}" "${SECURITY_PLATFORMS:?}" "${SECURITY_REPORT_DIRECTORY:?}" "${SYFT_CMD:?}" "${GRYPE_CMD:?}" |
| 39 | + mkdir -p "$SECURITY_REPORT_DIRECTORY" |
| 40 | + printf '%s\n' "$SECURITY_IMAGE" > "$SECURITY_REPORT_DIRECTORY/scanned-image.txt" |
| 41 | + printf '%s\n' "$SECURITY_PLATFORMS" > "$SECURITY_REPORT_DIRECTORY/scanned-platforms.txt" |
| 42 | + : > "$SECURITY_REPORT_DIRECTORY/gate.txt" |
| 43 | + # Explicit empty configurations prevent project ignore rules from silently |
| 44 | + # excluding findings or turning this into an only-fixed scan. |
| 45 | + printf '{}\n' > "$SECURITY_REPORT_DIRECTORY/syft-config.yaml" |
| 46 | + printf '{}\n' > "$SECURITY_REPORT_DIRECTORY/grype-config.yaml" |
| 47 | + export SYFT_CHECK_FOR_APP_UPDATE=false GRYPE_CHECK_FOR_APP_UPDATE=false |
| 48 | + export GRYPE_DB_AUTO_UPDATE=true GRYPE_DB_VALIDATE_AGE=true |
| 49 | + failed=0 |
| 50 | + count=0 |
| 51 | + IFS=',' read -r -a platforms <<< "${SECURITY_PLATFORMS//$'\n'/,}" |
| 52 | + for platform in "${platforms[@]}"; do |
| 53 | + platform="${platform//[[:space:]]/}" |
| 54 | + [[ "$platform" =~ ^linux/(amd64|arm64)(/v[0-9]+)?$ ]] || { |
| 55 | + echo "Unsupported or empty image platform: $platform" >&2; exit 2; |
| 56 | + } |
| 57 | + count=$((count + 1)) |
| 58 | + suffix="${platform//\//-}" |
| 59 | + sbom="$SECURITY_REPORT_DIRECTORY/$suffix.syft.json" |
| 60 | + report="$SECURITY_REPORT_DIRECTORY/$suffix.grype.json" |
| 61 | + echo "Generating SBOM and scanning $platform" |
| 62 | + if ! "$SYFT_CMD" scan "$SECURITY_IMAGE" --platform "$platform" \ |
| 63 | + --config "$SECURITY_REPORT_DIRECTORY/syft-config.yaml" \ |
| 64 | + --output "syft-json=$sbom"; then |
| 65 | + echo "Syft failed for $platform" >&2 |
| 66 | + printf '%s FAIL syft\n' "$platform" >> "$SECURITY_REPORT_DIRECTORY/gate.txt" |
| 67 | + failed=1 |
| 68 | + continue |
| 69 | + fi |
| 70 | + if "$GRYPE_CMD" "sbom:$sbom" --config "$SECURITY_REPORT_DIRECTORY/grype-config.yaml" \ |
| 71 | + --fail-on high --output json --file "$report"; then |
| 72 | + printf '%s PASS\n' "$platform" >> "$SECURITY_REPORT_DIRECTORY/gate.txt" |
| 73 | + else |
| 74 | + status=$? |
| 75 | + printf '%s FAIL exit=%s\n' "$platform" "$status" >> "$SECURITY_REPORT_DIRECTORY/gate.txt" |
| 76 | + echo "Grype failed for $platform (exit $status)" >&2 |
| 77 | + failed=1 |
| 78 | + fi |
| 79 | + done |
| 80 | + [[ "$count" -gt 0 ]] || exit 2 |
| 81 | + [[ "$failed" == 0 ]] || exit 1 |
| 82 | + ;; |
| 83 | + publish) |
| 84 | + : "${SECURITY_IMAGE:?}" "${SECURITY_REPORT_DIRECTORY:?}" |
| 85 | + [[ "$SECURITY_IMAGE" =~ ^ghcr\.io/[a-zA-Z0-9._/-]+@sha256:[a-f0-9]{64}$ ]] || { |
| 86 | + echo "Publication requires an immutable GHCR digest" >&2; exit 2; |
| 87 | + } |
| 88 | + [[ -s "$SECURITY_REPORT_DIRECTORY/gate.txt" ]] || { |
| 89 | + echo "Missing successful security gate evidence" >&2; exit 2; |
| 90 | + } |
| 91 | + if grep -q ' FAIL' "$SECURITY_REPORT_DIRECTORY/gate.txt"; then |
| 92 | + echo "Failed security gate prevents release tagging" >&2; exit 2 |
| 93 | + fi |
| 94 | + [[ "$(cat "$SECURITY_REPORT_DIRECTORY/scanned-image.txt")" == "registry:$SECURITY_IMAGE" ]] || { |
| 95 | + echo "Gate evidence belongs to a different image" >&2; exit 2; |
| 96 | + } |
| 97 | + args=(docker buildx imagetools create --prefer-index=false) |
| 98 | + while IFS= read -r tag; do |
| 99 | + [[ -z "$tag" ]] && continue |
| 100 | + args+=(--tag "$tag") |
| 101 | + done < "$SECURITY_REPORT_DIRECTORY/release-tags.txt" |
| 102 | + [[ "${#args[@]}" -gt 5 ]] || exit 2 |
| 103 | + "${args[@]}" "$SECURITY_IMAGE" |
| 104 | + digest="${SECURITY_IMAGE##*@}" |
| 105 | + while IFS= read -r tag; do |
| 106 | + [[ -z "$tag" ]] && continue |
| 107 | + actual="$(docker buildx imagetools inspect "$tag" --format '{{.Manifest.Digest}}')" |
| 108 | + [[ "$actual" == "$digest" ]] || { |
| 109 | + echo "Published digest differs from scanned image for $tag" >&2; exit 3; |
| 110 | + } |
| 111 | + printf '%s %s\n' "$tag" "$actual" >> "$SECURITY_REPORT_DIRECTORY/published-digests.txt" |
| 112 | + done < "$SECURITY_REPORT_DIRECTORY/release-tags.txt" |
| 113 | + ;; |
| 114 | + *) echo "Usage: $0 prepare|scan|publish" >&2; exit 2 ;; |
| 115 | +esac |
0 commit comments