shipwarden 0.2.0 → 0.2.2

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 CHANGED
@@ -35,6 +35,8 @@ Try it on the bundled demo: `npx shipwarden examples/vulnerable-demo`
35
35
  | Supabase table without Row Level Security | Your anon key is public, so every row is readable and writable by anyone. Migrations are replayed in order, including `DO` blocks and RLS loops. |
36
36
  | RLS policy with `using (true)` | The classic AI-written "System can manage X" policy: anyone can edit or delete everything. |
37
37
  | View or materialized view that bypasses RLS | Views run as their owner unless `security_invoker` is on. |
38
+ | Supabase Storage policy with no owner check | Lets anyone (or any logged-in user) overwrite or delete other people's files, upload to your buckets, or read private buckets. |
39
+ | `security definer` function without a fixed `search_path` | It runs as its owner and skips RLS, so it should never look objects up through the caller's search_path. |
38
40
  | `service_role` key in the code | Decoded from the JWT itself, so the public `anon` key never triggers it. |
39
41
  | Secrets behind `NEXT_PUBLIC_` / `VITE_` / `EXPO_PUBLIC_` | These are baked into your frontend bundle. |
40
42
  | AI SDK with `dangerouslyAllowBrowser` | Your OpenAI/Anthropic key ships to every visitor. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shipwarden",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Free security scanner and launch checklist for apps built with Lovable, Bolt, Cursor and v0: Supabase RLS, Firebase rules, leaked keys, unprotected endpoints, missing privacy policy and 404 pages.",
5
5
  "homepage": "https://github.com/patrickballard/shipwarden#readme",
