@soulbatical/tetra-dev-toolkit 1.17.3 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -194,15 +194,30 @@ function parseMigrations(projectRoot) {
194
194
  || content.match(/(?:TEXT\[\]|text\[\])\s*:=\s*ARRAY\s*\[\s*'([^[\]]+)'\s*\]/i)
195
195
  if (arrayMatch) {
196
196
  const loopTables = arrayMatch[1].split(/'\s*,\s*'/).map(t => t.trim())
197
- const execLines = [...content.matchAll(/EXECUTE\s+format\s*\(\s*'(CREATE\s+POLICY\s+.*?)'\s*,/gi)]
197
+ // Match EXECUTE format('CREATE POLICY ...', ...) — multi-line, with escaped quotes ('')
198
+ // PL/pgSQL escapes single quotes as '' inside strings, so we must allow '' within the match
199
+ // The format string ends with a single ' (not '') followed by , or ;
200
+ const execLines = [...content.matchAll(/EXECUTE\s+format\s*\(\s*'(CREATE\s+POLICY(?:[^']|'')*?)'\s*,/gi)]
198
201
  for (const exec of execLines) {
199
- const stmt = exec[1]
202
+ // Unescape PL/pgSQL doubled quotes back to single quotes for analysis
203
+ const stmt = exec[1].replace(/''/g, "'")
200
204
  const forOp = stmt.match(/FOR\s+(SELECT|INSERT|UPDATE|DELETE|ALL)/i)
201
205
  const operation = forOp ? forOp[1].toUpperCase() : 'ALL'
202
206
  const hasUsing = /\bUSING\b/i.test(stmt)
203
207
  const hasWithCheck = /WITH\s+CHECK/i.test(stmt)
204
- const condMatch = stmt.match(/(?:USING|WITH\s+CHECK)\s*\(\s*(.*?)\s*\)/i)
205
- const condition = condMatch ? condMatch[1] : ''
208
+
209
+ // Extract the full USING/WITH CHECK clause (may be multi-line with nested parens)
210
+ let usingCondition = ''
211
+ let withCheckCondition = ''
212
+ if (hasUsing) {
213
+ const uMatch = stmt.match(/USING\s*\(\s*([\s\S]*?)\s*\)\s*(?:WITH\s+CHECK|$)/i)
214
+ || stmt.match(/USING\s*\(\s*([\s\S]*?)\s*\)\s*$/i)
215
+ usingCondition = uMatch ? uMatch[1].trim() : ''
216
+ }
217
+ if (hasWithCheck) {
218
+ const wcMatch = stmt.match(/WITH\s+CHECK\s*\(\s*([\s\S]*?)\s*\)\s*$/i)
219
+ withCheckCondition = wcMatch ? wcMatch[1].trim() : ''
220
+ }
206
221
 
207
222
  for (const table of loopTables) {
208
223
  if (!tables.has(table)) tables.set(table, { rlsEnabled: false, policies: [], rpcFunctions: new Map() })
@@ -211,8 +226,8 @@ function parseMigrations(projectRoot) {
211
226
  tables.get(table).policies.push({
212
227
  name: policyName,
213
228
  operation,
214
- using: hasUsing ? condition : '',
215
- withCheck: hasWithCheck ? condition : '',
229
+ using: usingCondition,
230
+ withCheck: withCheckCondition,
216
231
  file: relFile
217
232
  })
218
233
  }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * RLS Live DB Audit — queries pg_policies from the LIVE database
3
+ *
4
+ * The source of truth for RLS policy validation. Migration file parsing can miss:
5
+ * - Policies applied directly via SQL (not in migration files)
6
+ * - PL/pgSQL dynamic policies (EXECUTE format)
7
+ * - Policies overridden by later manual changes
8
+ *
9
+ * Requires SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY in environment.
10
+ * Connects via @supabase/supabase-js and calls a lightweight RPC.
11
+ *
12
+ * Setup (one-time per project):
13
+ * CREATE OR REPLACE FUNCTION tetra_rls_audit()
14
+ * RETURNS TABLE(tablename text, policyname text, using_clause text, with_check_clause text)
15
+ * LANGUAGE sql SECURITY DEFINER AS $$
16
+ * SELECT tablename::text, policyname::text, qual::text, with_check::text
17
+ * FROM pg_policies WHERE schemaname = 'public';
18
+ * $$;
19
+ */
20
+
21
+ export const meta = {
22
+ id: 'rls-live-audit',
23
+ name: 'RLS Live DB Audit',
24
+ category: 'security',
25
+ severity: 'critical',
26
+ description: 'Queries pg_policies from the live database and validates all RLS clauses against whitelisted patterns. Catches bypasses that migration parsing misses.'
27
+ }
28
+
29
+ const BANNED_RLS_PATTERNS = [
30
+ { pattern: /service_role/i, label: 'service_role bypass — service role already bypasses RLS at the Supabase layer' },
31
+ { pattern: /auth\.role\s*\(\)\s*=\s*'service_role'/i, label: 'auth.role() service_role bypass' },
32
+ { pattern: /current_setting\s*\(\s*'role'/i, label: 'PostgreSQL role check — bypasses tenant isolation' },
33
+ { pattern: /current_setting\s*\(\s*'request\.jwt\.claims'/i, label: 'JWT claims role check — bypasses tenant isolation' },
34
+ { pattern: /session_user/i, label: 'session_user check — bypasses tenant isolation' },
35
+ { pattern: /current_user\s*=/i, label: 'current_user check — bypasses tenant isolation' },
36
+ { pattern: /pg_has_role/i, label: 'pg_has_role — bypasses tenant isolation' },
37
+ ]
38
+
39
+ const ALLOWED_RLS_PATTERNS = [
40
+ { pattern: /auth_admin_organizations\s*\(\)/i },
41
+ { pattern: /auth_user_organizations\s*\(\)/i },
42
+ { pattern: /auth_org_id\s*\(\)/i },
43
+ { pattern: /auth\.uid\s*\(\)/i },
44
+ { pattern: /auth_current_user_id\s*\(\)/i },
45
+ { pattern: /auth\.role\s*\(\)\s*=\s*'authenticated'/i },
46
+ { pattern: /auth\.role\s*\(\)\s*=\s*'anon'/i },
47
+ { pattern: /\w+\s*=\s*/i },
48
+ { pattern: /\w+\s+IS\s+(NOT\s+)?NULL/i },
49
+ { pattern: /\w+\s+IN\s*\(/i },
50
+ { pattern: /\w+\s*=\s*ANY\s*\(/i },
51
+ { pattern: /EXISTS\s*\(\s*SELECT/i },
52
+ { pattern: /^\s*\(?true\)?\s*$/i },
53
+ { pattern: /^\s*\(?false\)?\s*$/i },
54
+ { pattern: /auth\.jwt\s*\(\)/i },
55
+ { pattern: /current_setting\s*\(\s*'app\./i },
56
+ { pattern: /\b\w+\s*\([^)]*\)/i },
57
+ ]
58
+
59
+ function validateRlsClause(clause) {
60
+ if (!clause || !clause.trim()) return null
61
+
62
+ for (const { pattern, label } of BANNED_RLS_PATTERNS) {
63
+ if (pattern.test(clause)) return label
64
+ }
65
+
66
+ if (!ALLOWED_RLS_PATTERNS.some(({ pattern }) => pattern.test(clause))) {
67
+ return `Unrecognized RLS clause: "${clause.substring(0, 150)}". Only whitelisted patterns allowed.`
68
+ }
69
+
70
+ return null
71
+ }
72
+
73
+ export async function run(config, projectRoot) {
74
+ const results = {
75
+ passed: true,
76
+ skipped: false,
77
+ findings: [],
78
+ summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
79
+ details: { policiesChecked: 0, tablesChecked: 0, violations: 0 }
80
+ }
81
+
82
+ const supabaseUrl = process.env.SUPABASE_URL
83
+ const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY
84
+
85
+ if (!supabaseUrl || !supabaseKey) {
86
+ results.skipped = true
87
+ results.skipReason = 'No SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY in environment'
88
+ return results
89
+ }
90
+
91
+ // Try to call tetra_rls_audit() RPC
92
+ let policies
93
+ try {
94
+ const response = await fetch(`${supabaseUrl}/rest/v1/rpc/tetra_rls_audit`, {
95
+ method: 'POST',
96
+ headers: {
97
+ 'apikey': supabaseKey,
98
+ 'Authorization': `Bearer ${supabaseKey}`,
99
+ 'Content-Type': 'application/json',
100
+ },
101
+ body: '{}',
102
+ })
103
+
104
+ if (!response.ok) {
105
+ const status = response.status
106
+ if (status === 404) {
107
+ results.skipped = true
108
+ results.skipReason = 'RPC tetra_rls_audit() not found. Create it once:\n\n CREATE OR REPLACE FUNCTION tetra_rls_audit()\n RETURNS TABLE(tablename text, policyname text, using_clause text, with_check_clause text)\n LANGUAGE sql SECURITY DEFINER AS $$\n SELECT tablename::text, policyname::text, qual::text, with_check::text\n FROM pg_policies WHERE schemaname = \'public\';\n $$;'
109
+ } else {
110
+ results.skipped = true
111
+ results.skipReason = `RPC tetra_rls_audit() failed with status ${status}`
112
+ }
113
+ return results
114
+ }
115
+
116
+ policies = await response.json()
117
+ } catch (err) {
118
+ results.skipped = true
119
+ results.skipReason = `Failed to query live DB: ${err.message}`
120
+ return results
121
+ }
122
+
123
+ if (!Array.isArray(policies) || policies.length === 0) {
124
+ results.skipped = true
125
+ results.skipReason = 'No policies returned from tetra_rls_audit()'
126
+ return results
127
+ }
128
+
129
+ const backendOnlyTables = config.supabase?.backendOnlyTables || []
130
+ const tablesChecked = new Set()
131
+
132
+ for (const policy of policies) {
133
+ if (!policy) continue
134
+ const { tablename, policyname, using_clause, with_check_clause } = policy
135
+
136
+ if (backendOnlyTables.includes(tablename)) continue
137
+ if (tablename?.startsWith('_') || tablename?.startsWith('pg_')) continue
138
+
139
+ tablesChecked.add(tablename)
140
+ results.details.policiesChecked++
141
+
142
+ for (const [clauseType, clause] of [['USING', using_clause], ['WITH CHECK', with_check_clause]]) {
143
+ if (!clause) continue
144
+ const violation = validateRlsClause(clause)
145
+ if (violation) {
146
+ results.passed = false
147
+ results.details.violations++
148
+ results.findings.push({
149
+ file: `LIVE DB → ${tablename}`,
150
+ line: 1,
151
+ type: 'rls-live-bypass',
152
+ severity: 'critical',
153
+ message: `[LIVE] Policy "${policyname}" on "${tablename}": ${violation}`,
154
+ fix: 'Fix the policy in the database and add a corrective migration.'
155
+ })
156
+ results.summary.critical++
157
+ results.summary.total++
158
+ }
159
+ }
160
+ }
161
+
162
+ results.details.tablesChecked = tablesChecked.size
163
+ return results
164
+ }
@@ -63,6 +63,9 @@ const ALLOWED_FILE_PATTERNS = [
63
63
  /BaseCronService/,
64
64
  // Internal service-to-service routes (API key auth, no user JWT)
65
65
  /internalRoutes/,
66
+ // Billing routers — hybrid files with both authenticated admin routes and unauthenticated webhook handlers
67
+ // systemDB is needed for Tetra BillingService config callbacks (getSystemDB, getWebhookDB)
68
+ /billingRouter/,
66
69
  ]
67
70
 
68
71
  /**
package/lib/runner.js CHANGED
@@ -26,6 +26,7 @@ import * as namingConventions from './checks/codeQuality/naming-conventions.js'
26
26
  import * as routeSeparation from './checks/codeQuality/route-separation.js'
27
27
  import * as gitignoreValidation from './checks/security/gitignore-validation.js'
28
28
  import * as routeConfigAlignment from './checks/security/route-config-alignment.js'
29
+ import * as rlsLiveAudit from './checks/security/rls-live-audit.js'
29
30
  import * as rlsPolicyAudit from './checks/supabase/rls-policy-audit.js'
30
31
  import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
31
32
  import * as rpcGeneratorOrigin from './checks/supabase/rpc-generator-origin.js'
@@ -96,7 +97,8 @@ const ALL_CHECKS = {
96
97
  rpcSecurityMode,
97
98
  systemdbWhitelist,
98
99
  gitignoreValidation,
99
- routeConfigAlignment
100
+ routeConfigAlignment,
101
+ rlsLiveAudit
100
102
  ],
101
103
  stability: [
102
104
  huskyHooks,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soulbatical/tetra-dev-toolkit",
3
- "version": "1.17.3",
3
+ "version": "1.18.0",
4
4
  "publishConfig": {
5
5
  "access": "restricted"
6
6
  },
@@ -31,7 +31,8 @@
31
31
  "tetra-dev-token": "./bin/tetra-dev-token.js",
32
32
  "tetra-check-rls": "./bin/tetra-check-rls.js",
33
33
  "tetra-migration-lint": "./bin/tetra-migration-lint.js",
34
- "tetra-db-push": "./bin/tetra-db-push.js"
34
+ "tetra-db-push": "./bin/tetra-db-push.js",
35
+ "tetra-check-peers": "./bin/tetra-check-peers.js"
35
36
  },
36
37
  "files": [
37
38
  "bin/",