cleartoship 0.4.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.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/action.yml +154 -0
  4. package/dist/banner.d.ts +1 -0
  5. package/dist/banner.js +14 -0
  6. package/dist/cli.d.ts +2 -0
  7. package/dist/cli.js +103 -0
  8. package/dist/data/popular.d.ts +17 -0
  9. package/dist/data/popular.js +103 -0
  10. package/dist/index.d.ts +5 -0
  11. package/dist/index.js +4 -0
  12. package/dist/report.d.ts +15 -0
  13. package/dist/report.js +317 -0
  14. package/dist/scan.d.ts +24 -0
  15. package/dist/scan.js +81 -0
  16. package/dist/scanners/community.d.ts +2 -0
  17. package/dist/scanners/community.js +155 -0
  18. package/dist/scanners/dependencies.d.ts +21 -0
  19. package/dist/scanners/dependencies.js +549 -0
  20. package/dist/scanners/index.d.ts +8 -0
  21. package/dist/scanners/index.js +13 -0
  22. package/dist/scanners/rls.d.ts +7 -0
  23. package/dist/scanners/rls.js +531 -0
  24. package/dist/scanners/secrets.d.ts +2 -0
  25. package/dist/scanners/secrets.js +439 -0
  26. package/dist/scanners/server-actions.d.ts +2 -0
  27. package/dist/scanners/server-actions.js +466 -0
  28. package/dist/types.d.ts +65 -0
  29. package/dist/types.js +10 -0
  30. package/dist/utils/ast.d.ts +13 -0
  31. package/dist/utils/ast.js +107 -0
  32. package/dist/utils/detect.d.ts +2 -0
  33. package/dist/utils/detect.js +77 -0
  34. package/dist/utils/entropy.d.ts +7 -0
  35. package/dist/utils/entropy.js +19 -0
  36. package/dist/utils/files.d.ts +13 -0
  37. package/dist/utils/files.js +154 -0
  38. package/dist/utils/osv.d.ts +26 -0
  39. package/dist/utils/osv.js +123 -0
  40. package/dist/utils/registry.d.ts +44 -0
  41. package/dist/utils/registry.js +195 -0
  42. package/dist/utils/sql.d.ts +20 -0
  43. package/dist/utils/sql.js +157 -0
  44. package/dist/utils/suppress.d.ts +19 -0
  45. package/dist/utils/suppress.js +56 -0
  46. package/dist/utils/traverse.d.ts +2 -0
  47. package/dist/utils/traverse.js +14 -0
  48. package/dist/vendor/gitleaks/rules.d.ts +13 -0
  49. package/dist/vendor/gitleaks/rules.js +1571 -0
  50. package/dist/vendor/guardvibe/index.d.ts +11 -0
  51. package/dist/vendor/guardvibe/index.js +60 -0
  52. package/dist/vendor/guardvibe/rules/advanced-security.d.ts +2 -0
  53. package/dist/vendor/guardvibe/rules/advanced-security.js +418 -0
  54. package/dist/vendor/guardvibe/rules/ai-host-security.d.ts +2 -0
  55. package/dist/vendor/guardvibe/rules/ai-host-security.js +190 -0
  56. package/dist/vendor/guardvibe/rules/ai-security.d.ts +2 -0
  57. package/dist/vendor/guardvibe/rules/ai-security.js +413 -0
  58. package/dist/vendor/guardvibe/rules/ai-tool-runtime.d.ts +2 -0
  59. package/dist/vendor/guardvibe/rules/ai-tool-runtime.js +194 -0
  60. package/dist/vendor/guardvibe/rules/api-security.d.ts +2 -0
  61. package/dist/vendor/guardvibe/rules/api-security.js +154 -0
  62. package/dist/vendor/guardvibe/rules/auth.d.ts +2 -0
  63. package/dist/vendor/guardvibe/rules/auth.js +222 -0
  64. package/dist/vendor/guardvibe/rules/cicd.d.ts +2 -0
  65. package/dist/vendor/guardvibe/rules/cicd.js +108 -0
  66. package/dist/vendor/guardvibe/rules/core.d.ts +2 -0
  67. package/dist/vendor/guardvibe/rules/core.js +484 -0
  68. package/dist/vendor/guardvibe/rules/cve-versions.d.ts +2 -0
  69. package/dist/vendor/guardvibe/rules/cve-versions.js +1130 -0
  70. package/dist/vendor/guardvibe/rules/database.d.ts +2 -0
  71. package/dist/vendor/guardvibe/rules/database.js +173 -0
  72. package/dist/vendor/guardvibe/rules/deployment.d.ts +2 -0
  73. package/dist/vendor/guardvibe/rules/deployment.js +269 -0
  74. package/dist/vendor/guardvibe/rules/dockerfile.d.ts +2 -0
  75. package/dist/vendor/guardvibe/rules/dockerfile.js +99 -0
  76. package/dist/vendor/guardvibe/rules/firebase.d.ts +2 -0
  77. package/dist/vendor/guardvibe/rules/firebase.js +96 -0
  78. package/dist/vendor/guardvibe/rules/go.d.ts +2 -0
  79. package/dist/vendor/guardvibe/rules/go.js +85 -0
  80. package/dist/vendor/guardvibe/rules/modern-stack.d.ts +2 -0
  81. package/dist/vendor/guardvibe/rules/modern-stack.js +631 -0
  82. package/dist/vendor/guardvibe/rules/nextjs.d.ts +2 -0
  83. package/dist/vendor/guardvibe/rules/nextjs.js +233 -0
  84. package/dist/vendor/guardvibe/rules/other-services.d.ts +2 -0
  85. package/dist/vendor/guardvibe/rules/other-services.js +72 -0
  86. package/dist/vendor/guardvibe/rules/payments.d.ts +2 -0
  87. package/dist/vendor/guardvibe/rules/payments.js +123 -0
  88. package/dist/vendor/guardvibe/rules/react-native.d.ts +2 -0
  89. package/dist/vendor/guardvibe/rules/react-native.js +132 -0
  90. package/dist/vendor/guardvibe/rules/services.d.ts +2 -0
  91. package/dist/vendor/guardvibe/rules/services.js +149 -0
  92. package/dist/vendor/guardvibe/rules/shell.d.ts +2 -0
  93. package/dist/vendor/guardvibe/rules/shell.js +72 -0
  94. package/dist/vendor/guardvibe/rules/sql.d.ts +2 -0
  95. package/dist/vendor/guardvibe/rules/sql.js +60 -0
  96. package/dist/vendor/guardvibe/rules/supply-chain.d.ts +2 -0
  97. package/dist/vendor/guardvibe/rules/supply-chain.js +243 -0
  98. package/dist/vendor/guardvibe/rules/terraform.d.ts +2 -0
  99. package/dist/vendor/guardvibe/rules/terraform.js +84 -0
  100. package/dist/vendor/guardvibe/rules/types.d.ts +14 -0
  101. package/dist/vendor/guardvibe/rules/types.js +11 -0
  102. package/dist/vendor/guardvibe/rules/web-security.d.ts +2 -0
  103. package/dist/vendor/guardvibe/rules/web-security.js +258 -0
  104. package/examples/security.yml +23 -0
  105. package/package.json +70 -0