6
6
  "repository": {
@@ -87,6 +87,8 @@ export const publicEnvSecret = {
87
87
  for (const m of file.content.matchAll(PUBLIC_VAR)) {
88
88
  const [name, prefix, rest] = m;
89
89
  if (seen.has(name) || /PUBLISHABLE|ANON/.test(rest)) continue;
90
+ const lineStart = file.content.lastIndexOf('\n', m.index) + 1;
91
+ if (/^\s*(#|\/\/|\*)/.test(file.content.slice(lineStart, m.index))) continue; // commented out
90
92
  const critical = CRITICAL_NAME.test(rest);
91
93
  if (!critical && !AI_KEY_NAME.test(rest)) continue;
92
94
  // The ai-key-in-browser finding already covers an AI key used from browser code.
@@ -10,6 +10,10 @@ const STATEMENTS = {
10
10
  createView: new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?(?<mat>materialized\s+)?view\s+(?:if\s+not\s+exists\s+)?${QNAME}(?<rest>[^;]*)`, 'gi'),
11
11
  alterView: new RegExp(String.raw`\balter\s+view\s+(?:if\s+exists\s+)?${QNAME}\s+set\s*\((?<opts>[^)]*)\)`, 'gi'),
12
12
  dropView: new RegExp(String.raw`\bdrop\s+(?:materialized\s+)?view\s+(?:if\s+exists\s+)?${QNAME}`, 'gi'),
13
+ // Bodies are blanked here, so `[^;]*` runs to the end of the statement and sees every attribute.
14
+ createFunction: new RegExp(String.raw`\bcreate\s+(?:or\s+replace\s+)?(?:function|procedure)\s+${QNAME}\s*\((?<rest>[^;]*)`, 'gi'),
15
+ alterFunction: new RegExp(String.raw`\balter\s+(?:function|procedure)\s+${QNAME}(?<rest>[^;]*)`, 'gi'),
16
+ dropFunction: new RegExp(String.raw`\bdrop\s+(?:function|procedure)\s+(?:if\s+exists\s+)?${QNAME}`, 'gi'),
13
17
  };
14
18
 
15
19
  // Matched with DO-block bodies kept: Lovable/AI migrations often wrap these in `DO $$ BEGIN ... END $$`.
@@ -92,6 +96,69 @@ function invokerOn(opts) {
92
96
  return !!m && (!m[1] || /^(true|on|1|yes)$/i.test(m[1]));
93
97
  }
94
98
 
99
+ /** The text inside `using (...)` or `with check (...)`, with nested parentheses. */
100
+ function clause(body, keyword) {
101
+ const m = new RegExp(String.raw`\b${keyword}\s*\(`, 'i').exec(body);
102
+ if (!m) return null;
103
+ let depth = 1;
104
+ let i = m.index + m[0].length;
105
+ for (; i < body.length && depth; i++) depth += body[i] === '(' ? 1 : body[i] === ')' ? -1 : 0;
106
+ return body.slice(m.index + m[0].length, i - 1);
107
+ }
108
+
109
+ function policyInfo(body) {
110
+ const rolesRaw = /\bto\s+((?:"[^"]+"|[\w$]+)(?:\s*,\s*(?:"[^"]+"|[\w$]+))*)/i.exec(body)?.[1];
111
+ return {
112
+ cmd: (/\bfor\s+(all|select|insert|update|delete)\b/i.exec(body)?.[1] ?? 'all').toLowerCase(),
113
+ roles: rolesRaw ? rolesRaw.split(',').map((r) => r.trim().replace(/"/g, '').toLowerCase()) : ['public'],
114
+ using: clause(body, 'using'),
115
+ check: clause(body, String.raw`with\s+check`),
116
+ };
117
+ }
118
+
119
+ const ADMIN_ROLES = ['service_role', 'postgres', 'supabase_admin'];
120
+
121
+ /** `id -> public` for buckets created in SQL. Buckets made in the dashboard are unknown. */
122
+ function buckets(sqlFiles) {
123
+ const out = new Map();
124
+ const re = /\binsert\s+into\s+storage\s*\.\s*buckets\s*\(([^)]*)\)\s*values\s*((?:\([^)]*\)\s*,?\s*)+)/gi;
125
+ for (const file of sqlFiles) {
126
+ for (const m of cleanSql(file.content, { keepBodies: true }).matchAll(re)) {
127
+ const cols = m[1].split(',').map((c) => c.trim().replace(/"/g, '').toLowerCase());
128
+ const id = cols.indexOf('id') === -1 ? cols.indexOf('name') : cols.indexOf('id');
129
+ const pub = cols.indexOf('public');
130
+ for (const row of m[2].matchAll(/\(([^)]*)\)/g)) {
131
+ const values = row[1].match(/'(?:[^']|'')*'|[^,]+/g)?.map((v) => v.trim().replace(/^'|'$/g, '')) ?? [];
132
+ if (values[id]) out.set(values[id], pub !== -1 && /^true$/i.test(values[pub] ?? ''));
133
+ }
134
+ }
135
+ }
136
+ return out;
137
+ }
138
+
139
+ // "Logged in" conditions that don't tie a file to its owner.
140
+ const LOGGED_IN = /\(?\s*(?:select\s+)?auth\s*\.\s*uid\s*\(\s*\)\s*\)?\s*is\s+not\s+null|auth\s*\.\s*role\s*\(\s*\)\s*=\s*'authenticated'/gi;
141
+ const PATH_FUNCTIONS = new Set(['storage.foldername', 'storage.filename', 'storage.extension', 'lower', 'upper', 'coalesce', 'in', 'and', 'or', 'not', 'array']);
142
+
143
+ /**
144
+ * Whether a storage policy condition limits access to some user. Any call we don't recognise
145
+ * (a custom `is_admin()`, a subquery) counts as a check, so we stay quiet when unsure.
146
+ */
147
+ function checksUser(expr) {
148
+ const rest = expr.replace(LOGGED_IN, ' ');
149
+ if (/\bauth\s*\.\s*(?:uid|jwt)\s*\(|\bowner(?:_id)?\b|\bexists\s*\(|\bselect\b/i.test(rest)) return true;
150
+ return [...rest.matchAll(/([A-Za-z_][\w$]*(?:\s*\.\s*[A-Za-z_][\w$]*)?)\s*\(/g)].some((m) => !PATH_FUNCTIONS.has(m[1].replace(/\s+/g, '').toLowerCase()));
151
+ }
152
+
153
+ function bucketsIn(expr) {
154
+ const out = [];
155
+ for (const m of expr.matchAll(/\bbucket_id\s*(?:=\s*'([^']+)'|in\s*\(([^)]*)\))/gi)) {
156
+ if (m[1]) out.push(m[1]);
157
+ else out.push(...[...m[2].matchAll(/'([^']+)'/g)].map((b) => b[1]));
158
+ }
159
+ return out;
160
+ }
161
+
95
162
  const cache = new WeakMap();
96
163
 
97
164
  /**
@@ -135,6 +202,7 @@ function analyze(files) {
135
202
  const tables = new Map();
136
203
  const views = new Map();
137
204
  const policies = new Map();
205
+ const functions = new Map();
138
206
  for (const { kind, m, file, d } of events) {
139
207
  if (kind === 'dynamicRls') {
140
208
  for (const t of tables.values()) if (t.schema === 'public' && (d.all || d.names.includes(t.name.toLowerCase()))) t.rls = true;
@@ -167,12 +235,23 @@ function analyze(files) {
167
235
  policies.set(`${key}:${m.groups.policy}`, { schema, name, body: m.groups.body, ...loc });
168
236
  } else if (kind === 'dropPolicy') {
169
237
  policies.delete(`${key}:${m.groups.policy}`);
238
+ } else if (kind === 'createFunction') {
239
+ const { rest } = m.groups;
240
+ functions.set(key, { schema, name, definer: /\bsecurity\s+definer\b/i.test(rest), pinned: /\bset\s+search_path\b/i.test(rest), ...loc });
241
+ } else if (kind === 'alterFunction') {
242
+ const fn = functions.get(key);
243
+ if (!fn) continue;
244
+ if (/\bset\s+search_path\b/i.test(m.groups.rest)) fn.pinned = true;
245
+ if (/\bsecurity\s+invoker\b/i.test(m.groups.rest)) fn.definer = false;
246
+ if (/\bsecurity\s+definer\b/i.test(m.groups.rest)) fn.definer = true;
247
+ } else if (kind === 'dropFunction') {
248
+ functions.delete(key);
170
249
  }
171
250
  }
172
251
  for (const [key, t] of tables) {
173
252
  if (!t.rls && !t.disabledExplicitly && enabledAnywhere.has(key)) t.rls = true;
174
253
  }
175
- const result = { tables, views, policies: [...policies.values()] };
254
+ const result = { tables, views, functions, policies: [...policies.values()], buckets: buckets(sql) };
176
255
  cache.set(files, result);
177
256
  return result;
178
257
  }
@@ -230,10 +309,8 @@ export const permissivePolicy = {
230
309
  const findings = [];
231
310
  for (const p of analyze(files).policies) {
232
311
  if (p.schema !== 'public' || /\bas\s+restrictive\b/i.test(p.body)) continue;
233
- const cmd = (/\bfor\s+(all|select|insert|update|delete)\b/i.exec(p.body)?.[1] ?? 'all').toLowerCase();
234
- const rolesRaw = /\bto\s+((?:"[^"]+"|[\w$]+)(?:\s*,\s*(?:"[^"]+"|[\w$]+))*)/i.exec(p.body)?.[1];
235
- const roles = rolesRaw ? rolesRaw.split(',').map((r) => r.trim().replace(/"/g, '').toLowerCase()) : ['public'];
236
- if (roles.every((r) => ['service_role', 'postgres', 'supabase_admin'].includes(r))) continue;
312
+ const { cmd, roles } = policyInfo(p.body);
313
+ if (roles.every((r) => ADMIN_ROLES.includes(r))) continue;
237
314
  const usingTrue = /\busing\s*\(\s*true\s*\)/i.test(p.body);
238
315
  const checkTrue = /\bwith\s+check\s*\(\s*true\s*\)/i.test(p.body);
239
316
  const anyone = roles.some((r) => r === 'public' || r === 'anon');
@@ -290,4 +367,100 @@ export const unsafeView = {
290
367
  },
291
368
  };
292
369
 
293
- export default [rlsDisabled, permissivePolicy, unsafeView];
370
+ export const storagePolicy = {
371
+ id: 'supabase-storage-policy',
372
+ title: 'Storage policy lets anyone change files',
373
+ severity: 'high',
374
+ checkAll(files, ctx) {
375
+ if (!ctx.usesSupabase) return [];
376
+ const { policies, buckets: known } = analyze(files);
377
+ const findings = [];
378
+ for (const p of policies) {
379
+ if (p.schema !== 'storage' || p.name !== 'objects' || /\bas\s+restrictive\b/i.test(p.body)) continue;
380
+ const { cmd, roles, using, check } = policyInfo(p.body);
381
+ if (roles.every((r) => ADMIN_ROLES.includes(r))) continue;
382
+ const expr = (cmd === 'insert' ? check : using ?? check) ?? 'true';
383
+ if (checksUser(expr)) continue;
384
+
385
+ const anyone = roles.some((r) => r === 'public' || r === 'anon') && expr.replace(LOGGED_IN, '') === expr;
386
+ const who = anyone ? 'anyone' : 'any logged-in user';
387
+ const ids = bucketsIn(expr);
388
+ const where = ids.length === 0 ? 'every bucket' : ids.length === 1 ? `the \`${ids[0]}\` bucket` : `the ${ids.map((b) => `\`${b}\``).join(', ')} buckets`;
389
+ const bucket = ids[0] ?? 'your-bucket';
390
+ const ownerFix =
391
+ `Store each user's files under a folder named after their id and check it, e.g. ` +
392
+ `\`using (bucket_id = '${bucket}' and (storage.foldername(name))[1] = auth.uid()::text)\`.`;
393
+
394
+ if (['all', 'update', 'delete'].includes(cmd)) {
395
+ const verb = { all: 'read, overwrite and delete', update: 'overwrite', delete: 'delete' }[cmd];
396
+ findings.push(
397
+ makeFinding(this, p.file, p.line, {
398
+ severity: anyone ? 'high' : 'medium',
399
+ title: `Storage policy lets ${who} ${verb} any file in ${where}`,
400
+ message: `This policy on \`storage.objects\` never checks who uploaded a file, so ${anyone ? 'anyone with your public anon key' : 'any logged-in user'} can ${verb} every file in ${where}, including other users' files.`,
401
+ fix: ownerFix,
402
+ }),
403
+ );
404
+ } else if (cmd === 'insert' && anyone) {
405
+ findings.push(
406
+ makeFinding(this, p.file, p.line, {
407
+ severity: 'low',
408
+ title: `Storage policy lets anyone upload files to ${where}`,
409
+ message: `Logged-out visitors can upload any file to ${where} using your public anon key: people can fill your storage (and your bill) or use it to host their own files.`,
410
+ fix: `Add \`to authenticated\` to the policy, and ideally limit uploads to the user's own folder: \`with check (bucket_id = '${bucket}' and (storage.foldername(name))[1] = auth.uid()::text)\`.`,
411
+ }),
412
+ );
413
+ } else if (cmd === 'select') {
414
+ // Reading a public bucket is expected; only private buckets created in SQL are known to be private.
415
+ const exposed = (ids.length ? ids : [...known.keys()]).filter((b) => known.get(b) === false);
416
+ if (!exposed.length) continue;
417
+ const label = exposed.length === 1 ? `the private \`${exposed[0]}\` bucket` : `the private ${exposed.map((b) => `\`${b}\``).join(', ')} buckets`;
418
+ findings.push(
419
+ makeFinding(this, p.file, p.line, {
420
+ severity: anyone ? 'high' : 'medium',
421
+ title: `Storage policy lets ${who} read every file in ${label}`,
422
+ message: `The bucket is private, but this policy lets ${anyone ? 'anyone with your public anon key' : 'any logged-in user'} list and download every file in it, including other users' files.`,
423
+ fix: ownerFix,
424
+ }),
425
+ );
426
+ }
427
+ }
428
+ return findings;
429
+ },
430
+ };
431
+
432
+ export const definerSearchPath = {
433
+ id: 'supabase-definer-search-path',
434
+ title: 'SECURITY DEFINER function without a fixed search_path',
435
+ severity: 'low',
436
+ checkAll(files, ctx) {
437
+ if (!ctx.usesSupabase) return [];
438
+ // One finding per migration file: AI-written migrations often define a dozen of these at once.
439
+ const byFile = new Map();
440
+ for (const fn of analyze(files).functions.values()) {
441
+ if (!fn.definer || fn.pinned) continue;
442
+ if (!byFile.has(fn.file)) byFile.set(fn.file, []);
443
+ byFile.get(fn.file).push(fn);
444
+ }
445
+ return [...byFile.values()].map((fns) => {
446
+ const [first] = fns;
447
+ const names = fns.map((f) => `\`${f.name}\``);
448
+ const list = names.length > 4 ? `${names.slice(0, 4).join(', ')} and ${names.length - 4} more` : names.join(', ');
449
+ return makeFinding(this, first.file, first.line, {
450
+ title:
451
+ fns.length === 1
452
+ ? `Function \`${first.name}\` runs with its owner's privileges but has no fixed search_path`
453
+ : `${fns.length} functions run with their owner's privileges but have no fixed search_path`,
454
+ message:
455
+ `${fns.length === 1 ? 'This function is' : `${list} are`} \`security definer\`, so ${fns.length === 1 ? 'it runs' : 'they run'} as the owner and skip Row Level Security. ` +
456
+ "Without `set search_path`, tables and functions are looked up using the caller's search_path, so anyone who can create objects in a schema on that path " +
457
+ "can make them run their code with the owner's privileges. Supabase's Security Advisor flags this too.",
458
+ fix:
459
+ `Add \`set search_path = ''\` to each function and write table names in full inside it (e.g. \`public.profiles\`), ` +
460
+ `or run \`alter function ${first.schema}.${first.name} set search_path = '';\` for each one. \`set search_path = public\` also works if you'd rather not rename things.`,
461
+ });
462
+ });
463
+ },
464
+ };
465
+
466
+ export default [rlsDisabled, permissivePolicy, unsafeView, storagePolicy, definerSearchPath];
package/src/rules/web.js CHANGED
@@ -1,4 +1,4 @@
1
- import { EDGE_FUNCTION, ROUTE_HANDLER, hasDirective, isServerEndpoint, lineAt, makeFinding } from '../util.js';
1
+ import { EDGE_FUNCTION, ROUTE_HANDLER, hasDirective, isServerEndpoint, lineAt, looksLikePlaceholder, makeFinding } from '../util.js';
2
2
 
