@soulbatical/tetra-dev-toolkit 1.16.3 → 1.17.1
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/lib/checks/security/config-rls-alignment.js +56 -2
- package/lib/checks/security/direct-supabase-client.js +2 -2
- package/lib/checks/security/mixed-db-usage.js +5 -3
- package/lib/checks/security/route-config-alignment.js +35 -6
- package/lib/checks/security/systemdb-whitelist.js +2 -1
- package/lib/config.js +7 -1
- package/package.json +1 -1
|
@@ -280,6 +280,35 @@ function isWideOpen(using) {
|
|
|
280
280
|
return using.trim() === 'true' || using.trim() === '(true)'
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Forbidden patterns in RLS policies — these bypass RLS and defeat the purpose.
|
|
285
|
+
* Valid patterns (from sparkbuddy-live):
|
|
286
|
+
* - organization_id IN (SELECT auth_admin_organizations())
|
|
287
|
+
* - user_id = auth.uid()
|
|
288
|
+
* - USING(true) for public tables only
|
|
289
|
+
* - Subquery to parent table with org/user check
|
|
290
|
+
*
|
|
291
|
+
* Everything else that grants blanket access is a security hole.
|
|
292
|
+
*/
|
|
293
|
+
const FORBIDDEN_RLS_PATTERNS = [
|
|
294
|
+
{ pattern: /service_role/i, label: 'service_role bypass' },
|
|
295
|
+
{ pattern: /current_setting\s*\(\s*'role'/i, label: 'PostgreSQL role check bypass' },
|
|
296
|
+
{ pattern: /current_setting\s*\(\s*'request\.jwt\.claims'/i, label: 'JWT claims role bypass' },
|
|
297
|
+
{ pattern: /session_user\s*=\s*'postgres'/i, label: 'session_user postgres bypass' },
|
|
298
|
+
{ pattern: /current_user\s*=\s*'postgres'/i, label: 'current_user postgres bypass' },
|
|
299
|
+
{ pattern: /auth\.role\s*\(\)\s*=\s*'service_role'/i, label: 'auth.role() service_role bypass' },
|
|
300
|
+
{ pattern: /pg_has_role/i, label: 'pg_has_role bypass' },
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Check if a USING/WITH CHECK clause contains forbidden bypass patterns
|
|
305
|
+
* Returns array of { label } for each forbidden pattern found
|
|
306
|
+
*/
|
|
307
|
+
function findForbiddenPatterns(clause) {
|
|
308
|
+
if (!clause) return []
|
|
309
|
+
return FORBIDDEN_RLS_PATTERNS.filter(({ pattern }) => pattern.test(clause)).map(({ label }) => label)
|
|
310
|
+
}
|
|
311
|
+
|
|
283
312
|
export async function run(config, projectRoot) {
|
|
284
313
|
const results = {
|
|
285
314
|
passed: true,
|
|
@@ -424,8 +453,12 @@ export async function run(config, projectRoot) {
|
|
|
424
453
|
}
|
|
425
454
|
}
|
|
426
455
|
|
|
427
|
-
// CHECK 4: RPC functions must be SECURITY INVOKER
|
|
456
|
+
// CHECK 4: RPC functions must be SECURITY INVOKER (unless whitelisted)
|
|
457
|
+
const definerWhitelist = config?.supabase?.securityDefinerWhitelist || []
|
|
428
458
|
for (const rpcName of cfg.rpcFunctions) {
|
|
459
|
+
// Skip if explicitly whitelisted in config
|
|
460
|
+
if (definerWhitelist.includes(rpcName)) continue
|
|
461
|
+
|
|
429
462
|
const rpcInfo = tableRls.rpcFunctions.get(rpcName)
|
|
430
463
|
if (rpcInfo && rpcInfo.securityMode === 'DEFINER') {
|
|
431
464
|
results.passed = false
|
|
@@ -435,7 +468,28 @@ export async function run(config, projectRoot) {
|
|
|
435
468
|
type: 'rpc-security-definer',
|
|
436
469
|
severity: 'critical',
|
|
437
470
|
message: `RPC function "${rpcName}" for table "${tableName}" uses SECURITY DEFINER — this BYPASSES RLS completely. Any authenticated user can see ALL data.`,
|
|
438
|
-
fix: `Change to SECURITY INVOKER or
|
|
471
|
+
fix: `Change to SECURITY INVOKER or add "${rpcName}" to supabase.securityDefinerWhitelist in .tetra-quality.json.`
|
|
472
|
+
})
|
|
473
|
+
results.summary.critical++
|
|
474
|
+
results.summary.total++
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// CHECK 4b: No forbidden bypass patterns in any policy clause
|
|
479
|
+
for (const p of policies) {
|
|
480
|
+
const usingViolations = findForbiddenPatterns(p.using)
|
|
481
|
+
const checkViolations = findForbiddenPatterns(p.withCheck)
|
|
482
|
+
const allViolations = [...new Set([...usingViolations, ...checkViolations])]
|
|
483
|
+
|
|
484
|
+
if (allViolations.length > 0) {
|
|
485
|
+
results.passed = false
|
|
486
|
+
results.findings.push({
|
|
487
|
+
file: p.file,
|
|
488
|
+
line: 1,
|
|
489
|
+
type: 'rls-bypass-pattern',
|
|
490
|
+
severity: 'critical',
|
|
491
|
+
message: `Policy "${p.name}" on table "${tableName}" contains forbidden bypass pattern(s): ${allViolations.join(', ')}. RLS policies must ONLY use auth.uid(), auth_admin_organizations(), or parent-table subqueries. Service role bypasses RLS automatically — adding it to policies defeats the purpose.`,
|
|
492
|
+
fix: `Remove the bypass clause. Valid patterns: USING (organization_id IN (SELECT auth_admin_organizations())) or USING (user_id = auth.uid()).`
|
|
439
493
|
})
|
|
440
494
|
results.summary.critical++
|
|
441
495
|
results.summary.total++
|
|
@@ -74,8 +74,8 @@ export async function run(config, projectRoot) {
|
|
|
74
74
|
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// Build whitelist from hardcoded + config
|
|
78
|
-
const configWhitelist = config?.
|
|
77
|
+
// Build whitelist from hardcoded + config (canonical: security.directSupabaseClientWhitelist)
|
|
78
|
+
const configWhitelist = config?.security?.directSupabaseClientWhitelist || config?.directSupabaseClientWhitelist || []
|
|
79
79
|
const extraPatterns = configWhitelist.map(p => new RegExp(p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
|
80
80
|
const allAllowed = [...ALLOWED_FILES, ...extraPatterns]
|
|
81
81
|
|
|
@@ -77,7 +77,8 @@ export async function run(config, projectRoot) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
// Config-based ignore for controllers that legitimately mix DB helpers
|
|
80
|
-
|
|
80
|
+
// Canonical: security.mixedDbWhitelist
|
|
81
|
+
const mixedDbIgnore = config?.security?.mixedDbWhitelist || config?.mixedDbWhitelist || []
|
|
81
82
|
|
|
82
83
|
const files = await glob('**/*[Cc]ontroller*.ts', {
|
|
83
84
|
cwd: projectRoot,
|
|
@@ -157,11 +158,12 @@ export async function run(config, projectRoot) {
|
|
|
157
158
|
|
|
158
159
|
// HIGH: Multiple DB helper types in one controller
|
|
159
160
|
if (usedLevels.length > 1) {
|
|
160
|
-
// Allow: Superadmin controllers
|
|
161
|
+
// Allow: Superadmin controllers use superadminDB which internally calls systemDB
|
|
162
|
+
// So tetra sees SUPERADMIN + SYSTEM (or SUPERADMIN + ADMIN) — both are legitimate
|
|
161
163
|
const isSuperadminMixingAdmin = expectedLevel === 'SUPERADMIN' &&
|
|
162
164
|
usedLevels.length === 2 &&
|
|
163
165
|
usedLevels.includes('SUPERADMIN') &&
|
|
164
|
-
usedLevels.includes('ADMIN')
|
|
166
|
+
(usedLevels.includes('ADMIN') || usedLevels.includes('SYSTEM'))
|
|
165
167
|
|
|
166
168
|
if (!isSuperadminMixingAdmin) {
|
|
167
169
|
results.passed = false
|
|
@@ -102,6 +102,32 @@ function hasOrgAdminMiddleware(content) {
|
|
|
102
102
|
return /requireOrganizationAdmin/.test(content)
|
|
103
103
|
}
|
|
104
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
|
+
|
|
105
131
|
/**
|
|
106
132
|
* Expected route filename for a given accessLevel
|
|
107
133
|
*/
|
|
@@ -123,8 +149,11 @@ export async function run(config, projectRoot) {
|
|
|
123
149
|
details: { routesChecked: 0, violations: 0 }
|
|
124
150
|
}
|
|
125
151
|
|
|
126
|
-
//
|
|
127
|
-
const routeIgnore = config?.
|
|
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)
|
|
128
157
|
|
|
129
158
|
const featureConfigs = parseFeatureConfigs(projectRoot)
|
|
130
159
|
|
|
@@ -176,8 +205,8 @@ export async function run(config, projectRoot) {
|
|
|
176
205
|
if (cfg.accessLevel === 'admin') {
|
|
177
206
|
// Route name should be adminRoutes.ts
|
|
178
207
|
if (routeName === 'adminRoutes.ts') {
|
|
179
|
-
// CRITICAL: admin route MUST have authenticateToken
|
|
180
|
-
if (!hasAuthMiddleware(content)) {
|
|
208
|
+
// CRITICAL: admin route MUST have authenticateToken (in file OR via RouteManager group)
|
|
209
|
+
if (!hasAuthMiddleware(content) && !routeManagerHasGroupAuth) {
|
|
181
210
|
results.findings.push({
|
|
182
211
|
file: relRouteFile,
|
|
183
212
|
line: 1,
|
|
@@ -192,8 +221,8 @@ export async function run(config, projectRoot) {
|
|
|
192
221
|
results.details.violations++
|
|
193
222
|
}
|
|
194
223
|
|
|
195
|
-
// CRITICAL: admin route MUST have requireOrganizationAdmin
|
|
196
|
-
if (!hasOrgAdminMiddleware(content)) {
|
|
224
|
+
// CRITICAL: admin route MUST have requireOrganizationAdmin (in file OR via RouteManager group)
|
|
225
|
+
if (!hasOrgAdminMiddleware(content) && !routeManagerHasGroupAuth) {
|
|
197
226
|
results.findings.push({
|
|
198
227
|
file: relRouteFile,
|
|
199
228
|
line: 1,
|
|
@@ -86,7 +86,8 @@ const FORBIDDEN_FILE_PATTERNS = [
|
|
|
86
86
|
const DEFAULT_MAX_WHITELIST_SIZE = 35
|
|
87
87
|
|
|
88
88
|
export async function run(config, projectRoot) {
|
|
89
|
-
|
|
89
|
+
// Canonical: security.systemDbMaxEntries
|
|
90
|
+
const MAX_WHITELIST_SIZE = config?.security?.systemDbMaxEntries || config?.systemDB?.maxWhitelistEntries || DEFAULT_MAX_WHITELIST_SIZE
|
|
90
91
|
const results = {
|
|
91
92
|
passed: true,
|
|
92
93
|
findings: [],
|
package/lib/config.js
CHANGED
|
@@ -42,7 +42,13 @@ export const DEFAULT_CONFIG = {
|
|
|
42
42
|
// Code patterns
|
|
43
43
|
checkSqlInjection: true,
|
|
44
44
|
checkEvalUsage: true,
|
|
45
|
-
checkCommandInjection: true
|
|
45
|
+
checkCommandInjection: true,
|
|
46
|
+
|
|
47
|
+
// Whitelists — project-specific overrides in .tetra-quality.json
|
|
48
|
+
directSupabaseClientWhitelist: [], // Files allowed to import createClient directly
|
|
49
|
+
mixedDbWhitelist: [], // Controllers allowed to mix DB helper types
|
|
50
|
+
routeConfigIgnore: [], // Tables to skip in route↔config alignment check
|
|
51
|
+
systemDbMaxEntries: 35 // Max systemDB whitelist entries before warning
|
|
46
52
|
},
|
|
47
53
|
|
|
48
54
|
// Stability checks
|