@soulbatical/tetra-dev-toolkit 1.20.2 → 2.0.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.
- package/README.md +235 -238
- package/bin/tetra-setup.js +2 -172
- package/lib/checks/health/index.js +0 -1
- package/lib/checks/health/scanner.js +1 -3
- package/lib/checks/health/types.js +1 -1
- package/lib/checks/hygiene/stella-compliance.js +2 -2
- package/lib/checks/security/deprecated-supabase-admin.js +6 -15
- package/lib/checks/security/direct-supabase-client.js +4 -22
- package/lib/checks/security/frontend-supabase-queries.js +1 -1
- package/lib/checks/security/hardcoded-secrets.js +2 -5
- package/lib/checks/security/systemdb-whitelist.js +27 -116
- package/lib/config.js +1 -7
- package/lib/runner.js +7 -120
- package/package.json +2 -7
- package/bin/tetra-check-peers.js +0 -359
- package/bin/tetra-db-push.js +0 -91
- package/bin/tetra-migration-lint.js +0 -317
- package/bin/tetra-security-gate.js +0 -293
- package/bin/tetra-smoke.js +0 -532
- package/lib/checks/health/smoke-readiness.js +0 -150
- package/lib/checks/security/config-rls-alignment.js +0 -637
- package/lib/checks/security/mixed-db-usage.js +0 -204
- package/lib/checks/security/rls-live-audit.js +0 -255
- package/lib/checks/security/route-config-alignment.js +0 -342
- package/lib/checks/security/rpc-security-mode.js +0 -194
- package/lib/checks/security/tetra-core-compliance.js +0 -197
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Mixed Database Usage Detection — HARD BLOCK
|
|
3
|
-
*
|
|
4
|
-
* Controllers MUST use only ONE type of database helper.
|
|
5
|
-
* The DB helper must match the controller naming convention:
|
|
6
|
-
*
|
|
7
|
-
* AdminXxxController → adminDB(req) only
|
|
8
|
-
* UserXxxController → userDB(req) only
|
|
9
|
-
* PublicXxxController → publicDB() only
|
|
10
|
-
* SystemXxxController → systemDB(context) only
|
|
11
|
-
* SuperadminXxxController → superadminDB(req) only
|
|
12
|
-
* WebhookXxxController → systemDB(context) only (webhooks have no user session)
|
|
13
|
-
*
|
|
14
|
-
* Violations:
|
|
15
|
-
* - CRITICAL: Controller uses systemDB when user context is available (should be adminDB/userDB)
|
|
16
|
-
* - HIGH: Controller mixes multiple DB helper types
|
|
17
|
-
* - MEDIUM: Controller DB helper doesn't match naming convention
|
|
18
|
-
*
|
|
19
|
-
* Reference: stella_howto_get slug="tetra-architecture-guide"
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { readFileSync, existsSync } from 'fs'
|
|
23
|
-
import { join, basename, dirname } from 'path'
|
|
24
|
-
import { glob } from 'glob'
|
|
25
|
-
|
|
26
|
-
export const meta = {
|
|
27
|
-
id: 'mixed-db-usage',
|
|
28
|
-
name: 'Mixed DB Usage',
|
|
29
|
-
category: 'security',
|
|
30
|
-
severity: 'critical',
|
|
31
|
-
description: 'Controllers must use exactly ONE DB helper type matching their naming convention. Detects systemDB misuse when user context is available.'
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const DB_PATTERNS = {
|
|
35
|
-
systemDB: { level: 'SYSTEM', pattern: /\bsystemDB\s*\(/g, desc: 'System-level (cron, webhooks)' },
|
|
36
|
-
adminDB: { level: 'ADMIN', pattern: /(?<!\w)adminDB\s*\(/g, desc: 'Admin operations (org-scoped)' },
|
|
37
|
-
userDB: { level: 'USER', pattern: /\buserDB\s*\(/g, desc: 'User-specific operations' },
|
|
38
|
-
publicDB: { level: 'PUBLIC', pattern: /\bpublicDB\s*\(/g, desc: 'Public/unauthenticated' },
|
|
39
|
-
superadminDB: { level: 'SUPERADMIN', pattern: /\bsuperadminDB\s*\(/g, desc: 'Cross-org superadmin' }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Determine expected DB level from controller filename
|
|
44
|
-
*/
|
|
45
|
-
function getExpectedLevel(fileName) {
|
|
46
|
-
const lower = fileName.toLowerCase()
|
|
47
|
-
// Order matters — check compound names first
|
|
48
|
-
if (lower.includes('system')) return 'SYSTEM'
|
|
49
|
-
if (lower.includes('superadmin')) return 'SUPERADMIN'
|
|
50
|
-
if (lower.includes('webhook')) return 'SYSTEM' // webhooks have no user session
|
|
51
|
-
if (lower.includes('cron')) return 'SYSTEM'
|
|
52
|
-
if (lower.includes('admin')) return 'ADMIN'
|
|
53
|
-
if (lower.includes('user')) return 'USER'
|
|
54
|
-
if (lower.includes('public')) return 'PUBLIC'
|
|
55
|
-
return null
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Check if file has user authentication context available
|
|
60
|
-
*/
|
|
61
|
-
function hasUserContext(content) {
|
|
62
|
-
return content.includes('AuthenticatedRequest') ||
|
|
63
|
-
content.includes('req.userToken') ||
|
|
64
|
-
content.includes('req.user') ||
|
|
65
|
-
content.includes('authenticateToken')
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export async function run(config, projectRoot) {
|
|
69
|
-
const results = {
|
|
70
|
-
name: 'Mixed DB Usage',
|
|
71
|
-
slug: 'mixed-db-usage',
|
|
72
|
-
passed: true,
|
|
73
|
-
skipped: false,
|
|
74
|
-
findings: [],
|
|
75
|
-
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
76
|
-
details: { controllersChecked: 0, violations: 0 }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Config-based ignore for controllers that legitimately mix DB helpers
|
|
80
|
-
// Canonical: security.mixedDbWhitelist
|
|
81
|
-
const mixedDbIgnore = config?.security?.mixedDbWhitelist || config?.mixedDbWhitelist || []
|
|
82
|
-
|
|
83
|
-
const files = await glob('**/*[Cc]ontroller*.ts', {
|
|
84
|
-
cwd: projectRoot,
|
|
85
|
-
ignore: [
|
|
86
|
-
'**/node_modules/**',
|
|
87
|
-
'**/.next/**',
|
|
88
|
-
'**/dist/**',
|
|
89
|
-
'**/build/**',
|
|
90
|
-
...(config.ignore || []),
|
|
91
|
-
'**/*.test.ts',
|
|
92
|
-
'**/*.spec.ts',
|
|
93
|
-
'**/*.d.ts'
|
|
94
|
-
]
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
if (files.length === 0) {
|
|
98
|
-
results.skipped = true
|
|
99
|
-
results.skipReason = 'No controller files found'
|
|
100
|
-
return results
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
for (const file of files) {
|
|
104
|
-
const fullPath = join(projectRoot, file)
|
|
105
|
-
let content
|
|
106
|
-
try { content = readFileSync(fullPath, 'utf-8') } catch { continue }
|
|
107
|
-
|
|
108
|
-
results.details.controllersChecked++
|
|
109
|
-
const fileName = basename(file)
|
|
110
|
-
|
|
111
|
-
// Skip controllers explicitly ignored in config
|
|
112
|
-
if (mixedDbIgnore.some(pattern => file.includes(pattern) || fileName.includes(pattern))) continue
|
|
113
|
-
|
|
114
|
-
// Detect all DB usages in this file
|
|
115
|
-
const dbUsages = new Map() // level -> [{type, line, code}]
|
|
116
|
-
const lines = content.split('\n')
|
|
117
|
-
|
|
118
|
-
for (const [dbType, cfg] of Object.entries(DB_PATTERNS)) {
|
|
119
|
-
lines.forEach((line, idx) => {
|
|
120
|
-
// Reset regex lastIndex
|
|
121
|
-
cfg.pattern.lastIndex = 0
|
|
122
|
-
if (cfg.pattern.test(line)) {
|
|
123
|
-
const level = cfg.level
|
|
124
|
-
if (!dbUsages.has(level)) dbUsages.set(level, [])
|
|
125
|
-
dbUsages.get(level).push({
|
|
126
|
-
type: dbType,
|
|
127
|
-
line: idx + 1,
|
|
128
|
-
code: line.trim().substring(0, 120)
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
})
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (dbUsages.size === 0) continue
|
|
135
|
-
|
|
136
|
-
const expectedLevel = getExpectedLevel(fileName)
|
|
137
|
-
const usedLevels = [...dbUsages.keys()]
|
|
138
|
-
const userContextAvailable = hasUserContext(content)
|
|
139
|
-
|
|
140
|
-
// CRITICAL: systemDB used when user context is available and it's not a system controller
|
|
141
|
-
if (dbUsages.has('SYSTEM') && userContextAvailable && expectedLevel !== 'SYSTEM') {
|
|
142
|
-
results.passed = false
|
|
143
|
-
const locs = dbUsages.get('SYSTEM')
|
|
144
|
-
for (const loc of locs) {
|
|
145
|
-
results.findings.push({
|
|
146
|
-
file,
|
|
147
|
-
line: loc.line,
|
|
148
|
-
type: 'systemDB with user context',
|
|
149
|
-
severity: 'critical',
|
|
150
|
-
message: `${fileName} uses systemDB() but has user authentication context → MUST use ${expectedLevel === 'ADMIN' ? 'adminDB(req)' : expectedLevel === 'USER' ? 'userDB(req)' : 'adminDB(req) or userDB(req)'}`,
|
|
151
|
-
snippet: loc.code,
|
|
152
|
-
fix: `Replace systemDB('context') with ${expectedLevel === 'ADMIN' ? 'adminDB(req)' : 'userDB(req)'}. systemDB bypasses RLS — user operations MUST go through RLS. See: stella_howto_get slug="tetra-architecture-guide"`
|
|
153
|
-
})
|
|
154
|
-
results.summary.critical++
|
|
155
|
-
results.summary.total++
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// HIGH: Multiple DB helper types in one controller
|
|
160
|
-
if (usedLevels.length > 1) {
|
|
161
|
-
// Allow: Superadmin controllers use superadminDB which internally calls systemDB
|
|
162
|
-
// So tetra sees SUPERADMIN + SYSTEM (or SUPERADMIN + ADMIN) — both are legitimate
|
|
163
|
-
const isSuperadminMixingAdmin = expectedLevel === 'SUPERADMIN' &&
|
|
164
|
-
usedLevels.length === 2 &&
|
|
165
|
-
usedLevels.includes('SUPERADMIN') &&
|
|
166
|
-
(usedLevels.includes('ADMIN') || usedLevels.includes('SYSTEM'))
|
|
167
|
-
|
|
168
|
-
if (!isSuperadminMixingAdmin) {
|
|
169
|
-
results.passed = false
|
|
170
|
-
results.findings.push({
|
|
171
|
-
file,
|
|
172
|
-
line: 1,
|
|
173
|
-
type: 'mixed db usage',
|
|
174
|
-
severity: 'high',
|
|
175
|
-
message: `${fileName} mixes ${usedLevels.join(' + ')} database helpers. Controllers MUST use exactly ONE DB helper type.`,
|
|
176
|
-
fix: `Split into separate controllers per DB level, or refactor services to accept injected supabase client. Admin + System mix → move system ops to a separate SystemXxxController.`
|
|
177
|
-
})
|
|
178
|
-
results.summary.high++
|
|
179
|
-
results.summary.total++
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// MEDIUM: DB helper doesn't match naming convention
|
|
184
|
-
if (expectedLevel && usedLevels.length === 1 && !usedLevels.includes(expectedLevel)) {
|
|
185
|
-
const actualLevel = usedLevels[0]
|
|
186
|
-
// Don't double-report if already caught as critical
|
|
187
|
-
if (!(actualLevel === 'SYSTEM' && userContextAvailable)) {
|
|
188
|
-
results.findings.push({
|
|
189
|
-
file,
|
|
190
|
-
line: 1,
|
|
191
|
-
type: 'naming mismatch',
|
|
192
|
-
severity: 'medium',
|
|
193
|
-
message: `${fileName} implies ${expectedLevel} but uses ${actualLevel}. Either rename the controller or change the DB helper.`,
|
|
194
|
-
fix: `Rename to match DB level, or change DB helper to match controller naming convention.`
|
|
195
|
-
})
|
|
196
|
-
results.summary.medium++
|
|
197
|
-
results.summary.total++
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
results.details.violations = results.findings.length
|
|
203
|
-
return results
|
|
204
|
-
}
|
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RLS Live DB Audit — queries pg_policies from the LIVE database
|
|
3
|
-
*
|
|
4
|
-
* The PRIMARY source of truth for RLS policy validation.
|
|
5
|
-
* When this check runs successfully, migration-based RLS checks
|
|
6
|
-
* (rls-policy-audit, config-rls-alignment) are skipped for current state
|
|
7
|
-
* and only validate unapplied migrations (delta).
|
|
8
|
-
*
|
|
9
|
-
* Migration file parsing can miss:
|
|
10
|
-
* - Policies applied directly via SQL (not in migration files)
|
|
11
|
-
* - PL/pgSQL dynamic policies (EXECUTE format)
|
|
12
|
-
* - Policies overridden by later manual changes
|
|
13
|
-
*
|
|
14
|
-
* Requires SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY in environment.
|
|
15
|
-
*
|
|
16
|
-
* Setup (one-time per project):
|
|
17
|
-
* CREATE OR REPLACE FUNCTION tetra_rls_audit()
|
|
18
|
-
* RETURNS TABLE(tablename text, policyname text, cmd text, using_clause text, with_check_clause text)
|
|
19
|
-
* LANGUAGE sql SECURITY DEFINER AS $$
|
|
20
|
-
* SELECT tablename::text, policyname::text, cmd::text, qual::text, with_check::text
|
|
21
|
-
* FROM pg_policies WHERE schemaname = 'public';
|
|
22
|
-
* $$;
|
|
23
|
-
*
|
|
24
|
-
* -- Optional: also return RLS enabled status per table
|
|
25
|
-
* CREATE OR REPLACE FUNCTION tetra_rls_tables()
|
|
26
|
-
* RETURNS TABLE(table_name text, rls_enabled boolean, rls_forced boolean)
|
|
27
|
-
* LANGUAGE sql SECURITY DEFINER AS $$
|
|
28
|
-
* SELECT c.relname::text, c.relrowsecurity, c.relforcerowsecurity
|
|
29
|
-
* FROM pg_class c
|
|
30
|
-
* JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
31
|
-
* WHERE n.nspname = 'public' AND c.relkind = 'r';
|
|
32
|
-
* $$;
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
export const meta = {
|
|
36
|
-
id: 'rls-live-audit',
|
|
37
|
-
name: 'RLS Live DB Audit',
|
|
38
|
-
category: 'security',
|
|
39
|
-
severity: 'critical',
|
|
40
|
-
description: 'Queries pg_policies from the live database and validates all RLS clauses against whitelisted patterns. Catches bypasses that migration parsing misses.'
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const BANNED_RLS_PATTERNS = [
|
|
44
|
-
{ pattern: /service_role/i, label: 'service_role bypass — service role already bypasses RLS at the Supabase layer' },
|
|
45
|
-
{ pattern: /auth\.role\s*\(\)\s*=\s*'service_role'/i, label: 'auth.role() service_role bypass' },
|
|
46
|
-
{ pattern: /current_setting\s*\(\s*'role'/i, label: 'PostgreSQL role check — bypasses tenant isolation' },
|
|
47
|
-
{ pattern: /current_setting\s*\(\s*'request\.jwt\.claims'/i, label: 'JWT claims role check — bypasses tenant isolation' },
|
|
48
|
-
{ pattern: /session_user/i, label: 'session_user check — bypasses tenant isolation' },
|
|
49
|
-
{ pattern: /current_user\s*=/i, label: 'current_user check — bypasses tenant isolation' },
|
|
50
|
-
{ pattern: /pg_has_role/i, label: 'pg_has_role — bypasses tenant isolation' },
|
|
51
|
-
]
|
|
52
|
-
|
|
53
|
-
const ALLOWED_RLS_PATTERNS = [
|
|
54
|
-
{ pattern: /auth_admin_organizations\s*\(\)/i },
|
|
55
|
-
{ pattern: /auth_user_organizations\s*\(\)/i },
|
|
56
|
-
{ pattern: /auth_org_id\s*\(\)/i },
|
|
57
|
-
{ pattern: /auth\.uid\s*\(\)/i },
|
|
58
|
-
{ pattern: /auth_current_user_id\s*\(\)/i },
|
|
59
|
-
{ pattern: /auth\.role\s*\(\)\s*=\s*'authenticated'/i },
|
|
60
|
-
{ pattern: /auth\.role\s*\(\)\s*=\s*'anon'/i },
|
|
61
|
-
{ pattern: /\w+\s*=\s*/i },
|
|
62
|
-
{ pattern: /\w+\s+IS\s+(NOT\s+)?NULL/i },
|
|
63
|
-
{ pattern: /\w+\s+IN\s*\(/i },
|
|
64
|
-
{ pattern: /\w+\s*=\s*ANY\s*\(/i },
|
|
65
|
-
{ pattern: /EXISTS\s*\(\s*SELECT/i },
|
|
66
|
-
{ pattern: /^\s*\(?true\)?\s*$/i },
|
|
67
|
-
{ pattern: /^\s*\(?false\)?\s*$/i },
|
|
68
|
-
{ pattern: /auth\.jwt\s*\(\)/i },
|
|
69
|
-
{ pattern: /current_setting\s*\(\s*'app\./i },
|
|
70
|
-
{ pattern: /\b\w+\s*\([^)]*\)/i },
|
|
71
|
-
]
|
|
72
|
-
|
|
73
|
-
function validateRlsClause(clause) {
|
|
74
|
-
if (!clause || !clause.trim()) return null
|
|
75
|
-
|
|
76
|
-
for (const { pattern, label } of BANNED_RLS_PATTERNS) {
|
|
77
|
-
if (pattern.test(clause)) return label
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
if (!ALLOWED_RLS_PATTERNS.some(({ pattern }) => pattern.test(clause))) {
|
|
81
|
-
return `Unrecognized RLS clause: "${clause.substring(0, 150)}". Only whitelisted patterns allowed.`
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return null
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Fetch data from a Supabase RPC endpoint.
|
|
89
|
-
* Returns null if the RPC doesn't exist or fails.
|
|
90
|
-
*/
|
|
91
|
-
async function callRpc(supabaseUrl, supabaseKey, rpcName) {
|
|
92
|
-
try {
|
|
93
|
-
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/${rpcName}`, {
|
|
94
|
-
method: 'POST',
|
|
95
|
-
headers: {
|
|
96
|
-
'apikey': supabaseKey,
|
|
97
|
-
'Authorization': `Bearer ${supabaseKey}`,
|
|
98
|
-
'Content-Type': 'application/json',
|
|
99
|
-
},
|
|
100
|
-
body: '{}',
|
|
101
|
-
})
|
|
102
|
-
if (!response.ok) return null
|
|
103
|
-
return await response.json()
|
|
104
|
-
} catch {
|
|
105
|
-
return null
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Build a structured live DB state from raw policy + table data.
|
|
111
|
-
* This is the same format that config-rls-alignment uses from migration parsing,
|
|
112
|
-
* so it can transparently use either source.
|
|
113
|
-
*
|
|
114
|
-
* Returns: Map<tableName, { rlsEnabled, policies: [{ name, operation, using, withCheck }] }>
|
|
115
|
-
*/
|
|
116
|
-
export function buildLiveState(policies, tableStatus) {
|
|
117
|
-
const tables = new Map()
|
|
118
|
-
|
|
119
|
-
// Initialize from table status (if available)
|
|
120
|
-
if (Array.isArray(tableStatus)) {
|
|
121
|
-
for (const t of tableStatus) {
|
|
122
|
-
if (!t?.table_name) continue
|
|
123
|
-
tables.set(t.table_name, {
|
|
124
|
-
rlsEnabled: t.rls_enabled || false,
|
|
125
|
-
policies: [],
|
|
126
|
-
rpcFunctions: new Map(),
|
|
127
|
-
source: 'live-db'
|
|
128
|
-
})
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Add policies
|
|
133
|
-
for (const policy of policies) {
|
|
134
|
-
if (!policy) continue
|
|
135
|
-
const { tablename, policyname, cmd, using_clause, with_check_clause } = policy
|
|
136
|
-
if (!tablename) continue
|
|
137
|
-
|
|
138
|
-
if (!tables.has(tablename)) {
|
|
139
|
-
// Table exists in policies but not in table status — it has RLS (otherwise no policies)
|
|
140
|
-
tables.set(tablename, { rlsEnabled: true, policies: [], rpcFunctions: new Map(), source: 'live-db' })
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
tables.get(tablename).policies.push({
|
|
144
|
-
name: policyname,
|
|
145
|
-
operation: (cmd || 'ALL').toUpperCase(),
|
|
146
|
-
using: using_clause || '',
|
|
147
|
-
withCheck: with_check_clause || '',
|
|
148
|
-
file: 'LIVE DB'
|
|
149
|
-
})
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return tables
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
export async function run(config, projectRoot) {
|
|
156
|
-
const results = {
|
|
157
|
-
passed: true,
|
|
158
|
-
skipped: false,
|
|
159
|
-
findings: [],
|
|
160
|
-
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
161
|
-
details: { policiesChecked: 0, tablesChecked: 0, violations: 0 },
|
|
162
|
-
// Expose live data so runner can pass it to other checks
|
|
163
|
-
_liveData: null
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const supabaseUrl = process.env.SUPABASE_URL
|
|
167
|
-
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
168
|
-
|
|
169
|
-
if (!supabaseUrl || !supabaseKey) {
|
|
170
|
-
results.skipped = true
|
|
171
|
-
results.skipReason = 'No SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY in environment'
|
|
172
|
-
return results
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
// Try to call tetra_rls_audit() RPC
|
|
176
|
-
let policies
|
|
177
|
-
try {
|
|
178
|
-
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/tetra_rls_audit`, {
|
|
179
|
-
method: 'POST',
|
|
180
|
-
headers: {
|
|
181
|
-
'apikey': supabaseKey,
|
|
182
|
-
'Authorization': `Bearer ${supabaseKey}`,
|
|
183
|
-
'Content-Type': 'application/json',
|
|
184
|
-
},
|
|
185
|
-
body: '{}',
|
|
186
|
-
})
|
|
187
|
-
|
|
188
|
-
if (!response.ok) {
|
|
189
|
-
const status = response.status
|
|
190
|
-
if (status === 404) {
|
|
191
|
-
results.skipped = true
|
|
192
|
-
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, cmd text, using_clause text, with_check_clause text)\n LANGUAGE sql SECURITY DEFINER AS $$\n SELECT tablename::text, policyname::text, cmd::text, qual::text, with_check::text\n FROM pg_policies WHERE schemaname = \'public\';\n $$;'
|
|
193
|
-
} else {
|
|
194
|
-
results.skipped = true
|
|
195
|
-
results.skipReason = `RPC tetra_rls_audit() failed with status ${status}`
|
|
196
|
-
}
|
|
197
|
-
return results
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
policies = await response.json()
|
|
201
|
-
} catch (err) {
|
|
202
|
-
results.skipped = true
|
|
203
|
-
results.skipReason = `Failed to query live DB: ${err.message}`
|
|
204
|
-
return results
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
if (!Array.isArray(policies) || policies.length === 0) {
|
|
208
|
-
results.skipped = true
|
|
209
|
-
results.skipReason = 'No policies returned from tetra_rls_audit()'
|
|
210
|
-
return results
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Optionally fetch table-level RLS status
|
|
214
|
-
const tableStatus = await callRpc(supabaseUrl, supabaseKey, 'tetra_rls_tables')
|
|
215
|
-
|
|
216
|
-
// Build structured live state (shared with config-rls-alignment)
|
|
217
|
-
const liveState = buildLiveState(policies, tableStatus)
|
|
218
|
-
results._liveData = { policies, tableStatus, liveState }
|
|
219
|
-
|
|
220
|
-
const backendOnlyTables = config.supabase?.backendOnlyTables || []
|
|
221
|
-
const tablesChecked = new Set()
|
|
222
|
-
|
|
223
|
-
for (const policy of policies) {
|
|
224
|
-
if (!policy) continue
|
|
225
|
-
const { tablename, policyname, using_clause, with_check_clause } = policy
|
|
226
|
-
|
|
227
|
-
if (backendOnlyTables.includes(tablename)) continue
|
|
228
|
-
if (tablename?.startsWith('_') || tablename?.startsWith('pg_')) continue
|
|
229
|
-
|
|
230
|
-
tablesChecked.add(tablename)
|
|
231
|
-
results.details.policiesChecked++
|
|
232
|
-
|
|
233
|
-
for (const [clauseType, clause] of [['USING', using_clause], ['WITH CHECK', with_check_clause]]) {
|
|
234
|
-
if (!clause) continue
|
|
235
|
-
const violation = validateRlsClause(clause)
|
|
236
|
-
if (violation) {
|
|
237
|
-
results.passed = false
|
|
238
|
-
results.details.violations++
|
|
239
|
-
results.findings.push({
|
|
240
|
-
file: `LIVE DB → ${tablename}`,
|
|
241
|
-
line: 1,
|
|
242
|
-
type: 'rls-live-bypass',
|
|
243
|
-
severity: 'critical',
|
|
244
|
-
message: `[LIVE] Policy "${policyname}" on "${tablename}": ${violation}`,
|
|
245
|
-
fix: 'Fix the policy in the database and add a corrective migration.'
|
|
246
|
-
})
|
|
247
|
-
results.summary.critical++
|
|
248
|
-
results.summary.total++
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
results.details.tablesChecked = tablesChecked.size
|
|
254
|
-
return results
|
|
255
|
-
}
|