From 95739478e8f1e27d52b5c106f400fbe5fd2fea4d Mon Sep 17 00:00:00 2001 From: Dmitrii Creed Date: Sun, 24 May 2026 18:36:09 +0400 Subject: [PATCH] feat(ci): cosign-sign sc.sh itself before publishing to dist.simple-container.com Phase 2c verifies the *tarball* sc.sh downloads, but a bucket-write or CDN-swap attacker can replace sc.sh with a version whose verify_sc_tarball is stripped or hard-coded to succeed. The in-script protection regresses silently for every consumer running `bash <(curl ... sc.sh)`. This wires push.yaml's docker-finalize job to sign the post-template sc.sh (the bytes that actually land at dist.simple-container.com) under the same keyless cosign identity already used for the tarballs: push.yaml@refs/heads/main. The resulting sc.sh.cosign-bundle is dropped next to sc.sh in the dist bundle dir and shipped by the existing `welder deploy -e prod` step. Hard-fail by design: if `cosign sign-blob` fails, the deploy step never runs, so consumers never observe an sc.sh whose bundle is missing or points at a previous release. Atomic w.r.t. the script bytes. docs/SECURITY.md gets a parallel "Verifying sc.sh itself" section mirroring "Verifying tarballs", with a copy-pastable curl + verify-blob + bash snippet against the same regex. Welder.sh (welder.simple-container.com) is out of scope: welder has no GitHub Actions today and ships from a local mage flow, so giving it a real trust root needs its own scoped PR. Signing welder.sh from api/push.yaml would smear the trust root (api would attest to bytes it didn't build). Signed-off-by: Dmitrii Creed --- .github/workflows/push.yaml | 24 ++++++++++++++++++- docs/SECURITY.md | 47 +++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml index ef4161a8..e4831655 100644 --- a/.github/workflows/push.yaml +++ b/.github/workflows/push.yaml @@ -537,7 +537,9 @@ jobs: runs-on: blacksmith-8vcpu-ubuntu-2204 needs: [prepare, build-setup, build-platforms, build-binaries, test, build-docs, docker-build] permissions: - contents: write # `welder run tag-release` pushes the release git tag + contents: write # `welder run tag-release` pushes the release git tag + id-token: write # OIDC for keyless cosign sign-blob of sc.sh + attestations: write steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -545,6 +547,8 @@ jobs: - uses: fregante/setup-git-user@024bc0b8e177d7e77203b48dab6fb45666854b35 # v2.0.2 - name: install sc + welder tools uses: simple-container-com/actions/setup-sc-tooling@0af5a697f24ea484991660619d0ae42d50343b9d # main + - name: Install attestation tools (cosign for sc.sh sign-blob) + uses: simple-container-com/actions/install-attest-tools@0af5a697f24ea484991660619d0ae42d50343b9d # main - name: prepare secrets for build run: | cat << EOF > ./.sc/cfg.default.yaml @@ -636,6 +640,24 @@ jobs: cp sc.sh .sc/stacks/dist/bundle/sc.sh sed -i -e 's/VERSION="0\.0\.0"/VERSION="${VERSION}"/g' .sc/stacks/dist/bundle/sc.sh echo "${VERSION}" > .sc/stacks/dist/bundle/version + # Sign the post-template sc.sh that will land on dist.simple-container.com. + # Signing the *post-sed* artifact is intentional: consumers verify the + # bytes they actually fetch. Identity matches the tarball regex in + # docs/SECURITY.md ("Verifying sc.sh itself") — push.yaml@refs/heads/main. + # + # Hard-fail on purpose (no continue-on-error): if signing breaks, the + # `welder deploy` step that publishes sc.sh + sc.sh.cosign-bundle never + # runs, so consumers never observe an sc.sh whose bundle is missing or + # stale. Atomic w.r.t. the script — both publish or neither. + - name: Cosign sign-blob (keyless, bundle) for sc.sh + env: + COSIGN_EXPERIMENTAL: "1" + run: | + set -euo pipefail + cd .sc/stacks/dist/bundle + cosign sign-blob --yes \ + --bundle sc.sh.cosign-bundle \ + sc.sh - name: Run tag-release task after images are built env: VERSION: ${{ needs.prepare.outputs.version }} diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 2f6649bf..0d6c3cc9 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -190,6 +190,53 @@ 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. +### Verifying sc.sh itself + +`sc.sh` verifies the tarball it downloads, but a CDN compromise (or +bucket write that bypasses the publish workflow) could swap `sc.sh` +itself for a version whose `verify_sc_tarball` is stripped or +hard-coded to succeed. Consumers running `bash <(curl ... sc.sh)` +would then execute an unverified script, silently regressing the +in-script protection. + +The publish workflow therefore signs `sc.sh` itself with the same +cosign keyless scheme used for the tarballs. The bundle ships next to +the script: + +- `https://dist.simple-container.com/sc.sh` +- `https://dist.simple-container.com/sc.sh.cosign-bundle` + +Identity matches the tarball identity (push.yaml@refs/heads/main), so +the same regex verifies both. Recommended pattern for consumers who +want the script itself verified before execution: + +```bash +curl -fsSL https://dist.simple-container.com/sc.sh \ + -o sc.sh +curl -fsSL https://dist.simple-container.com/sc.sh.cosign-bundle \ + -o sc.sh.cosign-bundle +cosign verify-blob \ + --bundle sc.sh.cosign-bundle \ + --certificate-identity-regexp '^https://github\.com/simple-container-com/api/\.github/workflows/push\.yaml@refs/heads/main$' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + sc.sh +bash sc.sh +``` + +Verification is signing-side only: the publish workflow hard-fails the +release if `cosign sign-blob` does not succeed for `sc.sh`, so a new +`sc.sh` is never uploaded without a matching `.cosign-bundle`. We +deliberately do NOT teach `sc.sh` to self-verify before executing — +the script doing so would be the same code an attacker would tamper +with. Verification has to happen out-of-band, in the consumer's +runner, before the script is invoked. + +Consumers who cannot install cosign (e.g., minimal CI images) keep +working: the existing piped form (`bash <(curl ... sc.sh)`) still +works and still gives them the Phase 2c in-script tarball +verification. They simply don't gain the additional script-bytes +guarantee that the verify-then-bash pattern above provides. + #### Installing preview / branch-preview builds Default `sc.sh` accepts only production-signed tarballs (signed by