mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 21:51:30 +00:00
ff3f1cd864
Audit 2026-05-11 A-8 closure. Closes the deferred Phase 2 leg of the
2026-05-10 HIGH-12 closure (b81588e) — 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.
scripts/ci-guards/ — Regression-guard scripts
Each <id>.sh script in this directory pins one closed audit finding from
regressing. CI runs the full set on every push via the
Regression guards step in .github/workflows/ci.yml. Operators can
run any script locally:
bash scripts/ci-guards/G-3-env-docs-drift.sh
Contract
Every script in this directory MUST:
- Be exit-code 0 on a clean repo (no regression present).
- Be exit-code non-zero on regression, with a
::error::annotation prefix so PR reviewers see the failing line in the GitHub Actions UI. - Be runnable from repo root via
bash scripts/ci-guards/<id>.shwith NO arguments and NO env-var requirements. The CI loop step (for g in scripts/ci-guards/*.sh; do bash "$g"; done) iterates every.shhere without args; any script that requires an arg or env var WILL fail in that loop. - Carry a head-comment block matching the in-source justification from the original ci.yml entry: the audit-finding reference, the closure rationale, the exempt-surface list (if any).
- Use
set -eearly to fail-fast on internal command errors. - Produce no output on the happy path beyond a final
echo "<id>: clean."confirmation line.
Helpers vs guards
Scripts that consume input artifacts (a test-output log, a
coverage.out file) or env vars (PR_NUMBER, GH_TOKEN) are
HELPERS, not guards. They live in scripts/, NOT scripts/ci-guards/.
Current helpers:
scripts/vendor-e2e-skip-check.sh— consumestest-output.logarg from the deploy-vendor-e2e jobscripts/coverage-pr-comment.sh— consumescoverage.out+PR_NUMBER+GH_TOKENenv from the go-build-and-test jobscripts/check-coverage-thresholds.sh— consumescoverage.out.github/coverage-thresholds.yml
scripts/qa-doc-part-count.sh+scripts/qa-doc-seed-count.sh— invoked viamake verify-docspre-tag, not in CI
Adding a new guard
- Drop a new
<id>.shin this directory with the head-comment block describing the audit finding it closes. - Make it executable:
chmod +x scripts/ci-guards/<id>.sh. - Verify it fails on a deliberate regression and passes on clean repo.
- CI auto-picks up new scripts via the
for g in scripts/ci-guards/*.shloop in theRegression guardsstep — no ci.yml change required.
The 22 guards in this directory
| ID | Finding | Catches |
|---|---|---|
G-1-jwt-auth-literal |
G-1 JWT silent auth downgrade | "jwt" literal in additive auth-type surfaces |
L-001-insecure-skip-verify |
L-001 unjustified InsecureSkipVerify | InsecureSkipVerify: true without //nolint:gosec |
H-001-bare-from |
H-001 (CWE-829) tag-swap attack | Bare FROM line without @sha256 digest pin |
M-012-no-root-user |
M-012 (CWE-250) container-as-root | Dockerfile missing terminal USER <non-root> |
H-009-readme-jwt |
H-009 README JWT advertising | README.md re-introducing JWT-as-supported claim |
G-2-api-key-hash-json |
G-2 cat-s5-apikey_leak | api_key_hash in JSON-emitting surface |
U-2-plaintext-healthcheck |
U-2 healthcheck protocol mismatch | Plaintext http:// in HEALTHCHECK directive |
U-3-migration-mount |
U-3 seed initdb schema drift | Migration file mounted into postgres initdb |
D-1-D-2-statusbadge-phantom |
D-1 + D-2 dead keys + TS phantoms | StatusBadge dead keys + 5 Certificate / 5 Agent / 1 Issuer / 1 Notification phantom fields |
L-1-bulk-action-loop |
L-1 client-side bulk loops | for ... await triggerRenewal/updateCertificate in CertificatesPage |
B-1-orphan-crud |
B-1 orphan-CRUD client fns | 8 update/create/delete fns lose their page consumer |
S-2-strings-contains-err |
S-2 brittle error-dispatch | strings.Contains(err.Error(), "not found"|"violates foreign key") in handlers |
G-3-env-docs-drift |
G-3 env-var docs drift | CERTCTL_* env var defined OR documented but not both |
test-naming-convention |
I-001-extended | func TestXxx (lowercase first letter) — Go silently skips |
S-1-hardcoded-source-counts |
S-1 stale numeric prose | Hardcoded "N issuer connectors" / "N MCP tools" in README + docs |
P-1-documented-orphan-fns |
P-1 documented orphans | 16 read-fn names removed from client.ts exports |
T-1-frontend-page-coverage |
T-1 untested frontend pages | New page in web/src/pages/ without sibling .test.tsx and not on the deferred allowlist |
bundle-8-L-015-target-blank-rel-noopener |
L-015 (CWE-1022) reverse-tabnabbing | target="_blank" without rel="noopener noreferrer" |
bundle-8-L-019-dangerously-set-inner-html |
L-019 (CWE-79) XSS | dangerouslySetInnerHTML outside safeHtml.ts |
bundle-8-M-009-bare-usemutation |
M-009 + M-029 mutation contract | Bare useMutation() outside useTrackedMutation wrapper |
H-1-encryption-key-min-length |
H-1 closure follow-up (post-Phase-5 surfacing) | CERTCTL_CONFIG_ENCRYPTION_KEY literal in any deploy/docker-compose*.yml shorter than the 32-byte floor enforced by internal/config/config.go::Validate() |
test-compose-scep-coherence |
post-Phase-5 surfacing of dead SCEP test config | CERTCTL_SCEP_ENABLED=true in test compose without (a) a CI job that runs the SCEP integration test, (b) the ra.crt + ra.key + intune_trust_anchor.pem fixtures committed to deploy/test/fixtures/, AND (c) the matching volume mount |
Guards explicitly NOT here
QA-doc Part-count drift+QA-doc seed-count drift— these protect docs-the-operator-reads, not anything the product depends on. Moved tomake verify-docs(operator runs pre-tag, not on every push). See the ci-pipeline-cleanup spec, Phase 11.
Running the full set locally
for g in scripts/ci-guards/*.sh; do
echo "=== $(basename "$g") ==="
bash "$g" || echo " FAILED"
done