launchprep 0.0.1 → 0.1.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 +76 -12
- package/bin/launchprep.mjs +24 -0
- package/net/client.mjs +40 -0
- package/net/commands.mjs +156 -0
- package/net/consent.mjs +57 -0
- package/net/credentials.mjs +33 -0
- package/package.json +27 -12
- package/scripts/verify-readonly.mjs +80 -0
- package/src/brand.mjs +13 -0
- package/src/checks-ai.mjs +255 -0
- package/src/checks-auth.mjs +263 -0
- package/src/checks-authz.mjs +179 -0
- package/src/checks-batch2.mjs +385 -0
- package/src/checks-batch3.mjs +327 -0
- package/src/checks-batch4.mjs +529 -0
- package/src/checks-deploy.mjs +272 -0
- package/src/checks-frameworks.mjs +337 -0
- package/src/checks.mjs +209 -0
- package/src/detect.mjs +304 -0
- package/src/digest.mjs +169 -0
- package/src/fs-scan.mjs +118 -0
- package/src/gate.mjs +103 -0
- package/src/index.mjs +71 -0
- package/src/report.mjs +101 -0
- package/src/rules.json +3651 -0
- package/src/workspace.mjs +0 -0
- package/bin/cli.js +0 -11
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// Authorization: "is THIS user allowed to touch THIS record?"
|
|
2
|
+
// The corpus calls this the most common real-world vulnerability, and it is the
|
|
3
|
+
// family most likely to find something true in a vibe-coded app.
|
|
4
|
+
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
5
|
+
({ id, title, severity, file, line, detail, fix });
|
|
6
|
+
|
|
7
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
8
|
+
|
|
9
|
+
// Everything between a handler's opening brace and its matching close, so we can
|
|
10
|
+
// ask "does this handler mention the logged-in user anywhere?"
|
|
11
|
+
function functionBodies(text) {
|
|
12
|
+
const out = [];
|
|
13
|
+
const re = /(export\s+(?:default\s+)?(?:async\s+)?function\s+\w*|(?:export\s+)?const\s+\w+\s*=\s*(?:async\s*)?\([^)]*\)\s*=>|app\.(?:get|post|put|patch|delete)\s*\([^,]+,\s*(?:async\s*)?\([^)]*\)\s*=>)/g;
|
|
14
|
+
let m;
|
|
15
|
+
while ((m = re.exec(text))) {
|
|
16
|
+
const start = text.indexOf('{', m.index + m[0].length - 1);
|
|
17
|
+
if (start === -1) continue;
|
|
18
|
+
let depth = 0, i = start;
|
|
19
|
+
for (; i < text.length; i++) {
|
|
20
|
+
if (text[i] === '{') depth++;
|
|
21
|
+
else if (text[i] === '}') { depth--; if (!depth) break; }
|
|
22
|
+
}
|
|
23
|
+
out.push({ start: m.index, body: text.slice(start, i + 1) });
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SESSION_HINT =
|
|
29
|
+
/\b(session|getUser|currentUser|auth\(\)|getServerSession|req\.user|ctx\.user|userId|user_id|authUser|locals\.user|clerkId|auth\.uid)\b/;
|
|
30
|
+
const TENANT_HINT =
|
|
31
|
+
/\b(orgId|org_id|tenantId|tenant_id|workspaceId|workspace_id|teamId|team_id)\b/;
|
|
32
|
+
// no trailing \b: the alternation can end in '(' , where there is no word boundary
|
|
33
|
+
const ID_FROM_REQUEST =
|
|
34
|
+
/\b(?:params\.(?:id|\w+Id)|searchParams\.get\(|req\.query\.\w*[iI]d|body\.\w*[iI]d)/;
|
|
35
|
+
const DB_READ =
|
|
36
|
+
/\b(findUnique|findFirst|findById|findOne|\.select\s*\(|\.from\s*\(|db\.query\.|\.eq\s*\(\s*['"]id['"])/;
|
|
37
|
+
|
|
38
|
+
export const AUTHZ_CHECKS = [
|
|
39
|
+
|
|
40
|
+
// ---- Supabase: RLS left off ------------------------------------------------
|
|
41
|
+
{ id: 'AUTHZ-001', run(repo, profile) {
|
|
42
|
+
if (profile.stack?.database !== 'supabase') return [];
|
|
43
|
+
const out = [];
|
|
44
|
+
for (const f of repo.files) {
|
|
45
|
+
if (!f.text || !/\.sql$/.test(f.path)) continue;
|
|
46
|
+
const created = new Map();
|
|
47
|
+
const cre = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?(?:public\.)?["`]?(\w+)/gi;
|
|
48
|
+
let m;
|
|
49
|
+
while ((m = cre.exec(f.text))) created.set(m[1].toLowerCase(), m.index);
|
|
50
|
+
|
|
51
|
+
const enabled = new Set();
|
|
52
|
+
const ena = /ALTER\s+TABLE\s+["`]?(?:public\.)?["`]?(\w+)["`]?\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi;
|
|
53
|
+
while ((m = ena.exec(f.text))) enabled.add(m[1].toLowerCase());
|
|
54
|
+
|
|
55
|
+
for (const [table, idx] of created) {
|
|
56
|
+
if (enabled.has(table)) continue;
|
|
57
|
+
// whole-repo check: RLS may be enabled in a later migration
|
|
58
|
+
const elsewhere = repo.files.some(o => o.text && o !== f &&
|
|
59
|
+
new RegExp(`ALTER\\s+TABLE\\s+["\`]?(?:public\\.)?["\`]?${table}["\`]?\\s+ENABLE\\s+ROW`, 'i').test(o.text));
|
|
60
|
+
if (elsewhere) continue;
|
|
61
|
+
out.push(finding('AUTHZ-001', `Table "${table}" has row level security turned off`, 'critical',
|
|
62
|
+
f.path, lineOf(f.text, idx),
|
|
63
|
+
`Supabase leaves RLS off for tables created in raw SQL. Until it is on, any logged-in user can read and write every row in "${table}" through the public API — not just their own.`,
|
|
64
|
+
`ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY; then add a policy limiting rows to their owner.`));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}},
|
|
69
|
+
|
|
70
|
+
// ---- Supabase: RLS on, but the policy lets everyone through ---------------
|
|
71
|
+
{ id: 'AUTHZ-002', run(repo, profile) {
|
|
72
|
+
if (profile.stack?.database !== 'supabase') return [];
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const f of repo.files) {
|
|
75
|
+
if (!f.text || !/\.sql$/.test(f.path)) continue;
|
|
76
|
+
const re = /CREATE\s+POLICY\s+["`]?([^"`\s]+)["`]?[\s\S]{0,300}?(USING|WITH\s+CHECK)\s*\(\s*(true|1\s*=\s*1)\s*\)/gi;
|
|
77
|
+
let m;
|
|
78
|
+
while ((m = re.exec(f.text))) {
|
|
79
|
+
out.push(finding('AUTHZ-002', `Policy "${m[1]}" allows every row`, 'critical',
|
|
80
|
+
f.path, lineOf(f.text, m.index),
|
|
81
|
+
'The dashboard will show RLS as enabled, so this looks protected. USING (true) means every row matches for every user, which is the same as having no policy at all.',
|
|
82
|
+
"Replace true with an ownership test, e.g. USING (auth.uid() = user_id)."));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}},
|
|
87
|
+
|
|
88
|
+
// ---- IDOR: fetch by id with nobody checking ownership ---------------------
|
|
89
|
+
{ id: 'AUTHZ-003', run(repo, profile) {
|
|
90
|
+
if (!profile.has_accounts) return [];
|
|
91
|
+
const out = [];
|
|
92
|
+
for (const f of repo.files) {
|
|
93
|
+
if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
|
|
94
|
+
if (!/\/(api|routes?|server|controllers?|actions?)\//.test(f.path)) continue;
|
|
95
|
+
for (const { start, body } of functionBodies(f.text)) {
|
|
96
|
+
if (!ID_FROM_REQUEST.test(body)) continue;
|
|
97
|
+
if (!DB_READ.test(body)) continue;
|
|
98
|
+
if (SESSION_HINT.test(body) || TENANT_HINT.test(body)) continue;
|
|
99
|
+
out.push(finding('AUTHZ-003', 'Record fetched by id with no ownership check', 'critical',
|
|
100
|
+
f.path, lineOf(f.text, start),
|
|
101
|
+
'This handler takes an id straight from the request and looks it up, without checking the record belongs to the person logged in. Changing the id in the URL returns somebody else\'s data.',
|
|
102
|
+
'Add the signed-in user or organisation to the query itself, e.g. where: { id, orgId: session.orgId }.'));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}},
|
|
107
|
+
|
|
108
|
+
// ---- tenant scope missing on a multi-tenant query ------------------------
|
|
109
|
+
{ id: 'AUTHZ-006', run(repo, profile) {
|
|
110
|
+
if (profile.tenancy !== 'multi-tenant-shared-db') return [];
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const f of repo.files) {
|
|
113
|
+
if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
|
|
114
|
+
if (!/\/(api|routes?|server|controllers?|actions?)\//.test(f.path)) continue;
|
|
115
|
+
for (const { start, body } of functionBodies(f.text)) {
|
|
116
|
+
if (!/\b(findMany|\.select\s*\(|db\.query\.\w+\.findMany)\b/.test(body)) continue;
|
|
117
|
+
if (TENANT_HINT.test(body)) continue;
|
|
118
|
+
if (!SESSION_HINT.test(body)) continue; // unauthenticated routes are AUTHZ-003's job
|
|
119
|
+
out.push(finding('AUTHZ-006', 'List query is not limited to the user\'s organisation', 'critical',
|
|
120
|
+
f.path, lineOf(f.text, start),
|
|
121
|
+
'Your app stores several organisations in one database. This query knows who is logged in but never filters by their organisation, so it can return other customers\' rows.',
|
|
122
|
+
'Add the tenant key to the query, e.g. where: { orgId: session.orgId }.'));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
}},
|
|
127
|
+
|
|
128
|
+
// ---- admin area with only a client-side guard -----------------------------
|
|
129
|
+
{ id: 'AUTHZ-004', run(repo, profile) {
|
|
130
|
+
if (!profile.has_admin_panel) return [];
|
|
131
|
+
const out = [];
|
|
132
|
+
const server = repo.files.filter(f => f.text && /\/admin\//i.test(f.path) &&
|
|
133
|
+
/\.(ts|js|mjs)$/.test(f.path) && /\/(api|server|actions?)\//.test(f.path));
|
|
134
|
+
const guarded = server.some(f => /\b(role|isAdmin|is_admin|permission|hasRole)\b/.test(f.text) &&
|
|
135
|
+
SESSION_HINT.test(f.text));
|
|
136
|
+
if (server.length && !guarded) {
|
|
137
|
+
out.push(finding('AUTHZ-004', 'Admin endpoints have no server-side role check', 'critical',
|
|
138
|
+
server[0].path, 1,
|
|
139
|
+
'Hiding the admin UI does not protect the endpoints behind it. Anyone who knows the URL can call them directly.',
|
|
140
|
+
'Check the signed-in user\'s role on the server in every admin route, not only in the page that renders it.'));
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}},
|
|
144
|
+
|
|
145
|
+
// ---- mass assignment ------------------------------------------------------
|
|
146
|
+
{ id: 'API-001', run(repo) {
|
|
147
|
+
const out = [];
|
|
148
|
+
for (const f of repo.files) {
|
|
149
|
+
if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
|
|
150
|
+
const re = /(?:data|values)\s*:\s*(?:\{\s*\.\.\.\s*(?:req\.)?body\b|(?:req\.)?body\s*[,}])|\.(?:create|update|insert)\s*\(\s*(?:req\.)?body\s*\)/g;
|
|
151
|
+
let m;
|
|
152
|
+
while ((m = re.exec(f.text))) {
|
|
153
|
+
out.push(finding('API-001', 'Request body written straight to the database', 'critical',
|
|
154
|
+
f.path, lineOf(f.text, m.index),
|
|
155
|
+
'Every field the caller sends is saved, including ones your form never shows. That is how a user sets their own role to admin, or their own plan to unlimited.',
|
|
156
|
+
'Pick the fields explicitly, or validate the body against a schema before saving it.'));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}},
|
|
161
|
+
|
|
162
|
+
// ---- Supabase storage bucket left public ---------------------------------
|
|
163
|
+
{ id: 'SUP-001', run(repo, profile) {
|
|
164
|
+
if (profile.stack?.database !== 'supabase' || !profile.has_file_uploads) return [];
|
|
165
|
+
const out = [];
|
|
166
|
+
for (const f of repo.files) {
|
|
167
|
+
if (!f.text) continue;
|
|
168
|
+
const re = /(?:INSERT\s+INTO\s+storage\.buckets[\s\S]{0,200}?\btrue\b)|createBucket\s*\([^)]*public\s*:\s*true/gi;
|
|
169
|
+
let m;
|
|
170
|
+
while ((m = re.exec(f.text))) {
|
|
171
|
+
out.push(finding('SUP-001', 'Storage bucket is public', 'critical',
|
|
172
|
+
f.path, lineOf(f.text, m.index),
|
|
173
|
+
'A public bucket serves every file in it to anyone with the URL, with no login required.',
|
|
174
|
+
'Make the bucket private and serve files through signed URLs.'));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}},
|
|
179
|
+
];
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// The remaining critical and high tier-1 checks.
|
|
2
|
+
// Lesson applied throughout: judge the line, read the value, never the name alone.
|
|
3
|
+
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
4
|
+
({ id, title, severity, file, line, detail, fix });
|
|
5
|
+
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
6
|
+
const lineAt = (t, i) => {
|
|
7
|
+
const a = t.lastIndexOf('\n', i) + 1;
|
|
8
|
+
const b = t.indexOf('\n', i);
|
|
9
|
+
return t.slice(a, b === -1 ? t.length : b);
|
|
10
|
+
};
|
|
11
|
+
const isJS = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
|
|
12
|
+
const isServer = (p) => /\/(api|routes?|server|actions?|controllers?|handlers?)\//.test(p);
|
|
13
|
+
|
|
14
|
+
export const BATCH2_CHECKS = [
|
|
15
|
+
|
|
16
|
+
// ---- payments ------------------------------------------------------------
|
|
17
|
+
{ id: 'PAY-001', run(repo, profile) {
|
|
18
|
+
if (profile.handles_payments === 'none') return [];
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const f of repo.files) {
|
|
21
|
+
if (!f.text || !isJS(f.path) && !/\.py$/.test(f.path)) continue;
|
|
22
|
+
// a file whose PATH says webhook is a webhook. only files that merely mention
|
|
23
|
+
// one need corroborating that a payment provider is involved.
|
|
24
|
+
const pathSaysWebhook = /webhook/i.test(f.path);
|
|
25
|
+
const textSaysWebhook = /webhook/i.test(f.text.slice(0, 800));
|
|
26
|
+
if (!pathSaysWebhook && !textSaysWebhook) continue;
|
|
27
|
+
if (!pathSaysWebhook && !/(stripe|paddle|lemonsqueezy|payment|checkout)/i.test(f.text)) continue;
|
|
28
|
+
if (/constructEvent|construct_event|verify_header|Webhook\.verify|checkSignature|stripe-signature|verifyWebhook/i.test(f.text)) continue;
|
|
29
|
+
out.push(finding('PAY-001', 'Payment webhook does not verify its signature', 'critical',
|
|
30
|
+
f.path, 1,
|
|
31
|
+
'Without signature verification this endpoint accepts a payment notification from anyone who knows the URL. It is an unauthenticated "mark this order paid" API.',
|
|
32
|
+
'Verify with the provider SDK before trusting the body — stripe.webhooks.constructEvent(raw, sig, secret).'));
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}},
|
|
36
|
+
|
|
37
|
+
{ id: 'PAY-006', run(repo, profile) {
|
|
38
|
+
if (profile.handles_payments === 'none') return [];
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const f of repo.files) {
|
|
41
|
+
if (!f.text || !isJS(f.path)) continue;
|
|
42
|
+
const re = /(?:amount|unit_amount|price)\s*:\s*(?:Number\(|parseInt\(|parseFloat\()?\s*(?:req\.body|body|params|searchParams\.get\(|data)\.?/g;
|
|
43
|
+
let m;
|
|
44
|
+
while ((m = re.exec(f.text))) {
|
|
45
|
+
const around = f.text.slice(Math.max(0, m.index - 500), m.index + 200);
|
|
46
|
+
if (!/(paymentIntents|checkout\.sessions|charges|subscriptions)\.create|stripe\./i.test(around)) continue;
|
|
47
|
+
out.push(finding('PAY-006', 'Charge amount comes from the request', 'critical',
|
|
48
|
+
f.path, lineOf(f.text, m.index),
|
|
49
|
+
'The caller decides what they pay. Change the number in the request and the product costs whatever they like.',
|
|
50
|
+
'Look the price up on the server from your own catalogue, keyed by product id.'));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}},
|
|
55
|
+
|
|
56
|
+
// ---- api -----------------------------------------------------------------
|
|
57
|
+
{ id: 'API-005', run(repo, profile) {
|
|
58
|
+
if (profile.stack?.database === 'none') return [];
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const f of repo.files) {
|
|
61
|
+
if (!f.text || !isJS(f.path)) continue;
|
|
62
|
+
const re = /(?:orderBy|order|sort|sortBy|groupBy)\s*:\s*(?:req\.query|searchParams\.get\(|body|params)\.?/g;
|
|
63
|
+
let m;
|
|
64
|
+
while ((m = re.exec(f.text))) {
|
|
65
|
+
const around = f.text.slice(Math.max(0, m.index - 400), m.index + 300);
|
|
66
|
+
if (/ALLOWED_SORT|SORT_FIELDS|includes\(|z\.enum|Object\.keys|switch/i.test(around)) continue;
|
|
67
|
+
out.push(finding('API-005', 'Sort or grouping column taken from the request', 'critical',
|
|
68
|
+
f.path, lineOf(f.text, m.index),
|
|
69
|
+
'Column names cannot be parameterised the way values can, so this is pasted into the query. A caller can order by a column you never meant to expose, or worse.',
|
|
70
|
+
'Map the incoming value through a fixed allowlist of sortable columns.'));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}},
|
|
75
|
+
|
|
76
|
+
{ id: 'API-003', run(repo, profile) {
|
|
77
|
+
if (!profile.is_public || profile.stage !== 'production') return [];
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const f of repo.files) {
|
|
80
|
+
if (!f.text || !isJS(f.path) || !isServer(f.path)) continue;
|
|
81
|
+
const re = /(?:stack|e\.stack|err\.stack|error\.stack)\s*[,}\]]/g;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = re.exec(f.text))) {
|
|
84
|
+
const line = lineAt(f.text, m.index);
|
|
85
|
+
if (!/(res\.|Response\.json|json\(|send\(|return\s*\{)/.test(line)) continue;
|
|
86
|
+
if (/console\.|logger\.|log\(/.test(line)) continue; // logging a stack is correct
|
|
87
|
+
out.push(finding('API-003', 'Stack trace returned to the caller', 'high',
|
|
88
|
+
f.path, lineOf(f.text, m.index),
|
|
89
|
+
'A stack trace names your file paths, your dependencies and their versions — a map of your application handed to whoever can trigger an error.',
|
|
90
|
+
'Log the stack server-side, return a generic message and a reference id.'));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}},
|
|
95
|
+
|
|
96
|
+
// ---- next.js -------------------------------------------------------------
|
|
97
|
+
{ id: 'NEXT-006', run(repo, profile) {
|
|
98
|
+
if (profile.stack?.framework !== 'next') return [];
|
|
99
|
+
const out = [];
|
|
100
|
+
const SECRETISH = /(SECRET|PRIVATE|SERVICE_ROLE|_KEY|TOKEN|PASSWORD|DATABASE_URL)/;
|
|
101
|
+
for (const f of repo.files) {
|
|
102
|
+
if (!f.text || !isJS(f.path)) continue;
|
|
103
|
+
if (!/^\s*['"]use client['"]/m.test(f.text.slice(0, 400))) continue;
|
|
104
|
+
const re = /process\.env\.([A-Z0-9_]+)/g;
|
|
105
|
+
let m;
|
|
106
|
+
while ((m = re.exec(f.text))) {
|
|
107
|
+
if (m[1].startsWith('NEXT_PUBLIC_')) continue;
|
|
108
|
+
if (!SECRETISH.test(m[1])) continue;
|
|
109
|
+
out.push(finding('NEXT-006', `${m[1]} read inside a client component`, 'critical',
|
|
110
|
+
f.path, lineOf(f.text, m.index),
|
|
111
|
+
"This file is marked 'use client', so it is compiled into the browser bundle. A server-only variable read here is either undefined at runtime or, if it builds, shipped to every visitor.",
|
|
112
|
+
'Read it in a server component or route handler and pass down only what the browser needs.'));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}},
|
|
117
|
+
|
|
118
|
+
{ id: 'NEXT-014', run(repo, profile) {
|
|
119
|
+
if (profile.stack?.framework !== 'next' || !profile.has_accounts) return [];
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const f of repo.files) {
|
|
122
|
+
if (!f.text || !isJS(f.path)) continue;
|
|
123
|
+
const re = /redirect\s*\(\s*(?:searchParams\.get\(\s*['"](?:next|redirect|returnTo|callbackUrl)['"]\s*\)|(?:req\.)?query\.(?:next|redirect|returnTo|callbackUrl))/g;
|
|
124
|
+
let m;
|
|
125
|
+
while ((m = re.exec(f.text))) {
|
|
126
|
+
const around = f.text.slice(Math.max(0, m.index - 400), m.index);
|
|
127
|
+
if (/startsWith\s*\(\s*['"]\/|ALLOWED|new URL\([^)]*origin/.test(around)) continue;
|
|
128
|
+
out.push(finding('NEXT-014', 'Post-login redirect target comes from the URL', 'high',
|
|
129
|
+
f.path, lineOf(f.text, m.index),
|
|
130
|
+
'An attacker sends a login link that bounces through your domain and lands on theirs. The address bar says your site right up until the credentials are typed.',
|
|
131
|
+
"Accept only paths beginning with a single '/', or check against an allowlist."));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}},
|
|
136
|
+
|
|
137
|
+
// ---- storage & uploads ---------------------------------------------------
|
|
138
|
+
{ id: 'DATA-014', run(repo, profile) {
|
|
139
|
+
if (!profile.has_file_uploads) return [];
|
|
140
|
+
const EPHEMERAL = ['vercel', 'netlify', 'cloudflare', 'railway', 'fly', 'render'];
|
|
141
|
+
if (!EPHEMERAL.includes(profile.stack?.host)) return [];
|
|
142
|
+
const out = [];
|
|
143
|
+
for (const f of repo.files) {
|
|
144
|
+
if (!f.text || !isJS(f.path) && !/\.py$/.test(f.path)) continue;
|
|
145
|
+
const re = /(?:writeFile|writeFileSync|createWriteStream|copyFile)\s*\(\s*([^,)]+)/g;
|
|
146
|
+
let m;
|
|
147
|
+
while ((m = re.exec(f.text))) {
|
|
148
|
+
const target = m[1];
|
|
149
|
+
if (/tmpdir\(|['"]\/tmp|os\.tmp/.test(target)) continue; // /tmp is expected
|
|
150
|
+
if (!/(upload|public|static|storage|media|files|assets)/i.test(target)) continue;
|
|
151
|
+
out.push(finding('DATA-014', `Uploads written to the ${profile.stack.host} filesystem`, 'critical',
|
|
152
|
+
f.path, lineOf(f.text, m.index),
|
|
153
|
+
`${profile.stack.host} gives each deployment a fresh, temporary filesystem. Everything written here disappears the next time you deploy — and users' files disappear with it.`,
|
|
154
|
+
'Write to object storage — S3, R2, or Supabase Storage — and keep only the key in your database.'));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}},
|
|
159
|
+
|
|
160
|
+
{ id: 'UP-001', run(repo, profile) {
|
|
161
|
+
if (!profile.has_file_uploads) return [];
|
|
162
|
+
const out = [];
|
|
163
|
+
for (const f of repo.files) {
|
|
164
|
+
if (!f.text || !isJS(f.path) && !/\.py$/.test(f.path)) continue;
|
|
165
|
+
const re = /\.(?:endsWith|match)\s*\(\s*['"/][^)]*\.(?:jpg|jpeg|png|gif|pdf|svg|webp)/gi;
|
|
166
|
+
let m;
|
|
167
|
+
while ((m = re.exec(f.text))) {
|
|
168
|
+
const around = f.text.slice(Math.max(0, m.index - 600), m.index + 400);
|
|
169
|
+
if (!/(file|upload|attachment|mimetype|originalname|filename)/i.test(around)) continue;
|
|
170
|
+
if (/file-type|magic|sharp\(|imghdr|fileTypeFrom/i.test(around)) continue; // real content check
|
|
171
|
+
out.push(finding('UP-001', 'File type judged by its name', 'high',
|
|
172
|
+
f.path, lineOf(f.text, m.index),
|
|
173
|
+
'The caller chooses the filename, so the extension proves nothing. evil.php.jpg passes this check and is still a PHP file.',
|
|
174
|
+
'Inspect the actual bytes — file-type, sharp, or python-magic — and reject anything that does not match what it claims.'));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return out;
|
|
178
|
+
}},
|
|
179
|
+
|
|
180
|
+
{ id: 'UP-005', run(repo, profile) {
|
|
181
|
+
if (!profile.has_file_uploads) return [];
|
|
182
|
+
// Only an SVG the app ACCEPTS matters. Every project references its own icons,
|
|
183
|
+
// so a bare '.svg' proves nothing at all.
|
|
184
|
+
const ACCEPTS_SVG = [
|
|
185
|
+
/(?:allowed|accepted|valid|permitted|supported)[_A-Za-z]*(?:types?|mimes?|mime_?types?|formats?|extensions?)[^;\n]{0,220}svg/i,
|
|
186
|
+
/accept\s*=\s*["'][^"']*svg/i,
|
|
187
|
+
/fileFilter[\s\S]{0,300}svg/i,
|
|
188
|
+
/image\/svg\+xml[^;\n]{0,120}(?:allow|accept|upload)/i,
|
|
189
|
+
/(?:upload|attachment)[_A-Za-z]*(?:types?|formats?)[^;\n]{0,200}svg/i,
|
|
190
|
+
];
|
|
191
|
+
let site = null;
|
|
192
|
+
for (const f of repo.files) {
|
|
193
|
+
if (!f.text || !/\.(ts|tsx|js|jsx|mjs|py|rb|php|json|ya?ml)$/.test(f.path)) continue;
|
|
194
|
+
const hit = ACCEPTS_SVG.find(re => re.test(f.text));
|
|
195
|
+
if (hit) { site = { f, m: f.text.search(hit) }; break; }
|
|
196
|
+
}
|
|
197
|
+
if (!site) return [];
|
|
198
|
+
if (repo.grep(/DOMPurify|sanitize-svg|svgo|sanitizeSvg|bleach|scrubber/i).length) return [];
|
|
199
|
+
return [finding('UP-005', 'SVG uploads accepted without sanitisation', 'high',
|
|
200
|
+
site.f.path, lineOf(site.f.text, Math.max(site.m, 0)),
|
|
201
|
+
'An SVG is a document, not a picture. It can carry a <script> tag, and served from your own domain that script runs with your users\' sessions.',
|
|
202
|
+
'Sanitise with DOMPurify before storing, or serve user-supplied SVGs from a separate origin.')];
|
|
203
|
+
}},
|
|
204
|
+
|
|
205
|
+
// ---- supabase ------------------------------------------------------------
|
|
206
|
+
{ id: 'SUP-006', run(repo, profile) {
|
|
207
|
+
if (!['supabase', 'postgres'].includes(profile.stack?.database)) return [];
|
|
208
|
+
const out = [];
|
|
209
|
+
for (const f of repo.files) {
|
|
210
|
+
if (!f.text || !/\.sql$/.test(f.path)) continue;
|
|
211
|
+
const re = /CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION[\s\S]{0,900}?SECURITY\s+DEFINER/gi;
|
|
212
|
+
let m;
|
|
213
|
+
while ((m = re.exec(f.text))) {
|
|
214
|
+
const block = m[0];
|
|
215
|
+
if (/SET\s+search_path/i.test(block) ||
|
|
216
|
+
/SET\s+search_path/i.test(f.text.slice(m.index, m.index + block.length + 300))) continue;
|
|
217
|
+
out.push(finding('SUP-006', 'SECURITY DEFINER function with no fixed search_path', 'high',
|
|
218
|
+
f.path, lineOf(f.text, m.index),
|
|
219
|
+
'The function runs with its owner\'s privileges. Without a pinned search_path, a caller who can create objects can shadow a table name and have their version run as the owner.',
|
|
220
|
+
"Add: SET search_path = public, pg_temp;"));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}},
|
|
225
|
+
|
|
226
|
+
{ id: 'SUP-011', run(repo, profile) {
|
|
227
|
+
if (profile.stack?.database !== 'supabase') return [];
|
|
228
|
+
if (!['vercel', 'netlify', 'cloudflare'].includes(profile.stack?.host)) return [];
|
|
229
|
+
const hits = repo.grep(/db\.[a-z0-9]+\.supabase\.co:5432|:5432\/postgres/i,
|
|
230
|
+
/\.(ts|js|mjs|env|ya?ml|json)$/);
|
|
231
|
+
if (!hits.length) return [];
|
|
232
|
+
const f = hits[0];
|
|
233
|
+
return [finding('SUP-011', 'Direct database port used from a serverless host', 'high',
|
|
234
|
+
f.path, lineOf(f.text, f.text.search(/5432/)),
|
|
235
|
+
'Every serverless invocation opens its own connection. Against port 5432 you exhaust the connection limit under quite ordinary traffic, and the database starts refusing everyone.',
|
|
236
|
+
'Use the pooler connection string on port 6543 instead.')];
|
|
237
|
+
}},
|
|
238
|
+
|
|
239
|
+
// ---- tenancy -------------------------------------------------------------
|
|
240
|
+
{ id: 'TEN-003', run(repo, profile) {
|
|
241
|
+
if (profile.tenancy !== 'multi-tenant-shared-db') return [];
|
|
242
|
+
const inv = repo.grep(/invit(e|ation)/i, /\.(sql|prisma|ts|js|rb|py)$/);
|
|
243
|
+
if (!inv.length) return [];
|
|
244
|
+
const hasExpiry = inv.some(f => /expires?_?at|expiry|valid_?until|expires_in/i.test(f.text));
|
|
245
|
+
if (hasExpiry) return [];
|
|
246
|
+
return [finding('TEN-003', 'Invitation tokens never expire', 'high',
|
|
247
|
+
inv[0].path, 1,
|
|
248
|
+
'An invite link that works forever is a permanent key to your organisation. Forwarded emails, old inboxes and leaked screenshots all stay valid.',
|
|
249
|
+
'Add expires_at, default it to a few days, and check it on acceptance.')];
|
|
250
|
+
}},
|
|
251
|
+
|
|
252
|
+
// ---- real users ----------------------------------------------------------
|
|
253
|
+
{ id: 'RU-013', run(repo, profile) {
|
|
254
|
+
if (!profile.sends_email || !profile.is_public) return [];
|
|
255
|
+
const templates = repo.grep(/<html|<body|MIMEText|render_to_string/i, /\.(html|erb|tsx|jsx|ts|js|py|rb)$/)
|
|
256
|
+
.filter(f => /(email|mail|newsletter|campaign|template)/i.test(f.path));
|
|
257
|
+
if (!templates.length) return [];
|
|
258
|
+
if (templates.some(f => /unsubscribe|opt[-_]?out|List-Unsubscribe/i.test(f.text))) return [];
|
|
259
|
+
return [finding('RU-013', 'Bulk email with no unsubscribe path', 'high',
|
|
260
|
+
templates[0].path, 1,
|
|
261
|
+
'Marketing email without a working opt-out is a per-message violation under CAN-SPAM and the GDPR, and it is also how a domain gets blacklisted by mailbox providers.',
|
|
262
|
+
'Add an unsubscribe link to every non-transactional template and a List-Unsubscribe header.')];
|
|
263
|
+
}},
|
|
264
|
+
|
|
265
|
+
{ id: 'RU-007', run(repo, profile) {
|
|
266
|
+
if (!profile.is_public) return [];
|
|
267
|
+
if (!['web-app', 'web-site'].includes(profile.surface)) return [];
|
|
268
|
+
const html = repo.files.filter(f => f.text &&
|
|
269
|
+
/\.(html|tsx|jsx|erb|vue|svelte)$/.test(f.path) &&
|
|
270
|
+
/<head|export default function RootLayout|<!DOCTYPE/i.test(f.text));
|
|
271
|
+
if (!html.length) return [];
|
|
272
|
+
const all = html.map(f => f.text).join('\n') +
|
|
273
|
+
(repo.grep(/viewport/i, /\.(ts|tsx|js|jsx)$/).map(f => f.text).join('\n'));
|
|
274
|
+
if (/viewport/i.test(all)) return [];
|
|
275
|
+
return [finding('RU-007', 'No viewport meta tag', 'high',
|
|
276
|
+
html[0].path, 1,
|
|
277
|
+
'Without it, phones render the page at desktop width and scale it down. Most of your visitors will arrive on a phone and see something they have to pinch to read.',
|
|
278
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">')];
|
|
279
|
+
}},
|
|
280
|
+
|
|
281
|
+
// ---- observability & legal ----------------------------------------------
|
|
282
|
+
{ id: 'OBS-001', run(repo, profile) {
|
|
283
|
+
if (profile.stage !== 'production') return [];
|
|
284
|
+
if (profile.surface === 'library') return [];
|
|
285
|
+
const has = repo.grep(/@sentry|bugsnag|rollbar|honeybadger|datadog|new relic|appsignal|@opentelemetry/i,
|
|
286
|
+
/\.(ts|tsx|js|jsx|mjs|py|rb|json|ya?ml)$/).length;
|
|
287
|
+
if (has) return [];
|
|
288
|
+
const anchor = repo.files.find(f => f.text && /package\.json|requirements.*\.txt|Gemfile$/.test(f.path));
|
|
289
|
+
if (!anchor) return [];
|
|
290
|
+
return [finding('OBS-001', 'Nothing reports errors from production', 'high',
|
|
291
|
+
anchor.path, 1,
|
|
292
|
+
'When this breaks for a user, you find out when they tell you — if they bother. Most people just leave.',
|
|
293
|
+
'Add an error reporter. Sentry\'s free tier covers a small product entirely.')];
|
|
294
|
+
}},
|
|
295
|
+
|
|
296
|
+
{ id: 'LEG-001', run(repo, profile) {
|
|
297
|
+
if (profile.data_sensitivity === 'none' || !profile.is_public) return [];
|
|
298
|
+
if (!profile.has_accounts) return []; // a real product with users, not a template
|
|
299
|
+
const has = repo.has(/privacy[-_]?policy|privacy\.(html|tsx|jsx|md|erb|vue)|\/privacy\//i) ||
|
|
300
|
+
repo.grep(/privacy policy/i, /\.(tsx|jsx|html|erb|md|vue|svelte)$/).length > 0;
|
|
301
|
+
if (has) return [];
|
|
302
|
+
return [finding('LEG-001', 'No privacy policy, but you collect personal data', 'high',
|
|
303
|
+
'privacy policy', 1,
|
|
304
|
+
'The scan found personal fields in your schema and no privacy policy anywhere in the project. In the EU, UK, California and elsewhere the policy is required from the first email address you store — and app stores reject submissions without one.',
|
|
305
|
+
'Publish one and link it in your footer. It has to name what you collect, why, who else receives it, and how someone deletes it.')];
|
|
306
|
+
}},
|
|
307
|
+
|
|
308
|
+
// ---- mobile --------------------------------------------------------------
|
|
309
|
+
{ id: 'MOB-001', run(repo, profile) {
|
|
310
|
+
if (!['mobile-ios', 'mobile-android'].includes(profile.surface)) return [];
|
|
311
|
+
const out = [];
|
|
312
|
+
const PATTERNS = [
|
|
313
|
+
[/\bsk-ant-[A-Za-z0-9_-]{20,}/g, 'an Anthropic key'],
|
|
314
|
+
[/\bsk-[A-Za-z0-9]{32,}/g, 'an OpenAI key'],
|
|
315
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, 'an AWS access key'],
|
|
316
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/g, 'a Google API key'],
|
|
317
|
+
];
|
|
318
|
+
for (const f of repo.files) {
|
|
319
|
+
if (!f.text || !/\.(swift|kt|java|m|h|xml|plist|dart)$/.test(f.path)) continue;
|
|
320
|
+
for (const [re, label] of PATTERNS) {
|
|
321
|
+
let m; re.lastIndex = 0;
|
|
322
|
+
while ((m = re.exec(f.text))) {
|
|
323
|
+
out.push(finding('MOB-001', `${label} compiled into the app`, 'critical',
|
|
324
|
+
f.path, lineOf(f.text, m.index),
|
|
325
|
+
'A shipped binary is a public file. Anyone can download the app and read the strings out of it in about a minute — obfuscation only slows that down.',
|
|
326
|
+
'Move the call behind your own server and rotate the key. Treat it as already public.'));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}},
|
|
332
|
+
|
|
333
|
+
{ id: 'MOB-002', run(repo, profile) {
|
|
334
|
+
if (!['mobile-ios', 'mobile-android'].includes(profile.surface)) return [];
|
|
335
|
+
if (!profile.has_accounts) return [];
|
|
336
|
+
const out = [];
|
|
337
|
+
for (const f of repo.files) {
|
|
338
|
+
if (!f.text || !/\.(swift|kt|java|dart)$/.test(f.path)) continue;
|
|
339
|
+
const re = /(UserDefaults\.standard\.set|SharedPreferences[\s\S]{0,120}?\.putString|prefs\.setString)/g;
|
|
340
|
+
let m;
|
|
341
|
+
while ((m = re.exec(f.text))) {
|
|
342
|
+
const line = lineAt(f.text, m.index);
|
|
343
|
+
if (!/token|jwt|session|password|secret|credential|refresh/i.test(line)) continue;
|
|
344
|
+
out.push(finding('MOB-002', 'Credential stored outside the secure store', 'high',
|
|
345
|
+
f.path, lineOf(f.text, m.index),
|
|
346
|
+
'UserDefaults and SharedPreferences are plain files. On a rooted or jailbroken device, and in some backups, they are readable by other software.',
|
|
347
|
+
'Use Keychain on iOS or EncryptedSharedPreferences / Keystore on Android.'));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}},
|
|
352
|
+
|
|
353
|
+
// ---- deployment ----------------------------------------------------------
|
|
354
|
+
{ id: 'DEP-001', run(repo, profile) {
|
|
355
|
+
if (!['pre-launch', 'production'].includes(profile.stage)) return [];
|
|
356
|
+
const out = [];
|
|
357
|
+
for (const f of repo.files) {
|
|
358
|
+
if (!f.text || !/(next\.config|vite\.config|nuxt\.config|webpack\.config)/.test(f.path)) continue;
|
|
359
|
+
const re = /(?:define|env)\s*:\s*\{[\s\S]{0,400}?process\.env\.([A-Z0-9_]*(?:SECRET|KEY|TOKEN|PASSWORD)[A-Z0-9_]*)/g;
|
|
360
|
+
let m;
|
|
361
|
+
while ((m = re.exec(f.text))) {
|
|
362
|
+
if (m[1].startsWith('NEXT_PUBLIC_') || m[1].startsWith('VITE_')) continue;
|
|
363
|
+
out.push(finding('DEP-001', `${m[1]} baked into the build`, 'high',
|
|
364
|
+
f.path, lineOf(f.text, m.index),
|
|
365
|
+
'Build-time substitution writes the literal value into the compiled output. It ends up in your bundle, and rotating the key means rebuilding rather than restarting.',
|
|
366
|
+
'Read it at runtime on the server instead of injecting it at build time.'));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}},
|
|
371
|
+
|
|
372
|
+
{ id: 'DEP-008', run(repo, profile) {
|
|
373
|
+
if (profile.stage !== 'production') return [];
|
|
374
|
+
if (!['web-app', 'api-only'].includes(profile.surface)) return [];
|
|
375
|
+
const has = repo.has(/\/(health|healthz|_health|ping|status)\b/) ||
|
|
376
|
+
repo.grep(/['"]\/(health|healthz|ping|status)['"]/).length > 0;
|
|
377
|
+
if (has) return [];
|
|
378
|
+
const anchor = repo.files.find(f => f.text && isServer(f.path) && isJS(f.path));
|
|
379
|
+
if (!anchor) return [];
|
|
380
|
+
return [finding('DEP-008', 'No health endpoint', 'medium',
|
|
381
|
+
anchor.path, 1,
|
|
382
|
+
'Your host, your load balancer and any uptime monitor all need one path that answers "am I alive". Without it, a hung process keeps receiving traffic.',
|
|
383
|
+
'Add GET /health returning 200 once the app can actually serve — including its database connection.')];
|
|
384
|
+
}},
|
|
385
|
+
];
|