securitysecurity-review-2026-08-12 · v21099:claude-cowork-soc22026-08-12served from databaseAll documents

Estate security review v2 (2026-08-12) — verified live: no enforced change gate, ops_is_admin fails open, 28% of billed sessions orphaned

Estate security review — verified against production

Second pass, 12 August 2026. The first pass (v1 of this document) reported control design. This pass executed the tests against the live Railway and Supabase estate. Several items that were previously "unverified" are now confirmed findings; several others turned out to be clean and are recorded as passes, including one earlier concern that was wrong and is withdrawn.

Read-only throughout: introspection and SELECT only, every probe rolled back, nothing changed.

08 — Verified findings

Tested live against production, 12 August 2026. Everything below was executed, not inferred. Read-only: introspection and SELECT only; every probe ran inside a transaction that was rolled back. No grant, policy, function, row, or configuration was changed.

This file supersedes the "unverified / not assessed" status on the items it covers.


A. Change gate — CONFIRMED NOT ENFORCED

Directive v36 names nodedough, fedm8, and cipherdeck as the gated lane. The Railway configuration says otherwise.

Railway projectServiceRepoProduction branchEnvironmentsServes
nodedoughwebjbnx/nodedoughmainproduction onlynodedough.com
project-cipherdeckwebjbnx/cipherdeck-appsmainproduction onlyapp.cipherdeck.com
fedm8scan-workerjbnx/fedm8-scanmaintest + productionscan worker

All three gated projects build their production environment from main. nodedough and project-cipherdeck have no second environment at all — the production branches created earlier are wired to nothing. A push to main on any of these is a live production deploy.

Combined with the PROD-IT lane (jbnx-bill, jbnx.io/projects-portal, mkt, landing pages), no repository in the estate has an enforced change gate. CC8.1 fails estate-wide.

Fix: repoint each gated service's production environment to the production branch. Dashboard only, 2FA required — a CEO action. Acceptance test: re-read get-service-config for each; source.branch reads production.


B. ops_is_admin() fails open — FedM8 and CipherDeck Apps

v_claims := current_setting('request.jwt.claims', true);
if v_claims is null or v_claims = '' then return true; end if;   -- ← fail-open

The guard returns true when no JWT claims are present, rather than false. Present in identical form on both oeohvlajtexuxjfrtcdb (FedM8) and xrlpdsovirwluwhemuwf (CipherDeck Apps).

What it guards on CipherDeck Apps — each of these begins if not ops_is_admin() then raise exception 'forbidden':

last_sign_in_at, plan and subscription status

Reachability, tested both ways:

Caller contextrequest.jwt.claimsops_is_admin()admin_search_users('a',5)
Real PostgREST anonymous request{"role":"anon"}falseforbidden
Direct DB connection (psql, MCP, worker, cron)unsettruereturned 5 user rows incl. emails

Severity: High, not Critical. PostgREST always sets request.jwt.claims, so this is not exploitable over the public API — I verified that explicitly rather than assuming it. It is exploitable from any direct database connection, and anyone holding a connection string already has broader access, so the immediate blast radius is limited.

It matters because it is a fail-open default in a reusable authorisation primitive. The moment ops_is_admin() is referenced by an RLS policy, a new RPC, or an edge function that does not set claims, it silently grants admin. That is how this class of bug becomes critical later.

Fix: invert the default — return false when claims are absent, and grant the trusted-server case explicitly by checking for service_role. Acceptance test: with claims unset, ops_is_admin() returns false; with {"role":"service_role"}, returns true; anonymous PostgREST call to admin_search_users still returns forbidden.


C. FedM8 — 16 dead grants

anon and/or authenticated hold INSERT/UPDATE/DELETE with no policy backing them. RLS is enabled on every one, so nothing is currently reachable — but each becomes live the moment someone adds a permissive policy.

Tableanonauthenticated
companies, company_ai_wallets, company_capabilities, company_invites, company_join_requests, company_membersINS/UPD/DELINS/UPD/DEL
crm_accounts, crm_activities, crm_contacts, notification_prefsINS/UPD/DEL

Fix: revoke the unbacked privileges. Check relrowsecurity first on anything you touch. Acceptance test: the dead-grants query returns zero rows.

D. FedM8 — unauthenticated SECURITY DEFINER write paths

