@soulbatical/tetra-dev-toolkit 1.9.2 → 1.10.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.
|
@@ -68,19 +68,26 @@ export async function check(projectPath) {
|
|
|
68
68
|
result.details.missing.push('Layer 2: .husky/pre-push missing tetra-check-rls')
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
// Layer 3:
|
|
72
|
-
|
|
71
|
+
// Layer 3: build/deploy has tetra-check-rls --errors-only
|
|
72
|
+
// Check railway.json, Dockerfile, docker-compose, and CI workflows
|
|
73
|
+
const buildFiles = [
|
|
73
74
|
join(projectPath, 'railway.json'),
|
|
74
75
|
join(projectPath, '..', 'railway.json'),
|
|
75
|
-
join(projectPath, 'backend', 'railway.json')
|
|
76
|
+
join(projectPath, 'backend', 'railway.json'),
|
|
77
|
+
join(projectPath, 'Dockerfile'),
|
|
78
|
+
join(projectPath, 'backend', 'Dockerfile'),
|
|
79
|
+
join(projectPath, '.github', 'workflows', 'quality.yml'),
|
|
80
|
+
join(projectPath, '.github', 'workflows', 'deploy.yml'),
|
|
81
|
+
join(projectPath, '.github', 'workflows', 'ci.yml')
|
|
76
82
|
]
|
|
77
83
|
|
|
78
|
-
for (const p of
|
|
84
|
+
for (const p of buildFiles) {
|
|
79
85
|
if (existsSync(p)) {
|
|
80
86
|
try {
|
|
81
87
|
const content = readFileSync(p, 'utf-8')
|
|
82
88
|
if (content.includes('tetra-check-rls')) {
|
|
83
89
|
result.details.layer3_build = true
|
|
90
|
+
result.details.layer3_source = p.split('/').slice(-2).join('/')
|
|
84
91
|
result.score += 1
|
|
85
92
|
break
|
|
86
93
|
}
|
|
@@ -89,7 +96,7 @@ export async function check(projectPath) {
|
|
|
89
96
|
}
|
|
90
97
|
|
|
91
98
|
if (!result.details.layer3_build) {
|
|
92
|
-
result.details.missing.push('Layer 3:
|
|
99
|
+
result.details.missing.push('Layer 3: No build/deploy file contains tetra-check-rls --errors-only (checked railway.json, Dockerfile, CI workflows)')
|
|
93
100
|
}
|
|
94
101
|
|
|
95
102
|
// Set status
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontend Supabase Queries Detection — HARD BLOCK
|
|
3
|
+
*
|
|
4
|
+
* Frontend code MUST NEVER query the database directly.
|
|
5
|
+
* All data access must go through backend API endpoints.
|
|
6
|
+
*
|
|
7
|
+
* This check finds:
|
|
8
|
+
* - supabase.from('table') calls in frontend code
|
|
9
|
+
* - supabase.rpc('function') calls in frontend code
|
|
10
|
+
* - supabase.storage calls in frontend code
|
|
11
|
+
*
|
|
12
|
+
* ALLOWED:
|
|
13
|
+
* - supabase.auth.* (login, signup, session management — that's what the anon key is for)
|
|
14
|
+
* - Type-only imports (import type { ... })
|
|
15
|
+
* - Test files
|
|
16
|
+
* - Supabase client initialization files (creating the client is OK, using .from() is NOT)
|
|
17
|
+
*
|
|
18
|
+
* WHY THIS MATTERS:
|
|
19
|
+
* - Frontend queries bypass backend validation, audit logging, and business logic
|
|
20
|
+
* - RLS is the ONLY protection — if policies are wrong, data leaks
|
|
21
|
+
* - Backend APIs can enforce rate limiting, input validation, and access control
|
|
22
|
+
* - Frontend queries expose your table schema to anyone with DevTools
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { glob } from 'glob'
|
|
26
|
+
import { readFileSync } from 'fs'
|
|
27
|
+
|
|
28
|
+
export const meta = {
|
|
29
|
+
id: 'frontend-supabase-queries',
|
|
30
|
+
name: 'Frontend Direct DB Queries',
|
|
31
|
+
category: 'security',
|
|
32
|
+
severity: 'critical',
|
|
33
|
+
description: 'Blocks direct Supabase .from()/.rpc()/.storage calls in frontend code — all data access must go through backend API'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Patterns that indicate a direct database query (not auth)
|
|
38
|
+
*/
|
|
39
|
+
const QUERY_PATTERNS = [
|
|
40
|
+
// .from('table_name') or .from("table_name") or .from(variable)
|
|
41
|
+
{ regex: /\.from\s*\(\s*['"`]/, type: 'direct-db-query', desc: '.from() query' },
|
|
42
|
+
{ regex: /\.from\s*\(\s*[a-zA-Z]/, type: 'direct-db-query', desc: '.from() query with variable' },
|
|
43
|
+
// .rpc('function_name')
|
|
44
|
+
{ regex: /\.rpc\s*\(\s*['"`]/, type: 'direct-rpc-call', desc: '.rpc() call' },
|
|
45
|
+
// .storage.from('bucket')
|
|
46
|
+
{ regex: /\.storage\s*\./, type: 'direct-storage-access', desc: '.storage access' },
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Lines to skip (not violations)
|
|
51
|
+
*/
|
|
52
|
+
const SKIP_PATTERNS = [
|
|
53
|
+
// Comments
|
|
54
|
+
/^\s*\/\//,
|
|
55
|
+
/^\s*\*/,
|
|
56
|
+
/^\s*\/\*/,
|
|
57
|
+
// Type definitions
|
|
58
|
+
/import\s+type/,
|
|
59
|
+
// Array.from() — not Supabase
|
|
60
|
+
/Array\.from/,
|
|
61
|
+
// Date.from, Object.from, etc
|
|
62
|
+
/\b(?:Array|Object|Date|Map|Set|FormData|URLSearchParams|Buffer)\.from/,
|
|
63
|
+
// new FormData().from — not Supabase
|
|
64
|
+
/FormData/,
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Files that are allowed to have Supabase queries in the frontend.
|
|
69
|
+
* These should be VERY rare — ideally zero.
|
|
70
|
+
*/
|
|
71
|
+
const ALLOWED_FILES = [
|
|
72
|
+
// Supabase client setup files (creating client is OK)
|
|
73
|
+
/supabase\.ts$/,
|
|
74
|
+
/supabaseClient\.ts$/,
|
|
75
|
+
/supabase-client\.ts$/,
|
|
76
|
+
/createBrowserClient/,
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
export async function run(config, projectRoot) {
|
|
80
|
+
const results = {
|
|
81
|
+
passed: true,
|
|
82
|
+
findings: [],
|
|
83
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Detect frontend directories
|
|
87
|
+
const frontendDirs = []
|
|
88
|
+
const candidates = ['frontend', 'web', 'app', 'client', 'src/app', 'src/pages', 'src/components']
|
|
89
|
+
for (const dir of candidates) {
|
|
90
|
+
try {
|
|
91
|
+
const files = await glob(`${dir}/**/*.{ts,tsx,js,jsx}`, {
|
|
92
|
+
cwd: projectRoot,
|
|
93
|
+
ignore: config.ignore || []
|
|
94
|
+
})
|
|
95
|
+
if (files.length > 0) {
|
|
96
|
+
frontendDirs.push(dir)
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// If no frontend directory found, also check for Next.js app router pattern
|
|
104
|
+
if (frontendDirs.length === 0) {
|
|
105
|
+
results.skipped = true
|
|
106
|
+
results.skipReason = 'No frontend directory detected'
|
|
107
|
+
return results
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Scan all frontend files
|
|
111
|
+
for (const dir of frontendDirs) {
|
|
112
|
+
const files = await glob(`${dir}/**/*.{ts,tsx,js,jsx}`, {
|
|
113
|
+
cwd: projectRoot,
|
|
114
|
+
ignore: [
|
|
115
|
+
...config.ignore,
|
|
116
|
+
'**/*.test.*',
|
|
117
|
+
'**/*.spec.*',
|
|
118
|
+
'**/*.d.ts',
|
|
119
|
+
'**/node_modules/**',
|
|
120
|
+
'**/.next/**',
|
|
121
|
+
'**/dist/**',
|
|
122
|
+
'**/build/**',
|
|
123
|
+
]
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
// Check if file is in allowed list
|
|
128
|
+
if (ALLOWED_FILES.some(pattern => pattern.test(file))) continue
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const content = readFileSync(`${projectRoot}/${file}`, 'utf-8')
|
|
132
|
+
const lines = content.split('\n')
|
|
133
|
+
|
|
134
|
+
for (let i = 0; i < lines.length; i++) {
|
|
135
|
+
const line = lines[i]
|
|
136
|
+
|
|
137
|
+
// Skip comment lines and type imports
|
|
138
|
+
if (SKIP_PATTERNS.some(p => p.test(line))) continue
|
|
139
|
+
|
|
140
|
+
for (const pattern of QUERY_PATTERNS) {
|
|
141
|
+
if (pattern.regex.test(line)) {
|
|
142
|
+
// Double check it's not Array.from or similar
|
|
143
|
+
if (/(?:Array|Object|Date|Map|Set|FormData|URLSearchParams|Buffer|crypto)\.from/.test(line)) continue
|
|
144
|
+
// Skip if it's clearly in a comment at end of line
|
|
145
|
+
const commentIdx = line.indexOf('//')
|
|
146
|
+
const matchIdx = line.search(pattern.regex)
|
|
147
|
+
if (commentIdx >= 0 && commentIdx < matchIdx) continue
|
|
148
|
+
|
|
149
|
+
results.passed = false
|
|
150
|
+
results.findings.push({
|
|
151
|
+
file,
|
|
152
|
+
line: i + 1,
|
|
153
|
+
type: pattern.type,
|
|
154
|
+
severity: 'critical',
|
|
155
|
+
message: `BLOCKED: Frontend code has direct Supabase ${pattern.desc}. Move to backend API endpoint.`,
|
|
156
|
+
snippet: line.trim().substring(0, 120),
|
|
157
|
+
fix: `Create a backend API endpoint and call it via fetch() or your API client instead.`
|
|
158
|
+
})
|
|
159
|
+
results.summary.critical++
|
|
160
|
+
results.summary.total++
|
|
161
|
+
break // One finding per line is enough
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch {
|
|
166
|
+
// Skip unreadable files
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return results
|
|
172
|
+
}
|
|
@@ -63,6 +63,7 @@ export async function run(config, projectRoot) {
|
|
|
63
63
|
const tables = new Map() // tableName -> { created: true, rlsEnabled: false, policies: [], droppedPolicies: [], file: string }
|
|
64
64
|
const securityDefinerFns = [] // { name, file }
|
|
65
65
|
const publicTables = config.supabase?.publicTables || []
|
|
66
|
+
const backendOnlyTables = config.supabase?.backendOnlyTables || []
|
|
66
67
|
|
|
67
68
|
for (const filePath of sqlFiles) {
|
|
68
69
|
let content
|
|
@@ -171,6 +172,9 @@ export async function run(config, projectRoot) {
|
|
|
171
172
|
// Skip intentionally public tables
|
|
172
173
|
if (publicTables.includes(tableName)) continue
|
|
173
174
|
|
|
175
|
+
// Skip backend-only tables (RLS ON + 0 policies is intentional — accessed via systemDB only)
|
|
176
|
+
if (backendOnlyTables.includes(tableName)) continue
|
|
177
|
+
|
|
174
178
|
// Skip internal Supabase tables
|
|
175
179
|
if (tableName.startsWith('_') || tableName.startsWith('pg_') || tableName.startsWith('auth_')) continue
|
|
176
180
|
|
|
@@ -191,17 +195,17 @@ export async function run(config, projectRoot) {
|
|
|
191
195
|
|
|
192
196
|
results.details.tablesWithRls++
|
|
193
197
|
|
|
194
|
-
// 2. Check policies exist
|
|
198
|
+
// 2. Check policies exist — RLS ON + 0 policies = DEAD TABLE (nobody can access, not even legit code)
|
|
195
199
|
if (info.policies.length === 0) {
|
|
196
200
|
results.passed = false
|
|
197
201
|
results.findings.push({
|
|
198
202
|
file: info.file,
|
|
199
|
-
type: '
|
|
200
|
-
severity: '
|
|
201
|
-
message: `Table "${tableName}" has RLS enabled but
|
|
203
|
+
type: 'RLS without policies',
|
|
204
|
+
severity: 'critical',
|
|
205
|
+
message: `Table "${tableName}" has RLS enabled but ZERO policies — all access blocked, table is dead. Either add policies or document as backend-only in .tetra-quality.json supabase.backendOnlyTables`,
|
|
202
206
|
table: tableName
|
|
203
207
|
})
|
|
204
|
-
results.summary.
|
|
208
|
+
results.summary.critical++
|
|
205
209
|
results.summary.total++
|
|
206
210
|
} else {
|
|
207
211
|
results.details.tablesWithPolicies++
|
package/lib/runner.js
CHANGED
|
@@ -11,6 +11,7 @@ import * as hardcodedSecrets from './checks/security/hardcoded-secrets.js'
|
|
|
11
11
|
import * as serviceKeyExposure from './checks/security/service-key-exposure.js'
|
|
12
12
|
import * as deprecatedSupabaseAdmin from './checks/security/deprecated-supabase-admin.js'
|
|
13
13
|
import * as directSupabaseClient from './checks/security/direct-supabase-client.js'
|
|
14
|
+
import * as frontendSupabaseQueries from './checks/security/frontend-supabase-queries.js'
|
|
14
15
|
import * as systemdbWhitelist from './checks/security/systemdb-whitelist.js'
|
|
15
16
|
import * as huskyHooks from './checks/stability/husky-hooks.js'
|
|
16
17
|
import * as ciPipeline from './checks/stability/ci-pipeline.js'
|
|
@@ -33,6 +34,7 @@ const ALL_CHECKS = {
|
|
|
33
34
|
serviceKeyExposure,
|
|
34
35
|
deprecatedSupabaseAdmin,
|
|
35
36
|
directSupabaseClient,
|
|
37
|
+
frontendSupabaseQueries,
|
|
36
38
|
systemdbWhitelist,
|
|
37
39
|
gitignoreValidation
|
|
38
40
|
],
|
|
@@ -169,7 +171,9 @@ export async function runQuickCheck(options = {}) {
|
|
|
169
171
|
// Run only critical security checks
|
|
170
172
|
const criticalChecks = [
|
|
171
173
|
hardcodedSecrets,
|
|
172
|
-
serviceKeyExposure
|
|
174
|
+
serviceKeyExposure,
|
|
175
|
+
directSupabaseClient,
|
|
176
|
+
frontendSupabaseQueries
|
|
173
177
|
]
|
|
174
178
|
|
|
175
179
|
const results = {
|