diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8b10554..ba4cd06 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,15 +12,13 @@ name: Publish signed channel # layout: bundle-/bundle.tar.gz, channel-/statement.json # (engine internal/rulesource/githubtransport.go) # -# PREREQUISITES before this can promote successfully (the chicken-and-egg the -# rollout is sequenced around — see the engine's cutover plan): -# 1. RULES_SIGNING_KEY_ED25519 secret set (base64 32-byte seed from -# `rulesctl keygen`), ideally as a *production environment* secret. +# PREREQUISITES before this can promote successfully: +# 1. RULES_SIGNING_KEY_ED25519 secret set on the `production` environment +# (base64 32-byte seed from `rulesctl keygen`). # 2. The engine at `engine_ref` embeds the matching PUBLIC key in -# internal/rulesign/keyring.json (else the self-verify step fails closed, -# which correctly blocks promotion). -# 3. A GitHub Environment named `production` with required reviewers (gates the -# promote job below). +# internal/rulesign/keyring.json AND carries cmd/rulesctl (the "Guard" step +# below fails early with a clear message otherwise). +# 3. A GitHub Environment named `production` with required reviewers. on: workflow_dispatch: @@ -47,6 +45,12 @@ env: # GitHub side needs only the one signing secret; on rotation, bump this in # lockstep with keyring.json and the engine build. RULES_SIGNING_KEY_ID: trustabl-rules-2026-06 + # Dispatch inputs are routed through env (NOT interpolated into run: shell) to + # prevent script injection (CWE-94): referenced as $CHANNEL / $TTL_HOURS / + # $ENGINE_REF, a malicious value stays inert data instead of becoming shell code. + CHANNEL: ${{ github.event.inputs.channel }} + TTL_HOURS: ${{ github.event.inputs.ttl_hours }} + ENGINE_REF: ${{ github.event.inputs.engine_ref }} permissions: contents: write # create/upload releases @@ -58,13 +62,20 @@ concurrency: cancel-in-progress: false jobs: - # Build the candidate bundle + statement and prove the engine can verify them - # against the live releases. No channel re-point happens here. + # Build the candidate bundle + statement and prove the engine can verify them. + # No channel re-point happens here. build: runs-on: ubuntu-latest outputs: digest: ${{ steps.bundle.outputs.digest }} steps: + # Reject malformed inputs up front. Defense in depth on top of the env + # routing above: $CHANNEL is also used as a release-tag/path component. + - name: Validate inputs + run: | + [[ "$CHANNEL" =~ ^[a-z0-9._-]+$ ]] || { echo "invalid channel: '$CHANNEL'"; exit 1; } + [[ "$TTL_HOURS" =~ ^[0-9]+$ ]] || { echo "invalid ttl_hours: '$TTL_HOURS'"; exit 1; } + - name: Checkout rules uses: actions/checkout@v4 with: @@ -74,7 +85,7 @@ jobs: uses: actions/checkout@v4 with: repository: trustabl/trustabl - ref: ${{ inputs.engine_ref }} + ref: ${{ github.event.inputs.engine_ref }} path: engine - uses: actions/setup-go@v5 @@ -83,6 +94,16 @@ jobs: cache: true cache-dependency-path: engine/go.sum + # Fail early, with a clear message, if the engine ref lacks the signing + # tooling or embeds an empty keyring (e.g. dispatched before the engine + # change merged) — instead of a raw build/verify error later. + - name: Guard - engine carries signing tooling + key + run: | + test -f engine/cmd/rulesctl/main.go || { + echo "engine ref '$ENGINE_REF' has no cmd/rulesctl - merge the engine change first"; exit 1; } + python3 -c "import json,sys; sys.exit(0 if json.load(open('engine/internal/rulesign/keyring.json')).get('keys') else 1)" || { + echo "engine keyring.json embeds no signing key - this build cannot verify signed channels"; exit 1; } + - name: Build publisher + scanner working-directory: engine run: | @@ -114,16 +135,16 @@ jobs: run: | tag="bundle-${{ steps.bundle.outputs.digest }}" if gh release view "$tag" -R "${{ github.repository }}" >/dev/null 2>&1; then - echo "bundle release $tag already exists — skipping (content-addressed)." + echo "bundle release $tag already exists - skipping (content-addressed)." elif gh release create "$tag" "$RUNNER_TEMP/bundle.tar.gz#bundle.tar.gz" \ -R "${{ github.repository }}" \ --title "Rules bundle ${{ steps.bundle.outputs.digest }}" \ --notes "Immutable content-addressed rule bundle. Committed by a signed channel statement."; then echo "created bundle release $tag." elif gh release view "$tag" -R "${{ github.repository }}" >/dev/null 2>&1; then - # Lost a create race with a concurrent run; the content is identical + # Lost a create race with a concurrent run; content is identical # (content-addressed), so an existing tag is success, not failure. - echo "bundle release $tag created concurrently — ok." + echo "bundle release $tag created concurrently - ok." else echo "failed to create bundle release $tag"; exit 1 fi @@ -135,21 +156,18 @@ jobs: RULES_SIGNING_KEY_ED25519: ${{ secrets.RULES_SIGNING_KEY_ED25519 }} run: | test -n "$RULES_SIGNING_KEY_ED25519" || { echo "RULES_SIGNING_KEY_ED25519 secret not set"; exit 1; } - ttl="${{ inputs.ttl_hours }}h" - "$RUNNER_TEMP/trustabl" version >/dev/null 2>&1 || true "$RUNNER_TEMP/rulesctl" sign \ - --channel "${{ inputs.channel }}" \ + --channel "$CHANNEL" \ --digest "${{ steps.bundle.outputs.digest }}" \ --key-id "$RULES_SIGNING_KEY_ID" \ - --ttl "$ttl" \ + --ttl "${TTL_HOURS}h" \ --out "$RUNNER_TEMP/statement.json" cat "$RUNNER_TEMP/statement.json" - # Self-verify the CANDIDATE before promote. This checks the statement we just - # signed — NOT the live channel (which is only re-pointed in the gated - # promote job below) — against the engine's embedded trust keyring: signature, - # channel binding, freshness, and that the statement's digest matches the - # export we bundled. It is offline and needs no published channel release, so + # Self-verify the CANDIDATE before promote: check the statement we just + # signed — NOT the live channel (only re-pointed in the gated promote job) — + # against the engine's embedded trust keyring: signature, channel binding, + # freshness, and that the digest matches the export we bundled. Offline, so # it works on the very first publish too. Fails closed if the keyring at # engine_ref is empty/missing, which correctly blocks promotion. - name: Self-verify the candidate statement @@ -158,7 +176,7 @@ jobs: --statement "$RUNNER_TEMP/statement.json" \ --bundle-dir export \ --keyring engine/internal/rulesign/keyring.json \ - --channel "${{ inputs.channel }}" + --channel "$CHANNEL" - name: Upload statement artifact uses: actions/upload-artifact@v4 @@ -182,13 +200,11 @@ jobs: path: stmt # NOTE (clobber window): re-pointing the asset has a brief window where a - # concurrent scan could see a 404 (tolerable — engine falls back to cache) - # or, worse, a truncated body (hard ParseStatement failure). Promote - # off-peak; a fully atomic re-point would publish a versioned asset and flip - # a pointer, which GitHub release assets do not natively support. + # concurrent scan could see a 404 (tolerable - engine falls back to cache) + # or a truncated body. Promote off-peak. - name: Promote channel run: | - tag="channel-${{ inputs.channel }}" + tag="channel-$CHANNEL" if gh release view "$tag" -R "${{ github.repository }}" >/dev/null 2>&1; then gh release upload "$tag" "stmt/statement.json" --clobber -R "${{ github.repository }}" else @@ -196,8 +212,8 @@ jobs: # to clobber-upload so the channel ends up pointed at this statement. gh release create "$tag" "stmt/statement.json#statement.json" \ -R "${{ github.repository }}" \ - --title "Channel: ${{ inputs.channel }}" \ - --notes "Signed pointer to the current bundle for the ${{ inputs.channel }} channel." \ + --title "Channel: $CHANNEL" \ + --notes "Signed pointer to the current bundle for the $CHANNEL channel." \ || gh release upload "$tag" "stmt/statement.json" --clobber -R "${{ github.repository }}" fi - echo "promoted ${{ inputs.channel }} -> bundle ${{ needs.build.outputs.digest }}" + echo "promoted $CHANNEL -> bundle ${{ needs.build.outputs.digest }}"