launchprep 0.4.0 → 0.5.1
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 +21 -0
- package/net/client.mjs +7 -0
- package/net/commands.mjs +22 -1
- package/package.json +2 -2
- package/src/check-helpers.mjs +43 -0
- package/src/checks-ai.mjs +31 -7
- package/src/checks-auth.mjs +1 -4
- package/src/checks-authz.mjs +1 -3
- package/src/checks-batch2.mjs +4 -9
- package/src/checks-batch3.mjs +1 -3
- package/src/checks-batch4.mjs +5 -9
- package/src/checks-deploy.mjs +9 -15
- package/src/checks-frameworks.mjs +1 -3
- package/src/checks-injection.mjs +1 -15
- package/src/checks.mjs +83 -14
- package/src/detect.mjs +10 -3
- package/src/fs-scan.mjs +58 -13
- package/src/git-history.mjs +255 -0
- package/src/index.mjs +52 -4
- package/src/placeholder.mjs +72 -0
- package/src/questions.mjs +206 -0
- package/src/redact.mjs +7 -2
- package/src/report.mjs +41 -12
- package/src/rules.json +1 -1
- package/src/workspace.mjs +0 -0
package/README.md
CHANGED
|
@@ -69,6 +69,27 @@ 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
|
+
## Telling it about your app
|
|
73
|
+
|
|
74
|
+
Some things are not in the code — where your users live, whether your AI is
|
|
75
|
+
allowed to act, how many people you expect. Those checks are skipped, and the
|
|
76
|
+
scan lists them. Put the answers in `launchprep.json` at the top of your
|
|
77
|
+
project and run again:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"jurisdictions": ["eu"],
|
|
82
|
+
"has_accounts": true,
|
|
83
|
+
"llm_tools_enabled": true,
|
|
84
|
+
"stage": "production"
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
What you state beats what was detected, and the report shows it back under
|
|
89
|
+
**You said** so a wrong answer is visible rather than silently changing
|
|
90
|
+
results. The free scan is unlimited, so correcting and re-running costs
|
|
91
|
+
nothing.
|
|
92
|
+
|
|
72
93
|
## In CI
|
|
73
94
|
|
|
74
95
|
```bash
|
package/net/client.mjs
CHANGED
|
@@ -36,6 +36,13 @@ export const deepScan = ({ key, digest, profile, ruleIds, appName, idempotencyKe
|
|
|
36
36
|
rule_ids: ruleIds,
|
|
37
37
|
app_name: appName,
|
|
38
38
|
idempotency_key: idempotencyKey,
|
|
39
|
+
// What the digest read and what did not fit the size cap. The model is
|
|
40
|
+
// already told what it cannot see; this is so the CUSTOMER is told too,
|
|
41
|
+
// in the report - a big repo gets its most relevant slice, and reporting
|
|
42
|
+
// that slice as "everything" would let someone believe every file was
|
|
43
|
+
// checked when it was not.
|
|
44
|
+
files_read: digest.coverage?.filesRead ?? null,
|
|
45
|
+
files_skipped: digest.coverage?.filesSkipped ?? null,
|
|
39
46
|
}});
|
|
40
47
|
|
|
41
48
|
export { BASE };
|
package/net/commands.mjs
CHANGED
|
@@ -99,6 +99,17 @@ async function deep(args) {
|
|
|
99
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
100
|
process.exit(1);
|
|
101
101
|
}
|
|
102
|
+
// Not an error the customer did anything to cause, and nothing was spent -
|
|
103
|
+
// say both, or a 409 reads like a failure and they wonder if they lost a scan.
|
|
104
|
+
if (r.status === 409 && r.data?.error === 'scan_in_progress') {
|
|
105
|
+
const since = r.data.startedAt ? new Date(r.data.startedAt) : null;
|
|
106
|
+
const mins = since ? Math.max(1, Math.round((Date.now() - since.getTime()) / 60000)) : null;
|
|
107
|
+
console.error(`\n ${C.red}You already have a scan running.${C.off}`);
|
|
108
|
+
console.error(` ${C.d}Started ${mins ? mins + (mins === 1 ? ' minute' : ' minutes') + ' ago' : 'a moment ago'}` +
|
|
109
|
+
`. A scan takes about four minutes; wait for it to finish and run this again.${C.off}`);
|
|
110
|
+
console.error(` ${C.grn}No scan was used.${C.off}\n`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
102
113
|
if (r.status === 402 && (r.data?.error === 'attempt_ceiling' || r.data?.error === 'spend_ceiling')) {
|
|
103
114
|
console.error(`\n ${C.red}This key has run too many scans that did not finish.${C.off}`);
|
|
104
115
|
console.error(` ${C.d}${r.data.attempts} attempts against a ${r.data.limit}-scan licence. Something is going`);
|
|
@@ -150,7 +161,10 @@ async function deep(args) {
|
|
|
150
161
|
if (r.data.refunded) {
|
|
151
162
|
console.log(` ${C.grn}This scan did not finish, so it does not count against your five.${C.off}`);
|
|
152
163
|
}
|
|
153
|
-
|
|
164
|
+
const failed = missed.filter(m => !m.declined), declined = missed.filter(m => m.declined);
|
|
165
|
+
if (failed.length) console.log(` ${C.d}Run it again and these usually complete.${C.off}`);
|
|
166
|
+
if (declined.length) console.log(` ${C.d}${declined.length} ${declined.length === 1 ? 'was' : 'were'} declined by the review model for this code — a re-run will not change that.${C.off}`);
|
|
167
|
+
console.log('');
|
|
154
168
|
}
|
|
155
169
|
|
|
156
170
|
if (r.data.report?.url) {
|
|
@@ -158,6 +172,13 @@ async function deep(args) {
|
|
|
158
172
|
console.log(` ${C.o}${r.data.report.url}${C.off}`);
|
|
159
173
|
console.log(` ${C.d}dated, shareable, and it expires in ${r.data.report.expiresInDays} days${C.off}\n`);
|
|
160
174
|
}
|
|
175
|
+
// A big repo gets its most security-relevant slice, not every file. Saying
|
|
176
|
+
// nothing here would let someone believe every file was checked. One line,
|
|
177
|
+
// only when something was actually left out.
|
|
178
|
+
if (digest.coverage?.filesSkipped > 0) {
|
|
179
|
+
console.log(` ${C.d}Read the ${digest.coverage.filesRead} most security-relevant files; ` +
|
|
180
|
+
`${digest.coverage.filesSkipped} did not fit this scan's size cap and were not checked.${C.off}\n`);
|
|
181
|
+
}
|
|
161
182
|
console.log(` ${C.d}${r.data.scansRemaining} deep scan${r.data.scansRemaining === 1 ? '' : 's'} left${C.off}\n`);
|
|
162
183
|
|
|
163
184
|
// --fail-on <severity>: the CI gate, same contract as the free scan. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "launchprep",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"verify": "node scripts/verify-readonly.mjs",
|
|
19
19
|
"pretest": "node scripts/verify-readonly.mjs",
|
|
20
|
-
"test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs",
|
|
20
|
+
"test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.mjs && node test/standards.mjs",
|
|
21
21
|
"prepack": "node scripts/prepare-publish.mjs",
|
|
22
22
|
"postpack": "node scripts/restore-after-publish.mjs"
|
|
23
23
|
},
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// The helpers every tier-1 check file used to define for itself, byte-for-byte,
|
|
2
|
+
// in ten separate copies. That duplication is not harmless: it is exactly how
|
|
3
|
+
// isServer drifted into three different regexes, one of them still carrying a
|
|
4
|
+
// bug already fixed in another. One definition here means one behaviour
|
|
5
|
+
// everywhere, and a fix lands in one place.
|
|
6
|
+
//
|
|
7
|
+
// Only the provably-identical helpers live here. isServer is deliberately NOT
|
|
8
|
+
// among them - its three copies genuinely disagree about which directories are
|
|
9
|
+
// "server", and unifying them is a behaviour change that needs its own corpus
|
|
10
|
+
// pass, not a mechanical dedup.
|
|
11
|
+
|
|
12
|
+
// A finding is just this shape. Named so a check reads as prose.
|
|
13
|
+
export const finding = (id, title, severity, file, line, detail, fix) =>
|
|
14
|
+
({ id, title, severity, file, line, detail, fix });
|
|
15
|
+
|
|
16
|
+
// 1-based line number of a byte offset.
|
|
17
|
+
export const lineOf = (text, i) => text.slice(0, i).split('\n').length;
|
|
18
|
+
|
|
19
|
+
// The whole source line containing a byte offset (no trailing newline).
|
|
20
|
+
export const lineAt = (t, i) => {
|
|
21
|
+
const a = t.lastIndexOf('\n', i) + 1;
|
|
22
|
+
const b = t.indexOf('\n', i);
|
|
23
|
+
return t.slice(a, b === -1 ? t.length : b);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Is this path source code we should read the body of?
|
|
27
|
+
export const isCode = (p) => /\.(ts|tsx|js|jsx|mjs|cjs|py)$/.test(p);
|
|
28
|
+
|
|
29
|
+
// The text with comments blanked to spaces (line length and offsets preserved),
|
|
30
|
+
// so a pattern cannot match inside a // or /* */ or a leading-# comment. This is
|
|
31
|
+
// the guard behind "judge the line, not a 200-char neighbourhood".
|
|
32
|
+
export const codeOnly = (t) => {
|
|
33
|
+
let out = '', i = 0;
|
|
34
|
+
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
35
|
+
while (i < t.length) {
|
|
36
|
+
const two = t.slice(i, i + 2);
|
|
37
|
+
if (two === '//') { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
|
|
38
|
+
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; }
|
|
39
|
+
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; }
|
|
40
|
+
out += t[i]; i++;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
};
|
package/src/checks-ai.mjs
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
+
import { finding, lineOf, isCode } from './check-helpers.mjs';
|
|
1
2
|
// AI cost and safety. Barely covered by the corpus, which is exactly why it
|
|
2
3
|
// matters: this is the family nobody else is checking.
|
|
3
|
-
|
|
4
|
-
({ id, title, severity, file, line, detail, fix });
|
|
4
|
+
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
|
|
7
|
+
// (^|/) not just / — a slash was REQUIRED before the word, so a project laid
|
|
8
|
+
// out as api/src/... or server/src/... or backend/src/... never matched as a
|
|
9
|
+
// server, and then matched as a browser because it contains /src/. Result: the
|
|
10
|
+
// scanner told people running a plain Node backend that their API key was
|
|
11
|
+
// "shipped to every visitor". It fired twice on this repository's own api/.
|
|
12
|
+
// A fabricated CRITICAL is the failure this whole product is built to avoid.
|
|
13
|
+
// 'backend' and 'worker' added because they are as common as 'server'.
|
|
14
|
+
const isServer = (p) => /(^|\/)(api|routes?|server|backend|actions?|controllers?|handlers?|endpoints?|resolvers?|lib|services?|workers?|jobs?)\//.test(p);
|
|
15
|
+
const isClient = (p) => /(^|\/)(components?|pages?|app|src|client|frontend|ui|views?)\//.test(p) && !isServer(p);
|
|
10
16
|
|
|
11
17
|
const LLM_CALL = /\.(messages|chat\.completions|completions|responses)\.create\s*\(|generateText\s*\(|streamText\s*\(|\.generateContent\s*\(/;
|
|
12
18
|
|
|
@@ -148,12 +154,24 @@ export const AI_CHECKS = [
|
|
|
148
154
|
const out = [];
|
|
149
155
|
for (const f of repo.files) {
|
|
150
156
|
if (!f.text || !isCode(f.path)) continue;
|
|
151
|
-
|
|
157
|
+
// `exec` as a bare word is two different things. `child_process.exec(cmd)`
|
|
158
|
+
// runs a program; `re.exec(text)` is how JavaScript matches a regular
|
|
159
|
+
// expression, and appears in ordinary code constantly - three times in
|
|
160
|
+
// this scanner's own source, which is how it reported three CRITICALs
|
|
161
|
+
// about itself. So `.exec(` preceded by a dot only counts when the thing
|
|
162
|
+
// before the dot is actually child_process.
|
|
163
|
+
const re = /(?<![.\w])(eval|execSync|execFileSync|spawnSync|Function)\s*\(|(?<![.\w])exec\s*\(|\b(?:child_process|cp|shell)\.exec(?:File)?\s*\(|\.query\s*\(\s*`/g;
|
|
152
164
|
let m;
|
|
153
165
|
while ((m = re.exec(f.text))) {
|
|
154
166
|
const around = f.text.slice(Math.max(0, m.index - 600), m.index + 200);
|
|
155
167
|
if (!LLM_CALL.test(around) &&
|
|
156
168
|
!/\b(completion|aiResponse|modelOutput|generated|llmResult)\b/i.test(around)) continue;
|
|
169
|
+
// "generated" and "completion" are ordinary English words. Requiring
|
|
170
|
+
// the neighbourhood to ALSO look like a model call stops a comment
|
|
171
|
+
// containing "generated" from turning a regex into a CRITICAL.
|
|
172
|
+
const ls = f.text.lastIndexOf('\n', m.index) + 1;
|
|
173
|
+
let le = f.text.indexOf('\n', m.index); if (le < 0) le = f.text.length;
|
|
174
|
+
if (looksLikePlaceholder(m[0], f.text.slice(ls, le), f.path)) continue;
|
|
157
175
|
out.push(finding('AI-008', 'Model output used in a privileged operation', 'critical',
|
|
158
176
|
f.path, lineOf(f.text, m.index),
|
|
159
177
|
'What the model returns is being executed or used to build a query. Anyone who can steer the model can steer this.',
|
|
@@ -187,6 +205,12 @@ export const AI_CHECKS = [
|
|
|
187
205
|
// ---- provider key used straight from the browser -------------------------
|
|
188
206
|
{ id: 'SEC-006', run(repo, profile) {
|
|
189
207
|
if (!profile.calls_llm) return [];
|
|
208
|
+
// An app with no browser cannot ship a key to one. profile -> gate -> check
|
|
209
|
+
// is the whole idea of this scanner, and this check skipped the profile
|
|
210
|
+
// step: on an api-only, CLI or library project every src/ file looked like
|
|
211
|
+
// browser code, so a plain Node script was told its key is "shipped to
|
|
212
|
+
// every visitor" when it has no visitors.
|
|
213
|
+
if (['api-only', 'cli', 'library', 'mobile-ios', 'mobile-android'].includes(profile.surface)) return [];
|
|
190
214
|
const out = [];
|
|
191
215
|
for (const f of repo.files) {
|
|
192
216
|
if (!f.text || !isCode(f.path) || !isClient(f.path)) continue;
|
package/src/checks-auth.mjs
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
+
import { finding, lineOf, isCode } from './check-helpers.mjs';
|
|
1
2
|
// Accounts, sessions, and the secrets that protect them.
|
|
2
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
3
|
-
({ id, title, severity, file, line, detail, fix });
|
|
4
|
-
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
5
|
-
const isCode = (p) => /\.(ts|tsx|js|jsx|mjs|cjs|py)$/.test(p);
|
|
6
3
|
|
|
7
4
|
const RATE_LIMITER =
|
|
8
5
|
/rate-?limit|rateLimit|Ratelimit|throttle|slowDown|@upstash\/ratelimit|express-rate-limit|limiter\.|bottleneck/i;
|
package/src/checks-authz.mjs
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
+
import { finding, lineOf } from './check-helpers.mjs';
|
|
1
2
|
// Authorization: "is THIS user allowed to touch THIS record?"
|
|
2
3
|
// The corpus calls this the most common real-world vulnerability, and it is the
|
|
3
4
|
// family most likely to find something true in a vibe-coded app.
|
|
4
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
5
|
-
({ id, title, severity, file, line, detail, fix });
|
|
6
5
|
|
|
7
|
-
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
8
6
|
|
|
9
7
|
// Everything between a handler's opening brace and its matching close, so we can
|
|
10
8
|
// ask "does this handler mention the logged-in user anywhere?"
|
package/src/checks-batch2.mjs
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
+
import { finding, lineOf, lineAt } from './check-helpers.mjs';
|
|
1
2
|
// The remaining critical and high tier-1 checks.
|
|
2
3
|
// Lesson applied throughout: judge the line, read the value, never the name alone.
|
|
3
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
4
|
-
({ id, title, severity, file, line, detail, fix });
|
|
5
|
-
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
6
|
-
const lineAt = (t, i) => {
|
|
7
|
-
const a = t.lastIndexOf('\n', i) + 1;
|
|
8
|
-
const b = t.indexOf('\n', i);
|
|
9
|
-
return t.slice(a, b === -1 ? t.length : b);
|
|
10
|
-
};
|
|
11
4
|
const isJS = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
|
|
12
|
-
|
|
5
|
+
// anchored (^|\/) so a server dir at the REPO ROOT (api/x.ts) matches too -
|
|
6
|
+
// the same root-level miss fixed in checks-batch4/API-010. Same list, additive.
|
|
7
|
+
const isServer = (p) => /(^|\/)(api|routes?|server|actions?|controllers?|handlers?)\//.test(p);
|
|
13
8
|
|
|
14
9
|
export const BATCH2_CHECKS = [
|
|
15
10
|
|
package/src/checks-batch3.mjs
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
+
import { finding, lineOf } from './check-helpers.mjs';
|
|
1
2
|
// Remaining tier-1 checks: data, deployment, framework config, real-user readiness.
|
|
2
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
3
|
-
({ id, title, severity, file, line, detail, fix });
|
|
4
|
-
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
5
3
|
const isJS = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
|
|
6
4
|
const isProdSettings = (p) =>
|
|
7
5
|
/settings/i.test(p) && !/local|dev|development|test|example|template/i.test(p);
|
package/src/checks-batch4.mjs
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
|
+
import { finding, lineOf, lineAt } from './check-helpers.mjs';
|
|
1
2
|
// The last of the tier-1 checks.
|
|
2
3
|
//
|
|
3
4
|
// Discipline, same as everywhere else in this directory: read the value, not the
|
|
4
5
|
// identifier. Judge the line, not a 200-character neighbourhood. Require evidence
|
|
5
6
|
// that the thing is used the dangerous way before saying a word.
|
|
6
7
|
|
|
7
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
8
|
-
({ id, title, severity, file, line, detail, fix });
|
|
9
8
|
|
|
10
|
-
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
11
|
-
const lineAt = (t, i) => {
|
|
12
|
-
const a = t.lastIndexOf('\n', i) + 1;
|
|
13
|
-
const b = t.indexOf('\n', i);
|
|
14
|
-
return t.slice(a, b === -1 ? t.length : b);
|
|
15
|
-
};
|
|
16
9
|
// the whole call expression starting at i, bounded so we never judge a neighbour
|
|
17
10
|
const callAt = (t, i, max = 260) => {
|
|
18
11
|
const open = t.indexOf('(', i);
|
|
@@ -26,7 +19,10 @@ const callAt = (t, i, max = 260) => {
|
|
|
26
19
|
};
|
|
27
20
|
const isJS = (p) => /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(p);
|
|
28
21
|
const isServer = (p) =>
|
|
29
|
-
|
|
22
|
+
// anchored with (^|\/): a directory at the REPO ROOT (api/client.ts) has no
|
|
23
|
+
// leading slash, so /\/api\// missed it and API-010 skipped every root-level
|
|
24
|
+
// api/ file. Same list as before - this only adds the start-of-path case.
|
|
25
|
+
/(^|\/)(api|routes?|server|actions?|controllers?|handlers?|jobs?|workers?|lib|services?)\//.test(p);
|
|
30
26
|
|
|
31
27
|
// union of every manifest in the repo - monorepos keep the root one empty
|
|
32
28
|
const depNames = (repo) => {
|
package/src/checks-deploy.mjs
CHANGED
|
@@ -1,24 +1,12 @@
|
|
|
1
|
+
import { finding, lineOf, codeOnly } from './check-helpers.mjs';
|
|
1
2
|
// Deployment and transport. Mechanically detectable, high volume.
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
3
|
+
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
4
|
+
|
|
5
5
|
|
|
6
6
|
// Comments blanked to spaces - offsets and line numbers survive, the words do
|
|
7
7
|
// not. Our own server.mjs opens by explaining that it does NOT use
|
|
8
8
|
// express.json(), and DATA-008 read that sentence as the call itself. Twelfth
|
|
9
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
10
|
|
|
23
11
|
export const DEPLOY_CHECKS = [
|
|
24
12
|
|
|
@@ -33,6 +21,12 @@ export const DEPLOY_CHECKS = [
|
|
|
33
21
|
f.text.split('\n').forEach((line, i) => {
|
|
34
22
|
if (/\$\{\{\s*secrets\./.test(line)) return; // the correct pattern
|
|
35
23
|
const m = line.match(SECRET_VALUE);
|
|
24
|
+
// Every project that tests against a database puts a throwaway one in
|
|
25
|
+
// CI - postgres:postgres@localhost/..._test. That is not a credential,
|
|
26
|
+
// and flagging it as a CRITICAL on nearly every real repository is how
|
|
27
|
+
// a scanner loses the right to be believed. This repo's own CI was
|
|
28
|
+
// flagged by this check.
|
|
29
|
+
if (m && looksLikePlaceholder(m[0], line, f.path)) return;
|
|
36
30
|
if (m) out.push(finding('DEP-002', 'Credential written into the CI config', 'critical',
|
|
37
31
|
f.path, i + 1,
|
|
38
32
|
'This value is committed to the repository and printed in build logs. Anyone with read access to either has it.',
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
+
import { finding, lineOf } from './check-helpers.mjs';
|
|
1
2
|
// Django and Rails. Both ship deliberately permissive defaults for local
|
|
2
3
|
// development, and both document exactly which ones must change before deploying.
|
|
3
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
4
|
-
({ id, title, severity, file, line, detail, fix });
|
|
5
|
-
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
6
4
|
|
|
7
5
|
// a settings file that is meant for production, not somebody's laptop
|
|
8
6
|
const isProdSettings = (p) =>
|
package/src/checks-injection.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finding, lineOf, codeOnly } from './check-helpers.mjs';
|
|
1
2
|
// User input concatenated into a SQL query.
|
|
2
3
|
//
|
|
3
4
|
// This was missing from all 288 rules. A project with `${req.params.id}` dropped
|
|
@@ -18,25 +19,10 @@
|
|
|
18
19
|
// And then the safe forms are subtracted, because every one of them would
|
|
19
20
|
// otherwise fire on correct code.
|
|
20
21
|
|
|
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
22
|
|
|
25
23
|
// Comments blanked to spaces; offsets and line numbers survive, the words do
|
|
26
24
|
// not. A comment reading "never do `SELECT * FROM users WHERE id = ${id}`" is
|
|
27
25
|
// 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
26
|
|
|
41
27
|
// A verb and a clause. "select" alone matches a React prop, a CSS selector, a
|
|
42
28
|
// variable called selectedUser and about forty other innocent things.
|
package/src/checks.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { finding, lineOf } from './check-helpers.mjs';
|
|
1
2
|
// Tier-1 checks: deterministic, no LLM, no network. Each returns findings with
|
|
2
3
|
// a file:line so the user can go straight to it.
|
|
3
4
|
import { AUTHZ_CHECKS } from './checks-authz.mjs';
|
|
@@ -9,14 +10,40 @@ import { FRAMEWORK_CHECKS } from './checks-frameworks.mjs';
|
|
|
9
10
|
import { BATCH2_CHECKS } from './checks-batch2.mjs';
|
|
10
11
|
import { BATCH3_CHECKS } from './checks-batch3.mjs';
|
|
11
12
|
import { BATCH4_CHECKS } from './checks-batch4.mjs';
|
|
13
|
+
import { scanGitHistory } from './git-history.mjs';
|
|
14
|
+
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
15
|
+
import { makeView } from './fs-scan.mjs';
|
|
12
16
|
|
|
13
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
14
|
-
({ id, title, severity, file, line, detail, fix });
|
|
15
17
|
|
|
16
|
-
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
17
18
|
|
|
18
19
|
const BASE_CHECKS = [
|
|
19
20
|
|
|
21
|
+
// SEC-009 — a key that was committed, then "removed". SEC-001/003 read the
|
|
22
|
+
// working tree, so deleting the line hides it from them while it stays in
|
|
23
|
+
// .git forever and travels with every clone. scope:'root' because history is
|
|
24
|
+
// a property of the repository, not of a workspace.
|
|
25
|
+
//
|
|
26
|
+
// Only vendor-prefixed keys are looked for. See the long note in
|
|
27
|
+
// git-history.mjs: a test private key and a real one are byte-identical, so
|
|
28
|
+
// including those types fired on 15 of 68 real repositories and every hit was
|
|
29
|
+
// a fixture. Narrowed to unmistakable prefixes it fires on 0 of 20.
|
|
30
|
+
{ id:'SEC-009', scope:'root', run(repo){
|
|
31
|
+
const res = scanGitHistory(repo.root);
|
|
32
|
+
if (!res.findings.length) return [];
|
|
33
|
+
const kinds = [...new Set(res.findings.map(f => f.label))];
|
|
34
|
+
const what = kinds.length === 1 ? kinds[0]
|
|
35
|
+
: `${kinds.slice(0, -1).join(', ')} and ${kinds[kinds.length - 1]}`;
|
|
36
|
+
const n = res.findings.length;
|
|
37
|
+
return [finding('SEC-009',
|
|
38
|
+
`${n === 1 ? 'A key was' : `${n} keys were`} committed and is still in your git history`,
|
|
39
|
+
'critical', '.git', 1,
|
|
40
|
+
`Your ${what} appears in an old commit. Removing it from the file did not remove it — ` +
|
|
41
|
+
'every clone, fork and fetch of this repository still carries it, and anyone who has ever ' +
|
|
42
|
+
'had a copy can read it. Treat it as public.',
|
|
43
|
+
'Rotate the key at the provider now — that is what actually stops it being used. ' +
|
|
44
|
+
'Rewriting history afterwards is optional and does not help anyone who already cloned.')];
|
|
45
|
+
}},
|
|
46
|
+
|
|
20
47
|
{ id:'SEC-002', run(repo){
|
|
21
48
|
const gi = repo.read('.gitignore');
|
|
22
49
|
if (gi === null) return [];
|
|
@@ -46,6 +73,18 @@ const BASE_CHECKS = [
|
|
|
46
73
|
const full = m[1]+m[2];
|
|
47
74
|
if (PUBLIC_BY_DESIGN.test(m[2]) || NOT_A_CREDENTIAL.test(m[2])) continue;
|
|
48
75
|
|
|
76
|
+
// Require EVIDENCE this is a real env var, not the NAME of one sitting
|
|
77
|
+
// inside a regex, a string list or a comment. On a real project this
|
|
78
|
+
// matched `PUBLIC_KEY` in the alternation /(...|PUBLIC_KEY|...)/ and
|
|
79
|
+
// called it an exposed secret - the recurring "name in a pattern" FP
|
|
80
|
+
// this codebase keeps relearning. Two forms count as real: an assignment
|
|
81
|
+
// (NEXT_PUBLIC_X = / :), or an env access (process.env.NEXT_PUBLIC_X).
|
|
82
|
+
const after = f.text.slice(m.index + m[0].length, m.index + m[0].length + 24);
|
|
83
|
+
const before = f.text.slice(Math.max(0, m.index - 16), m.index);
|
|
84
|
+
const isAssignment = /^[A-Z0-9_]*\s*[:=](?!=)/.test(after); // NAME= or NAME: (not ==)
|
|
85
|
+
const isEnvAccess = /env\.$|env\[['\"`]?$|env\.get\(['\"`]?$/i.test(before);
|
|
86
|
+
if (!isAssignment && !isEnvAccess) continue;
|
|
87
|
+
|
|
49
88
|
const certain = DEFINITELY_SECRET.test(m[2]);
|
|
50
89
|
out.push(finding('SEC-004',
|
|
51
90
|
certain ? 'Server secret exposed to the browser' : 'Possible secret exposed to the browser',
|
|
@@ -85,6 +124,14 @@ const BASE_CHECKS = [
|
|
|
85
124
|
for (const [re,label] of PATTERNS){
|
|
86
125
|
let m; re.lastIndex=0;
|
|
87
126
|
while ((m=re.exec(f.text))){
|
|
127
|
+
// The shape of a key is not a key. AWS publishes AKIAIOSFODNN7EXAMPLE
|
|
128
|
+
// in its own docs; a regex that LOOKS FOR keys contains one by
|
|
129
|
+
// definition; a test fixture is meant to. Scanning this repository
|
|
130
|
+
// produced four CRITICALs and every one was the scanner reading its
|
|
131
|
+
// own detection patterns back to itself.
|
|
132
|
+
const ls = f.text.lastIndexOf('\n', m.index) + 1;
|
|
133
|
+
let le = f.text.indexOf('\n', m.index); if (le < 0) le = f.text.length;
|
|
134
|
+
if (looksLikePlaceholder(m[0], f.text.slice(ls, le), f.path)) continue;
|
|
88
135
|
out.push(finding('SEC-003',`Hardcoded ${label}`,'critical',
|
|
89
136
|
f.path, lineOf(f.text,m.index),
|
|
90
137
|
`A live ${label} is written directly into this file.`,
|
|
@@ -132,11 +179,29 @@ const BASE_CHECKS = [
|
|
|
132
179
|
}},
|
|
133
180
|
|
|
134
181
|
{ id:'DEP-010', run(repo){
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
182
|
+
// "Dependencies unpinned." The no-lockfile half of this rule is DEP-011's
|
|
183
|
+
// job now (root-scoped, higher severity) - both firing on no-lockfile was a
|
|
184
|
+
// duplicate finding at two severities. This half is the one DEP-011 does
|
|
185
|
+
// NOT cover and a lockfile does NOT save you from: a spec that means
|
|
186
|
+
// "whatever is newest" no matter what - a wildcard, a dist-tag, or a
|
|
187
|
+
// git/URL target that can move under the same string. Caret and tilde
|
|
188
|
+
// ranges are the norm and a lockfile pins them, so they are not flagged -
|
|
189
|
+
// that would be noise on nearly every project.
|
|
190
|
+
const raw = repo.read('package.json');
|
|
191
|
+
if (!raw) return [];
|
|
192
|
+
let pkg; try { pkg = JSON.parse(raw); } catch { return []; }
|
|
193
|
+
const deps = { ...(pkg.dependencies||{}), ...(pkg.devDependencies||{}) };
|
|
194
|
+
const UNPINNED = /^(\*|x|latest|next|\d+\.(x|\*)|\d+\.\d+\.(x|\*))$/i;
|
|
195
|
+
const MOVING = /^(git\+|github:|git:|https?:|file:)/i;
|
|
196
|
+
const bad = Object.entries(deps)
|
|
197
|
+
.filter(([, v]) => { const t = String(v).trim(); return UNPINNED.test(t) || MOVING.test(t); });
|
|
198
|
+
if (!bad.length) return [];
|
|
199
|
+
const names = bad.slice(0, 5).map(([n, v]) => `${n}: ${v}`).join(', ');
|
|
200
|
+
return [finding('DEP-010', 'Dependencies pinned to a moving target', 'medium', 'package.json', 1,
|
|
201
|
+
`${bad.length} dependency${bad.length === 1 ? ' resolves' : 'ies resolve'} to whatever is newest — ${names}` +
|
|
202
|
+
(bad.length > 5 ? ', and more' : '') + '. Even with a lockfile, anyone who installs without it gets ' +
|
|
203
|
+
'a different version than you tested, and the same string can mean new code tomorrow.',
|
|
204
|
+
'Pin these to a version — an exact one, or a caret range at least — so the same string always means the same code.')];
|
|
140
205
|
}},
|
|
141
206
|
|
|
142
207
|
{ id:'API-002', run(repo,profile){
|
|
@@ -185,12 +250,16 @@ const IS_TEST = /(^|\/)(tests?|__tests__|__mocks__|spec|e2e|fixtures?|examples?)
|
|
|
185
250
|
|
|
186
251
|
function withoutTests(repo){
|
|
187
252
|
const files = repo.files.filter(f => !IS_TEST.test(f.path));
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
253
|
+
// has/find/grep run over the tests-excluded files; exists/read/isIgnored keep
|
|
254
|
+
// delegating to the underlying repo (disk-backed on the root scan), so a check
|
|
255
|
+
// can still ask about a file that was filtered here. Built through makeView so
|
|
256
|
+
// it carries the same capability set as every other view - the previous
|
|
257
|
+
// {...repo} spread inherited those three by luck, which is the drift this
|
|
258
|
+
// consolidation removes.
|
|
259
|
+
return makeView({
|
|
260
|
+
files, root: repo.root,
|
|
261
|
+
read: repo.read, exists: repo.exists, isIgnored: repo.isIgnored,
|
|
262
|
+
});
|
|
194
263
|
}
|
|
195
264
|
|
|
196
265
|
// Some facts live at the repo root and nowhere else - the lockfile, the CI
|
package/src/detect.mjs
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
// Profile detection. Every fact carries
|
|
2
|
-
//
|
|
1
|
+
// Profile detection. Every fact carries a value and the evidence for it.
|
|
2
|
+
//
|
|
3
|
+
// There used to be a third field, `confidence`, and a comment promising "LOW
|
|
4
|
+
// confidence never produces a fail - the gate turns it into a question". The
|
|
5
|
+
// gate never read it: the mechanism was described but never wired, so the field
|
|
6
|
+
// was dead data on every fact and the comment described behaviour that did not
|
|
7
|
+
// exist. Removed. The confidence argument is still ACCEPTED at the ~90 call
|
|
8
|
+
// sites (harmless positional documentation of how sure the detector is) but is
|
|
9
|
+
// no longer stored, so nothing can come to depend on a value nothing computes.
|
|
3
10
|
import { readPackageJson, allDeps, stripComments, isWorkspaceRoot } from './fs-scan.mjs';
|
|
4
11
|
|
|
5
|
-
const fact = (value,
|
|
12
|
+
const fact = (value, _confidence, evidence = []) => ({ value, evidence });
|
|
6
13
|
const dep = (deps, ...names) => names.find(n => deps[n] !== undefined);
|
|
7
14
|
|
|
8
15
|
// ---------- stack ----------
|