From 0215908eaf01707cf74f70c0a9686742975931a6 Mon Sep 17 00:00:00 2001 From: CodeMash Date: Thu, 8 Oct 2026 15:43:52 +0300 Subject: [PATCH] feat(ci): sign the admin image by digest with the Norbix cosign key (staging + production) staging.yml and production.yml (ms-v*) had no sign step. Both now install cosign v3.1.3 (pinned sha256), check the secret is the Norbix key (public half = security/cosign.pub, copy of devops release/cosign.pub), sign @, verify, and pass the digest to devops in the deploy payload. Without COSIGN_PRIVATE_KEY / COSIGN_PASSWORD the image is pushed unsigned with a warning. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/production.yml | 76 ++++++++++++++++++++++++++++++-- .github/workflows/staging.yml | 73 +++++++++++++++++++++++++++++- security/cosign.pub | 4 ++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 security/cosign.pub diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index ba82838..b222640 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -20,7 +20,11 @@ name: Production · Build & Deploy # The dispatch is OFF until the repository variable PRODUCTION_DEPLOY_ENABLED # is "true" (the build still runs and pushes). # -# Secrets: AWS_ROLE_ARN, DEVOPS_DISPATCH_TOKEN — the same ones staging.yml uses. +# Secrets: AWS_ROLE_ARN, DEVOPS_DISPATCH_TOKEN, COSIGN_PRIVATE_KEY, +# COSIGN_PASSWORD — the same ones staging.yml uses. The image is signed by +# digest with the Norbix cosign key; without the cosign secrets it is pushed +# unsigned with a warning — and production (image_verification = strict) +# then refuses it. # Variable: ECR_REGISTRY (optional — falls back to the norbix account registry) on: @@ -67,6 +71,7 @@ jobs: runs-on: ubuntu-22.04 outputs: image_ref: ${{ steps.ref.outputs.image_ref }} + digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout uses: actions/checkout@v4 @@ -95,6 +100,7 @@ jobs: echo "image_ref=$REGISTRY/norbix/admin:${{ needs.plan.outputs.version }}" >> "$GITHUB_OUTPUT" - name: Build and push (amd64 + arm64) + id: build uses: docker/build-push-action@v6 with: context: . @@ -107,6 +113,68 @@ jobs: cache-from: type=gha,scope=production cache-to: type=gha,mode=max,scope=production + # ── cosign: sign the pushed image BY DIGEST with the Norbix key ─── + # ONE key for every Norbix image (canonical public key: devops + # release/cosign.pub; this repo's copy: security/cosign.pub). Signed by + # IMMUTABLE DIGEST, so the signature covers exactly the bytes just + # pushed, whatever tags point at them. The installs (devops + # terraform/hostinger, self-hosted/terraform) verify it with cosign v3 + # and deploy that digest. + # No secrets in this repo → the image is pushed UNSIGNED with a warning + # (nothing breaks before the owner adds them): staging + # (image_verification = warn) still deploys it, production (strict) + # refuses it. + - name: Check signing secrets + id: signing + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + if [ -n "$COSIGN_PRIVATE_KEY" ] && [ -n "$COSIGN_PASSWORD" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Image not signed::Secrets COSIGN_PRIVATE_KEY / COSIGN_PASSWORD are not set in this repo — the image was pushed UNSIGNED. Staging (image_verification = warn) still deploys it; production (strict) refuses it." + fi + + # cosign v3.1.3, pinned with its sha256 — the same binary the installs + # verify with (devops self-hosted/terraform/scripts/install-cosign.sh; + # move both together). A direct download instead of + # sigstore/cosign-installer: no floating action tag to break the job. + - name: Install cosign v3.1.3 (sha256 checked) + if: ${{ steps.signing.outputs.enabled == 'true' }} + run: | + set -euo pipefail + curl -fsSL --retry 3 -o /tmp/cosign \ + https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64 + echo "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 /tmp/cosign" | sha256sum -c - + sudo install -m 0755 /tmp/cosign /usr/local/bin/cosign + rm -f /tmp/cosign + cosign version | grep GitVersion + + - name: Sign image with cosign (by digest) and verify + if: ${{ steps.signing.outputs.enabled == 'true' }} + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + IMAGE_REF: ${{ steps.ref.outputs.image_ref }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + IMAGE_REF="${IMAGE_REF%:*}" # image_ref carries the tag; sign @ + [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] \ + || { echo "::error::the build step returned no digest ('$IMAGE_DIGEST') — nothing to sign"; exit 1; } + # The key must be THE Norbix key: its public half = security/cosign.pub + # (otherwise every install would reject the signature). + if [ "$(cosign public-key --key env://COSIGN_PRIVATE_KEY | tr -d '[:space:]')" != "$(tr -d '[:space:]' < security/cosign.pub)" ]; then + echo "::error::COSIGN_PRIVATE_KEY is not the Norbix key: its public half differs from security/cosign.pub (devops release/cosign.pub)" + exit 1 + fi + cosign sign --yes --key env://COSIGN_PRIVATE_KEY "${IMAGE_REF}@${IMAGE_DIGEST}" + cosign verify --key security/cosign.pub "${IMAGE_REF}@${IMAGE_DIGEST}" > /dev/null + echo "Signed and verified ${IMAGE_REF}@${IMAGE_DIGEST}" + echo "- signed \`${IMAGE_REF}@${IMAGE_DIGEST}\`" >> "$GITHUB_STEP_SUMMARY" + notify-devops: name: Trigger production deploy needs: [plan, build-push] @@ -117,13 +185,15 @@ jobs: env: TOKEN: ${{ secrets.DEVOPS_DISPATCH_TOKEN }} IMAGE: ${{ needs.build-push.outputs.image_ref }} + DIGEST: ${{ needs.build-push.outputs.digest }} run: | + # digest = what was pushed and signed; production verifies it (strict) PAYLOAD=$(jq -n \ - --arg i "$IMAGE" \ + --arg i "$IMAGE" --arg d "$DIGEST" \ --arg sha "${{ github.sha }}" --arg branch "${{ github.ref_name }}" \ '{ event_type: "production-deploy", client_payload: { release: "managed", source: "admin", sha: $sha, branch: $branch, - services: [ { name: "admin", image: $i } ] } }') + services: [ { name: "admin", image: $i, digest: $d } ] } }') echo "$PAYLOAD" | jq . STATUS=$(curl -s --http1.1 --retry 5 --retry-delay 5 -o /tmp/dispatch-resp.txt -w "%{http_code}" -X POST \ diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 8269e11..3fb9083 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -24,6 +24,10 @@ name: Staging · Build & Deploy # different org than gateway/cloud!) # DEVOPS_DISPATCH_TOKEN — fine-grained PAT for codemash-io/devops, # Contents: Read and write +# COSIGN_PRIVATE_KEY, — the Norbix cosign key + its password: the +# COSIGN_PASSWORD image is signed by digest (public key +# security/cosign.pub). Optional: without +# them it is pushed unsigned (warning). # Variable: ECR_REGISTRY (optional — falls back to the norbix account registry) on: @@ -95,6 +99,7 @@ jobs: runs-on: ubuntu-22.04 outputs: image_ref: ${{ steps.ref.outputs.image_ref }} + digest: ${{ steps.build.outputs.digest }} steps: - name: Checkout uses: actions/checkout@v4 @@ -120,6 +125,7 @@ jobs: echo "image_ref=$REGISTRY/norbix/${{ needs.plan.outputs.image }}:${{ needs.plan.outputs.version }}" >> "$GITHUB_OUTPUT" - name: Build and push (amd64 — staging box arch) + id: build uses: docker/build-push-action@v6 with: context: . @@ -133,6 +139,68 @@ jobs: cache-from: type=gha,scope=staging-${{ needs.plan.outputs.flavor }} cache-to: type=gha,mode=max,scope=staging-${{ needs.plan.outputs.flavor }} + # ── cosign: sign the pushed image BY DIGEST with the Norbix key ─── + # ONE key for every Norbix image (canonical public key: devops + # release/cosign.pub; this repo's copy: security/cosign.pub). Signed by + # IMMUTABLE DIGEST, so the signature covers exactly the bytes just + # pushed, whatever tags point at them. The installs (devops + # terraform/hostinger, self-hosted/terraform) verify it with cosign v3 + # and deploy that digest. + # No secrets in this repo → the image is pushed UNSIGNED with a warning + # (nothing breaks before the owner adds them): staging + # (image_verification = warn) still deploys it, production (strict) + # refuses it. + - name: Check signing secrets + id: signing + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + run: | + if [ -n "$COSIGN_PRIVATE_KEY" ] && [ -n "$COSIGN_PASSWORD" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::warning title=Image not signed::Secrets COSIGN_PRIVATE_KEY / COSIGN_PASSWORD are not set in this repo — the image was pushed UNSIGNED. Staging (image_verification = warn) still deploys it; production (strict) refuses it." + fi + + # cosign v3.1.3, pinned with its sha256 — the same binary the installs + # verify with (devops self-hosted/terraform/scripts/install-cosign.sh; + # move both together). A direct download instead of + # sigstore/cosign-installer: no floating action tag to break the job. + - name: Install cosign v3.1.3 (sha256 checked) + if: ${{ steps.signing.outputs.enabled == 'true' }} + run: | + set -euo pipefail + curl -fsSL --retry 3 -o /tmp/cosign \ + https://github.com/sigstore/cosign/releases/download/v3.1.3/cosign-linux-amd64 + echo "4629c757b7618056f8ddd7e2625ae9fdd94c0372a65049520bc7d9df9efc7f71 /tmp/cosign" | sha256sum -c - + sudo install -m 0755 /tmp/cosign /usr/local/bin/cosign + rm -f /tmp/cosign + cosign version | grep GitVersion + + - name: Sign image with cosign (by digest) and verify + if: ${{ steps.signing.outputs.enabled == 'true' }} + env: + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + IMAGE_REF: ${{ steps.ref.outputs.image_ref }} + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + IMAGE_REF="${IMAGE_REF%:*}" # image_ref carries the tag; sign @ + [[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] \ + || { echo "::error::the build step returned no digest ('$IMAGE_DIGEST') — nothing to sign"; exit 1; } + # The key must be THE Norbix key: its public half = security/cosign.pub + # (otherwise every install would reject the signature). + if [ "$(cosign public-key --key env://COSIGN_PRIVATE_KEY | tr -d '[:space:]')" != "$(tr -d '[:space:]' < security/cosign.pub)" ]; then + echo "::error::COSIGN_PRIVATE_KEY is not the Norbix key: its public half differs from security/cosign.pub (devops release/cosign.pub)" + exit 1 + fi + cosign sign --yes --key env://COSIGN_PRIVATE_KEY "${IMAGE_REF}@${IMAGE_DIGEST}" + cosign verify --key security/cosign.pub "${IMAGE_REF}@${IMAGE_DIGEST}" > /dev/null + echo "Signed and verified ${IMAGE_REF}@${IMAGE_DIGEST}" + echo "- signed \`${IMAGE_REF}@${IMAGE_DIGEST}\`" >> "$GITHUB_STEP_SUMMARY" + notify-devops: name: Trigger staging deploy needs: [plan, build-push] @@ -142,13 +210,16 @@ jobs: env: TOKEN: ${{ secrets.DEVOPS_DISPATCH_TOKEN }} run: | + # digest = what was pushed (and signed); devops records it and + # deploys @ PAYLOAD=$(jq -n \ --arg r "${{ needs.plan.outputs.release }}" \ --arg i "${{ needs.build-push.outputs.image_ref }}" \ + --arg d "${{ needs.build-push.outputs.digest }}" \ --arg sha "${{ github.sha }}" --arg branch "${{ github.ref_name }}" \ '{ event_type: "staging-deploy", client_payload: { release: $r, source: "admin", sha: $sha, branch: $branch, - services: [ { name: "admin", image: $i } ] } }') + services: [ { name: "admin", image: $i, digest: $d } ] } }') echo "$PAYLOAD" | jq . # --http1.1 + retries handle transient network resets (curl 92). diff --git a/security/cosign.pub b/security/cosign.pub new file mode 100644 index 0000000..a70a228 --- /dev/null +++ b/security/cosign.pub @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEITuglMoPComC7m+1P3IKqbiyi1n/ +vJVHci6DXVV0PXqOjAWhQkraouBNbT26QA1VJYoP4MHSggf/fgeKgSwyjg== +-----END PUBLIC KEY-----