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:
@@ -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:**
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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[@]}))"
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user