fix(release): harden Cosign trust root and platform SBOMs

Require SiriusScan/Sirius@refs/heads/main, pin the exact canonical
Cosign identity, emit 12 platform-scoped CycloneDX assets, re-verify
signatures before publish, and remove the 1.4↔1.5 task dependency cycle.
This commit is contained in:
0sm0s1z
2026-07-30 12:00:15 -07:00
parent a8460de25b
commit 9e336ef39c
10 changed files with 460 additions and 155 deletions
@@ -12,7 +12,8 @@ name: Publish Release Image Tags
# 9) explicitly dispatch verify-ghcr-release-tag.yml for the target tag
#
# Does NOT create Git tags. Already-published releases are fail-closed.
# Publication requires all six SBOM assets and verified Cosign signatures.
# Publication requires 12 platform SBOMs and verified Cosign signatures.
# Trust root: SiriusScan/Sirius on refs/heads/main only (canonical, not fork-dynamic).
on:
workflow_dispatch:
@@ -29,6 +30,9 @@ concurrency:
env:
REGISTRY: ghcr.io
IMAGE_NAMESPACE: siriusscan
# Canonical Cosign trust (must match scripts/release-components.sh).
COSIGN_CERTIFICATE_OIDC_ISSUER: https://token.actions.githubusercontent.com
COSIGN_CERTIFICATE_IDENTITY_REGEXP: '^https://github\.com/SiriusScan/Sirius/\.github/workflows/publish-release-image-tags\.yml@refs/heads/main$'
jobs:
validate-inputs:
@@ -50,8 +54,24 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
RAW_TARGET_TAG: ${{ github.event.inputs.target_tag }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
# Fail closed: canonical repo + default-branch dispatch only.
if [ "${GITHUB_REPOSITORY}" != "SiriusScan/Sirius" ]; then
echo "::error::publish-release-image-tags may only run in SiriusScan/Sirius (got ${GITHUB_REPOSITORY})"
exit 1
fi
if [ "${DEFAULT_BRANCH}" != "main" ]; then
echo "::error::canonical Cosign identity requires default_branch=main (got ${DEFAULT_BRANCH})"
exit 1
fi
if [ "${WORKFLOW_REF}" != "refs/heads/main" ]; then
echo "::error::workflow_dispatch must run on refs/heads/main (got ${WORKFLOW_REF})"
exit 1
fi
TARGET_TAG="${RAW_TARGET_TAG}"
if ! printf '%s' "${TARGET_TAG}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::target_tag must match vMAJOR.MINOR.PATCH"
@@ -78,7 +98,7 @@ jobs:
echo "target_tag=${TARGET_TAG}"
echo "source_commit=${SOURCE_COMMIT}"
} >> "$GITHUB_OUTPUT"
echo "Validated target_tag=${TARGET_TAG} -> ${SOURCE_COMMIT}"
echo "Validated target_tag=${TARGET_TAG} -> ${SOURCE_COMMIT} (trust root SiriusScan/Sirius@refs/heads/main)"
resolve-inventory:
needs: validate-inputs
@@ -252,7 +272,10 @@ jobs:
bash scripts/install-release-attest-tools.sh --dir "${HOME}/.local/bin"
echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
- name: Generate CycloneDX SBOMs from inventory digests
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Generate platform-scoped CycloneDX SBOMs from inventory indexes
env:
TARGET_TAG: ${{ needs.validate-inputs.outputs.target_tag }}
SOURCE_COMMIT: ${{ needs.validate-inputs.outputs.source_commit }}
@@ -268,13 +291,9 @@ jobs:
bash scripts/assert-release-sbom-assets.sh --tag "${TARGET_TAG}" --dir sboms
- name: Cosign keyless sign and verify inventory digests
env:
COSIGN_CERTIFICATE_OIDC_ISSUER: https://token.actions.githubusercontent.com
run: |
set -euo pipefail
# Fulcio subject: this workflow file on a branch ref (workflow_dispatch).
# Issuer: https://token.actions.githubusercontent.com (GitHub Actions OIDC).
export COSIGN_CERTIFICATE_IDENTITY_REGEXP="^https://github\\.com/${GITHUB_REPOSITORY//\//\\/}/\\.github/workflows/publish-release-image-tags\\.yml@refs/heads/.+$"
# Uses workflow env COSIGN_CERTIFICATE_* (canonical SiriusScan/Sirius@main).
INV="inventory/core-build-inventory.json"
bash scripts/sign-verify-release-images.sh \
--inventory "${INV}" \
@@ -293,6 +312,7 @@ jobs:
permissions:
contents: write
actions: write
packages: read
steps:
- name: Checkout workflow tooling
uses: actions/checkout@v4
@@ -364,6 +384,12 @@ jobs:
path: core-manifest.yaml
if-no-files-found: error
- name: Install pinned Cosign for final verification
run: |
set -euo pipefail
bash scripts/install-release-attest-tools.sh --dir "${HOME}/.local/bin" --tools cosign
echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
- name: Draft, upload, re-validate, then publish GitHub Release
env:
GH_TOKEN: ${{ github.token }}
@@ -379,13 +405,13 @@ jobs:
echo "- Images retagged from CI core-build-inventory digests (write-once)"
echo "- Public compose smoke test passed"
echo "- \`core-manifest.yaml\` attached (image digests, engine pins, schema ledger)"
echo "- CycloneDX SBOMs attached for all six public images (\`sbom-<component>-${TARGET_TAG}.cdx.json\`)"
echo "- Image manifests signed with Cosign (GitHub Actions OIDC / Fulcio keyless)"
echo "- CycloneDX SBOMs attached for all six images × linux/amd64 + linux/arm64 (12 assets: \`sbom-<component>-${TARGET_TAG}-linux-amd64|arm64.cdx.json\`)"
echo "- Image index manifests signed with Cosign (GitHub Actions OIDC / Fulcio keyless)"
echo
echo "### Verify Cosign signatures"
echo
echo '```bash'
echo "IDENTITY_RE='^https://github\\.com/${GITHUB_REPOSITORY//\//\\/}/\\.github/workflows/publish-release-image-tags\\.yml@refs/heads/.+$'"
echo "IDENTITY_RE='^https://github\\.com/SiriusScan/Sirius/\\.github/workflows/publish-release-image-tags\\.yml@refs/heads/main\$'"
echo "ISSUER='https://token.actions.githubusercontent.com'"
echo "for c in sirius-ui sirius-api sirius-engine sirius-postgres sirius-rabbitmq sirius-valkey; do"
echo " digest=\$(crane digest \"ghcr.io/siriusscan/\${c}:${TARGET_TAG}\")"
@@ -398,9 +424,9 @@ jobs:
} > "${NOTES_FILE}"
bash scripts/assert-release-sbom-assets.sh --tag "${TARGET_TAG}" --dir sboms
mapfile -t SBOM_FILES < <(ls -1 sboms/sbom-*-"${TARGET_TAG}".cdx.json)
[ "${#SBOM_FILES[@]}" -eq 6 ] || {
echo "::error::expected exactly six SBOM files before upload, got ${#SBOM_FILES[@]}"
mapfile -t SBOM_FILES < <(ls -1 sboms/sbom-*-"${TARGET_TAG}"-linux-*.cdx.json | sort)
[ "${#SBOM_FILES[@]}" -eq 12 ] || {
echo "::error::expected exactly twelve platform SBOM files before upload, got ${#SBOM_FILES[@]}"
exit 1
}
@@ -442,6 +468,12 @@ jobs:
--tag "${TARGET_TAG}" \
--dir /tmp/release-asset-check
# Final fail-closed Cosign verify of inventory index digests (no id-token).
INV="inventory/core-build-inventory.json"
bash scripts/sign-verify-release-images.sh \
--inventory "${INV}" \
--mode verify
echo "Publishing GitHub Release ${TARGET_TAG}"
gh release edit "${TARGET_TAG}" --draft=false
@@ -53,5 +53,5 @@ Community currently publishes primarily via mutable `latest` tags, with sparse S
## Implementation Notes
- Engine pin policy in `README.engine-component-pinning.md` is the model for Pro→Community pins.
- Public release train (`publish-release-image-tags.yml`) generates Syft CycloneDX SBOMs and Cosign keyless signatures (GitHub Actions OIDC / Fulcio) for all six inventory digests before publishing a GitHub Release. See [README.workflows.md](../deployment/README.workflows.md) for the verification identity policy.
- Public release train (`publish-release-image-tags.yml`) generates 12 platform-scoped Syft CycloneDX SBOMs (`linux/amd64` + `linux/arm64`) and Cosign keyless signatures (canonical GitHub Actions OIDC / Fulcio identity on `SiriusScan/Sirius@refs/heads/main`) for all six inventory index digests before publishing a GitHub Release. See [README.workflows.md](../deployment/README.workflows.md) for the verification identity policy.
- Nightly Pro compatibility runs against public `main` provide early breakage signal.
@@ -68,7 +68,7 @@ The Sirius CI/CD pipeline is implemented in `.github/workflows/ci.yml` and follo
**Other workflows** (not shown in the diagram below):
- [`.github/workflows/ci.yml`](../../../.github/workflows/ci.yml) `core-manifest-tests` — PR/main: `bash scripts/test-core-manifest.sh`. `core-build-inventory`**default-branch push only** (never `repository_dispatch`): write-once `sha-<commit>` snapshot + Compose smoke + inventory artifact. Depends on `core-manifest-tests`.
- [`.github/workflows/publish-release-image-tags.yml`](../../../.github/workflows/publish-release-image-tags.yml) — **gated Community release train** (manual). Requires an existing Git tag (`vMAJOR.MINOR.PATCH`) and fails early if a non-draft release exists. Resolves inventory from successful default-branch push CI runs, write-once retags from `@sha256` digests, smokes, **Syft CycloneDX SBOMs + Cosign keyless sign/verify on inventory digests**, draft→upload manifest+SBOMs→revalidate→publish, then dispatches verify. Concurrency keyed by target tag (`cancel-in-progress: false`). Attest job: `id-token:write` + `packages:write`. Final publish job: `contents:write` + `actions:write`.
- [`.github/workflows/publish-release-image-tags.yml`](../../../.github/workflows/publish-release-image-tags.yml) — **gated Community release train** (manual). Fail-closed trust root: `SiriusScan/Sirius` and `workflow_dispatch` on `refs/heads/main` only. Requires an existing Git tag (`vMAJOR.MINOR.PATCH`) and fails early if a non-draft release exists. Resolves inventory from successful default-branch push CI runs, write-once retags from `@sha256` digests, smokes, **platform-scoped Syft CycloneDX SBOMs (12) + Cosign keyless sign/verify on inventory index digests**, draft→upload manifest+SBOMs→revalidate→final Cosign verify→publish, then dispatches verify. Concurrency keyed by target tag (`cancel-in-progress: false`). Attest job: `id-token:write` + `packages:write`. Final publish job: `contents:write` + `actions:write` + `packages:read` (no `id-token`).
- [`.github/workflows/verify-ghcr-release-tag.yml`](../../../.github/workflows/verify-ghcr-release-tag.yml) — anonymous GHCR checks always; `core-manifest.yaml` digest verification required for **v1.1.0+** (pre-v1.1.0 legacy anonymous-only). Tooling from current checkout; inputs passed via step `env` only.
**Release scripts:**
@@ -78,18 +78,20 @@ The Sirius CI/CD pipeline is implemented in `.github/workflows/ci.yml` and follo
- `scripts/ci-dispatch-allowlist.sh` — exact submodule allowlist + 40-hex SHA for `repository_dispatch` pins.
- `scripts/generate-core-manifest.sh` — emit JSON-compatible `core-manifest.yaml` from inventory + Dockerfile pins + `schema_map.json`.
- `scripts/validate-core-manifest.sh` / `scripts/core-manifest` — strict Go stdlib validator (duplicate keys / unknown fields / types).
- `scripts/install-release-attest-tools.sh` — checksum-pinned Syft + Cosign install (no long-lived signing keys).
- `scripts/generate-release-sboms.sh` / `scripts/assert-release-sbom-assets.sh`six CycloneDX SBOMs from inventory `@sha256` refs; fail-closed asset checks.
- `scripts/sign-verify-release-images.sh` — Cosign keyless sign/verify of inventory digests (GitHub Actions OIDC → Fulcio).
- `scripts/install-release-attest-tools.sh` — checksum-pinned Syft and/or Cosign install (`--tools all|syft|cosign`).
- `scripts/generate-release-sboms.sh` / `scripts/assert-release-sbom-assets.sh`12 platform-scoped CycloneDX SBOMs from inventory OCI indexes (`linux/amd64` + `linux/arm64` child digests); fail-closed asset checks.
- `scripts/sign-verify-release-images.sh` — Cosign keyless sign/verify of inventory index digests (canonical GitHub Actions OIDC → Fulcio).
- `scripts/test-core-manifest.sh` — local fixture tests (no GHCR); includes `test-release-signing-contract.sh`; enforced in CI.
**Cosign trust / verification (Community releases):**
- **Trust root**: canonical repository `SiriusScan/Sirius` and `workflow_dispatch` ref `refs/heads/main` only (not fork-dynamic).
- **Identity**: GitHub Actions OIDC keyless signing (no repository private keys).
- **OIDC issuer**: `https://token.actions.githubusercontent.com`
- **Certificate identity regexp**: `^https://github\.com/<owner>/<repo>/\.github/workflows/publish-release-image-tags\.yml@refs/heads/.+$`
- **Signed subjects**: exact inventory digests `ghcr.io/siriusscan/<component>@sha256:...` (never `:latest`).
- **SBOM assets**: `sbom-<component>-<tag>.cdx.json` (CycloneDX JSON) for all six public images; required on the draft release before publish.
- **Certificate identity (exact)**: `https://github.com/SiriusScan/Sirius/.github/workflows/publish-release-image-tags.yml@refs/heads/main`
- **Certificate identity regexp**: `^https://github\.com/SiriusScan/Sirius/\.github/workflows/publish-release-image-tags\.yml@refs/heads/main$`
- **Signed subjects**: exact inventory OCI index digests `ghcr.io/siriusscan/<component>@sha256:...` (never `:latest`).
- **SBOM assets**: `sbom-<component>-<tag>-linux-amd64.cdx.json` and `sbom-<component>-<tag>-linux-arm64.cdx.json` for all six public images (12 total); required on the draft release before publish.
**Key characteristics:**
+16 -9
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Fail closed if any of the six CycloneDX SBOM release assets is missing/invalid.
# Fail closed if any of the 12 platform-scoped CycloneDX SBOM assets is missing/invalid.
#
# Usage:
# bash scripts/assert-release-sbom-assets.sh --tag v1.1.0 --dir /path/to/assets
@@ -40,14 +40,21 @@ printf '%s' "${TAG}" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || die "tag must mat
[ -d "${DIR}" ] || die "directory not found: ${DIR}"
command -v jq >/dev/null 2>&1 || die "jq is required"
count=0
for component in "${RELEASE_COMPONENTS[@]}"; do
asset="$(release_sbom_asset_name "${component}" "${TAG}")"
path="${DIR}/${asset}"
[ -f "${path}" ] || die "missing required SBOM asset: ${asset}"
[ -s "${path}" ] || die "empty SBOM asset: ${asset}"
jq -e '.bomFormat == "CycloneDX"' "${path}" >/dev/null \
|| die "invalid CycloneDX JSON for ${asset}"
echo "OK ${asset}"
for platform_entry in "${RELEASE_SBOM_PLATFORMS[@]}"; do
slug="${platform_entry##*:}"
asset="$(release_sbom_asset_name "${component}" "${TAG}" "${slug}")"
path="${DIR}/${asset}"
[ -f "${path}" ] || die "missing required SBOM asset: ${asset}"
[ -s "${path}" ] || die "empty SBOM asset: ${asset}"
jq -e '.bomFormat == "CycloneDX"' "${path}" >/dev/null \
|| die "invalid CycloneDX JSON for ${asset}"
echo "OK ${asset}"
count=$((count + 1))
done
done
echo "OK all ${#RELEASE_COMPONENTS[@]} SBOM assets present and valid for ${TAG}"
expected="$(release_expected_sbom_count)"
[ "${count}" -eq "${expected}" ] || die "expected ${expected} SBOM assets, validated ${count}"
echo "OK all ${count} platform-scoped SBOM assets present and valid for ${TAG}"
+80 -14
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env bash
# Generate one CycloneDX JSON SBOM per Community image from inventory digests.
# Generate platform-scoped CycloneDX JSON SBOMs for Community release images.
#
# For each of the six inventory OCI index digests, resolve linux/amd64 and
# linux/arm64 child manifest digests and scan those exact platform refs.
# Produces 12 assets: sbom-<component>-<tag>-linux-amd64|arm64.cdx.json
#
# Usage:
# bash scripts/generate-release-sboms.sh \
@@ -7,8 +11,10 @@
# --tag v1.1.0 \
# --outdir ./sboms
#
# Inputs must be immutable @sha256 refs from the inventory (never :latest).
# Override scanner with SYFT_CMD for tests (must accept IMAGE -o cyclonedx-json=FILE).
# Overrides for tests:
# SYFT_CMD — scanner command (IMAGE -o cyclonedx-json=FILE)
# RELEASE_IMAGE_INSPECT_RAW_CMD — prints OCI index JSON for a digest ref
# (default: docker buildx imagetools inspect --raw)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -36,7 +42,7 @@ while [ $# -gt 0 ]; do
shift 2
;;
-h|--help)
sed -n '2,14p' "$0"
sed -n '2,20p' "$0"
exit 0
;;
*)
@@ -57,7 +63,39 @@ if [ -z "${SYFT_CMD:-}" ]; then
command -v syft >/dev/null 2>&1 || die "syft not found on PATH (run scripts/install-release-attest-tools.sh)"
fi
inspect_index_raw() {
local ref="$1"
if [ -n "${RELEASE_IMAGE_INSPECT_RAW_CMD:-}" ]; then
# shellcheck disable=SC2086
${RELEASE_IMAGE_INSPECT_RAW_CMD} "${ref}"
else
docker buildx imagetools inspect --raw "${ref}"
fi
}
platform_digest_from_index() {
local index_json="$1"
local os="$2"
local arch="$3"
local digests
digests="$(printf '%s' "${index_json}" | jq -r \
--arg os "${os}" --arg arch "${arch}" '
[.manifests[]?
| select((.platform.os // "") == $os)
| select((.platform.architecture // "") == $arch)
| .digest
] | unique | .[]
')"
local count
count="$(printf '%s\n' "${digests}" | grep -c . || true)"
[ "${count}" -eq 1 ] || die "expected exactly one ${os}/${arch} digest in index, found ${count}"
printf '%s' "${digests}" | grep -Eq '^sha256:[a-f0-9]{64}$' \
|| die "invalid ${os}/${arch} digest: ${digests}"
printf '%s' "${digests}"
}
mkdir -p "${OUTDIR}"
generated=0
for component in "${RELEASE_COMPONENTS[@]}"; do
digest="$(jq -r --arg c "${component}" '.images[$c].digest // empty' "${INVENTORY}")"
@@ -67,18 +105,46 @@ for component in "${RELEASE_COMPONENTS[@]}"; do
printf '%s' "${ref}" | grep -Eq "@sha256:[a-f0-9]{64}$" \
|| die "inventory ref for ${component} must be digest-pinned (@sha256): ${ref}"
printf '%s' "${ref}" | grep -Eq ':latest(@|$)|/latest@' \
&& die "refutable mutable latest input forbidden for ${component}: ${ref}"
&& die "mutable latest input forbidden for ${component}: ${ref}"
[ "${ref}" = "ghcr.io/siriusscan/${component}@${digest}" ] \
|| die "inventory ref/digest mismatch for ${component}: ref=${ref} digest=${digest}"
asset="$(release_sbom_asset_name "${component}" "${TAG}")"
out="${OUTDIR}/${asset}"
echo "Generating SBOM ${asset} from ${ref}"
# Exact digest only — never a mutable tag.
${SYFT_BIN} "${ref}" -o "cyclonedx-json=${out}"
[ -s "${out}" ] || die "SBOM not written: ${out}"
jq -e '.bomFormat == "CycloneDX"' "${out}" >/dev/null \
|| die "SBOM is not CycloneDX JSON: ${out}"
echo "Resolving platform digests from index ${ref}"
index_json="$(inspect_index_raw "${ref}")"
[ -n "${index_json}" ] || die "empty index inspect for ${ref}"
# Exactly the two supported image platforms must be present (attestations ignored).
found_platforms="$(printf '%s' "${index_json}" | jq -r '
[.manifests[]?
| select((.platform.os // "") == "linux")
| select((.platform.architecture // "") == "amd64" or (.platform.architecture // "") == "arm64")
| "\(.platform.os)/\(.platform.architecture)"
] | unique | sort | join(",")
')"
[ "${found_platforms}" = "linux/amd64,linux/arm64" ] \
|| die "index ${ref} must expose exactly linux/amd64 and linux/arm64 image platforms; found: ${found_platforms:-<none>}"
for platform_entry in "${RELEASE_SBOM_PLATFORMS[@]}"; do
platform="${platform_entry%%:*}"
slug="${platform_entry##*:}"
os="${platform%%/*}"
arch="${platform##*/}"
platform_digest="$(platform_digest_from_index "${index_json}" "${os}" "${arch}")"
platform_ref="ghcr.io/siriusscan/${component}@${platform_digest}"
printf '%s' "${platform_ref}" | grep -Eq "@sha256:[a-f0-9]{64}$" \
|| die "platform ref not immutable for ${component}/${slug}"
asset="$(release_sbom_asset_name "${component}" "${TAG}" "${slug}")"
out="${OUTDIR}/${asset}"
echo "Generating SBOM ${asset} from ${platform_ref}"
${SYFT_BIN} "${platform_ref}" -o "cyclonedx-json=${out}"
[ -s "${out}" ] || die "SBOM not written: ${out}"
jq -e '.bomFormat == "CycloneDX"' "${out}" >/dev/null \
|| die "SBOM is not CycloneDX JSON: ${out}"
generated=$((generated + 1))
done
done
echo "OK generated ${#RELEASE_COMPONENTS[@]} CycloneDX SBOMs in ${OUTDIR}"
expected="$(release_expected_sbom_count)"
[ "${generated}" -eq "${expected}" ] || die "expected ${expected} SBOMs, generated ${generated}"
echo "OK generated ${generated} platform-scoped CycloneDX SBOMs in ${OUTDIR}"
+37 -15
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash
# Install version-pinned Syft and Cosign with SHA-256 verification.
# Install version-pinned Syft and/or Cosign with SHA-256 verification.
# No long-lived secrets; downloads official GitHub release assets only.
#
# Usage:
# bash scripts/install-release-attest-tools.sh [--dir <install-dir>]
# bash scripts/install-release-attest-tools.sh [--dir <install-dir>] [--tools all|syft|cosign]
#
# Defaults install to ${HOME}/.local/bin (added guidance printed for PATH).
# Defaults install both tools to ${HOME}/.local/bin.
set -euo pipefail
die() { echo "ERROR: $*" >&2; exit 1; }
@@ -21,12 +21,17 @@ COSIGN_SHA256="f7622ed3cf22e55e1ae6377c080979ff77a22da9981c11df222a2e444991e7cf"
COSIGN_URL="https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/${COSIGN_ASSET}"
INSTALL_DIR="${HOME}/.local/bin"
TOOLS="all"
while [ $# -gt 0 ]; do
case "$1" in
--dir)
INSTALL_DIR="${2:-}"
shift 2
;;
--tools)
TOOLS="${2:-}"
shift 2
;;
-h|--help)
sed -n '2,12p' "$0"
exit 0
@@ -37,6 +42,11 @@ while [ $# -gt 0 ]; do
esac
done
case "${TOOLS}" in
all|syft|cosign) ;;
*) die "--tools must be all, syft, or cosign" ;;
esac
[ -n "${INSTALL_DIR}" ] || die "--dir requires a path"
mkdir -p "${INSTALL_DIR}"
@@ -51,18 +61,30 @@ verify_sha256() {
[ "${got}" = "${want}" ] || die "checksum mismatch for $(basename "${file}"): got=${got} want=${want}"
}
echo "Installing Syft v${SYFT_VERSION} -> ${INSTALL_DIR}/syft"
curl -fsSL --retry 3 --retry-delay 2 -o "${TMP_DIR}/${SYFT_ASSET}" "${SYFT_URL}"
verify_sha256 "${TMP_DIR}/${SYFT_ASSET}" "${SYFT_SHA256}"
tar -xzf "${TMP_DIR}/${SYFT_ASSET}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" "${INSTALL_DIR}/syft"
install_syft=0
install_cosign=0
case "${TOOLS}" in
all) install_syft=1; install_cosign=1 ;;
syft) install_syft=1 ;;
cosign) install_cosign=1 ;;
esac
echo "Installing Cosign v${COSIGN_VERSION} -> ${INSTALL_DIR}/cosign"
curl -fsSL --retry 3 --retry-delay 2 -o "${TMP_DIR}/${COSIGN_ASSET}" "${COSIGN_URL}"
verify_sha256 "${TMP_DIR}/${COSIGN_ASSET}" "${COSIGN_SHA256}"
install -m 0755 "${TMP_DIR}/${COSIGN_ASSET}" "${INSTALL_DIR}/cosign"
if [ "${install_syft}" -eq 1 ]; then
echo "Installing Syft v${SYFT_VERSION} -> ${INSTALL_DIR}/syft"
curl -fsSL --retry 3 --retry-delay 2 -o "${TMP_DIR}/${SYFT_ASSET}" "${SYFT_URL}"
verify_sha256 "${TMP_DIR}/${SYFT_ASSET}" "${SYFT_SHA256}"
tar -xzf "${TMP_DIR}/${SYFT_ASSET}" -C "${TMP_DIR}" syft
install -m 0755 "${TMP_DIR}/syft" "${INSTALL_DIR}/syft"
"${INSTALL_DIR}/syft" version >/dev/null
fi
"${INSTALL_DIR}/syft" version >/dev/null
"${INSTALL_DIR}/cosign" version >/dev/null
echo "OK installed Syft v${SYFT_VERSION} and Cosign v${COSIGN_VERSION} in ${INSTALL_DIR}"
if [ "${install_cosign}" -eq 1 ]; then
echo "Installing Cosign v${COSIGN_VERSION} -> ${INSTALL_DIR}/cosign"
curl -fsSL --retry 3 --retry-delay 2 -o "${TMP_DIR}/${COSIGN_ASSET}" "${COSIGN_URL}"
verify_sha256 "${TMP_DIR}/${COSIGN_ASSET}" "${COSIGN_SHA256}"
install -m 0755 "${TMP_DIR}/${COSIGN_ASSET}" "${INSTALL_DIR}/cosign"
"${INSTALL_DIR}/cosign" version >/dev/null
fi
echo "OK installed tools=${TOOLS} in ${INSTALL_DIR}"
echo "Ensure PATH includes ${INSTALL_DIR}"
+24 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bash
# Shared Community release component list (six public images).
# Shared Community release component list and Cosign trust constants.
# Sourced by release-contract helpers; safe to `bash -n`.
# shellcheck disable=SC2034
RELEASE_COMPONENTS=(
sirius-ui
sirius-api
@@ -11,8 +12,29 @@ RELEASE_COMPONENTS=(
sirius-valkey
)
# Platform os/arch pairs for multi-arch SBOM coverage (OCI index children).
# Format: "<os>/<arch>:<asset-slug>"
RELEASE_SBOM_PLATFORMS=(
"linux/amd64:linux-amd64"
"linux/arm64:linux-arm64"
)
# Canonical trust root (intentionally not fork-dynamic).
CANONICAL_RELEASE_REPO="SiriusScan/Sirius"
CANONICAL_RELEASE_DEFAULT_BRANCH="main"
CANONICAL_RELEASE_REF="refs/heads/main"
CANONICAL_COSIGN_OIDC_ISSUER="https://token.actions.githubusercontent.com"
# Exact Fulcio subject for publish-release-image-tags.yml on main, anchored.
CANONICAL_COSIGN_CERTIFICATE_IDENTITY="https://github.com/SiriusScan/Sirius/.github/workflows/publish-release-image-tags.yml@refs/heads/main"
CANONICAL_COSIGN_CERTIFICATE_IDENTITY_REGEXP='^https://github\.com/SiriusScan/Sirius/\.github/workflows/publish-release-image-tags\.yml@refs/heads/main$'
release_sbom_asset_name() {
local component="$1"
local tag="$2"
printf 'sbom-%s-%s.cdx.json' "${component}" "${tag}"
local platform_slug="$3"
printf 'sbom-%s-%s-%s.cdx.json' "${component}" "${tag}" "${platform_slug}"
}
release_expected_sbom_count() {
printf '%s' "$((${#RELEASE_COMPONENTS[@]} * ${#RELEASE_SBOM_PLATFORMS[@]}))"
}
+13 -11
View File
@@ -6,14 +6,13 @@
# --inventory path/to/core-build-inventory.json \
# --mode sign|verify|sign-and-verify
#
# Signing identity (GitHub Actions OIDC / Fulcio):
# COSIGN_CERTIFICATE_OIDC_ISSUER
# default: https://token.actions.githubusercontent.com
# COSIGN_CERTIFICATE_IDENTITY_REGEXP
# required for verify; must match the publish-release-image-tags workflow
# certificate subject (e.g. ^https://github\.com/OWNER/REPO/\.github/workflows/publish-release-image-tags\.yml@refs/heads/.+$)
# Canonical trust (fail-closed; not fork-dynamic):
# Issuer: https://token.actions.githubusercontent.com
# Identity: https://github.com/SiriusScan/Sirius/.github/workflows/publish-release-image-tags.yml@refs/heads/main
# Regexp: ^https://github\.com/SiriusScan/Sirius/\.github/workflows/publish-release-image-tags\.yml@refs/heads/main$
#
# Signs exact inventory @sha256 refs only (never :latest).
# Optional env overrides must equal the canonical values above.
# Signs/verifies exact inventory @sha256 index refs only (never :latest).
# Override binary with COSIGN_CMD for tests.
set -euo pipefail
@@ -37,7 +36,7 @@ while [ $# -gt 0 ]; do
shift 2
;;
-h|--help)
sed -n '2,20p' "$0"
sed -n '2,22p' "$0"
exit 0
;;
*)
@@ -59,11 +58,14 @@ if [ -z "${COSIGN_CMD:-}" ]; then
command -v cosign >/dev/null 2>&1 || die "cosign not found on PATH (run scripts/install-release-attest-tools.sh)"
fi
OIDC_ISSUER="${COSIGN_CERTIFICATE_OIDC_ISSUER:-https://token.actions.githubusercontent.com}"
IDENTITY_REGEXP="${COSIGN_CERTIFICATE_IDENTITY_REGEXP:-}"
OIDC_ISSUER="${COSIGN_CERTIFICATE_OIDC_ISSUER:-${CANONICAL_COSIGN_OIDC_ISSUER}}"
IDENTITY_REGEXP="${COSIGN_CERTIFICATE_IDENTITY_REGEXP:-${CANONICAL_COSIGN_CERTIFICATE_IDENTITY_REGEXP}}"
if [ "${MODE}" = "verify" ] || [ "${MODE}" = "sign-and-verify" ]; then
[ -n "${IDENTITY_REGEXP}" ] || die "COSIGN_CERTIFICATE_IDENTITY_REGEXP is required for verify"
[ "${OIDC_ISSUER}" = "${CANONICAL_COSIGN_OIDC_ISSUER}" ] \
|| die "COSIGN_CERTIFICATE_OIDC_ISSUER must equal canonical issuer"
[ "${IDENTITY_REGEXP}" = "${CANONICAL_COSIGN_CERTIFICATE_IDENTITY_REGEXP}" ] \
|| die "COSIGN_CERTIFICATE_IDENTITY_REGEXP must equal canonical identity regexp"
fi
sign_one() {
+231 -79
View File
@@ -19,6 +19,10 @@ fail() { echo "FAIL: $*" >&2; exit 1; }
cd "${PROJECT_ROOT}"
EXPECTED_SBOM_COUNT="$(release_expected_sbom_count)"
CANONICAL_IDENTITY_RE="${CANONICAL_COSIGN_CERTIFICATE_IDENTITY_REGEXP}"
CANONICAL_ISSUER="${CANONICAL_COSIGN_OIDC_ISSUER}"
echo "==> release signing/SBOM shell syntax"
for script in \
scripts/release-components.sh \
@@ -32,19 +36,32 @@ do
done
pass "signing helper syntax"
echo "==> six release components contract"
echo "==> six release components and two platforms (12 SBOM assets)"
[ "${#RELEASE_COMPONENTS[@]}" -eq 6 ] || fail "expected exactly six RELEASE_COMPONENTS"
[ "${#RELEASE_SBOM_PLATFORMS[@]}" -eq 2 ] || fail "expected exactly two RELEASE_SBOM_PLATFORMS"
[ "${EXPECTED_SBOM_COUNT}" -eq 12 ] || fail "expected SBOM count 12, got ${EXPECTED_SBOM_COUNT}"
for component in sirius-ui sirius-api sirius-engine sirius-postgres sirius-rabbitmq sirius-valkey; do
printf '%s\n' "${RELEASE_COMPONENTS[@]}" | grep -qx "${component}" \
|| fail "missing component ${component}"
done
pass "six components listed"
pass "six components × two platforms"
echo "==> workflow covers six components with digest-pinned inventory inputs"
echo "==> canonical Cosign identity constants"
[ "${CANONICAL_RELEASE_REPO}" = "SiriusScan/Sirius" ] || fail "canonical repo constant wrong"
[ "${CANONICAL_RELEASE_REF}" = "refs/heads/main" ] || fail "canonical ref constant wrong"
[ "${CANONICAL_ISSUER}" = "https://token.actions.githubusercontent.com" ] || fail "issuer constant wrong"
[ "${CANONICAL_IDENTITY_RE}" = '^https://github\.com/SiriusScan/Sirius/\.github/workflows/publish-release-image-tags\.yml@refs/heads/main$' ] \
|| fail "canonical identity regexp mismatch"
pass "canonical identity constants"
echo "==> workflow trust root + SBOM/signing helpers"
wf="$(cat "${WORKFLOW}")"
for component in "${RELEASE_COMPONENTS[@]}"; do
printf '%s\n' "${wf}" | grep -q "${component}" || fail "workflow missing ${component}"
done
printf '%s\n' "${wf}" | grep -Eq 'SiriusScan/Sirius' \
|| fail "workflow must hard-require SiriusScan/Sirius"
printf '%s\n' "${wf}" | grep -Eq 'refs/heads/main' \
|| fail "workflow must hard-require refs/heads/main"
printf '%s\n' "${wf}" | grep -Fq "${CANONICAL_IDENTITY_RE}" \
|| fail "workflow must pin exact canonical Cosign identity regexp"
printf '%s\n' "${wf}" | grep -Eq 'generate-release-sboms\.sh' \
|| fail "workflow must invoke generate-release-sboms.sh"
printf '%s\n' "${wf}" | grep -Eq 'sign-verify-release-images\.sh' \
@@ -53,35 +70,30 @@ printf '%s\n' "${wf}" | grep -Eq 'assert-release-sbom-assets\.sh' \
|| fail "workflow must invoke assert-release-sbom-assets.sh"
printf '%s\n' "${wf}" | grep -Eq 'install-release-attest-tools\.sh' \
|| fail "workflow must install pinned Syft/Cosign tools"
pass "workflow invokes SBOM + signing helpers"
pass "workflow trust root + helpers"
echo "==> workflow uses exact @sha256 inventory refs (no mutable latest for attest)"
echo "==> attest job requires exact --mode sign-and-verify"
attest_block="$(awk '
/^ attest-release-images:/ {grab=1; next}
grab && /^ [a-zA-Z0-9_-]+:/ {exit}
grab {print}
' "${WORKFLOW}")"
[ -n "${attest_block}" ] || fail "missing attest-release-images job"
printf '%s\n' "${attest_block}" | grep -Eq 'sign-and-verify| --mode sign' \
|| fail "attest job must sign images"
printf '%s\n' "${attest_block}" | grep -Eq 'sign-verify-release-images\.sh' \
|| fail "attest job must call sign-verify-release-images.sh"
printf '%s\n' "${attest_block}" | grep -Eq 'generate-release-sboms\.sh' \
|| fail "attest job must generate SBOMs"
# Forbid mutable latest as an attest input (allow comments only if not used as image input).
printf '%s\n' "${attest_block}" | grep -Eq -- '--mode[[:space:]]+sign-and-verify' \
|| fail "attest job must use --mode sign-and-verify"
if printf '%s\n' "${attest_block}" | grep -Eq -- '--mode[[:space:]]+sign([[:space:]]|$)'; then
fail "--mode sign alone is forbidden; require --mode sign-and-verify"
fi
printf '%s\n' "${attest_block}" | grep -Eq 'id-token:\s*write' \
|| fail "attest-release-images requires id-token: write"
printf '%s\n' "${attest_block}" | grep -Eq 'packages:\s*write' \
|| fail "attest-release-images requires packages: write"
if printf '%s\n' "${attest_block}" | grep -vE '^\s*#' | grep -Eq '(:latest|source_tag:\s*latest|image_tag:\s*latest)'; then
fail "attest-release-images must not use mutable latest inputs"
fi
pass "attest job is digest-oriented"
pass "attest sign-and-verify + permissions"
echo "==> required OIDC / package permissions for keyless Cosign"
printf '%s\n' "${attest_block}" | grep -Eq 'id-token:\s*write' \
|| fail "attest-release-images requires id-token: write for keyless Cosign"
printf '%s\n' "${attest_block}" | grep -Eq 'packages:\s*write' \
|| fail "attest-release-images requires packages: write to publish signatures"
pass "attest permissions"
echo "==> fail-closed publish ordering (SBOM+sign before draft=false)"
echo "==> publish job final Cosign verify before draft=false"
publish_block="$(awk '
/^ publish-github-release:/ {grab=1; next}
grab && /^ [a-zA-Z0-9_-]+:/ {exit}
@@ -89,102 +101,184 @@ publish_block="$(awk '
' "${WORKFLOW}")"
printf '%s\n' "${publish_block}" | grep -Eq 'needs:.*attest-release-images' \
|| fail "publish-github-release must need attest-release-images"
# Ensure SBOM assert appears before publishing (draft=false).
assert_line="$(printf '%s\n' "${publish_block}" | grep -n 'assert-release-sbom-assets\.sh' | head -1 | cut -d: -f1)"
printf '%s\n' "${publish_block}" | grep -Eq 'packages:\s*read' \
|| fail "publish job requires packages: read for Cosign verify"
if printf '%s\n' "${publish_block}" | grep -Eq 'id-token:'; then
fail "publish job must not request id-token"
fi
printf '%s\n' "${publish_block}" | grep -Eq -- '--mode[[:space:]]+verify' \
|| fail "publish job must re-verify with --mode verify"
verify_line="$(printf '%s\n' "${publish_block}" | grep -n -- '--mode verify' | head -1 | cut -d: -f1)"
publish_line="$(printf '%s\n' "${publish_block}" | grep -n 'draft=false' | head -1 | cut -d: -f1)"
[ -n "${assert_line}" ] || fail "publish job must re-validate SBOM assets"
[ -n "${publish_line}" ] || fail "publish job must set draft=false"
[ "${assert_line}" -lt "${publish_line}" ] || fail "SBOM asset validation must precede draft=false"
# Upload of cdx assets before publish.
printf '%s\n' "${publish_block}" | grep -Eq 'sbom-.*\.cdx\.json|assert-release-sbom-assets' \
|| fail "publish job must handle six SBOM assets"
assert_line="$(printf '%s\n' "${publish_block}" | grep -n 'assert-release-sbom-assets\.sh' | tail -1 | cut -d: -f1)"
[ -n "${verify_line}" ] && [ -n "${publish_line}" ] && [ -n "${assert_line}" ] \
|| fail "publish ordering markers missing"
[ "${assert_line}" -lt "${verify_line}" ] || fail "SBOM assert must precede final Cosign verify"
[ "${verify_line}" -lt "${publish_line}" ] || fail "final Cosign verify must precede draft=false"
printf '%s\n' "${publish_block}" | grep -Eq 'exactly twelve|SBOM files before upload, got|linux-\*\.cdx\.json' \
|| fail "publish job must expect twelve platform SBOM assets"
pass "fail-closed publish ordering"
echo "==> pinning policy for Syft/Cosign installers (checksums, no floating latest tool tags)"
echo "==> pinning policy for Syft/Cosign installers"
install_script="$(cat scripts/install-release-attest-tools.sh)"
printf '%s\n' "${install_script}" | grep -Eq 'SYFT_SHA256=' || fail "Syft SHA256 pin missing"
printf '%s\n' "${install_script}" | grep -Eq 'COSIGN_SHA256=' || fail "Cosign SHA256 pin missing"
printf '%s\n' "${install_script}" | grep -Eq 'SYFT_VERSION=' || fail "Syft version pin missing"
printf '%s\n' "${install_script}" | grep -Eq 'COSIGN_VERSION=' || fail "Cosign version pin missing"
printf '%s\n' "${install_script}" | grep -Eq 'verify_sha256|sha256sum' || fail "checksum verification missing"
printf '%s\n' "${install_script}" | grep -Eq -- '--tools' || fail "install script must support --tools selection"
pass "tool pins + checksums"
echo "==> fixture: generate SBOMs from inventory digests via mock syft"
echo "==> fixture: platform SBOM generation from mock index inspect + mock syft"
MOCK_INSPECT="${TMP_DIR}/mock-inspect-raw.sh"
cat > "${MOCK_INSPECT}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
ref="${1:-}"
printf '%s' "${ref}" | grep -Eq '@sha256:[a-f0-9]{64}$' || { echo "inspect requires @sha256" >&2; exit 1; }
component="$(printf '%s' "${ref}" | sed -E 's#.*/([^@]+)@.*#\1#')"
# Deterministic fake platform digests derived from component name length parity.
case "${component}" in
sirius-ui) amd=sha256:1111111111111111111111111111111111111111111111111111111111111111; arm=sha256:2111111111111111111111111111111111111111111111111111111111111111 ;;
sirius-api) amd=sha256:1222222222222222222222222222222222222222222222222222222222222222; arm=sha256:2222222222222222222222222222222222222222222222222222222222222222 ;;
sirius-engine) amd=sha256:1333333333333333333333333333333333333333333333333333333333333333; arm=sha256:2333333333333333333333333333333333333333333333333333333333333333 ;;
sirius-postgres) amd=sha256:1444444444444444444444444444444444444444444444444444444444444444; arm=sha256:2444444444444444444444444444444444444444444444444444444444444444 ;;
sirius-rabbitmq) amd=sha256:1555555555555555555555555555555555555555555555555555555555555555; arm=sha256:2555555555555555555555555555555555555555555555555555555555555555 ;;
sirius-valkey) amd=sha256:1666666666666666666666666666666666666666666666666666666666666666; arm=sha256:2666666666666666666666666666666666666666666666666666666666666666 ;;
*) echo "unknown component ${component}" >&2; exit 1 ;;
esac
cat <<JSON
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"${amd}","size":1,"platform":{"architecture":"amd64","os":"linux"}},
{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"${arm}","size":1,"platform":{"architecture":"arm64","os":"linux"}},
{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","size":1,"platform":{"architecture":"unknown","os":"unknown"}}
]
}
JSON
EOF
chmod +x "${MOCK_INSPECT}"
MOCK_SYFT="${TMP_DIR}/mock-syft.sh"
cat > "${MOCK_SYFT}" <<'EOF'
SYFT_LOG="${TMP_DIR}/syft-refs.log"
: > "${SYFT_LOG}"
cat > "${MOCK_SYFT}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
ref=""
out=""
while [ $# -gt 0 ]; do
case "$1" in
while [ \$# -gt 0 ]; do
case "\$1" in
-o)
out="${2#cyclonedx-json=}"
out="\${2#cyclonedx-json=}"
shift 2
;;
*)
ref="$1"
ref="\$1"
shift
;;
esac
done
printf '%s' "${ref}" | grep -Eq '@sha256:[a-f0-9]{64}$' || { echo "mock syft requires @sha256 ref" >&2; exit 1; }
printf '%s' "${ref}" | grep -Eq ':latest@' && { echo "mock syft forbids latest" >&2; exit 1; }
[ -n "${out}" ] || { echo "missing -o cyclonedx-json=" >&2; exit 1; }
cat > "${out}" <<JSON
printf '%s' "\${ref}" | grep -Eq '@sha256:[a-f0-9]{64}\$' || { echo "mock syft requires @sha256 ref" >&2; exit 1; }
printf '%s' "\${ref}" | grep -Eq ':latest@' && { echo "mock syft forbids latest" >&2; exit 1; }
# Must be platform digest refs, not the inventory index digests from the fixture.
case "\${ref}" in
*aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa*|\
*bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb*|\
*cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc*|\
*dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd*|\
*eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee*|\
*ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff*)
echo "mock syft refused inventory index digest; expected platform child digest" >&2
exit 1
;;
esac
[ -n "\${out}" ] || { echo "missing -o cyclonedx-json=" >&2; exit 1; }
echo "\${ref}" >> "${SYFT_LOG}"
cat > "\${out}" <<JSON
{"bomFormat":"CycloneDX","specVersion":"1.5","metadata":{"component":{"name":"mock","version":"0"}}}
JSON
EOF
chmod +x "${MOCK_SYFT}"
SBOM_DIR="${TMP_DIR}/sboms"
SYFT_CMD="bash ${MOCK_SYFT}" bash scripts/generate-release-sboms.sh \
--inventory "${INVENTORY_FIXTURE}" \
--tag v1.1.0 \
--outdir "${SBOM_DIR}"
RELEASE_IMAGE_INSPECT_RAW_CMD="bash ${MOCK_INSPECT}" \
SYFT_CMD="bash ${MOCK_SYFT}" \
bash scripts/generate-release-sboms.sh \
--inventory "${INVENTORY_FIXTURE}" \
--tag v1.1.0 \
--outdir "${SBOM_DIR}"
for component in "${RELEASE_COMPONENTS[@]}"; do
asset="$(release_sbom_asset_name "${component}" "v1.1.0")"
[ -f "${SBOM_DIR}/${asset}" ] || fail "missing generated ${asset}"
for platform_entry in "${RELEASE_SBOM_PLATFORMS[@]}"; do
slug="${platform_entry##*:}"
asset="$(release_sbom_asset_name "${component}" "v1.1.0" "${slug}")"
[ -f "${SBOM_DIR}/${asset}" ] || fail "missing generated ${asset}"
done
done
bash scripts/assert-release-sbom-assets.sh --tag v1.1.0 --dir "${SBOM_DIR}"
pass "fixture SBOM generation + assert"
syft_count="$(wc -l < "${SYFT_LOG}" | tr -d ' ')"
[ "${syft_count}" -eq 12 ] || fail "expected 12 platform syft scans, got ${syft_count}"
# Every scanned ref must be immutable digest form.
while IFS= read -r scanned; do
printf '%s' "${scanned}" | grep -Eq '^ghcr\.io/siriusscan/[^@]+@sha256:[a-f0-9]{64}$' \
|| fail "non-immutable syft input: ${scanned}"
done < "${SYFT_LOG}"
pass "fixture platform SBOM generation + assert"
echo "==> fixture: sign-verify uses exact @sha256 refs"
echo "==> fixture: cosign verify receives exact identity + issuer flags"
MOCK_COSIGN="${TMP_DIR}/mock-cosign.sh"
cat > "${MOCK_COSIGN}" <<'EOF'
COSIGN_LOG="${TMP_DIR}/cosign.log"
: > "${COSIGN_LOG}"
cat > "${MOCK_COSIGN}" <<EOF
#!/usr/bin/env bash
set -euo pipefail
mode="$1"
mode="\$1"
shift
case "${mode}" in
case "\${mode}" in
sign)
[ "$1" = "--yes" ] || { echo "expected --yes" >&2; exit 1; }
ref="$2"
printf '%s' "${ref}" | grep -Eq '@sha256:[a-f0-9]{64}$' || exit 1
printf '%s' "${ref}" | grep -Eq ':latest@' && exit 1
echo "SIGNED ${ref}"
[ "\$1" = "--yes" ] || { echo "expected --yes" >&2; exit 1; }
ref="\$2"
printf '%s' "\${ref}" | grep -Eq '@sha256:[a-f0-9]{64}\$' || exit 1
echo "SIGNED \${ref}" | tee -a "${COSIGN_LOG}"
;;
verify)
identity=""
issuer=""
ref=""
while [ $# -gt 0 ]; do
case "$1" in
--certificate-identity-regexp|--certificate-oidc-issuer)
while [ \$# -gt 0 ]; do
case "\$1" in
--certificate-identity-regexp)
identity="\$2"
shift 2
;;
--certificate-oidc-issuer)
issuer="\$2"
shift 2
;;
*)
ref="$1"
ref="\$1"
shift
;;
esac
done
printf '%s' "${ref}" | grep -Eq '@sha256:[a-f0-9]{64}$' || exit 1
echo "VERIFIED ${ref}"
[ -n "\${identity}" ] || { echo "missing --certificate-identity-regexp" >&2; exit 1; }
[ -n "\${issuer}" ] || { echo "missing --certificate-oidc-issuer" >&2; exit 1; }
[ "\${identity}" = '${CANONICAL_IDENTITY_RE}' ] || {
echo "unexpected identity regexp: \${identity}" >&2
exit 1
}
[ "\${issuer}" = '${CANONICAL_ISSUER}' ] || {
echo "unexpected issuer: \${issuer}" >&2
exit 1
}
printf '%s' "\${ref}" | grep -Eq '@sha256:[a-f0-9]{64}\$' || exit 1
echo "VERIFIED \${ref}" | tee -a "${COSIGN_LOG}"
;;
version)
echo "mock-cosign"
;;
*)
echo "unexpected cosign mode ${mode}" >&2
echo "unexpected cosign mode \${mode}" >&2
exit 1
;;
esac
@@ -192,27 +286,85 @@ EOF
chmod +x "${MOCK_COSIGN}"
COSIGN_CMD="bash ${MOCK_COSIGN}" \
COSIGN_CERTIFICATE_IDENTITY_REGEXP='^https://github\.com/SiriusScan/Sirius/\.github/workflows/publish-release-image-tags\.yml@refs/heads/.+$' \
COSIGN_CERTIFICATE_IDENTITY_REGEXP="${CANONICAL_IDENTITY_RE}" \
COSIGN_CERTIFICATE_OIDC_ISSUER="${CANONICAL_ISSUER}" \
bash scripts/sign-verify-release-images.sh \
--inventory "${INVENTORY_FIXTURE}" \
--mode sign-and-verify
pass "fixture cosign sign-and-verify digest refs"
grep -c '^VERIFIED ' "${COSIGN_LOG}" | grep -qx 6 || fail "expected 6 verified refs"
pass "fixture cosign sign-and-verify with exact trust flags"
echo "==> negative: wrong identity/issuer rejected"
if COSIGN_CMD="bash ${MOCK_COSIGN}" \
COSIGN_CERTIFICATE_IDENTITY_REGEXP='^https://github\.com/evil/fork/\.github/workflows/publish-release-image-tags\.yml@refs/heads/main$' \
COSIGN_CERTIFICATE_OIDC_ISSUER="${CANONICAL_ISSUER}" \
bash scripts/sign-verify-release-images.sh \
--inventory "${INVENTORY_FIXTURE}" \
--mode verify 2>/dev/null; then
fail "expected non-canonical identity to be rejected"
fi
if COSIGN_CMD="bash ${MOCK_COSIGN}" \
COSIGN_CERTIFICATE_IDENTITY_REGEXP="${CANONICAL_IDENTITY_RE}" \
COSIGN_CERTIFICATE_OIDC_ISSUER='https://evil.example/oidc' \
bash scripts/sign-verify-release-images.sh \
--inventory "${INVENTORY_FIXTURE}" \
--mode verify 2>/dev/null; then
fail "expected non-canonical issuer to be rejected"
fi
pass "reject non-canonical identity/issuer"
echo "==> negative: verify mock fails if identity or issuer flags omitted"
MOCK_COSIGN_LOOSE="${TMP_DIR}/mock-cosign-loose.sh"
cat > "${MOCK_COSIGN_LOOSE}" <<'EOF'
#!/usr/bin/env bash
# Deliberately accepts verify without flags — used only to prove our script always passes both.
set -euo pipefail
echo "should-not-run-without-flags" >&2
exit 0
EOF
chmod +x "${MOCK_COSIGN_LOOSE}"
# Directly invoke a minimal check that our verify_one path always includes both flags by
# grepping the sign-verify script contract.
grep -Eq -- '--certificate-identity-regexp "\$\{IDENTITY_REGEXP\}"' scripts/sign-verify-release-images.sh \
|| fail "sign-verify must pass --certificate-identity-regexp"
grep -Eq -- '--certificate-oidc-issuer "\$\{OIDC_ISSUER\}"' scripts/sign-verify-release-images.sh \
|| fail "sign-verify must pass --certificate-oidc-issuer"
pass "verify flags always supplied"
echo "==> reject mutable latest inventory ref for SBOM generation"
BAD_INV="${TMP_DIR}/bad-inventory.json"
jq '.images["sirius-ui"].ref = "ghcr.io/siriusscan/sirius-ui:latest"' \
"${INVENTORY_FIXTURE}" > "${BAD_INV}"
if SYFT_CMD="bash ${MOCK_SYFT}" bash scripts/generate-release-sboms.sh \
--inventory "${BAD_INV}" --tag v1.1.0 --outdir "${TMP_DIR}/bad-sboms" 2>/dev/null; then
if RELEASE_IMAGE_INSPECT_RAW_CMD="bash ${MOCK_INSPECT}" \
SYFT_CMD="bash ${MOCK_SYFT}" \
bash scripts/generate-release-sboms.sh \
--inventory "${BAD_INV}" --tag v1.1.0 --outdir "${TMP_DIR}/bad-sboms" 2>/dev/null; then
fail "expected mutable latest inventory ref to be rejected"
fi
pass "reject mutable latest SBOM input"
echo "==> OIDC issuer + identity policy present in workflow"
printf '%s\n' "${attest_block}" | grep -Eq 'token\.actions\.githubusercontent\.com' \
|| fail "attest job must set GitHub Actions OIDC issuer"
printf '%s\n' "${attest_block}" | grep -Eq 'COSIGN_CERTIFICATE_IDENTITY_REGEXP|publish-release-image-tags\.yml' \
|| fail "attest job must pin Cosign certificate identity to this workflow"
pass "OIDC signing identity policy"
echo "==> reject index missing a required platform"
MOCK_INSPECT_ONE="${TMP_DIR}/mock-inspect-one.sh"
cat > "${MOCK_INSPECT_ONE}" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
cat <<'JSON'
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","size":1,"platform":{"architecture":"amd64","os":"linux"}}
]
}
JSON
EOF
chmod +x "${MOCK_INSPECT_ONE}"
if RELEASE_IMAGE_INSPECT_RAW_CMD="bash ${MOCK_INSPECT_ONE}" \
SYFT_CMD="bash ${MOCK_SYFT}" \
bash scripts/generate-release-sboms.sh \
--inventory "${INVENTORY_FIXTURE}" --tag v1.1.0 --outdir "${TMP_DIR}/one-plat" 2>/dev/null; then
fail "expected single-platform index to be rejected"
fi
pass "reject incomplete platform index"
echo "All release signing/SBOM contract tests passed."
+3 -3
View File
@@ -103,11 +103,11 @@
"id": "1.5",
"title": "SBOM generation and image signing in public CI",
"description": "Add Syft SBOMs and Cosign signatures for all six public images.",
"details": "REQUIRED BEFORE v1.1.0 GitHub Release publication. Implement in publish-release-image-tags.yml (attest-release-images job) after write-once retags and public smoke: generate one Syft CycloneDX SBOM per image from inventory @sha256 digests; Cosign keyless sign+verify all six digests via GitHub Actions OIDC (id-token:write, packages:write); upload/re-download/validate all six sbom-<component>-<tag>.cdx.json assets on the draft release before draft=false. Pin Syft/Cosign via checksummed install script. Static contract tests in scripts/test-release-signing-contract.sh (invoked by test-core-manifest.sh). Do not mark done until a real release run proves signatures and SBOM assets.",
"details": "REQUIRED BEFORE v1.1.0 GitHub Release publication (parallel with 1.4 release-train wiring; no dependency cycle). Implement in publish-release-image-tags.yml after write-once retags and public smoke: resolve linux/amd64 + linux/arm64 child digests from each inventory OCI index and generate 12 Syft CycloneDX assets (sbom-<component>-<tag>-linux-amd64|arm64.cdx.json); Cosign keyless sign+verify all six inventory index digests via GitHub Actions OIDC with canonical identity https://github.com/SiriusScan/Sirius/.github/workflows/publish-release-image-tags.yml@refs/heads/main (workflow fail-closed to SiriusScan/Sirius@refs/heads/main); re-verify Cosign in publish-github-release before draft=false (packages:read, no id-token). Pin Syft/Cosign via checksummed install script. Contract tests in scripts/test-release-signing-contract.sh. Do not mark done until a real release run proves signatures and the 12 SBOM assets.",
"status": "in_progress",
"priority": "high",
"dependencies": ["1.4"],
"testStrategy": "bash scripts/test-core-manifest.sh (includes signing contract); on a real release: cosign verify succeeds for all six digest refs with the workflow OIDC identity; six CycloneDX SBOM assets present on the GitHub Release."
"dependencies": ["1.2", "1.3"],
"testStrategy": "bash scripts/test-core-manifest.sh (includes signing contract); on a real release: cosign verify succeeds for all six inventory digest refs with the canonical OIDC identity; twelve platform-scoped CycloneDX SBOM assets present on the GitHub Release."
}
]
},