test(15-04): add fail-closed Phase 15 gate and ASVS L1 review

scripts/check-phase15.sh --all refuses skip/no-tests/race/dirty PHP pin; the review closes T-15-01..15 and T-15-SC with executed TestNames.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-06 19:22:19 +02:00
parent 05b77b7818
commit 7307b36baa
3 changed files with 636 additions and 34 deletions

395
scripts/check-phase15.sh Executable file
View File

@@ -0,0 +1,395 @@
#!/usr/bin/env bash
# Phase 15 fail-closed gate (Journal plugin + proof host). Every stage exits
# non-zero on a failing command, a go test run that fails, skips, matches
# zero tests, prints "no tests to run", a named required test that did not
# pass, a data race, a dirty PHP pin tree, a forbidden surface, or an
# unmitigated high threat. --self-test proves the detector fails closed on
# planted inputs. --all runs every stage and must end with
# "Phase 15 gate passed".
#
# Sibling repositories are invoked with `go -C`. Full mode runs Postgres
# integration and treats Docker unavailability as failure; -short is not
# final evidence.
set -euo pipefail
unset FORCE_COLOR
ROOT="${PHASE15_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
PLUGIN="${PHASE15_PLUGIN:-$ROOT/../sm-journal-plugin}"
HOST="${PHASE15_HOST:-$ROOT/../sm-grzybyfunkcjonalne-app}"
FONOTEKA="${PHASE15_FONOTEKA:-$ROOT/../fonoteka.go}"
PHP="${PHASE15_PHP:-/media/nvme/dev/golem15/fonoteka/plugins/golem15/journal}"
PHP_SHA="02110eb1c0c3861370b0b9b47b209a0702ac5d88"
PHASE_DIR="${PHASE15_PHASE_DIR:-$ROOT/.planning/phases/15-journal-plugin}"
REVIEW="$PHASE_DIR/15-SECURITY-REVIEW.md"
VALIDATION="$PHASE_DIR/15-VALIDATION.md"
PLUGIN_REQUIRE=(
TestJournalEndToEnd
TestJournal005DraftShow
TestJournal006MediaUpload
TestFillable
TestSearchGateOff
TestJournalWriteUnauthenticated
TestJournalPublicCategories
TestJournalPublicTags
TestJournalRSS
TestJournalFeaturedImageUnauthenticated
TestJournalCommands
TestPostsFormCompiles
TestJournalBuckets
TestJournalTables
TestJournalMigrationsRollbackAndRemigrate
TestFormatHTMLRejectsUnsafeHTML
)
HOST_REQUIRE=(
TestBootUserTranslateJournal
TestCORS
)
HIGH_THREATS=(
T-15-01 T-15-02 T-15-03 T-15-04 T-15-05 T-15-06 T-15-07
T-15-08 T-15-09 T-15-10 T-15-11 T-15-13 T-15-14 T-15-SC
)
ALL_THREATS=(
T-15-01 T-15-02 T-15-03 T-15-04 T-15-05 T-15-06 T-15-07
T-15-08 T-15-09 T-15-10 T-15-11 T-15-12 T-15-13 T-15-14
T-15-15 T-15-SC
)
usage() {
cat >&2 <<'EOF'
usage:
check-phase15.sh --self-test
check-phase15.sh --php
check-phase15.sh --layout
check-phase15.sh --plugin
check-phase15.sh --host
check-phase15.sh --forbidden
check-phase15.sh --security
check-phase15.sh --all
EOF
exit 2
}
# detect reads go test -json. Exit 1 fail/build, 2 skip, 3 zero/no-tests,
# 4 non-JSON, 5 missing required name, 6 data race.
detect() {
python3 - "$1" <<'PY'
import json, os, sys
path = sys.argv[1]
require = [n for n in os.environ.get("REQUIRE_TESTS", "").split() if n]
passed = set()
failed = []
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 ev.get("ImportPath") or ""
if action == "build-fail" or (action == "fail" and ev.get("FailedBuild")):
print(f"refuse: build failed {pkg}", file=sys.stderr)
sys.exit(1)
text = ev.get("Output") or ""
if action == "output":
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":
failed.append(f"{pkg} {test}".strip())
if action == "pass" and test:
passed.add(test)
if failed:
print("refuse: failed " + ", ".join(failed), file=sys.stderr)
sys.exit(1)
if not passed:
print("refuse: zero tests", file=sys.stderr)
sys.exit(3)
top = {name for name in passed if "/" not in name}
missing = [n for n in require if n not in top and not any(p.startswith(n + "/") or p == n for p in passed)]
if missing:
print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr)
sys.exit(5)
PY
}
go_json() {
local dir="$1"
shift
local log err rc=0 dc=0
log="$(mktemp)"
err="$(mktemp)"
(cd "$dir" && go test -json "$@") >"$log" 2>"$err" || rc=$?
detect "$log" || dc=$?
if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then
cat "$err" >&2 || true
grep -v '^{' "$log" | tail -n 40 >&2 || true
rm -f "$log" "$err"
echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2
return 1
fi
rm -f "$log" "$err"
}
expect_detect() {
local name="$1" want="$2" payload="$3" log dc=0
log="$(mktemp)"
printf '%s\n' "$payload" >"$log"
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
return 1
fi
}
run_self_test() {
bash -n "${BASH_SOURCE[0]}"
expect_detect pass 0 '{"Action":"pass","Package":"p","Test":"TestJournalEndToEnd"}'
expect_detect fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p","Test":"TestJournal005DraftShow"}'
expect_detect package-fail 1 '{"Action":"pass","Package":"p","Test":"TestA"}
{"Action":"fail","Package":"p"}'
expect_detect build 1 '{"Action":"build-fail","ImportPath":"p"}'
expect_detect skip 2 '{"Action":"skip","Package":"p","Test":"TestSearchGateOff"}'
expect_detect zero 3 '{"Action":"pass","Package":"p"}'
expect_detect no-tests 3 '{"Action":"output","Package":"p","Output":"testing: warning: no tests to run\n"}
{"Action":"pass","Package":"p"}'
expect_detect nonjson 4 '{"Action":"pass",'
expect_detect race 6 '{"Action":"output","Package":"p","Test":"TestA","Output":"WARNING: DATA RACE\n"}
{"Action":"pass","Package":"p","Test":"TestA"}'
REQUIRE_TESTS="TestJournalEndToEnd TestFillable" expect_detect missing-named 5 \
'{"Action":"pass","Package":"p","Test":"TestJournalEndToEnd"}'
local flag
for flag in --self-test --php --layout --plugin --host --forbidden --security --all; do
grep -q -- "^ $flag)" "${BASH_SOURCE[0]}" || {
echo "refuse: missing mode $flag" >&2
return 1
}
done
echo "phase15 self-test passed"
}
run_php() {
[[ -d "$PHP" ]] || {
echo "refuse: PHP pin tree $PHP is missing" >&2
return 1
}
local sha
sha="$(git -C "$PHP" rev-parse HEAD)"
if [[ "$sha" != "$PHP_SHA" ]]; then
echo "refuse: PHP SHA $sha, want $PHP_SHA" >&2
return 1
fi
if [[ -n "$(git -C "$PHP" status --porcelain)" ]]; then
git -C "$PHP" status --short >&2
echo "refuse: PHP pin tree has a diff" >&2
return 1
fi
echo "phase15 php passed ($sha)"
}
run_layout() {
[[ -f "$PLUGIN/go.mod" ]] || {
echo "refuse: plugin go.mod missing" >&2
return 1
}
grep -q '^module git.golem15.com/golem15/sm-journal-plugin$' "$PLUGIN/go.mod" || {
echo "refuse: plugin module path" >&2
return 1
}
grep -q '^replace git.golem15.com/golem15/summercms => ../summercms.go$' "$PLUGIN/go.mod" || {
echo "refuse: plugin must replace summercms => ../summercms.go" >&2
return 1
}
if grep -E '^replace .+sm-user-plugin|^replace .+sm-translate-plugin' "$PLUGIN/go.mod" >/dev/null; then
echo "refuse: plugin go.mod must not replace sibling plugins" >&2
return 1
fi
[[ -f "$HOST/go.work" && -f "$HOST/plugins.gen.go" && -f "$HOST/summer.yaml" ]] || {
echo "refuse: host layout is incomplete" >&2
return 1
}
local line
for path in plugins/golem15/user plugins/golem15/translate plugins/golem15/journal; do
line="$(git -C "$HOST" ls-files -s "$path")"
[[ "$line" == 160000* ]] || {
echo "refuse: $path is not a gitlink: $line" >&2
return 1
}
done
grep -q 'golem15.user' "$HOST/plugins.gen.go" || {
echo "refuse: plugins.gen.go missing golem15.user" >&2
return 1
}
grep -q 'golem15.translate' "$HOST/plugins.gen.go" || {
echo "refuse: plugins.gen.go missing golem15.translate" >&2
return 1
}
grep -q 'golem15.journal' "$HOST/plugins.gen.go" || {
echo "refuse: plugins.gen.go missing golem15.journal" >&2
return 1
}
if grep -E 'acme\.fixture' "$HOST/plugins.gen.go" >/dev/null; then
echo "refuse: production plugin list contains fixture" >&2
return 1
fi
if ! grep -q '_journal/api/\*' "$HOST/config/http.yaml"; then
echo "refuse: host CORS missing _journal/api/*" >&2
return 1
fi
echo "phase15 layout passed"
}
run_plugin() {
[[ -d "$PLUGIN" ]] || {
echo "refuse: plugin repository $PLUGIN not found" >&2
return 1
}
go -C "$PLUGIN" vet ./...
REQUIRE_TESTS="${PLUGIN_REQUIRE[*]}" go_json "$PLUGIN" ./... -count=1 -timeout 20m
REQUIRE_TESTS="${PLUGIN_REQUIRE[*]}" go_json "$PLUGIN" ./... -count=1 -race -timeout 25m
echo "phase15 plugin passed"
}
run_host() {
[[ -d "$HOST" ]] || {
echo "refuse: proof host $HOST not found" >&2
return 1
}
go -C "$HOST" vet ./...
REQUIRE_TESTS="${HOST_REQUIRE[*]}" go_json "$HOST" ./... -count=1 -timeout 5m
go -C "$HOST" build -o /tmp/phase15-host ./...
rm -f /tmp/phase15-host
echo "phase15 host passed"
}
run_forbidden() {
local bad=0 hits
hits="$(cd "$PLUGIN" && grep -RInE 'rainlab_journal_|winter_journal_' --include='*.go' . | grep -vE '_test\.go:' || true)"
if [[ -n "$hits" ]]; then
echo "refuse: Winter/RainLab journal table names in plugin Go: $hits" >&2
bad=1
fi
hits="$(cd "$PLUGIN" && grep -RInE 'plugin\.Open|yaegi' --include='*.go' . | grep -vE '_test\.go:' || true)"
if [[ -n "$hits" ]]; then
echo "refuse: runtime loading in production plugin: $hits" >&2
bad=1
fi
hits="$(cd "$PLUGIN" && grep -RInE '\.AutoMigrate\(' --include='*.go' . | grep -vE '_test\.go:' || true)"
if [[ -n "$hits" ]]; then
echo "refuse: AutoMigrate in production plugin: $hits" >&2
bad=1
fi
hits="$(cd "$PLUGIN" && grep -RInE 'sm-user-plugin' --include='*.go' . | grep -vE '_test\.go:' || true)"
if [[ -n "$hits" ]]; then
echo "refuse: production plugin imports sm-user-plugin: $hits" >&2
bad=1
fi
hits="$(cd "$PLUGIN" && grep -RInE 'fonoteka|p[lł]ytarium|grzybyfunkcjonalne' README.md || true)"
if [[ -n "$hits" ]]; then
echo "refuse: consuming-application name in plugin README: $hits" >&2
bad=1
fi
hits="$(cd "$PLUGIN" && grep -RInE 'Pages menu|dashboard widget|journalPost|journalPosts' --include='*.go' . | grep -vE '_test\.go:' || true)"
if [[ -n "$hits" ]]; then
echo "refuse: deferred Pages/dashboard/theme surface in production plugin: $hits" >&2
bad=1
fi
if [[ -n "$(git -C "$ROOT" diff -- modules/cabana/field_markdown.go)" ]]; then
echo "refuse: modules/cabana/field_markdown.go changed this phase (D-11 no-op)" >&2
bad=1
fi
local tide
tide="$(grep -RIn '/_journal/api/v1' "$FONOTEKA/parity" "$FONOTEKA/modules/tide" "$ROOT/modules/tide" 2>/dev/null || true)"
if [[ -n "$tide" ]]; then
echo "refuse: tide/parity harness newly mentions /_journal/api/v1: $tide" >&2
bad=1
fi
hits="$(gofmt -l "$PLUGIN" 2>/dev/null || true)"
if [[ -n "$hits" ]]; then
echo "refuse: gofmt: $hits" >&2
bad=1
fi
[[ "$bad" -eq 0 ]] || return 1
echo "phase15 forbidden passed"
}
run_security() {
[[ -f "$REVIEW" ]] || {
echo "refuse: missing $REVIEW" >&2
return 1
}
[[ -f "$VALIDATION" ]] || {
echo "refuse: missing $VALIDATION" >&2
return 1
}
local id count
for id in "${ALL_THREATS[@]}"; do
count="$(grep -c -- "$id" "$REVIEW" || true)"
if [[ "$count" -lt 1 ]]; then
echo "refuse: security review missing $id" >&2
return 1
fi
done
if grep -qiE 'unmitigated high' "$REVIEW"; then
echo "refuse: security review still has an unmitigated high finding" >&2
return 1
fi
for id in "${HIGH_THREATS[@]}"; do
grep -q -- "$id" "$REVIEW" || {
echo "refuse: high threat $id missing" >&2
return 1
}
grep -A2 -- "$id" "$REVIEW" | grep -qi mitigate || {
echo "refuse: high threat $id is not marked mitigate" >&2
return 1
}
done
if ! grep -q 'No external API integration' "$REVIEW" && ! grep -qi 'no external SaaS SDK' "$REVIEW"; then
echo "refuse: security review must state there is no external SaaS SDK" >&2
return 1
fi
if ! grep -q 'nyquist_compliant: true' "$VALIDATION"; then
echo "refuse: VALIDATION is not signed off" >&2
return 1
fi
echo "phase15 security passed"
}
run_all() {
local stage
for stage in self-test php layout plugin host forbidden security; do
if bash "${BASH_SOURCE[0]}" "--$stage"; then
echo "PASS $stage"
else
echo "FAIL $stage"
exit 1
fi
done
echo "Phase 15 gate passed"
}
case "${1:---all}" in
--self-test) run_self_test ;;
--php) run_php ;;
--layout) run_layout ;;
--plugin) run_plugin ;;
--host) run_host ;;
--forbidden) run_forbidden ;;
--security) run_security ;;
--all) run_all ;;
*) usage ;;
esac