launchprep 0.5.5 → 0.5.7
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 +58 -4
- package/net/client.mjs +14 -2
- package/net/commands.mjs +77 -5
- package/package.json +1 -1
- package/src/checks-auth.mjs +12 -3
- package/src/checks-batch3.mjs +25 -5
- package/src/checks-batch4.mjs +22 -3
- package/src/checks-deploy.mjs +20 -11
- package/src/checks-injection.mjs +7 -1
- package/src/checks.mjs +31 -3
- package/src/detect.mjs +14 -1
package/README.md
CHANGED
|
@@ -1,6 +1,43 @@
|
|
|
1
1
|
# launchprep
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Is your AI-built app safe to launch?** Launchprep reads your codebase, works
|
|
4
|
+
out what you actually built, and reports what will break when real users arrive
|
|
5
|
+
— leaked keys, an exposed database, runaway AI spend, GDPR gaps. Every finding
|
|
6
|
+
names the consequence in plain English, with a file, a line, and the fix.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npx launchprep
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
No signup, no dashboard, nothing added to your repo. The free scan runs entirely
|
|
13
|
+
on your machine and uploads nothing.
|
|
14
|
+
|
|
15
|
+
*(`npx` fetches it, runs it once and cleans up — there is nothing to install.
|
|
16
|
+
npm shows an `npm i` line above because it shows one for every package; you do
|
|
17
|
+
not need it unless you want the command available offline.)*
|
|
18
|
+
|
|
19
|
+
## Built with Cursor, Claude, Lovable, v0, Bolt or Replit?
|
|
20
|
+
|
|
21
|
+
That is who this is for. The code works — that is not the same as being safe to
|
|
22
|
+
put in front of people. Launchprep is the pre-launch check for founders who are
|
|
23
|
+
not security engineers: it detects your stack (Next.js, Django, Rails, Express,
|
|
24
|
+
Supabase, Postgres), runs only the checks that can apply to it, and tells you
|
|
25
|
+
what it skipped and why.
|
|
26
|
+
|
|
27
|
+
## A finding looks like this
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
CRITICAL Server-only secret exposed to the client bundle
|
|
31
|
+
apps/web/.env.local:4
|
|
32
|
+
The NEXT_PUBLIC_ prefix makes this key PUBLIC — it ships in the
|
|
33
|
+
JavaScript every visitor downloads. The name reads like it is
|
|
34
|
+
protected. It is not.
|
|
35
|
+
Fix Rename it without the prefix and read it only on the server.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Not "missing authorization predicate". The consequence, the file, the line, the fix.
|
|
39
|
+
|
|
40
|
+
## Commands
|
|
4
41
|
|
|
5
42
|
```bash
|
|
6
43
|
npx launchprep # the free checks — runs here, uploads nothing
|
|
@@ -9,11 +46,9 @@ npx launchprep deep # the paid scan — asks before sending anything
|
|
|
9
46
|
npx launchprep whoami # scans remaining
|
|
10
47
|
```
|
|
11
48
|
|
|
12
|
-
No signup. No dashboard. Nothing added to your repo.
|
|
13
|
-
|
|
14
49
|
## What it does
|
|
15
50
|
|
|
16
|
-
|
|
51
|
+
290 checks — but it works out what you built **before** running any of them, so
|
|
17
52
|
you are not told about problems you cannot have. A static site is asked 48
|
|
18
53
|
questions; a multi-tenant SaaS with payments and AI is asked 158.
|
|
19
54
|
|
|
@@ -103,6 +138,14 @@ fail the build. On a deep scan, checks that did not complete fail it too — a
|
|
|
103
138
|
check that could not run has not passed. A path that does not exist always
|
|
104
139
|
exits 2: a scan of nothing must never look like a clean scan of something.
|
|
105
140
|
|
|
141
|
+
## Mapped to published standards
|
|
142
|
+
|
|
143
|
+
Every check carries its place in the **OWASP Top 10**, the **OWASP API Security
|
|
144
|
+
Top 10**, the **OWASP Top 10 for LLM Applications**, **ASVS** chapters and
|
|
145
|
+
**CWE** — and to the privacy law that reaches your users (**GDPR**, UK GDPR,
|
|
146
|
+
**CCPA**). Not name-dropped: the mapping is per rule, and a build step fails if
|
|
147
|
+
any rule is unmapped.
|
|
148
|
+
|
|
106
149
|
## Free and paid
|
|
107
150
|
|
|
108
151
|
Everything above is free, unlimited, forever — every check a program can make on
|
|
@@ -118,3 +161,14 @@ delete. See <https://launchprep.dev>.
|
|
|
118
161
|
Proprietary — see the LICENSE file. Run it on anything you are authorised to
|
|
119
162
|
scan; the rules and checks may not be copied into other products. Versions
|
|
120
163
|
0.1.0 and 0.2.0 were published under MIT and remain so.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
**Scan your app before your users do:**
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npx launchprep
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Docs and the full check catalogue: <https://launchprep.dev> ·
|
|
174
|
+
Questions: <https://launchprep.dev/faq>
|
package/net/client.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { BRAND } from '../src/brand.mjs';
|
|
3
3
|
const BASE = process.env.LAUNCHPREP_API || BRAND.api;
|
|
4
4
|
|
|
5
|
-
async function call(path, { key, body, method = 'POST' } = {}) {
|
|
5
|
+
async function call(path, { key, body, method = 'POST', timeoutMs } = {}) {
|
|
6
6
|
let res;
|
|
7
7
|
try {
|
|
8
8
|
res = await fetch(BASE + path, {
|
|
@@ -15,7 +15,7 @@ async function call(path, { key, body, method = 'POST' } = {}) {
|
|
|
15
15
|
// A deep scan is minutes of model time, not seconds. But it is not
|
|
16
16
|
// forever either - API-010, our own rule: nothing outbound should be able
|
|
17
17
|
// to hang indefinitely.
|
|
18
|
-
signal: AbortSignal.timeout(Number(process.env.LAUNCHPREP_TIMEOUT_MS || 900_000)),
|
|
18
|
+
signal: AbortSignal.timeout(timeoutMs || Number(process.env.LAUNCHPREP_TIMEOUT_MS || 900_000)),
|
|
19
19
|
});
|
|
20
20
|
} catch (e) {
|
|
21
21
|
if (e.name === 'TimeoutError') throw new Error('launchprep.dev did not answer in time');
|
|
@@ -31,6 +31,13 @@ export const validate = (key) => call('/v1/validate', { key });
|
|
|
31
31
|
|
|
32
32
|
export const deepScan = ({ key, digest, profile, ruleIds, appName, idempotencyKey }) =>
|
|
33
33
|
call('/v1/scan', { key, body: {
|
|
34
|
+
// Ask for a job rather than an answer. A deep scan takes minutes and a
|
|
35
|
+
// hosting proxy will cut a request long before it finishes - which is
|
|
36
|
+
// exactly what happened: the scan ran, the money was spent, and the customer
|
|
37
|
+
// got a 502. The server now replies with an id in under a second and we ask
|
|
38
|
+
// for the result afterwards. Older servers ignore this field and answer the
|
|
39
|
+
// old way, so this is safe to send at any of them.
|
|
40
|
+
async: true,
|
|
34
41
|
digest: digest.text,
|
|
35
42
|
profile,
|
|
36
43
|
rule_ids: ruleIds,
|
|
@@ -45,4 +52,9 @@ export const deepScan = ({ key, digest, profile, ruleIds, appName, idempotencyKe
|
|
|
45
52
|
files_skipped: digest.coverage?.filesSkipped ?? null,
|
|
46
53
|
}});
|
|
47
54
|
|
|
55
|
+
// One poll. Short timeout on purpose: this is a small GET, and if it fails we
|
|
56
|
+
// simply ask again on the next tick rather than giving up on the whole scan.
|
|
57
|
+
export const scanStatus = ({ key, scanId }) =>
|
|
58
|
+
call(`/v1/scan/${scanId}`, { key, method: 'GET', timeoutMs: 30_000 });
|
|
59
|
+
|
|
48
60
|
export { BASE };
|
package/net/commands.mjs
CHANGED
|
@@ -6,11 +6,16 @@ import { basename, resolve } from 'node:path';
|
|
|
6
6
|
import { BRAND } from '../src/brand.mjs';
|
|
7
7
|
import { scanRepo } from '../src/fs-scan.mjs';
|
|
8
8
|
import { detectProfile, toGateProfile } from '../src/detect.mjs';
|
|
9
|
-
import { gate } from '../src/gate.mjs';
|
|
9
|
+
import { gate, allRules } from '../src/gate.mjs';
|
|
10
10
|
import { buildDigest } from '../src/digest.mjs';
|
|
11
11
|
import { save, load, clear, FILE } from './credentials.mjs';
|
|
12
12
|
import { confirmUpload } from './consent.mjs';
|
|
13
|
-
import { validate, deepScan, BASE } from './client.mjs';
|
|
13
|
+
import { validate, deepScan, scanStatus, BASE } from './client.mjs';
|
|
14
|
+
|
|
15
|
+
// Waiting between polls. setTimeout only - the read-only guard forbids the
|
|
16
|
+
// scanner opening anything, and this file is the paid half where a network
|
|
17
|
+
// call is allowed, but a timer is all this needs.
|
|
18
|
+
const sleep = (ms) => new Promise(done => setTimeout(done, ms));
|
|
14
19
|
|
|
15
20
|
const C = { b:'\x1b[1m', d:'\x1b[2m', g:'\x1b[90m', o:'\x1b[38;5;209m',
|
|
16
21
|
grn:'\x1b[32m', red:'\x1b[31m', yel:'\x1b[33m', off:'\x1b[0m' };
|
|
@@ -80,7 +85,11 @@ async function deep(args) {
|
|
|
80
85
|
const digest = buildDigest(repo, { maxTokens: 150_000, profile });
|
|
81
86
|
|
|
82
87
|
console.log(` ${C.g}what it is ${C.off}${profile.surface} · ${[profile.stack.framework, profile.stack.database, profile.stack.host].filter(Boolean).join(' · ') || '—'}`);
|
|
83
|
-
|
|
88
|
+
// The denominator must be the DEEP total. evaluated+skipped mixes tier 1 and
|
|
89
|
+
// tier 2 and printed "76 of 250" - a number matching nothing the customer has
|
|
90
|
+
// been told (290 total, 173 deep, 117 free).
|
|
91
|
+
const deepTotal = allRules().filter(r => r.tier === 2).length;
|
|
92
|
+
console.log(` ${C.g}deep checks ${C.off}${tier2.length} of ${deepTotal} apply`);
|
|
84
93
|
|
|
85
94
|
const okToSend = await confirmUpload({ digest, host: new URL(BASE).host, assumeYes: flag(args, 'yes') });
|
|
86
95
|
if (!okToSend) process.exit(1);
|
|
@@ -92,9 +101,48 @@ async function deep(args) {
|
|
|
92
101
|
.update(digest.text).update(tier2.map(r => r.id).join(',')).digest('hex').slice(0, 32);
|
|
93
102
|
|
|
94
103
|
console.log(` ${C.d}scanning — this takes a few minutes${C.off}`);
|
|
95
|
-
|
|
104
|
+
let r = await deepScan({ key: c.key, digest, profile,
|
|
96
105
|
ruleIds: tier2.map(x => x.id), appName: basename(target), idempotencyKey: idem });
|
|
97
106
|
|
|
107
|
+
// 202 means the server took the job and is holding the answer for us. Poll for
|
|
108
|
+
// it rather than keep one request open for ten minutes: a hosting proxy cuts a
|
|
109
|
+
// long request and returns 502, which cost a real customer a real scan. It also
|
|
110
|
+
// means the wait is no longer silent - there is a count to show.
|
|
111
|
+
if (r.status === 202 && r.data?.scanId) {
|
|
112
|
+
const scanId = r.data.scanId;
|
|
113
|
+
const started = Date.now();
|
|
114
|
+
const tty = process.stdout.isTTY;
|
|
115
|
+
let shown = '';
|
|
116
|
+
for (;;) {
|
|
117
|
+
await sleep(4000);
|
|
118
|
+
const p = await scanStatus({ key: c.key, scanId });
|
|
119
|
+
// A failed poll is not a failed scan - the scan runs on the server whatever
|
|
120
|
+
// this one request did. Keep asking, and only give up after a long while.
|
|
121
|
+
if (!p.ok || !p.data) {
|
|
122
|
+
if (Date.now() - started > 20 * 60_000) {
|
|
123
|
+
if (tty && shown) process.stdout.write('\r\x1b[2K');
|
|
124
|
+
console.error(`\n ${C.red}Lost contact with the scan.${C.off}`);
|
|
125
|
+
console.error(` ${C.d}It may still finish. Run this again in a minute — if it did,${C.off}`);
|
|
126
|
+
console.error(` ${C.d}you get its report without spending another scan.${C.off}\n`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const st = p.data.status;
|
|
132
|
+
if (st === 'running') {
|
|
133
|
+
const pr = p.data.progress;
|
|
134
|
+
const line = pr && pr.total
|
|
135
|
+
? ` ${C.d}checking — ${pr.done} of ${pr.total} groups done${C.off}`
|
|
136
|
+
: ` ${C.d}checking — reading your code${C.off}`;
|
|
137
|
+
if (tty && line !== shown) { process.stdout.write('\r\x1b[2K' + line); shown = line; }
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (tty && shown) process.stdout.write('\r\x1b[2K');
|
|
141
|
+
r = { status: st === 'done' ? 200 : 500, ok: st === 'done', data: p.data };
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
98
146
|
if (r.status === 429) {
|
|
99
147
|
console.error(`\n ${C.red}Too many requests.${C.off} ${C.d}Try again in ${backoff(r)}. No scan was used.${C.off}\n`);
|
|
100
148
|
process.exit(1);
|
|
@@ -127,11 +175,35 @@ async function deep(args) {
|
|
|
127
175
|
}
|
|
128
176
|
if (!r.ok) {
|
|
129
177
|
console.error(`\n ${C.red}The scan did not run${C.off} ${C.d}(${r.status})${C.off} ${r.data?.detail || r.data?.error || ''}`);
|
|
130
|
-
|
|
178
|
+
// A proxy 5xx carries NO body, so r.data is undefined and this reassurance
|
|
179
|
+
// never printed - the customer was left wondering whether one of their five
|
|
180
|
+
// had just been spent on nothing. Say it either way, and say how to check:
|
|
181
|
+
// that is the only question they have at this moment.
|
|
182
|
+
if (r.data?.refunded) {
|
|
183
|
+
console.error(` ${C.grn}Your scan was not used.${C.off}`);
|
|
184
|
+
} else if (r.status >= 500 || r.status === 0) {
|
|
185
|
+
console.error(` ${C.d}A scan only counts once it finishes. One that was started${C.off}`);
|
|
186
|
+
console.error(` ${C.d}but never returned is released, and comes back to you.${C.off}`);
|
|
187
|
+
console.error(` ${C.d}Check with${C.off} npx ${BRAND.slug} whoami${C.d} — and run this${C.off}`);
|
|
188
|
+
console.error(` ${C.d}again: if the scan did finish, you get its report free.${C.off}`);
|
|
189
|
+
}
|
|
131
190
|
console.error('');
|
|
132
191
|
process.exit(1);
|
|
133
192
|
}
|
|
134
193
|
|
|
194
|
+
// A dead replay comes back as HTTP 200 carrying { status: 'failed' } - the
|
|
195
|
+
// request succeeded, the SCAN did not. Every guard above tests r.ok, so this
|
|
196
|
+
// sailed through and rendered as a clean scan with zero findings: the worst
|
|
197
|
+
// possible answer, because "0 findings" reads as "you are fine". Observed on a
|
|
198
|
+
// real licence twice. A scan that did not finish must never render as one that
|
|
199
|
+
// found nothing.
|
|
200
|
+
if (r.data?.status === 'failed') {
|
|
201
|
+
console.error(`\n ${C.red}That scan did not finish.${C.off} ${C.d}${r.data.error || ''}${C.off}`);
|
|
202
|
+
if (r.data.refunded) console.error(` ${C.grn}It was not counted — your scans are intact.${C.off}`);
|
|
203
|
+
console.error(` ${C.d}Run this again to start a fresh one.${C.off}\n`);
|
|
204
|
+
process.exit(1);
|
|
205
|
+
}
|
|
206
|
+
|
|
135
207
|
const findings = r.data.findings || [];
|
|
136
208
|
const rank = { critical:0, high:1, medium:2, low:3 };
|
|
137
209
|
findings.sort((a, b) => rank[a.severity] - rank[b.severity]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchprep",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.7",
|
|
4
4
|
"description": "Read-only CLI that scans your AI-built app and reports what will break before real users hit it — auth, tenant isolation, data, payments, GDPR. 117 checks run free on your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/checks-auth.mjs
CHANGED
|
@@ -264,9 +264,18 @@ export const AUTH_CHECKS = [
|
|
|
264
264
|
if (!profile.is_public || !['web-app', 'web-site'].includes(profile.surface)) return [];
|
|
265
265
|
const all = repo.files.filter(f => f.text).map(f => f.text).join('\n');
|
|
266
266
|
const missing = [];
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
267
|
+
// Match the MIDDLEWARE that sets each header, not only the literal header
|
|
268
|
+
// string. helmet.noSniff() IS X-Content-Type-Options; helmet.frameguard() IS
|
|
269
|
+
// X-Frame-Options; a bare helmet() sets all three. Grepping for the literal
|
|
270
|
+
// only meant this fired on apps that had done it the standard way - i.e. it
|
|
271
|
+
// misfired precisely on the people who got it right.
|
|
272
|
+
const helmetAll = /\bhelmet\s*\(\s*\)|\bapp\.use\s*\(\s*helmet\s*[,)]|require\(['"]helmet['"]\)\s*\(\s*\)/i.test(all);
|
|
273
|
+
if (!/Content-Security-Policy|helmet\.contentSecurityPolicy|CSP_DEFAULT_SRC|content_security_policy/i.test(all) && !helmetAll)
|
|
274
|
+
missing.push('Content-Security-Policy');
|
|
275
|
+
if (!/X-Content-Type-Options|helmet\.noSniff|SECURE_CONTENT_TYPE_NOSNIFF|secure_headers/i.test(all) && !helmetAll)
|
|
276
|
+
missing.push('X-Content-Type-Options');
|
|
277
|
+
if (!/(X-Frame-Options|frame-ancestors|helmet\.frameguard|X_FRAME_OPTIONS|secure_headers)/i.test(all) && !helmetAll)
|
|
278
|
+
missing.push('X-Frame-Options');
|
|
270
279
|
if (missing.length < 2) return [];
|
|
271
280
|
const f = repo.files.find(x => /(next\.config|middleware\.[tj]s|server\.[tj]s|vercel\.json|netlify\.toml)/.test(x.path));
|
|
272
281
|
if (!f) return [];
|
package/src/checks-batch3.mjs
CHANGED
|
@@ -69,9 +69,22 @@ export const BATCH3_CHECKS = [
|
|
|
69
69
|
if (!profile.has_migrations || profile.stage !== 'production') return [];
|
|
70
70
|
const out = [];
|
|
71
71
|
const dirs = repo.find(/migrations?\//);
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
if (!dirs.length) return [];
|
|
73
|
+
// Django reverses CreateModel/AddField/AlterField itself - there is no
|
|
74
|
+
// "down" to write, and looking for Alembic's `def downgrade` or Rails'
|
|
75
|
+
// `def self.down` in a Django project is looking for a convention that
|
|
76
|
+
// does not exist there. It fired on 100% of Django repos. Only a
|
|
77
|
+
// RunPython/RunSQL without its reverse is genuinely irreversible.
|
|
78
|
+
const isDjango = dirs.some(f => /\.py$/.test(f.path));
|
|
79
|
+
if (isDjango) {
|
|
80
|
+
const irreversible = repo.grep(
|
|
81
|
+
/RunPython\((?![^)]*reverse_code)|RunSQL\((?![^)]*reverse_sql)/, /\.py$/).length;
|
|
82
|
+
if (!irreversible) return [];
|
|
83
|
+
} else {
|
|
84
|
+
const hasDown = dirs.some(f => /down|rollback|revert/i.test(f.path)) ||
|
|
85
|
+
repo.grep(/def downgrade|\.down\s*=|DROP.*-- rollback|def self\.down/i, /\.(py|rb|ts|js|sql)$/).length;
|
|
86
|
+
if (hasDown) return [];
|
|
87
|
+
}
|
|
75
88
|
return [finding('DEP-005', 'Migrations have no rollback path', 'medium',
|
|
76
89
|
dirs[0].path, 1,
|
|
77
90
|
'A migration that fails halfway through production leaves the schema in a state nothing knows how to undo — under pressure, at the worst possible moment.',
|
|
@@ -236,8 +249,15 @@ export const BATCH3_CHECKS = [
|
|
|
236
249
|
{ id: 'RU-010', scope: 'root', run(repo, profile) {
|
|
237
250
|
if (!profile.is_public || profile.stage !== 'production') return [];
|
|
238
251
|
if (profile.surface === 'library') return [];
|
|
239
|
-
|
|
240
|
-
|
|
252
|
+
// A contact route is a contact route. Grepping only for role aliases
|
|
253
|
+
// (support@/help@/contact@) denied that a project had any way to reach it
|
|
254
|
+
// while a real address sat in its README - which is how most founders
|
|
255
|
+
// actually do it, with their own name. Accept any address, a Slack/Discord
|
|
256
|
+
// invite, or a Support/Contact heading. `git@github.com` is a remote, not a
|
|
257
|
+
// contact, so it is excluded.
|
|
258
|
+
const has = repo.grep(
|
|
259
|
+
/mailto:|(?!git@)[\w.+-]+@[\w-]+\.[a-z]{2,}|slack\.com\/(?:archives|invite)|discord\.gg|\/contact\b|#{1,3}\s*(?:Support|Contact)\b/i,
|
|
260
|
+
/\.(tsx|jsx|html|erb|vue|svelte|md|py|rb|txt)$/).length;
|
|
241
261
|
if (has) return [];
|
|
242
262
|
return [finding('RU-010', 'No way for a customer to reach you', 'medium',
|
|
243
263
|
'contact details', 1,
|
package/src/checks-batch4.mjs
CHANGED
|
@@ -110,10 +110,25 @@ export const BATCH4_CHECKS = [
|
|
|
110
110
|
const cols = []; let total = 0, at = null;
|
|
111
111
|
for (const f of sql) {
|
|
112
112
|
let m;
|
|
113
|
-
|
|
113
|
+
// `ALTER TABLE x ADD COLUMN IF NOT EXISTS col uuid REFERENCES y(id)` puts SQL
|
|
114
|
+
// keywords at the start of the line, so "^\s*(word)" captured ADD and the
|
|
115
|
+
// finding read "foreign keys ... add, fact_id". Skip the DDL preamble before
|
|
116
|
+
// taking the column name.
|
|
117
|
+
// TWO passes, and the order matters. An explicit `FOREIGN KEY (col)` clause is
|
|
118
|
+
// unambiguous, so take those FIRST and only fall back to the inline
|
|
119
|
+
// `col type REFERENCES other(id)` form on lines that have no such clause.
|
|
120
|
+
// Running one combined regex let the inline branch match earlier in the string
|
|
121
|
+
// and win on Drizzle output - `ALTER TABLE "t" ADD CONSTRAINT "..." FOREIGN KEY
|
|
122
|
+
// ("org_id") REFERENCES ...` reported the column as "ALTER". Same class of bug
|
|
123
|
+
// as the ADD COLUMN one: the line starts with SQL keywords, not a column.
|
|
124
|
+
const fkExplicit = /FOREIGN\s+KEY\s*\(\s*"?([A-Za-z0-9_]+)"?\s*\)/gi;
|
|
125
|
+
const fkInline = /^\s*(?:ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?)?"?([A-Za-z0-9_]+)"?\s+[A-Za-z]+[^,\n]*\bREFERENCES\b/gim;
|
|
126
|
+
const fk = /FOREIGN\s+KEY\s*\(/i.test(f.text) ? fkExplicit : fkInline;
|
|
114
127
|
while ((m = fk.exec(f.text))) {
|
|
115
128
|
const col = (m[1] || m[2] || '').toLowerCase();
|
|
116
|
-
|
|
129
|
+
// belt and braces: a SQL keyword is never a column name
|
|
130
|
+
const SQL_KW = new Set(['add','column','constraint','table','alter','create','if','not','exists','key','foreign','primary','unique']);
|
|
131
|
+
if (!col || SQL_KW.has(col) || indexed.has(col) || cols.includes(col)) continue;
|
|
117
132
|
if (!at) at = { path: f.path, line: lineOf(f.text, m.index) };
|
|
118
133
|
total++; cols.push(col);
|
|
119
134
|
}
|
|
@@ -241,7 +256,11 @@ export const BATCH4_CHECKS = [
|
|
|
241
256
|
if (!t || !/\.(ts|tsx|js|jsx|mjs|py|rb|php)$/.test(f.path)) continue;
|
|
242
257
|
const m = t.match(/(?:image\/jpe?g|image\/heic|\.jpe?g['"]|\.heic['"])/i);
|
|
243
258
|
if (!m) continue;
|
|
244
|
-
|
|
259
|
+
// Require evidence of a USER upload, not merely a file that writes jpegs.
|
|
260
|
+
// `storage` / `.save(` / `createReadStream` matched a video frame writer that
|
|
261
|
+
// emits f000001.jpg - no user, no upload, no EXIF to leak. Read the context,
|
|
262
|
+
// not the extension.
|
|
263
|
+
if (!/\b(?:multer|formidable|busboy|uploadedFile|req\.files?\b|FormData|multipart\/form-data|putObject|presigned|\.upload\s*\()/i.test(t)) continue;
|
|
245
264
|
return [finding('UP-008', 'Photo uploads keep their GPS coordinates', 'medium',
|
|
246
265
|
f.path, lineOf(t, t.indexOf(m[0])),
|
|
247
266
|
'A photo taken on a phone carries the exact place and time it was taken. Stored and served as-is, anyone who downloads a user\'s picture learns where they live.',
|
package/src/checks-deploy.mjs
CHANGED
|
@@ -168,18 +168,27 @@ export const DEPLOY_CHECKS = [
|
|
|
168
168
|
// ---- no body size limit ---------------------------------------------------
|
|
169
169
|
{ id: 'DATA-008', run(repo, profile) {
|
|
170
170
|
if (!profile.is_public) return [];
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
// `express.json()` and `bodyParser.json()` ALREADY default to 100kb. Flagging
|
|
172
|
+
// a bare call was wrong on essentially every Express app - and the fix this
|
|
173
|
+
// check used to suggest, limit: "1mb", LOOSENED the cap tenfold. Advice that
|
|
174
|
+
// makes a reader less safe is worse than no check.
|
|
175
|
+
//
|
|
176
|
+
// The genuinely unbounded sink is a file-upload middleware with no limits:
|
|
177
|
+
// express-fileupload, multer and busboy all accept an unlimited body unless
|
|
178
|
+
// told otherwise.
|
|
179
|
+
const UPLOAD = /\b(?:fileUpload|multer|busboy|Busboy)\s*\(/;
|
|
180
|
+
const hits = repo.grep(UPLOAD)
|
|
173
181
|
.map(f => ({ ...f, code: codeOnly(f.text) }))
|
|
174
|
-
.filter(f =>
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
'
|
|
182
|
+
.filter(f => UPLOAD.test(f.code));
|
|
183
|
+
if (!hits.length) return [];
|
|
184
|
+
// a limits: / fileSize: / maxFileSize option anywhere in the same file
|
|
185
|
+
const capped = hits.some(f => /\blimits\s*:|\bfileSize\s*:|\bmaxFileSize\s*:/.test(f.code));
|
|
186
|
+
if (capped) return [];
|
|
187
|
+
const f = hits[0];
|
|
188
|
+
return [finding('DATA-008', 'File uploads with no size limit', 'medium',
|
|
189
|
+
f.path, lineOf(f.code, f.code.search(UPLOAD)),
|
|
190
|
+
'This upload middleware accepts a body of any size. One request can fill the disk or exhaust memory, and no account is needed to send it.',
|
|
191
|
+
'Pass a ceiling — multer({ limits: { fileSize: 5 * 1024 * 1024 } }), or fileUpload({ limits: { fileSize: 5e6 } }).')];
|
|
183
192
|
}},
|
|
184
193
|
|
|
185
194
|
// ---- SQL built by string concatenation -----------------------------------
|
package/src/checks-injection.mjs
CHANGED
|
@@ -119,7 +119,13 @@ const usesTainted = (expr, names) => {
|
|
|
119
119
|
|
|
120
120
|
// Wrapped in a numeric coercion it cannot carry a quote, a comment marker or a
|
|
121
121
|
// semicolon. Flagging it would be flagging arithmetic.
|
|
122
|
-
|
|
122
|
+
// A value that cannot carry a quote cannot break out of a string. Numeric
|
|
123
|
+
// coercion was already here; hashing belongs with it - md5/sha/digest return
|
|
124
|
+
// hex, and a uuid is hex and dashes. Without this, `${security.hash(req.body
|
|
125
|
+
// .password)}` inside an otherwise-parameterised query reported a CRITICAL
|
|
126
|
+
// SQL injection, which is the most damaging false positive the tool can make:
|
|
127
|
+
// maximum severity, on ordinary code.
|
|
128
|
+
const COERCED = /^\s*(?:Number|parseInt|parseFloat|int|float|Integer\.parseInt|to_i|intval|Boolean|BigInt)\s*\(|^\s*\+\s*[A-Za-z_$]|^\s*[\w.$]*\b(?:hash|md5|sha1|sha256|sha512|sha\d*|digest|hexdigest|hashSync|bcrypt|scrypt|uuid|uuidv4|randomUUID)\w*\s*\(/i;
|
|
123
129
|
|
|
124
130
|
// Tagged templates that parameterise. These look EXACTLY like the dangerous
|
|
125
131
|
// form and are the reason a naive version of this check would be unusable:
|
package/src/checks.mjs
CHANGED
|
@@ -47,10 +47,38 @@ const BASE_CHECKS = [
|
|
|
47
47
|
{ id:'SEC-002', run(repo){
|
|
48
48
|
const gi = repo.read('.gitignore');
|
|
49
49
|
if (gi === null) return [];
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
// Not every project's env file is called ".env". docker-compose `env_file:`
|
|
51
|
+
// and `--env-file vars.env` are ordinary, and a project keeping secrets in
|
|
52
|
+
// vars.env - with vars.env correctly ignored - was told to protect a file it
|
|
53
|
+
// does not have. Work out which env files this project ACTUALLY has, then
|
|
54
|
+
// check those names. Say nothing when there is no env file at all.
|
|
55
|
+
const envFiles = repo.find(/(^|\/)[\w.-]*\.env(\.[\w-]+)?$/)
|
|
56
|
+
.map(f => f.path.split('/').pop())
|
|
57
|
+
.filter(n => !/\.example$|\.sample$|\.template$/i.test(n));
|
|
58
|
+
// A correctly-ignored env file is INVISIBLE to us - git never committed it,
|
|
59
|
+
// so it is not on disk to find. The .gitignore naming one is therefore the
|
|
60
|
+
// evidence that the project handled this, whatever the file is called.
|
|
61
|
+
const ignoresSomeEnv = gi.split('\n')
|
|
62
|
+
.map(l => l.trim())
|
|
63
|
+
.some(l => l && !l.startsWith('#') && /(^|[\w.*-])\.env(\*|$)|^\*\.env$/.test(l));
|
|
64
|
+
if (!envFiles.length && ignoresSomeEnv) return [];
|
|
65
|
+
const names = [...new Set(envFiles.length ? envFiles : ['.env'])];
|
|
66
|
+
const ignored = (name) => gi.split('\n').some(line => {
|
|
67
|
+
const l = line.trim();
|
|
68
|
+
if (!l || l.startsWith('#')) return false;
|
|
69
|
+
if (l === name) return true; // vars.env
|
|
70
|
+
if (l.replace(/\*/g, '') === '' ) return false;
|
|
71
|
+
if (/^\.env/.test(l) && /^\.env/.test(name)) return true; // .env / .env*
|
|
72
|
+
if (l.endsWith('*') && name.startsWith(l.slice(0, -1))) return true;
|
|
73
|
+
if (l.startsWith('*') && name.endsWith(l.slice(1))) return true; // *.env
|
|
74
|
+
return false;
|
|
75
|
+
});
|
|
76
|
+
const exposed = names.filter(n => !ignored(n));
|
|
77
|
+
if (!exposed.length) return [];
|
|
78
|
+
const which = exposed[0];
|
|
79
|
+
return [finding('SEC-002', which + ' is not in .gitignore','high','.gitignore',1,
|
|
52
80
|
'Nothing stops a future commit from publishing your secrets.',
|
|
53
|
-
'Add a line containing
|
|
81
|
+
'Add a line containing ' + which + ' to .gitignore')];
|
|
54
82
|
}},
|
|
55
83
|
|
|
56
84
|
{ id:'SEC-004', run(repo){
|
package/src/detect.mjs
CHANGED
|
@@ -254,7 +254,20 @@ function detectSurface(repo, framework, deps) {
|
|
|
254
254
|
if (['flutter','react-native'].includes(f)) return fact('mobile-android','low',['cross-platform mobile']);
|
|
255
255
|
if (['django','rails','laravel','symfony','flask'].includes(f))
|
|
256
256
|
return fact('web-app','high',[`${f} application`]);
|
|
257
|
-
if (['express','fastify','nest','fastapi','sinatra'].includes(f))
|
|
257
|
+
if (['express','fastify','nest','fastapi','sinatra'].includes(f)) {
|
|
258
|
+
// The evidence said "no UI framework" without ever looking for one. A very
|
|
259
|
+
// common full-stack shape - Express serving an API next to a Vite/React
|
|
260
|
+
// client/ folder - was called api-only, which SKIPPED 27 checks including
|
|
261
|
+
// the UI, privacy and social ones the site actually needs. Look before
|
|
262
|
+
// asserting: a UI dependency plus real component files means a web app.
|
|
263
|
+
const uiDep = ['react','react-dom','vue','svelte','preact','solid-js','@angular/core']
|
|
264
|
+
.some(d => deps[d] || deps['dependencies']?.[d]);
|
|
265
|
+
const uiFiles = repo.find(/\.(tsx|jsx|vue|svelte)$/).length;
|
|
266
|
+
const hasHtml = repo.has(/(^|\/)index\.html$/);
|
|
267
|
+
if (uiDep && (uiFiles >= 3 || hasHtml))
|
|
268
|
+
return fact('web-app','low',[`${f} api with a browser front end`]);
|
|
269
|
+
return fact('api-only','low',['server framework, no UI framework']);
|
|
270
|
+
}
|
|
258
271
|
if (['next','remix','nuxt','sveltekit'].includes(f)) {
|
|
259
272
|
const dynamic = repo.has(/\/(api|actions)\//) || repo.grep(/'use server'/).length;
|
|
260
273
|
return fact(dynamic ? 'web-app' : 'web-site','low',[dynamic?'server routes present':'no server routes found']);
|