chore(12-05): add the fail-closed Phase 12 gate

scripts/check-phase12.sh, modelled on check-phase11.sh:
- --go: vet and test both repositories, golang.org/x/image pinned at v0.46.0
- --parity: 99 ported routes in the manifest, TestParityCorpus with its
  coverage subtest, all four broadcast goldens, both Nuxt flows,
  check_corpus --require-recorded --check-secrets and a secret scan of the
  fuzz seed corpus
- --named: every test 12-VALIDATION.md names, by exact name, the fonoteka
  plugin's under -race
- --removal: 25 anchor-exact mutations behind 12-SECURITY-REVIEW.md, each
  required to fail its named test on an assertion; a dirty file is
  refused and every file is restored and compared with cmp
- --coverage: an 80% floor per Phase 12 package in both repositories
- --evidence: one review row per T-12 threat, a removal row per high
  mitigated threat, a green validation file naming only tests the gate runs
- --self-test: every detector, plant and harness branch fails closed
This commit is contained in:
Jakub Zych
2026-10-02 16:39:03 +02:00
parent 6e30624ece
commit 6f4386c2f9

789
scripts/check-phase12.sh Executable file
View File

@@ -0,0 +1,789 @@
#!/usr/bin/env bash
# Phase 12 fail-closed gate (collections and albums API: API-01, API-02).
#
# Every stage exits non-zero on a failing command, a go test run that fails,
# skips, matches zero tests or prints "no tests to run", a named test that
# did not pass, a coverage floor missed, a corpus secret or an evidence gap.
# --self-test proves each detector fails closed on planted inputs.
#
# --removal is the anchor-exact mutation harness behind the RC rows of
# 12-SECURITY-REVIEW.md: it removes one protection at a time, requires its
# named test to fail on an assertion, and restores the file byte for byte
# (checked with cmp). It refuses a file with uncommitted changes and edits
# tracked source while it runs, so it is not part of --all.
#
# Framework commands run in summercms.go; application commands run in the
# sibling repository named by PHASE12_APP (default ../fonoteka.go).
set -euo pipefail
ROOT="${PHASE12_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
APP="${PHASE12_APP:-$(cd "$ROOT/../fonoteka.go" && pwd)}"
PHASE_DIR="${PHASE12_PHASE_DIR:-$ROOT/.planning/phases/12-p-ytarium-api-collections-and-albums}"
REVIEW="$PHASE_DIR/12-SECURITY-REVIEW.md"
VALIDATION="$PHASE_DIR/12-VALIDATION.md"
APP_PLUGINS=(./plugins/golem15/fonoteka/... ./plugins/golem15/user/...)
EXPECTED_PORTED=99
COVERAGE_FLOOR=80
XIMAGE_VERSION="v0.46.0"
FUZZ_CORPUS="plugins/golem15/fonoteka/testdata/fuzz"
usage() {
cat >&2 <<'EOF'
usage:
check-phase12.sh --self-test
check-phase12.sh --go
check-phase12.sh --parity
check-phase12.sh --named
check-phase12.sh --removal
check-phase12.sh --coverage
check-phase12.sh --evidence
check-phase12.sh --all
EOF
exit 2
}
# phase12_detect reads go test -json. Exit 1 fail, 2 skip, 3 zero tests or
# "no tests to run", 4 non-JSON, 5 a required test did not pass, 6 a data
# race was reported. PHASE12_REQUIRE lists tests that must pass.
phase12_detect() {
python3 - "$1" <<'PY'
import json, os, sys
path = sys.argv[1]
require = set(os.environ.get("PHASE12_REQUIRE", "").split())
passed = set()
failed_tests, failed_pkgs = {}, []
build_failed = False
with open(path, encoding="utf-8", errors="replace") as fh:
for raw in fh:
line = raw.strip()
if not line.startswith("{"):
continue
try:
ev = json.loads(line)
except json.JSONDecodeError:
print("refuse: non-json test output", file=sys.stderr)
sys.exit(4)
action = ev.get("Action")
test = ev.get("Test") or ""
pkg = ev.get("Package") or ""
if action == "build-fail":
build_failed = True
if action == "output":
text = ev.get("Output") or ""
if "no tests to run" in text:
print(f"refuse: no tests to run in {pkg}", file=sys.stderr)
sys.exit(3)
if "WARNING: DATA RACE" in text:
print(f"refuse: data race in {pkg} {test}", file=sys.stderr)
sys.exit(6)
if action == "skip" and test:
print(f"refuse: skipped {pkg} {test}", file=sys.stderr)
sys.exit(2)
if action == "fail":
if ev.get("FailedBuild"):
build_failed = True
if test:
failed_tests.setdefault(pkg, []).append(test)
else:
failed_pkgs.append(pkg)
if action == "pass" and test:
passed.add(test)
if build_failed:
print("refuse: build failed", file=sys.stderr)
sys.exit(1)
for pkg, tests in failed_tests.items():
for test in tests:
print(f"refuse: failed {pkg} {test}", file=sys.stderr)
sys.exit(1)
for pkg in failed_pkgs:
print(f"refuse: failed {pkg or 'unknown package'}", file=sys.stderr)
sys.exit(1)
missing = sorted(name for name in require if name not in passed)
if missing:
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
if not passed:
print("refuse: zero tests", file=sys.stderr)
sys.exit(3)
PY
}
# phase12_go DIR ARGS... runs go test -json -count=1 ARGS through the
# detector.
phase12_go() {
local dir="$1"
shift
local log err
log="$(mktemp)"
err="$(mktemp)"
set +e
(cd "$dir" && go test -json -count=1 "$@") >"$log" 2>"$err"
local rc=$?
set -e
local dc=0
phase12_detect "$log" || dc=$?
if [[ "$dc" -ne 0 || "$rc" -ne 0 ]]; then
cat "$err" >&2 || true
tail -n 40 "$log" >&2 || true
rm -f "$log" "$err"
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
exit 1
fi
rm -f "$log" "$err"
}
# phase12_tests DIR PKG [-race] TEST... requires every named test to run and
# pass, each matched by its exact name.
phase12_tests() {
local dir="$1" pkg="$2"
shift 2
local extra=()
if [[ "${1:-}" == "-race" ]]; then
extra=(-race)
shift
fi
local names="$*"
local regex="^($(tr ' ' '|' <<<"$names"))\$"
PHASE12_REQUIRE="$names" phase12_go "$dir" "$pkg" "${extra[@]}" -run "$regex"
}
expect_detect() {
local name="$1" want="$2" payload="$3"
local log dc=0
log="$(mktemp)"
printf '%s\n' "$payload" >"$log"
phase12_detect "$log" 2>/dev/null || dc=$?
rm -f "$log"
if [[ "$dc" -ne "$want" ]]; then
echo "refuse: self-test $name: detector exit $dc, want $want" >&2
exit 1
fi
}
# The named tests: every test 12-VALIDATION.md names, by package. The
# evidence stage refuses a validation row naming a test missing here.
NAMED_ROOT_LAGOON="TestValidateRequestEmptyArrayStopsAtRequired TestValidateRequestWildcardNamesIndexedAttribute TestValidateRequestWildcardWithoutParentAddsNothing TestValidateRequestStringLengthCountsCharacters TestValidateRequestBetweenIntegerBoundary TestValidateRequestNumericPrecision TestValidateRequestPolishFallsBackToEnglish TestValidateRequestPresenceSemantics TestValidateRequestBailAndOrder TestValidateRequestCustomRuleMessageVerbatim TestValidateRequestParseRules TestValidateRequestUploadedFile TestValidateRequestEmailURLBoolean TestValidateRequestExistsNeedsDatabase TestValidateRequestErrorKeysDeclarationOrder TestValidateRequestGoTypedValues TestValidateRequestMimesSniffing TestValidateRequestUploadedFileFromHeader TestValidateRequestRuleBuilders TestValidateRequestCustomLinePlaceholders TestValidateRequestExistsRule TestValidateRulesMatchLaravel TestPHPFloatStringMatchesPHPCast TestValidateNumericRangeMessagePicksFailedBound"
NAMED_ROOT_ATTACH="TestFileURLWinterLayout TestThumbWebP TestThumbBrokenSourceServesPlaceholder TestThumbModesAndFormats TestBucketAndURLEdges"
NAMED_ROOT_TIDE="TestMultipartRecordReplaySendsIdenticalBytes TestMultipartTamperedPartFileFailsLoad TestMultipartRejectsInvalidParts TestMultipartKeepsNonMultipartContentType TestNormalizeUploadURLMasksRandomParts TestNormalizeUploadURLReportsWrongShape TestNormalizePublicationAlbumDates TestMultipartPartEdges TestNormalizeMaskEdges TestCoverageReportHelpers"
NAMED_ROOT_BEACHCOMBER="TestSearchPageUsesPageSearcher TestSearchPageFallsBackToSearchIDs TestSearchPageNullEngine"
NAMED_ROOT_TYPESENSE="TestTypesenseSearchPageFoundAndWeights TestTypesenseSearchPageRejectsBadQueries"
NAMED_APP_USER_UPDATES="TestUserGroupsMigration TestUserGroupsCodesAndRelation"
NAMED_APP_USER_CLASSES="TestUserGroupCodesAndHasGroupCode TestUserClassHelpers"
NAMED_APP_FONOTEKA="TestCollectionsIndexBothGroups TestResolveProvisionsOnce TestResolvePinnedToken TestResolveFallbackHasNoKindFilter TestCollectionDeleteRemovesAlbumsOneByOne TestCollectionPhotoUpload TestCollectionSwitchRefusals TestMeContextFlags TestRealtimeChannelsName TestShareTokenAlphabet TestShareOwnerOnly TestTokenGroupOneScopePerRoute TestInvitationMailEnqueuedInTx TestInvitationAcceptAddsEditor TestRemoveEditorRepairsContext TestPendingInvitationGuard TestConcurrentAcceptSingleEditor TestAlbumStoreSingleCreatedEvent TestAlbumAddedNotifiesHousehold TestAlbumWriteHelpersMatchPHP TestCoverImportAfterCommit TestManualCoverReasons TestAlbumPhotoUpload TestBulkSingleSummaryEvent TestRatingUpsertConcurrent TestAlbumValueFormatting TestAlbumSearchSQLEscaping TestAlbumSearchTypesenseRecount TestSearchLeak TestRouteTablePhase12 FuzzWriteEndpoints TestPhase12Threats TestAlbumSyncRoute TestAlbumsMissingRoute TestStylesRoutes TestHouseholdInvitationsIndexRoute TestHandlersFailClosedOnDatabaseErrors TestHandlerRequestPaths"
NAMED_APP_CLASSES="TestPHPValueCasts TestValidLaravelEmailRFC TestParseTracklistTextMatchesPHP TestParseAddedDateMatchesPHP TestMatchNormalizersMatchPHP TestFormatValueTotalMatchesPHP TestSyncCursorAndCarbonTime TestSearchHelpers"
NAMED_APP_API="TestDecodeInput TestRequestCasts TestWinterErrorWriters"
NAMED_APP_PARITY="TestParityCorpus TestBroadcastGoldens TestFonotekaNuxtFlows TestCheckCorpusInvitationToken"
all_named() {
echo "$NAMED_ROOT_LAGOON $NAMED_ROOT_ATTACH $NAMED_ROOT_TIDE $NAMED_ROOT_BEACHCOMBER $NAMED_ROOT_TYPESENSE $NAMED_APP_USER_UPDATES $NAMED_APP_USER_CLASSES $NAMED_APP_FONOTEKA $NAMED_APP_CLASSES $NAMED_APP_API $NAMED_APP_PARITY"
}
# module_pin MODLIST: golang.org/x/image stays at the audited version
# (D-24, T-12-SC).
REASON_PIN="golang.org/x/image is not pinned at $XIMAGE_VERSION"
module_pin() {
local modlist="$1" hits
hits="$(grep -E '^golang\.org/x/image ' "$modlist" | sort -u || true)"
if [[ -z "$hits" ]] || grep -vqE "^golang\.org/x/image $XIMAGE_VERSION\$" <<<"$hits"; then
echo "refuse: hygiene: $REASON_PIN: ${hits:-<absent>}" >&2
return 1
fi
return 0
}
run_go() {
(cd "$ROOT" && go vet ./...)
phase12_go "$ROOT" ./...
(cd "$APP" && go vet ./... "${APP_PLUGINS[@]}")
phase12_go "$APP" ./... "${APP_PLUGINS[@]}"
local modlist
modlist="$(mktemp)"
(cd "$ROOT" && go list -m all) >"$modlist"
(cd "$APP" && go list -m all) >>"$modlist"
if ! module_pin "$modlist"; then
rm -f "$modlist"
exit 1
fi
rm -f "$modlist"
echo "phase12 go passed"
}
# corpus_scan DIR: the fuzz seed corpus holds synthetic values only
# (T-12-27): no 64-hex token, inv_ personal token, JWT or bearer header.
corpus_scan() {
python3 - "$1" <<'PY'
import os, re, sys
root = sys.argv[1]
if not os.path.isdir(root):
print(f"refuse: fuzz corpus {root} is missing", file=sys.stderr)
sys.exit(1)
patterns = [
("64-hex value", re.compile(r"(?<![0-9A-Fa-f])[0-9A-Fa-f]{64}(?![0-9A-Fa-f])")),
("personal token", re.compile(r"inv_[A-Za-z0-9]{16,}")),
("JWT", re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.")),
("bearer header", re.compile(r"(?i)bearer\s+[A-Za-z0-9._-]{12,}")),
]
files = 0
for dirpath, _, names in os.walk(root):
for name in names:
files += 1
text = open(os.path.join(dirpath, name), encoding="utf-8", errors="replace").read()
for label, rx in patterns:
if rx.search(text):
print(f"refuse: fuzz corpus {os.path.join(dirpath, name)} holds a {label}", file=sys.stderr)
sys.exit(1)
if files == 0:
print(f"refuse: fuzz corpus {root} is empty", file=sys.stderr)
sys.exit(1)
PY
}
# ported_count MANIFEST: the number of routes with status ported.
ported_count() {
grep -cE '^[[:space:]]*status:[[:space:]]*ported[[:space:]]*$' "$1" || true
}
run_parity() {
local n
n="$(ported_count "$APP/parity/manifest.yaml")"
if [[ "$n" -ne "$EXPECTED_PORTED" ]]; then
echo "refuse: parity manifest has $n ported routes, want $EXPECTED_PORTED" >&2
exit 1
fi
PHASE12_REQUIRE="TestParityCorpus TestParityCorpus/coverage TestBroadcastGoldens TestBroadcastGoldens/created TestBroadcastGoldens/updated TestBroadcastGoldens/deleted TestBroadcastGoldens/bulk TestFonotekaNuxtFlows TestFonotekaNuxtFlows/nuxt-collections TestFonotekaNuxtFlows/nuxt-albums TestCheckCorpusInvitationToken" \
phase12_go "$APP" ./parity -run '^(TestParityCorpus|TestBroadcastGoldens|TestFonotekaNuxtFlows|TestCheckCorpusInvitationToken)$'
(cd "$APP" && go run ./parity/check_corpus.go --manifest parity/manifest.yaml --require-recorded --check-secrets)
corpus_scan "$APP/$FUZZ_CORPUS"
echo "phase12 parity passed ($n ported, 0 failing)"
}
run_named() {
phase12_tests "$ROOT" ./modules/lagoon $NAMED_ROOT_LAGOON
phase12_tests "$ROOT" ./modules/lagoon/attach $NAMED_ROOT_ATTACH
phase12_tests "$ROOT" ./modules/tide $NAMED_ROOT_TIDE
phase12_tests "$ROOT" ./modules/beachcomber $NAMED_ROOT_BEACHCOMBER
phase12_tests "$ROOT" ./modules/beachcomber/typesense $NAMED_ROOT_TYPESENSE
phase12_tests "$APP" ./plugins/golem15/user/updates $NAMED_APP_USER_UPDATES
phase12_tests "$APP" ./plugins/golem15/user/classes $NAMED_APP_USER_CLASSES
phase12_tests "$APP" ./plugins/golem15/fonoteka -race $NAMED_APP_FONOTEKA
phase12_tests "$APP" ./plugins/golem15/fonoteka/classes $NAMED_APP_CLASSES
phase12_tests "$APP" ./plugins/golem15/fonoteka/controllers/api $NAMED_APP_API
phase12_tests "$APP" ./parity $NAMED_APP_PARITY
echo "phase12 named passed"
}
# coverage_report FLOOR PROFILE... prints one line per package of the merged
# profiles (a block counts as covered when any profile covered it) and
# refuses any package below FLOOR percent.
coverage_report() {
python3 - "$@" <<'PY'
import collections, sys
floor = float(sys.argv[1])
blocks = {}
for path in sys.argv[2:]:
for line in open(path):
if line.startswith("mode:") or not line.strip():
continue
loc, n, c = line.rsplit(" ", 2)
n, c = int(n), int(c)
prev = blocks.get(loc, (n, 0))
blocks[loc] = (n, max(prev[1], c))
total, covered = collections.Counter(), collections.Counter()
for loc, (n, c) in blocks.items():
pkg = loc.split(":")[0].rsplit("/", 1)[0]
total[pkg] += n
if c:
covered[pkg] += n
if not total:
print("refuse: coverage profile is empty", file=sys.stderr)
sys.exit(1)
low = []
for pkg in sorted(total):
pct = 100.0 * covered[pkg] / total[pkg]
print(f"coverage {pkg} {pct:.1f}%")
if pct < floor:
low.append(f"{pkg} {pct:.1f}%")
if low:
print(f"refuse: below the {floor:.0f}% coverage floor: " + ", ".join(low), file=sys.stderr)
sys.exit(1)
PY
}
# cover_profile DIR OUT ARGS... writes a coverage profile of go test ARGS.
cover_profile() {
local dir="$1" out="$2"
shift 2
local log
log="$(mktemp)"
if ! (cd "$dir" && go test -count=1 -coverprofile="$out" "$@") >"$log" 2>&1; then
tail -n 40 "$log" >&2
rm -f "$log"
echo "refuse: go test -coverprofile $* in $dir" >&2
exit 1
fi
rm -f "$log"
}
run_coverage() {
local dir
dir="$(mktemp -d)"
trap 'rm -rf "$dir"' RETURN
local pkg i=0
# Framework packages: each package's own tests.
for pkg in ./modules/lagoon ./modules/lagoon/attach ./modules/tide ./modules/beachcomber ./modules/beachcomber/typesense; do
i=$((i + 1))
cover_profile "$ROOT" "$dir/root$i.out" "$pkg"
done
coverage_report "$COVERAGE_FLOOR" "$dir"/root*.out
# Application packages: every test of the plugin that exercises them.
cover_profile "$APP" "$dir/app.out" ./plugins/golem15/fonoteka/... \
-coverpkg=./plugins/golem15/fonoteka/classes,./plugins/golem15/fonoteka/controllers/api
coverage_report "$COVERAGE_FLOOR" "$dir/app.out"
cover_profile "$APP" "$dir/user.out" ./plugins/golem15/user/... \
-coverpkg=./plugins/golem15/user/classes,./plugins/golem15/user/updates
coverage_report "$COVERAGE_FLOOR" "$dir/user.out"
echo "phase12 coverage passed"
}
# removal_table: the RC rows of 12-SECURITY-REVIEW.md. Fields: id, threat,
# repo (root|app|script), file, anchor, replacement, package, test regex.
# Anchors must occur exactly once.
removal_table() {
cat <<'EOF'
[
["RC-01", "T-12-01", "app", "plugins/golem15/fonoteka/controllers/api/collections_controller.go",
"\t\tScopes(classes.AccessibleBy(userID, token)).\n", "", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-01$"],
["RC-02", "T-12-02", "app", "plugins/golem15/fonoteka/classes/album_search.go",
"if err := ScopedAlbums(ctx, db, p.UserID, p.Token, p.CollectionID).\n\t\t\tWhere(\"golem15_fonoteka_albums.id IN ?\", ids).",
"if err := db.WithContext(ctx).Model(&models.Album{}).\n\t\t\tWhere(\"golem15_fonoteka_albums.id IN ?\", ids).", "./plugins/golem15/fonoteka", "^TestSearchLeak$"],
["RC-03", "T-12-28", "app", "plugins/golem15/fonoteka/classes/album_search.go",
"if err := ScopedAlbums(ctx, db, p.UserID, p.Token, p.CollectionID).\n\t\t\tWhere(\"golem15_fonoteka_albums.id IN ?\", all).",
"if err := db.WithContext(ctx).Model(&models.Album{}).\n\t\t\tWhere(\"golem15_fonoteka_albums.id IN ?\", all).", "./plugins/golem15/fonoteka", "^TestSearchLeak$"],
["RC-04", "T-12-02", "app", "plugins/golem15/fonoteka/classes/album_queries.go",
"\t\tScopes(AlbumsAccessibleBy(userID, token)).\n", "", "./plugins/golem15/fonoteka", "^TestSearchLeak$"],
["RC-05", "T-12-03", "app", "plugins/golem15/fonoteka/classes/access.go",
"if pin := tokenCollectionPin(token); len(pin) > 0 {", "if pin := tokenCollectionPin(token); false && len(pin) > 0 {", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-03$"],
["RC-06", "T-12-34", "app", "plugins/golem15/fonoteka/classes/active_collection.go",
"if token != nil {\n\t\treturn resolvePinnedTokenCollection(", "if false {\n\t\treturn resolvePinnedTokenCollection(", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-34$"],
["RC-07", "T-12-04", "app", "plugins/golem15/fonoteka/routes.go",
"g.Get(\"/me\", api.MeToken(p.app), \"inv.scope:read\")", "g.Get(\"/me\", api.MeToken(p.app), \"inv.scope:read\")\n\t\tg.Get(\"/collection/share\", api.CollectionShareShow(p.app), \"inv.scope:read\")", "./plugins/golem15/fonoteka", "^TestRouteTablePhase12$"],
["RC-08", "T-12-31", "app", "plugins/golem15/fonoteka/routes.go",
"g.Get(\"/me\", api.MeToken(p.app), \"inv.scope:read\")", "g.Get(\"/me\", api.MeToken(p.app), \"inv.scope:read\")\n\t\tg.Get(\"/household/members\", api.HouseholdMembersIndex(p.app), \"inv.scope:read\")", "./plugins/golem15/fonoteka", "^TestRouteTablePhase12$"],
["RC-09", "T-12-31", "app", "plugins/golem15/fonoteka/classes/invitation_service.go",
"if token != nil || actor == nil || c == nil || c.OwnerID != actor.ID {", "if actor == nil || c == nil || c.OwnerID != actor.ID {", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-31$"],
["RC-10", "T-12-05", "app", "plugins/golem15/fonoteka/controllers/api/collections_controller.go",
"if c == nil || c.OwnerID != user.ID {", "if c == nil {", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-05$"],
["RC-11", "T-12-32", "app", "plugins/golem15/fonoteka/controllers/api/invitations_controller.go",
"\tif c.OwnerID != user.ID {\n\t\twriteWinterHTTPError(w, app, http.StatusNotFound)\n\t\treturn nil, false", "\tif false {\n\t\twriteWinterHTTPError(w, app, http.StatusNotFound)\n\t\treturn nil, false", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-32$"],
["RC-12", "T-12-06", "app", "plugins/golem15/fonoteka/classes/invitation_service.go",
"if inv.Email != email {\n\t\t\treturn ErrInvitationUnavailable", "if false && inv.Email != email {\n\t\t\treturn ErrInvitationUnavailable", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-06$"],
["RC-13", "T-12-06", "app", "plugins/golem15/fonoteka/classes/invitation_service.go",
"invitationTokenHash(rawToken)).Scan(&rows)", "rawToken).Scan(&rows)", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-06$"],
["RC-14", "T-12-07", "app", "plugins/golem15/fonoteka/classes/invitation_service.go",
"InvitationMailArgs{InvitationID: invitationID, Token: s}", "InvitationMailArgs{InvitationID: invitationID, Token: raw}", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-07$"],
["RC-15", "T-12-08", "app", "plugins/golem15/fonoteka/classes/share_service.go",
"shareRejectAt = 248", "shareRejectAt = 255", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-08$"],
["RC-16", "T-12-09", "app", "plugins/golem15/fonoteka/classes/manual_cover_fetcher.go",
"return fetchguard.Fetch(ctx, rawURL, policy, nil)", "return &fetchguard.Result{StatusCode: 200, ContentType: \"image/png\"}, nil", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-09$"],
["RC-17", "T-12-10", "app", "plugins/golem15/fonoteka/classes/image_guard.go",
"func IsAllowedImage(data []byte) bool {\n\tif len(data) == 0 {", "func IsAllowedImage(data []byte) bool {\n\treturn true\n\tif len(data) == 0 {", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-10$"],
["RC-18", "T-12-11", "app", "plugins/golem15/fonoteka/classes/album_write_service.go",
"\ta.CollectionID = collectionID\n", "\ta.CollectionID = uint(phpIntCast(input[\"collection_id\"]))\n", "./plugins/golem15/fonoteka", "^FuzzWriteEndpoints$"],
["RC-19", "T-12-30", "app", "plugins/golem15/fonoteka/classes/collection_write_service.go",
"var CollectionFillFields = []string{\"name\", \"description\"}", "var CollectionFillFields = []string{\"name\", \"description\", \"owner_id\"}", "./plugins/golem15/fonoteka", "^FuzzWriteEndpoints$"],
["RC-20", "T-12-14", "root", "modules/lagoon/validate_rules.go",
"if !identName.MatchString(table) {\n\t\t\tpanic(", "if false {\n\t\t\tpanic(", "./modules/lagoon", "^TestValidateRequestParseRules$"],
["RC-21", "T-12-20", "app", "parity/check_corpus.go",
"if hex64Re.MatchString(line) {", "if false && hex64Re.MatchString(line) {", "./parity", "^TestCheckCorpusInvitationToken$"],
["RC-22", "T-12-33", "app", "plugins/golem15/fonoteka/controllers/api/album_photos_controller.go",
"\"attachment_type = ? AND attachment_id = ? AND field = ? AND id = ?\",\n\t\t\tmodels.Album{}.MorphName(), strconv.FormatUint(uint64(album.ID), 10), classes.AlbumPhotosField, pathID(r, \"fileId\")).",
"\"id = ? AND ? <> '' AND ? <> '' AND ? <> ''\",\n\t\t\tpathID(r, \"fileId\"), models.Album{}.MorphName(), strconv.FormatUint(uint64(album.ID), 10), classes.AlbumPhotosField).", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-33$"],
["RC-23", "T-12-33", "app", "plugins/golem15/fonoteka/classes/album_queries.go",
"err := ScopedAlbums(ctx, db, userID, token, collectionID).\n\t\tWhere(\"golem15_fonoteka_albums.id = ?\", id).",
"err := db.WithContext(ctx).Model(&models.Album{}).\n\t\tWhere(\"golem15_fonoteka_albums.id = ?\", id).", "./plugins/golem15/fonoteka", "^TestPhase12Threats$/^T-12-33$"],
["RC-24", "T-12-SC", "script", "scripts/check-phase12.sh",
"hits=\"$(grep -E '^golang\\.org/x/image ' \"$modlist\" | sort -u || true)\"", "hits=\"golang.org/x/image $XIMAGE_VERSION\"", "", "--self-test"],
["RC-25", "T-12-27", "script", "scripts/check-phase12.sh",
"holds a {label}\", file=sys.stderr)\n sys.exit(1)", "holds a {label}\", file=sys.stderr)\n pass", "", "--self-test"]
]
EOF
}
# removal_harness TABLE_FILE: for each row, refuse a file with uncommitted
# changes, save it, apply the anchor-exact mutation, run the named test (or,
# for the gate script, its --self-test on a mutated copy) and require it to
# fail on an assertion, then restore the file and require cmp to match.
removal_harness() {
python3 - "$1" "$ROOT" "$APP" <<'PY'
import json, os, shutil, subprocess, sys, tempfile
table = json.load(open(sys.argv[1]))
root, app = sys.argv[2], sys.argv[3]
only = set(os.environ.get("PHASE12_RC", "").split())
failures = 0
for rc, threat, repo, rel, anchor, repl, pkg, run in table:
if only and rc not in only:
continue
base = {"root": root, "app": app, "script": root}[repo]
path = os.path.join(base, rel)
tracked = subprocess.run(["git", "-C", os.path.dirname(path), "rev-parse", "--is-inside-work-tree"], capture_output=True, text=True).returncode == 0
if tracked and repo != "script":
dirty = subprocess.run(["git", "-C", os.path.dirname(path), "status", "--porcelain", "--", os.path.basename(path)], capture_output=True, text=True).stdout.strip()
if dirty:
print(f"refuse: {rc}: {rel} is dirty; commit or restore it first", file=sys.stderr)
sys.exit(1)
original = open(path, "rb").read()
text = original.decode()
n = text.count(anchor)
if n != 1:
print(f"refuse: {rc} {threat}: anchor occurs {n} times in {rel}", file=sys.stderr)
sys.exit(1)
mutated = text.replace(anchor, repl, 1)
scratch = tempfile.mkdtemp(prefix="phase12-rc-")
saved = os.path.join(scratch, "saved")
shutil.copyfile(path, saved)
try:
if repo == "script":
copy = os.path.join(scratch, os.path.basename(rel))
open(copy, "w").write(mutated)
env = dict(os.environ, PHASE12_ROOT=root, PHASE12_APP=app)
proc = subprocess.run(["bash", copy, run], cwd=root, env=env, capture_output=True, text=True, timeout=900)
out = proc.stdout + proc.stderr
ok = proc.returncode != 0 and "refuse:" in out
evidence = next((l for l in out.splitlines() if l.startswith("refuse:")), "")
else:
with open(path, "w") as fh:
fh.write(mutated)
proc = subprocess.run(["go", "test", pkg, "-run", run, "-count=1"], cwd=base, capture_output=True, text=True, timeout=1200)
out = proc.stdout + proc.stderr
build = "[build failed]" in out or "[setup failed]" in out
ok = proc.returncode != 0 and "--- FAIL" in out and not build
fails = [l.strip() for l in out.splitlines() if l.strip().startswith("--- FAIL")]
names = [l.split()[2] for l in fails if len(l.split()) > 2]
evidence = ", ".join(names[:5]) + (f" (+{len(names) - 5} more)" if len(names) > 5 else "") if names else ("build failed" if build else "no failure")
finally:
with open(path, "wb") as fh:
fh.write(original)
same = subprocess.run(["cmp", "-s", saved, path]).returncode == 0
shutil.rmtree(scratch, ignore_errors=True)
if not same:
print(f"refuse: {rc}: {rel} was not restored byte for byte", file=sys.stderr)
sys.exit(1)
status = "fails as required" if ok else "SURVIVED"
print(f"{rc} {threat} {rel}: {status}: {evidence}")
if not ok:
failures += 1
if failures:
print(f"refuse: {failures} removal check(s) survived", file=sys.stderr)
sys.exit(1)
PY
}
run_removal() {
local table
table="$(mktemp)"
removal_table >"$table"
if ! removal_harness "$table"; then
rm -f "$table"
exit 1
fi
rm -f "$table"
echo "phase12 removal passed"
}
# removal_harness_in ROOT TABLE runs the harness against another root.
removal_harness_in() {
local root="$1" table="$2"
(
ROOT="$root"
APP="$root"
export GOWORK=off GOFLAGS=-mod=mod
removal_harness "$table"
)
}
# evidence_check PHASE_DIR REVIEW VALIDATION NAMED: every T-12 threat the
# plans declare has exactly one review row copying its severity and
# disposition; a high mitigated threat names a test or gate stage and has a
# removal row; the validation file is validated, Nyquist-compliant, without
# a pending or TBD row, names API-01 and API-02, and every test it names is
# run by the --named stage.
evidence_check() {
python3 - "$@" <<'PY'
import glob, os, re, sys
phase_dir, review_path, validation_path, named = sys.argv[1], sys.argv[2], sys.argv[3], set(sys.argv[4].split())
for p in (review_path, validation_path):
if not os.path.isfile(p):
print(f"refuse: {p} is missing", file=sys.stderr)
sys.exit(1)
review = open(review_path).read()
validation = open(validation_path).read()
declared = {}
for plan in sorted(glob.glob(os.path.join(phase_dir, "12-0*-PLAN.md"))):
for line in open(plan):
m = re.match(r"^\| (T-12-(?:\d\d|SC)) \|", line)
if m:
cells = [c.strip().lower() for c in line.strip().strip("|").split("|")]
declared.setdefault(m.group(1), cells)
if not declared:
print("refuse: no plan declares a T-12 threat", file=sys.stderr)
sys.exit(1)
lines = review.splitlines()
removal = [l for l in lines if re.match(r"^\| RC-\d+ \| T-12-", l)]
for tid, cells in sorted(declared.items()):
rows = [l for l in lines if l.startswith("| " + tid + " |")]
if len(rows) != 1:
print(f"refuse: review has {len(rows)} threat rows for {tid}, want 1", file=sys.stderr)
sys.exit(1)
row = [c.strip().lower() for c in rows[0].strip().strip("|").split("|")]
severity, disposition = cells[3], cells[4]
if severity not in row or disposition not in row:
print(f"refuse: review row {tid} does not copy severity {severity!r} and disposition {disposition!r}", file=sys.stderr)
sys.exit(1)
if disposition == "mitigate" and not re.search(r"(Test|Fuzz)[A-Z][A-Za-z0-9]+|check-phase12\.sh", rows[0]):
print(f"refuse: mitigated threat {tid} names no test or gate stage", file=sys.stderr)
sys.exit(1)
if severity == "high" and disposition == "mitigate":
if not any(re.match(r"^\| RC-\d+ \| " + re.escape(tid) + r" \|", l) for l in removal):
print(f"refuse: high threat {tid} has no removal check row", file=sys.stderr)
sys.exit(1)
if not re.search(r"^nyquist_compliant: true$", validation, re.M):
print("refuse: validation is not nyquist_compliant", file=sys.stderr)
sys.exit(1)
if not re.search(r"^status: validated$", validation, re.M):
print("refuse: validation status is not validated", file=sys.stderr)
sys.exit(1)
status_word = re.compile(r"(?<![A-Za-z])pending(?![A-Za-z])|\u2b1c|\| TBD \|", re.I)
for line in validation.splitlines():
if line.startswith("|") and status_word.search(line):
print("refuse: validation row still pending: " + line, file=sys.stderr)
sys.exit(1)
for req in ["API-01", "API-02"]:
if req not in validation:
print(f"refuse: validation does not name {req}", file=sys.stderr)
sys.exit(1)
task_rows = "\n".join(l for l in validation.splitlines() if re.match(r"^\| 12-\d\d-T\d", l))
if not task_rows:
print("refuse: validation has no per-task verification rows", file=sys.stderr)
sys.exit(1)
for name in sorted(set(re.findall(r"\b(?:Test|Fuzz)[A-Z][A-Za-z0-9_]*", task_rows))):
if name not in named:
print(f"refuse: validation names {name}, which the --named stage does not run", file=sys.stderr)
sys.exit(1)
print("phase12 evidence passed")
PY
}
run_evidence() {
evidence_check "$PHASE_DIR" "$REVIEW" "$VALIDATION" "$(all_named)"
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestSearchLeak"}'
expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestPhase12Threats/T-12-01"}'
expect_detect skip 2 '{"Action":"skip","Package":"p","Test":"TestSearchLeak"}'
expect_detect zero 3 '{"Action":"pass","Package":"p"}'
expect_detect no-tests-to-run 3 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"output","Package":"q","Output":"testing: warning: no tests to run\n"}'
expect_detect nonjson 4 '{"Action":"pass",'
expect_detect build 1 '{"Action":"build-fail","ImportPath":"p"}
{"Action":"pass","Package":"q","Test":"TestA"}'
expect_detect build-flag 1 '{"Action":"pass","Package":"q","Test":"TestA"}
{"Action":"fail","Package":"p","FailedBuild":"p"}'
expect_detect package 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p"}'
expect_detect race 6 '{"Action":"output","Package":"p","Test":"TestA","Output":"WARNING: DATA RACE\n"}
{"Action":"pass","Package":"p","Test":"TestA"}'
PHASE12_REQUIRE="TestSearchLeak TestRouteTablePhase12" expect_detect required 5 \
'{"Action":"pass","Package":"p","Test":"TestSearchLeak"}'
local flag
for flag in --self-test --go --parity --named --removal --coverage --evidence --all; do
grep -q -- "^$flag)" "${BASH_SOURCE[0]}" || {
echo "refuse: missing mode $flag" >&2
exit 1
}
done
local scratch
scratch="$(mktemp -d)"
trap 'rm -rf "$scratch"' RETURN
# The module pin refuses a changed or missing x/image and accepts the
# audited line.
printf 'golang.org/x/image %s\n' "$XIMAGE_VERSION" >"$scratch/mods"
module_pin "$scratch/mods" 2>/dev/null || {
echo "refuse: self-test module_pin rejected the audited version" >&2
exit 1
}
for plant in 'golang.org/x/image v0.45.0' ''; do
printf '%s\n' "$plant" >"$scratch/mods"
if module_pin "$scratch/mods" 2>/dev/null; then
echo "refuse: self-test module_pin accepted ${plant:-a missing x/image}" >&2
exit 1
fi
done
# The corpus scan refuses each planted secret shape and accepts
# synthetic values.
mkdir -p "$scratch/corpus"
printf 'go test fuzz v1\nstring("POST /x")\nstring("{\\"owner_id\\":\\"1\\",\\"pad\\":\\"AAAA\\"}")\n' >"$scratch/corpus/seed"
corpus_scan "$scratch/corpus" 2>/dev/null || {
echo "refuse: self-test corpus_scan rejected a synthetic seed" >&2
exit 1
}
local secret
for secret in "$(printf 'a%.0s' $(seq 64))" "inv_ABCDEFGHIJKLMNOPQRST" "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.sig" "Bearer abcdefghijklmnop"; do
printf 'string("%s")\n' "$secret" >"$scratch/corpus/planted"
if corpus_scan "$scratch/corpus" 2>/dev/null; then
echo "refuse: self-test corpus_scan accepted a planted secret ${secret:0:12}" >&2
exit 1
fi
done
rm -f "$scratch/corpus/planted" "$scratch/corpus/seed"
if corpus_scan "$scratch/corpus" 2>/dev/null; then
echo "refuse: self-test corpus_scan accepted an empty corpus" >&2
exit 1
fi
# The ported counter reads only status lines.
printf 'routes:\n - id: a\n status: ported\n - id: b\n status: pending\n - id: c\n status: ported\n# status: ported\n' >"$scratch/manifest.yaml"
if [[ "$(ported_count "$scratch/manifest.yaml")" -ne 2 ]]; then
echo "refuse: self-test ported_count miscounted" >&2
exit 1
fi
# The coverage report refuses a package under the floor and accepts one
# over it; a block covered by any profile counts once.
printf 'mode: set\nexample.test/a/x.go:1.1,2.2 8 1\nexample.test/a/x.go:3.1,4.2 2 0\n' >"$scratch/p1"
printf 'mode: set\nexample.test/a/x.go:3.1,4.2 2 1\nexample.test/b/y.go:1.1,2.2 5 0\nexample.test/b/y.go:3.1,4.2 5 1\n' >"$scratch/p2"
local out
out="$(coverage_report 80 "$scratch/p1" 2>&1)" || {
echo "refuse: self-test coverage_report refused 80% at an 80% floor: $out" >&2
exit 1
}
if out="$(coverage_report 80 "$scratch/p1" "$scratch/p2" 2>&1)"; then
echo "refuse: self-test coverage_report accepted a 50% package" >&2
exit 1
fi
grep -q "coverage example.test/a 100.0%" <<<"$out" || {
echo "refuse: self-test coverage_report did not merge profiles: $out" >&2
exit 1
}
printf 'mode: set\n' >"$scratch/empty"
if coverage_report 80 "$scratch/empty" 2>/dev/null; then
echo "refuse: self-test coverage_report accepted an empty profile" >&2
exit 1
fi
# The evidence check refuses a missing threat row, a wrong disposition,
# a high threat without a removal row, a pending validation row and a
# validation test the named stage does not run.
mkdir -p "$scratch/phase"
printf '| T-12-90 | Spoofing | x | high | mitigate | y |\n| T-12-91 | Tampering | x | low | accept | y |\n' >"$scratch/phase/12-01-PLAN.md"
cat >"$scratch/review.md" <<'EOR'
| T-12-90 | Spoofing | x | high | mitigate | y | TestAlpha | pass | none |
| T-12-91 | Tampering | x | low | accept | y | none (accepted) | accepted | none |
| RC-90 | T-12-90 | f | a | b | c | fails |
EOR
cat >"$scratch/validation.md" <<'EOV'
status: validated
nyquist_compliant: true
| 12-01-T1 | API-01, API-02 | `go test -run '^TestAlpha$'` | ✅ green |
EOV
evidence_check "$scratch/phase" "$scratch/review.md" "$scratch/validation.md" "TestAlpha" >/dev/null 2>&1 || {
echo "refuse: self-test evidence_check rejected a complete record" >&2
exit 1
}
local case
for case in missing-row disposition removal pending unnamed; do
cp "$scratch/review.md" "$scratch/review.case"
cp "$scratch/validation.md" "$scratch/validation.case"
local named="TestAlpha"
case "$case" in
missing-row) sed -i '/^| T-12-91 /d' "$scratch/review.case" ;;
disposition) sed -i 's/| low | accept |/| low | mitigate |/' "$scratch/review.case" ;;
removal) sed -i '/^| RC-90 /d' "$scratch/review.case" ;;
pending) printf '| 12-02-T1 | API-01 | x | ⬜ pending |\n' >>"$scratch/validation.case" ;;
unnamed) named="TestBeta" ;;
esac
if evidence_check "$scratch/phase" "$scratch/review.case" "$scratch/validation.case" "$named" >/dev/null 2>&1; then
echo "refuse: self-test evidence_check accepted the $case plant" >&2
exit 1
fi
done
# The removal harness refuses an anchor that is not unique and a dirty
# tracked file, restores the file byte for byte, and reports a mutation
# whose test passes.
local fake="$scratch/fake"
mkdir -p "$fake/modules/acme"
printf 'module example.test/acme\n\ngo 1.27\n' >"$fake/go.mod"
printf 'package acme\n\nfunc Guard(n int) bool {\n\tif n > 3 {\n\t\treturn false\n\t}\n\treturn true\n}\n' >"$fake/modules/acme/acme.go"
printf 'package acme\n\nimport "testing"\n\nfunc TestGuard(t *testing.T) {\n\tif Guard(4) {\n\t\tt.Fatal("guard removed")\n\t}\n}\n\nfunc TestOther(t *testing.T) {}\n' >"$fake/modules/acme/acme_test.go"
cp "$fake/modules/acme/acme.go" "$scratch/acme.go.saved"
local table="$scratch/table.json"
printf '[["RC-T1","T-X","root","modules/acme/acme.go","if n > 3 {","if false {","./modules/acme","^TestGuard$"]]' >"$table"
out="$(removal_harness_in "$fake" "$table" 2>&1)" || {
echo "refuse: self-test removal harness did not catch a guarded mutation: $out" >&2
exit 1
}
grep -q "RC-T1 T-X modules/acme/acme.go: fails as required" <<<"$out" || {
echo "refuse: self-test removal harness output: $out" >&2
exit 1
}
cmp -s "$fake/modules/acme/acme.go" "$scratch/acme.go.saved" || {
echo "refuse: self-test removal harness did not restore the file" >&2
exit 1
}
printf '[["RC-T2","T-X","root","modules/acme/acme.go","if n > 3 {","if false {","./modules/acme","^TestOther$"]]' >"$table"
if out="$(removal_harness_in "$fake" "$table" 2>&1)"; then
echo "refuse: self-test removal harness accepted a mutation whose test passes: $out" >&2
exit 1
fi
grep -q "RC-T2 T-X modules/acme/acme.go: SURVIVED" <<<"$out" || {
echo "refuse: self-test removal harness refused a surviving mutation for the wrong reason: $out" >&2
exit 1
}
printf '[["RC-T3","T-X","root","modules/acme/acme.go","return","x","./modules/acme","^TestGuard$"]]' >"$table"
if out="$(removal_harness_in "$fake" "$table" 2>&1)"; then
echo "refuse: self-test removal harness accepted a non-unique anchor" >&2
exit 1
fi
grep -q "anchor occurs 2 times" <<<"$out" || {
echo "refuse: self-test removal harness refused a non-unique anchor for the wrong reason: $out" >&2
exit 1
}
(cd "$fake" && git init -q && git add -A && git -c user.email=gate@example.test -c user.name=gate commit -qm init) >/dev/null
printf '// local edit\n' >>"$fake/modules/acme/acme.go"
printf '[["RC-T4","T-X","root","modules/acme/acme.go","if n > 3 {","if false {","./modules/acme","^TestGuard$"]]' >"$table"
if out="$(removal_harness_in "$fake" "$table" 2>&1)"; then
echo "refuse: self-test removal harness mutated a dirty file" >&2
exit 1
fi
grep -q "is dirty" <<<"$out" || {
echo "refuse: self-test removal harness refused a dirty file for the wrong reason: $out" >&2
exit 1
}
echo "phase12 self-test passed"
}
case "${1:-}" in
--self-test) run_self_test ;;
--go) run_go ;;
--parity) run_parity ;;
--named) run_named ;;
--removal) run_removal ;;
--coverage) run_coverage ;;
--evidence) run_evidence ;;
--all)
run_self_test
run_go
run_parity
run_named
run_coverage
run_evidence
echo "phase12 all passed"
;;
*) usage ;;
esac