3
3
  const MUTATING_EXPORT = /export\s+(?:async\s+)?function\s+(POST|PUT|PATCH|DELETE)\b|export\s+const\s+(POST|PUT|PATCH|DELETE)\s*=|\bas\s+(POST|PUT|PATCH|DELETE)\b/;
4
4
 
@@ -177,8 +177,11 @@ export const committedEnvFile = {
177
177
  severity: 'high',
178
178
  check(file, ctx) {
179
179
  if (!ctx.tracked || !ENV_FILE.test(file.path) || ENV_TEMPLATE.test(file.path)) return [];
180
- const names = [...file.content.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['"]?[^\s'"#]/gm)].map((m) => m[1]);
181
- const secrets = names.filter((n) => !PUBLIC_PREFIX.test(n));
180
+ const vars = [...file.content.matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*['"]?([^\s'"#][^\n'"]*)/gm)];
181
+ // Public values and local-only dev settings (localhost databases, "password") aren't leaks.
182
+ const secrets = vars
183
+ .filter(([, name, value]) => !PUBLIC_PREFIX.test(name) && !/localhost|127\.0\.0\.1|0\.0\.0\.0|host\.docker\.internal/.test(value) && !looksLikePlaceholder(value) && !/^(password|secret|changeme|true|false|\d+|development|production|test)$/i.test(value))
184
+ .map(([, name]) => name);
182
185
  if (!secrets.length) return [];
183
186
  const shown = secrets.slice(0, 5).map((n) => `\`${n}\``).join(', ');
184
187
  return [