@soulbatical/tetra-dev-toolkit 1.20.0 → 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 -175
- package/lib/checks/security/tetra-core-compliance.js +0 -197
|
@@ -1,342 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Route ↔ Config Alignment Check
|
|
3
|
-
*
|
|
4
|
-
* Verifies that route files match the feature config accessLevel:
|
|
5
|
-
*
|
|
6
|
-
* - accessLevel 'admin' → route file must be adminRoutes.ts AND must have authenticateToken + requireOrganizationAdmin
|
|
7
|
-
* - accessLevel 'user' → route file must be userRoutes.ts AND must have authenticateToken
|
|
8
|
-
* - accessLevel 'public' → route can be publicRoutes.ts, NO auth middleware required
|
|
9
|
-
* - accessLevel 'system' → should NOT have any route file (backend-only)
|
|
10
|
-
* - accessLevel 'creator' → route must have authenticateToken
|
|
11
|
-
*
|
|
12
|
-
* CRITICAL if an admin endpoint has no auth middleware.
|
|
13
|
-
* HIGH if route name doesn't match access level.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { readFileSync, existsSync } from 'fs'
|
|
17
|
-
import { join, basename, dirname } from 'path'
|
|
18
|
-
import { globSync } from 'glob'
|
|
19
|
-
|
|
20
|
-
export const meta = {
|
|
21
|
-
id: 'route-config-alignment',
|
|
22
|
-
name: 'Route ↔ Config Alignment',
|
|
23
|
-
category: 'security',
|
|
24
|
-
severity: 'critical',
|
|
25
|
-
description: 'Verifies route middleware matches feature config accessLevel'
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Find files matching a glob pattern
|
|
30
|
-
*/
|
|
31
|
-
function findFiles(projectRoot, pattern) {
|
|
32
|
-
try {
|
|
33
|
-
return globSync(pattern, { cwd: projectRoot, absolute: true, ignore: ['**/node_modules/**'] })
|
|
34
|
-
} catch {
|
|
35
|
-
return []
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Parse all feature configs to extract tableName → accessLevel + feature directory
|
|
41
|
-
*/
|
|
42
|
-
function parseFeatureConfigs(projectRoot) {
|
|
43
|
-
const configs = [] // { tableName, accessLevel, configFile, featureDir }
|
|
44
|
-
|
|
45
|
-
const configFiles = [
|
|
46
|
-
...findFiles(projectRoot, 'backend/src/features/**/config/*.config.ts'),
|
|
47
|
-
...findFiles(projectRoot, 'src/features/**/config/*.config.ts')
|
|
48
|
-
]
|
|
49
|
-
|
|
50
|
-
for (const file of configFiles) {
|
|
51
|
-
let content
|
|
52
|
-
try { content = readFileSync(file, 'utf-8') } catch { continue }
|
|
53
|
-
|
|
54
|
-
const tableMatch = content.match(/tableName:\s*['"]([^'"]+)['"]/)
|
|
55
|
-
if (!tableMatch) continue
|
|
56
|
-
|
|
57
|
-
const tableName = tableMatch[1]
|
|
58
|
-
|
|
59
|
-
const accessMatch = content.match(/accessLevel:\s*['"]([^'"]+)['"]/)
|
|
60
|
-
const accessLevel = accessMatch ? accessMatch[1] : 'admin'
|
|
61
|
-
|
|
62
|
-
// Feature directory is two levels up from config file (features/X/config/file.ts → features/X)
|
|
63
|
-
const configDir = dirname(file)
|
|
64
|
-
const featureDir = dirname(configDir)
|
|
65
|
-
|
|
66
|
-
configs.push({
|
|
67
|
-
tableName,
|
|
68
|
-
accessLevel,
|
|
69
|
-
configFile: file.replace(projectRoot + '/', ''),
|
|
70
|
-
featureDir
|
|
71
|
-
})
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return configs
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Find route files in a feature directory
|
|
79
|
-
*/
|
|
80
|
-
function findRouteFiles(featureDir) {
|
|
81
|
-
const routesDir = join(featureDir, 'routes')
|
|
82
|
-
if (!existsSync(routesDir)) return []
|
|
83
|
-
|
|
84
|
-
try {
|
|
85
|
-
return globSync('*.ts', { cwd: routesDir, absolute: true, ignore: ['*.d.ts'] })
|
|
86
|
-
} catch {
|
|
87
|
-
return []
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Check if a route file contains authenticateToken middleware
|
|
93
|
-
*/
|
|
94
|
-
function hasAuthMiddleware(content) {
|
|
95
|
-
return /authenticateToken/.test(content)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Check if a route file contains requireOrganizationAdmin middleware
|
|
100
|
-
*/
|
|
101
|
-
function hasOrgAdminMiddleware(content) {
|
|
102
|
-
return /requireOrganizationAdmin/.test(content)
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Check if admin routes are protected by a RouteManager group-level middleware.
|
|
107
|
-
* Many projects apply auth middleware at the route group level (e.g., all /api/admin/* routes)
|
|
108
|
-
* rather than in individual route files.
|
|
109
|
-
*/
|
|
110
|
-
function hasRouteManagerGroupAuth(projectRoot) {
|
|
111
|
-
const candidates = [
|
|
112
|
-
join(projectRoot, 'backend/src/core/RouteManager.ts'),
|
|
113
|
-
join(projectRoot, 'src/core/RouteManager.ts'),
|
|
114
|
-
join(projectRoot, 'backend/src/routes/index.ts'),
|
|
115
|
-
join(projectRoot, 'src/routes/index.ts')
|
|
116
|
-
]
|
|
117
|
-
|
|
118
|
-
for (const file of candidates) {
|
|
119
|
-
if (!existsSync(file)) continue
|
|
120
|
-
try {
|
|
121
|
-
const content = readFileSync(file, 'utf-8')
|
|
122
|
-
// Check for group middleware pattern: prefix '/api/admin' with authenticateToken
|
|
123
|
-
if (/\/api\/admin/.test(content) && /authenticateToken/.test(content)) {
|
|
124
|
-
return true
|
|
125
|
-
}
|
|
126
|
-
} catch { /* skip */ }
|
|
127
|
-
}
|
|
128
|
-
return false
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Expected route filename for a given accessLevel
|
|
133
|
-
*/
|
|
134
|
-
function expectedRouteFileName(accessLevel) {
|
|
135
|
-
switch (accessLevel) {
|
|
136
|
-
case 'admin': return 'adminRoutes.ts'
|
|
137
|
-
case 'user': return 'userRoutes.ts'
|
|
138
|
-
case 'public': return 'publicRoutes.ts'
|
|
139
|
-
default: return null
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export async function run(config, projectRoot) {
|
|
144
|
-
const results = {
|
|
145
|
-
passed: true,
|
|
146
|
-
skipped: false,
|
|
147
|
-
findings: [],
|
|
148
|
-
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
149
|
-
details: { routesChecked: 0, violations: 0 }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Canonical: security.routeConfigIgnore
|
|
153
|
-
const routeIgnore = config?.security?.routeConfigIgnore || config?.routeConfigAlignmentIgnore || []
|
|
154
|
-
|
|
155
|
-
// Detect if RouteManager applies group-level auth middleware to /api/admin/*
|
|
156
|
-
const routeManagerHasGroupAuth = hasRouteManagerGroupAuth(projectRoot)
|
|
157
|
-
|
|
158
|
-
const featureConfigs = parseFeatureConfigs(projectRoot)
|
|
159
|
-
|
|
160
|
-
if (featureConfigs.length === 0) {
|
|
161
|
-
results.skipped = true
|
|
162
|
-
results.skipReason = 'No feature config files found'
|
|
163
|
-
return results
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
for (const cfg of featureConfigs) {
|
|
167
|
-
// Skip features explicitly ignored in config
|
|
168
|
-
if (routeIgnore.some(pattern => cfg.tableName === pattern || cfg.configFile.includes(pattern))) continue
|
|
169
|
-
const routeFiles = findRouteFiles(cfg.featureDir)
|
|
170
|
-
|
|
171
|
-
// --- system: should NOT have any route file ---
|
|
172
|
-
if (cfg.accessLevel === 'system') {
|
|
173
|
-
if (routeFiles.length > 0) {
|
|
174
|
-
const routeNames = routeFiles.map(f => basename(f)).join(', ')
|
|
175
|
-
results.findings.push({
|
|
176
|
-
file: cfg.configFile,
|
|
177
|
-
line: 1,
|
|
178
|
-
type: 'system-has-routes',
|
|
179
|
-
severity: 'high',
|
|
180
|
-
message: `Config declares accessLevel "system" for table "${cfg.tableName}" but feature has route files: ${routeNames}. System features should be backend-only with no HTTP routes.`,
|
|
181
|
-
fix: `Remove route files or change accessLevel in the config.`
|
|
182
|
-
})
|
|
183
|
-
results.summary.high++
|
|
184
|
-
results.summary.total++
|
|
185
|
-
results.passed = false
|
|
186
|
-
results.details.violations++
|
|
187
|
-
}
|
|
188
|
-
continue
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Skip features with no routes (may be intentional for some configs)
|
|
192
|
-
if (routeFiles.length === 0) continue
|
|
193
|
-
|
|
194
|
-
// Check each route file in this feature
|
|
195
|
-
for (const routeFile of routeFiles) {
|
|
196
|
-
results.details.routesChecked++
|
|
197
|
-
|
|
198
|
-
let content
|
|
199
|
-
try { content = readFileSync(routeFile, 'utf-8') } catch { continue }
|
|
200
|
-
|
|
201
|
-
const routeName = basename(routeFile)
|
|
202
|
-
const relRouteFile = routeFile.replace(projectRoot + '/', '')
|
|
203
|
-
|
|
204
|
-
// --- admin checks ---
|
|
205
|
-
if (cfg.accessLevel === 'admin') {
|
|
206
|
-
// Route name should be adminRoutes.ts
|
|
207
|
-
if (routeName === 'adminRoutes.ts') {
|
|
208
|
-
// CRITICAL: admin route MUST have authenticateToken (in file OR via RouteManager group)
|
|
209
|
-
if (!hasAuthMiddleware(content) && !routeManagerHasGroupAuth) {
|
|
210
|
-
results.findings.push({
|
|
211
|
-
file: relRouteFile,
|
|
212
|
-
line: 1,
|
|
213
|
-
type: 'admin-route-no-auth',
|
|
214
|
-
severity: 'critical',
|
|
215
|
-
message: `Admin route for table "${cfg.tableName}" is missing authenticateToken middleware. Endpoints are accessible without authentication.`,
|
|
216
|
-
fix: `Add authenticateToken middleware: router.use(authenticateToken, requireOrganizationAdmin)`
|
|
217
|
-
})
|
|
218
|
-
results.summary.critical++
|
|
219
|
-
results.summary.total++
|
|
220
|
-
results.passed = false
|
|
221
|
-
results.details.violations++
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// CRITICAL: admin route MUST have requireOrganizationAdmin (in file OR via RouteManager group)
|
|
225
|
-
if (!hasOrgAdminMiddleware(content) && !routeManagerHasGroupAuth) {
|
|
226
|
-
results.findings.push({
|
|
227
|
-
file: relRouteFile,
|
|
228
|
-
line: 1,
|
|
229
|
-
type: 'admin-route-no-org-admin',
|
|
230
|
-
severity: 'critical',
|
|
231
|
-
message: `Admin route for table "${cfg.tableName}" is missing requireOrganizationAdmin middleware. Any authenticated user can access admin endpoints.`,
|
|
232
|
-
fix: `Add requireOrganizationAdmin middleware: router.use(authenticateToken, requireOrganizationAdmin)`
|
|
233
|
-
})
|
|
234
|
-
results.summary.critical++
|
|
235
|
-
results.summary.total++
|
|
236
|
-
results.passed = false
|
|
237
|
-
results.details.violations++
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// --- user checks ---
|
|
243
|
-
if (cfg.accessLevel === 'user') {
|
|
244
|
-
if (routeName === 'userRoutes.ts') {
|
|
245
|
-
if (!hasAuthMiddleware(content)) {
|
|
246
|
-
results.findings.push({
|
|
247
|
-
file: relRouteFile,
|
|
248
|
-
line: 1,
|
|
249
|
-
type: 'user-route-no-auth',
|
|
250
|
-
severity: 'critical',
|
|
251
|
-
message: `User route for table "${cfg.tableName}" is missing authenticateToken middleware. Endpoints are accessible without authentication.`,
|
|
252
|
-
fix: `Add authenticateToken middleware: router.use(authenticateToken)`
|
|
253
|
-
})
|
|
254
|
-
results.summary.critical++
|
|
255
|
-
results.summary.total++
|
|
256
|
-
results.passed = false
|
|
257
|
-
results.details.violations++
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// HIGH: user-level feature shouldn't primarily use adminRoutes
|
|
262
|
-
if (routeName === 'adminRoutes.ts') {
|
|
263
|
-
results.findings.push({
|
|
264
|
-
file: relRouteFile,
|
|
265
|
-
line: 1,
|
|
266
|
-
type: 'user-feature-admin-route',
|
|
267
|
-
severity: 'high',
|
|
268
|
-
message: `Config declares accessLevel "user" for table "${cfg.tableName}" but has adminRoutes.ts. Route file name does not match access level.`,
|
|
269
|
-
fix: `Rename to userRoutes.ts or update config accessLevel to "admin".`
|
|
270
|
-
})
|
|
271
|
-
results.summary.high++
|
|
272
|
-
results.summary.total++
|
|
273
|
-
results.passed = false
|
|
274
|
-
results.details.violations++
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// --- creator checks ---
|
|
279
|
-
if (cfg.accessLevel === 'creator') {
|
|
280
|
-
// Creator routes must have authenticateToken
|
|
281
|
-
if (!hasAuthMiddleware(content) && routeName !== 'publicRoutes.ts') {
|
|
282
|
-
results.findings.push({
|
|
283
|
-
file: relRouteFile,
|
|
284
|
-
line: 1,
|
|
285
|
-
type: 'creator-route-no-auth',
|
|
286
|
-
severity: 'critical',
|
|
287
|
-
message: `Creator route "${routeName}" for table "${cfg.tableName}" is missing authenticateToken middleware. Endpoints are accessible without authentication.`,
|
|
288
|
-
fix: `Add authenticateToken middleware: router.use(authenticateToken)`
|
|
289
|
-
})
|
|
290
|
-
results.summary.critical++
|
|
291
|
-
results.summary.total++
|
|
292
|
-
results.passed = false
|
|
293
|
-
results.details.violations++
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
// --- public checks: no auth required, just verify naming ---
|
|
298
|
-
// public routes are fine without auth middleware, no action needed
|
|
299
|
-
|
|
300
|
-
// --- Cross-access-level route name mismatch ---
|
|
301
|
-
// Skip if route file has explicit @tetra-audit-ignore for this check
|
|
302
|
-
const hasIgnoreDirective = /@tetra-audit-ignore\s+route-config-alignment\b/.test(content)
|
|
303
|
-
if (cfg.accessLevel === 'admin' && routeName === 'publicRoutes.ts' && !hasAuthMiddleware(content) && !hasIgnoreDirective) {
|
|
304
|
-
results.findings.push({
|
|
305
|
-
file: relRouteFile,
|
|
306
|
-
line: 1,
|
|
307
|
-
type: 'admin-feature-public-route-no-auth',
|
|
308
|
-
severity: 'critical',
|
|
309
|
-
message: `Config declares accessLevel "admin" for table "${cfg.tableName}" but has a publicRoutes.ts without auth. Admin data may be exposed publicly.`,
|
|
310
|
-
fix: `Either add auth middleware to publicRoutes.ts, or add @tetra-audit-ignore route-config-alignment comment if the public route is intentional.`
|
|
311
|
-
})
|
|
312
|
-
results.summary.critical++
|
|
313
|
-
results.summary.total++
|
|
314
|
-
results.passed = false
|
|
315
|
-
results.details.violations++
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
// HIGH: Check if expected route file name exists for the access level
|
|
320
|
-
const expected = expectedRouteFileName(cfg.accessLevel)
|
|
321
|
-
if (expected && routeFiles.length > 0) {
|
|
322
|
-
const hasExpected = routeFiles.some(f => basename(f) === expected)
|
|
323
|
-
if (!hasExpected) {
|
|
324
|
-
const routeNames = routeFiles.map(f => basename(f)).join(', ')
|
|
325
|
-
results.findings.push({
|
|
326
|
-
file: cfg.configFile,
|
|
327
|
-
line: 1,
|
|
328
|
-
type: 'route-name-mismatch',
|
|
329
|
-
severity: 'high',
|
|
330
|
-
message: `Config declares accessLevel "${cfg.accessLevel}" for table "${cfg.tableName}" expecting ${expected} but found: ${routeNames}.`,
|
|
331
|
-
fix: `Rename the primary route file to ${expected} or update the config accessLevel.`
|
|
332
|
-
})
|
|
333
|
-
results.summary.high++
|
|
334
|
-
results.summary.total++
|
|
335
|
-
results.passed = false
|
|
336
|
-
results.details.violations++
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return results
|
|
342
|
-
}
|
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* RPC Security Mode Check — HARD BLOCK
|
|
3
|
-
*
|
|
4
|
-
* Scans ALL SQL migrations for RPC functions and verifies their security mode:
|
|
5
|
-
*
|
|
6
|
-
* SECURITY DEFINER = function runs as the DB owner, BYPASSES RLS completely.
|
|
7
|
-
* SECURITY INVOKER = function runs as the calling user, RLS is enforced.
|
|
8
|
-
*
|
|
9
|
-
* Rules:
|
|
10
|
-
* - Data query RPCs (get_*, list_*, search_*) → MUST be INVOKER
|
|
11
|
-
* - Auth helper functions (auth_org_id, auth_uid) → DEFINER is OK (they need to read auth.users)
|
|
12
|
-
* - Count/results RPCs linked to feature configs → MUST be INVOKER
|
|
13
|
-
* - Public RPCs (explicitly returning only public columns) → DEFINER is OK if whitelisted
|
|
14
|
-
*
|
|
15
|
-
* Whitelist: .tetra-quality.json → supabase.securityDefinerWhitelist: ['auth_org_id', ...]
|
|
16
|
-
*
|
|
17
|
-
* Reference: stella_howto_get slug="tetra-architecture-guide"
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import { readFileSync, existsSync } from 'fs'
|
|
21
|
-
import { join } from 'path'
|
|
22
|
-
import { globSync } from 'glob'
|
|
23
|
-
|
|
24
|
-
export const meta = {
|
|
25
|
-
id: 'rpc-security-mode',
|
|
26
|
-
name: 'RPC Security Mode',
|
|
27
|
-
category: 'security',
|
|
28
|
-
severity: 'critical',
|
|
29
|
-
description: 'Verifies all RPC functions use SECURITY INVOKER (not DEFINER) unless explicitly whitelisted. DEFINER bypasses RLS completely.'
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Functions that legitimately need SECURITY DEFINER
|
|
33
|
-
const BUILTIN_DEFINER_WHITELIST = [
|
|
34
|
-
// Auth helpers (need to read auth schema / organization_members)
|
|
35
|
-
'auth_org_id',
|
|
36
|
-
'auth_admin_organizations',
|
|
37
|
-
'auth_user_organizations',
|
|
38
|
-
'auth_creator_organizations',
|
|
39
|
-
'get_user_org_role',
|
|
40
|
-
'get_org_id',
|
|
41
|
-
'handle_new_user',
|
|
42
|
-
'moddatetime',
|
|
43
|
-
// Public RPCs (called by anon users, need DEFINER to bypass RLS and return only safe columns)
|
|
44
|
-
'search_public_ad_library',
|
|
45
|
-
// System/billing RPCs (called by systemDB, no user context)
|
|
46
|
-
'get_org_credit_limits',
|
|
47
|
-
// Supabase internal
|
|
48
|
-
'pgsodium_encrypt',
|
|
49
|
-
'pgsodium_decrypt'
|
|
50
|
-
]
|
|
51
|
-
|
|
52
|
-
export async function run(config, projectRoot) {
|
|
53
|
-
const results = {
|
|
54
|
-
passed: true,
|
|
55
|
-
skipped: false,
|
|
56
|
-
findings: [],
|
|
57
|
-
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
58
|
-
details: { rpcsFound: 0, definerCount: 0, invokerCount: 0, defaultCount: 0, whitelistedCount: 0 }
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const migrationDirs = [
|
|
62
|
-
join(projectRoot, 'supabase/migrations'),
|
|
63
|
-
join(projectRoot, 'backend/supabase/migrations')
|
|
64
|
-
]
|
|
65
|
-
|
|
66
|
-
const sqlFiles = []
|
|
67
|
-
for (const dir of migrationDirs) {
|
|
68
|
-
if (!existsSync(dir)) continue
|
|
69
|
-
try {
|
|
70
|
-
const files = globSync('*.sql', { cwd: dir, absolute: true })
|
|
71
|
-
sqlFiles.push(...files)
|
|
72
|
-
} catch { /* skip */ }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
if (sqlFiles.length === 0) {
|
|
76
|
-
results.skipped = true
|
|
77
|
-
results.skipReason = 'No SQL migration files found'
|
|
78
|
-
return results
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// Build whitelist from config + builtins
|
|
82
|
-
const userWhitelist = (config.supabase?.securityDefinerWhitelist || [])
|
|
83
|
-
const whitelist = new Set([...BUILTIN_DEFINER_WHITELIST, ...userWhitelist])
|
|
84
|
-
|
|
85
|
-
// Track latest definition per function (migrations can override)
|
|
86
|
-
const functions = new Map() // funcName → { securityMode, file, line, isDataQuery }
|
|
87
|
-
|
|
88
|
-
for (const file of sqlFiles) {
|
|
89
|
-
let content
|
|
90
|
-
try { content = readFileSync(file, 'utf-8') } catch { continue }
|
|
91
|
-
|
|
92
|
-
const relFile = file.replace(projectRoot + '/', '')
|
|
93
|
-
|
|
94
|
-
// Find all CREATE [OR REPLACE] FUNCTION statements
|
|
95
|
-
const funcRegex = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\(/gi
|
|
96
|
-
let match
|
|
97
|
-
|
|
98
|
-
while ((match = funcRegex.exec(content)) !== null) {
|
|
99
|
-
const funcName = match[1]
|
|
100
|
-
const startPos = match.index
|
|
101
|
-
|
|
102
|
-
// Extract the function body (up to next CREATE FUNCTION or end of file, max 5000 chars)
|
|
103
|
-
const bodyEnd = Math.min(startPos + 5000, content.length)
|
|
104
|
-
const funcBody = content.substring(startPos, bodyEnd)
|
|
105
|
-
|
|
106
|
-
// Determine security mode
|
|
107
|
-
let securityMode = 'DEFAULT' // PostgreSQL default is INVOKER
|
|
108
|
-
if (/SECURITY\s+DEFINER/i.test(funcBody.substring(0, 2000))) {
|
|
109
|
-
securityMode = 'DEFINER'
|
|
110
|
-
} else if (/SECURITY\s+INVOKER/i.test(funcBody.substring(0, 2000))) {
|
|
111
|
-
securityMode = 'INVOKER'
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Determine if this is a data query function
|
|
115
|
-
const isDataQuery = /^(get_|list_|search_|find_|fetch_|count_)/i.test(funcName) ||
|
|
116
|
-
/_counts$|_results$|_detail$/i.test(funcName)
|
|
117
|
-
|
|
118
|
-
// Calculate line number
|
|
119
|
-
const beforeMatch = content.substring(0, startPos)
|
|
120
|
-
const line = (beforeMatch.match(/\n/g) || []).length + 1
|
|
121
|
-
|
|
122
|
-
// Store (later definitions override earlier ones)
|
|
123
|
-
functions.set(funcName, { securityMode, file: relFile, line, isDataQuery, funcBody: funcBody.substring(0, 500) })
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
results.details.rpcsFound = functions.size
|
|
128
|
-
|
|
129
|
-
for (const [funcName, info] of functions) {
|
|
130
|
-
if (info.securityMode === 'DEFINER') {
|
|
131
|
-
results.details.definerCount++
|
|
132
|
-
|
|
133
|
-
// Check whitelist
|
|
134
|
-
if (whitelist.has(funcName)) {
|
|
135
|
-
results.details.whitelistedCount++
|
|
136
|
-
continue
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Data query RPCs with DEFINER = CRITICAL
|
|
140
|
-
if (info.isDataQuery) {
|
|
141
|
-
results.passed = false
|
|
142
|
-
results.findings.push({
|
|
143
|
-
file: info.file,
|
|
144
|
-
line: info.line,
|
|
145
|
-
type: 'data-rpc-security-definer',
|
|
146
|
-
severity: 'critical',
|
|
147
|
-
message: `Data RPC "${funcName}" uses SECURITY DEFINER — bypasses ALL RLS policies. Any authenticated user can see ALL data from ALL organizations.`,
|
|
148
|
-
fix: `Change to SECURITY INVOKER or remove the SECURITY DEFINER clause. If this function legitimately needs DEFINER, add "${funcName}" to supabase.securityDefinerWhitelist in .tetra-quality.json.`
|
|
149
|
-
})
|
|
150
|
-
results.summary.critical++
|
|
151
|
-
results.summary.total++
|
|
152
|
-
} else {
|
|
153
|
-
// Non-data RPCs with DEFINER = HIGH (should still be investigated)
|
|
154
|
-
results.findings.push({
|
|
155
|
-
file: info.file,
|
|
156
|
-
line: info.line,
|
|
157
|
-
type: 'non-data-rpc-security-definer',
|
|
158
|
-
severity: 'high',
|
|
159
|
-
message: `RPC "${funcName}" uses SECURITY DEFINER but is not whitelisted. DEFINER functions bypass RLS — ensure this is intentional.`,
|
|
160
|
-
fix: `Change to SECURITY INVOKER, or add "${funcName}" to supabase.securityDefinerWhitelist in .tetra-quality.json if DEFINER is intentional.`
|
|
161
|
-
})
|
|
162
|
-
results.summary.high++
|
|
163
|
-
results.summary.total++
|
|
164
|
-
results.passed = false
|
|
165
|
-
}
|
|
166
|
-
} else if (info.securityMode === 'INVOKER') {
|
|
167
|
-
results.details.invokerCount++
|
|
168
|
-
} else {
|
|
169
|
-
results.details.defaultCount++
|
|
170
|
-
// DEFAULT = INVOKER in PostgreSQL, which is correct
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return results
|
|
175
|
-
}
|