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 +2 -0
- package/package.json +1 -1
- package/src/rules/secrets.js +2 -0
- package/src/rules/supabase.js +179 -6
- package/src/rules/web.js +6 -3
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.
|
|
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": {
|
package/src/rules/secrets.js
CHANGED
|
@@ -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.
|
package/src/rules/supabase.js
CHANGED
|
@@ -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 = (
|
|
234
|
-
|
|
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
|
|
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
|
|
181
|
-
|
|
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 [
|