name: CI on: push: branches: - master - v2-dev pull_request: branches: - master jobs: go-build-and-test: name: Go Build & Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go uses: actions/setup-go@v5 with: go-version: '1.25.9' - name: Go Build run: | go build ./cmd/server/... go build ./cmd/agent/... go build ./cmd/mcp-server/... go build ./cmd/cli/... - name: Go Vet run: go vet ./... - name: Install golangci-lint run: | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.11.4 - name: Run golangci-lint run: golangci-lint run ./... --timeout 5m - name: Install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: Run govulncheck (M-024 hard gate) # Bundle-7 / D-001 partial: govulncheck distinguishes called-vs-uncalled # advisories. Default exit code is non-zero only when YOUR code calls # the vulnerable function — deferred-call advisories show up in the # output but don't fail the gate. # # Bundle F / Audit M-024 (NIST SSDF PW.7.2): the govulncheck step # is now a hard CI gate (no `continue-on-error`). Bundle E's # transitive bumps (x/net 0.42→0.47, x/crypto 0.41→0.45) cleared # the 5 deferred-call advisories that were previously on the # exception list, so the carve-out the original Bundle F prompt # designed is unnecessary — a clean `govulncheck ./...` is the # right gate. If a future advisory lands in a function our code # does call, this step fails the build until either upstream # ships a fix OR we cut the dep. Deferred-call advisories that # legitimately can't be remediated yet should be added to the # NIST SSDF deviation log in docs/security.md, not silenced here. run: govulncheck ./... - name: Install staticcheck (Bundle-7 / D-001) run: go install honnef.co/go/tools/cmd/staticcheck@latest - name: Run staticcheck # Bundle-7 / D-001: Go static analysis additive to vet. Suppressed # rules live in staticcheck.conf with documented justifications; # adding a new entry requires an explicit security review. # # SOFT gate (continue-on-error: true) until M-028 closes the 6 # remaining SA1019 deprecated-API sites: # - cmd/server/main_test.go × 3: middleware.NewAuth → NewAuthWithNamedKeys # - internal/api/handler/scep.go: csr.Attributes → Extensions # - internal/connector/issuer/local/local.go: elliptic.Marshal → crypto/ecdh # When M-028 ships, flip continue-on-error to false to make this # a hard gate. Until then, the step still annotates findings on PRs. continue-on-error: true run: staticcheck ./... - name: Forbidden auth-type literal regression guard (G-1) # G-1 closed the JWT silent auth downgrade by removing "jwt" from the # accepted CERTCTL_AUTH_TYPE values. This step grep-fails the build # if "jwt" reappears in any of the *additive* auth-type surfaces: # the validAuthTypes / ValidAuthTypes() set, the OpenAPI enum, the # helm chart's allowed-types list, or the .env.example default. # Comment lines and the dedicated rejection branch in config.go # (`c.Auth.Type == "jwt"`) are intentionally exempt — those are the # G-1 fix itself, not a regression. # # Connector packages (internal/connector/) are exempt because the # Google OAuth2 service-account JWT and step-ca provisioner one- # time-token JWT are external-protocol uses, unrelated to certctl's # own auth shape. Test files (_test.go) are exempt so negative # tests can pass the literal. # # See docs/upgrade-to-v2-jwt-removal.md for the closure rationale, # or internal/config/config.go::ValidAuthTypes for the allowed set. run: | set -e # Scoped patterns that indicate "jwt" being added back to an # allowed-set surface. Each catches a regression shape we've # actually seen in pre-G-1 code: # - Go map/slice literal: "jwt": true or "jwt", # - Go switch case: case "jwt" # - YAML enum: enum: [..., jwt, ...] or - jwt # - .env conditional: AUTH_TYPE.*"jwt"|=jwt$ BAD=$(grep -rnEH \ -e '"jwt"\s*:\s*true' \ -e '"jwt"\s*,' \ -e 'case\s+"jwt"' \ -e 'enum:.*\bjwt\b' \ -e '^\s*-\s*jwt\s*$' \ -e 'AUTH_TYPE\s*=\s*jwt\s*$' \ -e 'AUTH_TYPE\s*=\s*jwt\s*#' \ -e 'auth\.type\s*=\s*jwt\s*$' \ -e 'AuthType\("jwt"\)' \ internal/config/ \ internal/api/ \ cmd/ \ api/openapi.yaml \ .env.example \ deploy/.env.example \ deploy/helm/certctl/values.yaml \ deploy/helm/certctl/templates/ \ 2>/dev/null \ | grep -v '_test.go' \ | grep -vE '^\s*[^:]+:[0-9]+:\s*(//|#)' \ | grep -v 'is no longer accepted' \ || true) if [ -n "$BAD" ]; then echo "G-1 regression: \"jwt\" reappeared in an allowed-set surface:" echo "$BAD" echo "" echo "Allowed surface for 'jwt' literals: comment lines, the" echo "dedicated rejection branch in internal/config/config.go," echo "and connector packages (Google OAuth2, step-ca)." echo "See docs/upgrade-to-v2-jwt-removal.md and" echo "internal/config/config.go::ValidAuthTypes()." exit 1 fi - name: Forbidden bare InsecureSkipVerify regression guard (L-001) # L-001 audited every production InsecureSkipVerify=true call site # and documented the justification per site in docs/tls.md. This # step grep-fails the build if any new `InsecureSkipVerify: true` # lands in a non-test Go file without a `//nolint:gosec` comment # carrying the justification. Test files (_test.go) are exempt. # Updating the documented surface goes through the docs/tls.md # table — net-new sites must be reasoned about before merge. run: | set -e # Find every "InsecureSkipVerify: true" or "InsecureSkipVerify = true" # in a non-test .go file. Then for each, check the same line OR the # immediately preceding line for `//nolint:gosec`. BAD="" while IFS= read -r match; do file=$(echo "$match" | cut -d: -f1) line=$(echo "$match" | cut -d: -f2) same=$(sed -n "${line}p" "$file" 2>/dev/null) prev=$(sed -n "$((line - 1))p" "$file" 2>/dev/null) if echo "$same $prev" | grep -q 'nolint:gosec'; then continue fi BAD="$BAD\n$match" done < <(grep -rnE 'InsecureSkipVerify:\s*true|InsecureSkipVerify\s*=\s*true' \ --include='*.go' \ --exclude='*_test.go' \ . || true) if [ -n "$BAD" ]; then echo "::error::New InsecureSkipVerify=true site without //nolint:gosec justification:" echo -e "$BAD" echo "" echo "Add a //nolint:gosec comment with justification on the same" echo "or preceding line, AND add a row to the docs/tls.md table." exit 1 fi - name: Forbidden bare FROM regression guard (H-001) # Bundle A / Audit H-001 (CWE-829): every FROM line in every # Dockerfile in the repo MUST carry an @sha256:... digest pin in # addition to the human-readable tag. A registry-side tag swap # cannot then change what we pull. This step grep-fails the # build if any new FROM lands without the @sha256 suffix. run: | set -e # Match any "FROM image[:tag]" that does NOT contain @sha256. # Strip comments and blank lines defensively. BAD=$(find . -name 'Dockerfile*' -not -path './web/node_modules/*' \ -exec grep -HnE '^FROM\s+[^@#]+(\s+AS\s+\S+)?\s*$' {} \; || true) if [ -n "$BAD" ]; then echo "::error::Dockerfile has bare FROM (no @sha256 digest pin):" echo "$BAD" echo "" echo "Pin every FROM to an immutable digest. See the bump" echo "procedure in Dockerfile's header comment (Bundle A / H-001)." exit 1 fi - name: Forbidden missing USER regression guard (M-012) # Bundle A / Audit M-012 (CWE-250): every Dockerfile in the repo # MUST end with a `USER ` directive before the # ENTRYPOINT/CMD so the container never runs as uid=0. This step # grep-fails the build if any Dockerfile is missing such a USER. # `USER root` and `USER 0` are explicitly rejected. run: | set -e BAD="" for df in $(find . -name 'Dockerfile*' -not -path './web/node_modules/*'); do # Find the LAST USER directive in the file. last_user=$(grep -E '^USER\s+\S+' "$df" | tail -1 | awk '{print $2}') if [ -z "$last_user" ]; then BAD="$BAD\n$df: no USER directive at all" continue fi if [ "$last_user" = "root" ] || [ "$last_user" = "0" ]; then BAD="$BAD\n$df: terminal USER is $last_user (must drop privileges)" continue fi done if [ -n "$BAD" ]; then echo "::error::Dockerfile USER-drop regression:" echo -e "$BAD" exit 1 fi - name: Forbidden README JWT advertising regression guard (H-009) # H-009 closed by Bundle D as verified-already-clean: at audit time # the README does NOT advertise JWT support (certctl does not ship # in-process JWT middleware; JWT/OIDC integration is via an # authenticating gateway, see docs/architecture.md "Authenticating- # gateway pattern"). This step grep-fails the build if README ever # re-introduces a sentence advertising JWT as a supported auth mode. # Pattern: "JWT" within ~6 words of "support|auth|enabled|mode" in # README.md. The architecture / compliance / connector docs that # legitimately mention JWT (Google OAuth2 service-account JWT, # step-ca provisioner JWT, JWT-via-gateway pattern) are out of # scope — they describe what certctl does NOT do, or external # protocol uses. run: | set -e if grep -inE 'JWT.{0,40}(support|auth|enabled|mode|provider)' README.md \ | grep -v 'gateway' | grep -v 'pre-G-1'; then echo "::error::README.md appears to advertise JWT auth support." echo "certctl does NOT ship in-process JWT middleware. JWT/OIDC" echo "integration is via an authenticating gateway — see" echo "docs/architecture.md::Authenticating-gateway pattern." echo "If you added a sentence about JWT to README, either remove" echo "it or rewrite it to point at the gateway pattern." exit 1 fi - name: Forbidden api_key_hash JSON-shape regression guard (G-2) # G-2 closed cat-s5-apikey_leak by tagging Agent.APIKeyHash # `json:"-"` and adding a defense-in-depth Agent.MarshalJSON that # zeroes the field on the marshal-time copy. This step grep-fails # the build if `api_key_hash` reappears in any of the *additive* # JSON-emitting surfaces: a Go struct json tag in internal/domain/, # an OpenAPI Agent schema property, a TypeScript field declaration # in web/src/, or an enum-list / discriminator in handler # production code. # # Repository, migration, seed, service, integration-test, and # unit-test files are exempt — those are server-internal use # sites (the DB column stays, the in-memory struct field stays, # the auth-lookup path stays). Comment lines are exempt so the # G-2 closure rationale can stay in the source. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-s5-apikey_leak for the closure rationale, or # internal/domain/connector.go::Agent::MarshalJSON for the # redaction enforcement. run: | set -e # Scoped patterns that indicate api_key_hash being added back # to a JSON-emitting surface. Each catches a regression shape # that pre-G-2 actually shipped or that a future refactor # could plausibly introduce: # - Go struct tag: `json:"api_key_hash"` # - Frontend interface: api_key_hash[?]: string # - OpenAPI schema property: api_key_hash: (column-aligned) # - YAML enum / array: - api_key_hash BAD=$(grep -rnEH \ -e 'json:"api_key_hash[",]' \ -e '^\s*api_key_hash\??\s*:' \ -e '^\s*-\s*api_key_hash\s*$' \ internal/domain/ \ internal/api/ \ cmd/ \ api/openapi.yaml \ web/src/ \ 2>/dev/null \ | grep -v '_test.go' \ | grep -vE '^\s*[^:]+:[0-9]+:\s*(//|#)' \ || true) if [ -n "$BAD" ]; then echo "G-2 regression: api_key_hash reappeared in a JSON-emitting surface:" echo "$BAD" echo "" echo "Allowed surface for api_key_hash literals: comment lines," echo "the database column (migrations/), the in-memory struct" echo "field tagged \`json:\"-\"\`, and the repository / service" echo "use sites. See internal/domain/connector.go::Agent and" echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md" echo "cat-s5-apikey_leak for the closure rationale." exit 1 fi - name: Forbidden plaintext HEALTHCHECK regression guard (U-2) # U-2 closed cat-u-healthcheck_protocol_mismatch by switching the # published image's HEALTHCHECK from `curl -f http://localhost: # 8443/health` (always failed against the HTTPS-only listener) to # `curl -fsk https://localhost:8443/health`. This step grep-fails # the build if any Dockerfile in the repo carries the pre-U-2 # plaintext shape — either explicitly (`http://localhost:8443/ # health` in a HEALTHCHECK) or via the looser pattern of any # HEALTHCHECK that targets `http://` against the certctl server # port. # # Comment lines and the docs/upgrade-to-tls.md:182 expected-to- # fail invariant ("plaintext is gone, expect Connection refused") # are intentionally exempt — we DO want the upgrade-doc string # `http://localhost:8443/health` to remain there, since it # documents what operators should test for to confirm plaintext # is dead. The guardrail is scoped to Dockerfile* only, so docs # are out of its reach. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-u-healthcheck_protocol_mismatch for the closure rationale, # or deploy/test/healthcheck_test.go for the binary-image # contract the runtime test pins. run: | set -e # Patterns that catch the actual regression shapes: # - HEALTHCHECK directive carrying any http:// (even if the # port differs, no plaintext probe should ship). # - The exact pre-U-2 string for grep-friendliness. BAD=$(grep -rnEH \ -e 'HEALTHCHECK.*http://' \ -e 'curl[^|&;]*-f[^|&;]*http://localhost:8443/health' \ Dockerfile Dockerfile.agent Dockerfile.* 2>/dev/null \ | grep -vE '^\s*[^:]+:[0-9]+:\s*#' \ || true) if [ -n "$BAD" ]; then echo "U-2 regression: plaintext HEALTHCHECK reappeared in a Dockerfile:" echo "$BAD" echo "" echo "Allowed: HTTPS HEALTHCHECK with -k (acceptable for" echo "localhost-to-localhost), or non-HTTP probe shapes" echo "(pgrep, /proc check). See Dockerfile / Dockerfile.agent" echo "for the post-U-2 reference shape and" echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md" echo "cat-u-healthcheck_protocol_mismatch for rationale." exit 1 fi - name: Forbidden migration mount in compose initdb (U-3) # U-3 closed cat-u-seed_initdb_schema_drift (GitHub #10) by # eliminating the dual-source-of-truth between # `migrations/*.up.sql` mounted into postgres # `/docker-entrypoint-initdb.d/` and the same files re-applied at # runtime by `RunMigrations`. Pre-U-3 every new migration that # the seed depended on (000013 added `policy_rules.severity`, # 000017 renames `retry_interval_seconds`, etc.) had to be added # by hand to the compose mount list; missing the update crashed # initdb on first boot, postgres flagged unhealthy, and the # whole stack failed to start from a fresh clone. Post-U-3 the # server is the single source of truth — `RunMigrations` + # `RunSeed` apply everything at boot. # # This step grep-fails the build if any compose file under # `deploy/` re-introduces a `migrations/.*\.sql` mount into # `/docker-entrypoint-initdb.d`. Comments are exempt so the # post-fix rationale block in the compose files (which # documents WHY the mounts were removed) doesn't trip the guard. # The demo overlay's `seed_demo.sql` is the explicit exception: # it is tolerated only when it lives behind the # CERTCTL_DEMO_SEED env var (post-U-3 demo path) — bare initdb # mounts are NOT tolerated. The grep matches all compose # mount-list shapes (`-` indented, `volumes:` indented, both), # so any future drift surfaces here before the operator hits it # on a fresh clone. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-u-seed_initdb_schema_drift for the closure rationale, or # internal/repository/postgres/db.go::RunSeed for the runtime # contract. run: | set -e BAD=$(grep -rnEH \ -e 'migrations/.*\.sql:.*docker-entrypoint-initdb' \ -e 'seed.*\.sql:.*docker-entrypoint-initdb' \ deploy/docker-compose.yml \ deploy/docker-compose.test.yml \ deploy/docker-compose.demo.yml \ 2>/dev/null \ | grep -vE '^\s*[^:]+:[0-9]+:\s*#' \ || true) if [ -n "$BAD" ]; then echo "U-3 regression: migration/seed mount into postgres initdb reappeared:" echo "$BAD" echo "" echo "The post-U-3 contract is: postgres comes up with an empty" echo "schema and the server applies migrations + seed at boot via" echo "internal/repository/postgres.RunMigrations + RunSeed. Demo" echo "data lives behind CERTCTL_DEMO_SEED=true (RunDemoSeed)," echo "not an initdb mount. See" echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md" echo "cat-u-seed_initdb_schema_drift for the closure rationale." exit 1 fi - name: Forbidden StatusBadge dead-key + TS phantom-field regression guard (D-1 + D-2) # D-1 master closed cat-d-359e92c20cbf (Agent: 'Stale' dead key, # 'Degraded' missing), cat-d-9f4c8e4a91f1 (Notification: 'dead' # missing), cat-d-1447e04732e7 (Cert: 'PendingIssuance' dead # key), cat-f-cert_detail_page_key_render_fallback (render-site # uses cert.X directly), and cat-f-ae0d06b6588f (Certificate # TS phantom fields). This step grep-fails the build if either # half of the closure is reverted: # # 1. The dead StatusBadge keys ('Stale' for Agent, 'PendingIssuance' # for Cert) reappearing as map literals, OR # 2. The five phantom Certificate TS fields (serial_number, # fingerprint_sha256, key_algorithm, key_size, issued_at) # reappearing on the `Certificate` interface in types.ts # (CertificateVersion legitimately carries them and is # explicitly excluded by the awk pre-filter below). # # Comments are exempt so the closure prose in StatusBadge.tsx + # types.ts can stay. Test files are exempt so negative tests # asserting the dead keys fall through to neutral keep working. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-d-* / cat-f-* for the closure rationale, or # web/src/components/StatusBadge.test.tsx for the live # enum-coverage contract. run: | set -e BAD_BADGE=$(grep -nE "^\s*(Stale|PendingIssuance)\s*:\s*'badge-" \ web/src/components/StatusBadge.tsx 2>/dev/null \ | grep -v '\.test\.' \ | grep -vE '^\s*[^:]+:[0-9]+:\s*//' \ || true) if [ -n "$BAD_BADGE" ]; then echo "D-1 regression: dead StatusBadge key reappeared:" echo "$BAD_BADGE" echo "" echo "Allowed surface: comment lines naming the removed key in" echo "the file's preamble. The Go-side AgentStatus values are" echo "Online/Offline/Degraded (no Stale); CertificateStatus values" echo "are Pending/Active/... (no PendingIssuance). See" echo "web/src/components/StatusBadge.test.tsx for the contract." exit 1 fi # Certificate TS phantom-field check. Scoped to the # `export interface Certificate {` block in web/src/api/types.ts # — CertificateVersion legitimately declares these fields and # must NOT trip the guardrail. The awk window opens on the # exact `Certificate {` header (not `CertificateVersion {`, # not `CertificateProfile {`) and closes at the first `}`, # then the grep matches a phantom-field declaration anywhere # in that window. BAD_TS=$(awk ' /^export interface Certificate \{/ { flag=1; next } flag && /^\}/ { flag=0 } flag { print FILENAME":"NR":"$0 } ' web/src/api/types.ts \ | grep -E '\b(serial_number|fingerprint_sha256|key_algorithm|key_size|issued_at)\??\s*:' \ || true) if [ -n "$BAD_TS" ]; then echo "D-1 regression: Certificate TS interface re-added a phantom field:" echo "$BAD_TS" echo "" echo "These fields live on CertificateVersion, not ManagedCertificate." echo "The Go-side ManagedCertificate has never carried them; the" echo "TS optional declarations were silently undefined on every" echo "list response. Render-site consumers (e.g. CertificateDetailPage)" echo "use latestVersion?.field as the canonical access path." echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md" echo "cat-f-ae0d06b6588f for the closure rationale." exit 1 fi # D-2 master closed five diff-05x06-* type-drift findings: # Agent (5 phantoms), Issuer (1 phantom), Notification (1 phantom) # — TRIM half. The Target (2 missing fields) and DiscoveredCertificate # (1 missing field) — ADD half is pinned by the literal-construction # blocks in web/src/api/types.test.ts, not a CI grep. The phantom- # trim regression vector is an awk-windowed grep per interface # mirroring the D-1 Certificate check above. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # diff-05x06-7cdf4e78ae24 (Agent), diff-05x06-97fab8783a5c (Issuer), # diff-05x06-caba9eb3620e (Notification) for the closure rationale. # D-2 Agent phantom-field check. The grep matches `last_heartbeat` # but NOT `last_heartbeat_at` (the legitimate Go-emitted field) — # the `\b...\b` boundaries plus the `grep -v 'last_heartbeat_at'` # filter handle that. BAD_AGENT=$(awk ' /^export interface Agent \{/ { flag=1; next } flag && /^\}/ { flag=0 } flag { print FILENAME":"NR":"$0 } ' web/src/api/types.ts \ | grep -E '\b(last_heartbeat|capabilities|tags|created_at|updated_at)\??\s*:' \ | grep -v 'last_heartbeat_at' \ || true) if [ -n "$BAD_AGENT" ]; then echo "D-2 regression: Agent TS interface re-added a phantom field:" echo "$BAD_AGENT" echo "" echo "The Go-side internal/domain/connector.go::Agent emits exactly:" echo "id, name, hostname, status, last_heartbeat_at?, registered_at," echo "os, architecture, ip_address, version, retired_at?, retired_reason?." echo "The five fields blocked by this guard (last_heartbeat," echo "capabilities, tags, created_at, updated_at) were TS phantoms" echo "the Go struct never emitted. See unified-audit.md" echo "diff-05x06-7cdf4e78ae24 for closure rationale." exit 1 fi # D-2 Issuer phantom-field check. BAD_ISSUER=$(awk ' /^export interface Issuer \{/ { flag=1; next } flag && /^\}/ { flag=0 } flag { print FILENAME":"NR":"$0 } ' web/src/api/types.ts \ | grep -E '\bstatus\??\s*:' \ || true) if [ -n "$BAD_ISSUER" ]; then echo "D-2 regression: Issuer TS interface re-added a phantom 'status' field:" echo "$BAD_ISSUER" echo "" echo "The Go-side internal/domain/connector.go::Issuer has no 'status'" echo "field — only 'enabled' (bool). Render sites derive the displayed" echo "status from 'enabled' at the call site (see" echo "web/src/pages/IssuersPage.tsx::issuerStatus). See unified-audit.md" echo "diff-05x06-97fab8783a5c for closure rationale." exit 1 fi # D-2 Notification phantom-field check. BAD_NOTIF=$(awk ' /^export interface Notification \{/ { flag=1; next } flag && /^\}/ { flag=0 } flag { print FILENAME":"NR":"$0 } ' web/src/api/types.ts \ | grep -E '\bsubject\??\s*:' \ || true) if [ -n "$BAD_NOTIF" ]; then echo "D-2 regression: Notification TS interface re-added a phantom 'subject' field:" echo "$BAD_NOTIF" echo "" echo "The Go-side internal/domain/notification.go::NotificationEvent" echo "has no 'subject' field — only 'message'. Pre-D-2 the consumer" echo "at NotificationsPage.tsx had a dead '|| n.subject' fallback" echo "that always fell through. See unified-audit.md" echo "diff-05x06-caba9eb3620e for closure rationale." exit 1 fi - name: Forbidden client-side bulk-action loop regression guard (L-1) # L-1 master closed cat-l-fa0c1ac07ab5 (bulk-renew loop) and # cat-l-8a1fb258a38a (bulk-reassign loop) by adding server-side # bulk endpoints (POST /api/v1/certificates/bulk-renew and # POST /api/v1/certificates/bulk-reassign) that the GUI calls # in a single round-trip. Pre-L-1 the GUI looped per-cert # HTTP calls — 100 selected certs = 100 round-trips × ~50–200ms # each = a 5–20-second wedge during which the operator stares # at a progress bar. # # This step grep-fails the build if either loop shape reappears # in CertificatesPage.tsx. Patterns catch the actual pre-L-1 # shapes: # - `for (const id of ids) { await triggerRenewal(id) }` # - `for (const id of ids) { await updateCertificate(id, { owner_id }) }` # - `for (let i = 0; i < ids.length; i++) { await triggerRenewal(ids[i]) }` # # Allowed: comment lines explaining the pre-L-1 pattern in the # docblock above each handler. Test files (_test.tsx) exempt # so negative-pattern tests can keep working. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-l-fa0c1ac07ab5 and cat-l-8a1fb258a38a for closure # rationale, or web/src/api/client.ts::bulkRenewCertificates # / bulkReassignCertificates for the canonical call path. run: | set -e BAD_LOOP=$(grep -nE 'for[[:space:]]*\(' web/src/pages/CertificatesPage.tsx 2>/dev/null \ | grep -E 'await[[:space:]]+(triggerRenewal|updateCertificate)\(' \ | grep -v '\.test\.' \ | grep -vE '^\s*[^:]+:[0-9]+:\s*//' \ || true) if [ -n "$BAD_LOOP" ]; then echo "L-1 regression: client-side bulk-action loop reappeared in CertificatesPage.tsx:" echo "$BAD_LOOP" echo "" echo "Use bulkRenewCertificates({ certificate_ids: [...] }) or" echo "bulkReassignCertificates({ certificate_ids: [...], owner_id, team_id? })" echo "instead of looping per-item HTTP calls. See" echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-l-* for rationale." exit 1 fi - name: Forbidden orphan-CRUD client function regression guard (B-1) # B-1 master closed four audit findings — three orphan-update fns # (cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9) and one orphan CRUD # surface (cat-b-4631ca092bee, RenewalPolicy) — by wiring per-page # Edit modals so every backend write endpoint has at least one # GUI consumer. The fourth finding (cat-b-9b97ffb35ef7) deleted # the dead `exportCertificatePEM` duplicate. # # Pre-B-1 the failure mode was: backend ships a CRUD handler, # client.ts ships the matching `update*` / `delete*` / `create*` # function, but no page imports it. Operators were forced to # `psql` directly to edit team names, owner emails, agent-group # match rules, issuer names, profile names, or any renewal-policy # field — turning a 30-second GUI task into a 30-minute database # excursion with audit-trail gaps. # # This step fails the build if any of the eight previously-orphan # client functions loses its page consumer (i.e. a future refactor # accidentally re-orphans them). Each fn must have ≥1 non-test # consumer under web/src/pages/. Tests (*.test.ts(x)) and the # client.ts definition file itself are exempt. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-b-31ceb6aaa9f1, cat-b-7a34f893a8f9, cat-b-4631ca092bee, # cat-b-9b97ffb35ef7 for closure rationale. run: | set -e ORPHAN_FNS="updateOwner updateTeam updateAgentGroup updateIssuer updateProfile createRenewalPolicy updateRenewalPolicy deleteRenewalPolicy" FAIL=0 for fn in $ORPHAN_FNS; do HITS=$(grep -rE "\b${fn}\b" web/src/pages/ 2>/dev/null \ | grep -vE '\.test\.(ts|tsx):' \ | wc -l) if [ "$HITS" -eq 0 ]; then echo "::error::B-1 regression: client function '${fn}' has zero consumers under web/src/pages/." echo " Every backend CRUD endpoint must have a GUI consumer to avoid forcing operators to psql." echo " Either restore the page consumer or delete the client function in the same commit." FAIL=1 fi done # cat-b-9b97ffb35ef7: exportCertificatePEM was deleted as a dead # duplicate of downloadCertificatePEM. Block resurrection. if grep -nE 'export\s+const\s+exportCertificatePEM' web/src/api/client.ts >/dev/null 2>&1; then echo "::error::B-1 regression: exportCertificatePEM was removed as a dead duplicate of downloadCertificatePEM." echo " If a JSON variant is needed, add an explicit page consumer in the same commit." FAIL=1 fi if [ "$FAIL" -ne 0 ]; then exit 1 fi echo "B-1 orphan-CRUD client function guardrail: all 8 functions have page consumers." - name: Forbidden strings.Contains(err.Error()) regression guard (S-2) # S-2 closure (cat-s6-efc7f6f6bd50): replaced 30 brittle # substring-match error-dispatch sites in internal/api/handler/ # with errors.Is + typed sentinels (repository.ErrNotFound, # repository.ErrForeignKeyConstraint via the # repository.IsForeignKeyError helper). This step grep-fails # the build if any new strings.Contains(err.Error(), "not found") # or strings.Contains(err.Error(), "violates foreign key") # site appears under internal/api/handler/. # # Allowed: closure-comments documenting the convention (e.g. # bulk_reassignment.go's "post-M-1 errToStatus convention" # docblock); domain-specific substring patterns that are # legitimately one-off ("cannot approve", "cannot reject", # "cannot be parsed", "challenge password") — flagged as # deferred follow-ups in the S-2 commit message. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-s6-efc7f6f6bd50 for closure rationale. run: | set -e BAD=$(grep -rnE 'strings\.Contains\(err\.Error\(\),\s*"(not found|violates foreign key|RESTRICT)"' internal/api/handler/ 2>/dev/null \ | grep -vE '^\s*[^:]+:[0-9]+:\s*//' \ || true) if [ -n "$BAD" ]; then echo "S-2 regression: brittle substring-match error-dispatch reappeared:" echo "$BAD" echo "" echo "Use errors.Is(err, repository.ErrNotFound) for not-found dispatch," echo "or repository.IsForeignKeyError(err) for FK violations." echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md" echo "cat-s6-efc7f6f6bd50 for closure rationale." exit 1 fi echo "S-2 typed-sentinel error-dispatch guardrail: clean." - name: Race Detection run: go test -race ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/scheduler/... ./internal/connector/... ./internal/crypto/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -timeout 300s - name: Go Test with Coverage run: | go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... -count=1 -cover -coverprofile=coverage.out - name: Check Coverage Thresholds run: | # Extract per-package coverage from test output echo "=== Coverage Report ===" go tool cover -func=coverage.out | tail -1 # Check service layer coverage (target: 60%+) SERVICE_COV=$(go tool cover -func=coverage.out | grep 'internal/service' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "Service layer coverage: ${SERVICE_COV}%" # Check handler layer coverage (target: 60%+) HANDLER_COV=$(go tool cover -func=coverage.out | grep 'internal/api/handler' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "Handler layer coverage: ${HANDLER_COV}%" # Check domain layer coverage (target: 40%+) DOMAIN_COV=$(go tool cover -func=coverage.out | grep 'internal/domain' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "Domain layer coverage: ${DOMAIN_COV}%" # Check middleware layer coverage (target: 50%+) MIDDLEWARE_COV=$(go tool cover -func=coverage.out | grep 'internal/api/middleware' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "Middleware layer coverage: ${MIDDLEWARE_COV}%" # Check crypto package coverage (target: 85%+) # M-8 rationale: encryption primitives are a security-critical gate. # v2 format, key-derivation, fallback, and fail-closed sentinel paths # all need exhaustive coverage to avoid silent regressions (CWE-916 / CWE-329). CRYPTO_COV=$(go tool cover -func=coverage.out | grep 'internal/crypto' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "Crypto package coverage: ${CRYPTO_COV}%" # Bundle-7 / Audit H-005 — extended crypto-cluster gates per CLAUDE.md. # internal/pkcs7/ is at 100% at HEAD (encoder-only, exhaustively tested # via Bundle-4 fuzz targets + unit tests). internal/connector/issuer/local/ # is at 68.3% at HEAD; H-010 tracks the gap and will lift this floor # to 85% once the missing CSR-validation + CA-cert-loading tests land. PKCS7_COV=$(go tool cover -func=coverage.out | grep 'internal/pkcs7' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "PKCS7 package coverage: ${PKCS7_COV}%" LOCAL_ISSUER_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/local' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "Local-issuer coverage: ${LOCAL_ISSUER_COV}%" # Bundle-J / Coverage-Audit C-001 (partial-closed) — ACME failure-mode # batch lifts internal/connector/issuer/acme from 41.8% to ~55.6% # (per-package package-scoped run). The global per-file average can # come in lower because this awk pattern divides by file count # rather than weighting by line count, but with the failure-mode # tests landed every file in the package has at least 50% coverage. # Floor set at 50 to accommodate the global-run arithmetic; bumps # to 85 when Bundle J-extended (Pebble-style mock) lands and the # IssueCertificate / solveAuthorizations* flows are exercisable. ACME_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/acme' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "ACME issuer coverage: ${ACME_COV}%" # Bundle-L.B / Coverage-Audit C-005 — StepCA failure-mode + JWE # round-trip tests lift internal/connector/issuer/stepca from # 52.1% to 90.4% (per-package run). Floor at 80 with margin. STEPCA_COV=$(go tool cover -func=coverage.out | grep 'internal/connector/issuer/stepca' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "StepCA issuer coverage: ${STEPCA_COV}%" # Bundle-K / Coverage-Audit C-002 — MCP per-tool dispatch via # in-memory transport lifts internal/mcp from 28.0% to 93.1% # (per-package run). Floor at 85. MCP_COV=$(go tool cover -func=coverage.out | grep 'internal/mcp/' | awk '{print $NF}' | sed 's/%//' | awk '{sum+=$1; n++} END {if(n>0) printf "%.1f", sum/n; else print "0"}') echo "MCP coverage: ${MCP_COV}%" # Fail if thresholds not met. # Bundle R-CI-extended raises (post-Bundle-N.C-extended): # service 55 -> 70 (HEAD 73.4%; 3pp margin); handler 60 -> 75 # (HEAD 79.8%; 4pp margin). Prescribed Bundle R target was 80; # held lower to avoid false-positives on single low-coverage # files dragging the global per-file-average down. if [ "$(echo "$SERVICE_COV < 70" | bc -l)" -eq 1 ]; then echo "::error::Service layer coverage ${SERVICE_COV}% is below 70% (Bundle R-CI-extended floor — add tests, do not lower the gate)" exit 1 fi if [ "$(echo "$HANDLER_COV < 75" | bc -l)" -eq 1 ]; then echo "::error::Handler layer coverage ${HANDLER_COV}% is below 75% (Bundle R-CI-extended floor — add tests, do not lower the gate)" exit 1 fi if [ "$(echo "$DOMAIN_COV < 40" | bc -l)" -eq 1 ]; then echo "::error::Domain layer coverage ${DOMAIN_COV}% is below 40% threshold" exit 1 fi if [ "$(echo "$MIDDLEWARE_COV < 30" | bc -l)" -eq 1 ]; then echo "::error::Middleware layer coverage ${MIDDLEWARE_COV}% is below 30% threshold" exit 1 fi # Bundle R / Coverage Audit Closure — CI threshold raise checkpoint #3. # Crypto package floor lifted 85 → 88. Post-Bundle-Q package-scoped # coverage at HEAD: 88.2% (Bundle Q's gopter property tests don't add # production-code coverage — they exercise the same paths via # generative inputs). The remaining ~12% gap is platform-failure # branches (rand.Reader / aes.NewCipher) that require interface seams # the production code doesn't use; closing them is tracked as # R-CI-extended, not Bundle R scope. if [ "$(echo "$CRYPTO_COV < 88" | bc -l)" -eq 1 ]; then echo "::error::Crypto package coverage ${CRYPTO_COV}% is below 88% (Bundle R closure floor — add tests, do not lower the gate)" exit 1 fi # Bundle-7 / H-005: pkcs7 coverage is INFORMATIONAL only in this run. # The global `go test -cover ./...` invocation in CI doesn't exercise # internal/pkcs7's tests (they're primarily Fuzz* targets that # require an explicit `-fuzz` invocation, plus encoder helpers # exercised transitively). The deep-scan workflow runs # `go test -cover ./internal/pkcs7/...` directly and confirmed 100% # at Bundle-7 close — that's the load-bearing measurement. Keeping # the global-run number visible here for trend-watching but not # gating because 0% is a measurement artifact, not a regression. echo "PKCS7 package coverage (global run, informational): ${PKCS7_COV}%" # Bundle-9 / H-010 closure: local-issuer HARD gate at 85%. The # transitional 60% floor (Bundle-7) was an explicit promise in the # CI config that H-010 would raise it once CSR-validation + CA- # cert-loading + key-rotation + key-encoding pin tests landed. # Bundle-9 ships those tests (bundle9_coverage_test.go) and lifts # the package-scoped run to ~86.7%; the global run averages a few # points lower (per-function arithmetic), so the gate is set to 85 # with the live `go test -cover` number being the source of truth. # If this gate trips, the fix is to add tests, NOT to lower the # floor — every percentage point under 85 is a regression on the # H-010 closure invariant. # Bundle R / Coverage Audit Closure — CI threshold raise checkpoint #3. # Local-issuer floor lifted 85 → 86. Post-Bundle-Q package-scoped # coverage at HEAD: 86.7%. The prescribed Bundle R target was # 92, but reaching it requires interface seams for crypto/x509 # signing-error branches — tracked as R-CI-extended. if [ "$(echo "$LOCAL_ISSUER_COV < 86" | bc -l)" -eq 1 ]; then echo "::error::Local-issuer coverage ${LOCAL_ISSUER_COV}% is below 86% (Bundle R closure floor — add tests, do not lower the gate)" exit 1 fi # Bundle R-CI-extended threshold raise (post-Bundle-J-extended): # ACME 50 -> 80. The Pebble-style mock + per-CA failure tests # lift package-scoped ACME to 85.4%; gate at 80 with 5pp margin # to absorb the global-run per-file-average dip. The prescribed # Bundle R target was 85; held at 80 to avoid false-positives # on single low-coverage files. if [ "$(echo "$ACME_COV < 80" | bc -l)" -eq 1 ]; then echo "::error::ACME issuer coverage ${ACME_COV}% is below 80% (Bundle R-CI-extended floor — add tests, do not lower the gate)" exit 1 fi if [ "$(echo "$STEPCA_COV < 80" | bc -l)" -eq 1 ]; then echo "::error::StepCA issuer coverage ${STEPCA_COV}% is below 80% (Bundle L.B closure floor — add tests, do not lower the gate)" exit 1 fi if [ "$(echo "$MCP_COV < 85" | bc -l)" -eq 1 ]; then echo "::error::MCP coverage ${MCP_COV}% is below 85% (Bundle K closure floor — add tests, do not lower the gate)" exit 1 fi echo "Coverage thresholds passed!" - name: Upload Coverage Report uses: actions/upload-artifact@v4 with: name: go-coverage path: coverage.out retention-days: 30 # Bundle P / Strengthening #6 — QA-doc drift guards. Forces every PR # that adds a Part to docs/testing-guide.md OR a seed row to # migrations/seed_demo.sql to keep docs/qa-test-guide.md in sync. This # eliminates the doc-drift class structurally — the symptom Bundle I # had to clean up by hand becomes a CI-time error going forward. - name: QA-doc Part-count drift guard run: | set -e DOC_PARTS=$(grep -oE '49 of [0-9]+ Parts' docs/qa-test-guide.md | grep -oE '[0-9]+' | tail -1) GUIDE_PARTS=$(grep -cE '^## Part [0-9]+:' docs/testing-guide.md) if [ -z "$DOC_PARTS" ]; then echo "::error::Could not extract Part count from docs/qa-test-guide.md headline." echo " Expected pattern: '49 of Parts'" exit 1 fi if [ "$DOC_PARTS" != "$GUIDE_PARTS" ]; then echo "::error::DRIFT — qa-test-guide.md headline claims $DOC_PARTS Parts; testing-guide.md has $GUIDE_PARTS Parts." echo " Update docs/qa-test-guide.md to match. Bundle I patched this once;" echo " Bundle P added this guard so the drift cannot recur silently." exit 1 fi echo "QA-doc Part-count drift guard: clean ($DOC_PARTS == $GUIDE_PARTS)." - name: QA-doc seed-count drift guard run: | set -e # Seed-cert count: agnostic to documented header format. The current # documented count lives in `### Certificates (32 total in ...` — # extract the first integer in that header. DOC_CERTS=$(grep -oE '### Certificates \([0-9]+' docs/qa-test-guide.md | grep -oE '[0-9]+' | head -1) # Authoritative count: unique mc-* IDs in seed_demo.sql. SEED_CERTS=$(grep -oE 'mc-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l | tr -d ' ') if [ -z "$DOC_CERTS" ]; then echo "::warning::Could not extract documented cert count from docs/qa-test-guide.md." echo " Skipping cert-count drift check (header format may have changed)." elif [ "$DOC_CERTS" != "$SEED_CERTS" ]; then echo "::error::DRIFT — qa-test-guide.md says $DOC_CERTS certs; seed_demo.sql has $SEED_CERTS unique mc-* IDs." echo " Update docs/qa-test-guide.md::Seed Data Reference to match." exit 1 fi # Issuers: seed-table count vs doc claim. DOC_ISS=$(grep -oE '### Issuers \([0-9]+' docs/qa-test-guide.md | grep -oE '[0-9]+' | head -1) # Authoritative: unique iss-* IDs (close enough proxy; the issuers # table count IS the unique-ID count for this prefix). SEED_ISS=$(grep -oE 'iss-[a-z0-9_-]+' migrations/seed_demo.sql | sort -u | wc -l | tr -d ' ') if [ -z "$DOC_ISS" ]; then echo "::warning::Could not extract documented issuer count." elif [ "$DOC_ISS" != "$SEED_ISS" ] && [ "$((SEED_ISS - DOC_ISS))" -gt 5 ]; then # Allow up to 5pp slack — iss-* IDs appear in audit_events and # other reference tables that aren't issuer-table rows. Drift # only flags when the spread grows large. echo "::error::DRIFT — qa-test-guide.md says $DOC_ISS issuers; seed_demo.sql has $SEED_ISS unique iss-* IDs (spread > 5)." exit 1 fi echo "QA-doc seed-count drift guard: clean." # Bundle Q / I-001 closure — test-naming convention guard (informational). # The convention is `Test__`. This step # prints any non-conformant tests but does NOT fail the build until the # Bundle I-001-extended (2026-04-27) — promoted from informational # to hard-fail. The convention is now: every `func TestXxx(...)` MUST # match Go's standard test-runner pattern (`^func Test[A-Z]`). Tests # whose name starts with `func Test` are silently SKIPPED # by `go test` (Go only runs `Test[A-Z]...`) — those are the real # bugs this guard catches. # # The original audit's `Test__` triple- # token prescription has been relaxed: single-function pin tests like # `TestNewAgent` or `TestSplitPEMChain` are valid Go convention, with # internal scenarios expressed via `t.Run` subtests. Requiring the # underscore-Scenario-Result triple repo-wide would mean renaming # 167 legitimate tests for no observable behavior change. The # Test__ form remains documented as # the recommended pattern for parameterized scenarios in # docs/qa-test-guide.md, but is not gated. - name: Test-naming convention guard (hard-fail) run: | # Catch tests Go itself would silently skip: `func TestX...` where # the first letter after `Test` is lowercase. Go's testing runner # requires uppercase to register the test; lowercase tests don't # run, which is a real bug a CI guard should catch. INVALID=$(grep -rnE '^func Test[a-z]' --include='*_test.go' . \ | grep -v '_test.go.bak' \ || true) if [ -n "$INVALID" ]; then echo "::error::Found tests Go would silently skip (lowercase after 'Test'):" echo "$INVALID" echo "Rename to start with an uppercase letter — Go's test runner only matches ^Test[A-Z]." exit 1 fi echo "Test-naming convention guard: clean (no Go-invalid test names found)." frontend-build: name: Frontend Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: '22' - name: Install Dependencies working-directory: web run: npm ci - name: TypeScript Check working-directory: web run: npx tsc --noEmit - name: Run Frontend Tests working-directory: web run: npx vitest run - name: Build Frontend working-directory: web run: npx vite build - name: Forbidden hardcoded source-count prose regression guard (S-1) # S-1 master closed cat-s1-9ce1cbe26876 (README + features.md # stale numeric counts; explicit CLAUDE.md violation per # "version-stamped numbers rot") and # cat-s1-features_md_issuer_count_contradiction (features.md # self-disagreed on issuer count: 9 vs 12 in the same doc). # The fix replaced source-derived numbers in prose with # "rebuild via " patterns documented in CLAUDE.md:: # "Current-state commands". This step grep-fails the build if # any of the previously-stale sites reintroduces a hardcoded # count. # # Allowed surfaces: demo-fixture prose in README ("32 # certificates" — those are seed_demo.sql facts, not live # source counts), historical-milestone counts in # WORKSPACE-CHANGELOG.md, the testing-guide example phrasing # ("README claims 8 issuer connectors but only 6 exist"), # and any number that quotes the source command immediately # adjacent. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-s1-9ce1cbe26876 + cat-s1-features_md_issuer_count_contradiction # for closure rationale. run: | set -e BAD=$(grep -rnE '\b[0-9]+\s+(issuer connectors?|target connectors?|notifier connectors?|discovery connectors?|MCP tools|OpenAPI operations|migrations|database tables|frontend pages|HTTP routes)\b' \ README.md docs/ 2>/dev/null \ | grep -vE 'WORKSPACE-CHANGELOG|seed_demo|demo override' \ | grep -vE 'DRIFT HAZARD|Source: |Rebuild|rebuild via|grep -|wc -l|ls -d|find ' \ | grep -vE 'README claims [0-9]+ issuer connectors but only [0-9]+ exist' \ || true) if [ -n "$BAD" ]; then echo "S-1 regression: hardcoded source-count prose reappeared:" echo "$BAD" echo "" echo "CLAUDE.md rule: 'Numeric claims about current state rot.'" echo "Replace the count with the grep command from CLAUDE.md::" echo "'Current-state commands' (e.g. 'ls -d internal/connector/issuer/*/ | wc -l')" echo "or rephrase to reference the rebuild command on the same line." echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md" echo "cat-s1-9ce1cbe26876 for closure rationale." exit 1 fi echo "S-1 stale-counts guardrail: clean." - name: Documented orphan client fns sync guard (P-1) # P-1 master closed diff-04x03-d24864996ad4 + cat-b-dc46aadab98e # by documenting 17 detail-page-candidate orphan client.ts # functions in a docblock at the top of web/src/api/client.ts. # This step verifies the docblock list ↔ export list relationship: # every name listed in the docblock must still be declared as # an export below it (catches drift where someone deletes the # export but forgets the docblock, or vice versa). # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale. run: | set -e DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOCSPStatus getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck' MISSING="" for fn in $DOCUMENTED; do if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then MISSING="${MISSING}${fn} " fi done if [ -n "$MISSING" ]; then echo "P-1 regression: documented orphan(s) missing from client.ts exports:" echo " $MISSING" echo "" echo "Either restore the export, or delete the corresponding line" echo "in the documented-orphans docblock at the top of client.ts." echo "See coverage-gap-audit-2026-04-24-v5/unified-audit.md" echo "diff-04x03-d24864996ad4 for closure rationale." exit 1 fi echo "P-1 documented-orphans sync guard: clean ($(echo $DOCUMENTED | wc -w) fns verified)." - name: Frontend page-coverage regression guard (T-1) # T-1 closure (cat-s2-c24a548076c6): pre-T-1 only 3 of 28 pages # had Vitest coverage. T-1 lifted that to 11/28 by writing tests # for the 8 highest-leverage pages (CertificatesPage filter + # pagination state, the new B-1 Edit modals, the D-2 type-trim # render sites, etc.). The remaining pages are deferred to per- # page commits — when the next feature change touches them, the # test gets added in the same commit. This step blocks new # pages from landing without tests. # # Allowlist: pages that are explicitly deferred — listed below # with a one-line "why deferred" justification. Each entry must # be removed when the page gets its test. # - LoginPage: static auth form, no business logic # - AuditPage: read-only timeline; D-2 already trimmed # - ShortLivedPage: derived view of certs already covered by CertificatesPage # - DigestPage: server-rendered digest; minimal client logic # - ObservabilityPage: exposes Prometheus / Grafana links only # - HealthMonitorPage: wraps M-006 health check timeline; M-006 has its own tests # - NetworkScanPage: wraps the network scanner UX; SSRF unit-tested in domain # - JobsPage: covered transitively via AgentDetailPage # - JobDetailPage: drill-down view; covered transitively via JobsPage # - AgentFleetPage: bulk overview; covered transitively via AgentsPage # - ProfilesPage: CRUD form; mirrors PoliciesPage shape (covered) # - CertificateDetailPage: drill-down view; covered transitively via CertificatesPage # - IssuerDetailPage: drill-down view; covered transitively via IssuersPage # - TargetDetailPage: drill-down view; covered transitively via TargetsPage # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-s2-c24a548076c6 for closure rationale. run: | set -e ALLOW='^(LoginPage|AuditPage|ShortLivedPage|DigestPage|ObservabilityPage|HealthMonitorPage|NetworkScanPage|JobsPage|JobDetailPage|AgentFleetPage|ProfilesPage|CertificateDetailPage|IssuerDetailPage|TargetDetailPage)$' UNTESTED="" for f in web/src/pages/*.tsx; do base=$(basename "$f" .tsx) case "$f" in *.test.tsx) continue ;; esac if [ -f "web/src/pages/${base}.test.tsx" ]; then continue; fi if echo "$base" | grep -qE "$ALLOW"; then continue; fi UNTESTED="${UNTESTED}${base} " done if [ -n "$UNTESTED" ]; then echo "T-1 regression: page(s) without sibling .test.tsx and not on the deferred allowlist:" echo " $UNTESTED" echo "" echo "Either add web/src/pages/.test.tsx (mirror NotificationsPage.test.tsx)," echo "or add the page to the ALLOW pattern in .github/workflows/ci.yml with a" echo "one-line 'why deferred' comment. See" echo "coverage-gap-audit-2026-04-24-v5/unified-audit.md cat-s2-c24a548076c6" echo "for closure rationale." exit 1 fi ALLOWLIST_SIZE=$(echo "$ALLOW" | tr '|' '\n' | wc -l) echo "T-1 page-coverage guardrail: clean (allowlist size: $ALLOWLIST_SIZE pages deferred)." - name: Bundle-8 / L-015 target=_blank rel=noopener regression guard # Audit L-015 / CWE-1022 (reverse-tabnabbing): every # MUST carry rel="noopener noreferrer" so a malicious page at the # target URL cannot navigate the opener window via window.opener. # At Bundle-8 close (commit b566355→) all 3 sites in the codebase # already comply — this guard prevents regression. The # ExternalLink component (web/src/components/ExternalLink.tsx) # is the recommended way to add new external links. # # Test files (web/src/**/*.test.{ts,tsx}) are excluded so test # docstrings or fixture data describing the attack vector by # name don't trip the guard — symmetric with the L-019 guard. run: | set -e OFFENDERS=$(grep -rnE 'target=["'"'"']?_blank["'"'"']?' web/src/ 2>/dev/null \ | grep -v 'noopener noreferrer' \ | grep -v 'web/src/components/ExternalLink.tsx' \ | grep -vE '\.test\.(ts|tsx)(:[0-9]+)?:' \ || true) if [ -n "$OFFENDERS" ]; then echo "L-015 regression: target=\"_blank\" without rel=\"noopener noreferrer\":" echo "$OFFENDERS" echo "" echo "Either add rel=\"noopener noreferrer\" inline," echo "or migrate to from web/src/components/ExternalLink.tsx." exit 1 fi echo "L-015 target=_blank guardrail: clean." - name: Bundle-8 / L-019 dangerouslySetInnerHTML regression guard # Audit L-019 / CWE-79 (XSS): no PRODUCTION code may use # dangerouslySetInnerHTML directly. At Bundle-8 close the codebase # has 0 sites; future genuine needs MUST route through # web/src/utils/safeHtml.ts::sanitizeHtml. # # Test files (web/src/**/*.test.{ts,tsx}) are explicitly excluded: # the M-029 Pass 3 XSS-hardening test docstrings legitimately cite # the attack vector by name to explain what the test is guarding # against (e.g. "a careless refactor to dangerouslySetInnerHTML # would let an attacker-controlled CSR deliver an XSS payload"). # Tests describing the threat aren't using it; the guard's intent # is production code only. run: | set -e OFFENDERS=$(grep -rnE 'dangerouslySetInnerHTML' web/src/ 2>/dev/null \ | grep -v 'web/src/utils/safeHtml.ts' \ | grep -vE '\.test\.(ts|tsx)(:[0-9]+)?:' \ || true) if [ -n "$OFFENDERS" ]; then echo "L-019 regression: dangerouslySetInnerHTML used outside safeHtml.ts:" echo "$OFFENDERS" echo "" echo "Route through web/src/utils/safeHtml.ts::sanitizeHtml — see file" echo "header for the activation procedure (DOMPurify dependency)." exit 1 fi echo "L-019 dangerouslySetInnerHTML guardrail: clean." - name: Bundle-8 / M-009 + M-029 Pass 1 mutation contract guard (hard zero) # Audit M-009 + M-029 Pass 1 closure: # # Pre-Bundle-8 the codebase had 56 bare useMutation sites with # discretionary invalidation. Bundle 8 shipped the useTrackedMutation # wrapper (web/src/hooks/useTrackedMutation.ts) that requires every # caller to declare `invalidates: QueryKey[] | 'noop'`. M-029 Pass 1 # then migrated all 56 sites to the wrapper across 6 batches. # # This guard pins the contract going forward: every useMutation call # in src/ MUST be inside useTrackedMutation.ts (the wrapper itself # is the only legitimate caller of useMutation). Any bare useMutation # call elsewhere is a regression — adding a new mutation site means # going through the wrapper so the invalidates contract is enforced # per-site, not by a soft budget guard. # # If you genuinely need raw useMutation (extremely unlikely — the # wrapper supports invalidates: 'noop' for fire-and-forget mutations), # update this guard's exclusion list and document the carve-out. run: | set -e # Test files (web/src/**/*.test.{ts,tsx}) are excluded so existing # useMutation-mocking test patterns and the wrapper's own unit # tests don't trip the production guard — symmetric with L-015 # and L-019 above. BARE=$(grep -rnE '\buseMutation\(' web/src/ 2>/dev/null \ | grep -v 'web/src/hooks/useTrackedMutation\.ts' \ | grep -vE '\.test\.(ts|tsx)(:[0-9]+)?:' \ || true) if [ -n "$BARE" ]; then echo "M-009 hard-zero regression: bare useMutation() call(s) outside the wrapper:" echo "$BARE" echo echo "Every mutation must go through useTrackedMutation" echo "(web/src/hooks/useTrackedMutation.ts) with explicit" echo "invalidates: QueryKey[] | 'noop'. See file header for usage." exit 1 fi # Sanity counts (informational, not a gate). TRACKED=$(grep -rcE '\buseTrackedMutation\(' web/src/ 2>/dev/null | awk -F: '{s+=$2} END{print s}') INVALIDATIONS=$(grep -rcE 'invalidateQueries|setQueryData|removeQueries|invalidates:' web/src/ 2>/dev/null | awk -F: '{s+=$2} END{print s}') echo "M-009 hard-zero: bare useMutation sites = 0 (wrapper-internal call + test files excluded)." echo "M-009 informational: useTrackedMutation sites = $TRACKED; invalidation surface = $INVALIDATIONS." - name: Forbidden env-var docs drift regression guard (G-3) # G-3 master closed cat-g-163dae19bc59 (docs-only env vars # phantom in features.md), cat-g-b8f8f8796159 (6 config-only # env vars never documented), and cat-g-renewal_check_interval_rename_drift # (features.md still advertised the pre-rename # CERTCTL_RENEWAL_CHECK_INTERVAL after it was renamed to # CERTCTL_SCHEDULER_RENEWAL_CHECK_INTERVAL). This step runs # `comm -23` both ways between the env vars defined in Go # source (config.go + cmd/agent + deploy/test fixtures + ACME # DNS-01 script env exports) and the env vars mentioned in # README + docs/ + deploy/helm/. # # Allowlist: env vars that are documented as integration- # surface contracts (script env exports for ACME DNS-01, # OpenSSL CA scripts, StepCA per-issuer-config-blob fields, # Webhook per-notifier-config-blob fields, ACME EAB, audit # exclusion, demo-stack overrides) but not consumed directly # by config.go. Each entry below has a one-line justification # — if you add a new entry, add the justification too. # # See coverage-gap-audit-2026-04-24-v5/unified-audit.md # cat-g-* for closure rationale. run: | set -e # Defined: config.go + agent + cli + mcp-server + server cmds + test fixtures + ACME DNS export { grep -nE '"CERTCTL_[A-Z_]+"' internal/config/config.go | sed -E 's/.*"(CERTCTL_[A-Z_]+)".*/\1/' grep -rhoE '"CERTCTL_[A-Z_]+"' cmd/agent/*.go cmd/cli/*.go cmd/mcp-server/*.go cmd/server/*.go 2>/dev/null | sed -E 's/"(CERTCTL_[A-Z_]+)"/\1/' grep -rhoE 'CERTCTL_[A-Z_]+' deploy/test/qa_test.go internal/connector/issuer/acme/dns.go 2>/dev/null } | grep -E '^CERTCTL_' | sort -u > /tmp/g3-defined.txt # Documented: README + docs + helm grep -rhoE '\bCERTCTL_[A-Z_]+\b' README.md docs/ deploy/helm/ 2>/dev/null | sort -u > /tmp/g3-docs.txt # Allowlist of env vars documented as external integration contracts. # Each entry justifies itself in one line; if you add to this list, # add the justification. ALLOWED='^( CERTCTL_OPENSSL_SIGN_SCRIPT| CERTCTL_OPENSSL_REVOKE_SCRIPT| CERTCTL_OPENSSL_CRL_SCRIPT| CERTCTL_OPENSSL_TIMEOUT_SECONDS| CERTCTL_STEPCA_URL| CERTCTL_STEPCA_FINGERPRINT| CERTCTL_STEPCA_PROVISIONER| CERTCTL_STEPCA_PROVISIONER_NAME| CERTCTL_STEPCA_PROVISIONER_KEY| CERTCTL_STEPCA_PROVISIONER_JWK| CERTCTL_STEPCA_PROVISIONER_PASSWORD| CERTCTL_STEPCA_PASSWORD| CERTCTL_STEPCA_KEY_PATH| CERTCTL_STEPCA_ROOT_CA| CERTCTL_WEBHOOK_URL| CERTCTL_WEBHOOK_SECRET| CERTCTL_ACME_EAB_KID| CERTCTL_ACME_EAB_HMAC| CERTCTL_ACME_DNS_PROPAGATION_WAIT| CERTCTL_AUDIT_EXCLUDE_PATHS| CERTCTL_TLS_| CERTCTL_TLS_INSECURE_SKIP_VERIFY| CERTCTL_SERVER_CA_BUNDLE_PATH| CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY| CERTCTL_QA_[A-Z_]+ )$' # ^ The CERTCTL_OPENSSL_* / CERTCTL_STEPCA_* / CERTCTL_WEBHOOK_* / # CERTCTL_ACME_EAB_* / CERTCTL_ACME_DNS_PROPAGATION_WAIT / # CERTCTL_AUDIT_EXCLUDE_PATHS / CERTCTL_TLS_* / CERTCTL_SERVER_* / # CERTCTL_QA_* sets are documented integration-surface contracts # (script invocations, per-issuer config-blob field names, # per-notifier config-blob field names, demo-stack overrides, # test fixtures) — not server-side env vars in config.go. # The audit's "37 docs-only" count over-flagged these; the # closure narrows the gate to the specific drift sites # (renewal-interval rename + 6 config-only) and allowlists # the documented external contracts here. ALLOWED_FLAT=$(echo "$ALLOWED" | tr -d '\n ') DOCS_ONLY=$(comm -13 /tmp/g3-defined.txt /tmp/g3-docs.txt | grep -vE "$ALLOWED_FLAT" || true) CONFIG_ONLY=$(comm -23 /tmp/g3-defined.txt /tmp/g3-docs.txt || true) if [ -n "$DOCS_ONLY" ]; then echo "G-3 regression: env var(s) mentioned in docs but not defined in Go source AND not in the documented integration-surface allowlist:" echo "$DOCS_ONLY" echo "" echo "Either delete from docs (phantom/typo) or add to config.go," echo "or add to the ALLOWED list with a one-line justification." exit 1 fi if [ -n "$CONFIG_ONLY" ]; then echo "G-3 regression: env var(s) defined in Go source but never documented:" echo "$CONFIG_ONLY" echo "" echo "Add an entry to docs/features.md (or another canonical doc) so operators can find it." exit 1 fi echo "G-3 env-var docs drift guardrail: clean." helm-lint: name: Helm Chart Validation runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Helm uses: azure/setup-helm@v4 with: version: '3.13.0' # HTTPS-Everywhere (v2.0.47): the chart fails render when no TLS source is # configured. Every lint/template invocation below must pick exactly one # provisioning mode — see deploy/helm/certctl/templates/_helpers.tpl # (certctl.tls.required) and docs/tls.md. - name: Lint Helm Chart run: | helm lint deploy/helm/certctl/ \ --set server.tls.existingSecret=certctl-tls-ci - name: Template Helm Chart (existingSecret mode) run: | helm template certctl deploy/helm/certctl/ \ --set server.tls.existingSecret=certctl-tls-ci \ > /dev/null - name: Template Helm Chart (cert-manager mode) run: | helm template certctl deploy/helm/certctl/ \ --set server.tls.certManager.enabled=true \ --set server.tls.certManager.issuerRef.name=letsencrypt-prod \ > /dev/null - name: Template Helm Chart (guard fails without TLS) run: | # Inverse test: the chart MUST refuse to render when no TLS source is # configured. If this ever renders successfully, the fail-loud guard # in certctl.tls.required has regressed. if helm template certctl deploy/helm/certctl/ > /dev/null 2>&1; then echo "::error::Helm chart rendered without a TLS source — fail-loud guard regressed" exit 1 fi