@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,317 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Tetra Migration Lint — HARD BLOCK before supabase db push
|
|
5
|
-
*
|
|
6
|
-
* Scans SQL migration files OFFLINE (no Supabase connection needed) for:
|
|
7
|
-
* 1. SECURITY DEFINER on data RPCs (must be INVOKER)
|
|
8
|
-
* 2. Tables without ENABLE ROW LEVEL SECURITY
|
|
9
|
-
* 3. DROP POLICY / DROP TABLE without explicit confirmation
|
|
10
|
-
* 4. GRANT to public/anon on sensitive tables
|
|
11
|
-
* 5. Missing WITH CHECK on INSERT/UPDATE policies
|
|
12
|
-
*
|
|
13
|
-
* Usage:
|
|
14
|
-
* tetra-migration-lint # Lint all migrations
|
|
15
|
-
* tetra-migration-lint --staged # Only git-staged .sql files (for pre-commit)
|
|
16
|
-
* tetra-migration-lint --file path.sql # Lint a specific file
|
|
17
|
-
* tetra-migration-lint --fix-suggestions # Show fix SQL for each violation
|
|
18
|
-
* tetra-migration-lint --json # JSON output for CI
|
|
19
|
-
*
|
|
20
|
-
* Exit codes:
|
|
21
|
-
* 0 = all clean
|
|
22
|
-
* 1 = violations found (CRITICAL or HIGH)
|
|
23
|
-
* 2 = warnings only (MEDIUM/LOW) — does not block
|
|
24
|
-
*
|
|
25
|
-
* Hook usage (.husky/pre-commit):
|
|
26
|
-
* tetra-migration-lint --staged || exit 1
|
|
27
|
-
*
|
|
28
|
-
* Wrapper usage (replace `supabase db push`):
|
|
29
|
-
* tetra-migration-lint && supabase db push
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import { readFileSync, existsSync } from 'fs'
|
|
33
|
-
import { join, resolve, basename, relative } from 'path'
|
|
34
|
-
import { globSync } from 'glob'
|
|
35
|
-
import { execSync } from 'child_process'
|
|
36
|
-
import chalk from 'chalk'
|
|
37
|
-
import { program } from 'commander'
|
|
38
|
-
|
|
39
|
-
// ─── Dangerous patterns ────────────────────────────────────────────
|
|
40
|
-
const RULES = [
|
|
41
|
-
{
|
|
42
|
-
id: 'DEFINER_DATA_RPC',
|
|
43
|
-
severity: 'critical',
|
|
44
|
-
pattern: /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+(?:public\.)?(\w+)\s*\([^)]*\)[\s\S]*?SECURITY\s+DEFINER/gi,
|
|
45
|
-
test: (match, fullContent) => {
|
|
46
|
-
const funcName = match[1]
|
|
47
|
-
// Auth helpers are OK as DEFINER
|
|
48
|
-
const authWhitelist = [
|
|
49
|
-
// Auth helpers (need DEFINER to read auth schema)
|
|
50
|
-
'auth_org_id', 'auth_uid', 'auth_role', 'auth_user_role',
|
|
51
|
-
'auth_admin_organizations', 'auth_user_id', 'requesting_user_id',
|
|
52
|
-
'get_auth_org_id', 'get_current_user_id',
|
|
53
|
-
'auth_user_organizations', 'auth_creator_organizations',
|
|
54
|
-
'auth_organization_id', 'auth_is_admin', 'auth_is_superadmin',
|
|
55
|
-
// Public RPCs (anon access, DEFINER needed to bypass RLS and return safe columns)
|
|
56
|
-
'search_public_ad_library',
|
|
57
|
-
// System/billing RPCs (no user context)
|
|
58
|
-
'get_org_credit_limits',
|
|
59
|
-
// Supabase internal
|
|
60
|
-
'handle_new_user', 'moddatetime'
|
|
61
|
-
]
|
|
62
|
-
return !authWhitelist.includes(funcName)
|
|
63
|
-
},
|
|
64
|
-
message: (match) => `SECURITY DEFINER on RPC "${match[1]}" — bypasses RLS completely. Use SECURITY INVOKER instead.`,
|
|
65
|
-
fix: (match) => `ALTER FUNCTION ${match[1]} SECURITY INVOKER;`
|
|
66
|
-
},
|
|
67
|
-
{
|
|
68
|
-
id: 'CREATE_TABLE_NO_RLS',
|
|
69
|
-
severity: 'critical',
|
|
70
|
-
// Match CREATE TABLE that is NOT followed by ENABLE ROW LEVEL SECURITY in the same migration
|
|
71
|
-
test: (match, fullContent) => {
|
|
72
|
-
const tableName = match[1]
|
|
73
|
-
const rlsPattern = new RegExp(`ALTER\\s+TABLE\\s+(?:public\\.)?${tableName}\\s+ENABLE\\s+ROW\\s+LEVEL\\s+SECURITY`, 'i')
|
|
74
|
-
return !rlsPattern.test(fullContent)
|
|
75
|
-
},
|
|
76
|
-
pattern: /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)\s*\(/gi,
|
|
77
|
-
message: (match) => `CREATE TABLE "${match[1]}" without ENABLE ROW LEVEL SECURITY in same migration.`,
|
|
78
|
-
fix: (match) => `ALTER TABLE ${match[1]} ENABLE ROW LEVEL SECURITY;`
|
|
79
|
-
},
|
|
80
|
-
{
|
|
81
|
-
id: 'DROP_POLICY',
|
|
82
|
-
severity: 'high',
|
|
83
|
-
pattern: /DROP\s+POLICY\s+(?:IF\s+EXISTS\s+)?["']?(\w+)["']?\s+ON\s+(?:public\.)?(\w+)/gi,
|
|
84
|
-
message: (match) => `DROP POLICY "${match[1]}" on "${match[2]}" — removes security. Ensure a replacement policy exists in the same migration.`,
|
|
85
|
-
test: (match, fullContent) => {
|
|
86
|
-
const table = match[2]
|
|
87
|
-
// OK if there's a CREATE POLICY in the same migration for the same table
|
|
88
|
-
const createPattern = new RegExp(`CREATE\\s+POLICY.*ON\\s+(?:public\\.)?${table}`, 'i')
|
|
89
|
-
if (createPattern.test(fullContent)) return false
|
|
90
|
-
// OK if there's a DO $$ block that references the table (dynamic policy creation)
|
|
91
|
-
const doBlockPattern = new RegExp(`DO\\s+\\$\\$[\\s\\S]*?['"]${table}['"][\\s\\S]*?\\$\\$`, 'i')
|
|
92
|
-
if (doBlockPattern.test(fullContent)) return false
|
|
93
|
-
// OK if DROP POLICY IF EXISTS (safe — only drops if present)
|
|
94
|
-
const dropIfExists = new RegExp(`DROP\\s+POLICY\\s+IF\\s+EXISTS`, 'i')
|
|
95
|
-
const matchStr = match[0]
|
|
96
|
-
if (dropIfExists.test(matchStr)) {
|
|
97
|
-
// Check if ANY policy creation exists for this table (even dynamic)
|
|
98
|
-
const anyCreate = new RegExp(`(CREATE\\s+POLICY|EXECUTE\\s+format.*CREATE\\s+POLICY)`, 'i')
|
|
99
|
-
if (anyCreate.test(fullContent)) return false
|
|
100
|
-
}
|
|
101
|
-
return true
|
|
102
|
-
},
|
|
103
|
-
fix: (match) => `-- Add replacement policy:\n-- CREATE POLICY "${match[1]}" ON ${match[2]} FOR ALL USING (organization_id = auth_org_id());`
|
|
104
|
-
},
|
|
105
|
-
{
|
|
106
|
-
id: 'GRANT_PUBLIC_ANON',
|
|
107
|
-
severity: 'critical',
|
|
108
|
-
pattern: /GRANT\s+(?:ALL|INSERT|UPDATE|DELETE)\s+ON\s+(?:TABLE\s+)?(?:public\.)?(\w+)\s+TO\s+(public|anon)/gi,
|
|
109
|
-
message: (match) => `GRANT ${match[0].match(/GRANT\s+(\w+)/)[1]} to ${match[2]} on "${match[1]}" — allows unauthenticated access.`,
|
|
110
|
-
fix: (match) => `-- Remove this GRANT or restrict to authenticated:\n-- GRANT SELECT ON ${match[1]} TO authenticated;`
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
id: 'DISABLE_RLS',
|
|
114
|
-
severity: 'critical',
|
|
115
|
-
pattern: /ALTER\s+TABLE\s+(?:public\.)?(\w+)\s+DISABLE\s+ROW\s+LEVEL\s+SECURITY/gi,
|
|
116
|
-
message: (match) => `DISABLE ROW LEVEL SECURITY on "${match[1]}" — removes ALL protection.`,
|
|
117
|
-
fix: (match) => `-- Do NOT disable RLS. If you need service-level access, use systemDB() in backend code.`
|
|
118
|
-
},
|
|
119
|
-
{
|
|
120
|
-
id: 'POLICY_NO_WITH_CHECK',
|
|
121
|
-
severity: 'medium',
|
|
122
|
-
pattern: /CREATE\s+POLICY\s+["']?(\w+)["']?\s+ON\s+(?:public\.)?(\w+)\s+FOR\s+(INSERT|UPDATE)\s+TO\s+\w+\s+USING\s*\([^)]+\)(?!\s*WITH\s+CHECK)/gi,
|
|
123
|
-
message: (match) => `Policy "${match[1]}" on "${match[2]}" for ${match[3]} has USING but no WITH CHECK — users could write data they can't read.`,
|
|
124
|
-
fix: (match) => `-- Add WITH CHECK clause matching the USING clause`
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
id: 'USING_TRUE_WRITE',
|
|
128
|
-
severity: 'critical',
|
|
129
|
-
pattern: /CREATE\s+POLICY\s+["']?(\w+)["']?\s+ON\s+(?:public\.)?(\w+)\s+FOR\s+(INSERT|UPDATE|DELETE|ALL)\s+(?:TO\s+\w+\s+)?USING\s*\(\s*true\s*\)/gi,
|
|
130
|
-
message: (match) => `Policy "${match[1]}" on "${match[2]}" allows ${match[3]} with USING(true) — anyone can write.`,
|
|
131
|
-
fix: (match) => `-- Replace USING(true) with proper org/user scoping:\n-- USING (organization_id = auth_org_id())`
|
|
132
|
-
},
|
|
133
|
-
{
|
|
134
|
-
id: 'RAW_SERVICE_KEY',
|
|
135
|
-
severity: 'critical',
|
|
136
|
-
pattern: /eyJ[A-Za-z0-9_-]{20,}\.eyJ[A-Za-z0-9_-]{20,}/g,
|
|
137
|
-
message: () => `Hardcoded JWT/service key found in migration file.`,
|
|
138
|
-
fix: () => `-- NEVER put keys in migrations. Use environment variables or Vault.`
|
|
139
|
-
}
|
|
140
|
-
]
|
|
141
|
-
|
|
142
|
-
// ─── Load whitelist from .tetra-quality.json ───────────────────────
|
|
143
|
-
function loadWhitelist(projectRoot) {
|
|
144
|
-
const configPath = join(projectRoot, '.tetra-quality.json')
|
|
145
|
-
if (!existsSync(configPath)) return { securityDefinerWhitelist: [], tables: { noRls: [] } }
|
|
146
|
-
try {
|
|
147
|
-
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
|
|
148
|
-
return {
|
|
149
|
-
securityDefinerWhitelist: config?.supabase?.securityDefinerWhitelist || [],
|
|
150
|
-
tables: { noRls: config?.supabase?.backendOnlyTables || [] }
|
|
151
|
-
}
|
|
152
|
-
} catch { return { securityDefinerWhitelist: [], tables: { noRls: [] } } }
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ─── Find migration files ──────────────────────────────────────────
|
|
156
|
-
function findMigrations(projectRoot, options) {
|
|
157
|
-
if (options.file) {
|
|
158
|
-
const filePath = resolve(options.file)
|
|
159
|
-
return existsSync(filePath) ? [filePath] : []
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (options.staged) {
|
|
163
|
-
try {
|
|
164
|
-
const staged = execSync('git diff --cached --name-only --diff-filter=ACM', {
|
|
165
|
-
cwd: projectRoot, encoding: 'utf-8'
|
|
166
|
-
})
|
|
167
|
-
return staged.split('\n')
|
|
168
|
-
.filter(f => f.endsWith('.sql'))
|
|
169
|
-
.map(f => join(projectRoot, f))
|
|
170
|
-
.filter(f => existsSync(f))
|
|
171
|
-
} catch { return [] }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// All migrations
|
|
175
|
-
const patterns = [
|
|
176
|
-
'supabase/migrations/**/*.sql',
|
|
177
|
-
'backend/supabase/migrations/**/*.sql',
|
|
178
|
-
'migrations/**/*.sql'
|
|
179
|
-
]
|
|
180
|
-
const files = []
|
|
181
|
-
for (const pattern of patterns) {
|
|
182
|
-
files.push(...globSync(pattern, { cwd: projectRoot, absolute: true }))
|
|
183
|
-
}
|
|
184
|
-
return [...new Set(files)]
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// ─── Lint a single file ────────────────────────────────────────────
|
|
188
|
-
function lintFile(filePath, projectRoot, whitelist) {
|
|
189
|
-
const content = readFileSync(filePath, 'utf-8')
|
|
190
|
-
const relPath = relative(projectRoot, filePath)
|
|
191
|
-
const findings = []
|
|
192
|
-
|
|
193
|
-
for (const rule of RULES) {
|
|
194
|
-
// Reset regex lastIndex
|
|
195
|
-
rule.pattern.lastIndex = 0
|
|
196
|
-
let match
|
|
197
|
-
while ((match = rule.pattern.exec(content)) !== null) {
|
|
198
|
-
// Check whitelist for DEFINER rules
|
|
199
|
-
if (rule.id === 'DEFINER_DATA_RPC' && whitelist.securityDefinerWhitelist.includes(match[1])) {
|
|
200
|
-
continue
|
|
201
|
-
}
|
|
202
|
-
// Check backendOnlyTables for CREATE_TABLE_NO_RLS
|
|
203
|
-
if (rule.id === 'CREATE_TABLE_NO_RLS' && whitelist.tables.noRls.includes(match[1])) {
|
|
204
|
-
continue
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Run custom test if exists
|
|
208
|
-
if (rule.test && !rule.test(match, content)) {
|
|
209
|
-
continue
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Find line number
|
|
213
|
-
const lineNum = content.substring(0, match.index).split('\n').length
|
|
214
|
-
|
|
215
|
-
findings.push({
|
|
216
|
-
file: relPath,
|
|
217
|
-
line: lineNum,
|
|
218
|
-
rule: rule.id,
|
|
219
|
-
severity: rule.severity,
|
|
220
|
-
message: rule.message(match),
|
|
221
|
-
fix: rule.fix ? rule.fix(match) : null
|
|
222
|
-
})
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return findings
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// ─── Main ──────────────────────────────────────────────────────────
|
|
230
|
-
program
|
|
231
|
-
.name('tetra-migration-lint')
|
|
232
|
-
.description('Lint SQL migrations for security issues before pushing to Supabase')
|
|
233
|
-
.option('--staged', 'Only lint git-staged .sql files (for pre-commit hook)')
|
|
234
|
-
.option('--file <path>', 'Lint a specific SQL file')
|
|
235
|
-
.option('--fix-suggestions', 'Show fix SQL for each violation')
|
|
236
|
-
.option('--json', 'JSON output for CI')
|
|
237
|
-
.option('--project <path>', 'Project root (default: cwd)')
|
|
238
|
-
.parse()
|
|
239
|
-
|
|
240
|
-
const opts = program.opts()
|
|
241
|
-
const projectRoot = resolve(opts.project || process.cwd())
|
|
242
|
-
const whitelist = loadWhitelist(projectRoot)
|
|
243
|
-
const files = findMigrations(projectRoot, opts)
|
|
244
|
-
|
|
245
|
-
if (files.length === 0) {
|
|
246
|
-
if (!opts.json) console.log(chalk.gray('No migration files to lint.'))
|
|
247
|
-
process.exit(0)
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const allFindings = []
|
|
251
|
-
for (const file of files) {
|
|
252
|
-
allFindings.push(...lintFile(file, projectRoot, whitelist))
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// ─── Output ────────────────────────────────────────────────────────
|
|
256
|
-
if (opts.json) {
|
|
257
|
-
console.log(JSON.stringify({
|
|
258
|
-
files: files.length,
|
|
259
|
-
findings: allFindings,
|
|
260
|
-
critical: allFindings.filter(f => f.severity === 'critical').length,
|
|
261
|
-
high: allFindings.filter(f => f.severity === 'high').length,
|
|
262
|
-
medium: allFindings.filter(f => f.severity === 'medium').length,
|
|
263
|
-
passed: allFindings.filter(f => f.severity === 'critical' || f.severity === 'high').length === 0
|
|
264
|
-
}, null, 2))
|
|
265
|
-
} else {
|
|
266
|
-
const critical = allFindings.filter(f => f.severity === 'critical')
|
|
267
|
-
const high = allFindings.filter(f => f.severity === 'high')
|
|
268
|
-
const medium = allFindings.filter(f => f.severity === 'medium')
|
|
269
|
-
|
|
270
|
-
console.log('')
|
|
271
|
-
console.log(chalk.bold(' Tetra Migration Lint'))
|
|
272
|
-
console.log(chalk.gray(` ${files.length} migration files scanned`))
|
|
273
|
-
console.log('')
|
|
274
|
-
|
|
275
|
-
if (allFindings.length === 0) {
|
|
276
|
-
console.log(chalk.green(' ✅ All migrations pass security lint'))
|
|
277
|
-
console.log('')
|
|
278
|
-
process.exit(0)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Group by file
|
|
282
|
-
const byFile = {}
|
|
283
|
-
for (const f of allFindings) {
|
|
284
|
-
if (!byFile[f.file]) byFile[f.file] = []
|
|
285
|
-
byFile[f.file].push(f)
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
for (const [file, findings] of Object.entries(byFile)) {
|
|
289
|
-
console.log(chalk.underline(` ${file}`))
|
|
290
|
-
for (const f of findings) {
|
|
291
|
-
const icon = f.severity === 'critical' ? '🔴' : f.severity === 'high' ? '🟠' : '🟡'
|
|
292
|
-
const color = f.severity === 'critical' ? chalk.red : f.severity === 'high' ? chalk.yellow : chalk.gray
|
|
293
|
-
console.log(` ${icon} ${color(`[${f.severity.toUpperCase()}]`)} Line ${f.line}: ${f.message}`)
|
|
294
|
-
if (opts.fixSuggestions && f.fix) {
|
|
295
|
-
console.log(chalk.cyan(` Fix: ${f.fix}`))
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
console.log('')
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
console.log(chalk.bold(' Summary:'))
|
|
302
|
-
if (critical.length) console.log(chalk.red(` ${critical.length} CRITICAL`))
|
|
303
|
-
if (high.length) console.log(chalk.yellow(` ${high.length} HIGH`))
|
|
304
|
-
if (medium.length) console.log(chalk.gray(` ${medium.length} MEDIUM`))
|
|
305
|
-
console.log('')
|
|
306
|
-
|
|
307
|
-
if (critical.length || high.length) {
|
|
308
|
-
console.log(chalk.red.bold(' ❌ BLOCKED — fix CRITICAL/HIGH issues before pushing migrations'))
|
|
309
|
-
console.log(chalk.gray(' Run with --fix-suggestions to see fix SQL'))
|
|
310
|
-
console.log('')
|
|
311
|
-
process.exit(1)
|
|
312
|
-
} else {
|
|
313
|
-
console.log(chalk.yellow(' ⚠️ Warnings found but not blocking'))
|
|
314
|
-
console.log('')
|
|
315
|
-
process.exit(0)
|
|
316
|
-
}
|
|
317
|
-
}
|
|
@@ -1,293 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Tetra Security Gate — AI-powered pre-push security review
|
|
5
|
-
*
|
|
6
|
-
* Detects security-sensitive file changes in the current git diff,
|
|
7
|
-
* submits them to ralph-manager's security gate agent for review,
|
|
8
|
-
* and blocks the push if the agent denies the changes.
|
|
9
|
-
*
|
|
10
|
-
* Usage:
|
|
11
|
-
* tetra-security-gate # Auto-detect ralph-manager URL
|
|
12
|
-
* tetra-security-gate --url <url> # Explicit ralph-manager URL
|
|
13
|
-
* tetra-security-gate --timeout 120 # Custom timeout (seconds)
|
|
14
|
-
* tetra-security-gate --dry-run # Show what would be sent, don't block
|
|
15
|
-
*
|
|
16
|
-
* Exit codes:
|
|
17
|
-
* 0 = approved (or no security files changed)
|
|
18
|
-
* 1 = denied (security violation found)
|
|
19
|
-
* 0 = ralph-manager offline (graceful fallback, doesn't block)
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { program } from 'commander'
|
|
23
|
-
import { execSync } from 'child_process'
|
|
24
|
-
import chalk from 'chalk'
|
|
25
|
-
|
|
26
|
-
// Security-sensitive file patterns
|
|
27
|
-
const SECURITY_PATTERNS = [
|
|
28
|
-
/supabase\/migrations\/.*\.sql$/i,
|
|
29
|
-
/\.rls\./i,
|
|
30
|
-
/rls[-_]?policy/i,
|
|
31
|
-
/auth[-_]?config/i,
|
|
32
|
-
/middleware\/auth/i,
|
|
33
|
-
/middleware\/security/i,
|
|
34
|
-
/security\.ts$/i,
|
|
35
|
-
/security\.js$/i,
|
|
36
|
-
/\.env$/,
|
|
37
|
-
/\.env\.\w+$/,
|
|
38
|
-
/doppler\.yaml$/,
|
|
39
|
-
/auth-config/i,
|
|
40
|
-
/permissions/i,
|
|
41
|
-
/checks\/security\//i,
|
|
42
|
-
]
|
|
43
|
-
|
|
44
|
-
function isSecurityFile(file) {
|
|
45
|
-
return SECURITY_PATTERNS.some(p => p.test(file))
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function getChangedFiles() {
|
|
49
|
-
try {
|
|
50
|
-
// Files changed between HEAD and upstream (what's being pushed)
|
|
51
|
-
const upstream = execSync('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', { encoding: 'utf8' }).trim()
|
|
52
|
-
if (upstream) {
|
|
53
|
-
return execSync(`git diff --name-only ${upstream}...HEAD`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
|
54
|
-
}
|
|
55
|
-
} catch {
|
|
56
|
-
// No upstream — compare against origin/main or origin/master
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
for (const base of ['origin/main', 'origin/master']) {
|
|
60
|
-
try {
|
|
61
|
-
return execSync(`git diff --name-only ${base}...HEAD`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
|
62
|
-
} catch { /* try next */ }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
// Fallback: last commit
|
|
66
|
-
try {
|
|
67
|
-
return execSync('git diff --name-only HEAD~1', { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
|
|
68
|
-
} catch {
|
|
69
|
-
return []
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function getDiff(files) {
|
|
74
|
-
try {
|
|
75
|
-
const upstream = execSync('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', { encoding: 'utf8' }).trim()
|
|
76
|
-
if (upstream) {
|
|
77
|
-
return execSync(`git diff ${upstream}...HEAD -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
|
|
78
|
-
}
|
|
79
|
-
} catch { /* fallback */ }
|
|
80
|
-
|
|
81
|
-
for (const base of ['origin/main', 'origin/master']) {
|
|
82
|
-
try {
|
|
83
|
-
return execSync(`git diff ${base}...HEAD -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
|
|
84
|
-
} catch { /* try next */ }
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
return execSync(`git diff HEAD~1 -- ${files.join(' ')}`, { encoding: 'utf8', maxBuffer: 1024 * 1024 })
|
|
89
|
-
} catch {
|
|
90
|
-
return ''
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function getProjectName() {
|
|
95
|
-
try {
|
|
96
|
-
const remoteUrl = execSync('git remote get-url origin 2>/dev/null', { encoding: 'utf8' }).trim()
|
|
97
|
-
const match = remoteUrl.match(/\/([^/]+?)(?:\.git)?$/)
|
|
98
|
-
if (match) return match[1]
|
|
99
|
-
} catch { /* fallback */ }
|
|
100
|
-
|
|
101
|
-
try {
|
|
102
|
-
return execSync('basename "$(git rev-parse --show-toplevel)"', { encoding: 'utf8' }).trim()
|
|
103
|
-
} catch {
|
|
104
|
-
return 'unknown'
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function getRalphManagerUrl() {
|
|
109
|
-
// Check .ralph/ports.json first
|
|
110
|
-
try {
|
|
111
|
-
const portsJson = execSync('cat .ralph/ports.json 2>/dev/null', { encoding: 'utf8' })
|
|
112
|
-
const ports = JSON.parse(portsJson)
|
|
113
|
-
if (ports.api_url) return ports.api_url
|
|
114
|
-
} catch { /* fallback */ }
|
|
115
|
-
|
|
116
|
-
// Check RALPH_MANAGER_API env
|
|
117
|
-
if (process.env.RALPH_MANAGER_API) return process.env.RALPH_MANAGER_API
|
|
118
|
-
|
|
119
|
-
// Default
|
|
120
|
-
return 'http://localhost:3005'
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
async function pollForVerdict(baseUrl, gateId, timeoutSeconds) {
|
|
124
|
-
const deadline = Date.now() + timeoutSeconds * 1000
|
|
125
|
-
const pollInterval = 3000 // 3 seconds
|
|
126
|
-
|
|
127
|
-
while (Date.now() < deadline) {
|
|
128
|
-
try {
|
|
129
|
-
const resp = await fetch(`${baseUrl}/api/internal/security-gate/${gateId}`)
|
|
130
|
-
if (!resp.ok) {
|
|
131
|
-
console.error(chalk.yellow(` Poll failed: HTTP ${resp.status}`))
|
|
132
|
-
break
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const { data } = await resp.json()
|
|
136
|
-
|
|
137
|
-
if (data.status === 'approved') {
|
|
138
|
-
return { status: 'approved', reason: data.reason, findings: data.findings }
|
|
139
|
-
}
|
|
140
|
-
if (data.status === 'denied') {
|
|
141
|
-
return { status: 'denied', reason: data.reason, findings: data.findings }
|
|
142
|
-
}
|
|
143
|
-
if (data.status === 'error') {
|
|
144
|
-
return { status: 'error', reason: data.reason }
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Still pending — wait and retry
|
|
148
|
-
await new Promise(r => setTimeout(r, pollInterval))
|
|
149
|
-
} catch {
|
|
150
|
-
// Network error — ralph-manager might be restarting
|
|
151
|
-
await new Promise(r => setTimeout(r, pollInterval))
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
return { status: 'timeout', reason: `No verdict within ${timeoutSeconds}s` }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
program
|
|
159
|
-
.name('tetra-security-gate')
|
|
160
|
-
.description('AI-powered pre-push security gate — reviews RLS/auth/security changes')
|
|
161
|
-
.version('1.0.0')
|
|
162
|
-
.option('--url <url>', 'Ralph Manager URL (default: auto-detect)')
|
|
163
|
-
.option('--timeout <seconds>', 'Max wait time for agent verdict', '180')
|
|
164
|
-
.option('--dry-run', 'Show what would be submitted, do not block')
|
|
165
|
-
.action(async (options) => {
|
|
166
|
-
try {
|
|
167
|
-
console.log(chalk.blue.bold('\n Tetra Security Gate\n'))
|
|
168
|
-
|
|
169
|
-
// Step 1: Detect changed files
|
|
170
|
-
const allFiles = getChangedFiles()
|
|
171
|
-
const securityFiles = allFiles.filter(isSecurityFile)
|
|
172
|
-
|
|
173
|
-
if (securityFiles.length === 0) {
|
|
174
|
-
console.log(chalk.green(' No security-sensitive files changed — skipping gate.'))
|
|
175
|
-
console.log(chalk.gray(` (checked ${allFiles.length} files)\n`))
|
|
176
|
-
process.exit(0)
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
console.log(chalk.yellow(` ${securityFiles.length} security-sensitive file(s) detected:`))
|
|
180
|
-
for (const f of securityFiles) {
|
|
181
|
-
console.log(chalk.gray(` - ${f}`))
|
|
182
|
-
}
|
|
183
|
-
console.log()
|
|
184
|
-
|
|
185
|
-
// Step 2: Get the diff
|
|
186
|
-
const diff = getDiff(securityFiles)
|
|
187
|
-
if (!diff.trim()) {
|
|
188
|
-
console.log(chalk.green(' No actual diff content — skipping gate.\n'))
|
|
189
|
-
process.exit(0)
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const project = getProjectName()
|
|
193
|
-
|
|
194
|
-
if (options.dryRun) {
|
|
195
|
-
console.log(chalk.cyan(' [DRY RUN] Would submit to security gate:'))
|
|
196
|
-
console.log(chalk.gray(` Project: ${project}`))
|
|
197
|
-
console.log(chalk.gray(` Files: ${securityFiles.length}`))
|
|
198
|
-
console.log(chalk.gray(` Diff size: ${diff.length} chars`))
|
|
199
|
-
console.log()
|
|
200
|
-
process.exit(0)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Step 3: Submit to ralph-manager
|
|
204
|
-
const baseUrl = options.url || getRalphManagerUrl()
|
|
205
|
-
const timeout = parseInt(options.timeout, 10)
|
|
206
|
-
|
|
207
|
-
console.log(chalk.gray(` Submitting to ${baseUrl}...`))
|
|
208
|
-
|
|
209
|
-
let gateId
|
|
210
|
-
try {
|
|
211
|
-
const resp = await fetch(`${baseUrl}/api/internal/security-gate`, {
|
|
212
|
-
method: 'POST',
|
|
213
|
-
headers: { 'Content-Type': 'application/json' },
|
|
214
|
-
body: JSON.stringify({ project, files_changed: securityFiles, diff }),
|
|
215
|
-
signal: AbortSignal.timeout(10_000),
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
if (!resp.ok) {
|
|
219
|
-
const body = await resp.text()
|
|
220
|
-
console.error(chalk.yellow(` Ralph Manager returned ${resp.status}: ${body}`))
|
|
221
|
-
console.log(chalk.yellow(' Falling back to PASS (ralph-manager error).\n'))
|
|
222
|
-
process.exit(0)
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const { data } = await resp.json()
|
|
226
|
-
gateId = data.id
|
|
227
|
-
|
|
228
|
-
// If already resolved (e.g. fallback auto-approve)
|
|
229
|
-
if (data.status === 'approved') {
|
|
230
|
-
console.log(chalk.green(` ${chalk.bold('APPROVED')} (immediate): ${data.reason || 'OK'}\n`))
|
|
231
|
-
process.exit(0)
|
|
232
|
-
}
|
|
233
|
-
if (data.status === 'denied') {
|
|
234
|
-
console.error(chalk.red.bold(`\n PUSH BLOCKED — Security Gate DENIED\n`))
|
|
235
|
-
console.error(chalk.red(` Reason: ${data.reason}\n`))
|
|
236
|
-
process.exit(1)
|
|
237
|
-
}
|
|
238
|
-
} catch (err) {
|
|
239
|
-
// Ralph-manager offline — don't block the push
|
|
240
|
-
console.log(chalk.yellow(` Cannot reach ralph-manager at ${baseUrl}`))
|
|
241
|
-
console.log(chalk.yellow(' Falling back to PASS (offline fallback).\n'))
|
|
242
|
-
process.exit(0)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// Step 4: Poll for verdict
|
|
246
|
-
console.log(chalk.gray(` Agent reviewing... (timeout: ${timeout}s)`))
|
|
247
|
-
const result = await pollForVerdict(baseUrl, gateId, timeout)
|
|
248
|
-
|
|
249
|
-
if (result.status === 'approved') {
|
|
250
|
-
console.log(chalk.green.bold(`\n APPROVED: ${result.reason || 'No issues found'}`))
|
|
251
|
-
if (result.findings?.length) {
|
|
252
|
-
for (const f of result.findings) {
|
|
253
|
-
console.log(chalk.yellow(` ⚠ ${f}`))
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
console.log()
|
|
257
|
-
process.exit(0)
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
if (result.status === 'denied') {
|
|
261
|
-
console.error(chalk.red.bold(`\n ════════════════════════════════════════════════════════════`))
|
|
262
|
-
console.error(chalk.red.bold(` PUSH BLOCKED — Security Gate DENIED`))
|
|
263
|
-
console.error(chalk.red.bold(` ════════════════════════════════════════════════════════════`))
|
|
264
|
-
console.error(chalk.red(`\n Reason: ${result.reason}`))
|
|
265
|
-
if (result.findings?.length) {
|
|
266
|
-
console.error(chalk.red(`\n Findings:`))
|
|
267
|
-
for (const f of result.findings) {
|
|
268
|
-
console.error(chalk.red(` - ${f}`))
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
console.error(chalk.yellow(`\n Fix the issues and try again.\n`))
|
|
272
|
-
process.exit(1)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
if (result.status === 'timeout') {
|
|
276
|
-
console.log(chalk.yellow(` Agent did not respond within ${timeout}s.`))
|
|
277
|
-
console.log(chalk.yellow(' Falling back to PASS (timeout fallback).\n'))
|
|
278
|
-
process.exit(0)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// Unknown status — don't block
|
|
282
|
-
console.log(chalk.yellow(` Unexpected verdict status: ${result.status}`))
|
|
283
|
-
console.log(chalk.yellow(' Falling back to PASS.\n'))
|
|
284
|
-
process.exit(0)
|
|
285
|
-
|
|
286
|
-
} catch (err) {
|
|
287
|
-
console.error(chalk.red(`\n ERROR: ${err.message}\n`))
|
|
288
|
-
// Never block on internal errors
|
|
289
|
-
process.exit(0)
|
|
290
|
-
}
|
|
291
|
-
})
|
|
292
|
-
|
|
293
|
-
program.parse()
|