From acede63adb10183025cede186025f259a23c1550 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 21 Sep 2026 16:43:52 +0200 Subject: [PATCH 1/3] fix(security): narrow the app tenants' publish matchers to their approved revisions wedding-app and ascoachingogvaner accepted any 40-hex publish-app.yaml revision, including superseded ones. They now name their approved set: the applied signer, the default-branch pin and the latest released actions revision. Each tenant's required check already holds a pin move until the platform approves it, so narrowing cannot strand a release. Co-Authored-By: Claude Opus 5 (1M context) --- .../ascoachingogvaner/oci-repository.yaml | 2 +- .../apps/wedding-app/oci-repository.yaml | 2 +- ...ard-publish-workflow-approved-revisions.sh | 38 +++-------- ...publish-workflow-approved-revisions.lib.sh | 63 ++++++++++++----- ...blish-workflow-approved-revisions-guard.sh | 67 +++++++++++-------- .../test-write-publish-workflow-matchers.sh | 38 ++++++----- scripts/write-publish-workflow-matchers.sh | 13 ++-- 7 files changed, 124 insertions(+), 99 deletions(-) diff --git a/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml b/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml index cdacc98f37..aa884a489d 100644 --- a/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml +++ b/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml @@ -21,4 +21,4 @@ spec: # namespace, RBAC, network and admission policy bound the release; the remaining # risk is accepted in devantler-tech/platform#3746. - issuer: '^https://token\.actions\.githubusercontent\.com$' - subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}$' + subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(a147ead8b8bc937fbfb1974e3698c7e32f95517d|3c3a6348b3e7aacc995ae49fc60032f95f70a779|6f47d8ca360fa3058f57175d54fa148deac08983)$' diff --git a/k8s/bases/apps/wedding-app/oci-repository.yaml b/k8s/bases/apps/wedding-app/oci-repository.yaml index 697b9879dc..2e2d9c2496 100644 --- a/k8s/bases/apps/wedding-app/oci-repository.yaml +++ b/k8s/bases/apps/wedding-app/oci-repository.yaml @@ -21,4 +21,4 @@ spec: # namespace, RBAC, network and admission policy bound the release; the remaining # risk is accepted in devantler-tech/platform#3746. - issuer: '^https://token\.actions\.githubusercontent\.com$' - subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}$' + subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(759ff9b2526cd1b142b49c5dc1616118a1ef44d9|3c3a6348b3e7aacc995ae49fc60032f95f70a779|6f47d8ca360fa3058f57175d54fa148deac08983)$' diff --git a/scripts/guard-publish-workflow-approved-revisions.sh b/scripts/guard-publish-workflow-approved-revisions.sh index ec8614183e..9d044f4b71 100755 --- a/scripts/guard-publish-workflow-approved-revisions.sh +++ b/scripts/guard-publish-workflow-approved-revisions.sh @@ -1,8 +1,7 @@ #!/usr/bin/env bash # guard-publish-workflow-approved-revisions.sh — assert that each per-consumer cosign matcher -# for the shared devantler-tech/actions publish workflows stays inside its declared trust -# boundary: trusted tenant release streams accept any immutable shared-workflow commit, while -# infrastructure/configuration consumers pin exactly the generated revision pair. +# for the shared devantler-tech/actions publish workflows names exactly its generated approved +# revision set. # # WHY A SECOND GUARD # guard-shared-publish-workflow-pin.sh proves every matcher pins a FIXED revision — the pattern @@ -10,15 +9,13 @@ # says nothing about set MEMBERSHIP: a matcher narrowed to a revision nobody approved, or a # regeneration that moved the set while a matcher stayed behind, passes it. This guard reads # scripts/publish-workflow-approved-revisions.tsv (written by -# generate-publish-workflow-approved-revisions.sh, #3550). Bounded consumers must name their -# generated pair and nothing else. Trusted release streams must name the immutable commit -# pattern and nothing broader, which preserves the issuer/workflow boundary without a release -# approval gate. +# generate-publish-workflow-approved-revisions.sh, #3550). Every consumer, application tenants +# included, must name its approved set and nothing else: the applied signer, the default-branch +# pin and, for publish-app consumers, the latest released actions revision (#3917). # # ENFORCEMENT # CI and scheduled regeneration set APPROVED_REVISIONS_ENFORCE=1, which refuses the pattern -# form for bounded infrastructure/configuration consumers. Trusted release streams require -# that form in every mode so tenant releases never create a platform-side repin. +# form for every consumer. # # SCOPE — GENERIC SUBJECTS ARE EXCLUDED BY NAME # Three subjects name the shared workflows without belonging to one consumer: the Kyverno @@ -36,7 +33,7 @@ # SEAMS (tests substitute these; production leaves them unset) # APPROVED_REVISIONS_FILE the generated set (default scripts/publish-workflow-approved-revisions.tsv) # PUBLISH_CONSUMER_ROOT scan root for consumer manifests (default: the repository root) -# APPROVED_REVISIONS_ENFORCE `1` refuses pattern form on bounded consumers (default: off) +# APPROVED_REVISIONS_ENFORCE `1` refuses the pattern form (default: off) set -euo pipefail GUARD_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -48,21 +45,10 @@ for consumer in "${EXPECTED_CONSUMERS[@]}"; do record="$(lookup "$observed" "$consumer")" [ -n "$record" ] || refuse "no OCIRepository under $SCAN_ROOT is attributed to registered consumer $consumer; the scan, not the tree, is the likely cause" IFS=$'\t' read -r file workflow ref <<<"$record" - IFS=$'\t' read -r set_workflow signer pin <<<"$(lookup "$approved" "$consumer")" + IFS=$'\t' read -r set_workflow signer pin candidate <<<"$(lookup "$approved" "$consumer")" [ "$workflow" = "$set_workflow" ] || refuse "$consumer: $file names $workflow but the approved set names $set_workflow" - if is_trusted_release_stream_consumer "$consumer"; then - [ "$ref" = "$PATTERN_REF" ] || - refuse "$consumer: trusted release stream $file pins '@$ref'; use the immutable shared-workflow pattern '@$PATTERN_REF' so tenant releases do not require platform approval" - printf 'ok %s %s %s form=trusted-release-stream\n' "$consumer" "$workflow" "$file" - continue - fi - - if [ "$signer" = "$pin" ]; then - accepted="$signer or ($signer)" - else - accepted="($signer|$pin) or ($pin|$signer)" - fi + accepted="$(approved_ref "$signer" "$pin" "$candidate") (in any order)" form="" if [ "$ref" = "$PATTERN_REF" ]; then @@ -70,12 +56,10 @@ for consumer in "${EXPECTED_CONSUMERS[@]}"; do refuse "$consumer: $file still carries the pattern form '@$PATTERN_REF' while APPROVED_REVISIONS_ENFORCE=1; narrow it to @$accepted" fi form="pattern (accepted while the switch is off)" - elif [ "$signer" = "$pin" ] && { [ "$ref" = "$signer" ] || [ "$ref" = "($signer)" ]; }; then - form="set" - elif [ "$signer" != "$pin" ] && { [ "$ref" = "($signer|$pin)" ] || [ "$ref" = "($pin|$signer)" ]; }; then + elif ref_matches_approved "$ref" "$signer" "$pin" "$candidate"; then form="set" else - refuse "$consumer: $file pins '@$ref', which is not the generated pair — expected @$accepted (or the pattern form while the switch is off)" + refuse "$consumer: $file pins '@$ref', which is not the generated set — expected @$accepted (or the pattern form while the switch is off); run scripts/write-publish-workflow-matchers.sh" fi printf 'ok %s %s %s form=%s\n' "$consumer" "$workflow" "$file" "$form" done diff --git a/scripts/publish-workflow-approved-revisions.lib.sh b/scripts/publish-workflow-approved-revisions.lib.sh index 6dd7202d12..771774d2e1 100644 --- a/scripts/publish-workflow-approved-revisions.lib.sh +++ b/scripts/publish-workflow-approved-revisions.lib.sh @@ -24,15 +24,12 @@ readonly GENERIC_SUBJECT_FILES=( 'talos/cluster/verify-first-party-images.yaml' ) -# Application tenants are trusted to release independently inside the platform-owned -# boundary: exact GitHub OIDC issuer, exact shared workflow path, and an immutable 40-hex -# workflow commit. Pinning these streams to the currently observed workflow revisions would -# make every ordinary tenant release wait for a platform change. Infrastructure/configuration -# consumers remain tied to their generated revision sets below. -readonly TRUSTED_RELEASE_STREAM_CONSUMERS=( - 'ascoachingogvaner' - 'wedding-app' -) +# Every registered consumer, application tenants included, is narrowed to its approved revision +# set: the revision that signed the running artifact, the revision its default branch pins, and, +# for publish-app consumers, the latest released devantler-tech/actions revision (#3960). A tenant +# cannot move its pin ahead of that set: each app tenant runs a required check that refuses a pin +# the committed approved set does not contain (#3961), so a new shared-workflow revision holds the +# tenant's dependency PR rather than failing its release. readonly SUBJECT_PREFIX='^https://github\.com/devantler-tech/actions/\.github/workflows/publish-' # Used by both callers after the shared discovery completes. @@ -63,12 +60,48 @@ lookup() { printf '%s\n' "$1" | awk -F'\t' -v k="$2" '$1 "" == k "" { sub(/^[^\t]*\t/, ""); print; exit }' } -is_trusted_release_stream_consumer() { - local candidate="$1" trusted - for trusted in "${TRUSTED_RELEASE_STREAM_CONSUMERS[@]}"; do - [ "$candidate" = "$trusted" ] && return 0 +# approved_revisions → each distinct approved revision on its own line, +# in signer, pin, candidate order. A `-` candidate (publish-manifests rows) names no revision. +approved_revisions() { + local seen=" " rev + for rev in "$@"; do + [ "$rev" != '-' ] || continue + case "$seen" in *" $rev "*) continue ;; esac + seen="$seen$rev " + printf '%s\n' "$rev" done - return 1 +} + +# approved_ref → the canonical matcher ref: the single revision, or a +# grouped alternation of the distinct revisions in approved_revisions order. +approved_ref() { + local revs + revs="$(approved_revisions "$@")" + if [ "$(printf '%s\n' "$revs" | grep -c .)" -eq 1 ]; then + printf '%s' "$revs" + else + printf '(%s)' "$(printf '%s\n' "$revs" | paste -sd'|' -)" + fi +} + +# ref_matches_approved → success when names exactly the +# approved revisions, each once, in any order. A single revision may be grouped or bare; several +# must be grouped, because an ungrouped `a|b` inside the anchored subject splits the whole regex +# into two alternatives and admits far more than either revision. Anything else fails, including +# a ref that adds, omits or repeats a revision. +ref_matches_approved() { + local ref="$1" body got want + shift + case "$ref" in + '('*')') body="${ref#\(}"; body="${body%\)}" ;; + *'|'*) return 1 ;; + *) body="$ref" ;; + esac + got="$(printf '%s\n' "$body" | tr '|' '\n')" + ! grep -qvxE '[0-9a-f]{40}' <<<"$got" || return 1 + [ "$(printf '%s\n' "$got" | sort | uniq -d)" = "" ] || return 1 + want="$(approved_revisions "$@" | sort)" + [ "$(printf '%s\n' "$got" | sort)" = "$want" ] } # consumer → "workflowsignerpin" @@ -100,7 +133,7 @@ while IFS= read -r row; do fi [ -z "$(lookup "$approved" "$consumer")" ] || refuse "approved set names $consumer twice" : "$applied_tag" "$applied_digest" - approved="${approved}${consumer}"$'\t'"${workflow}"$'\t'"${signer}"$'\t'"${pin}"$'\n' + approved="${approved}${consumer}"$'\t'"${workflow}"$'\t'"${signer}"$'\t'"${pin}"$'\t'"${candidate}"$'\n' done <"$APPROVED_SET" # Exactly the registered consumers, both directions: a missing row is a consumer nobody diff --git a/scripts/tests/test-publish-workflow-approved-revisions-guard.sh b/scripts/tests/test-publish-workflow-approved-revisions-guard.sh index 648e7193e7..5c2c34f42c 100755 --- a/scripts/tests/test-publish-workflow-approved-revisions-guard.sh +++ b/scripts/tests/test-publish-workflow-approved-revisions-guard.sh @@ -58,14 +58,6 @@ first_consumer="$(printf '%s\n' "$consumers" | sed -n 1p | cut -f1)" first_workflow="$(printf '%s\n' "$consumers" | sed -n 1p | cut -f2)" second_consumer='aws' second_workflow="$(printf '%s\n' "$consumers" | awk -F'\t' -v c="$second_consumer" '$1 == c {print $2}')" -TRUSTED_RELEASE_STREAMS='ascoachingogvaner wedding-app' - -is_trusted_release_stream() { - case " $TRUSTED_RELEASE_STREAMS " in - *" $1 "*) return 0 ;; - *) return 1 ;; - esac -} # The artifact name is the repository name except for `.github`, which publishes as # `github-config` — the same table the report uses. @@ -183,6 +175,15 @@ EOF # candidate_for — the release_candidate_sha column: a commit for publish-app, `-` otherwise. candidate_for() { if [ "$1" = publish-app ]; then printf '%s' "$SHA_E"; else printf '-'; fi; } +# set_for — the consumer's canonical generated set, as write_set records it. +set_for() { + if [ "$2" = publish-app ]; then + printf '(%s|%s|%s)' "$(signer_for "$1")" "$SHA_B" "$SHA_E" + else + printf '(%s|%s)' "$(signer_for "$1")" "$SHA_B" + fi +} + # write_set [] [] — the approved set for every registered consumer. write_set() { local path="$1" omit="${2:-}" extra="${3:-}" repo workflow @@ -195,9 +196,9 @@ write_set() { [ -z "$extra" ] || printf '%s\n' "$extra" >>"$path" } -# build_tree — a complete fixture. `pair` means exact generated sets for -# bounded consumers and the immutable commit pattern for trusted release streams. Generic -# subjects always remain on the pattern form. +# build_tree — a complete fixture. `pair` means every consumer carries its +# exact generated set: signer and pin, plus the release candidate for publish-app consumers. +# Generic subjects always remain on the pattern form. build_tree() { local name="$1" form="$2" root repo workflow ref root="$WORK/$name" @@ -207,13 +208,7 @@ build_tree() { [ -n "$repo" ] || continue case "$form" in pattern) ref="$PATTERN" ;; - pair) - if is_trusted_release_stream "$repo"; then - ref="$PATTERN" - else - ref="($(signer_for "$repo")|$SHA_B)" - fi - ;; + pair) ref="$(set_for "$repo" "$workflow")" ;; *) printf 'build_tree: unknown form %s\n' "$form" >&2; exit 1 ;; esac write_consumer "$root" "$repo" "$workflow" "$ref" @@ -261,7 +256,7 @@ root="$(build_tree off-pattern pattern)" expect_pass 'switch off: every consumer on the pattern form passes' "$root" 0 root="$(build_tree off-pair pair)" -expect_pass 'switch off: bounded consumers use generated pairs and trusted streams use the pattern' "$root" 0 +expect_pass 'switch off: every consumer, application tenants included, uses its generated set' "$root" 0 root="$(build_tree off-pair-reversed pair)" write_consumer "$root" "$first_consumer" "$first_workflow" "($SHA_B|$(signer_for "$first_consumer"))" @@ -271,7 +266,7 @@ expect_pass 'switch off: the pair in the other order is the same set and passes' root="$(build_tree off-foreign pattern)" write_consumer "$root" "$first_consumer" "$first_workflow" "($(signer_for "$first_consumer")|$SHA_D)" expect_refusal 'switch off: an alternation carrying a revision outside the pair is refused by consumer and ref' \ - "$root" 0 "$first_consumer" "$SHA_D" 'not the generated pair' + "$root" 0 "$first_consumer" "$SHA_D" 'not the generated set' root="$(build_tree off-single-foreign pattern)" write_consumer "$root" "$first_consumer" "$first_workflow" "$SHA_D" @@ -281,7 +276,7 @@ expect_refusal 'switch off: a single concrete revision outside the pair is refus root="$(build_tree off-half pattern)" write_consumer "$root" "$first_consumer" "$first_workflow" "$(signer_for "$first_consumer")" expect_refusal 'switch off: one revision where the pair needs two is refused' \ - "$root" 0 "$first_consumer" 'not the generated pair' + "$root" 0 "$first_consumer" 'not the generated set' root="$(build_tree off-three pair)" write_consumer "$root" "$first_consumer" "$first_workflow" "($(signer_for "$first_consumer")|$SHA_B|$SHA_D)" @@ -294,12 +289,28 @@ write_consumer "$root" "$first_consumer" "$first_workflow" "($(signer_for "$seco expect_refusal "switch off: another consumer's pair on $first_consumer is refused" \ "$root" 0 "$first_consumer" "$(signer_for "$second_consumer")" -# A concrete pair is still cryptographically narrow, but on a trusted tenant release stream it -# recreates the platform approval gate this boundary is designed to remove. -root="$(build_tree release-stream-pinned pair)" +# Application tenants are held to their generated set like every other consumer (#3917): the +# release candidate belongs in it, the pattern form is refused under enforcement, and a revision +# outside the set is refused for the tenant specifically. +root="$(build_tree tenant-reordered pair)" +write_consumer "$root" 'wedding-app' 'publish-app' "($SHA_E|$SHA_A|$SHA_B)" +expect_pass 'a tenant set in another order is the same set and passes' "$root" 1 +root="$(build_tree tenant-no-candidate pair)" write_consumer "$root" 'wedding-app' 'publish-app' "($SHA_A|$SHA_B)" -expect_refusal 'a trusted release stream pinned to a generated pair is refused' \ - "$root" 1 'wedding-app' 'trusted release stream' "$PATTERN" +expect_refusal 'a tenant set missing its release candidate is refused' \ + "$root" 1 'wedding-app' 'not the generated set' "$SHA_E" +root="$(build_tree tenant-foreign pair)" +write_consumer "$root" 'ascoachingogvaner' 'publish-app' "($SHA_A|$SHA_B|$SHA_D)" +expect_refusal 'a tenant revision outside its approved set is refused for that tenant' \ + "$root" 1 'ascoachingogvaner' "$SHA_D" 'not the generated set' +root="$(build_tree tenant-pattern pair)" +write_consumer "$root" 'wedding-app' 'publish-app' "$PATTERN" +expect_refusal 'switch on: a tenant on the pattern form is refused' \ + "$root" 1 'wedding-app' 'pattern form' +root="$(build_tree tenant-ungrouped pair)" +write_consumer "$root" 'wedding-app' 'publish-app' "$SHA_A|$SHA_B|$SHA_E" +expect_refusal 'an ungrouped alternation is refused: it would split the anchored subject regex' \ + "$root" 0 'wedding-app' 'not the generated set' root="$(build_tree off-tag pair)" write_consumer "$root" "$first_consumer" "$first_workflow" 'refs/tags/v.+' @@ -342,7 +353,7 @@ expect_pass 'signer == pin: the single revision in a group passes' "$root" 1 root="$(build_tree same-doubled pair)"; same_set "$root" write_consumer "$root" "$first_consumer" "$first_workflow" "($SHA_B|$SHA_B)" expect_refusal 'signer == pin: a doubled alternation is not the canonical set and is refused' \ - "$root" 0 "$first_consumer" 'not the generated pair' + "$root" 0 "$first_consumer" 'not the generated set' root="$(build_tree same-foreign pair)"; same_set "$root" write_consumer "$root" "$first_consumer" "$first_workflow" "($SHA_B|$SHA_D)" expect_refusal 'signer == pin: an alternation adding a foreign revision is refused' \ @@ -350,7 +361,7 @@ expect_refusal 'signer == pin: an alternation adding a foreign revision is refus # ── switch ON: the pattern form is refused for a per-consumer subject ──────────────────────── root="$(build_tree on-pair pair)" -expect_pass 'switch on: bounded pairs and trusted release streams pass together' "$root" 1 +expect_pass 'switch on: every consumer on its generated set passes' "$root" 1 root="$(build_tree on-pattern pair)" write_consumer "$root" "$first_consumer" "$first_workflow" "$PATTERN" diff --git a/scripts/tests/test-write-publish-workflow-matchers.sh b/scripts/tests/test-write-publish-workflow-matchers.sh index 0c25397d96..cda14f2282 100755 --- a/scripts/tests/test-write-publish-workflow-matchers.sh +++ b/scripts/tests/test-write-publish-workflow-matchers.sh @@ -86,26 +86,30 @@ guard >"$WORK/guard.log" 2>&1 || { cat "$WORK/guard.log" >&2; fail 'rewritten tr [ "$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/github-config/oci-repository.yaml")" = \ '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-manifests\.yaml@(1111111111111111111111111111111111111111|2222222222222222222222222222222222222222)$' ] || fail 'github-config did not receive its own exact pair' [ "$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml")" = \ - '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}$' ] || fail 'ascoachingogvaner release stream was pinned to a workflow revision set' + '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(3333333333333333333333333333333333333333|2222222222222222222222222222222222222222)$' ] || fail 'ascoachingogvaner did not receive its set with the candidate deduplicated against its signer' [ "$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/aws/oci-repository.yaml")" = \ '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-manifests\.yaml@1111111111111111111111111111111111111111$' ] || fail 'equal revisions were not deduplicated' [ "$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/wedding-app/oci-repository.yaml")" = \ - '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@[0-9a-f]{40}$' ] || fail 'wedding-app release stream was pinned to a workflow revision set' + '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(1111111111111111111111111111111111111111|2222222222222222222222222222222222222222|3333333333333333333333333333333333333333)$' ] || fail 'wedding-app did not receive its signer, pin and release candidate' [ "$(yq -r '.spec.interval' "$TREE/k8s/bases/apps/aws/oci-repository.yaml")" = 5m ] || fail 'unrelated YAML changed' grep -Fq '# Preserve this consumer comment.' "$TREE/k8s/bases/apps/aws/oci-repository.yaml" || fail 'consumer comment was lost' for file in "${GENERIC_FILES[@]}"; do cmp -s "$ROOT/$file" "$TREE/$file" || fail "generic subject $file changed"; done printf 'ok: exact consumer pairs, deduplication, unrelated fields and generic subjects\n' -# Trusted application release streams advance without a platform-side signer repin. A -# concrete revision pair on either stream reintroduces that approval gate and must fail -# enforcement even though it still verifies the currently deployed artifact. -SUBJECT="^https://github\\.com/devantler-tech/actions/\\.github/workflows/publish-app\\.yaml@($A|$B)\$" \ - yq -i '.spec.verify.matchOIDCIdentity[0].subject = strenv(SUBJECT)' "$TREE/k8s/bases/apps/wedding-app/oci-repository.yaml" -if guard >"$WORK/guard.log" 2>&1; then fail 'a trusted release stream pinned to a revision pair passed enforcement'; fi -grep -Fq 'trusted release stream' "$WORK/guard.log" || fail 'release-stream refusal did not name the boundary' -write_matchers >"$WORK/writer.log" 2>&1 || fail 'writer did not restore the trusted release-stream boundary' -guard >"$WORK/guard.log" 2>&1 || fail 'restored release-stream boundary did not pass enforcement' -printf 'ok: trusted release streams refuse per-revision approval gates\n' +# An application tenant is held to its approved set like every other consumer (#3917). Each of +# these tenant matchers still verifies the currently deployed artifact, yet each is refused: a set +# that drops the release candidate, one that adds a revision nobody approved, and an ungrouped +# alternation, which splits the anchored subject regex. The recognisable shape goes last: the +# writer refuses to rewrite an existing ref it cannot recognise, so it can only restore from that. +for bad_ref in "$A|$B|$C" "($A|$B|$C|$D)" "($A|$B)"; do + SUBJECT="^https://github\\.com/devantler-tech/actions/\\.github/workflows/publish-app\\.yaml@$bad_ref\$" \ + yq -i '.spec.verify.matchOIDCIdentity[0].subject = strenv(SUBJECT)' "$TREE/k8s/bases/apps/wedding-app/oci-repository.yaml" + if guard >"$WORK/guard.log" 2>&1; then fail "a tenant matcher @$bad_ref passed enforcement"; fi + grep -Fq 'not the generated set' "$WORK/guard.log" || fail "tenant refusal for @$bad_ref had the wrong cause" +done +write_matchers >"$WORK/writer.log" 2>&1 || fail 'writer did not restore the tenant set' +guard >"$WORK/guard.log" 2>&1 || fail 'restored tenant set did not pass enforcement' +printf 'ok: an application tenant refuses out-of-set, incomplete and ungrouped revision lists\n' before="$(snapshot)" write_matchers >"$WORK/writer.log" 2>&1 @@ -116,14 +120,12 @@ printf 'ok: unchanged input is a byte-identical no-op\n' awk -F '\t' -v OFS='\t' -v pin="$D" 'NR > 1 {$6=pin} {print}' "$SET" >"$WORK/moved.tsv" mv "$WORK/moved.tsv" "$SET" if guard >"$WORK/guard.log" 2>&1; then fail 'old matcher unexpectedly accepts a moved set'; fi -grep -Fq 'not the generated pair' "$WORK/guard.log" || fail 'moved-set refusal had the wrong cause' -ascoaching_before="$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml")" -wedding_before="$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/wedding-app/oci-repository.yaml")" +grep -Fq 'not the generated set' "$WORK/guard.log" || fail 'moved-set refusal had the wrong cause' write_matchers >"$WORK/writer.log" 2>&1 guard >"$WORK/guard.log" 2>&1 || fail 'pin bump did not converge to a guard-clean tree' -[ "$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml")" = "$ascoaching_before" ] || fail 'a pin bump rewrote the ascoachingogvaner release stream' -[ "$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/wedding-app/oci-repository.yaml")" = "$wedding_before" ] || fail 'a pin bump rewrote the wedding-app release stream' -printf 'ok: a pin bump rewrites bounded consumers and leaves trusted release streams unchanged\n' +[ "$(yq -r '.spec.verify.matchOIDCIdentity[0].subject' "$TREE/k8s/bases/apps/wedding-app/oci-repository.yaml")" = \ + '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(1111111111111111111111111111111111111111|4444444444444444444444444444444444444444|3333333333333333333333333333333333333333)$' ] || fail 'a pin bump did not move the wedding-app set to the new pin' +printf 'ok: a pin bump rewrites every consumer, application tenants included\n' fixture sed '$d' "$SET" >"$WORK/incomplete.tsv" diff --git a/scripts/write-publish-workflow-matchers.sh b/scripts/write-publish-workflow-matchers.sh index 953b1fa023..492d0b1f48 100755 --- a/scripts/write-publish-workflow-matchers.sh +++ b/scripts/write-publish-workflow-matchers.sh @@ -1,6 +1,5 @@ #!/usr/bin/env bash -# Derive bounded-consumer cosign subjects from the generated approved revision set and keep -# trusted tenant release streams on the immutable shared-workflow commit pattern. +# Derive every registered consumer's cosign subject from the generated approved revision set. # Validate every input and staged result before replacing any consumer manifest. # Generic multi-consumer subjects remain unchanged. Unchanged output preserves bytes. set -euo pipefail @@ -9,7 +8,7 @@ WRITER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=scripts/publish-workflow-approved-revisions.lib.sh source "$WRITER_DIR/publish-workflow-approved-revisions.lib.sh" -readonly FIXED_REF_RE='^([0-9a-f]{40}|\([0-9a-f]{40}\)|\([0-9a-f]{40}\|[0-9a-f]{40}\))$' +readonly FIXED_REF_RE='^([0-9a-f]{40}|\([0-9a-f]{40}(\|[0-9a-f]{40}){0,2}\))$' STAGED="$(mktemp -d "${TMPDIR:-/tmp}/write-publish-workflow-matchers.XXXXXX")" trap 'rm -rf "$STAGED"' EXIT @@ -24,7 +23,7 @@ for consumer in "${EXPECTED_CONSUMERS[@]}"; do record="$(lookup "$observed" "$consumer")" [ -n "$record" ] || refuse "no OCIRepository is attributed to registered consumer $consumer" IFS=$'\t' read -r file workflow ref <<<"$record" - IFS=$'\t' read -r set_workflow signer pin <<<"$(lookup "$approved" "$consumer")" + IFS=$'\t' read -r set_workflow signer pin candidate <<<"$(lookup "$approved" "$consumer")" [ "$workflow" = "$set_workflow" ] || refuse "$consumer: $file names $workflow but the approved set names $set_workflow" [ "$ref" = "$PATTERN_REF" ] || [[ "$ref" =~ $FIXED_REF_RE ]] || refuse "$consumer: unrecognised existing revision '$ref'; review the source identity before rewriting" @@ -32,11 +31,7 @@ for consumer in "${EXPECTED_CONSUMERS[@]}"; do refuse "$consumer: source manifest must be a writable regular file, not a symlink" fi - desired_ref="$PATTERN_REF" - if ! is_trusted_release_stream_consumer "$consumer"; then - desired_ref="$signer" - [ "$signer" = "$pin" ] || desired_ref="($signer|$pin)" - fi + desired_ref="$(approved_ref "$signer" "$pin" "$candidate")" subject="${SUBJECT_PREFIX}${workflow#publish-}"'\.yaml@'"$desired_ref"'$' mkdir -p "$STAGED/$(dirname "$file")" # Select the actual OCIRepository document; unrelated documents remain unchanged. From dd42bce352721b32bf0329bc138c219a274aedc9 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 21 Sep 2026 16:52:41 +0200 Subject: [PATCH 2/3] fix(security): keep the authorization approval across three-revision tenant sets The authorization fingerprint projects an exact publish-workflow revision set back to the approved 40-hex pattern, but only recognised up to two revisions. A publish-app tenant's set also carries the release candidate, so it now recognises up to three there; publish-manifests consumers stay at two. Co-Authored-By: Claude Opus 5 (1M context) --- .../publish_matcher_projection.go | 23 ++++++++++++++----- .../publish_matcher_projection_test.go | 12 +++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/scripts/validate-eks-ci-role-policy/publish_matcher_projection.go b/scripts/validate-eks-ci-role-policy/publish_matcher_projection.go index e4346e63d9..538935e04d 100644 --- a/scripts/validate-eks-ci-role-policy/publish_matcher_projection.go +++ b/scripts/validate-eks-ci-role-policy/publish_matcher_projection.go @@ -5,7 +5,7 @@ import "strings" // publishMatcherSurfaceDocument recognizes only strict subsets of the shared // 40-hex signer pattern already covered by the authorization approval. The // required approved-revisions guard separately enforces each consumer's exact -// generated pair. Issuer, workflow, artifact, identity count and all other +// generated set. Issuer, workflow, artifact, identity count and all other // resource fields remain fingerprinted, so regeneration needs no hash refresh. func publishMatcherSurfaceDocument(identity resourceIdentity, document map[string]any) map[string]any { if identity.apiVersion != "source.toolkit.fluxcd.io/v1" || identity.kind != "OCIRepository" { @@ -42,7 +42,7 @@ func publishMatcherSurfaceDocument(identity resourceIdentity, document map[strin return document } ref := strings.TrimSuffix(strings.TrimPrefix(subject, prefix), "$") - if !isExactPublishRevisionSet(ref) { + if !isExactPublishRevisionSet(ref, maxPublishRevisions(workflow)) { return document } @@ -57,9 +57,20 @@ func publishMatcherSurfaceDocument(identity resourceIdentity, document map[strin return projected } -// isExactPublishRevisionSet accepts one concrete SHA or one parenthesized pair; -// regular expressions, floating refs, and additional alternatives stay exact. -func isExactPublishRevisionSet(ref string) bool { +// maxPublishRevisions is the largest approved set a workflow's consumers carry: the +// applied signer and the default-branch pin, plus the latest released actions revision +// for publish-app consumers (#3917). +func maxPublishRevisions(workflow string) int { + if workflow == "publish-app" { + return 3 + } + return 2 +} + +// isExactPublishRevisionSet accepts one concrete SHA or one parenthesized set of at most +// limit concrete SHAs; regular expressions, floating refs, and additional alternatives +// stay exact. +func isExactPublishRevisionSet(ref string, limit int) bool { if exactGitCommit.MatchString(ref) { return true } @@ -67,7 +78,7 @@ func isExactPublishRevisionSet(ref string) bool { return false } revisions := strings.Split(ref[1:len(ref)-1], "|") - if len(revisions) > 2 { + if len(revisions) > limit { return false } for _, revision := range revisions { diff --git a/scripts/validate-eks-ci-role-policy/publish_matcher_projection_test.go b/scripts/validate-eks-ci-role-policy/publish_matcher_projection_test.go index b860b4aba7..3430b7b840 100644 --- a/scripts/validate-eks-ci-role-policy/publish_matcher_projection_test.go +++ b/scripts/validate-eks-ci-role-policy/publish_matcher_projection_test.go @@ -56,11 +56,21 @@ spec: original := strings.ReplaceAll(manifest, "github-config", consumer.name) original = strings.ReplaceAll(original, "publish-manifests", consumer.workflow) baseline := entry(original) - for _, ref := range []string{a, "(" + a + ")", "(" + a + "|" + b + ")", "(" + b + "|" + c + ")"} { + refs := []string{a, "(" + a + ")", "(" + a + "|" + b + ")", "(" + b + "|" + c + ")"} + tooMany := "(" + a + "|" + b + "|" + c + ")" + if consumer.workflow == "publish-app" { + // An application tenant's set also carries the latest released revision (#3917). + refs = append(refs, "("+a+"|"+b+"|"+c+")") + tooMany = "(" + a + "|" + b + "|" + c + "|" + strings.Repeat("4", 40) + ")" + } + for _, ref := range refs { if entry(strings.Replace(original, "[0-9a-f]{40}", ref, 1)) != baseline { t.Errorf("exact signer subset %q moved the approval fingerprint", ref) } } + if entry(strings.Replace(original, "[0-9a-f]{40}", tooMany, 1)) == baseline { + t.Errorf("a set larger than %s consumers carry (%q) escaped the approval fingerprint", consumer.workflow, tooMany) + } }) } From 849b6bdcb3a79f6beeda97d768d04689499d3a8a Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 21 Sep 2026 19:33:17 +0200 Subject: [PATCH 3/3] docs(security): describe the narrowed app tenant matchers as they now are The comments still described the former any-commit matcher and its accepted residual risk; both subjects now accept only the approved revisions. Co-Authored-By: Claude Opus 5 (1M context) --- k8s/bases/apps/ascoachingogvaner/oci-repository.yaml | 8 ++++---- k8s/bases/apps/wedding-app/oci-repository.yaml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml b/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml index aa884a489d..0f13facd7b 100644 --- a/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml +++ b/k8s/bases/apps/ascoachingogvaner/oci-repository.yaml @@ -16,9 +16,9 @@ spec: verify: provider: cosign matchOIDCIdentity: - # Matches the issuer and the shared publish-app.yaml workflow path at a 40-hex - # commit: the commit shape, not a reviewed revision. Package write access, - # namespace, RBAC, network and admission policy bound the release; the remaining - # risk is accepted in devantler-tech/platform#3746. + # The subject is written from scripts/publish-workflow-approved-revisions.tsv by + # scripts/write-publish-workflow-matchers.sh. It accepts only the approved + # publish-app.yaml revisions: the deployed artifact signer, this consumer's + # current pin, and the latest actions release candidate. - issuer: '^https://token\.actions\.githubusercontent\.com$' subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(a147ead8b8bc937fbfb1974e3698c7e32f95517d|3c3a6348b3e7aacc995ae49fc60032f95f70a779|6f47d8ca360fa3058f57175d54fa148deac08983)$' diff --git a/k8s/bases/apps/wedding-app/oci-repository.yaml b/k8s/bases/apps/wedding-app/oci-repository.yaml index 2e2d9c2496..8ebd8feb4b 100644 --- a/k8s/bases/apps/wedding-app/oci-repository.yaml +++ b/k8s/bases/apps/wedding-app/oci-repository.yaml @@ -16,9 +16,9 @@ spec: verify: provider: cosign matchOIDCIdentity: - # Matches the issuer and the shared publish-app.yaml workflow path at a 40-hex - # commit: the commit shape, not a reviewed revision. Package write access, - # namespace, RBAC, network and admission policy bound the release; the remaining - # risk is accepted in devantler-tech/platform#3746. + # The subject is written from scripts/publish-workflow-approved-revisions.tsv by + # scripts/write-publish-workflow-matchers.sh. It accepts only the approved + # publish-app.yaml revisions: the deployed artifact signer, this consumer's + # current pin, and the latest actions release candidate. - issuer: '^https://token\.actions\.githubusercontent\.com$' subject: '^https://github\.com/devantler-tech/actions/\.github/workflows/publish-app\.yaml@(759ff9b2526cd1b142b49c5dc1616118a1ef44d9|3c3a6348b3e7aacc995ae49fc60032f95f70a779|6f47d8ca360fa3058f57175d54fa148deac08983)$'