@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.
@@ -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
- }