@@ -0,0 +1,531 @@
1
+ import { read, rel, isSql, snippetAt } from '../utils/files.js';
2
+ import { splitStatements, clauseAfter, normaliseTable, isAlwaysTrue, QUALIFIED_NAME, } from '../utils/sql.js';
3
+ import { Suppressions } from '../utils/suppress.js';
4
+ import { emptyResult } from '../types.js';
5
+ const OWNER_COLUMNS = [
6
+ 'user_id', 'owner_id', 'tenant_id', 'org_id', 'organization_id', 'account_id',
7
+ 'profile_id', 'created_by', 'author_id', 'auth_id', 'uid', 'customer_id',
8
+ 'workspace_id', 'team_id', 'member_id',
9
+ ];
10
+ const SENSITIVE_COLUMNS = [
11
+ 'email', 'phone', 'address', 'ssn', 'social_security', 'password', 'password_hash',
12
+ 'token', 'access_token', 'refresh_token', 'secret', 'api_key', 'private_key',
13
+ 'stripe_customer', 'stripe_account', 'card', 'iban', 'account_number', 'salary',
14
+ 'balance', 'date_of_birth', 'dob', 'birth_date', 'passport', 'license_number',
15
+ 'ip_address', 'full_name', 'first_name', 'last_name',
16
+ ];
17
+ /** Roles reachable with nothing but the public anon key. */
18
+ const PUBLIC_ROLES = new Set(['anon', 'public', 'authenticated']);
19
+ const UNAUTHENTICATED_ROLES = new Set(['anon', 'public']);
20
+ function parseColumns(body) {
21
+ const cols = [];
22
+ let depth = 0;
23
+ let current = '';
24
+ let inString = null;
25
+ for (const ch of body) {
26
+ if (inString) {
27
+ current += ch;
28
+ if (ch === inString)
29
+ inString = null;
30
+ continue;
31
+ }
32
+ if (ch === "'" || ch === '"') {
33
+ inString = ch;
34
+ current += ch;
35
+ continue;
36
+ }
37
+ if (ch === '(')
38
+ depth++;
39
+ if (ch === ')')
40
+ depth--;
41
+ if (ch === ',' && depth === 0) {
42
+ cols.push(current);
43
+ current = '';
44
+ continue;
45
+ }
46
+ current += ch;
47
+ }
48
+ cols.push(current);
49
+ return cols
50
+ .map((c) => c.trim())
51
+ .filter(Boolean)
52
+ .map((c) => /^("[^"]+"|[A-Za-z_][A-Za-z0-9_$]*)/.exec(c)?.[1] ?? '')
53
+ .map((c) => c.replace(/^"(.*)"$/, '$1').toLowerCase())
54
+ .filter((c) => c && !['constraint', 'primary', 'foreign', 'unique', 'check', 'exclude', 'like'].includes(c));
55
+ }
56
+ export const rlsScanner = {
57
+ name: 'Supabase / PostgreSQL Row Level Security',
58
+ applies(ctx) {
59
+ return ctx.files.some(isSql);
60
+ },
61
+ async run(ctx) {
62
+ const result = emptyResult();
63
+ const tables = new Map();
64
+ const policies = [];
65
+ const definerFunctions = new Set();
66
+ const publicBuckets = [];
67
+ const findings = [];
68
+ const suppressors = new Map();
69
+ const sources = new Map();
70
+ const sqlFiles = ctx.files.filter(isSql).sort();
71
+ // Pass 1: build a model of the schema by replaying every migration in order.
72
+ for (const file of sqlFiles) {
73
+ const source = read(file);
74
+ if (source === null)
75
+ continue;
76
+ const relPath = rel(ctx.root, file);
77
+ sources.set(relPath, source);
78
+ suppressors.set(relPath, new Suppressions(source));
79
+ for (const stmt of splitStatements(source)) {
80
+ const text = stmt.text;
81
+ const flat = text.replace(/\s+/g, ' ');
82
+ const create = new RegExp(`^create\\s+(?:unlogged\\s+|temp(?:orary)?\\s+)?table\\s+(?:if\\s+not\\s+exists\\s+)?(${QUALIFIED_NAME})`, 'i').exec(flat);
83
+ if (create) {
84
+ const name = normaliseTable(create[1]);
85
+ const open = text.indexOf('(', create[0].length - create[1].length);
86
+ const body = open === -1 ? '' : text.slice(open + 1, text.lastIndexOf(')'));
87
+ tables.set(name, {
88
+ name,
89
+ columns: parseColumns(body),
90
+ rlsEnabled: false,
91
+ file: relPath,
92
+ line: stmt.line,
93
+ createdInPublic: name.startsWith('public.'),
94
+ });
95
+ continue;
96
+ }
97
+ const alter = new RegExp(`^alter\\s+table\\s+(?:only\\s+)?(?:if\\s+exists\\s+)?(${QUALIFIED_NAME})\\s+(enable|disable|force|no\\s+force)\\s+row\\s+level\\s+security`, 'i').exec(flat);
98
+ if (alter) {
99
+ const name = normaliseTable(alter[1]);
100
+ const verb = alter[2].toLowerCase();
101
+ const t = tables.get(name);
102
+ if (t)
103
+ t.rlsEnabled = verb === 'enable' || verb === 'force';
104
+ else {
105
+ tables.set(name, {
106
+ name, columns: [], rlsEnabled: verb === 'enable' || verb === 'force',
107
+ file: relPath, line: stmt.line, createdInPublic: name.startsWith('public.'),
108
+ });
109
+ }
110
+ continue;
111
+ }
112
+ const drop = new RegExp(`^drop\\s+table\\s+(?:if\\s+exists\\s+)?(${QUALIFIED_NAME})`, 'i').exec(flat);
113
+ if (drop) {
114
+ tables.delete(normaliseTable(drop[1]));
115
+ continue;
116
+ }
117
+ const policy = new RegExp(`^create\\s+policy\\s+(${QUALIFIED_NAME}|"[^"]+")\\s+on\\s+(${QUALIFIED_NAME})`, 'i').exec(flat);
118
+ if (policy) {
119
+ const cmd = /\bfor\s+(all|select|insert|update|delete)\b/i.exec(flat)?.[1]?.toUpperCase() ?? 'ALL';
120
+ const toClause = /\bto\s+([a-z_",\s]+?)(?:\s+using\b|\s+with\s+check\b|$)/i.exec(flat)?.[1];
121
+ const roles = toClause
122
+ ? toClause.split(',').map((r) => r.trim().replace(/^"(.*)"$/, '$1').toLowerCase()).filter(Boolean)
123
+ : ['public'];
124
+ policies.push({
125
+ name: policy[1].replace(/^"(.*)"$/, '$1'),
126
+ table: normaliseTable(policy[2]),
127
+ command: cmd,
128
+ roles,
129
+ permissive: !/\bas\s+restrictive\b/i.test(flat),
130
+ using: clauseAfter(text, /\busing\s*(?=\()/i),
131
+ withCheck: clauseAfter(text, /\bwith\s+check\s*(?=\()/i),
132
+ file: relPath,
133
+ line: stmt.line,
134
+ });
135
+ continue;
136
+ }
137
+ const dropPolicy = new RegExp(`^drop\\s+policy\\s+(?:if\\s+exists\\s+)?(${QUALIFIED_NAME}|"[^"]+")\\s+on\\s+(${QUALIFIED_NAME})`, 'i').exec(flat);
138
+ if (dropPolicy) {
139
+ const pname = dropPolicy[1].replace(/^"(.*)"$/, '$1');
140
+ const ptable = normaliseTable(dropPolicy[2]);
141
+ for (let k = policies.length - 1; k >= 0; k--) {
142
+ if (policies[k].name === pname && policies[k].table === ptable)
143
+ policies.splice(k, 1);
144
+ }
145
+ continue;
146
+ }
147
+ // GRANT write privileges directly to the anonymous role.
148
+ const grant = /^grant\s+(.+?)\s+on\s+(.+?)\s+to\s+([a-z_",\s]+)/i.exec(flat);
149
+ if (grant) {
150
+ const privs = grant[1].toLowerCase();
151
+ const grantees = grant[3].split(',').map((r) => r.trim().replace(/^"(.*)"$/, '$1').toLowerCase());
152
+ const writes = /\ball\b|\binsert\b|\bupdate\b|\bdelete\b|\btruncate\b/.test(privs);
153
+ const target = grant[2].toLowerCase();
154
+ if (target.includes('function') && /\bexecute\b|\ball\b/.test(privs)) {
155
+ const fnName = new RegExp(`function\\s+(${QUALIFIED_NAME})`, 'i').exec(grant[2])?.[1];
156
+ const normalised = fnName ? normaliseTable(fnName) : null;
157
+ if (normalised && definerFunctions.has(normalised) && grantees.some((g) => UNAUTHENTICATED_ROLES.has(g))) {
158
+ findings.push({
159
+ id: 'CTS052',
160
+ severity: 'high',
161
+ title: 'SECURITY DEFINER function is callable without signing in',
162
+ detail: `\`${normalised}\` runs with its owner's privileges and EXECUTE is granted to ` +
163
+ `${grantees.join(', ')}. Anyone holding the public anon key can call it, and whatever ` +
164
+ 'it does happens with the definer’s rights rather than theirs — Row Level Security ' +
165
+ 'included.',
166
+ fix: `REVOKE EXECUTE ON FUNCTION ${normalised} FROM anon, public;\n` +
167
+ 'Grant it to `authenticated` only, and check the caller inside the function body.',
168
+ file: relPath,
169
+ line: stmt.line,
170
+ cwe: 'CWE-269: Improper Privilege Management',
171
+ owasp: 'A01:2025 - Broken Access Control',
172
+ meta: { function: normalised, grantees },
173
+ });
174
+ }
175
+ continue;
176
+ }
177
+ if (writes && grantees.some((g) => UNAUTHENTICATED_ROLES.has(g)) && !target.includes('sequence')) {
178
+ findings.push({
179
+ id: 'CTS017',
180
+ severity: 'critical',
181
+ title: 'Write privileges granted to the anonymous role',
182
+ detail: `\`GRANT ${grant[1].trim()} ON ${grant[2].trim()} TO ${grant[3].trim()}\` hands write access ` +
183
+ 'to unauthenticated callers. Anyone holding the public anon key can invoke it.',
184
+ fix: 'Revoke the grant and give the privilege to `authenticated` only, then let a Row Level ' +
185
+ 'Security policy decide which rows that role may touch.',
186
+ file: relPath,
187
+ line: stmt.line,
188
+ cwe: 'CWE-732: Incorrect Permission Assignment for Critical Resource',
189
+ owasp: 'A01:2025 - Broken Access Control',
190
+ });
191
+ }
192
+ continue;
193
+ }
194
+ // Supabase Storage: a bucket marked public serves every object in it
195
+ // to unauthenticated callers over a predictable URL.
196
+ if (/^insert\s+into\s+storage\s*\.\s*buckets\b/i.test(flat) && /\btrue\b/i.test(flat)) {
197
+ const id = /values\s*\(\s*'([^']+)'/i.exec(flat)?.[1] ?? 'unknown';
198
+ publicBuckets.push({ id, file: relPath, line: stmt.line });
199
+ continue;
200
+ }
201
+ // SECURITY DEFINER functions without a pinned search_path.
202
+ if (/^create\s+(or\s+replace\s+)?function/i.test(flat) && /security\s+definer/i.test(flat)) {
203
+ const declared = new RegExp(`^create\\s+(?:or\\s+replace\\s+)?function\\s+(${QUALIFIED_NAME})`, 'i').exec(flat)?.[1];
204
+ if (declared)
205
+ definerFunctions.add(normaliseTable(declared));
206
+ if (!/set\s+search_path/i.test(flat)) {
207
+ const fname = declared ?? 'function';
208
+ findings.push({
209
+ id: 'CTS015',
210
+ severity: 'medium',
211
+ title: 'SECURITY DEFINER function without a pinned search_path',
212
+ detail: `\`${fname}\` runs with the definer's privileges but inherits the caller's \`search_path\`. ` +
213
+ 'A caller who can create objects in a schema earlier on that path can shadow a table or ' +
214
+ 'operator the function uses and have their own code run as the definer.',
215
+ fix: 'Add `SET search_path = \'\'` (or an explicit schema list) to the function definition.',
216
+ file: relPath,
217
+ line: stmt.line,
218
+ cwe: 'CWE-426: Untrusted Search Path',
219
+ owasp: 'A01:2025 - Broken Access Control',
220
+ });
221
+ }
222
+ continue;
223
+ }
224
+ // Views bypass the RLS of their base tables unless security_invoker is set.
225
+ const view = new RegExp(`^create\\s+(?:or\\s+replace\\s+)?(materialized\\s+)?view\\s+(?:if\\s+not\\s+exists\\s+)?(${QUALIFIED_NAME})`, 'i').exec(flat);
226
+ if (view) {
227
+ const materialized = Boolean(view[1]);
228
+ const name = normaliseTable(view[2]);
229
+ // A view over auth.users republishes every account's email and, on
230
+ // older projects, the encrypted password, through the REST API.
231
+ if (name.startsWith('public.') && /\bauth\s*\.\s*users\b/i.test(flat)) {
232
+ findings.push({
233
+ id: 'CTS019',
234
+ severity: 'critical',
235
+ title: 'auth.users is republished through the public schema',
236
+ detail: `${materialized ? 'Materialized view' : 'View'} \`${name}\` selects from \`auth.users\` ` +
237
+ 'and lives in the schema PostgREST exposes. Supabase keeps that table out of the API ' +
238
+ 'precisely because it holds every user’s email address, phone number and auth metadata; ' +
239
+ 'a view over it hands all of that back to the API.',
240
+ fix: `Drop the view, or move it to a private schema and expose only the columns you need ` +
241
+ 'through a `security_invoker` view over your own `profiles` table.',
242
+ file: relPath,
243
+ line: stmt.line,
244
+ cwe: 'CWE-200: Exposure of Sensitive Information to an Unauthorized Actor',
245
+ owasp: 'A01:2025 - Broken Access Control',
246
+ meta: { view: name, materialized },
247
+ });
248
+ continue;
249
+ }
250
+ // Materialized views never consult the RLS of their base tables.
251
+ if (name.startsWith('public.') && materialized) {
252
+ findings.push({
253
+ id: 'CTS016',
254
+ severity: 'high',
255
+ title: 'Materialized view is exposed over the Data API',
256
+ detail: `Materialized view \`${name}\` is in the public schema. Materialized views hold their ` +
257
+ 'own copy of the data and never evaluate the Row Level Security policies of the tables ' +
258
+ 'they were built from, so every row in the snapshot is readable by anyone who can reach ' +
259
+ 'the API.',
260
+ fix: 'Move the materialized view into a private schema and expose a filtered, ' +
261
+ '`security_invoker` view over it, or revoke SELECT from `anon` and `authenticated`.',
262
+ file: relPath,
263
+ line: stmt.line,
264
+ cwe: 'CWE-863: Incorrect Authorization',
265
+ owasp: 'A01:2025 - Broken Access Control',
266
+ meta: { view: name, materialized: true },
267
+ });
268
+ continue;
269
+ }
270
+ if (name.startsWith('public.') && !/security_invoker\s*=\s*(on|true)/i.test(flat)) {
271
+ findings.push({
272
+ id: 'CTS016',
273
+ severity: 'medium',
274
+ title: 'API-exposed view runs with definer rights',
275
+ detail: `View \`${name}\` is in the public schema, so PostgREST exposes it over the REST API. ` +
276
+ 'Without `security_invoker`, it queries its base tables as the view owner and the ' +
277
+ 'caller-side RLS policies on those tables are not applied.',
278
+ fix: "Recreate the view with `WITH (security_invoker = on)`, or move it out of the `public` " +
279
+ 'schema so it is not exposed through the API.',
280
+ file: relPath,
281
+ line: stmt.line,
282
+ cwe: 'CWE-863: Incorrect Authorization',
283
+ owasp: 'A01:2025 - Broken Access Control',
284
+ });
285
+ }
286
+ continue;
287
+ }
288
+ }
289
+ }
290
+ // Pass 2: judge the resulting schema.
291
+ const policiesByTable = new Map();
292
+ for (const p of policies) {
293
+ const list = policiesByTable.get(p.table) ?? [];
294
+ list.push(p);
295
+ policiesByTable.set(p.table, list);
296
+ }
297
+ for (const table of tables.values()) {
298
+ if (!table.createdInPublic)
299
+ continue;
300
+ const tablePolicies = policiesByTable.get(table.name) ?? [];
301
+ if (!table.rlsEnabled) {
302
+ const hasPolicies = tablePolicies.length > 0;
303
+ findings.push({
304
+ id: 'CTS010',
305
+ severity: 'critical',
306
+ title: 'Public table with Row Level Security disabled',
307
+ detail: `Table \`${table.name}\` is in the schema PostgREST exposes, and RLS was never enabled on it. ` +
308
+ 'Every row is readable — and writable — by anyone holding the anon key, which ships in your ' +
309
+ 'client bundle.' +
310
+ (hasPolicies
311
+ ? ` ${tablePolicies.length} polic${tablePolicies.length === 1 ? 'y is' : 'ies are'} defined on this table but they are inert until RLS is on.`
312
+ : ''),
313
+ fix: `ALTER TABLE "${table.name.split('.')[1]}" ENABLE ROW LEVEL SECURITY;\n` +
314
+ (hasPolicies ? 'The existing policies then take effect.' : 'Then add a policy scoping rows to `auth.uid()`.'),
315
+ file: table.file,
316
+ line: table.line,
317
+ cwe: 'CWE-1220: Insufficient Granularity of Access Control',
318
+ owasp: 'A01:2025 - Broken Access Control',
319
+ meta: { table: table.name, policyCount: tablePolicies.length },
320
+ });
321
+ continue;
322
+ }
323
+ if (tablePolicies.length === 0) {
324
+ findings.push({
325
+ id: 'CTS011',
326
+ severity: 'low',
327
+ title: 'RLS enabled but no policy defined',
328
+ detail: `Table \`${table.name}\` has RLS on and no policies, so PostgreSQL denies every row to every ` +
329
+ 'non-superuser role. This is fail-closed and therefore safe, but it usually means a feature ' +
330
+ 'silently returns empty results.',
331
+ fix: 'Add the policies this table needs, or confirm it is only ever reached via a service-role client.',
332
+ file: table.file,
333
+ line: table.line,
334
+ owasp: 'A01:2025 - Broken Access Control',
335
+ meta: { table: table.name },
336
+ });
337
+ continue;
338
+ }
339
+ const ownerColumn = table.columns.find((c) => OWNER_COLUMNS.includes(c));
340
+ const sensitive = table.columns.filter((c) => SENSITIVE_COLUMNS.some((s) => c === s || c.includes(s)));
341
+ const referencesAuth = tablePolicies.some((p) => /auth\.uid\(\)|auth\.jwt\(\)|current_setting\s*\(|auth\.role\(\)/i.test(`${p.using ?? ''} ${p.withCheck ?? ''}`));
342
+ if (ownerColumn && !referencesAuth) {
343
+ findings.push({
344
+ id: 'CTS014',
345
+ severity: 'high',
346
+ title: 'Per-user table with no tenant isolation in its policies',
347
+ detail: `Table \`${table.name}\` has an ownership column (\`${ownerColumn}\`), but none of the ` +
348
+ `${tablePolicies.length} polic${tablePolicies.length === 1 ? 'y' : 'ies'} defined on it compare ` +
349
+ 'that column against `auth.uid()`. Every authenticated user therefore sees every other ' +
350
+ 'user’s rows.',
351
+ fix: `CREATE POLICY "own rows" ON "${table.name.split('.')[1]}"\n` +
352
+ ` FOR ALL TO authenticated\n` +
353
+ ` USING (${ownerColumn} = (SELECT auth.uid()))\n` +
354
+ ` WITH CHECK (${ownerColumn} = (SELECT auth.uid()));`,
355
+ file: tablePolicies[0].file,
356
+ line: tablePolicies[0].line,
357
+ cwe: 'CWE-639: Authorization Bypass Through User-Controlled Key',
358
+ owasp: 'A01:2025 - Broken Access Control',
359
+ meta: { table: table.name, ownerColumn },
360
+ });
361
+ }
362
+ for (const p of tablePolicies) {
363
+ const publicFacing = p.roles.some((r) => PUBLIC_ROLES.has(r));
364
+ if (!publicFacing)
365
+ continue;
366
+ const anonFacing = p.roles.some((r) => UNAUTHENTICATED_ROLES.has(r));
367
+ const isWrite = p.command !== 'SELECT';
368
+ const permissive = isAlwaysTrue(p.using) || isAlwaysTrue(p.withCheck);
369
+ if (permissive && isWrite) {
370
+ findings.push({
371
+ id: 'CTS012',
372
+ severity: 'critical',
373
+ title: 'Policy allows unrestricted writes',
374
+ detail: `Policy \`${p.name}\` on \`${p.table}\` grants \`${p.command}\` to ` +
375
+ `${p.roles.join(', ')} with an always-true predicate. ` +
376
+ (anonFacing
377
+ ? 'Anyone with the public anon key can insert, overwrite or delete arbitrary rows.'
378
+ : 'Any signed-up user can overwrite or delete every other user’s rows.'),
379
+ fix: 'Replace `USING (true)` / `WITH CHECK (true)` with a predicate that ties the row to the ' +
380
+ 'caller, e.g. `user_id = (SELECT auth.uid())`.',
381
+ file: p.file,
382
+ line: p.line,
383
+ cwe: 'CWE-863: Incorrect Authorization',
384
+ owasp: 'A01:2025 - Broken Access Control',
385
+ meta: { table: p.table, policy: p.name, command: p.command, roles: p.roles },
386
+ });
387
+ }
388
+ else if (permissive && anonFacing && sensitive.length > 0) {
389
+ findings.push({
390
+ id: 'CTS013',
391
+ severity: 'high',
392
+ title: 'Policy exposes sensitive columns to anonymous readers',
393
+ detail: `Policy \`${p.name}\` grants unauthenticated SELECT over \`${p.table}\`, which holds ` +
394
+ `${sensitive.slice(0, 4).map((c) => `\`${c}\``).join(', ')}` +
395
+ `${sensitive.length > 4 ? ` and ${sensitive.length - 4} more sensitive column(s)` : ''}. ` +
396
+ 'The whole table is downloadable with the anon key.',
397
+ fix: 'Restrict the policy to `authenticated` and scope it to the caller, or expose only the ' +
398
+ 'non-sensitive columns through a `security_invoker` view.',
399
+ file: p.file,
400
+ line: p.line,
401
+ cwe: 'CWE-200: Exposure of Sensitive Information',
402
+ owasp: 'A01:2025 - Broken Access Control',
403
+ meta: { table: p.table, policy: p.name, columns: sensitive },
404
+ });
405
+ }
406
+ if (/user_metadata/i.test(`${p.using ?? ''} ${p.withCheck ?? ''}`)) {
407
+ findings.push({
408
+ id: 'CTS018',
409
+ severity: 'critical',
410
+ title: 'Policy trusts user-editable JWT metadata',
411
+ detail: `Policy \`${p.name}\` on \`${p.table}\` reads \`user_metadata\` from the JWT. That claim is ` +
412
+ 'writable by the user themselves through the auth API, so anyone can set the field the ' +
413
+ 'policy checks and grant themselves access.',
414
+ fix: 'Move the attribute into `app_metadata` (server-writable only) or into a table the user ' +
415
+ 'cannot update, and have the policy read it from there.',
416
+ file: p.file,
417
+ line: p.line,
418
+ cwe: 'CWE-807: Reliance on Untrusted Inputs in a Security Decision',
419
+ owasp: 'A01:2025 - Broken Access Control',
420
+ meta: { table: p.table, policy: p.name },
421
+ });
422
+ }
423
+ }
424
+ }
425
+ // Permissive policies are OR-ed together, so a second broad policy silently
426
+ // widens whatever the first one narrowed (splinter 0006).
427
+ const overlap = new Map();
428
+ for (const p of policies) {
429
+ if (!p.permissive)
430
+ continue;
431
+ const commands = p.command === 'ALL' ? ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] : [p.command];
432
+ for (const cmd of commands) {
433
+ for (const role of p.roles) {
434
+ const key = `${p.table}|${cmd}|${role}`;
435
+ const list = overlap.get(key) ?? [];
436
+ list.push(p);
437
+ overlap.set(key, list);
438
+ }
439
+ }
440
+ }
441
+ const alreadyReported = new Set();
442
+ for (const [key, group] of overlap) {
443
+ if (group.length < 2)
444
+ continue;
445
+ const [table, cmd, role] = key.split('|');
446
+ const names = [...new Set(group.map((p) => p.name))];
447
+ if (names.length < 2)
448
+ continue;
449
+ const dedupe = `${table}|${cmd}|${role}|${names.join(',')}`;
450
+ if (alreadyReported.has(dedupe))
451
+ continue;
452
+ alreadyReported.add(dedupe);
453
+ findings.push({
454
+ id: 'CTS050',
455
+ severity: 'medium',
456
+ title: 'Overlapping permissive policies widen access',
457
+ detail: `\`${table}\` has ${names.length} permissive policies covering \`${cmd}\` for \`${role}\` ` +
458
+ `(${names.map((n) => `\`${n}\``).join(', ')}). PostgreSQL ORs permissive policies together, ` +
459
+ 'so a row is visible if *any* of them allows it — adding a policy can only ever widen access, ' +
460
+ 'never narrow it. A carefully scoped policy is defeated by a broad one sitting beside it.',
461
+ fix: 'Merge them into a single policy whose predicate expresses the whole rule, or make the ' +
462
+ 'narrowing one `AS RESTRICTIVE` so it is AND-ed instead of OR-ed.',
463
+ file: group[0].file,
464
+ line: group[0].line,
465
+ cwe: 'CWE-863: Incorrect Authorization',
466
+ owasp: 'A01:2025 - Broken Access Control',
467
+ meta: { table, command: cmd, role, policies: names },
468
+ });
469
+ }
470
+ // Supabase Storage listing: a broad SELECT policy on storage.objects lets a
471
+ // caller enumerate every file in every bucket, public or not (splinter 0025).
472
+ for (const p of policies) {
473
+ if (p.table !== 'storage.objects')
474
+ continue;
475
+ if (p.command !== 'SELECT' && p.command !== 'ALL')
476
+ continue;
477
+ if (!p.roles.some((r) => UNAUTHENTICATED_ROLES.has(r)))
478
+ continue;
479
+ const predicate = `${p.using ?? ''} ${p.withCheck ?? ''}`;
480
+ const constrained = /bucket_id|owner|auth\.uid\(\)|name\s*(like|~)/i.test(predicate);
481
+ if (constrained && !isAlwaysTrue(p.using))
482
+ continue;
483
+ findings.push({
484
+ id: 'CTS051',
485
+ severity: 'high',
486
+ title: 'Storage policy allows listing every object in every bucket',
487
+ detail: `Policy \`${p.name}\` grants unauthenticated SELECT on \`storage.objects\` without ` +
488
+ 'constraining `bucket_id` or `owner`. Reading an object needs only its URL, but listing is ' +
489
+ 'what turns "unguessable filename" into "here is the index" — a caller can enumerate every ' +
490
+ 'upload in the project, including buckets that are not public.' +
491
+ (publicBuckets.length
492
+ ? ` ${publicBuckets.length} public bucket(s) are also declared (${publicBuckets.map((b) => `\`${b.id}\``).join(', ')}).`
493
+ : ''),
494
+ fix: "Scope the policy to one bucket and to the caller, e.g. `bucket_id = 'avatars' AND owner = " +
495
+ '(select auth.uid())`, and keep private buckets out of any `anon` policy entirely.',
496
+ file: p.file,
497
+ line: p.line,
498
+ cwe: 'CWE-200: Exposure of Sensitive Information to an Unauthorized Actor',
499
+ owasp: 'A01:2025 - Broken Access Control',
500
+ meta: { policy: p.name, publicBuckets: publicBuckets.map((b) => b.id) },
501
+ });
502
+ }
503
+ // Apply inline suppressions now that every finding has a location.
504
+ for (const f of findings) {
505
+ const sup = f.file ? suppressors.get(f.file) : undefined;
506
+ if (sup && f.line && sup.suppressed(f.line, f.id))
507
+ continue;
508
+ const src = f.file ? sources.get(f.file) : undefined;
509
+ if (src && f.line)
510
+ f.snippet = snippetAt(src, f.line);
511
+ result.findings.push(f);
512
+ }
513
+ const publicTables = [...tables.values()].filter((t) => t.createdInPublic);
514
+ if (publicTables.length > 0) {
515
+ const unprotected = result.findings.filter((f) => f.id === 'CTS010').length;
516
+ result.checks.push({
517
+ label: `Row Level Security (${publicTables.length} public table${publicTables.length === 1 ? '' : 's'}, ${policies.length} polic${policies.length === 1 ? 'y' : 'ies'})`,
518
+ passed: unprotected === 0,
519
+ });
520
+ }
521
+ else if (sqlFiles.length > 0) {
522
+ result.checks.push({
523
+ label: 'Row Level Security',
524
+ passed: true,
525
+ note: 'no CREATE TABLE statements found in the scanned SQL',
526
+ });
527
+ }
528
+ return result;
529
+ },
530
+ };
531
+ export const _internals = { parseColumns };
@@ -0,0 +1,2 @@
1
+ import type { Scanner } from '../types.js';
2
+ export declare const secretsScanner: Scanner;