diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b73165..254ac13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,12 +84,15 @@ repository still gets a decision, never by following the link; no release carrie - :memo: docs(changelog): stamp the v0.3.0 section after tagging - :memo: docs(spec): decompose the 2026-08-18 audit Next wave as epic P5-AUD2 (D-152) - :memo: docs(spec): AUD2-S02 names resolveRunFacts, not the nonexistent loadProviderHosts +- :memo: docs(openspec): amend AUD2-S03 to the shipped pin and log D-153 (SEC-03) ### Fixes - :bug: docs(decisions): reference the AUD2 spec as a path, not an mkdocs link ### Security - :lock: fix(cmd): discriminate absent provider declaration from forge failure (REL-03) +- :lock: fix(release): pin cosign signer identity and issuer in install.sh (SEC-03) +- :lock: fix(release): widen the cosign identity pin to the real signer casing (SEC-03) ### Testing - :white_check_mark: test(cmd): assert the REL-03 error wrap as one contiguous substring diff --git a/SECURITY.md b/SECURITY.md index 2d0d8d6..0598a46 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -78,17 +78,21 @@ cd dist-verify sha256sum -c checksums.txt # 2) Cosign — per-archive bundles (keyless, GitHub Actions OIDC issuer) +# The identity pin is a REGEXP matched case-sensitively: the repository was +# renamed to PlatformRelay/Assent between v0.1.0 and v0.2.0, so v0.1.0's +# certificate says `assent` and v0.2.0+ say `Assent`. `[Aa]` covers both; the +# dots are escaped and the org/repo stays anchored, so a mirror still fails. ARCHIVE=assent_X.Y.Z_linux_amd64.tar.gz # adjust OS/arch cosign verify-blob \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-identity-regexp '^https://github.com/PlatformRelay/assent/' \ + --certificate-identity-regexp '^https://github\.com/PlatformRelay/[Aa]ssent/' \ --bundle "${ARCHIVE}.sigstore.json" \ "${ARCHIVE}" # Cosign — checksum manifest (covers archives + SBOMs listed inside) cosign verify-blob \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --certificate-identity-regexp '^https://github.com/PlatformRelay/assent/' \ + --certificate-identity-regexp '^https://github\.com/PlatformRelay/[Aa]ssent/' \ --bundle checksums.txt.sigstore.json \ checksums.txt diff --git a/Taskfile.yml b/Taskfile.yml index f077315..03dd465 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -102,6 +102,11 @@ tasks: # which is skipped on pull_request — without this a regression merges green and only # reds main afterwards. Fast and offline (stubbed `gh`); needs jq. - task: release-verify-tag-gate-test + # AUD2-S03 (SEC-03): hack/install.sh's cosign signer pin. Offline (stubbed + # `cosign`), and the only gate that reads hack/install.sh's verify path — + # without this line an unpinned `cosign verify-blob` ships silently again, + # and SECURITY.md's published issuer/identity pair can drift away from it. + - task: release-install-cosign-pin-test # D-124: the AUD-S06 docs truth-lag gates shipped green and invoked by nothing, # which is the same manual-gate defect they exist to close. Sequential (not a # `deps:` entry) — go-task runs deps in parallel and readme_smoke_test.sh builds @@ -257,6 +262,11 @@ tasks: cmds: - bash hack/release/verify_tag_gate_test.sh + release-install-cosign-pin-test: + desc: "AUD2-S03 gate: hack/install.sh pins the cosign signer identity + OIDC issuer, no drift from SECURITY.md (REQ-AUD2-S03-01..05)" + cmds: + - bash hack/release/install_cosign_pin_test.sh + audit-exitgate-test: desc: "AUD-S18 exit gate: 2026-08-06 audit conditions closed + gates green at the new bar (REQ-AUD-S18-01/02). NOT in `check` — it runs `task check` itself." cmds: diff --git a/docs/decisions/decisions.md b/docs/decisions/decisions.md index d7d381e..b1d7e71 100644 --- a/docs/decisions/decisions.md +++ b/docs/decisions/decisions.md @@ -157,3 +157,4 @@ project/process decisions. | D-150 | 2026-08-16 | **SONAR-GO-CX-TEST is RESOLVED: adopt a checked-in `sonar-project.properties` excluding `**/*_test.go` from the S3776 cognitive-complexity rule, accepting the loss of SonarCloud Automatic Analysis.** 66 CODE_SMELL issues, concentrated in 34 table-driven Go test files, against a Quality Gate that is otherwise OK (0 bugs/vulns/hotspots) — high cognitive complexity is the idiom in table-driven Go tests, not a defect. Chosen over the UI-only Won't-Fix route (keeps Automatic Analysis, but the suppression is not reviewable in-tree and does not survive a project re-import) and over refactoring the 34 files (churn on passing tests for a non-gating metric). Accepted cost: a scanner step must be added to CI and maintained going forward — Automatic Analysis stops once a `sonar-project.properties` is present. Revert: delete the properties file to restore Automatic Analysis; the 66 smells return. | | D-151 | 2026-08-16 | **OpenSSF Best Practices project creation is RESOLVED: (a) — the operator will create the `bestpractices.dev` project for `PlatformRelay/assent`.** Unblocks SEC-SC-S02 (evidence page + README badge, once *passing*); SEC-SC-S01 (fuzzing) is not blocked by this and can proceed independently. Operator action only — requires a personal account at bestpractices.dev, not automatable in-tree. Current CII score is 0, which drags the OpenSSF Scorecard (~7); the repo already satisfies most criteria (pinned CodeQL, Dependabot, secret scanning, signed releases, SLSA provenance). Revert: leave CII at 0 indefinitely — SEC-SC-S01 is unaffected either way. | | D-152 | 2026-08-18 | **The 2026-08-18 audit's "Next (risk reduction)" wave is decomposed as its own epic P5-AUD2 (`openspec/specs/p5-aud2-audit-remediation/spec.md`), five stories, spec-first.** Context: `agent-context/PROJECT-AUDIT-2026-08-18.md` closed both P1 conditions the same day and v0.3.0 shipped, leaving a named but untracked wave — exec-transport trio (REL-01/02/07), REL-03 `ErrNotFound` discrimination, SEC-03 cosign identity pin, TEST-02 mutant. Options considered: **(a)** hand-fix them as loose commits (rejected — AGENTS.md rule 4 is spec-first, and REL-01 is now byte-identical across *three* audits precisely because nothing ever tracked it); **(b)** append them to the existing P5-AUD epic (rejected — that epic's exit gate is CLOSED and reopening it would make "AUD complete" a moving claim); **(c)** a new AUD2 epic keyed to this audit **(chosen)**. Three consequences recorded here so they are not re-litigated: **(1)** AUD2 has **no release-condition story** — the audit had exactly two P1s and both are already closed; **(2)** **WG-S01 is deliberately excluded** — it carries the LGTM governance marker, and GOVERNANCE says such stories are surfaced to the maintainer, which an autonomous loop's decide-and-log does not override; **(3)** AUD2's exit gate is a **`task check` stage**, not a `release-exitgate` step, because that job is `pull_request`-skipped (RELSE-08) and wiring the gate there is how AUD-S18's own stale `CHECK_STAGES` pin survived four merges. A 2026-08-10-keyed AUD2 draft exists only in the local stash `leave-aud2-not-this-epic`, was never committed, and is superseded; its still-open items (F3/F5/F7) stay Later-wave. Revert: delete the spec + backlog section; the four findings return to the audit report untracked. | +| D-153 | 2026-08-19 | **The cosign signer-identity pin published in `SECURITY.md` was WRONG, not merely missing from `hack/install.sh`: the identity regexp is widened to `^https://github\.com/PlatformRelay/[Aa]ssent/` in both files (AUD2-S03 / SEC-03).** Substance first: `SECURITY.md`'s "Verify a tagged release" instructions pinned `--certificate-identity-regexp '^https://github.com/PlatformRelay/assent/'`, and an adopter who followed them on **v0.2.0 or v0.3.0 got a verification FAILURE on a genuine, correctly signed artifact** — the published recipe has been broken for every release since v0.2.0, and the natural reading of that failure is "this release was tampered with". Cause: the repository was renamed `PlatformRelay/assent` → `PlatformRelay/Assent` between v0.1.0 and v0.2.0; the keyless signing certificate's Subject Alternative Name carries GitHub's canonical casing; cosign compiles `--certificate-identity-regexp` as a Go RE2 pattern and matches it **case-sensitively**. Decoded from the published bundles: v0.3.0 and v0.2.0 sign as `https://github.com/PlatformRelay/Assent/.github/workflows/release.yaml@refs/tags/vX.Y.Z`, v0.1.0 as `https://github.com/PlatformRelay/assent/.github/workflows/release.yaml@refs/heads/main`. All three verify under the new value; independently re-confirmed with real cosign against the real v0.3.0 artifact (`Verified OK` under the new pin, exit 1 "expected SAN value to match regex" under the old). Options considered: **(a)** ship the pin byte-identical to the published-but-broken value and file the breakage as a follow-up (rejected — it would land a green gate asserting a guarantee that fails closed on this project's own releases, D-124's defect one level up); **(b)** case-insensitive `(?i)` (rejected — wider than the defect and it silently accepts casings GitHub never issues); **(c)** an explicit `[Aa]` class in both files, dots escaped (chosen). Not widened otherwise: the `^` anchor and the owner/repo scope stand, so another owner, an `assent-mirror` typosquat, another forge and an unescaped-dot host all still fail. Enforcement, per D-128: **one** published truth — `hack/release/install_cosign_pin_test.sh` extracts the pair from `hack/install.sh` and `SECURITY.md`, requires exactly one distinct value per file, reddens on drift, and (this is the assertion that would have caught the defect) matches the pin against the three **real** SANs above, committed as offline fixtures, plus six negatives. Extends D-110 (bundles beside archives) and D-109; the maintainer-path twin at `hack/release/verify-artifacts.sh:124` is still unpinned and is tracked in the backlog, not fixed here. Revert: restore `'^https://github.com/PlatformRelay/assent/'` in both files — which re-breaks verification of v0.2.0 and every later release, so revert only together with a repo rename back to lowercase. | diff --git a/hack/audit/exitgate_test.sh b/hack/audit/exitgate_test.sh index a97519b..3bdde4b 100644 --- a/hack/audit/exitgate_test.sh +++ b/hack/audit/exitgate_test.sh @@ -139,6 +139,7 @@ CHECK_STAGES=( changelog-verify release-changelog-gate-test release-verify-tag-gate-test + release-install-cosign-pin-test docs-gates lint-depguard-test lint-workflow-pins-test diff --git a/hack/install.sh b/hack/install.sh index 5707301..7927cf1 100755 --- a/hack/install.sh +++ b/hack/install.sh @@ -111,7 +111,23 @@ done if [[ -n "$bundle" ]]; then command -v cosign >/dev/null 2>&1 || die "cosign required to verify ${bundle} but not found on PATH" - cosign verify-blob --bundle "$bundle" "$ARCHIVE" >/dev/null \ + # SEC-03 / REQ-AUD2-S03-01: pin the signer. Keyless verify-blob without an + # identity pin accepts ANY Fulcio certificate, so a mirror-swapped archive + # shipped with its own validly signed bundle would verify clean. The issuer and + # identity regexp below are byte-identical to the pair SECURITY.md publishes; + # hack/release/install_cosign_pin_test.sh reddens if the two ever drift apart. + # + # The [Aa] class is not cosmetic: the repository was renamed to PlatformRelay/ + # Assent between v0.1.0 and v0.2.0, and the Fulcio SAN carries GitHub's + # canonical casing — v0.1.0's certificate says `assent`, v0.2.0/v0.3.0's say + # `Assent`. cosign matches this regexp case-SENSITIVELY, so a lowercase-only + # pin rejects the project's own current releases. The dots are escaped because + # this is a regexp, not a literal. The pin is still anchored at the org/repo: + # any other owner, or an `assent-mirror`-style typosquat, fails. + cosign verify-blob \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp '^https://github\.com/PlatformRelay/[Aa]ssent/' \ + --bundle "$bundle" "$ARCHIVE" >/dev/null \ || die "cosign verification failed for ${ARCHIVE}" elif [[ "$REQUIRE_SIGNATURE" -eq 1 ]]; then die "--require-signature set but no .sigstore.json bundle found beside ${ARCHIVE}" diff --git a/hack/release/install_cosign_pin_test.sh b/hack/release/install_cosign_pin_test.sh new file mode 100755 index 0000000..c142e00 --- /dev/null +++ b/hack/release/install_cosign_pin_test.sh @@ -0,0 +1,466 @@ +#!/usr/bin/env bash +# REQ-AUD2-S03-01..05 — SEC-03: hack/install.sh pins the cosign signer identity +# and OIDC issuer, and that pin is a real, load-bearing guarantee. +# +# Finding closed here (agent-context/PROJECT-AUDIT-2026-08-18.md, SEC-03): +# `cosign verify-blob --bundle "$bundle" "$ARCHIVE"` carried NO +# --certificate-identity-regexp / --certificate-oidc-issuer, while SECURITY.md +# publishes both in its manual copy-paste instructions. Keyless verification +# without an identity pin either errors out (cosign v2 requires the flags) or +# accepts ANY Fulcio identity — so a mirror-swapped archive shipped with its +# own validly signed bundle verifies clean and `--require-signature` promises +# something it does not deliver. +# +# ONE PUBLISHED TRUTH. The issuer/identity pair is published in SECURITY.md; +# hack/install.sh copies it byte-identically. Section 3 below is a drift gate: +# if either file is edited alone, this script reddens (REQ-AUD2-S03-04). +# +# REAL-IDENTITY FIXTURES (section 4c) — the assertion that matters most. The +# first version of this pin copied SECURITY.md's published value faithfully and +# was still WRONG: the repo was renamed PlatformRelay/assent -> PlatformRelay/ +# Assent between v0.1.0 and v0.2.0, cosign matches the identity regexp +# case-sensitively, and so the published lowercase pin rejected the project's +# own v0.2.0/v0.3.0 artifacts. Self-made fixtures cannot catch that, so the SANs +# decoded from the REAL published bundles are committed here as test data and +# every one of them must verify. +# +# ANTI-VACUITY DISCIPLINE (this repo has a documented history of gates that +# cannot fail — D-124, AUD-S18). Every assertion here is a FUNCTION over a file +# or a fixture, run twice: once against the real tree (must be GREEN) and once +# against a mutant carrying the very defect it exists to catch (must be RED). +# Every extraction is positive-controlled (non-empty, exactly one distinct +# value) so a pattern that silently stopped matching fails loudly instead of +# passing vacuously. +# +# OFFLINE. No network, no real Fulcio, no real cosign: section 4 puts a stub +# `cosign` first on PATH that parses the flags it was handed and matches them +# against a certificate identity carved into a fake .sigstore.json bundle. The +# stub models the PERMISSIVE pre-fix behaviour (no --certificate-identity-regexp +# => accept any identity), which is the dangerous branch of the finding: it lets +# section 5c show that deleting the flag from install.sh installs a +# foreign-signed archive, i.e. that the pin is what closes the hole. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT" + +INSTALL="$ROOT/hack/install.sh" +SECURITY="$ROOT/SECURITY.md" +TASKFILE="$ROOT/Taskfile.yml" +AUDIT_GATE="$ROOT/hack/audit/exitgate_test.sh" +STAGE="release-install-cosign-pin-test" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +# --------------------------------------------------------------- helpers -- + +# cosign_invocation — the `cosign verify-blob …` command with its +# backslash continuations folded onto one line. Comment lines are skipped so a +# flag mentioned only in prose can never satisfy an assertion. +cosign_invocation() { + awk ' + /^[[:space:]]*#/ { next } + !inv && /cosign verify-blob/ { inv = 1 } + inv { + line = $0 + sub(/^[[:space:]]+/, "", line) + sub(/[[:space:]]*\\$/, "", line) + printf "%s ", line + if ($0 !~ /\\[[:space:]]*$/) { printf "\n"; inv = 0 } + } + ' "$1" +} + +# extract_issuer — every distinct --certificate-oidc-issuer value. +extract_issuer() { + sed -nE "s/.*--certificate-oidc-issuer[[:space:]]+['\"]?([^'\"[:space:]]+)['\"]?.*/\1/p" "$1" | sort -u +} + +# extract_identity — every distinct --certificate-identity-regexp value. +# The value is single-quoted in both files (a shell regexp must be); an unquoted +# value extracts as nothing and trips the positive controls rather than passing. +extract_identity() { + sed -nE "s/.*--certificate-identity-regexp[[:space:]]+'([^']*)'.*/\1/p" "$1" | sort -u +} + +# one_value