66 lines
3.3 KiB
Bash
Executable File
66 lines
3.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# Create/verify a signed release provenance statement. The statement binds the
|
|
# exact commit, SBOM, test evidence and immutable image digests. The signing key
|
|
# is injected by CI from the secret manager and never lives in the repository.
|
|
#
|
|
# release-provenance.sh create <statement.json> <private.pem> <sbom.json> <test-evidence> <image@sha256...>...
|
|
# release-provenance.sh verify <statement.json> <public.pem> [expected-commit]
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
source "$SCRIPT_DIR/casan-paths.sh"
|
|
ROOT="$CASAN_APP_ROOT"
|
|
|
|
hash_of() { openssl dgst -sha256 "$1" | awk '{print $NF}'; }
|
|
fail() { echo "RELEASE_PROVENANCE_FAIL $*" >&2; exit 1; }
|
|
cmd="${1:-}"; shift || true
|
|
|
|
case "$cmd" in
|
|
create)
|
|
statement="${1:-}"; key="${2:-}"; sbom="${3:-}"; evidence="${4:-}"; shift 4 || true
|
|
[[ -n "$statement" && -f "$key" && -s "$sbom" && -s "$evidence" && "$#" -ge 1 ]] || fail "create_usage_or_missing_input"
|
|
for image in "$@"; do [[ "$image" =~ @sha256:[a-f0-9]{64}$ ]] || fail "image_not_digest_pinned image=$image"; done
|
|
commit="$(git -C "$ROOT" rev-parse HEAD)"
|
|
python3 - "$statement" "$commit" "$sbom" "$evidence" "$@" <<'PY'
|
|
import hashlib, json, sys
|
|
out, commit, sbom, evidence, *images = sys.argv[1:]
|
|
def sha(path):
|
|
h=hashlib.sha256()
|
|
with open(path,'rb') as f:
|
|
for c in iter(lambda:f.read(65536), b''): h.update(c)
|
|
return h.hexdigest()
|
|
payload = {
|
|
"_type": "https://in-toto.io/Statement/v1",
|
|
"predicateType": "https://slsa.dev/provenance/v1",
|
|
"subject": [{"name": image, "digest": {"sha256": image.rsplit("@sha256:",1)[1]}} for image in images],
|
|
"predicate": {"buildDefinition": {"resolvedDependencies": [{"uri": "git+casan", "digest": {"sha1": commit}}]},
|
|
"runDetails": {"builder": {"id": "casan-gitea-ci"},
|
|
"byproducts": [{"name": "npm.cyclonedx.json", "sha256": sha(sbom)}, {"name": "test-evidence", "sha256": sha(evidence)}]}}
|
|
}
|
|
with open(out, 'w', encoding='utf-8') as f: json.dump(payload, f, sort_keys=True, separators=(',', ':'))
|
|
PY
|
|
openssl dgst -sha256 -sign "$key" -out "$statement.sig" "$statement" || fail "sign_failed"
|
|
echo "RELEASE_PROVENANCE_CREATED statement=$statement commit=$commit images=$# sbom_sha256=$(hash_of "$sbom")"
|
|
;;
|
|
verify)
|
|
statement="${1:-}"; key="${2:-}"; expected="${3:-$(git -C "$ROOT" rev-parse HEAD)}"
|
|
[[ -s "$statement" && -s "$statement.sig" && -f "$key" ]] || fail "verify_missing_statement_signature_or_key"
|
|
openssl dgst -sha256 -verify "$key" -signature "$statement.sig" "$statement" >/dev/null || fail "signature_invalid"
|
|
python3 - "$statement" "$expected" <<'PY' || exit 1
|
|
import json, re, sys
|
|
d=json.load(open(sys.argv[1])); expected=sys.argv[2]
|
|
assert d.get('_type') == 'https://in-toto.io/Statement/v1'
|
|
assert d.get('predicateType') == 'https://slsa.dev/provenance/v1'
|
|
deps=d.get('predicate',{}).get('buildDefinition',{}).get('resolvedDependencies',[])
|
|
assert deps and deps[0].get('digest',{}).get('sha1') == expected
|
|
subjects=d.get('subject',[]); assert subjects
|
|
for s in subjects:
|
|
digest=s.get('digest',{}).get('sha256','')
|
|
assert re.fullmatch(r'[a-f0-9]{64}', digest), digest
|
|
PY
|
|
echo "RELEASE_PROVENANCE_VERIFIED statement=$statement commit=$expected"
|
|
;;
|
|
*) fail "usage create|verify" ;;
|
|
esac
|