Files
certctl/scripts/ci-guards/no-new-synthetic-admin.sh
T
shankar0123 a923cf697c harden(auth): demo-mode residual-grants detector + cleanup endpoint + CI guard (A-8)
Audit 2026-05-11 A-8 closure. Closes the deferred Phase 2 leg of the
2026-05-10 HIGH-12 closure (2e97cc1) — production-startup observability
for actor-demo-anon residual grants + CI guard banning new synthetic-
admin code paths.

What this changes:

* cmd/server/preflight_demo_residual.go (new) runs after the DB pool +
  audit service are constructed and before the HTTPS listener starts.
  Under any non-'none' auth type it queries actor_roles for the
  synthetic actor-demo-anon and emits a WARN log + a categorized audit
  row (auth.demo_residual_grants_detected) listing every grant
  present. Migration 000029 unconditionally seeds the ar-demo-anon-admin
  row at install time, so EVERY production deploy will see this WARN
  on first boot; the intended cutover workflow is cleanup-once at
  production handover.

* CERTCTL_DEMO_MODE_RESIDUAL_STRICT (new env var on AuthConfig,
  default false) pivots the WARN to fail-closed startup refusal for
  operators who want a paranoid posture against re-seeding.

* POST /api/v1/auth/demo-residual/cleanup (new handler at
  internal/api/handler/demo_residual.go) is an admin-class
  (auth.role.assign) endpoint that removes every actor-demo-anon row
  from actor_roles and returns {removed: int64}. Idempotent; refuses
  503 under Auth.Type=none (deleting the row would break the demo
  path); audit-logs every invocation including no-op zero-removed
  calls so the admin's action is always recorded.

