launchprep 0.1.0 → 0.2.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/bin/launchprep.mjs +20 -6
- package/net/client.mjs +2 -1
- package/net/commands.mjs +10 -0
- package/package.json +1 -1
- 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/rules.json +11 -1
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,10 @@ 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
|
+
}
|
|
97
102
|
if (r.status === 402) {
|
|
98
103
|
console.error(`\n ${C.red}No deep scans left${C.off} ${C.d}(${r.data?.used}/${r.data?.limit} used)${C.off}\n`);
|
|
99
104
|
process.exit(1);
|
|
@@ -147,6 +152,11 @@ ${C.b}${BRAND.name}${C.off} ${C.d}${BRAND.tagline}${C.off}
|
|
|
147
152
|
`);
|
|
148
153
|
}
|
|
149
154
|
|
|
155
|
+
const backoff = (r) => {
|
|
156
|
+
const s = Number(r.data?.retryAfter) || 60;
|
|
157
|
+
return s < 90 ? `${s} seconds` : `${Math.ceil(s / 60)} minutes`;
|
|
158
|
+
};
|
|
159
|
+
|
|
150
160
|
export async function run(cmd, args) {
|
|
151
161
|
if (cmd === 'login') return login(args);
|
|
152
162
|
if (cmd === 'logout') return logout();
|
package/package.json
CHANGED
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
|
+
];
|
package/src/checks.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Tier-1 checks: deterministic, no LLM, no network. Each returns findings with
|
|
2
2
|
// a file:line so the user can go straight to it.
|
|
3
3
|
import { AUTHZ_CHECKS } from './checks-authz.mjs';
|
|
4
|
+
import { INJECTION_CHECKS } from './checks-injection.mjs';
|
|
4
5
|
import { AI_CHECKS } from './checks-ai.mjs';
|
|
5
6
|
import { DEPLOY_CHECKS } from './checks-deploy.mjs';
|
|
6
7
|
import { AUTH_CHECKS } from './checks-auth.mjs';
|
|
@@ -75,6 +76,12 @@ const BASE_CHECKS = [
|
|
|
75
76
|
if (!f.text || /\.(md|lock)$/.test(f.path)) continue;
|
|
76
77
|
if (/\.(example|sample|template|dist)$/i.test(f.path) ||
|
|
77
78
|
/\.env\.(example|sample|template)/i.test(f.path)) continue;
|
|
79
|
+
// A key in a .env file is not hardcoded - .env IS the environment
|
|
80
|
+
// variable, and telling someone to "move it to an environment variable"
|
|
81
|
+
// is advice to do the thing they already did. Whether that file is safe
|
|
82
|
+
// is a question about .gitignore, and SEC-001 is the check that knows
|
|
83
|
+
// how to ask it.
|
|
84
|
+
if (/(^|\/)\.env(\.[A-Za-z0-9_-]+)?$/.test(f.path)) continue;
|
|
78
85
|
for (const [re,label] of PATTERNS){
|
|
79
86
|
let m; re.lastIndex=0;
|
|
80
87
|
while ((m=re.exec(f.text))){
|
|
@@ -137,7 +144,12 @@ const BASE_CHECKS = [
|
|
|
137
144
|
const out=[];
|
|
138
145
|
for (const f of repo.files){
|
|
139
146
|
if (!f.text || !/\.(ts|js|mjs)$/.test(f.path)) continue;
|
|
140
|
-
|
|
147
|
+
// \b around the backreference, and it is not cosmetic. The catch
|
|
148
|
+
// variable is usually `e`, and without boundaries that `e` matched the
|
|
149
|
+
// one inside the word "error" - so `res.json({ error: 'internal', ref })`,
|
|
150
|
+
// which is the CORRECT handling, was reported as leaking internals.
|
|
151
|
+
// Found by writing the test pair this check shipped without.
|
|
152
|
+
const re=/catch\s*\(\s*(\w+)\s*\)\s*\{[^}]{0,200}?(?:json|send)\s*\(\s*\{[^}]{0,120}?\b\1\b(?:\.message|\.stack)?/gs;
|
|
141
153
|
let m;
|
|
142
154
|
while ((m=re.exec(f.text))){
|
|
143
155
|
out.push(finding('API-002','Internal error details returned to the client','high',
|
|
@@ -165,7 +177,7 @@ const BASE_CHECKS = [
|
|
|
165
177
|
const BASE = BASE_CHECKS.filter(c => c.id !== 'AI-003');
|
|
166
178
|
// the fuller AUTH_CHECKS version supersedes the early inline SEC-001
|
|
167
179
|
const B2 = BASE.filter(c => c.id !== 'SEC-001');
|
|
168
|
-
export const CHECKS = [...B2, ...AUTHZ_CHECKS, ...AI_CHECKS, ...DEPLOY_CHECKS, ...AUTH_CHECKS, ...FRAMEWORK_CHECKS, ...BATCH2_CHECKS, ...BATCH3_CHECKS, ...BATCH4_CHECKS];
|
|
180
|
+
export const CHECKS = [...B2, ...AUTHZ_CHECKS, ...AI_CHECKS, ...DEPLOY_CHECKS, ...AUTH_CHECKS, ...FRAMEWORK_CHECKS, ...BATCH2_CHECKS, ...BATCH3_CHECKS, ...BATCH4_CHECKS, ...INJECTION_CHECKS];
|
|
169
181
|
|
|
170
182
|
// Test and fixture files are not deployed. A "vulnerability" in a spec file is
|
|
171
183
|
// noise, and noise is what makes people stop reading findings.
|
package/src/fs-scan.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Cheap repo walk. Everything downstream reads from this one pass so we never
|
|
2
2
|
// hit the disk twice for the same file.
|
|
3
3
|
import { readdirSync, readFileSync, statSync, existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
4
|
-
import { join, relative, extname, resolve, sep } from 'node:path';
|
|
4
|
+
import { join, relative, extname, resolve, sep, dirname, posix } from 'node:path';
|
|
5
5
|
|
|
6
6
|
const SKIP = new Set([
|
|
7
7
|
'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
|
|
@@ -34,6 +34,98 @@ function containedRealPath(root, full) {
|
|
|
34
34
|
} catch { return null; }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
|
|
38
|
+
// ---- the whole ignore chain, not just the file at the scan root ------------
|
|
39
|
+
// .gitignore is resolved against the GIT repository root, which is often above
|
|
40
|
+
// the directory being scanned. Reading only `<scanroot>/.gitignore` told us our
|
|
41
|
+
// own api/.env was unprotected when the rule excluding it sits one level up, in
|
|
42
|
+
// the repo root - a CRITICAL finding that was simply wrong. Same shape as every
|
|
43
|
+
// other false positive we have had: it judged the file without the context that
|
|
44
|
+
// decides it.
|
|
45
|
+
//
|
|
46
|
+
// Read-only, and it reads .git directly rather than shelling out to git, which
|
|
47
|
+
// verify-readonly.mjs forbids.
|
|
48
|
+
function gitRoot(from) {
|
|
49
|
+
let d = resolve(from);
|
|
50
|
+
for (let i = 0; i < 40; i++) {
|
|
51
|
+
if (existsSync(join(d, '.git'))) return d;
|
|
52
|
+
const up = dirname(d);
|
|
53
|
+
if (up === d) return null;
|
|
54
|
+
d = up;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Enough of the gitignore syntax to answer "is this path excluded": globs,
|
|
60
|
+
// anchoring, directory-only rules and negation. Not the whole spec - but the
|
|
61
|
+
// alternative was a regex that guessed, and guessing is what produced the bug.
|
|
62
|
+
function toRegExp(pattern) {
|
|
63
|
+
let p = pattern;
|
|
64
|
+
const anchored = p.startsWith('/') || p.slice(0, -1).includes('/');
|
|
65
|
+
if (p.startsWith('/')) p = p.slice(1);
|
|
66
|
+
if (p.endsWith('/')) p = p.slice(0, -1);
|
|
67
|
+
let re = '';
|
|
68
|
+
for (let i = 0; i < p.length; i++) {
|
|
69
|
+
const c = p[i];
|
|
70
|
+
if (c === '*') {
|
|
71
|
+
if (p[i + 1] === '*') { re += '.*'; i++; if (p[i + 1] === '/') i++; }
|
|
72
|
+
else re += '[^/]*';
|
|
73
|
+
}
|
|
74
|
+
else if (c === '?') re += '[^/]';
|
|
75
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
76
|
+
}
|
|
77
|
+
return new RegExp('^' + (anchored ? '' : '(?:.*/)?') + re + '(?:/.*)?$');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Each rule is kept with the directory its .gitignore was written in, because
|
|
81
|
+
// that is what its paths are relative to. Exported so the test shim builds its
|
|
82
|
+
// matcher the same way rather than approximating it.
|
|
83
|
+
export function buildIgnore(rootAbs, sources) {
|
|
84
|
+
const rules = [];
|
|
85
|
+
for (const { dir, text } of sources) {
|
|
86
|
+
for (const raw of String(text ?? '').split('\n')) {
|
|
87
|
+
const line = raw.trim();
|
|
88
|
+
if (!line || line.startsWith('#')) continue;
|
|
89
|
+
const negate = line.startsWith('!');
|
|
90
|
+
const pattern = negate ? line.slice(1) : line;
|
|
91
|
+
if (!pattern) continue;
|
|
92
|
+
rules.push({ dir, re: toRegExp(pattern), negate });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// last matching rule wins, which is what git does
|
|
96
|
+
return (relPath) => {
|
|
97
|
+
const abs = resolve(rootAbs, relPath);
|
|
98
|
+
let ignored = false;
|
|
99
|
+
for (const r of rules) {
|
|
100
|
+
const rel = relative(r.dir, abs);
|
|
101
|
+
if (!rel || rel.startsWith('..')) continue;
|
|
102
|
+
if (r.re.test(rel.split(sep).join(posix.sep))) ignored = !r.negate;
|
|
103
|
+
}
|
|
104
|
+
return ignored;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Every .gitignore from the git root down to the scan root, plus .git/info/exclude.
|
|
109
|
+
export function ignoreChain(root) {
|
|
110
|
+
const rootAbs = resolve(root);
|
|
111
|
+
const gr = gitRoot(rootAbs);
|
|
112
|
+
const dirs = [];
|
|
113
|
+
if (gr) { let d = rootAbs; while (true) { dirs.unshift(d); if (d === gr) break; const up = dirname(d); if (up === d) break; d = up; } }
|
|
114
|
+
else dirs.push(rootAbs);
|
|
115
|
+
|
|
116
|
+
const sources = [];
|
|
117
|
+
const readAt = (dir, rel) => { try { return readFileSync(join(dir, rel), 'utf8'); } catch { return null; } };
|
|
118
|
+
for (const d of dirs) {
|
|
119
|
+
const t = readAt(d, '.gitignore');
|
|
120
|
+
if (t !== null) sources.push({ dir: d, text: t });
|
|
121
|
+
}
|
|
122
|
+
if (gr) {
|
|
123
|
+
const t = readAt(gr, join('.git', 'info', 'exclude'));
|
|
124
|
+
if (t !== null) sources.push({ dir: gr, text: t });
|
|
125
|
+
}
|
|
126
|
+
return buildIgnore(rootAbs, sources);
|
|
127
|
+
}
|
|
128
|
+
|
|
37
129
|
export function scanRepo(root, { maxFiles = 6000 } = {}) {
|
|
38
130
|
const files = [];
|
|
39
131
|
const rootAbs = resolve(root);
|
|
@@ -74,6 +166,7 @@ export function scanRepo(root, { maxFiles = 6000 } = {}) {
|
|
|
74
166
|
files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
|
|
75
167
|
exists: (rel) => existsSync(join(root, rel)),
|
|
76
168
|
read: (rel) => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; } },
|
|
169
|
+
isIgnored: ignoreChain(root),
|
|
77
170
|
};
|
|
78
171
|
}
|
|
79
172
|
|
package/src/rules.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "0.1",
|
|
3
|
-
"count":
|
|
3
|
+
"count": 289,
|
|
4
4
|
"rules": [
|
|
5
5
|
{
|
|
6
6
|
"id": "SEC-001",
|
|
@@ -1013,6 +1013,16 @@
|
|
|
1013
1013
|
"category": "data-and-scale"
|
|
1014
1014
|
},
|
|
1015
1015
|
{
|
|
1016
|
+
"id": "DATA-015",
|
|
1017
|
+
"title": "User input concatenated into a SQL query",
|
|
1018
|
+
"severity": "critical",
|
|
1019
|
+
"tier": 1,
|
|
1020
|
+
"check": "a request value interpolated into a SQL string instead of passed as a parameter",
|
|
1021
|
+
"note": "the oldest way to lose a database and still the most common. Parameterised queries are not a mitigation, they are the fix.",
|
|
1022
|
+
"origin": "gap",
|
|
1023
|
+
"category": "data-and-scale"
|
|
1024
|
+
},
|
|
1025
|
+
{
|
|
1016
1026
|
"id": "INF-001",
|
|
1017
1027
|
"title": "Single instance with no redundancy",
|
|
1018
1028
|
"severity": "medium",
|