@soulbatical/tetra-dev-toolkit 1.17.4 → 1.18.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/bin/tetra-check-peers.js +66 -27
- package/lib/checks/security/rls-live-audit.js +164 -0
- package/lib/runner.js +3 -1
- package/package.json +1 -1
package/bin/tetra-check-peers.js
CHANGED
|
@@ -38,50 +38,89 @@ function readJson(path) {
|
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function satisfiesRange(installed, range) {
|
|
41
|
-
//
|
|
42
|
-
//
|
|
41
|
+
// Check if a consumer's dependency range can satisfy a peer dep range.
|
|
42
|
+
// Both `installed` and `range` can be semver ranges (^2.48.0, ^2.93.3, etc.)
|
|
43
|
+
// We check if the ranges CAN overlap — i.e., there exists a version that satisfies both.
|
|
43
44
|
if (!installed || !range) return false
|
|
44
|
-
if (range === '*') return true
|
|
45
|
+
if (range === '*' || installed === '*') return true
|
|
45
46
|
|
|
46
|
-
// Clean versions: remove leading ^ ~ >= <= > < =
|
|
47
47
|
const cleanVersion = (v) => v.replace(/^[\^~>=<\s]+/, '').trim()
|
|
48
48
|
const parseVersion = (v) => {
|
|
49
49
|
const cleaned = cleanVersion(v)
|
|
50
50
|
const parts = cleaned.split('.').map(Number)
|
|
51
51
|
return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0 }
|
|
52
52
|
}
|
|
53
|
+
const versionGte = (a, b) => {
|
|
54
|
+
if (a.major !== b.major) return a.major > b.major
|
|
55
|
+
if (a.minor !== b.minor) return a.minor > b.minor
|
|
56
|
+
return a.patch >= b.patch
|
|
57
|
+
}
|
|
53
58
|
|
|
54
|
-
|
|
55
|
-
const
|
|
59
|
+
// For || ranges, check each part independently
|
|
60
|
+
const rangeParts = range.split('||').map(p => p.trim())
|
|
61
|
+
const installedParts = installed.split('||').map(p => p.trim())
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
63
|
+
return rangeParts.some(rPart => {
|
|
64
|
+
return installedParts.some(iPart => {
|
|
65
|
+
return rangesOverlap(iPart, rPart, parseVersion, versionGte)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rangesOverlap(installedRange, requiredRange, parseVersion, versionGte) {
|
|
71
|
+
const isCaret = (r) => r.startsWith('^')
|
|
72
|
+
const isTilde = (r) => r.startsWith('~')
|
|
73
|
+
const isGte = (r) => r.startsWith('>=')
|
|
74
|
+
|
|
75
|
+
const inst = parseVersion(installedRange)
|
|
76
|
+
const req = parseVersion(requiredRange)
|
|
77
|
+
|
|
78
|
+
// Both caret ranges with same major: they overlap if their ranges intersect
|
|
79
|
+
// ^2.48.0 allows 2.48.0 - 2.x.x, ^2.93.3 allows 2.93.3 - 2.x.x
|
|
80
|
+
// They overlap because ^2.48.0 includes 2.93.3
|
|
81
|
+
if ((isCaret(installedRange) || !installedRange.match(/^[\^~>=]/)) &&
|
|
82
|
+
(isCaret(requiredRange) || !requiredRange.match(/^[\^~>=]/))) {
|
|
83
|
+
// Same major = ranges can overlap
|
|
84
|
+
if (inst.major === req.major) {
|
|
85
|
+
// The higher minimum must be reachable from the lower range
|
|
86
|
+
// ^2.48.0 (allows up to <3.0.0) can reach 2.93.3 ✓
|
|
87
|
+
// ^3.0.0 cannot reach 2.93.3 ✗
|
|
88
|
+
return true // Same major with caret = always overlapping
|
|
89
|
+
}
|
|
90
|
+
// Different major with exact versions: only if equal
|
|
91
|
+
if (!isCaret(installedRange) && !isCaret(requiredRange)) {
|
|
92
|
+
return inst.major === req.major && inst.minor === req.minor && inst.patch === req.patch
|
|
93
|
+
}
|
|
62
94
|
return false
|
|
63
95
|
}
|
|
64
96
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
// >= range checks
|
|
98
|
+
if (isGte(requiredRange)) {
|
|
99
|
+
// Required >= X.Y.Z: consumer's range must be able to produce a version >= X.Y.Z
|
|
100
|
+
// ^9.0.0 can produce 9.0.0+ which is >= 8.0.0 ✓
|
|
101
|
+
// ^5.3.3 can produce 5.3.3+ which is >= 5.0.0 ✓
|
|
102
|
+
// The max version of consumer's caret range is <(major+1).0.0
|
|
103
|
+
// So: consumer max >= required min
|
|
104
|
+
const consumerMax = isCaret(installedRange) ? { major: inst.major + 1, minor: 0, patch: 0 } : inst
|
|
105
|
+
return versionGte(consumerMax, req)
|
|
106
|
+
}
|
|
107
|
+
if (isGte(installedRange)) {
|
|
108
|
+
// Consumer has >= X, required has ^Y.Z.W — any version >= X could satisfy ^Y if X <= Y
|
|
109
|
+
return true // >= is open-ended, always overlaps with bounded ranges
|
|
76
110
|
}
|
|
77
111
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (inst.major !== req.major
|
|
81
|
-
|
|
112
|
+
// Tilde: ~X.Y.Z allows X.Y.Z - X.Y+1.0
|
|
113
|
+
if (isTilde(installedRange) || isTilde(requiredRange)) {
|
|
114
|
+
if (inst.major !== req.major) return false
|
|
115
|
+
// Tilde ranges on same major.minor overlap
|
|
116
|
+
if (isTilde(installedRange) && isTilde(requiredRange)) {
|
|
117
|
+
return inst.minor === req.minor
|
|
118
|
+
}
|
|
119
|
+
// Tilde + caret: tilde range must include or be included in caret range
|
|
120
|
+
return inst.minor === req.minor || (isCaret(requiredRange) && inst.minor >= req.minor)
|
|
82
121
|
}
|
|
83
122
|
|
|
84
|
-
//
|
|
123
|
+
// Fallback: exact match
|
|
85
124
|
return inst.major === req.major && inst.minor === req.minor && inst.patch === req.patch
|
|
86
125
|
}
|
|
87
126
|
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RLS Live DB Audit — queries pg_policies from the LIVE database
|
|
3
|
+
*
|
|
4
|
+
* The source of truth for RLS policy validation. Migration file parsing can miss:
|
|
5
|
+
* - Policies applied directly via SQL (not in migration files)
|
|
6
|
+
* - PL/pgSQL dynamic policies (EXECUTE format)
|
|
7
|
+
* - Policies overridden by later manual changes
|
|
8
|
+
*
|
|
9
|
+
* Requires SUPABASE_URL + SUPABASE_SERVICE_ROLE_KEY in environment.
|
|
10
|
+
* Connects via @supabase/supabase-js and calls a lightweight RPC.
|
|
11
|
+
*
|
|
12
|
+
* Setup (one-time per project):
|
|
13
|
+
* CREATE OR REPLACE FUNCTION tetra_rls_audit()
|
|
14
|
+
* RETURNS TABLE(tablename text, policyname text, using_clause text, with_check_clause text)
|
|
15
|
+
* LANGUAGE sql SECURITY DEFINER AS $$
|
|
16
|
+
* SELECT tablename::text, policyname::text, qual::text, with_check::text
|
|
17
|
+
* FROM pg_policies WHERE schemaname = 'public';
|
|
18
|
+
* $$;
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const meta = {
|
|
22
|
+
id: 'rls-live-audit',
|
|
23
|
+
name: 'RLS Live DB Audit',
|
|
24
|
+
category: 'security',
|
|
25
|
+
severity: 'critical',
|
|
26
|
+
description: 'Queries pg_policies from the live database and validates all RLS clauses against whitelisted patterns. Catches bypasses that migration parsing misses.'
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BANNED_RLS_PATTERNS = [
|
|
30
|
+
{ pattern: /service_role/i, label: 'service_role bypass — service role already bypasses RLS at the Supabase layer' },
|
|
31
|
+
{ pattern: /auth\.role\s*\(\)\s*=\s*'service_role'/i, label: 'auth.role() service_role bypass' },
|
|
32
|
+
{ pattern: /current_setting\s*\(\s*'role'/i, label: 'PostgreSQL role check — bypasses tenant isolation' },
|
|
33
|
+
{ pattern: /current_setting\s*\(\s*'request\.jwt\.claims'/i, label: 'JWT claims role check — bypasses tenant isolation' },
|
|
34
|
+
{ pattern: /session_user/i, label: 'session_user check — bypasses tenant isolation' },
|
|
35
|
+
{ pattern: /current_user\s*=/i, label: 'current_user check — bypasses tenant isolation' },
|
|
36
|
+
{ pattern: /pg_has_role/i, label: 'pg_has_role — bypasses tenant isolation' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const ALLOWED_RLS_PATTERNS = [
|
|
40
|
+
{ pattern: /auth_admin_organizations\s*\(\)/i },
|
|
41
|
+
{ pattern: /auth_user_organizations\s*\(\)/i },
|
|
42
|
+
{ pattern: /auth_org_id\s*\(\)/i },
|
|
43
|
+
{ pattern: /auth\.uid\s*\(\)/i },
|
|
44
|
+
{ pattern: /auth_current_user_id\s*\(\)/i },
|
|
45
|
+
{ pattern: /auth\.role\s*\(\)\s*=\s*'authenticated'/i },
|
|
46
|
+
{ pattern: /auth\.role\s*\(\)\s*=\s*'anon'/i },
|
|
47
|
+
{ pattern: /\w+\s*=\s*/i },
|
|
48
|
+
{ pattern: /\w+\s+IS\s+(NOT\s+)?NULL/i },
|
|
49
|
+
{ pattern: /\w+\s+IN\s*\(/i },
|
|
50
|
+
{ pattern: /\w+\s*=\s*ANY\s*\(/i },
|
|
51
|
+
{ pattern: /EXISTS\s*\(\s*SELECT/i },
|
|
52
|
+
{ pattern: /^\s*\(?true\)?\s*$/i },
|
|
53
|
+
{ pattern: /^\s*\(?false\)?\s*$/i },
|
|
54
|
+
{ pattern: /auth\.jwt\s*\(\)/i },
|
|
55
|
+
{ pattern: /current_setting\s*\(\s*'app\./i },
|
|
56
|
+
{ pattern: /\b\w+\s*\([^)]*\)/i },
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
function validateRlsClause(clause) {
|
|
60
|
+
if (!clause || !clause.trim()) return null
|
|
61
|
+
|
|
62
|
+
for (const { pattern, label } of BANNED_RLS_PATTERNS) {
|
|
63
|
+
if (pattern.test(clause)) return label
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!ALLOWED_RLS_PATTERNS.some(({ pattern }) => pattern.test(clause))) {
|
|
67
|
+
return `Unrecognized RLS clause: "${clause.substring(0, 150)}". Only whitelisted patterns allowed.`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function run(config, projectRoot) {
|
|
74
|
+
const results = {
|
|
75
|
+
passed: true,
|
|
76
|
+
skipped: false,
|
|
77
|
+
findings: [],
|
|
78
|
+
summary: { total: 0, critical: 0, high: 0, medium: 0, low: 0 },
|
|
79
|
+
details: { policiesChecked: 0, tablesChecked: 0, violations: 0 }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const supabaseUrl = process.env.SUPABASE_URL
|
|
83
|
+
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY
|
|
84
|
+
|
|
85
|
+
if (!supabaseUrl || !supabaseKey) {
|
|
86
|
+
results.skipped = true
|
|
87
|
+
results.skipReason = 'No SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY in environment'
|
|
88
|
+
return results
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Try to call tetra_rls_audit() RPC
|
|
92
|
+
let policies
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(`${supabaseUrl}/rest/v1/rpc/tetra_rls_audit`, {
|
|
95
|
+
method: 'POST',
|
|
96
|
+
headers: {
|
|
97
|
+
'apikey': supabaseKey,
|
|
98
|
+
'Authorization': `Bearer ${supabaseKey}`,
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
|
+
},
|
|
101
|
+
body: '{}',
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (!response.ok) {
|
|
105
|
+
const status = response.status
|
|
106
|
+
if (status === 404) {
|
|
107
|
+
results.skipped = true
|
|
108
|
+
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, using_clause text, with_check_clause text)\n LANGUAGE sql SECURITY DEFINER AS $$\n SELECT tablename::text, policyname::text, qual::text, with_check::text\n FROM pg_policies WHERE schemaname = \'public\';\n $$;'
|
|
109
|
+
} else {
|
|
110
|
+
results.skipped = true
|
|
111
|
+
results.skipReason = `RPC tetra_rls_audit() failed with status ${status}`
|
|
112
|
+
}
|
|
113
|
+
return results
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
policies = await response.json()
|
|
117
|
+
} catch (err) {
|
|
118
|
+
results.skipped = true
|
|
119
|
+
results.skipReason = `Failed to query live DB: ${err.message}`
|
|
120
|
+
return results
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!Array.isArray(policies) || policies.length === 0) {
|
|
124
|
+
results.skipped = true
|
|
125
|
+
results.skipReason = 'No policies returned from tetra_rls_audit()'
|
|
126
|
+
return results
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const backendOnlyTables = config.supabase?.backendOnlyTables || []
|
|
130
|
+
const tablesChecked = new Set()
|
|
131
|
+
|
|
132
|
+
for (const policy of policies) {
|
|
133
|
+
if (!policy) continue
|
|
134
|
+
const { tablename, policyname, using_clause, with_check_clause } = policy
|
|
135
|
+
|
|
136
|
+
if (backendOnlyTables.includes(tablename)) continue
|
|
137
|
+
if (tablename?.startsWith('_') || tablename?.startsWith('pg_')) continue
|
|
138
|
+
|
|
139
|
+
tablesChecked.add(tablename)
|
|
140
|
+
results.details.policiesChecked++
|
|
141
|
+
|
|
142
|
+
for (const [clauseType, clause] of [['USING', using_clause], ['WITH CHECK', with_check_clause]]) {
|
|
143
|
+
if (!clause) continue
|
|
144
|
+
const violation = validateRlsClause(clause)
|
|
145
|
+
if (violation) {
|
|
146
|
+
results.passed = false
|
|
147
|
+
results.details.violations++
|
|
148
|
+
results.findings.push({
|
|
149
|
+
file: `LIVE DB → ${tablename}`,
|
|
150
|
+
line: 1,
|
|
151
|
+
type: 'rls-live-bypass',
|
|
152
|
+
severity: 'critical',
|
|
153
|
+
message: `[LIVE] Policy "${policyname}" on "${tablename}": ${violation}`,
|
|
154
|
+
fix: 'Fix the policy in the database and add a corrective migration.'
|
|
155
|
+
})
|
|
156
|
+
results.summary.critical++
|
|
157
|
+
results.summary.total++
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
results.details.tablesChecked = tablesChecked.size
|
|
163
|
+
return results
|
|
164
|
+
}
|
package/lib/runner.js
CHANGED
|
@@ -26,6 +26,7 @@ import * as namingConventions from './checks/codeQuality/naming-conventions.js'
|
|
|
26
26
|
import * as routeSeparation from './checks/codeQuality/route-separation.js'
|
|
27
27
|
import * as gitignoreValidation from './checks/security/gitignore-validation.js'
|
|
28
28
|
import * as routeConfigAlignment from './checks/security/route-config-alignment.js'
|
|
29
|
+
import * as rlsLiveAudit from './checks/security/rls-live-audit.js'
|
|
29
30
|
import * as rlsPolicyAudit from './checks/supabase/rls-policy-audit.js'
|
|
30
31
|
import * as rpcParamMismatch from './checks/supabase/rpc-param-mismatch.js'
|
|
31
32
|
import * as rpcGeneratorOrigin from './checks/supabase/rpc-generator-origin.js'
|
|
@@ -96,7 +97,8 @@ const ALL_CHECKS = {
|
|
|
96
97
|
rpcSecurityMode,
|
|
97
98
|
systemdbWhitelist,
|
|
98
99
|
gitignoreValidation,
|
|
99
|
-
routeConfigAlignment
|
|
100
|
+
routeConfigAlignment,
|
|
101
|
+
rlsLiveAudit
|
|
100
102
|
],
|
|
101
103
|
stability: [
|
|
102
104
|
huskyHooks,
|