Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<sha>-preview.<sha> \
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.<sha>-preview.<sha> \
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/<that exact branch>`.
When `_SHA` is also set, `cosign verify-blob` is given
`--certificate-github-workflow-sha <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,
Expand Down
144 changes: 135 additions & 9 deletions sc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -430,31 +430,157 @@ 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=<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
# out with "unknown flag: --yes" — which is what broke every install
# 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=<branch-name> 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=<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=<branch-name>"
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
Expand Down
Loading