* scripts/ci-guards/no-new-synthetic-admin.sh pins the 17-entry
  allowlist of source files that legitimately reference the
  actor-demo-anon literal. New runtime code paths that resolve to the
  synthetic actor (the same pattern that produced the original CRIT
  class) are rejected at PR time. CI workflow auto-picks the script
  via the existing scripts/ci-guards/*.sh loop in .github/workflows/
  ci.yml; no workflow edit needed.

Regression matrix:

* cmd/server/preflight_demo_residual_test.go — 7 tests covering the
  4 main behaviour branches (testcontainers-backed, testing.Short()-
  skipped: DemoModeActive_Skips, NoResidue_Passes, HasResidue_LogsAnd
  Audits, StrictMode_RefusesStartup, DeleteDemoAnonResidue_Idempotent)
  plus 3 pure-Go stdlib unit tests for the row-string formatter +
  nil-safety contracts on both helpers.

* internal/api/handler/demo_residual_test.go — 7 stdlib+httptest
  cases: HappyPath, Idempotent_ReturnsZero, RejectsInDemoMode (503),
  CleanupError_Surfaces500, NilCleanupFn (defensive 500),
  NilAuditWriter_DoesNotPanic, MissingActorContext (falls back to
  'unknown' actor in the audit row).

* internal/api/router/openapi_parity_test.go — new
  POST /api/v1/auth/demo-residual/cleanup entry plus 6 pre-existing
  pre-A-8 entries (oidc/test, jwks-status, users CRUD, runtime-config)
  that had drifted out of SpecParityExceptions; the parity test was
  red on dev/auth-bundle-2 before my work; this commit returns it to
  green with full per-entry justifications + parity-debt notes.

Docs:

* docs/operator/security.md — new 'Demo-to-production cutover (Audit
  2026-05-11 A-8)' section explaining the WARN message, the cleanup
  curl one-liner, the equivalent SQL, the strict-mode env var, and
  the CI guard.

* docs/operator/rbac.md — Last-reviewed bump + pointer to the new
  env var + the security.md section.

* cowork/auth-bundles-audit-2026-05-10.md — HIGH-12 row gains an
  'A-8 follow-on CLOSED 2026-05-11' annotation describing the
  deferred Phase 2 leg now landed.

* CHANGELOG.md — Unreleased ### Security entry summarizing the four
  legs (detector + cleanup + strict-mode flag + CI guard) and the
  acquisition-readiness narrative this closes.

Operator-facing impact: this closes a credibility gap, not an
exploitable vulnerability. The residue requires a regression
elsewhere in the middleware chain to be exploitable. After this
fix, the canonical narrative ('RBAC primitive with no synthetic-
admin fallback') is fully true.

Refs cowork/auth-bundles-fixes-2026-05-11/08-high-demo-mode-residual-
cleanup.md.
2026-05-11 11:45:54 +00:00

75 lines
3.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# Audit 2026-05-11 A-8 — no new code paths may reference actor-demo-anon
# outside the declared allowlist. The synthetic actor is a load-bearing
# demo-mode primitive but ANY new reference in production code paths is
# a candidate footgun (the original CRIT class was a fallback that
# resolved unauthenticated requests to this actor and got full admin).
#
# Adding a legitimate new reference? Add the file to ALLOWLIST below
# AND describe the reason in this header. Operators (auditors) read
# this script to understand where the synthetic admin "lives" in the
# codebase.
#
# Test files (*_test.go), /vendor/, /docs/, and CHANGELOG entries are
# excluded — they don't introduce new runtime code paths.
set -euo pipefail
# Files that legitimately reference the actor-demo-anon literal in
# source. Each entry needs a one-line rationale comment so future
# maintainers don't have to trace why it's here.
ALLOWLIST=(
"./cmd/server/main.go" # HandlerRegistry comment + DemoResidual wiring
"./cmd/server/preflight_demo_residual.go" # A-8 detector + cleanup helpers
"./internal/api/handler/auth.go" # interface docstring for ListKeys
"./internal/api/handler/demo_residual.go" # A-8 cleanup endpoint
"./internal/api/router/router.go" # routing comment for cleanup endpoint
"./internal/auth/context.go" # const DemoAnonActorID source-of-truth (canonical)
"./internal/auth/middleware.go" # NewDemoModeAuth — injects synthetic actor under Type=none
"./internal/cli/auth_scope_down.go" # interactive prompt filter
"./internal/config/config.go" # validate-time guard comments + DemoModeResidualStrict env var
"./internal/domain/audit.go" # audit-event documentation comment
"./internal/domain/auth/validate.go" # const DemoAnonActorID mirror
"./internal/mcp/tools_auth.go" # MCP tool description for ListKeys + Revoke
"./internal/mcp/types.go" # MCP request-schema description
"./internal/repository/auth.go" # ActorRoleRepository interface docstrings
"./internal/service/auth/actor_role_service.go" # reserved-actor mutation guard (CRIT-1 closure)
"./internal/service/auth/authorizer.go" # synthetic-actor authorization comment
"./scripts/ci-guards/no-new-synthetic-admin.sh" # this script itself
)
declare -A allow=()
for loc in "${ALLOWLIST[@]}"; do allow["$loc"]=1; done
violations=()
# rg/grep with -l prints filenames. We exclude test files, vendored
# code, docs (operator-facing prose), and CHANGELOG markdown.
while IFS= read -r file; do
[ -z "$file" ] && continue
if [ -z "${allow[$file]:-}" ]; then
violations+=("$file")
fi
done < <(grep -rln 'actor-demo-anon' \
--include='*.go' --include='*.sh' . \
2>/dev/null \
| grep -v '_test\.go$' \
| grep -v '^\./vendor/' \
| grep -v '^\./docs/' \
| grep -v '^\./CHANGELOG\.md$' \
| sort -u)
if [ ${#violations[@]} -gt 0 ]; then
printf 'A-8 GUARD FAIL: new actor-demo-anon reference outside the established allowlist:\n'
printf ' %s\n' "${violations[@]}"
printf '\n'
printf 'If this reference is legitimate, add the file to ALLOWLIST in\n'
printf ' scripts/ci-guards/no-new-synthetic-admin.sh\n'
printf 'WITH a rationale comment describing why the synthetic admin\n'
printf 'literal needs to appear there. Otherwise, route through the\n'
printf 'public DemoAnonActorID constant or refactor the new code path\n'
printf 'to NOT reference the synthetic actor at all (preferred).\n'
exit 1
fi
echo "A-8 guard PASS — actor-demo-anon references confined to the declared ${#ALLOWLIST[@]}-entry allowlist."