diff --git a/scripts/guard-shared-publish-workflow-pin.sh b/scripts/guard-shared-publish-workflow-pin.sh index 5b53e8ebec..04d7c49c2b 100755 --- a/scripts/guard-shared-publish-workflow-pin.sh +++ b/scripts/guard-shared-publish-workflow-pin.sh @@ -52,6 +52,22 @@ # narrowing is derived from that report rather than from this guard or by hand (#3308). # Set MEMBERSHIP — that each per-consumer matcher names exactly the generated pair and no # revision outside it — is guard-publish-workflow-approved-revisions.sh's question (#3551). +# +# TWO PUBLISHER FAMILIES (#4502) +# A manifest matcher may accept the canonical manifest workflow in devantler-tech/.github +# beside the legacy one, as ONE subject holding one group: +# +# ^https://github\.com/devantler-tech/(actions/\.github/workflows/publish-\.yaml@|\.github/\.github/workflows/publish-\.yaml@)$ +# +# That subject carries two `@`, which the single-identity rule below exists to refuse, so +# it is read by its own strict parse instead of by loosening that rule: the exact prefix, +# the legacy family first, the canonical family second, each exactly once, and nothing +# else. The legacy ref is judged by the same allow-list as every other subject. The +# canonical ref must be ONE concrete 40-hex commit — the pattern form is not accepted +# there, because the canonical family is approved one reviewed commit at a time. A subject +# naming only the canonical family, or the two in any other arrangement, is refused: an +# arrangement nobody reviewed is not a spelling variant. Which commit is approved is still +# the approved-revisions guard's question, not this one's. set -euo pipefail @@ -64,7 +80,28 @@ readonly REPO_ROOT # OCIRepositories, `subjectRegex` on the Talos image-verification config, # `subjectRegExp` in Kyverno policies) — a fourth spelling appearing later should show # up as a MISSING match against the floor below, not be silently skipped. -readonly SUBJECT_PATTERN='(subject|subjectRegex|subjectRegExp):[[:space:]]*.?\^?https://github\\?\.com/devantler-tech/actions/\\?\.github/workflows/publish-(app|manifests)\\?\.yaml@' +# +# Either publisher family, with an optional group opener (#4502), so a two-family subject +# and a canonical-only one are FOUND and judged rather than left outside the scan. Kept +# textually parallel to FAMILY_SUBJECT_PATTERN in publish-workflow-approved-revisions.lib.sh. +readonly SUBJECT_PATTERN='(subject|subjectRegex|subjectRegExp):[[:space:]]*.?\^?https://github\\?\.com/devantler-tech/[(]?(actions|\\?\.github)/\\?\.github/workflows/publish-(app|manifests)\\?\.yaml@' + +# The workflow identity of either family with no key name, no organisation prefix and no +# line structure, for the independent discovery below. The canonical half of a two-family +# subject follows a `|`, not the organisation, so a pattern anchored on the organisation +# would not see a canonical reference written anywhere but first. +readonly DISCOVERY_PATTERN='(actions|\\?\.github)/\\?\.github/workflows/publish-(app|manifests)\\?\.yaml' + +# The exact text of a two-family subject around its two refs (#4502). These are compared +# literally, so the backslashes are the ones written in the manifest. +readonly FAMILY_PREFIX='^https://github\.com/devantler-tech/(' +readonly LEGACY_FAMILY='actions/\.github/workflows/publish-' +# The exact opening of a single-family subject, up to the workflow name. +readonly LEGACY_SUBJECT_PREFIX='^https://github\.com/devantler-tech/actions/\.github/workflows/publish-' +readonly CANONICAL_FAMILY='\.github/\.github/workflows/publish-' +# Only the manifest workflow has a canonical family today; the application workflow and +# the three generic subjects stay legacy-only. +readonly CANONICAL_WORKFLOW_NAME='manifests' # A floor, because an empty result from a filtered read is a claim about the FILTER. # If a refactor moves these subjects into a generator, a template, or a different key, @@ -173,6 +210,174 @@ yaml_scalar() { printf '%s' "$scalar" } +# judge_ref — the allow-list over one ref constraint. Returns non-zero, +# having said why, unless every alternative of the ref pins a fixed revision. +judge_ref() { + local location="$1" ref="$2" status=0 + # An ALLOW-LIST over each alternative, not a list of bad shapes to reject. + # + # Enumerating what is forbidden only ever catches the regressions someone thought + # of: rejecting `refs/heads/` and a bare `.+` still waves through `@main`, `@v1` + # or `@my-branch`, none of which pins a revision. Requiring each alternative to be + # positively recognisable inverts that — an unfamiliar shape fails, and adding a + # legitimately new one is a deliberate edit here rather than a silent widening. + # + # Exactly one form qualifies: a 40-hex commit (#3022). + # + # An alternation is therefore always a widening now, but it is still parsed + # per alternative rather than rejected wholesale on sight — that way the error + # names the offending alternative instead of the whole string, and a future + # legitimately-added form is a deliberate edit to the allow-list below rather + # than a change to this parsing. + # A grouped ref must be FULLY grouped. `(A|B)` is the shape this understands; + # `(A)?B` is not, and stripping a leading paren from it would silently hand the + # trailing `B` to the per-alternative check as part of A's text. Reject the + # shape here so an unparsed construct can never reach the allow-list below. + local alternatives alternative + case "$ref" in + '('*')') + alternatives="${ref#\(}" + alternatives="${alternatives%\)}" + ;; + '('* | *')') + printf '%s: ref %s is not a fully grouped alternation; this guard cannot prove it pins a revision\n' \ + "$location" "$ref" >&2 + return 1 + ;; + *'|'*) + # An alternation with no group around it is not an alternation over REFS. `@A|B$` + # reads as `^…@A` OR `B$`, and the second half is anchored to nothing before it — + # so `@|[0-9a-f]{40}$` accepts any identity at all that ends in a commit. + # Splitting it on `|` and finding two well-formed alternatives is what waved it + # through. Several refs must be written `(A|B)`. + printf '%s: ref %s is not a fully grouped alternation; this guard cannot prove it pins a revision\n' \ + "$location" "$ref" >&2 + return 1 + ;; + *) alternatives="$ref" ;; + esac + + while IFS= read -r alternative; do + # Match the alternative WHOLE (`-x`), never by prefix. + # + # A prefix match is what let `(refs/tags/v.+)?refs/heads/.+$` through when the + # tag form was still accepted: the group was optional and a branch ref followed + # it, yet the guard reported all eight subjects pinned. The commit form has no + # prefix hazard of its own, but the whole-line anchor is what guarantees that — + # `[0-9a-f]{40}` must be the entire alternative, so nothing can be appended to + # it. + # TWO recognisable forms, both of which pin (#3308). + # + # 1. The literal PATTERN text `[0-9a-f]{40}`. These subjects are cosign identity + # regexes, so this fragment accepts any 40-hex commit: a FIXED revision, but + # not an APPROVED one. + # 2. A CONCRETE 40-hex commit, which is what a generated approved-revision + # allow-list emits. It is strictly NARROWER than form 1 — it names one + # revision rather than the whole 40-hex space. + # + # Only form 1 is a regex fragment; form 2 is a literal identity. Both are judged + # by the same allow-list below, so adding a third recognisable shape is likewise a + # deliberate edit here rather than a silent widening (#3308). + # + # It is a WIDENING OF THE ALLOW-LIST, not a loosening of the property. Both forms + # keep the whole-line anchor (`-x`), so nothing may be appended to either: a short + # SHA, a tag, a branch, a bare ref and a partial group are all still rejected, and + # each keeps its own RED case in the test. Uppercase hex is deliberately NOT + # accepted — git emits lowercase, so an uppercase subject is an unreviewed shape + # rather than a spelling variant. + if printf '%s' "$alternative" | grep -qxE '\[0-9a-f\]\{40\}'; then + continue + fi + if printf '%s' "$alternative" | grep -qxE '[0-9a-f]{40}'; then + continue + fi + printf '%s: ref alternative %s does not pin a fixed revision (subject: %s)\n' \ + "$location" "$alternative" "$ref" >&2 + status=1 + done < <(printf '%s\n' "$alternatives" | tr '|' '\n') + return "$status" +} + +# judge_family_subject — the strict parse of a two-family subject +# (#4502). Returns non-zero, having said why, unless the subject is exactly the legacy +# family followed by the canonical family, the legacy ref passes the allow-list and the +# canonical ref is one concrete commit. +# +# The split is on the literal text that opens the canonical family, never on `|` or `@` +# alone: the legacy ref is itself an alternation, so both characters occur inside it. +judge_family_subject() { + local location="$1" subject="$2" inner boundary legacy_part canonical_part legacy_ref canonical_ref at_count + + case "$subject" in + "$FAMILY_PREFIX"*')$') ;; + *) + printf '%s: subject opens a publisher group but is not exactly %s|)$; this guard cannot prove an arrangement it does not recognise pins a revision (subject: %s)\n' \ + "$location" "$FAMILY_PREFIX" "$subject" >&2 + return 1 + ;; + esac + + at_count="$(printf '%s' "$subject" | tr -cd '@' | wc -c | tr -d ' ')" + if [ "$at_count" -ne 2 ]; then + printf '%s: two-family subject carries %s "@" separators where exactly two are expected, one per publisher family (subject: %s)\n' \ + "$location" "$at_count" "$subject" >&2 + return 1 + fi + + inner="${subject#"$FAMILY_PREFIX"}" + inner="${inner%')$'}" + boundary='|'"$CANONICAL_FAMILY" + case "$inner" in + "$LEGACY_FAMILY"*"$boundary"*) ;; + *) + printf '%s: two-family subject is not the legacy family followed by the canonical family (subject: %s)\n' \ + "$location" "$subject" >&2 + return 1 + ;; + esac + canonical_part="${inner#*"$boundary"}" # \.yaml@ + legacy_part="${inner%"$boundary$canonical_part"}" + legacy_part="${legacy_part#"$LEGACY_FAMILY"}" # \.yaml@ + case "$legacy_part$canonical_part" in + *"$LEGACY_FAMILY"* | *"$CANONICAL_FAMILY"*) + printf '%s: two-family subject names a publisher family more than once (subject: %s)\n' \ + "$location" "$subject" >&2 + return 1 + ;; + esac + + # Each side of the boundary ends up holding exactly one `@`, so neither ref can hide a + # further identity, without a check of its own: the subject carries two in all, the + # canonical side must be `\.yaml@` followed by 40 hex characters and nothing + # else, and the legacy side must open with `\.yaml@`. + canonical_ref="${canonical_part#"$CANONICAL_WORKFLOW_NAME"'\.yaml@'}" + if [ "$canonical_ref" = "$canonical_part" ]; then + printf '%s: the canonical family names a workflow other than publish-%s, the only one with a canonical publisher (subject: %s)\n' \ + "$location" "$CANONICAL_WORKFLOW_NAME" "$subject" >&2 + return 1 + fi + case "$legacy_part" in + "$CANONICAL_WORKFLOW_NAME"'\.yaml@'*) ;; + *) + printf '%s: the two publisher families name different workflows (subject: %s)\n' \ + "$location" "$subject" >&2 + return 1 + ;; + esac + + # ONE concrete commit, matched whole. The pattern text `[0-9a-f]{40}` is deliberately + # not accepted here: it would admit every commit of the canonical repository, and the + # canonical family is approved one reviewed commit at a time. + if ! printf '%s' "$canonical_ref" | grep -qxE '[0-9a-f]{40}'; then + printf '%s: canonical ref %s does not pin one concrete 40-hex commit (subject: %s)\n' \ + "$location" "$canonical_ref" "$subject" >&2 + return 1 + fi + + legacy_ref="${legacy_part#*@}" + judge_ref "$location" "$legacy_ref" +} + main() { cd "$REPO_ROOT" @@ -205,14 +410,13 @@ main() { # no line structure, so it finds a reference however it is written. Anything it # finds that the strict pattern did not validate is reported rather than skipped. local discovered_lines unvalidated - discovered_lines="$(grep -rlE 'devantler-tech/actions/\\?\.github/workflows/publish-(app|manifests)\\?\.yaml' \ - --include='*.yaml' . || true)" + discovered_lines="$(grep -rlE "$DISCOVERY_PATTERN" --include='*.yaml' . || true)" unvalidated="" local file discovered_count validated_count while IFS= read -r file; do [ -n "$file" ] || continue - discovered_count="$(grep -cE 'devantler-tech/actions/\\?\.github/workflows/publish-(app|manifests)\\?\.yaml' "$file" || true)" + discovered_count="$(grep -cE "$DISCOVERY_PATTERN" "$file" || true)" validated_count="$(grep -cE "$SUBJECT_PATTERN" "$file" || true)" if [ "$discovered_count" -gt "$validated_count" ]; then unvalidated="$unvalidated $file (references: $discovered_count, validated as subjects: $validated_count) @@ -299,6 +503,22 @@ EOF continue fi + # A TWO-FAMILY SUBJECT IS READ BY ITS OWN STRICT PARSE (#4502). It carries two `@` by + # design, so it is dispatched before the single-identity rule below rather than by + # relaxing that rule: every other subject is still held to exactly one. + case "$subject" in + *'devantler-tech/('*) + judge_family_subject "$location" "$subject" || status=1 + continue + ;; + *'devantler-tech/\.github/'* | *'devantler-tech/.github/'*) + printf '%s: subject names only the canonical publisher; the recognised forms are the legacy family alone or the legacy family followed by the canonical one (subject: %s)\n' \ + "$location" "$subject" >&2 + status=1 + continue + ;; + esac + # EXACTLY ONE `@`, checked BEFORE the ref is read. The ref constraint is everything # after the LAST @, so any alternative carrying its own `...@...` earlier in the scalar # is never examined. Writing the fixed-SHA alternative LAST makes the last-@ read land @@ -319,85 +539,29 @@ EOF continue fi - # Everything after the LAST @ in the scalar is the ref constraint; a trailing - # $ anchor and any closing quote are not part of it. - ref="${subject##*@}" - ref="${ref%\'}" - ref="${ref%\"}" - ref="${ref%$}" - - # An ALLOW-LIST over each alternative, not a list of bad shapes to reject. - # - # Enumerating what is forbidden only ever catches the regressions someone thought - # of: rejecting `refs/heads/` and a bare `.+` still waves through `@main`, `@v1` - # or `@my-branch`, none of which pins a revision. Requiring each alternative to be - # positively recognisable inverts that — an unfamiliar shape fails, and adding a - # legitimately new one is a deliberate edit here rather than a silent widening. - # - # Exactly one form qualifies: a 40-hex commit (#3022). - # - # An alternation is therefore always a widening now, but it is still parsed - # per alternative rather than rejected wholesale on sight — that way the error - # names the offending alternative instead of the whole string, and a future - # legitimately-added form is a deliberate edit to the allow-list below rather - # than a change to this parsing. - # A grouped ref must be FULLY grouped. `(A|B)` is the shape this understands; - # `(A)?B` is not, and stripping a leading paren from it would silently hand the - # trailing `B` to the per-alternative check as part of A's text. Reject the - # shape here so an unparsed construct can never reach the allow-list below. - local alternatives alternative - case "$ref" in - '('*')') - alternatives="${ref#\(}" - alternatives="${alternatives%\)}" - ;; - '('* | *')') - printf '%s: ref %s is not a fully grouped alternation; this guard cannot prove it pins a revision\n' \ - "$location" "$ref" >&2 + # THE WHOLE SCALAR, NOT JUST ITS REF. SUBJECT_PATTERN selects a LINE, and it matches + # anywhere on it — inside a comment, or after a `|` in the value. Judging only the text + # after the single `@` therefore validated subjects whose identity half was never the + # shared workflow at all: a value of `.*@[0-9a-f]{40}` followed by a comment naming the + # workflow was selected by the comment and passed on its ref. So the scalar itself must + # be exactly the anchored legacy identity, a ref, and the closing anchor. Compared + # literally: an unescaped dot or a character class in place of `\.` is a wider regex, + # not a spelling of this one. + case "$subject" in + "$LEGACY_SUBJECT_PREFIX"'app\.yaml@'*'$' | "$LEGACY_SUBJECT_PREFIX"'manifests\.yaml@'*'$') ;; + *) + printf '%s: subject is not exactly %s(app|manifests)\\.yaml@$, so the identity it accepts is not the one this line was selected for (subject: %s)\n' \ + "$location" "$LEGACY_SUBJECT_PREFIX" "$subject" >&2 status=1 continue ;; - *) alternatives="$ref" ;; esac - while IFS= read -r alternative; do - # Match the alternative WHOLE (`-x`), never by prefix. - # - # A prefix match is what let `(refs/tags/v.+)?refs/heads/.+$` through when the - # tag form was still accepted: the group was optional and a branch ref followed - # it, yet the guard reported all eight subjects pinned. The commit form has no - # prefix hazard of its own, but the whole-line anchor is what guarantees that — - # `[0-9a-f]{40}` must be the entire alternative, so nothing can be appended to - # it. - # TWO recognisable forms, both of which pin (#3308). - # - # 1. The literal PATTERN text `[0-9a-f]{40}`. These subjects are cosign identity - # regexes, so this fragment accepts any 40-hex commit: a FIXED revision, but - # not an APPROVED one. - # 2. A CONCRETE 40-hex commit, which is what a generated approved-revision - # allow-list emits. It is strictly NARROWER than form 1 — it names one - # revision rather than the whole 40-hex space. - # - # Only form 1 is a regex fragment; form 2 is a literal identity. Both are judged - # by the same allow-list below, so adding a third recognisable shape is likewise a - # deliberate edit here rather than a silent widening (#3308). - # - # It is a WIDENING OF THE ALLOW-LIST, not a loosening of the property. Both forms - # keep the whole-line anchor (`-x`), so nothing may be appended to either: a short - # SHA, a tag, a branch, a bare ref and a partial group are all still rejected, and - # each keeps its own RED case in the test. Uppercase hex is deliberately NOT - # accepted — git emits lowercase, so an uppercase subject is an unreviewed shape - # rather than a spelling variant. - if printf '%s' "$alternative" | grep -qxE '\[0-9a-f\]\{40\}'; then - continue - fi - if printf '%s' "$alternative" | grep -qxE '[0-9a-f]{40}'; then - continue - fi - printf '%s: ref alternative %s does not pin a fixed revision (subject: %s)\n' \ - "$location" "$alternative" "$ref" >&2 - status=1 - done < <(printf '%s\n' "$alternatives" | tr '|' '\n') + # Everything after the single @ is the ref constraint, less the closing anchor. + ref="${subject##*@}" + ref="${ref%$}" + + judge_ref "$location" "$ref" || status=1 done <|\.github/…@)$`. +# Without it a consumer that gains a canonical approval drops out of the scan, and the +# identity floor then reports a deployed consumer as missing. The legacy family stays the +# anchor on purpose: every answer this report gives is about the legacy signer, so a +# file whose subjects name ONLY the canonical family is not selected and its consumer +# fails the floor rather than being reported with a revision this script never looked up. +# Selection is per FILE: the pin guard, not this scan, is what refuses a canonical-only +# subject wherever it is written. +readonly SUBJECT_PATTERN='(subject|subjectRegex|subjectRegExp):[[:space:]]*.?\^?https://github\\?\.com/devantler-tech/[(]?actions/\\?\.github/workflows/publish-(app|manifests)\\?\.yaml@' # 🔴 AN IDENTITY FLOOR, NOT A COUNT. A count answers "did I find five things?", which is # not the question — "did I find THESE five?" is. With a bare count, one consumer moving diff --git a/scripts/tests/test-publish-workflow-signing-revisions.sh b/scripts/tests/test-publish-workflow-signing-revisions.sh index f5603ccefd..ae36e5eee8 100755 --- a/scripts/tests/test-publish-workflow-signing-revisions.sh +++ b/scripts/tests/test-publish-workflow-signing-revisions.sh @@ -1970,8 +1970,135 @@ for floor_ok in '>=2.0' '>=2.5.0'; do fi done +# --------------------------------------------------------------------------- +# 31. A TWO-PUBLISHER-FAMILY SUBJECT IS STILL THE SAME CONSUMER (#4502). A manifest +# consumer that gains a canonical approval is rendered as one subject holding the +# legacy family first and the canonical family second inside a group. The scan +# selected files by a pattern that had no group opener, so that consumer left the +# set and the floor reported a deployed consumer as missing — which would have failed +# every run from the first canonical approval onward. +# +# The report's answers are about the LEGACY signer, so the legacy family stays the +# anchor: a subject naming only the canonical family is deliberately not selected, +# and its consumer fails the floor by name rather than being reported with a revision +# nobody looked up. +# --------------------------------------------------------------------------- +write_subject_consumer() { # + cat >"$1" <=1.0.0" + url: oci://ghcr.io/devantler-tech/$3 + verify: + provider: cosign + matchOIDCIdentity: + - issuer: '^https://token\\.actions\\.githubusercontent\\.com\$' + subject: '$4' +YAML +} +readonly FAMILY_LEGACY='actions/\.github/workflows/publish-manifests\.yaml@[0-9a-f]{40}' +readonly FAMILY_CANONICAL='\.github/\.github/workflows/publish-manifests\.yaml@'"$SHA_C" +# write_family_consumers — the registered consumers, with every +# publish-manifests one carrying the supplied subject and the rest left legacy-only. +write_family_consumers() { + local root="$1" subject="$2" repo workflow _version _artifact oci n=0 + mkdir -p "$root" + while IFS=$'\t' read -r repo workflow _version _artifact; do + [ -n "$repo" ] || continue + n=$((n + 1)) + oci="$repo" + [ "$repo" = '.github' ] && oci='github-config' + if [ "$workflow" = 'publish-manifests' ]; then + write_subject_consumer "$root/real-$n.yaml" "r$n" "$oci/manifests" "$subject" + else + write_consumer "$root/real-$n.yaml" "r$n" "$oci/manifests" "$workflow" + fi + done <<<"$consumers" +} +manifest_consumers="$(printf '%s\n' "$consumers" | awk -F'\t' '$2 == "publish-manifests" { print $1 }')" +if [ -z "$manifest_consumers" ]; then + fail 'no publish-manifests consumer is registered; the two-family cases below would pass vacuously' +fi + +# 31a. The two-family subject yields exactly the rows the legacy subject does, and the +# report resolves every consumer. +legacy_root="$WORK/family-legacy" +write_real_consumers "$legacy_root" manifests +family_root="$WORK/family" +write_family_consumers "$family_root" \ + '^https://github\.com/devantler-tech/('"$FAMILY_LEGACY"'|'"$FAMILY_CANONICAL"')$' +legacy_list="$(PUBLISH_CONSUMER_ROOT="$legacy_root" "$SCRIPT" --list-consumers 2>&1 || true)" +family_list="$(PUBLISH_CONSUMER_ROOT="$family_root" "$SCRIPT" --list-consumers 2>&1 || true)" +if [ -n "$legacy_list" ] && [ "$family_list" = "$legacy_list" ]; then + pass 'a two-family subject is discovered as the same consumer, with the same row' +else + fail "a two-family subject changed consumer discovery: $family_list" +fi +family_out="$WORK/family.out" +if PUBLISH_REVISION_RESOLVER="$(make_stub "$agree_table" agree)" \ +PUBLISH_CONSUMER_ROOT="$family_root" "$SCRIPT" >"$family_out" 2>&1; then + family_insync="$(grep -c '^IN-SYNC' "$family_out" || true)" + [ "$family_insync" -eq "$consumer_count" ] || + fail "two-family consumers were not all resolved: expected $consumer_count IN-SYNC, got $family_insync" + pass 'the report resolves every consumer on a two-family tree' +else + fail "the report failed on a two-family tree: $(cat "$family_out")" +fi + +# 31b. A subject naming ONLY the canonical family is not a consumer this report can answer +# for: it fails the floor and names each one, instead of dropping it silently. +canonical_only_root="$WORK/family-canonical-only" +write_family_consumers "$canonical_only_root" \ + '^https://github\.com/devantler-tech/'"$FAMILY_CANONICAL"'$' +canonical_only_out="$WORK/family-canonical-only.out" +if PUBLISH_REVISION_RESOLVER="$(make_stub "$agree_table" agree)" \ +PUBLISH_CONSUMER_ROOT="$canonical_only_root" "$SCRIPT" >"$canonical_only_out" 2>&1; then + fail 'a consumer accepting only the canonical publisher was reported as if its legacy signer had been read' +else + while IFS= read -r repository; do + [ -n "$repository" ] || continue + grep 'not discovered' "$canonical_only_out" | grep -qF -- " $repository" || + fail "the canonical-only consumer $repository is not named as missing" + done <<<"$manifest_consumers" + pass 'a canonical-only subject fails the floor and names the consumer' +fi + +# 31c. The canonical family written FIRST is not the reviewed arrangement, so it is not +# selected either: the legacy family is the anchor, wherever else it appears. +reversed_root="$WORK/family-reversed" +write_family_consumers "$reversed_root" \ + '^https://github\.com/devantler-tech/('"$FAMILY_CANONICAL"'|'"$FAMILY_LEGACY"')$' +reversed_out="$WORK/family-reversed.out" +if PUBLISH_REVISION_RESOLVER="$(make_stub "$agree_table" agree)" \ +PUBLISH_CONSUMER_ROOT="$reversed_root" "$SCRIPT" >"$reversed_out" 2>&1; then + fail 'a two-family subject with the canonical family first was accepted' +else + grep -q 'not discovered' "$reversed_out" || + fail "the reversed two-family subject failed for another reason: $(head -3 "$reversed_out")" + pass 'a two-family subject with the canonical family first is not selected' +fi + +# 31d. Two families naming DIFFERENT workflows leave the attribution ambiguous, and the +# existing refusal still fires on the new form. +mixed_root="$WORK/family-mixed" +write_family_consumers "$mixed_root" \ + '^https://github\.com/devantler-tech/('"$FAMILY_LEGACY"'|\.github/\.github/workflows/publish-app\.yaml@'"$SHA_C"')$' +mixed_out="$WORK/family-mixed.out" +if PUBLISH_REVISION_RESOLVER="$(make_stub "$agree_table" agree)" \ +PUBLISH_CONSUMER_ROOT="$mixed_root" "$SCRIPT" >"$mixed_out" 2>&1; then + fail 'a two-family subject naming two different workflows was attributed to one of them' +else + grep -q 'names more than one shared publish workflow' "$mixed_out" || + fail "the mixed-workflow subject failed for another reason: $(head -3 "$mixed_out")" + pass 'a two-family subject naming two workflows is refused as ambiguous' +fi + if [ "$failures" -ne 0 ]; then printf '\n%d failure(s)\n' "$failures" >&2 exit 1 fi -printf '\nPASS: publish-workflow signing-revision report (30 groups)\n' +printf '\nPASS: publish-workflow signing-revision report (31 groups)\n' diff --git a/scripts/tests/test-shared-publish-workflow-pin-guard.sh b/scripts/tests/test-shared-publish-workflow-pin-guard.sh index 4dba9c7aed..536a7d1a86 100755 --- a/scripts/tests/test-shared-publish-workflow-pin-guard.sh +++ b/scripts/tests/test-shared-publish-workflow-pin-guard.sh @@ -396,6 +396,223 @@ assert_line_accepted "a comment opening immediately after the closing quote" \ " subject: ${Q}${SUBJECT_ID}${SHA_PATTERN}\$${Q}# pinned by #2816" \ quotednospacecomment +# --- RED/GREEN: the two-publisher-family subject (#4502) ---------------------- +# +# A manifest matcher may accept the canonical manifest workflow beside the legacy one, +# as ONE subject holding one group. That subject carries two `@` by design, which is +# exactly what the single-identity rule above refuses, so the guard reads it with its own +# strict parse. The GREEN cases prove the reviewed arrangement passes; each RED case +# isolates one way a second family could let a moving ref, a second identity or an +# unreviewed arrangement through, and asserts the refusal names that cause — the floor +# and the single-identity rule both fire on a mis-escaped fixture, so an exit status +# alone would prove nothing. + +readonly FAMILY_OPEN='^https://github\.com/devantler-tech/(' +readonly LEGACY_ID='actions/\.github/workflows/publish-manifests\.yaml@' +readonly CANONICAL_ID='\.github/\.github/workflows/publish-manifests\.yaml@' + +# family_line — a single-quoted two-family subject line. +family_line() { + printf ' subject: %s%s%s%s|%s%s)$%s' \ + "${Q}" "${FAMILY_OPEN}" "${LEGACY_ID}" "$1" "${CANONICAL_ID}" "$2" "${Q}" +} + +assert_line_accepted "a two-family subject with one concrete legacy commit" \ + "$(family_line "${CONCRETE_SHA}" "${CONCRETE_SHA_2}")" \ + family-single + +assert_line_accepted "a two-family subject whose legacy ref is an approved set" \ + "$(family_line "(${CONCRETE_SHA}|${CONCRETE_SHA_2})" "${CONCRETE_SHA_2}")" \ + family-set + +assert_line_accepted "a two-family subject whose legacy ref is the pattern form" \ + "$(family_line "${SHA_PATTERN}" "${CONCRETE_SHA}")" \ + family-pattern + +assert_line_accepted "a two-family subject written as a plain scalar" \ + " subject: ${FAMILY_OPEN}${LEGACY_ID}${CONCRETE_SHA}|${CANONICAL_ID}${CONCRETE_SHA_2})\$" \ + family-plain + +# The canonical family is approved one reviewed commit at a time, so the pattern form — +# accepted for the legacy family — is refused here: it would admit every commit of the +# canonical repository. +assert_line_rejected "a canonical ref in the pattern form" \ + "$(family_line "${CONCRETE_SHA}" "${SHA_PATTERN}")" \ + family-canonical-pattern 'does not pin one concrete 40-hex commit' + +assert_line_rejected "a canonical ref naming a branch" \ + "$(family_line "${CONCRETE_SHA}" 'refs/heads/main')" \ + family-canonical-branch 'does not pin one concrete 40-hex commit' + +assert_line_rejected "a canonical ref naming a bare moving ref" \ + "$(family_line "${CONCRETE_SHA}" 'main')" \ + family-canonical-bare 'does not pin one concrete 40-hex commit' + +assert_line_rejected "a canonical ref that is a wildcard" \ + "$(family_line "${CONCRETE_SHA}" '.+')" \ + family-canonical-wildcard 'does not pin one concrete 40-hex commit' + +assert_line_rejected "a canonical ref that is an alternation of two commits" \ + "$(family_line "${CONCRETE_SHA}" "(${CONCRETE_SHA}|${CONCRETE_SHA_2})")" \ + family-canonical-set 'does not pin one concrete 40-hex commit' + +assert_line_rejected "a short canonical commit" \ + "$(family_line "${CONCRETE_SHA}" '0123456')" \ + family-canonical-short 'does not pin one concrete 40-hex commit' + +assert_line_rejected "an uppercase canonical commit" \ + "$(family_line "${CONCRETE_SHA}" "$(printf '%s' "${CONCRETE_SHA}" | tr 'a-f' 'A-F')")" \ + family-canonical-upper 'does not pin one concrete 40-hex commit' + +assert_line_rejected "a canonical commit with a wildcard appended" \ + "$(family_line "${CONCRETE_SHA}" "${CONCRETE_SHA_2}.*")" \ + family-canonical-suffix 'does not pin one concrete 40-hex commit' + +# A third alternative appended INSIDE the group keeps the `)$` ending and both `@`, so +# only reading the canonical ref whole catches it. +assert_line_rejected "a third alternative appended inside the group" \ + "$(family_line "${CONCRETE_SHA}" "${CONCRETE_SHA_2})|(.*")" \ + family-third-alternative 'does not pin one concrete 40-hex commit' + +# The legacy family keeps the allow-list it has outside a group. +assert_line_rejected "a two-family subject whose legacy ref is a branch" \ + "$(family_line 'refs/heads/main' "${CONCRETE_SHA}")" \ + family-legacy-branch 'does not pin a fixed revision' + +assert_line_rejected "a two-family subject whose legacy ref is a wildcard" \ + "$(family_line '.+' "${CONCRETE_SHA}")" \ + family-legacy-wildcard 'does not pin a fixed revision' + +assert_line_rejected "a two-family subject whose legacy set carries a branch" \ + "$(family_line "(${CONCRETE_SHA}|refs/heads/main)" "${CONCRETE_SHA_2}")" \ + family-legacy-setbranch 'does not pin a fixed revision' + +assert_line_rejected "a two-family subject whose legacy ref is partially grouped" \ + "$(family_line "(${CONCRETE_SHA})?.+" "${CONCRETE_SHA_2}")" \ + family-legacy-partial 'not a fully grouped' + +# Arrangements nobody reviewed. Each still names only pinned commits, which is the point: +# the refusal is about the arrangement, not about a ref the allow-list would catch anyway. +assert_line_rejected "the canonical family written first" \ + " subject: ${Q}${FAMILY_OPEN}${CANONICAL_ID}${CONCRETE_SHA}|${LEGACY_ID}${CONCRETE_SHA_2})\$${Q}" \ + family-reversed 'not the legacy family followed by the canonical family' + +assert_line_rejected "a group holding the legacy family twice" \ + " subject: ${Q}${FAMILY_OPEN}${LEGACY_ID}${CONCRETE_SHA}|${LEGACY_ID}${CONCRETE_SHA_2})\$${Q}" \ + family-legacy-twice 'not the legacy family followed by the canonical family' + +assert_line_rejected "a group naming the canonical family twice" \ + "$(family_line "${CONCRETE_SHA}" "${CONCRETE_SHA_2}|${CANONICAL_ID}${CONCRETE_SHA}")" \ + family-canonical-twice 'where exactly two are expected' + +assert_line_rejected "a canonical family repeated without a second ref" \ + " subject: ${Q}${FAMILY_OPEN}${LEGACY_ID}${CONCRETE_SHA}|\\.github/\\.github/workflows/publish-${CANONICAL_ID}${CONCRETE_SHA_2})\$${Q}" \ + family-canonical-nested 'names a publisher family more than once' + +assert_line_rejected "a subject naming only the canonical publisher" \ + " subject: ${Q}^https://github\\.com/devantler-tech/${CANONICAL_ID}${CONCRETE_SHA}\$${Q}" \ + family-canonical-only 'names only the canonical publisher' + +assert_line_rejected "a group that is not closed and anchored" \ + " subject: ${Q}${FAMILY_OPEN}${LEGACY_ID}${CONCRETE_SHA}|${CANONICAL_ID}${CONCRETE_SHA_2})${Q}" \ + family-unanchored-end 'opens a publisher group but is not exactly' + +assert_line_rejected "a group followed by a further alternative" \ + " subject: ${Q}${FAMILY_OPEN}${LEGACY_ID}${CONCRETE_SHA}|${CANONICAL_ID}${CONCRETE_SHA_2})\$|.*${Q}" \ + family-trailing-alternative 'opens a publisher group but is not exactly' + +assert_line_rejected "a group that is not anchored at its start" \ + " subject: ${Q}https://github\\.com/devantler-tech/(${LEGACY_ID}${CONCRETE_SHA}|${CANONICAL_ID}${CONCRETE_SHA_2})\$${Q}" \ + family-unanchored-start 'opens a publisher group but is not exactly' + +assert_line_rejected "a two-family subject with unescaped dots" \ + " subject: ${Q}^https://github.com/devantler-tech/(actions/.github/workflows/publish-manifests.yaml@${CONCRETE_SHA}|.github/.github/workflows/publish-manifests.yaml@${CONCRETE_SHA_2})\$${Q}" \ + family-bare-dots 'opens a publisher group but is not exactly' + +# Only the manifest workflow has a canonical publisher. +assert_line_rejected "a two-family subject for the application workflow" \ + " subject: ${Q}${FAMILY_OPEN}actions/\\.github/workflows/publish-app\\.yaml@${CONCRETE_SHA}|\\.github/\\.github/workflows/publish-app\\.yaml@${CONCRETE_SHA_2})\$${Q}" \ + family-app 'names a workflow other than publish-manifests' + +assert_line_rejected "two families naming different workflows" \ + " subject: ${Q}${FAMILY_OPEN}actions/\\.github/workflows/publish-app\\.yaml@${CONCRETE_SHA}|${CANONICAL_ID}${CONCRETE_SHA_2})\$${Q}" \ + family-mixed-workflows 'name different workflows' + +# Discovery is independent of the subject pattern, and it has to see the canonical family +# too: a canonical reference under a key the guard does not validate is reported, not +# skipped, exactly as a legacy one is. +tree="${work_dir}/red-family-undiscovered" +build_tree "${tree}" "${SHA_PATTERN}" +printf 'spec:\n verify:\n matchOIDCIdentity:\n - issuer: x\n identity: '\''^https://github\\.com/devantler-tech/\\.github/\\.github/workflows/publish-manifests\\.yaml@.+$'\''\n' \ + >"${tree}/k8s/canonical-other-key.yaml" +if run_tree "${tree}"; then + fail "guard ACCEPTED a canonical-publisher reference it never validated" +fi +grep -q 'did not validate' "${tree}/stderr" || + fail "guard rejected an unvalidated canonical reference but not for the discovery reason: $(head -1 "${tree}/stderr")" +ok "reports a canonical-publisher reference under a key it does not validate" + +# --- RED: an alternation over refs must be grouped ------------------------------ +# +# `@A|B$` is not two refs: it reads as `^…@A` OR `B$`, and the second half is anchored to +# nothing before it. Splitting the ref on `|` found two well-formed alternatives and +# accepted a subject whose second half matches ANY identity ending in a commit. + +assert_rejected_grouping() { #