diff --git a/scripts/check-phase12.sh b/scripts/check-phase12.sh new file mode 100755 index 0000000..582aa91 --- /dev/null +++ b/scripts/check-phase12.sh @@ -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:-}" >&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"(?&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"(?&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