diff --git a/docs/SECURITY.md b/docs/SECURITY.md index eeb121fb..2f6649bf 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -190,6 +190,66 @@ end-to-end supply-chain integrity should install cosign before bootstrapping (https://docs.sigstore.dev/system_config/installation/). The commands above remain the manual / out-of-band verification path. +#### Installing preview / branch-preview builds + +Default `sc.sh` accepts only production-signed tarballs (signed by +`push.yaml@refs/heads/main`). Tarballs produced by +`branch-preview.yaml` carry a different OIDC identity (the feature +branch's own workflow run) and are rejected by default — even though +they ship to the same CDN with valid Sigstore bundles. Feature branches +lack `main`'s branch protection (required reviews, signed commits), so +extending trust to *every* branch is materially weaker than the strict +production posture. Preview installs therefore require **explicit pinning +to one named branch** — and, when possible, the exact commit SHA. + +Minimum (branch-pinned): + +```bash +SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH=feat/your-feature-branch \ +SIMPLE_CONTAINER_VERSION=YYYY.M.D-pre.-preview. \ + bash <(curl -Ls https://dist.simple-container.com/sc.sh) +``` + +Recommended for CI (branch + commit SHA pin): + +```bash +SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH=feat/your-feature-branch \ +SIMPLE_CONTAINER_TRUST_PREVIEW_SHA=4cc1a03ca5c259a428e07d4f0bb8eb9120a6e2b7 \ +SIMPLE_CONTAINER_VERSION=YYYY.M.D-pre.-preview. \ + bash <(curl -Ls https://dist.simple-container.com/sc.sh) +``` + +When `_BRANCH` is set, `verify_sc_tarball` widens the accepted identity +regex to also include `branch-preview.yaml@refs/heads/`. +When `_SHA` is also set, `cosign verify-blob` is given +`--certificate-github-workflow-sha `, which the Sigstore certificate's +GitHub OIDC claim must match exactly — pinning the verification to a +specific commit rather than the mutable branch head. + +Security properties preserved across both modes: + +- Signature, Rekor log entry, OIDC issuer, and SHA-256 sidecar are all + still verified end-to-end. The opt-in only changes which signer-workflow + identities the regex permits. +- Branch name is validated against `^[A-Za-z0-9._/-]+$` and `..` / + leading-slash / trailing-slash / `.lock`-suffix rejections before being + interpolated into the regex. The `.` regex metachar is escaped to prevent + e.g. `feat.foo` shadowing `feat/foo`. +- Production users default to strict. The previous `SIMPLE_CONTAINER_ALLOW_PREVIEW=1` + shape (any branch) is intentionally **not** supported and fails loudly with + a pointer to `_BRANCH`. There is no "trust all preview" mode. +- A loud stderr warning is printed on every invocation when preview mode is + active so a forgotten `export` in shell config is visible, not silent. + +The manual `cosign verify-blob` equivalent for preview tarballs (branch + SHA pin): + +```bash +cosign verify-blob --bundle "$T.cosign-bundle" \ + --certificate-identity-regexp "^https://github\.com/simple-container-com/api/\.github/workflows/(push\.yaml@refs/heads/main|branch-preview\.yaml@refs/heads/$BRANCH)$" \ + --certificate-github-workflow-sha "$SHA" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com "$T" +``` + ### Composite-action consumers — SHA-pin the underlying image `simple-container-com/api/.github/actions/{deploy-client-stack, diff --git a/sc.sh b/sc.sh index 810e3244..17aff144 100755 --- a/sc.sh +++ b/sc.sh @@ -430,10 +430,30 @@ verify_sc_tarball() { echo "✅" echo -n "🔍 Verifying tarball signature against build-workflow identity... " - # Identity regex matches the production push.yaml on refs/heads/main — - # the only workflow allowed to publish tarballs to dist. Staging / - # preview tarballs do not land at dist.simple-container.com, so a - # single anchored regex suffices here. Mirror this in SECURITY.md. + # Default: STRICT. Identity regex matches the production push.yaml on + # refs/heads/main — the only workflow allowed to publish tarballs that + # this code path accepts without further opt-in. Mirror in docs/SECURITY.md. + # + # Preview opt-in (SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH=) narrows + # the trust extension to ONE named branch's branch-preview.yaml signature. + # We deliberately do NOT support an "any branch" opt-in (e.g. =1), because + # accepting `branch-preview.yaml@refs/heads/.+` would trust every push- + # writer on any branch in the repo — a much broader radius than picking + # up an unreviewed feature branch you actually want to test. + # + # Why this still requires explicit user action: + # - branch-preview.yaml runs on workflow_dispatch from feature branches + # that lack main's branch protection / required reviews / signed + # commits. The cosign certificate proves "this run dispatched from + # this ref" but cannot attest to the integrity of the workflow's + # contents at that ref (no SHA pinning at the identity layer). + # - For higher assurance, also set SIMPLE_CONTAINER_TRUST_PREVIEW_SHA + # to a 40-char commit SHA. We pass --certificate-github-workflow-sha + # to cosign so the Sigstore cert's workflow_sha claim must match + # EXACTLY — pinning to a specific commit, not a mutable branch head. + # This neutralizes "attacker pushes new commit to the branch then + # re-dispatches" and "CDN replays an old tarball signed from the + # same branch." # # IMPORTANT: do NOT pass --yes here. cosign 2.x only accepts --yes on # sign-blob (skip interactive confirmation); on verify-blob it errors @@ -441,20 +461,126 @@ verify_sc_tarball() { # after Phase 2c shipped. Capture cosign's stderr (don't /dev/null it) # so future failures surface the real error instead of a generic # message. + + # Refuse the deprecated/never-shipped "=1" form loudly, with a hint at + # the supported form. This forces the user to commit to a specific + # branch instead of broadening trust to the entire repo's push-writers. + if [ "${SIMPLE_CONTAINER_ALLOW_PREVIEW:-}" = "1" ]; then + echo "❌" + echo "❌ SIMPLE_CONTAINER_ALLOW_PREVIEW=1 is not supported (security: trusts every branch in the repo)." + echo " Use SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH= instead — it pins" + echo " cosign verification to one branch's branch-preview.yaml signature." + echo " Optionally also set SIMPLE_CONTAINER_TRUST_PREVIEW_SHA=<40-char-commit-sha>" + echo " to pin the exact commit of that branch (recommended for CI)." + echo " See https://github.com/simple-container-com/api/blob/main/docs/SECURITY.md#installing-preview--branch-preview-builds" + return 1 + fi + + local identity_regex='^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' + local preview_branch="${SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH:-}" + local preview_sha="${SIMPLE_CONTAINER_TRUST_PREVIEW_SHA:-}" + local cosign_extra_args=() + if [ -n "$preview_branch" ]; then + # Validate against a conservative allowlist BEFORE interpolating into the + # regex. Git's check-ref-format is more permissive than what we want here + # (it allows `+`, `(`, `)`, `{`, `}`, `|`, `$` — all regex metachars). + # Constraining to alphanumerics + `._/-` keeps the regex string literal- + # equivalent so we don't need a separate escape pass, and matches the + # naming conventions every Integrail/SC branch already uses (feat/fix/ + # chore/docs prefixes with kebab-case bodies). + if ! printf '%s' "$preview_branch" | grep -qE '^[A-Za-z0-9._/-]+$'; then + echo "❌" + echo "❌ Invalid SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH value: $preview_branch" + echo " Allowed characters: letters, digits, dot, underscore, slash, hyphen." + echo " Refusing to interpolate into the cosign identity regex." + return 1 + fi + # Additional git-style rejections (parts of check-ref-format that our + # allowlist already covers but worth being explicit about): + case "$preview_branch" in + ..*|*..*|*/..*|*..) + echo "❌"; echo "❌ Branch name must not contain '..' segments."; return 1 ;; + /*|*/) + echo "❌"; echo "❌ Branch name must not start or end with '/'."; return 1 ;; + *.lock|*.lock/*|*/*.lock) + echo "❌"; echo "❌ Branch name segments must not end with '.lock'."; return 1 ;; + esac + + # Optional SHA pin — must be 40 lowercase hex chars (canonical git SHA-1). + if [ -n "$preview_sha" ]; then + if ! printf '%s' "$preview_sha" | grep -qE '^[a-f0-9]{40}$'; then + echo "❌" + echo "❌ Invalid SIMPLE_CONTAINER_TRUST_PREVIEW_SHA value: $preview_sha" + echo " Must be 40 lowercase hex characters (a full git commit SHA-1)." + return 1 + fi + cosign_extra_args+=(--certificate-github-workflow-sha "$preview_sha") + fi + + # Loud warning to stderr so a forgotten `export` in shell rc is visible + # on every install, not just the first. T3 mitigation per review. + echo "" >&2 + echo "⚠️ PREVIEW SIGNATURE TRUST EXTENDED" >&2 + echo " SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH is set — accepting tarballs signed by" >&2 + echo " branch-preview.yaml on refs/heads/$preview_branch" >&2 + if [ -n "$preview_sha" ]; then + echo " pinned to commit SHA $preview_sha" >&2 + else + echo " (no SHA pin — branch HEAD trusted; set SIMPLE_CONTAINER_TRUST_PREVIEW_SHA= to pin)" >&2 + fi + echo " Production-strict mode disabled. Unset the env var to restore strict mode." >&2 + echo "" >&2 + + # Build the widened regex with the validated branch name. The branch + # name has already been allowlist-restricted; the only metachar that + # could appear is `.`, which we escape here to avoid e.g. `feat.main` + # matching a regex intended for `feat/main` (gemini's "identity + # shadowing" point). `/` and `-` are regex-safe. + local escaped_branch + escaped_branch=$(printf '%s' "$preview_branch" | sed 's/\./\\./g') + identity_regex="^https://github\\.com/simple-container-com/api/\\.github/workflows/(push\\.yaml@refs/heads/main|branch-preview\\.yaml@refs/heads/${escaped_branch})\$" + fi + local cosign_err if ! cosign_err=$(COSIGN_EXPERIMENTAL=1 cosign verify-blob \ --bundle "$bundle_path" \ - --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ + --certificate-identity-regexp "$identity_regex" \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ + "${cosign_extra_args[@]}" \ "$tarball_path" 2>&1); then echo "❌" echo "❌ Signature verification FAILED for $tarball_path" echo " cosign output:" echo "$cosign_err" | sed 's/^/ /' - echo " The tarball does not bear a valid signature from the SC" - echo " production publish workflow. This could mean: tarball was" - echo " tampered in transit, CDN was compromised, or the signing" - echo " identity rotated — see https://github.com/simple-container-com/api" + # Detect the preview-signed-but-strict-mode case and give the user a + # specific hint instead of the generic "compromised CDN" copy. The + # cosign error includes the actual signer identity in `got subjects [...]`. + if echo "$cosign_err" | grep -q 'branch-preview\.yaml@refs/heads/'; then + # Try to surface the branch the tarball was actually signed from so + # the user can copy-paste it as the env var value. The cosign error + # text format is "got subjects [URL]". + local actual_branch + actual_branch=$(echo "$cosign_err" | grep -oE 'branch-preview\.yaml@refs/heads/[^]]+' | head -1 | sed 's|branch-preview\.yaml@refs/heads/||') + echo " The tarball was signed by branch-preview.yaml (a feature-branch" + echo " build), not by the production push.yaml@main workflow. If you" + echo " trust this preview, set:" + echo "" + if [ -n "$actual_branch" ]; then + echo " export SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH=$actual_branch" + else + echo " export SIMPLE_CONTAINER_TRUST_PREVIEW_BRANCH=" + fi + echo "" + echo " Optionally also pin the exact commit:" + echo " export SIMPLE_CONTAINER_TRUST_PREVIEW_SHA=<40-char-sha>" + echo "" + echo " See https://github.com/simple-container-com/api/blob/main/docs/SECURITY.md#installing-preview--branch-preview-builds" + else + echo " The tarball does not bear a valid signature from the SC" + echo " production publish workflow. This could mean: tarball was" + echo " tampered in transit, CDN was compromised, or the signing" + echo " identity rotated — see https://github.com/simple-container-com/api" + fi echo " Refusing to extract." return 1 fi