diff --git a/.github/README.md b/.github/README.md index d7df9b38..32e669bf 100644 --- a/.github/README.md +++ b/.github/README.md @@ -9,7 +9,7 @@ the trailing comment) and every downloaded tool sha256-verified. | `images.yml` | push to `main`, `workflow_dispatch` | digest-only build of the seven images (`backend`, `frontend`, `operator`, `linux-base`, `linux-desktop`, `browser`, `kasm-adapter`) → isolated trivy gate + SBOM → promote `ghcr.io/tinyorbitvn/tinycdi-:{sha-,main}` + cosign keyless signature/SBOM attestation. Publishes only when `github.ref == refs/heads/main`; a dispatch elsewhere builds + scans without pushing. | | `release.yml` | tag `v*.*.*`, `workflow_dispatch` (dry-run only) | digest-only build of the `build/release-images.txt` set → isolated trivy gate → `environment: release` publish job: sign + attest digests, `helm push` to `oci://ghcr.io/tinyorbitvn/charts` + sign the chart, then promote `:`/`latest` tags, GitHub Release with binaries + CRDs + SBOMs + KasmVNC source bundle + `sha256sums.txt` + sigstore bundles | | `runtime-freshness.yml` | daily schedule, `workflow_dispatch` | runs `check-browser-freshness.sh` for **both** pinned engines (chromium and firefox-esr); when bookworm-security offers a newer build — or the pinned version no longer exists there ("pinned version gone": the browser image can no longer be built from scratch) — `bump-browser-pin.sh` repins `build/browser/Dockerfile` (and, for firefox-esr, `build/linux-desktop/Dockerfile` — the desktop image carries the same Firefox pin) + the doc pins (firefox-esr: with its deb sha256) and one pin-bump PR is opened (`gh pr create`). Also runs `check-runtime-image-age.sh`: fails when the newest `runtime-*` release is older than 14 days (D28) | -| `runtime-images.yml` | push to `main` touching `build/{linux-base,linux-desktop,browser}/**`, weekly schedule, `workflow_dispatch` | the runtime image release train (D27): digest-only build of linux-base, then linux-desktop + browser (both `FROM` the base digest) → isolated trivy gate → cosign sign + SBOM attest → promote `rt-YYYYMMDD.N` tag (N = next free number over the day's published `rt-*` tags — successful publishes only) → `runtime-images.json` attached to GitHub Release `runtime-YYYY.MM.DD`. Never builds or tags control-plane images; publishes only on `refs/heads/main` | +| `runtime-images.yml` | push to `main` touching `build/{linux-base,linux-desktop,browser}/**`, weekly schedule, `workflow_dispatch` | the runtime image release train (D27): digest-only build of linux-base, then linux-desktop + browser (both `FROM` the base digest) → isolated trivy gate → cosign sign + SBOM attest (+ `attest-build-provenance` under `TRAIN_ATTESTATIONS_ENABLED`, off by default) → promote `rt-YYYYMMDD.N` tag (N = next free number over the day's published `rt-*` tags — successful publishes only) → `runtime-images.json` sign-blob'd and attached to GitHub Release `runtime-YYYY.MM.DD`. Never builds or tags control-plane images; publishes only on `refs/heads/main` | ## Supply-chain pipeline shape @@ -47,7 +47,9 @@ split publish rights from scanner execution (SEC-04/SEC-05): the chart tgz + static binaries + CRD bundle + KasmVNC source bundle in the release bundle, and the digests stamped into the packaged chart must equal the image refs. The images.yml promote job validates refs - against the same strict regex (`validate-image-refs.sh`). + against the same strict regex (`validate-image-refs.sh`). Scan jobs + apply the same strict form to artifact-supplied refs **before** + writing them to `$GITHUB_ENV` (SUPF-10). A non-publishing run (`release.yml` dry_run, `images.yml` on a non-main ref) exports the image as a `type=docker` tar artifact instead; the scan job scans @@ -116,6 +118,7 @@ Created by `setup-repo-protection.sh --apply` (or manually under |---|---|---| | `CODE_SCANNING_ENABLED` | `true` | trivy SARIF also pushed to Security → Code scanning; dependency-review runs on PRs. Both need Advanced Security on private repos — off today | | `ATTESTATIONS_ENABLED` | `true` | additionally emits `actions/attest-build-provenance` for each release image. Attestation storage on private repos needs GitHub Enterprise (SEC-I12) — off until the repo goes public | +| `TRAIN_ATTESTATIONS_ENABLED` | `true` | same for the runtime train's publish job (`runtime-images.yml`) — a separate variable so train provenance stays off until verified on a tag build. NOT set by `setup-repo-protection.sh`; create it manually when enabling | | `BASE_MIRROR_REGISTRY` | registry host | optional private mirror for Dockerfile `FROM` bases — when set, build jobs docker-login to it with the `BASE_MIRROR_*` secrets below | | unset | (default) | the gated steps are skipped; SARIF/SBOM artifacts and cosign signing are unaffected | @@ -216,6 +219,18 @@ cosign verify ghcr.io/tinyorbitvn/tinycdi-browser:rt-. \ 'https://github.com/tinyorbitvn/tinycdi/.github/workflows/runtime-images.yml@refs/heads/main' ``` +`runtime-images.json` itself is signed — `cosign sign-blob` in the same +publish job — and the release carries `runtime-images.json.sigstore.json` +next to it. Verify the manifest before taking digests from it: + +```sh +cosign verify-blob --bundle runtime-images.json.sigstore.json \ + runtime-images.json \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity \ + 'https://github.com/tinyorbitvn/tinycdi/.github/workflows/runtime-images.yml@refs/heads/main' +``` + ## Released image set `release.yml` builds, scans, signs and publishes exactly the images listed diff --git a/.github/scripts/collect-publish-inputs.sh b/.github/scripts/collect-publish-inputs.sh index b343c2c4..380823bf 100755 --- a/.github/scripts/collect-publish-inputs.sh +++ b/.github/scripts/collect-publish-inputs.sh @@ -61,7 +61,7 @@ want=() for img in "${expected[@]}"; do want+=("image-ref-$img" "sbom-$img") done -want+=(release-chart release-assets) +want+=(release-chart release-assets sbom-chart sbom-binaries) mapfile -t got < <(find "$DL" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort) [ "$(printf '%s\n' "${got[@]:-}")" = "$(printf '%s\n' "${want[@]}" | sort)" ] \ || die "artifact folder set is [${got[*]:-none}] — expected exactly [${want[*]}]" @@ -77,8 +77,10 @@ done "$SCRIPT_DIR/validate-image-refs.sh" "$OUT/refs" "${expected[@]}" # ---- 2. SBOMs: one per image, allowlisted name, valid JSON ------------- +# sbom-chart / sbom-binaries are the dedicated SBOMs for the packaged +# Helm chart and the release binaries (SEC-17) — same shape check. mkdir -p "$OUT/sboms" -for img in "${expected[@]}"; do +for img in "${expected[@]}" chart binaries; do exact_files "$DL/sbom-$img" "sbom-$img.spdx.json" f="$DL/sbom-$img/sbom-$img.spdx.json" [ -s "$f" ] || die "missing SBOM for '$img'" diff --git a/.github/tests/publish-inputs.test.sh b/.github/tests/publish-inputs.test.sh index 048da284..47d1e5ca 100755 --- a/.github/tests/publish-inputs.test.sh +++ b/.github/tests/publish-inputs.test.sh @@ -35,6 +35,12 @@ build_store() { echo "{\"spdxVersion\":\"SPDX-2.3\",\"name\":\"$img\"}" \ > "$dl/sbom-$img/sbom-$img.spdx.json" done + # dedicated SBOMs for the packaged chart and the release binaries (SEC-17) + for extra in chart binaries; do + mkdir -p "$dl/sbom-$extra" + echo "{\"spdxVersion\":\"SPDX-2.3\",\"name\":\"$extra\"}" \ + > "$dl/sbom-$extra/sbom-$extra.spdx.json" + done # packaged chart: real stamp script + real tar layout local cd_="$WORK/chart-src" @@ -92,8 +98,12 @@ expect_ok() { || { echo "FAIL: bundle lacks kasmvnc sha256"; fails=1; } [ "$(find "$WORK/out/refs" -name '*.ref' | wc -l)" -eq "${#IMGS[@]}" ] \ || { echo "FAIL: refs dir wrong"; fails=1; } - [ "$(find "$WORK/out/sboms" -name '*.json' | wc -l)" -eq "${#IMGS[@]}" ] \ + [ "$(find "$WORK/out/sboms" -name '*.json' | wc -l)" -eq "$(( ${#IMGS[@]} + 2 ))" ] \ || { echo "FAIL: sboms dir wrong"; fails=1; } + for extra in chart binaries; do + [ -f "$WORK/out/bundle/sbom-$extra.spdx.json" ] \ + || { echo "FAIL: bundle lacks sbom-$extra.spdx.json"; fails=1; } + done echo "ok: happy path" } @@ -136,6 +146,8 @@ mut_bad_chart() { } mut_no_kasmvnc() { rm -f "$1/release-assets/kasmvnc-$KV-corresponding-source.tar.gz.sha256"; } mut_bad_sbom() { echo 'not json' > "$1/sbom-backend/sbom-backend.spdx.json"; } +mut_no_chart_sbom() { rm -rf "$1/sbom-chart"; } +mut_bad_bins_sbom() { echo 'not json' > "$1/sbom-binaries/sbom-binaries.spdx.json"; } mut_missing_bin() { rm -f "$1/release-assets/tinycdi-backend-$VERSION-linux-amd64"; } # v0.2 removed the api/gateway commands — a stale binary must not ride along. mut_stale_bin() { local c=api; echo old > "$1/release-assets/tinycdi-$c-$VERSION-linux-amd64"; } @@ -151,6 +163,8 @@ expect_fail "ref digest != chart digest" "digest" mut_wrong_digest expect_fail "chart stamped with wrong digests" "digest" mut_bad_chart expect_fail "missing kasmvnc checksum" "KasmVNC" mut_no_kasmvnc expect_fail "invalid sbom json" "not valid JSON" mut_bad_sbom +expect_fail "missing chart sbom artifact" "artifact folder set" mut_no_chart_sbom +expect_fail "invalid binaries sbom json" "not valid JSON" mut_bad_bins_sbom expect_fail "missing release binary" "missing" mut_missing_bin expect_fail "stale removed-component binary" "unexpected release-assets file" mut_stale_bin expect_fail "removed image artifact" "artifact folder set" mut_removed_image diff --git a/.github/tests/supply-chain-hardening.test.sh b/.github/tests/supply-chain-hardening.test.sh new file mode 100755 index 00000000..b98d1ff3 --- /dev/null +++ b/.github/tests/supply-chain-hardening.test.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# supply-chain-hardening.test.sh — structural guards for the v1.0 +# supply-chain hardening pass on the publishing workflows: +# +# * scan-env hygiene (SUPF-10): every scan job must validate the +# artifact-supplied image ref against the strict +# ghcr.io/tinyorbitvn/tinycdi-@sha256:<64hex> regex BEFORE it +# lands in $GITHUB_ENV — a newline in a forged ref file would +# otherwise inject arbitrary env vars into the scan job. +# * runtime manifest signing (SEC-17): runtime-images.json is the +# deployment contract consumers pin digests from — it ships with a +# cosign sign-blob Sigstore bundle on the runtime-* release, signed +# in the same job that publishes it. +# * train provenance parity: the runtime train's publish job carries +# the same var-gated actions/attest-build-provenance legs as +# release.yml — gated on its own TRAIN_ATTESTATIONS_ENABLED variable +# (defaults OFF until verified), with attestations:write scoped to +# the publish job only. +# * release-asset SBOMs (SEC-17): the packaged Helm chart and the +# release binaries get dedicated syft SBOMs, validated by +# collect-publish-inputs.sh and signed/released like every other +# asset. +# * digest-addressability is documented: gate-failed digest-pushes +# stay pullable-by-digest but are never tagged/signed — the docs +# must say so. +set -uo pipefail + +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +IMG="$ROOT/.github/workflows/images.yml" +REL="$ROOT/.github/workflows/release.yml" +TRAIN="$ROOT/.github/workflows/runtime-images.yml" +COLLECT="$ROOT/.github/scripts/collect-publish-inputs.sh" +README="$ROOT/.github/README.md" +PROV="$ROOT/docs/security/provenance.md" +fails=0 + +chk() { # chk + local desc="$1" file="$2" pat="$3" + if ! grep -qE -e "$pat" "$file"; then + echo "FAIL: $desc"; fails=1 + fi +} +chk_absent() { # chk_absent + local desc="$1" file="$2" pat="$3" + if grep -qE -e "$pat" "$file"; then + echo "FAIL: $desc"; fails=1 + fi +} +order() { # order + local desc="$1" file="$2" a="$3" b="$4" la lb + la="$(grep -nE "$a" "$file" | head -1 | cut -d: -f1)" + lb="$(grep -nE "$b" "$file" | head -1 | cut -d: -f1)" + if [ -z "$la" ] || [ -z "$lb" ] || [ "$la" -ge "$lb" ]; then + echo "FAIL: $desc"; fails=1 + fi +} + +# --------------------------------------------------------------- +# SUPF-10: scan-env hygiene — the ref is validated BEFORE the +# $GITHUB_ENV write, inside the same 'resolve scan target' step. +# --------------------------------------------------------------- +for wf in "$IMG" "$REL" "$TRAIN"; do + name="$(basename "$wf")" + block="$(awk ' + /^ - name: resolve scan target/ { inb=1 } + inb && /^ - / && !/resolve scan target/ { inb=0 } + inb { print } + ' "$wf")" + [ -n "$block" ] || { echo "FAIL: $name: no 'resolve scan target' step"; fails=1; continue; } + grep -qF 'SCAN_REF=' <<< "$block" \ + || { echo "FAIL: $name: resolve step does not write SCAN_REF"; fails=1; } + grep -qF 'ghcr\.io/tinyorbitvn/tinycdi-${IMG}@sha256:[0-9a-f]{64}' <<< "$block" \ + || { echo "FAIL: $name: resolve step lacks the strict ref regex"; fails=1; } + # the guard must precede the env write + la="$(grep -nF 'tinycdi-${IMG}@sha256' <<< "$block" | head -1 | cut -d: -f1)" + lb="$(grep -nF 'SCAN_REF=' <<< "$block" | head -1 | cut -d: -f1)" + { [ -n "$la" ] && [ -n "$lb" ] && [ "$la" -lt "$lb" ]; } \ + || { echo "FAIL: $name: ref regex is not evaluated before the SCAN_REF env write"; fails=1; } + # a non-conforming ref must fail the step, not fall through + grep -qE 'exit 1' <<< "$block" \ + || { echo "FAIL: $name: invalid ref does not fail the scan job"; fails=1; } +done + +# Unit-test the guard regex itself — the same pattern the scan steps run. +IMG=browser +accept() { [[ "$1" =~ ^ghcr\.io/tinyorbitvn/tinycdi-${IMG}@sha256:[0-9a-f]{64}$ ]]; } +D64="$(printf 'a%.0s' $(seq 64))" +accept "ghcr.io/tinyorbitvn/tinycdi-browser@sha256:$D64" \ + || { echo "FAIL: regex rejects a valid ref"; fails=1; } +for bad in \ + "ghcr.io/tinyorbitvn/tinycdi-browser@sha256:$D64 +POISONED=1" \ + "ghcr.io/evil/tinycdi-browser@sha256:$D64" \ + "ghcr.io/tinyorbitvn/tinycdi-backend@sha256:$D64" \ + "ghcr.io/tinyorbitvn/tinycdi-browser@sha256:$(printf 'a%.0s' $(seq 63))" \ + "ghcr.io/tinyorbitvn/tinycdi-browser@sha256:$(printf 'A%.0s' $(seq 64))" \ + "ghcr.io/tinyorbitvn/tinycdi-browser:latest" \ + " ghcr.io/tinyorbitvn/tinycdi-browser@sha256:$D64" \ + "local"; do + if accept "$bad"; then + echo "FAIL: regex accepted $(printf '%q' "$bad")"; fails=1 + fi +done + +# --------------------------------------------------------------- +# SEC-17: runtime-images.json is sign-blob'd by the publish job and +# the bundle rides the runtime-* release next to the manifest. +# --------------------------------------------------------------- +chk "runtime-images: sign-blob the manifest" "$TRAIN" 'cosign sign-blob .*runtime-images\.json' +chk "runtime-images: sigstore bundle for the manifest" "$TRAIN" 'runtime-images\.json\.sigstore\.json' +chk "runtime-images: bundle uploaded to the release" "$TRAIN" 'gh release (upload|create).*runtime-images\.json\.sigstore\.json|runtime-images\.json runtime-images\.json\.sigstore\.json' +order "runtime-images: manifest signed before the release write" "$TRAIN" \ + 'cosign sign-blob' 'create or update the runtime release' + +# the consumption docs carry the exact verify-blob line with the +# pinned signer identity (workflow path + refs/heads/main) +chk "docs: README documents manifest verify-blob" "$README" \ + 'cosign verify-blob .*runtime-images\.json' +chk "docs: README pins the train signer identity" "$README" \ + 'runtime-images\.yml@refs/heads/main' +chk "docs: images.md points at the signed manifest" "$ROOT/docs/images.md" \ + 'verify-blob|sigstore\.json' + +# --------------------------------------------------------------- +# Train build-provenance parity with release.yml — var-gated OFF by +# default (TRAIN_ATTESTATIONS_ENABLED), attestations:write only on +# the publish job. +# --------------------------------------------------------------- +PUB="$(sed -n '/^ publish:/,$p' "$TRAIN")" +chk "runtime-images: publish job holds attestations: write" <(echo "$PUB") \ + 'attestations: write' +n="$(grep -c 'attestations: write' "$TRAIN")" +[ "$n" -eq 1 ] \ + || { echo "FAIL: runtime-images: attestations: write appears $n times (want exactly the publish job)"; fails=1; } +chk "runtime-images: attestations gated on TRAIN_ATTESTATIONS_ENABLED" "$TRAIN" \ + "TRAIN_ATTESTATIONS_ENABLED == 'true'" +chk "runtime-images: sha-pinned attest-build-provenance" "$TRAIN" \ + 'actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8' +for i in linux-base linux-desktop browser; do + chk "runtime-images: provenance attestation for $i" "$TRAIN" \ + "attest build provenance — $i" +done +# each attest step is var-gated: three ifs + the subject-resolver +n="$(grep -c "TRAIN_ATTESTATIONS_ENABLED == 'true'" "$TRAIN")" +[ "$n" -ge 4 ] \ + || { echo "FAIL: runtime-images: only $n TRAIN_ATTESTATIONS_ENABLED gates (want resolver + 3 legs)"; fails=1; } +order "runtime-images: subjects resolved before attestation" "$TRAIN" \ + 'resolve attestation subjects' 'attest build provenance — linux-base' +order "runtime-images: attestation before rt tag promotion" "$TRAIN" \ + 'attest build provenance — linux-base' 'name: promote rt tag' +chk "docs: README documents TRAIN_ATTESTATIONS_ENABLED" "$README" \ + 'TRAIN_ATTESTATIONS_ENABLED' + +# --------------------------------------------------------------- +# SEC-17: dedicated SBOMs for the packaged chart and the release +# binaries — built by the producing job, validated by +# collect-publish-inputs.sh, shipped + signed in the bundle. +# --------------------------------------------------------------- +chk "release: chart SBOM step" "$REL" 'sbom-chart\.spdx\.json' +chk "release: chart SBOM artifact" "$REL" 'name: sbom-chart' +chk "release: binaries SBOM step" "$REL" 'sbom-binaries\.spdx\.json' +chk "release: binaries SBOM artifact" "$REL" 'name: sbom-binaries' +chk "release: publish downloads sbom-chart" "$REL" 'gh run download.*-n sbom-chart' +chk "release: publish downloads sbom-binaries" "$REL" 'gh run download.*-n sbom-binaries' +chk "collect: validates sbom-chart" "$COLLECT" 'sbom-chart' +chk "collect: validates sbom-binaries" "$COLLECT" 'sbom-binaries' +chk "collect: extra SBOMs join the signed bundle" "$COLLECT" \ + 'cp .*sboms/.*\.spdx\.json.*bundle' + +# --------------------------------------------------------------- +# F3 docs: digest-addressable gate-failed manifests are documented. +# --------------------------------------------------------------- +chk "docs: digest-addressable != released documented" "$PROV" \ + 'pullable|digest-addressable' +chk "docs: unsigned digest fails cosign verify" "$PROV" \ + 'never (signed|tagged)|unsigned' + +[ "$fails" -eq 0 ] && echo "supply-chain-hardening: all guards pass" +exit "$fails" diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index c72e441d..ca42eedd 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -515,12 +515,18 @@ jobs: - name: resolve scan target run: | + # SUPF-10: the ref file is artifact content — validate the + # strict repo@sha256 form before it lands in $GITHUB_ENV; a + # newline in a forged ref would otherwise inject env vars. REF="$(cat "refs/$IMG.ref")" if [ "$REF" = "local" ]; then echo "SCAN_MODE=tar" >> "$GITHUB_ENV" - else + elif [[ "$REF" =~ ^ghcr\.io/tinyorbitvn/tinycdi-${IMG}@sha256:[0-9a-f]{64}$ ]]; then echo "SCAN_MODE=registry" >> "$GITHUB_ENV" echo "SCAN_REF=$REF" >> "$GITHUB_ENV" + else + echo "::error::ref for '$IMG' is not ghcr.io/tinyorbitvn/tinycdi-$IMG@sha256:<64hex>" + exit 1 fi - name: download image tar (non-publishing runs) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d81012c2..85a97ba5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,6 +48,8 @@ # tinycdi-crds-.yaml config/crd/bases bundle # tinycdi-.tgz helm chart # sbom-.spdx.json SPDX SBOM per image +# sbom-chart.spdx.json packaged chart SBOM +# sbom-binaries.spdx.json release binaries SBOM # kasmvnc--corresponding-source.tar.gz(.sha256) GPL-2.0 source # offer (PUB-2) # sha256sums.txt checksums over the above @@ -761,12 +763,18 @@ jobs: - name: resolve scan target run: | + # SUPF-10: the ref file is artifact content — validate the + # strict repo@sha256 form before it lands in $GITHUB_ENV; a + # newline in a forged ref would otherwise inject env vars. REF="$(cat "refs/$IMG.ref")" if [ "$REF" = "local" ]; then echo "SCAN_MODE=tar" >> "$GITHUB_ENV" - else + elif [[ "$REF" =~ ^ghcr\.io/tinyorbitvn/tinycdi-${IMG}@sha256:[0-9a-f]{64}$ ]]; then echo "SCAN_MODE=registry" >> "$GITHUB_ENV" echo "SCAN_REF=$REF" >> "$GITHUB_ENV" + else + echo "::error::ref for '$IMG' is not ghcr.io/tinyorbitvn/tinycdi-$IMG@sha256:<64hex>" + exit 1 fi - name: download image tar (dry-run) @@ -962,6 +970,36 @@ jobs: --destination dist ls -l dist/ + - name: install syft ${{ env.SYFT_VERSION }} (sha256-verified) + run: | + mkdir -p "$PWD/bin" + curl -fsSL -o /tmp/syft.tgz \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" + echo "${SYFT_TGZ_SHA256} /tmp/syft.tgz" | sha256sum -c - + tar -xzf /tmp/syft.tgz -C "$PWD/bin" syft + + - name: syft SBOM for the packaged chart (SPDX JSON) + # SEC-17: the chart tgz is a release asset — its contents get a + # dedicated SPDX SBOM (scanned from the packaged archive, not the + # source dir), signed + shipped like every other bundle member. + # SUPR-8: the config is generated HERE — a committed .syft.yaml + # can never steer the tool. file.metadata.selection=all gives the + # SBOM a real per-file inventory with sha256 checksums. + run: | + printf 'file:\n metadata:\n selection: all\n' > /tmp/syft-files.yaml + mkdir -p "$RUNNER_TEMP/chart-src" + tar -xzf "dist/tinycdi-$CHART_VERSION.tgz" -C "$RUNNER_TEMP/chart-src" + "$PWD/bin/syft" --config /tmp/syft-files.yaml \ + --source-name "tinycdi-$CHART_VERSION.tgz" \ + "dir:$RUNNER_TEMP/chart-src" -o spdx-json=sbom-chart.spdx.json + + - name: upload chart SBOM artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom-chart + path: sbom-chart.spdx.json + retention-days: 30 + - name: upload chart artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -997,6 +1035,25 @@ jobs: # `var version` — the stamp is wired so the first version var that # lands lights it up (see .github/README.md). + - name: install syft ${{ env.SYFT_VERSION }} (sha256-verified) + run: | + mkdir -p "$PWD/bin" + curl -fsSL -o /tmp/syft.tgz \ + "https://github.com/anchore/syft/releases/download/v${SYFT_VERSION}/syft_${SYFT_VERSION}_linux_amd64.tar.gz" + echo "${SYFT_TGZ_SHA256} /tmp/syft.tgz" | sha256sum -c - + tar -xzf /tmp/syft.tgz -C "$PWD/bin" syft + + - name: syft SBOM for the release binaries (SPDX JSON) + # SEC-17: dist/ holds exactly the two static binaries at this + # point — the dedicated SBOM covers them before the CRD bundle + # and KasmVNC source join dist/. The go-binary cataloger reads + # each binary's embedded module list. + run: | + : > /tmp/syft-empty.yaml # SUPR-8: never load a repo .syft.yaml + "$PWD/bin/syft" --config /tmp/syft-empty.yaml \ + --source-name tinycdi-binaries --source-version "$VERSION" \ + dir:dist -o spdx-json=sbom-binaries.spdx.json + - name: CRDs bundle run: | for f in config/crd/bases/*.yaml; do @@ -1026,6 +1083,13 @@ jobs: path: dist/ retention-days: 30 + - name: upload binaries SBOM artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom-binaries + path: sbom-binaries.spdx.json + retention-days: 30 + publish: # The only job that makes content addressable or official. Everything # upstream is digest-only pushes + the isolated scan gate. SUPR-13: @@ -1094,6 +1158,8 @@ jobs: done gh run download "$GITHUB_RUN_ID" -n release-chart -D dl/release-chart gh run download "$GITHUB_RUN_ID" -n release-assets -D dl/release-assets + gh run download "$GITHUB_RUN_ID" -n sbom-chart -D dl/sbom-chart + gh run download "$GITHUB_RUN_ID" -n sbom-binaries -D dl/sbom-binaries - name: validate publish inputs (strict allowlist) # SUPF-1: every artifact folder must hold exactly its expected diff --git a/.github/workflows/runtime-images.yml b/.github/workflows/runtime-images.yml index 8d396fe4..65bd8e35 100644 --- a/.github/workflows/runtime-images.yml +++ b/.github/workflows/runtime-images.yml @@ -38,7 +38,11 @@ # validates refs (validate-image-refs.sh), cosign # keyless signs + attests the digests BEFORE # `imagetools create` promotes the rt tag (SUPR-13), -# then writes the manifest and the GitHub Release. +# plus var-gated GitHub build-provenance +# (TRAIN_ATTESTATIONS_ENABLED, off by default — +# SEC-I12), then sign-blobs runtime-images.json and +# attaches manifest + Sigstore bundle to the GitHub +# Release. # # No shared GHA build cache (SEC-16): like release.yml, train builds do # not read or write `type=gha` scopes — main-branch CI cache is a @@ -512,12 +516,18 @@ jobs: - name: resolve scan target run: | + # SUPF-10: the ref file is artifact content — validate the + # strict repo@sha256 form before it lands in $GITHUB_ENV; a + # newline in a forged ref would otherwise inject env vars. REF="$(cat "refs/$IMG.ref")" if [ "$REF" = "local" ]; then echo "SCAN_MODE=tar" >> "$GITHUB_ENV" - else + elif [[ "$REF" =~ ^ghcr\.io/tinyorbitvn/tinycdi-${IMG}@sha256:[0-9a-f]{64}$ ]]; then echo "SCAN_MODE=registry" >> "$GITHUB_ENV" echo "SCAN_REF=$REF" >> "$GITHUB_ENV" + else + echo "::error::ref for '$IMG' is not ghcr.io/tinyorbitvn/tinycdi-$IMG@sha256:<64hex>" + exit 1 fi - name: download image tar (non-publishing runs) @@ -652,7 +662,8 @@ jobs: permissions: contents: write # gh release create + the runtime-* tag packages: write # imagetools retag + cosign signatures to ghcr - id-token: write # cosign keyless + id-token: write # cosign keyless + provenance OIDC + attestations: write # attest-build-provenance (var-gated, SEC-I12) actions: read # gh run download of this run's artifacts (SUPF-1) env: RT_TAG: ${{ needs.meta.outputs.rt_tag }} @@ -730,6 +741,45 @@ jobs: --predicate "sboms/sbom-$name.spdx.json" "$ref" done + - name: resolve attestation subjects + # SEC-I12: GitHub build-provenance attestations are gated on the + # TRAIN_ATTESTATIONS_ENABLED repo variable (defaults off — set it + # only once verified on a tag build; .github/README.md). + if: vars.TRAIN_ATTESTATIONS_ENABLED == 'true' + id: refs + run: | + for f in refs/*.ref; do + img="$(basename "$f" .ref)" + ref="$(tr -d '[:space:]' < "$f")" + key="${img//-/_}" + echo "name_$key=${ref%@*}" >> "$GITHUB_OUTPUT" + echo "digest_$key=${ref#*@}" >> "$GITHUB_OUTPUT" + done + + - name: attest build provenance — linux-base + if: vars.TRAIN_ATTESTATIONS_ENABLED == 'true' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ steps.refs.outputs.name_linux_base }} + subject-digest: ${{ steps.refs.outputs.digest_linux_base }} + push-to-registry: true + + - name: attest build provenance — linux-desktop + if: vars.TRAIN_ATTESTATIONS_ENABLED == 'true' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ steps.refs.outputs.name_linux_desktop }} + subject-digest: ${{ steps.refs.outputs.digest_linux_desktop }} + push-to-registry: true + + - name: attest build provenance — browser + if: vars.TRAIN_ATTESTATIONS_ENABLED == 'true' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ steps.refs.outputs.name_browser }} + subject-digest: ${{ steps.refs.outputs.digest_browser }} + push-to-registry: true + - name: derive rt tag at publish time # Backlog 15: N numbers successful publishes, not run attempts — # it is the next free number over the day's already-promoted @@ -772,26 +822,41 @@ jobs: .github/scripts/write-runtime-manifest.sh refs sboms runtime-images.json jq . runtime-images.json + - name: cosign sign-blob runtime-images.json + # SEC-17: the manifest is the deployment contract consumers pin + # digests from — it must not be the one unsigned artifact in the + # chain. The Sigstore bundle ships next to it on the runtime-* + # release; the verify-blob line is in .github/README.md. + run: | + cosign sign-blob --yes \ + --bundle runtime-images.json.sigstore.json \ + runtime-images.json + - name: upload manifest artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: runtime-images-manifest - path: runtime-images.json + path: | + runtime-images.json + runtime-images.json.sigstore.json retention-days: 30 - name: create or update the runtime release # P7: one runtime-YYYY.MM.DD release per day carrying - # runtime-images.json; a second run the same day re-uploads the - # manifest instead of minting another release. + # runtime-images.json + its Sigstore bundle; a second run the + # same day re-uploads both instead of minting another release. # --latest=false: the repo's "Latest release" is the control-plane # v* release (release.yml); a runtime release must never take it. env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | if gh release view "$REL_TAG" >/dev/null 2>&1; then - gh release upload "$REL_TAG" runtime-images.json --clobber + gh release upload "$REL_TAG" \ + runtime-images.json runtime-images.json.sigstore.json \ + --clobber else - gh release create "$REL_TAG" runtime-images.json \ + gh release create "$REL_TAG" \ + runtime-images.json runtime-images.json.sigstore.json \ --target "$GITHUB_SHA" \ --latest=false \ --title "Runtime images ${REL_TAG#runtime-}" \ @@ -801,7 +866,9 @@ jobs: \`runtime-images.json\` pins the signed digests this train shipped under \`$RT_TAG\`; deployments bump \`images.*.digest\`/\`images.*.builtAt\` from it (chart values - are GitOps-owned — nothing else is mutated). + are GitOps-owned — nothing else is mutated). Verify the + manifest against \`runtime-images.json.sigstore.json\` with + the cosign verify-blob line in .github/README.md. EOF fi diff --git a/docs/images.md b/docs/images.md index 1859b4e7..f2e06fea 100644 --- a/docs/images.md +++ b/docs/images.md @@ -73,8 +73,10 @@ the manifest `runtime-images.json` (three images; the profiles also record the browser versions they actually installed — `chromium` + `firefox` on `browser`, `firefox` on `linux-desktop`, read from each image's attested SPDX SBOM at publish time) is attached to the GitHub Release -`runtime-YYYY.MM.DD` (see `.github/README.md` for the manifest shape and -the cosign verify line). Deployments pin `images.*.digest`/`builtAt` +`runtime-YYYY.MM.DD` **together with its Sigstore bundle** +`runtime-images.json.sigstore.json` — verify the manifest with +`cosign verify-blob` before pinning digests from it (exact command in +`.github/README.md`). Deployments pin `images.*.digest`/`builtAt` from that manifest — the GitOps bump copies the engine versions from it too (`images..engines.chromium`/`.firefox`), so the template's stale-image view shows both — values are GitOps-owned and never written @@ -306,8 +308,10 @@ measured memory and 4× the p95 CPU. See `docs/runbooks/capacity.md`. `tcdi/linux-base` is the runtime contract with nothing on top: build your own desktop (another environment, your applications, your branding) by extending it. Take the digest from the `runtime-images.json` of a `runtime-*` release -(or `images.linuxBase.digest` of a released chart) and verify it with the -cosign line in `.github/README.md`. +(or `images.linuxBase.digest` of a released chart) — the manifest is +sigstore-signed (`runtime-images.json.sigstore.json` rides the same +release; `cosign verify-blob` line in `.github/README.md`) — and verify +the image with the cosign line in `.github/README.md`. ```dockerfile FROM ghcr.io/tinyorbitvn/tinycdi-linux-base@sha256: @@ -483,7 +487,10 @@ findings, and the XFCE packages bring none the gate can act on. Local builds are not pinned anywhere. The digests that matter are the signed ones in `runtime-images.json` of each `runtime-*` GitHub Release (and -`images.*.digest` of a released chart). +`images.*.digest` of a released chart). The manifest itself is signed: +verify `runtime-images.json.sigstore.json` with `cosign verify-blob` +(command in `.github/README.md`) before trusting it, then verify each +image digest with `cosign verify`. ## Known limitations diff --git a/docs/runbooks/upgrade.md b/docs/runbooks/upgrade.md index b4388333..cb8a1da7 100644 --- a/docs/runbooks/upgrade.md +++ b/docs/runbooks/upgrade.md @@ -166,7 +166,10 @@ per user matters. 4. Pin digests: `images.{backend,frontend,operator}.digest` for the platform release; `images.{linuxDesktop,browser}.digest` (with `builtAt`/`engines`) and `images.linuxBase.digest` from - `runtime-images.json` for the runtime train. + `runtime-images.json` for the runtime train — that manifest is + sigstore-signed; verify it (`cosign verify-blob` against + `runtime-images.json.sigstore.json`, command in `.github/README.md`) + before taking digests from it. 5. `helm template | kubectl diff`, then `helm upgrade` — the shared "Procedure" below has the full commands (its steps 3–4). 6. Watch the rollouts in order — `deployment/backend` (each pod's diff --git a/docs/security/provenance.md b/docs/security/provenance.md index 7fafecd3..d202d904 100644 --- a/docs/security/provenance.md +++ b/docs/security/provenance.md @@ -23,7 +23,9 @@ the Helm chart: them). 2. **SBOM** — SPDX JSON generated with pinned syft in the **build job** (the scan and promote jobs never consume scan-job outputs — SUPR-2). - Release assets additionally carry `sbom-.spdx.json` files. + Release assets additionally carry `sbom-.spdx.json` files plus + dedicated `sbom-chart.spdx.json` (the packaged Helm chart) and + `sbom-binaries.spdx.json` (the static release binaries). 3. **Vulnerability report** — trivy SARIF + table per image/arch, produced by the no-secrets scan job; the gate semantics live in [vulnerability-policy.md](vulnerability-policy.md). @@ -52,14 +54,22 @@ ambiguity): |---|---|---| | `:main` / `:sha-*` images | images.yml | `https://github.com/tinyorbitvn/tinycdi/.github/workflows/images.yml@refs/heads/main` | | release images + chart + blobs | release.yml | `https://github.com/tinyorbitvn/tinycdi/.github/workflows/release.yml@refs/tags/v` | +| `rt-*` images + `runtime-images.json` | runtime-images.yml | `https://github.com/tinyorbitvn/tinycdi/.github/workflows/runtime-images.yml@refs/heads/main` | Release runs additionally `cosign sign` the pushed OCI chart digest (`oci://ghcr.io/tinyorbitvn/charts/tinycdi`) and `cosign sign-blob` every release asset (`.sigstore.json` bundles), then `gh release create`. +The runtime train does the same for its deployment manifest: every +`runtime-*` release carries `runtime-images.json` **and** +`runtime-images.json.sigstore.json` — verify it with `cosign +verify-blob` against the train identity above before pinning digests +from it (exact command in `.github/README.md`). GitHub build-provenance attestations (`actions/attest-build-provenance`) are emitted only when the `ATTESTATIONS_ENABLED` repo variable is set — attestation storage on private repos requires GitHub Enterprise; the cosign -SBOM attestations attached to the digests are unaffected. +SBOM attestations attached to the digests are unaffected. The runtime +train has its own gate, `TRAIN_ATTESTATIONS_ENABLED` (unset = off until +verified on a tag build). The provenance predicate records, at minimum: @@ -97,3 +107,26 @@ verification is out of MVP scope; the Helm chart already refuses non-digest image references in seeded templates and requires a pinned tag or digest for the node-profiles installer image, which is the MVP-level guarantee. + +## Digest-addressable does not mean released + +Build jobs push `type=registry,push-by-digest=true` **before** the scan +gate runs, so a gate-failed (or never-promoted) build leaves an unsigned +manifest that stays pullable by digest — +`ghcr.io/tinyorbitvn/tinycdi-@sha256:` resolves on GHCR and +nothing prunes it. That residual is acceptable by construction: + +- it is **never tagged** — tags (`rt-*`, `:`, `:latest`, `:main`, + `:sha-*`) are promoted only by the publish job, after the trivy gate + passes and the digest is signed (SUPR-13); +- it is **never signed or attested** — `cosign verify` / + `verify-attestation` against the digest fails, and no signed manifest + (`runtime-images.json`, the packaged chart's `images.*.digest`, the + release `images.txt`) references it; +- consumers pin digests only out of signed manifests and verify the + signature first — pulling a never-promoted digest is + indistinguishable from pulling any other unsigned image, so the + digest-addressable leftover carries no release trust. + +Pruning untagged, unsigned manifests is a possible hygiene cleanup; it is +not a security boundary. diff --git a/docs/security/test-inventory.md b/docs/security/test-inventory.md index bf446a21..87446a68 100644 --- a/docs/security/test-inventory.md +++ b/docs/security/test-inventory.md @@ -350,11 +350,17 @@ fail-when-nothing-happens rule to any new kill/revoke drill. scan chain, then a minimal-permission `publish` job: cosign signs + attests digests (plus `attest-build-provenance` when enabled), signs the Helm chart, and only then promotes tags / drafts the release - (`docs/security/provenance.md`). + (`docs/security/provenance.md`). Dedicated SPDX SBOMs cover the packaged + chart (`sbom-chart.spdx.json`) and the static binaries + (`sbom-binaries.spdx.json`) — validated by `collect-publish-inputs.sh` + and signed like every other asset. `.github/workflows/runtime-images.yml` — the runtime image train: daily check → pin-bump PR → build/trivy-scan/cosign-sign/publish `runtime-*` -releases. Intentionally no human gate on publish (A6-S12). +releases; `runtime-images.json` carries a `cosign sign-blob` Sigstore +bundle, and GitHub build-provenance legs run under the +`TRAIN_ATTESTATIONS_ENABLED` variable (off by default). Intentionally no +human gate on publish (A6-S12). `.github/workflows/runtime-freshness.yml` — daily: fails while a pinned Chromium/Firefox-ESR build is stale (opens a pin-bump PR); fails when the @@ -377,7 +383,7 @@ newest `runtime-*` release is older than the 14-day SLO. `workflow-expressions.test.sh`, `release-topology.test.sh`, `publish-inputs.test.sh`, `setup-repo-protection.test.sh`, `validate-release-version.test.sh`, `release-notes-review-status.test.sh`, - `preflight.test.sh`. + `supply-chain-hardening.test.sh`, `preflight.test.sh`. - `hack/preflight/preflight.sh` — pre-install environment checks (includes node/AppArmor posture detection relevant to A6-S16). diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index ed8ee7de..f19773d8 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -479,6 +479,21 @@ edge-triggered Warning event until the stream realigns (S24). intentional for daily runs (A6-S12: review each job's permissions, the keyless signing identity, and whether consumers can distinguish a train image from a release image). +- The train's deployment manifest is signed too (v1.0 fix — it was the + one unsigned artifact): `runtime-images.json` ships a + `cosign sign-blob` Sigstore bundle on every `runtime-*` release; + consumers verify it before pinning digests (`.github/README.md`). +- Scan jobs validate artifact-supplied image refs against the strict + `ghcr.io/tinyorbitvn/tinycdi-@sha256:<64hex>` form before writing + them to `$GITHUB_ENV` (SUPF-10; v1.0 fix — previously unvalidated). +- Build jobs push by digest before the scan gate; a gate-failed digest + stays pullable-by-digest but is never tagged or signed, so it carries + no release trust — accepted residual, documented in `provenance.md` + "Digest-addressable does not mean released". +- The train publish job emits `attest-build-provenance` only under the + dedicated `TRAIN_ATTESTATIONS_ENABLED` repo variable (off by default, + SEC-I12), and the release ships dedicated `sbom-chart` / + `sbom-binaries` SPDX SBOMs for the packaged chart and static binaries. - Required checks and enforce-admins are codified in `.github/scripts/setup-repo-protection.sh`.