launchprep 0.1.0 → 0.3.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/LICENSE +31 -0
- package/README.md +17 -2
- package/bin/launchprep.mjs +20 -6
- package/net/client.mjs +2 -1
- package/net/commands.mjs +55 -2
- package/package.json +10 -9
- package/src/brand.mjs +2 -0
- package/src/checks-auth.mjs +7 -3
- package/src/checks-deploy.mjs +23 -3
- package/src/checks-injection.mjs +322 -0
- package/src/checks.mjs +14 -2
- package/src/fs-scan.mjs +94 -1
- package/src/index.mjs +37 -2
- package/src/rules.json +1 -3651
package/LICENSE
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Launchprep Licence
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bc. Bektur Aibekov, IČO 29522471, Prague, Czech Republic.
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
You may:
|
|
7
|
+
|
|
8
|
+
1. Install and run this software to scan source code that you own or are
|
|
9
|
+
authorised to scan.
|
|
10
|
+
2. Make copies as reasonably necessary for that use (for example, inside a
|
|
11
|
+
CI pipeline or a container image you operate).
|
|
12
|
+
|
|
13
|
+
You may not:
|
|
14
|
+
|
|
15
|
+
3. Copy, redistribute, sublicense, sell, or publish this software or any
|
|
16
|
+
part of it, including the rule and check definitions it contains.
|
|
17
|
+
4. Extract, reproduce, or adapt the rule and check definitions for use in
|
|
18
|
+
any other product or service, commercial or not.
|
|
19
|
+
5. Remove or alter this notice.
|
|
20
|
+
|
|
21
|
+
Versions 0.1.0 and 0.2.0 of the "launchprep" npm package were published under
|
|
22
|
+
the MIT licence; that grant stands for those versions. This licence applies to
|
|
23
|
+
this version and all later ones.
|
|
24
|
+
|
|
25
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
26
|
+
IMPLIED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
27
|
+
OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE. Findings are produced
|
|
28
|
+
by automated analysis and may be incomplete or wrong; they are information,
|
|
29
|
+
not a guarantee of security or fitness for launch.
|
|
30
|
+
|
|
31
|
+
Questions and permissions: hello@launchprep.dev
|
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ No signup. No dashboard. Nothing added to your repo.
|
|
|
13
13
|
|
|
14
14
|
## What it does
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
289 checks — but it works out what you built **before** running any of them, so
|
|
17
17
|
you are not told about problems you cannot have. A static site is asked 48
|
|
18
18
|
questions; a multi-tenant SaaS with payments and AI is asked 158.
|
|
19
19
|
|
|
@@ -69,6 +69,19 @@ the report link keeps working, and that link expires.
|
|
|
69
69
|
|
|
70
70
|
If there is no terminal — CI, a script — it refuses rather than assuming yes.
|
|
71
71
|
|
|
72
|
+
## In CI
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npx launchprep . --fail-on critical # exit 1 if a critical is found
|
|
76
|
+
npx launchprep deep . --yes --fail-on high # the paid scan as a gate
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Without `--fail-on` the exit code stays 0 — a report, not a verdict. With it,
|
|
80
|
+
findings at or above the threshold (`critical`, `high`, `medium`, `low`, `any`)
|
|
81
|
+
fail the build. On a deep scan, checks that did not complete fail it too — a
|
|
82
|
+
check that could not run has not passed. A path that does not exist always
|
|
83
|
+
exits 2: a scan of nothing must never look like a clean scan of something.
|
|
84
|
+
|
|
72
85
|
## Free and paid
|
|
73
86
|
|
|
74
87
|
Everything above is free, unlimited, forever — every check a program can make on
|
|
@@ -81,4 +94,6 @@ delete. See <https://launchprep.dev>.
|
|
|
81
94
|
|
|
82
95
|
## Licence
|
|
83
96
|
|
|
84
|
-
|
|
97
|
+
Proprietary — see the LICENSE file. Run it on anything you are authorised to
|
|
98
|
+
scan; the rules and checks may not be copied into other products. Versions
|
|
99
|
+
0.1.0 and 0.2.0 were published under MIT and remain so.
|
package/bin/launchprep.mjs
CHANGED
|
@@ -15,10 +15,24 @@ const argv = process.argv.slice(2);
|
|
|
15
15
|
const cmd = argv[0];
|
|
16
16
|
const KNOWN = new Set(['login', 'logout', 'deep', 'whoami', 'help', '--help', '-h']);
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
// Anything that reaches here uncaught would otherwise print a Node stack
|
|
19
|
+
// trace at someone who paid $29 and cannot read one. A network that is down
|
|
20
|
+
// is not a bug in their project, and it should not look like one.
|
|
21
|
+
try {
|
|
22
|
+
if (!KNOWN.has(cmd)) {
|
|
23
|
+
// free scan - src/ only, nothing from net/ is loaded at all
|
|
24
|
+
await import('../src/index.mjs');
|
|
25
|
+
} else {
|
|
26
|
+
const { run } = await import('../net/commands.mjs');
|
|
27
|
+
await run(cmd, argv.slice(1));
|
|
28
|
+
}
|
|
29
|
+
} catch (e) {
|
|
30
|
+
const msg = e?.message || String(e);
|
|
31
|
+
process.stderr.write(`\n \x1b[31m${msg}\x1b[0m\n`);
|
|
32
|
+
if (/could not reach|did not answer/i.test(msg)) {
|
|
33
|
+
process.stderr.write(` \x1b[2mThe free checks do not need the network. Run \x1b[0mnpx ${BRAND.slug}\x1b[2m on its own.\x1b[0m\n`);
|
|
34
|
+
process.stderr.write(` \x1b[2mIf this keeps happening, mail ${BRAND.email} — your scans are not spent unless a scan runs.\x1b[0m\n`);
|
|
35
|
+
}
|
|
36
|
+
process.stderr.write('\n');
|
|
37
|
+
process.exit(1);
|
|
24
38
|
}
|
package/net/client.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Talking to launchprep.dev. The only file in the CLI that opens a connection.
|
|
2
|
-
|
|
2
|
+
import { BRAND } from '../src/brand.mjs';
|
|
3
|
+
const BASE = process.env.LAUNCHPREP_API || BRAND.api;
|
|
3
4
|
|
|
4
5
|
async function call(path, { key, body, method = 'POST' } = {}) {
|
|
5
6
|
let res;
|
package/net/commands.mjs
CHANGED
|
@@ -32,6 +32,7 @@ async function login(args) {
|
|
|
32
32
|
if (!/^lp_/.test(key)) { console.error(`\n ${C.red}That does not look like a licence key.${C.off} They start with lp_\n`); process.exit(1); }
|
|
33
33
|
|
|
34
34
|
const r = await validate(key);
|
|
35
|
+
if (r.status === 429) { console.error(`\n ${C.red}Too many requests.${C.off} ${C.d}Try again in ${backoff(r)}.${C.off}\n`); process.exit(1); }
|
|
35
36
|
if (r.status === 401) { console.error(`\n ${C.red}That key is not recognised.${C.off}\n`); process.exit(1); }
|
|
36
37
|
if (r.status === 403) { console.error(`\n ${C.red}That key has been revoked.${C.off}\n`); process.exit(1); }
|
|
37
38
|
if (!r.ok) { console.error(`\n ${C.red}Could not check the key${C.off} ${C.d}(${r.status})${C.off}\n`); process.exit(1); }
|
|
@@ -94,6 +95,16 @@ async function deep(args) {
|
|
|
94
95
|
const r = await deepScan({ key: c.key, digest, profile,
|
|
95
96
|
ruleIds: tier2.map(x => x.id), appName: basename(target), idempotencyKey: idem });
|
|
96
97
|
|
|
98
|
+
if (r.status === 429) {
|
|
99
|
+
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
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
if (r.status === 402 && (r.data?.error === 'attempt_ceiling' || r.data?.error === 'spend_ceiling')) {
|
|
103
|
+
console.error(`\n ${C.red}This key has run too many scans that did not finish.${C.off}`);
|
|
104
|
+
console.error(` ${C.d}${r.data.attempts} attempts against a ${r.data.limit}-scan licence. Something is going`);
|
|
105
|
+
console.error(` wrong on our side rather than yours — mail ${BRAND.email} and we will sort it out.${C.off}\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
97
108
|
if (r.status === 402) {
|
|
98
109
|
console.error(`\n ${C.red}No deep scans left${C.off} ${C.d}(${r.data?.used}/${r.data?.limit} used)${C.off}\n`);
|
|
99
110
|
process.exit(1);
|
|
@@ -122,12 +133,47 @@ async function deep(args) {
|
|
|
122
133
|
console.log(` ${f.detail}`);
|
|
123
134
|
console.log(` ${C.grn}Fix${C.off} ${f.fix}\n`);
|
|
124
135
|
}
|
|
136
|
+
// Printed BEFORE the report link, and before the scans-left line, because a
|
|
137
|
+
// reader who stops at the findings must still have seen this. A check that
|
|
138
|
+
// did not run is a hole in the answer, not a footnote to it.
|
|
139
|
+
const missed = r.data.incomplete || [];
|
|
140
|
+
if (missed.length) {
|
|
141
|
+
console.log(`${C.red}${C.b}${missed.length} check${missed.length === 1 ? '' : 's'} did not complete${C.off}`);
|
|
142
|
+
console.log(` ${C.d}These are not a pass. Nothing below was ruled out.${C.off}`);
|
|
143
|
+
for (const m of missed.slice(0, 8)) {
|
|
144
|
+
console.log(` ${C.red}~${C.off} ${C.g}${m.id.padEnd(12)}${C.off}${C.d}${(m.title || '').slice(0, 58)}${C.off}`);
|
|
145
|
+
}
|
|
146
|
+
if (missed.length > 8) console.log(` ${C.d}and ${missed.length - 8} more${C.off}`);
|
|
147
|
+
if (r.data.coverage !== undefined) {
|
|
148
|
+
console.log(` ${C.d}${Math.round(r.data.coverage * 100)}% of the ${r.data.rulesAsked} checks in this scan answered.${C.off}`);
|
|
149
|
+
}
|
|
150
|
+
if (r.data.refunded) {
|
|
151
|
+
console.log(` ${C.grn}This scan did not finish, so it does not count against your five.${C.off}`);
|
|
152
|
+
}
|
|
153
|
+
console.log(` ${C.d}Run it again and they usually complete.${C.off}\n`);
|
|
154
|
+
}
|
|
155
|
+
|
|
125
156
|
if (r.data.report?.url) {
|
|
126
157
|
console.log(`${C.b}Your report${C.off}`);
|
|
127
158
|
console.log(` ${C.o}${r.data.report.url}${C.off}`);
|
|
128
159
|
console.log(` ${C.d}dated, shareable, and it expires in ${r.data.report.expiresInDays} days${C.off}\n`);
|
|
129
160
|
}
|
|
130
161
|
console.log(` ${C.d}${r.data.scansRemaining} deep scan${r.data.scansRemaining === 1 ? '' : 's'} left${C.off}\n`);
|
|
162
|
+
|
|
163
|
+
// --fail-on <severity>: the CI gate, same contract as the free scan. The
|
|
164
|
+
// human report above has fully printed; the pipeline now gets its verdict.
|
|
165
|
+
// A scan with holes trips the gate too — a check that did not run has not
|
|
166
|
+
// passed, and a gate that shrugs at that is a gate that waves through the
|
|
167
|
+
// exact case it exists for.
|
|
168
|
+
const failOn = args[args.indexOf('--fail-on') + 1];
|
|
169
|
+
if (args.includes('--fail-on')) {
|
|
170
|
+
if (!(failOn in rank) && failOn !== 'any') {
|
|
171
|
+
console.error(' --fail-on must be one of: critical, high, medium, low, any');
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
const limit = failOn === 'any' ? 3 : rank[failOn];
|
|
175
|
+
if (findings.some(f => rank[f.severity] <= limit) || missed.length) process.exit(1);
|
|
176
|
+
}
|
|
131
177
|
}
|
|
132
178
|
|
|
133
179
|
function help() {
|
|
@@ -140,13 +186,20 @@ ${C.b}${BRAND.name}${C.off} ${C.d}${BRAND.tagline}${C.off}
|
|
|
140
186
|
${C.b}npx ${BRAND.slug} logout${C.off} forget the key
|
|
141
187
|
${C.b}npx ${BRAND.slug} deep${C.off} ${C.d}[path]${C.off} the paid scan — asks before sending anything
|
|
142
188
|
|
|
143
|
-
${C.d}--yes${C.off}
|
|
144
|
-
${C.d}--json${C.off}
|
|
189
|
+
${C.d}--yes${C.off} skip the upload confirmation ${C.d}(for CI)${C.off}
|
|
190
|
+
${C.d}--json${C.off} machine readable ${C.d}(free scan only)${C.off}
|
|
191
|
+
${C.d}--fail-on${C.off} ${C.d}<sev>${C.off} exit 1 if findings reach this severity — fails a CI build
|
|
192
|
+
${C.d}critical | high | medium | low | any${C.off}
|
|
145
193
|
|
|
146
194
|
${C.d}https://${BRAND.slug}.dev${C.off}
|
|
147
195
|
`);
|
|
148
196
|
}
|
|
149
197
|
|
|
198
|
+
const backoff = (r) => {
|
|
199
|
+
const s = Number(r.data?.retryAfter) || 60;
|
|
200
|
+
return s < 90 ? `${s} seconds` : `${Math.ceil(s / 60)} minutes`;
|
|
201
|
+
};
|
|
202
|
+
|
|
150
203
|
export async function run(cmd, args) {
|
|
151
204
|
if (cmd === 'login') return login(args);
|
|
152
205
|
if (cmd === 'logout') return logout();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchprep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,12 +11,15 @@
|
|
|
11
11
|
"net",
|
|
12
12
|
"bin",
|
|
13
13
|
"scripts/verify-readonly.mjs",
|
|
14
|
-
"README.md"
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"verify": "node scripts/verify-readonly.mjs",
|
|
18
19
|
"pretest": "node scripts/verify-readonly.mjs",
|
|
19
|
-
"test": "node test/run.mjs"
|
|
20
|
+
"test": "node test/run.mjs && node test/gate.mjs",
|
|
21
|
+
"prepack": "node scripts/prepare-publish.mjs",
|
|
22
|
+
"postpack": "node scripts/restore-after-publish.mjs"
|
|
20
23
|
},
|
|
21
24
|
"keywords": [
|
|
22
25
|
"security",
|
|
@@ -29,13 +32,11 @@
|
|
|
29
32
|
"checklist"
|
|
30
33
|
],
|
|
31
34
|
"homepage": "https://launchprep.dev",
|
|
32
|
-
"
|
|
33
|
-
"type": "git",
|
|
34
|
-
"url": "git+https://github.com/312labs/launchprep-app.git",
|
|
35
|
-
"directory": "cli"
|
|
36
|
-
},
|
|
37
|
-
"license": "MIT",
|
|
35
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
38
36
|
"engines": {
|
|
39
37
|
"node": ">=18"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"email": "hello@launchprep.dev"
|
|
40
41
|
}
|
|
41
42
|
}
|
package/src/brand.mjs
CHANGED
|
@@ -8,6 +8,8 @@ export const BRAND = {
|
|
|
8
8
|
name: 'Launchprep', // shown to humans
|
|
9
9
|
slug: 'launchprep', // npm package, plugin id, slash command
|
|
10
10
|
tagline: 'readiness scan',
|
|
11
|
+
email: 'hello@launchprep.dev', // the address that actually receives
|
|
12
|
+
api: 'https://api.launchprep.dev',
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export const CMD = '/' + BRAND.slug;
|
package/src/checks-auth.mjs
CHANGED
|
@@ -13,8 +13,12 @@ export const AUTH_CHECKS = [
|
|
|
13
13
|
{ id: 'SEC-001', run(repo) {
|
|
14
14
|
const envs = repo.files.filter(f => /(^|\/)\.env(\.local|\.production|\.development)?$/.test(f.path));
|
|
15
15
|
if (!envs.length) return [];
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
|
|
17
|
+
// Asked per file, against every .gitignore from the git repository root
|
|
18
|
+
// down - not just one at the scan root. Our own api/.env is excluded by a
|
|
19
|
+
// rule one directory up, and reading only the local file reported it as a
|
|
20
|
+
// CRITICAL leak. The whole chain, or the answer is a guess.
|
|
21
|
+
const ignored = repo.isIgnored || (() => false);
|
|
18
22
|
|
|
19
23
|
// A committed .env full of ChangeMe and localhost is a template, not a leak.
|
|
20
24
|
// Only the values decide - flagging a placeholder file as CRITICAL is how a
|
|
@@ -33,7 +37,7 @@ export const AUTH_CHECKS = [
|
|
|
33
37
|
|
|
34
38
|
const out = [];
|
|
35
39
|
for (const f of envs) {
|
|
36
|
-
if (!f.text) continue;
|
|
40
|
+
if (!f.text || ignored(f.path)) continue;
|
|
37
41
|
const hits = [];
|
|
38
42
|
for (const line of f.text.split('\n')) {
|
|
39
43
|
const eq = line.indexOf('=');
|
package/src/checks-deploy.mjs
CHANGED
|
@@ -3,6 +3,23 @@ const finding = (id, title, severity, file, line, detail, fix) =>
|
|
|
3
3
|
({ id, title, severity, file, line, detail, fix });
|
|
4
4
|
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
5
5
|
|
|
6
|
+
// Comments blanked to spaces - offsets and line numbers survive, the words do
|
|
7
|
+
// not. Our own server.mjs opens by explaining that it does NOT use
|
|
8
|
+
// express.json(), and DATA-008 read that sentence as the call itself. Twelfth
|
|
9
|
+
// time this shape has bitten: it matched a name where no code was.
|
|
10
|
+
const codeOnly = (t) => {
|
|
11
|
+
let out = '', i = 0;
|
|
12
|
+
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
13
|
+
while (i < t.length) {
|
|
14
|
+
const two = t.slice(i, i + 2);
|
|
15
|
+
if (two === '//') { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
|
|
16
|
+
if (two === '/*') { const e = t.indexOf('*/', i + 2); const j = e === -1 ? t.length : e + 2; out += blank(t.slice(i, j)); i = j; continue; }
|
|
17
|
+
if (t[i] === '#' && /(^|\n)[ \t]*$/.test(out.slice(-40))) { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
|
|
18
|
+
out += t[i]; i++;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
};
|
|
22
|
+
|
|
6
23
|
export const DEPLOY_CHECKS = [
|
|
7
24
|
|
|
8
25
|
// ---- a credential pasted into a CI workflow ------------------------------
|
|
@@ -157,13 +174,16 @@ export const DEPLOY_CHECKS = [
|
|
|
157
174
|
// ---- no body size limit ---------------------------------------------------
|
|
158
175
|
{ id: 'DATA-008', run(repo, profile) {
|
|
159
176
|
if (!profile.is_public) return [];
|
|
160
|
-
const
|
|
177
|
+
const CALL = /express\.json\s*\(|bodyParser\.json\s*\(/;
|
|
178
|
+
const express = repo.grep(CALL)
|
|
179
|
+
.map(f => ({ ...f, code: codeOnly(f.text) }))
|
|
180
|
+
.filter(f => CALL.test(f.code));
|
|
161
181
|
if (!express.length) return [];
|
|
162
|
-
const limited = express.some(f => /json\s*\(\s*\{[^}]*limit\s*:/.test(f.
|
|
182
|
+
const limited = express.some(f => /json\s*\(\s*\{[^}]*limit\s*:/.test(f.code));
|
|
163
183
|
if (limited) return [];
|
|
164
184
|
const f = express[0];
|
|
165
185
|
return [finding('DATA-008', 'No limit on request body size', 'medium',
|
|
166
|
-
f.path, lineOf(f.
|
|
186
|
+
f.path, lineOf(f.code, f.code.search(CALL)),
|
|
167
187
|
'A single request can send a body large enough to exhaust memory. No account needed.',
|
|
168
188
|
'Pass a limit, e.g. express.json({ limit: "1mb" }).')];
|
|
169
189
|
}},
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// User input concatenated into a SQL query.
|
|
2
|
+
//
|
|
3
|
+
// This was missing from all 288 rules. A project with `${req.params.id}` dropped
|
|
4
|
+
// straight into a query got back "SELECT * on users, MEDIUM" and nothing about
|
|
5
|
+
// the injection on the same line - which is the single most famous way a launch
|
|
6
|
+
// goes wrong, and the one a reader would most expect us to catch.
|
|
7
|
+
//
|
|
8
|
+
// It is also the check most likely to cry wolf, because half the SQL in a modern
|
|
9
|
+
// codebase is written with tagged templates that look identical to the dangerous
|
|
10
|
+
// form and are completely safe. So the discipline that removed 166 false alarms
|
|
11
|
+
// applies in full: read the value, judge the line, and require evidence the
|
|
12
|
+
// thing is used the dangerous way. Three separate facts must all hold:
|
|
13
|
+
//
|
|
14
|
+
// 1. the string is actually SQL - a verb AND a clause, not the word "select"
|
|
15
|
+
// 2. something is interpolated into it - not a constant string
|
|
16
|
+
// 3. that something came from a request - not a literal, not a column name
|
|
17
|
+
//
|
|
18
|
+
// And then the safe forms are subtracted, because every one of them would
|
|
19
|
+
// otherwise fire on correct code.
|
|
20
|
+
|
|
21
|
+
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
22
|
+
({ id, title, severity, file, line, detail, fix });
|
|
23
|
+
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
24
|
+
|
|
25
|
+
// Comments blanked to spaces; offsets and line numbers survive, the words do
|
|
26
|
+
// not. A comment reading "never do `SELECT * FROM users WHERE id = ${id}`" is
|
|
27
|
+
// advice against the bug, not the bug.
|
|
28
|
+
const codeOnly = (t) => {
|
|
29
|
+
let out = '', i = 0;
|
|
30
|
+
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
31
|
+
while (i < t.length) {
|
|
32
|
+
const two = t.slice(i, i + 2);
|
|
33
|
+
if (two === '//') { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
|
|
34
|
+
if (two === '/*') { const e = t.indexOf('*/', i + 2); const j = e === -1 ? t.length : e + 2; out += blank(t.slice(i, j)); i = j; continue; }
|
|
35
|
+
if (t[i] === '#' && /(^|\n)[ \t]*$/.test(out.slice(-40))) { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
|
|
36
|
+
out += t[i]; i++;
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// A verb and a clause. "select" alone matches a React prop, a CSS selector, a
|
|
42
|
+
// variable called selectedUser and about forty other innocent things.
|
|
43
|
+
const IS_SQL = /\b(select|insert\s+into|update|delete\s+from|replace\s+into)\b[\s\S]{0,300}?\b(from|into|where|set|values|join)\b/i;
|
|
44
|
+
|
|
45
|
+
// The expression must trace to something the caller sent. A table name pulled
|
|
46
|
+
// from a constant is a different (smaller) problem and not this finding.
|
|
47
|
+
//
|
|
48
|
+
// Every entry here is ROOTED - req.query, not query. The first version accepted
|
|
49
|
+
// a bare `query.`, `params.` and `body.` and immediately flagged Sequelize's own
|
|
50
|
+
//
|
|
51
|
+
// query.query = `SELECT * FROM FINAL TABLE (${query.query})`
|
|
52
|
+
//
|
|
53
|
+
// as a critical injection, because `query.query` matched. That is the twelfth
|
|
54
|
+
// time this codebase has matched a NAME where it needed a CONTEXT, and on a
|
|
55
|
+
// CRITICAL rule it is the one that costs the most: a reader cannot tell a
|
|
56
|
+
// wrong finding from an irrelevant one, and stops believing the other 288.
|
|
57
|
+
const FROM_REQUEST = new RegExp([
|
|
58
|
+
'req\\.(params|query|body|headers|cookies)',
|
|
59
|
+
'request\\.(params|query|body|args|form|json|GET|POST|values|data)',
|
|
60
|
+
'\\bctx\\.(request|params|query)',
|
|
61
|
+
'searchParams\\.get',
|
|
62
|
+
'\\$_(GET|POST|REQUEST|COOKIE)\\b',
|
|
63
|
+
'event\\.(queryStringParameters|pathParameters|body)',
|
|
64
|
+
'\\bgetQuery\\(|\\breadBody\\(',
|
|
65
|
+
'\\bformData\\.get',
|
|
66
|
+
].join('|'));
|
|
67
|
+
|
|
68
|
+
// Bare `params.id` IS the request in a Next.js, Remix or SvelteKit route
|
|
69
|
+
// handler - and is just a variable anywhere else. So it counts as evidence
|
|
70
|
+
// only in a file that is a route handler, which is a fact about the file
|
|
71
|
+
// rather than a guess about the name.
|
|
72
|
+
const BARE_PARAMS = /\bparams\.[A-Za-z_$]|\bsearchParams\.[A-Za-z_$]/;
|
|
73
|
+
const IS_ROUTE_FILE = (path, code) =>
|
|
74
|
+
/(^|\/)(app|pages|src\/app|src\/pages)\/.*\/(route|page)\.(ts|tsx|js|jsx)$/.test(path) ||
|
|
75
|
+
/(^|\/)pages\/api\//.test(path) ||
|
|
76
|
+
/(^|\/)routes?\//.test(path) ||
|
|
77
|
+
/\+server\.(ts|js)$/.test(path) ||
|
|
78
|
+
/\bexport\s+(async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE)\s*\(/.test(code) ||
|
|
79
|
+
/\bexport\s+const\s+(GET|POST|PUT|PATCH|DELETE)\s*=/.test(code);
|
|
80
|
+
|
|
81
|
+
// One hop of data flow, because one hop is where the bug actually lives.
|
|
82
|
+
//
|
|
83
|
+
// const { id } = req.params;
|
|
84
|
+
// db.query(`select * from users where id = ${id}`)
|
|
85
|
+
//
|
|
86
|
+
// is far more common than interpolating req.params.id directly, and a check
|
|
87
|
+
// that only saw the direct form would miss most real instances while claiming
|
|
88
|
+
// to cover this. So: collect the names a file binds FROM a request, then treat
|
|
89
|
+
// those names as the request.
|
|
90
|
+
//
|
|
91
|
+
// Scope is ignored on purpose. Tracking it properly needs a parser, and a file
|
|
92
|
+
// that reads req.params.id into `id` and then pastes `id` into SQL is not
|
|
93
|
+
// meaningfully ambiguous. What this must not do is taint a name bound from
|
|
94
|
+
// anything else, which is why only these forms count.
|
|
95
|
+
const TAINT_SOURCES = [
|
|
96
|
+
// const { a, b } = req.params / req.query / req.body / await req.json()
|
|
97
|
+
/(?:const|let|var)\s*\{([^}]{1,200})\}\s*=\s*(?:await\s+)?(?:req|request)\.(?:params|query|body|json\(\)|formData\(\))/g,
|
|
98
|
+
// const { id } = await params (Next.js 15 route handlers)
|
|
99
|
+
/(?:const|let|var)\s*\{([^}]{1,200})\}\s*=\s*await\s+(?:params|searchParams)\b/g,
|
|
100
|
+
// const x = req.query.y / req.params.y / req.body.y
|
|
101
|
+
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?(?:req|request)\.(?:params|query|body|headers|cookies)\b/g,
|
|
102
|
+
// const x = searchParams.get('y') / url.searchParams.get('y') / formData.get('y')
|
|
103
|
+
/(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*[\w.$]*(?:searchParams|formData)\.get\s*\(/g,
|
|
104
|
+
// python: x = request.args.get('y') / request.form['y'] / request.json['y']
|
|
105
|
+
/([A-Za-z_][\w]*)\s*=\s*request\.(?:args|form|json|values|data|GET|POST)\b/g,
|
|
106
|
+
// php: $x = $_GET['y']
|
|
107
|
+
/\$([A-Za-z_]\w*)\s*=\s*\$_(?:GET|POST|REQUEST|COOKIE)\b/g,
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
function taintedNames(code) {
|
|
111
|
+
const names = new Set();
|
|
112
|
+
for (const re of TAINT_SOURCES) {
|
|
113
|
+
re.lastIndex = 0;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m = re.exec(code))) {
|
|
116
|
+
for (const part of m[1].split(',')) {
|
|
117
|
+
// { id } and { id: userId } and { id = 1 } all bind the LAST name
|
|
118
|
+
const n = part.split(':').pop().split('=')[0].trim().replace(/^\.\.\./, '');
|
|
119
|
+
if (/^[A-Za-z_$][\w$]*$/.test(n)) names.add(n);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// Names so generic that binding one would taint half the file's arithmetic.
|
|
124
|
+
for (const junk of ['data', 'body', 'params', 'query', 'req', 'request', 'options', 'props']) names.delete(junk);
|
|
125
|
+
return names;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const usesTainted = (expr, names) => {
|
|
129
|
+
if (!names.size) return false;
|
|
130
|
+
for (const m of expr.matchAll(/[A-Za-z_$][\w$]*/g)) if (names.has(m[0])) return true;
|
|
131
|
+
return false;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Wrapped in a numeric coercion it cannot carry a quote, a comment marker or a
|
|
135
|
+
// semicolon. Flagging it would be flagging arithmetic.
|
|
136
|
+
const COERCED = /^\s*(Number|parseInt|parseFloat|int|float|Integer\.parseInt|to_i|intval)\s*\(|^\s*\+\s*[A-Za-z_$]/;
|
|
137
|
+
|
|
138
|
+
// Tagged templates that parameterise. These look EXACTLY like the dangerous
|
|
139
|
+
// form and are the reason a naive version of this check would be unusable:
|
|
140
|
+
// prisma.$queryRaw`...${id}...` is safe, prisma.$queryRawUnsafe(`...${id}...`)
|
|
141
|
+
// is not, and the difference is six characters.
|
|
142
|
+
const SAFE_TAG = /(^|[^\w$])(sql|SQL|sqlx?|prisma\.\$queryRaw|prisma\.\$executeRaw|\$queryRaw|\$executeRaw|db\.sql|tx\.sql|conn\.sql|knex\.raw|Prisma\.sql|drizzle|postgres|neon|planetscale)$/;
|
|
143
|
+
|
|
144
|
+
// Where a template literal ends, honouring escapes and nested ${ } which may
|
|
145
|
+
// themselves contain template literals.
|
|
146
|
+
function endOfTemplate(t, start) {
|
|
147
|
+
let i = start + 1;
|
|
148
|
+
while (i < t.length) {
|
|
149
|
+
const c = t[i];
|
|
150
|
+
if (c === '\\') { i += 2; continue; }
|
|
151
|
+
if (c === '`') return i;
|
|
152
|
+
if (c === '$' && t[i + 1] === '{') {
|
|
153
|
+
let depth = 1; i += 2;
|
|
154
|
+
while (i < t.length && depth > 0) {
|
|
155
|
+
if (t[i] === '\\') { i += 2; continue; }
|
|
156
|
+
if (t[i] === '`') { const e = endOfTemplate(t, i); i = e === -1 ? t.length : e + 1; continue; }
|
|
157
|
+
if (t[i] === '{') depth++;
|
|
158
|
+
else if (t[i] === '}') depth--;
|
|
159
|
+
i++;
|
|
160
|
+
}
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
i++;
|
|
164
|
+
}
|
|
165
|
+
return -1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The ${ ... } expressions inside one template literal, as text.
|
|
169
|
+
function holes(tpl) {
|
|
170
|
+
const out = [];
|
|
171
|
+
for (let i = 0; i < tpl.length; i++) {
|
|
172
|
+
if (tpl[i] === '\\') { i++; continue; }
|
|
173
|
+
if (tpl[i] === '$' && tpl[i + 1] === '{') {
|
|
174
|
+
let depth = 1, j = i + 2; const from = j;
|
|
175
|
+
while (j < tpl.length && depth > 0) {
|
|
176
|
+
if (tpl[j] === '\\') { j += 2; continue; }
|
|
177
|
+
if (tpl[j] === '{') depth++;
|
|
178
|
+
else if (tpl[j] === '}') depth--;
|
|
179
|
+
if (depth > 0) j++;
|
|
180
|
+
}
|
|
181
|
+
out.push(tpl.slice(from, j));
|
|
182
|
+
i = j;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return out;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const JS = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
189
|
+
const PY = /\.py$/;
|
|
190
|
+
const PHP = /\.php$/;
|
|
191
|
+
const RB = /\.rb$/;
|
|
192
|
+
|
|
193
|
+
function scanJs(path, code, out) {
|
|
194
|
+
const routeFile = IS_ROUTE_FILE(path, code);
|
|
195
|
+
const tainted = taintedNames(code);
|
|
196
|
+
const isInput = (h) => FROM_REQUEST.test(h)
|
|
197
|
+
|| (routeFile && BARE_PARAMS.test(h))
|
|
198
|
+
|| usesTainted(h, tainted);
|
|
199
|
+
for (let i = 0; i < code.length; i++) {
|
|
200
|
+
if (code[i] !== '`') continue;
|
|
201
|
+
const end = endOfTemplate(code, i);
|
|
202
|
+
if (end === -1) break;
|
|
203
|
+
const tpl = code.slice(i + 1, end);
|
|
204
|
+
const before = code.slice(Math.max(0, i - 60), i).trimEnd();
|
|
205
|
+
i = end;
|
|
206
|
+
|
|
207
|
+
if (!IS_SQL.test(tpl)) continue;
|
|
208
|
+
if (SAFE_TAG.test(before)) continue; // parameterised by the tag
|
|
209
|
+
|
|
210
|
+
const bad = holes(tpl).filter(h => isInput(h) && !COERCED.test(h));
|
|
211
|
+
if (!bad.length) continue;
|
|
212
|
+
out.push({ line: lineOf(code, i - tpl.length), expr: bad[0].trim(), how: 'a template literal' });
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// "select ... where id = " + req.params.id
|
|
216
|
+
const CONCAT = /(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*\+\s*([^;\n]{1,120})/g;
|
|
217
|
+
let m;
|
|
218
|
+
while ((m = CONCAT.exec(code))) {
|
|
219
|
+
if (!IS_SQL.test(m[2])) continue;
|
|
220
|
+
const rhs = m[3];
|
|
221
|
+
if (!isInput(rhs) || COERCED.test(rhs)) continue;
|
|
222
|
+
out.push({ line: lineOf(code, m.index), expr: rhs.trim().slice(0, 60), how: 'string concatenation' });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function scanPy(path, code, out) {
|
|
227
|
+
const tainted = taintedNames(code);
|
|
228
|
+
const isInput = (h) => FROM_REQUEST.test(h) || usesTainted(h, tainted);
|
|
229
|
+
// f"... {req.args['id']} ..." -- the f prefix is what makes it interpolate
|
|
230
|
+
const FSTR = /\bf(["'])((?:(?!\1)[^\\]|\\.)*)\1|\bf("""|''')([\s\S]*?)\3/g;
|
|
231
|
+
let m;
|
|
232
|
+
while ((m = FSTR.exec(code))) {
|
|
233
|
+
const body = m[2] ?? m[4] ?? '';
|
|
234
|
+
if (!IS_SQL.test(body)) continue;
|
|
235
|
+
const bad = [...body.matchAll(/\{([^{}]+)\}/g)].map(x => x[1])
|
|
236
|
+
.filter(h => isInput(h) && !COERCED.test(h));
|
|
237
|
+
if (!bad.length) continue;
|
|
238
|
+
out.push({ line: lineOf(code, m.index), expr: bad[0].trim(), how: 'an f-string' });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// "... %s ..." % x and "...".format(x)
|
|
242
|
+
//
|
|
243
|
+
// The % HAS to be applied to the string. cursor.execute("... %s", (x,)) is
|
|
244
|
+
// the correct parameterised call and looks almost the same - the difference
|
|
245
|
+
// is a comma instead of a percent sign, and it is the whole difference.
|
|
246
|
+
const APPLIED = /(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*(?:%\s*|\.format\s*\()([^\n;]{1,120})/g;
|
|
247
|
+
while ((m = APPLIED.exec(code))) {
|
|
248
|
+
if (!IS_SQL.test(m[2])) continue;
|
|
249
|
+
if (!isInput(m[3]) || COERCED.test(m[3])) continue;
|
|
250
|
+
out.push({ line: lineOf(code, m.index), expr: m[3].trim().slice(0, 60), how: 'string formatting' });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function scanPhp(path, code, out) {
|
|
255
|
+
const tainted = taintedNames(code);
|
|
256
|
+
const isInput = (h) => FROM_REQUEST.test(h) || usesTainted(h, tainted);
|
|
257
|
+
const STR = /"((?:[^"\\]|\\.)*)"/g;
|
|
258
|
+
let m;
|
|
259
|
+
while ((m = STR.exec(code))) {
|
|
260
|
+
const body = m[1];
|
|
261
|
+
if (!IS_SQL.test(body)) continue;
|
|
262
|
+
const bad = [...body.matchAll(/\{?\$([A-Za-z_]\w*(?:\[[^\]]+\])?(?:->\w+)?)\}?/g)].map(x => '$' + x[1])
|
|
263
|
+
.filter(h => isInput(h));
|
|
264
|
+
if (bad.length) { out.push({ line: lineOf(code, m.index), expr: bad[0], how: 'string interpolation' }); continue; }
|
|
265
|
+
}
|
|
266
|
+
const DOT = /(["'])((?:(?!\1)[^\\]|\\.)*)\1\s*\.\s*([^;\n]{1,120})/g;
|
|
267
|
+
while ((m = DOT.exec(code))) {
|
|
268
|
+
if (!IS_SQL.test(m[2])) continue;
|
|
269
|
+
if (!isInput(m[3])) continue;
|
|
270
|
+
out.push({ line: lineOf(code, m.index), expr: m[3].trim().slice(0, 60), how: 'string concatenation' });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function scanRb(path, code, out) {
|
|
275
|
+
const STR = /"((?:[^"\\]|\\.)*)"/g;
|
|
276
|
+
let m;
|
|
277
|
+
while ((m = STR.exec(code))) {
|
|
278
|
+
const body = m[1];
|
|
279
|
+
if (!IS_SQL.test(body)) continue;
|
|
280
|
+
const bad = [...body.matchAll(/#\{([^}]+)\}/g)].map(x => x[1])
|
|
281
|
+
.filter(h => FROM_REQUEST.test(h) && !COERCED.test(h));
|
|
282
|
+
if (!bad.length) continue;
|
|
283
|
+
out.push({ line: lineOf(code, m.index), expr: bad[0].trim(), how: 'string interpolation' });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export const INJECTION_CHECKS = [
|
|
288
|
+
|
|
289
|
+
{ id: 'DATA-015', run(repo) {
|
|
290
|
+
const out = [];
|
|
291
|
+
for (const f of repo.files) {
|
|
292
|
+
if (!f.text) continue;
|
|
293
|
+
const isJs = JS.test(f.path), isPy = PY.test(f.path),
|
|
294
|
+
isPhp = PHP.test(f.path), isRb = RB.test(f.path);
|
|
295
|
+
if (!isJs && !isPy && !isPhp && !isRb) continue;
|
|
296
|
+
// A migration writes its own SQL and takes no request input; a seed file
|
|
297
|
+
// is the same. Both are full of raw SQL and neither is reachable.
|
|
298
|
+
if (/(^|\/)(migrations?|seeds?|db\/migrate)\//.test(f.path)) continue;
|
|
299
|
+
|
|
300
|
+
const code = codeOnly(f.text);
|
|
301
|
+
const hits = [];
|
|
302
|
+
if (isJs) scanJs(f.path, code, hits);
|
|
303
|
+
if (isPy) scanPy(f.path, code, hits);
|
|
304
|
+
if (isPhp) scanPhp(f.path, code, hits);
|
|
305
|
+
if (isRb) scanRb(f.path, code, hits);
|
|
306
|
+
|
|
307
|
+
// One finding per file. Somebody who builds queries this way builds them
|
|
308
|
+
// this way in twenty places, and twenty identical CRITICALs is not twenty
|
|
309
|
+
// times the information - it is one piece of information, shouted.
|
|
310
|
+
if (!hits.length) continue;
|
|
311
|
+
const h = hits[0];
|
|
312
|
+
const more = hits.length > 1 ? ` The same pattern appears ${hits.length} times in this file.` : '';
|
|
313
|
+
out.push(finding('DATA-015',
|
|
314
|
+
'Anyone can read or delete your whole database by typing into a form', 'critical',
|
|
315
|
+
f.path, h.line,
|
|
316
|
+
`A value that came straight from the visitor is pasted into a database query through ${h.how} — here it is \`${h.expr}\`. Whatever they type is not treated as data, it is treated as part of the instruction. Typing the right thing into that field reads every table, changes any row, or deletes the lot, and nothing in the code stops it.${more}`,
|
|
317
|
+
'Never build the query by joining strings. Leave a placeholder where the value goes and pass the value alongside it — `db.query("select * from users where id = $1", [id])` — so the database treats it as data no matter what it contains.'));
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}},
|
|
321
|
+
|
|
322
|
+
];
|