write with fully attacker-controlled fields including p_ip and arbitrary p_meta JSON.

write log rows attributed to any user id. Spoofable attribution in a security-relevant log.

Fix: derive p_ip and p_user_id server-side rather than accepting them; rate-limit ticket submission.

E. FedM8 — trigger functions executable by anon

ops_touch_ticket() and vet_profiles_guard_stable_ids() are trigger functions and are directly callable by anon. Trigger functions should never be. vet_profiles_guard_stable_ids is a guard on veteran profile identity. This is the default-grant issue the runbook documents, unremediated.

Fix: revoke execute ... from anon, authenticated on both.

F. Billing — 28% of work sessions are orphaned

MeasureValue
billing.work_sessions rows269
with project_id NULL76 (28%)
Distinct slugs used in sessions14
Slugs missing from billing.projects0 ✅
billing.projects rows17
billing.customer_projects assignments5
full-stack present in billing.projectsyes (205851e0…)
full-stack assigned to a customer0

The directive defines this exactly: "A run that bills a slug missing from billing.projects / customer assignment is a defect — same class (invisible hours)." Twelve of seventeen billed projects have no customer assignment, and my own session on full-stack is one of them.

Fix: backfill project_id on the 76 orphaned sessions; assign the 12 unassigned projects to their paying customer; add a NOT NULL constraint or a nightly orphan check so it cannot recur. Acceptance test: orphan count is 0 and every slug appearing in work_sessions resolves to an assigned customer.

G. Secrets — GITHUB_TOKEN in a production worker

fedm8 scan-worker production carries GITHUB_TOKEN alongside SUPABASE_SECRET_KEY, STRIPE_SECRET_KEY, LITELLM_API_KEY, RESEND_API_KEY, SAM_API_KEY, AGENT_GATE_TOKEN, and OUTBOUND_WEBHOOK_INGEST_TOKEN. A GitHub token in a scanning worker is a lateral-movement path from a data-processing service into source control.

Fix: confirm the token's scopes; if it is a classic PAT, replace with a fine-grained token limited to the one repository and permission required, or remove it if unused.


What passed — corrections to earlier "unverified" status

Recorded because a review that only reports problems is not an assessment, and because several of these overturn assumptions in the first draft of this framework.

CheckResult
Tables with RLS disabled in publicNone on FedM8, NodeDough, or CipherDeck Apps
TRUNCATE granted to anon/authenticatedNone on FedM8 or NodeDough
Write privileges on viewsNone on FedM8, NodeDough, or CipherDeck Apps — the 2026-07-31 NodeDough fix has held
Dead grants on CipherDeck AppsNone
FedM8 entitlement functions as anonmy_plan()free, plan_at_least('pro')false, ops_is_admin()false. Fails closed correctly under a real anon request
NodeDough validate_invite_codeExists in production. The runbook records it as missing; that is now stale
CipherDeck Apps key formatMigrated to SUPABASE_PUBLISHABLE_KEY / SUPABASE_SECRET_KEY. NodeDough still on legacy SUPABASE_ANON_KEY — migration is partial, not absent
Billing credits arithmeticCorrect. hoursUsed() returns full precision with a comment explaining why, and creditsUsed = Math.round(hoursUsedVal * 6000) rounds once at the credit boundary. 325.867s → 543 credits → $5.43. The hours: 0.09 in the claim/release API response is display rounding only; the stored row holds exact timestamps. An earlier concern in this engagement about a 3-credit variance was wrong and is withdrawn.

Revised priority order

  1. Repoint the three Railway production environments — CEO, dashboard, 2FA. Unblocks CC8.1.
  2. Invert the ops_is_admin() default on both databases — one-line fix, removes a fail-open

primitive before it gets reused.

  1. Revoke FedM8's 16 dead grants and the two anon-executable trigger functions.
  2. Backfill billing project_id and the 12 missing customer assignments — currently invisible

hours that a customer cannot see and you cannot bill for.

  1. Harden ops_submit_ticket / ops_log_404 — stop accepting p_ip and p_user_id from callers.
  2. Scope or remove GITHUB_TOKEN on the FedM8 scan worker.

Items 2, 3, and 5 are database changes. Per the standing instruction not to modify code, they are written here as findings with acceptance tests rather than applied. Say the word and they are a short, reviewable migration.