launchprep 0.5.0 → 0.5.2
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/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 +1 -4
- package/src/checks-auth.mjs +18 -7
- 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 +1 -15
- package/src/checks-frameworks.mjs +1 -3
- package/src/checks-injection.mjs +1 -15
- package/src/checks.mjs +47 -14
- package/src/detect.mjs +10 -3
- package/src/fs-scan.mjs +58 -13
- package/src/index.mjs +131 -67
- package/src/interactive.mjs +100 -0
- package/src/redact.mjs +7 -2
- package/src/report.mjs +23 -3
- package/src/workspace.mjs +0 -0
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.5.
|
|
3
|
+
"version": "0.5.2",
|
|
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 && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.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 && node test/interactive.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,9 @@
|
|
|
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
|
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
4
5
|
|
|
5
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
6
|
-
({ id, title, severity, file, line, detail, fix });
|
|
7
6
|
|
|
8
|
-
const lineOf = (text, i) => text.slice(0, i).split('\n').length;
|
|
9
|
-
const isCode = (p) => /\.(ts|tsx|js|jsx|mjs|cjs|py)$/.test(p);
|
|
10
7
|
// (^|/) not just / — a slash was REQUIRED before the word, so a project laid
|
|
11
8
|
// out as api/src/... or server/src/... or backend/src/... never matched as a
|
|
12
9
|
// server, and then matched as a browser because it contains /src/. Result: the
|
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;
|
|
@@ -118,11 +115,25 @@ export const AUTH_CHECKS = [
|
|
|
118
115
|
const out = [];
|
|
119
116
|
for (const f of repo.files) {
|
|
120
117
|
if (!f.text || !isCode(f.path)) continue;
|
|
121
|
-
const re = /createHash\s*\(\s*['"](md5|sha1|sha256|sha512)['"]\)
|
|
118
|
+
const re = /createHash\s*\(\s*['"](md5|sha1|sha256|sha512)['"]\)\s*\.update\s*\(\s*([A-Za-z0-9_$.]+)?/g;
|
|
122
119
|
let m;
|
|
123
120
|
while ((m = re.exec(f.text))) {
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
// Judge what is being HASHED, not a 400-char neighbourhood. On real
|
|
122
|
+
// code this fired CRITICAL on `hashToken(raw){ createHash('sha256')
|
|
123
|
+
// .update(raw) }` because the word "password" appeared elsewhere in the
|
|
124
|
+
// file (a password-policy import). sha256 on a high-entropy token - a
|
|
125
|
+
// reset token, a session token, a CSRF value - is correct; on a user's
|
|
126
|
+
// password it is the vulnerability. The thing being hashed decides.
|
|
127
|
+
const arg = m[2] || '';
|
|
128
|
+
const TOKEN = /token|csrf|nonce|otp|session|reset|verif|magic|digest|\braw\b|hmac|signature|fingerprint|etag|checksum|api[_-]?key/i;
|
|
129
|
+
const PASS = /password|passwd|\bpwd\b|\bpass\b/i;
|
|
130
|
+
if (TOKEN.test(arg)) continue; // hashing a token, not a password
|
|
131
|
+
const ctx = f.text.slice(Math.max(0, m.index - 200), m.index + 80);
|
|
132
|
+
const ctxToken = /token|csrf|nonce|\botp\b|session|reset|verif|magic|fingerprint/i.test(ctx);
|
|
133
|
+
const ctxPass = /password|passwd|\bpwd\b/i.test(ctx);
|
|
134
|
+
// fire only when a password is what is hashed here: the argument names
|
|
135
|
+
// it, or the immediate surroundings do and are not about a token.
|
|
136
|
+
if (!(PASS.test(arg) || (ctxPass && !ctxToken))) continue;
|
|
126
137
|
out.push(finding('AUTH-008', `Passwords hashed with ${m[1]}`, 'critical',
|
|
127
138
|
f.path, lineOf(f.text, m.index),
|
|
128
139
|
`${m[1]} is built to be fast, which is exactly wrong for passwords — a modern GPU tries billions of guesses per second. If your database leaks, the passwords are recoverable.`,
|
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,26 +1,12 @@
|
|
|
1
|
+
import { finding, lineOf, codeOnly } from './check-helpers.mjs';
|
|
1
2
|
// Deployment and transport. Mechanically detectable, high volume.
|
|
2
3
|
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
3
4
|
|
|
4
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
5
|
-
({ id, title, severity, file, line, detail, fix });
|
|
6
|
-
const lineOf = (t, i) => t.slice(0, i).split('\n').length;
|
|
7
5
|
|
|
8
6
|
// Comments blanked to spaces - offsets and line numbers survive, the words do
|
|
9
7
|
// not. Our own server.mjs opens by explaining that it does NOT use
|
|
10
8
|
// express.json(), and DATA-008 read that sentence as the call itself. Twelfth
|
|
11
9
|
// time this shape has bitten: it matched a name where no code was.
|
|
12
|
-
const codeOnly = (t) => {
|
|
13
|
-
let out = '', i = 0;
|
|
14
|
-
const blank = (s) => s.replace(/[^\n]/g, ' ');
|
|
15
|
-
while (i < t.length) {
|
|
16
|
-
const two = t.slice(i, i + 2);
|
|
17
|
-
if (two === '//') { const e = t.indexOf('\n', i); const j = e === -1 ? t.length : e; out += blank(t.slice(i, j)); i = j; continue; }
|
|
18
|
-
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; }
|
|
19
|
-
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; }
|
|
20
|
-
out += t[i]; i++;
|
|
21
|
-
}
|
|
22
|
-
return out;
|
|
23
|
-
};
|
|
24
10
|
|
|
25
11
|
export const DEPLOY_CHECKS = [
|
|
26
12
|
|
|
@@ -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';
|
|
@@ -11,11 +12,9 @@ import { BATCH3_CHECKS } from './checks-batch3.mjs';
|
|
|
11
12
|
import { BATCH4_CHECKS } from './checks-batch4.mjs';
|
|
12
13
|
import { scanGitHistory } from './git-history.mjs';
|
|
13
14
|
import { looksLikePlaceholder } from './placeholder.mjs';
|
|
15
|
+
import { makeView } from './fs-scan.mjs';
|
|
14
16
|
|
|
15
|
-
const finding = (id, title, severity, file, line, detail, fix) =>
|
|
16
|
-
({ id, title, severity, file, line, detail, fix });
|
|
17
17
|
|
|
18
|
-
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
19
18
|
|
|
20
19
|
const BASE_CHECKS = [
|
|
21
20
|
|
|
@@ -74,6 +73,18 @@ const BASE_CHECKS = [
|
|
|
74
73
|
const full = m[1]+m[2];
|
|
75
74
|
if (PUBLIC_BY_DESIGN.test(m[2]) || NOT_A_CREDENTIAL.test(m[2])) continue;
|
|
76
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
|
+
|
|
77
88
|
const certain = DEFINITELY_SECRET.test(m[2]);
|
|
78
89
|
out.push(finding('SEC-004',
|
|
79
90
|
certain ? 'Server secret exposed to the browser' : 'Possible secret exposed to the browser',
|
|
@@ -168,11 +179,29 @@ const BASE_CHECKS = [
|
|
|
168
179
|
}},
|
|
169
180
|
|
|
170
181
|
{ id:'DEP-010', run(repo){
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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.')];
|
|
176
205
|
}},
|
|
177
206
|
|
|
178
207
|
{ id:'API-002', run(repo,profile){
|
|
@@ -221,12 +250,16 @@ const IS_TEST = /(^|\/)(tests?|__tests__|__mocks__|spec|e2e|fixtures?|examples?)
|
|
|
221
250
|
|
|
222
251
|
function withoutTests(repo){
|
|
223
252
|
const files = repo.files.filter(f => !IS_TEST.test(f.path));
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
+
});
|
|
230
263
|
}
|
|
231
264
|
|
|
232
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 ----------
|
package/src/fs-scan.mjs
CHANGED
|
@@ -105,8 +105,19 @@ export function buildIgnore(rootAbs, sources) {
|
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
// Every .gitignore from the git root down to the scan root, plus
|
|
109
|
-
|
|
108
|
+
// Every .gitignore from the git root down to the scan root, plus
|
|
109
|
+
// .git/info/exclude, plus any .gitignore found INSIDE the tree.
|
|
110
|
+
//
|
|
111
|
+
// The ancestors alone were not enough. A .gitignore written in a subdirectory -
|
|
112
|
+
// config/.gitignore, apps/web/.gitignore - was never read, so a .env it
|
|
113
|
+
// correctly excluded was reported by SEC-001 as a leaked credential. Nesting
|
|
114
|
+
// one is ordinary practice and it is the natural place for it in a monorepo.
|
|
115
|
+
//
|
|
116
|
+
// `inner` is appended AFTER the ancestors on purpose: buildIgnore is
|
|
117
|
+
// last-match-wins and scopes each rule to the directory it was written in, so
|
|
118
|
+
// appending gives the deeper file precedence, which is what git does. A nested
|
|
119
|
+
// `!keep.env` therefore beats a root `.env*`.
|
|
120
|
+
export function ignoreChain(root, inner = []) {
|
|
110
121
|
const rootAbs = resolve(root);
|
|
111
122
|
const gr = gitRoot(rootAbs);
|
|
112
123
|
const dirs = [];
|
|
@@ -123,11 +134,37 @@ export function ignoreChain(root) {
|
|
|
123
134
|
const t = readAt(gr, join('.git', 'info', 'exclude'));
|
|
124
135
|
if (t !== null) sources.push({ dir: gr, text: t });
|
|
125
136
|
}
|
|
126
|
-
return buildIgnore(rootAbs, sources);
|
|
137
|
+
return buildIgnore(rootAbs, [...sources, ...inner]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The ONE repo-view constructor. Every "repo" object a check receives - the
|
|
141
|
+
// whole-repo scan, a workspace slice, the tests-excluded view - is built here,
|
|
142
|
+
// so all three expose the SAME capabilities. Three hand-rolled versions is
|
|
143
|
+
// exactly how isIgnored went missing from the workspace view and SEC-001 cried
|
|
144
|
+
// wolf on every monorepo. Add a capability here and it exists in every view, or
|
|
145
|
+
// the omission is a one-line default rather than a silent gap.
|
|
146
|
+
//
|
|
147
|
+
// has/find/grep always read the `files` array. The file accessors
|
|
148
|
+
// (exists/read/isIgnored) are injected because they genuinely differ: the root
|
|
149
|
+
// scan answers from disk, a slice answers from its own files. Injecting them
|
|
150
|
+
// keeps that difference explicit instead of accidentally absent.
|
|
151
|
+
const GREP_CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|sql|prisma)$/;
|
|
152
|
+
export function makeView({ files, root, isIgnored, read, exists, grepDefault = GREP_CODE }) {
|
|
153
|
+
return {
|
|
154
|
+
root, files,
|
|
155
|
+
has: (re) => files.some(f => re.test(f.path)),
|
|
156
|
+
find: (re) => files.filter(f => re.test(f.path)),
|
|
157
|
+
grep: (re, pathRe = grepDefault) =>
|
|
158
|
+
files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
|
|
159
|
+
exists: exists || ((rel) => files.some(f => f.path === rel)),
|
|
160
|
+
read: read || ((rel) => files.find(f => f.path === rel)?.text ?? null),
|
|
161
|
+
isIgnored: isIgnored || (() => false),
|
|
162
|
+
};
|
|
127
163
|
}
|
|
128
164
|
|
|
129
165
|
export function scanRepo(root, { maxFiles = 6000 } = {}) {
|
|
130
166
|
const files = [];
|
|
167
|
+
const nested = []; // .gitignore files found inside the tree
|
|
131
168
|
const rootAbs = resolve(root);
|
|
132
169
|
const walk = (dir) => {
|
|
133
170
|
if (files.length >= maxFiles) return;
|
|
@@ -135,6 +172,15 @@ export function scanRepo(root, { maxFiles = 6000 } = {}) {
|
|
|
135
172
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
136
173
|
for (const e of entries) {
|
|
137
174
|
if (files.length >= maxFiles) return;
|
|
175
|
+
// Read before the dotfile skip below, and deliberately NOT added to
|
|
176
|
+
// `files` - this is input to the ignore matcher, not part of the scan.
|
|
177
|
+
// The one at the scan root is already covered by the ancestor chain.
|
|
178
|
+
if (e.name === '.gitignore') {
|
|
179
|
+
if (dir !== rootAbs) {
|
|
180
|
+
try { nested.push({ dir, text: readFileSync(join(dir, e.name), 'utf8') }); } catch {}
|
|
181
|
+
}
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
138
184
|
if (e.name.startsWith('.') && !e.name.startsWith('.env') && e.name !== '.github') continue;
|
|
139
185
|
const full = join(dir, e.name);
|
|
140
186
|
|
|
@@ -157,17 +203,16 @@ export function scanRepo(root, { maxFiles = 6000 } = {}) {
|
|
|
157
203
|
}
|
|
158
204
|
};
|
|
159
205
|
walk(root);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
|
|
206
|
+
// The root scan answers exists/read from disk (so a check can ask about a file
|
|
207
|
+
// that was not walked into `files`, e.g. a large or binary one), greps a wider
|
|
208
|
+
// set of file types than a slice does, and carries the full .gitignore chain.
|
|
209
|
+
return makeView({
|
|
210
|
+
files, root,
|
|
211
|
+
grepDefault: /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|php|erb|sql|prisma|ya?ml)$|(^|\/)(Gemfile|manage\.py|artisan)$/,
|
|
167
212
|
exists: (rel) => existsSync(join(root, rel)),
|
|
168
|
-
read:
|
|
169
|
-
isIgnored: ignoreChain(root),
|
|
170
|
-
};
|
|
213
|
+
read: (rel) => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; } },
|
|
214
|
+
isIgnored: ignoreChain(root, nested),
|
|
215
|
+
});
|
|
171
216
|
}
|
|
172
217
|
|
|
173
218
|
export function readPackageJson(repo) {
|
package/src/index.mjs
CHANGED
|
@@ -5,11 +5,17 @@ import { detectProfile, toGateProfile } from './detect.mjs';
|
|
|
5
5
|
import { gate, missingFacts } from './gate.mjs';
|
|
6
6
|
import { runChecks, runRootChecks, CHECKS } from './checks.mjs';
|
|
7
7
|
import { render } from './report.mjs';
|
|
8
|
+
import { askQuestions, confirm } from './interactive.mjs';
|
|
9
|
+
import { questionFor } from './questions.mjs';
|
|
8
10
|
import { existsSync, statSync } from 'node:fs';
|
|
9
11
|
|
|
10
12
|
const args = process.argv.slice(2);
|
|
11
13
|
const target = args.find(a => !a.startsWith('-')) || process.cwd();
|
|
12
14
|
const asJson = args.includes('--json');
|
|
15
|
+
// Opt out of the terminal Q&A: --yes (take defaults, ask nothing) or the
|
|
16
|
+
// explicit --no-interactive. It is also skipped automatically when there is no
|
|
17
|
+
// TTY, so a pipe or a CI job never blocks waiting on a keypress.
|
|
18
|
+
const noInteractive = args.includes('--yes') || args.includes('--no-interactive');
|
|
13
19
|
|
|
14
20
|
// --fail-on <severity>: the CI gate. Findings at or above the threshold turn
|
|
15
21
|
// the exit code to 1 so a pipeline can stop the deploy. Opt-in on purpose —
|
|
@@ -45,12 +51,10 @@ const packages = splitWorkspaces(repo);
|
|
|
45
51
|
//
|
|
46
52
|
// The scanner works most things out from the code, but some facts are simply
|
|
47
53
|
// not in the code — where the users live, whether the AI is allowed to act,
|
|
48
|
-
// how many people are expected. Those
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
// Reading a file the user wrote is still read-only. Nothing is written.
|
|
53
|
-
const stated = (() => {
|
|
54
|
+
// how many people are expected. Those come either from a launchprep.json the
|
|
55
|
+
// user wrote, or from the terminal Q&A below. Reading a file the user wrote is
|
|
56
|
+
// still read-only, and the Q&A only reads the keyboard: nothing is written.
|
|
57
|
+
const fileStated = (() => {
|
|
54
58
|
const raw = repo.read('launchprep.json');
|
|
55
59
|
if (raw === null) return null;
|
|
56
60
|
try {
|
|
@@ -67,7 +71,7 @@ const stated = (() => {
|
|
|
67
71
|
|
|
68
72
|
// Merge one level deep so { "stack": { "auth": "clerk" } } overrides only auth
|
|
69
73
|
// and leaves the detected framework and database alone.
|
|
70
|
-
const applyStated = (profile) => {
|
|
74
|
+
const applyStated = (profile, stated) => {
|
|
71
75
|
if (!stated) return profile;
|
|
72
76
|
const out = { ...profile };
|
|
73
77
|
for (const [k, v] of Object.entries(stated)) {
|
|
@@ -78,77 +82,137 @@ const applyStated = (profile) => {
|
|
|
78
82
|
return out;
|
|
79
83
|
};
|
|
80
84
|
|
|
81
|
-
// Profile and gate every app in the repo. Libraries are profiled too - they
|
|
82
|
-
// simply match very few rules, which is the correct outcome, not a bug.
|
|
83
|
-
const scanned = packages.map(w => {
|
|
84
|
-
const full = detectProfile(w.view, { root: repo });
|
|
85
|
-
const profile = applyStated(toGateProfile(full));
|
|
86
|
-
const g = gate(profile);
|
|
87
|
-
const applicableIds = new Set(g.evaluated.map(r => r.id));
|
|
88
|
-
const findings = runChecks(w.view, profile, applicableIds)
|
|
89
|
-
.map(f => ({ ...f, file: w.prefix ? `${w.prefix}/${f.file}` : f.file }));
|
|
90
|
-
return { name: w.name, full, profile, gate: g, findings };
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// The app most representative of this repo leads the report.
|
|
94
|
-
const apps = scanned.filter(s => s.profile.surface !== 'library');
|
|
95
|
-
const lead = apps.sort((a, b) => b.gate.evaluated.length - a.gate.evaluated.length)[0] || scanned[0];
|
|
96
|
-
|
|
97
|
-
// Root-scoped checks see the whole repo, once, regardless of how many
|
|
98
|
-
// workspaces it contains. Gated by the lead app's profile.
|
|
99
|
-
const rootIds = new Set(lead.gate.evaluated.map(r => r.id));
|
|
100
|
-
const rootFindings = runRootChecks(repo, lead.profile, rootIds);
|
|
101
|
-
|
|
102
|
-
const allFindings = [...scanned.flatMap(s => s.findings), ...rootFindings];
|
|
103
|
-
const seen = new Set();
|
|
104
|
-
// Each workspace sorts its own findings, but concatenating several workspaces
|
|
105
|
-
// and the root-scoped checks destroys that order - a monorepo would show a LOW
|
|
106
|
-
// from apps/web above a CRITICAL from apps/api. Sort once, at the end.
|
|
107
85
|
const RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
108
|
-
const findings = allFindings
|
|
109
|
-
.filter(f => {
|
|
110
|
-
const k = `${f.id}:${f.file}:${f.line}`;
|
|
111
|
-
if (seen.has(k)) return false;
|
|
112
|
-
seen.add(k); return true;
|
|
113
|
-
})
|
|
114
|
-
.sort((a, b) => RANK[a.severity] - RANK[b.severity]);
|
|
115
|
-
|
|
116
|
-
// Eleven tier-2 rules have a cheap static approximation that ships free. When
|
|
117
|
-
// one of those finds nothing that is NOT a pass - a pattern cannot see an
|
|
118
|
-
// ownership check that lives in middleware. Reporting it as clean would tell
|
|
119
|
-
// someone they are safe when the check simply could not look. Say so instead.
|
|
120
|
-
const firedIds = new Set(findings.map(f => f.id));
|
|
121
|
-
const shallow = lead.gate.evaluated
|
|
122
|
-
.filter(r => r.has_static_approximation && !firedIds.has(r.id));
|
|
123
|
-
|
|
124
|
-
const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
|
|
125
|
-
|
|
126
|
-
// "51 checks apply to this app" counted 17 that nothing examined — tier-2 rules
|
|
127
|
-
// that need the deep scan. Counting them as applying is a silent pass on a
|
|
128
|
-
// third of the headline number, which is the one thing this scanner must never
|
|
129
|
-
// do. It also threw away the sentence that sells the paid tier: those 17 are
|
|
130
|
-
// not a gap, they are the product.
|
|
131
86
|
const implemented = new Set(CHECKS.map(c => c.id));
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
87
|
+
const keyOf = (f) => `${f.id}:${f.file}:${f.line}`;
|
|
88
|
+
|
|
89
|
+
// One scan pass, given whatever the user has stated so far. Called once for the
|
|
90
|
+
// initial report and again after the terminal Q&A, so the answers can unlock
|
|
91
|
+
// more checks without asking the user to touch a file.
|
|
92
|
+
function analyze(stated) {
|
|
93
|
+
// Profile and gate every app in the repo. Libraries are profiled too - they
|
|
94
|
+
// simply match very few rules, which is the correct outcome, not a bug.
|
|
95
|
+
const scanned = packages.map(w => {
|
|
96
|
+
const full = detectProfile(w.view, { root: repo });
|
|
97
|
+
const profile = applyStated(toGateProfile(full), stated);
|
|
98
|
+
const g = gate(profile);
|
|
99
|
+
const applicableIds = new Set(g.evaluated.map(r => r.id));
|
|
100
|
+
const findings = runChecks(w.view, profile, applicableIds)
|
|
101
|
+
.map(f => ({ ...f, file: w.prefix ? `${w.prefix}/${f.file}` : f.file }));
|
|
102
|
+
return { name: w.name, full, profile, gate: g, findings };
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The app most representative of this repo leads the report.
|
|
106
|
+
const apps = scanned.filter(s => s.profile.surface !== 'library');
|
|
107
|
+
const lead = apps.sort((a, b) => b.gate.evaluated.length - a.gate.evaluated.length)[0] || scanned[0];
|
|
108
|
+
|
|
109
|
+
// Root-scoped checks see the whole repo, once, regardless of how many
|
|
110
|
+
// workspaces it contains. Gated by the lead app's profile.
|
|
111
|
+
const rootIds = new Set(lead.gate.evaluated.map(r => r.id));
|
|
112
|
+
const rootFindings = runRootChecks(repo, lead.profile, rootIds);
|
|
113
|
+
|
|
114
|
+
const allFindings = [...scanned.flatMap(s => s.findings), ...rootFindings];
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
// Concatenating several workspaces and the root-scoped checks destroys each
|
|
117
|
+
// one's own order - a monorepo would show a LOW from apps/web above a
|
|
118
|
+
// CRITICAL from apps/api. Sort once, at the end.
|
|
119
|
+
const findings = allFindings
|
|
120
|
+
.filter(f => { const k = keyOf(f); if (seen.has(k)) return false; seen.add(k); return true; })
|
|
121
|
+
.sort((a, b) => RANK[a.severity] - RANK[b.severity]);
|
|
122
|
+
|
|
123
|
+
// Eleven tier-2 rules have a cheap static approximation that ships free. When
|
|
124
|
+
// one of those finds nothing that is NOT a pass - a pattern cannot see an
|
|
125
|
+
// ownership check that lives in middleware. Reporting it as clean would tell
|
|
126
|
+
// someone they are safe when the check simply could not look. Say so instead.
|
|
127
|
+
const firedIds = new Set(findings.map(f => f.id));
|
|
128
|
+
const shallow = lead.gate.evaluated.filter(r => r.has_static_approximation && !firedIds.has(r.id));
|
|
129
|
+
|
|
130
|
+
const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
|
|
131
|
+
|
|
132
|
+
// "51 checks apply" counted tier-2 rules that nothing examined - a silent pass
|
|
133
|
+
// on a third of the headline number. Split them: what actually ran, and what
|
|
134
|
+
// still needs the deep scan (which is the product, not a gap).
|
|
135
|
+
const coverage = {
|
|
136
|
+
ran: lead.gate.evaluated.filter(r => implemented.has(r.id)).length,
|
|
137
|
+
needsDeep: lead.gate.evaluated.filter(r => !implemented.has(r.id)).length,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return { scanned, lead, findings, shallow, questions, coverage };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let stated = fileStated;
|
|
144
|
+
let r = analyze(stated);
|
|
136
145
|
|
|
137
146
|
if (asJson) {
|
|
138
147
|
console.log(JSON.stringify({
|
|
139
|
-
packages: scanned.map(s => ({
|
|
148
|
+
packages: r.scanned.map(s => ({
|
|
140
149
|
name: s.name, profile: s.profile,
|
|
141
150
|
evaluated: s.gate.evaluated.length, skipped: s.gate.skipped.length, unknown: s.gate.unknown.length,
|
|
142
151
|
})),
|
|
143
|
-
findings, questions, coverage, stated,
|
|
144
|
-
shallow: shallow.map(
|
|
152
|
+
findings: r.findings, questions: r.questions, coverage: r.coverage, stated,
|
|
153
|
+
shallow: r.shallow.map(s => ({ id: s.id, title: s.title, severity: s.severity })),
|
|
145
154
|
}, null, 2));
|
|
146
155
|
} else {
|
|
147
|
-
process.stdout.write(render({ repo: target,
|
|
156
|
+
process.stdout.write(render({ repo: target, ...r, stated }));
|
|
157
|
+
|
|
158
|
+
// The Q&A. Only in a real terminal, only when there is something to unlock,
|
|
159
|
+
// and never when the caller opted out - so a pipe, --json or a CI job is
|
|
160
|
+
// untouched. Answers are held in memory and merged; no file is written.
|
|
161
|
+
if (!noInteractive && process.stdin.isTTY && process.stdout.isTTY && r.questions.length) {
|
|
162
|
+
const unlocks = r.questions.reduce((n, [, c]) => n + c, 0);
|
|
163
|
+
const yes = await confirm(
|
|
164
|
+
`\n \x1b[36mAnswer ${r.questions.length} quick question${r.questions.length === 1 ? '' : 's'}` +
|
|
165
|
+
` to unlock up to ${unlocks} more checks?\x1b[0m \x1b[2m[Y/n]\x1b[0m `);
|
|
166
|
+
if (yes) {
|
|
167
|
+
const answers = await askQuestions(r.questions.map(([fact]) => fact));
|
|
168
|
+
if (Object.keys(answers).length) {
|
|
169
|
+
const before = new Set(r.findings.map(keyOf));
|
|
170
|
+
const ranBefore = r.coverage.ran;
|
|
171
|
+
stated = { ...(fileStated || {}), ...answers };
|
|
172
|
+
r = analyze(stated);
|
|
173
|
+
const fresh = r.findings.filter(f => !before.has(keyOf(f)));
|
|
174
|
+
printDelta(fresh, r.coverage.ran - ranBefore);
|
|
175
|
+
} else {
|
|
176
|
+
process.stdout.write(`\n \x1b[2mNothing answered — the report above is unchanged.\x1b[0m\n\n`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
148
180
|
}
|
|
149
181
|
|
|
150
|
-
// The gate, after
|
|
151
|
-
//
|
|
152
|
-
|
|
182
|
+
// The gate, after everything has printed: the human sees the whole report, the
|
|
183
|
+
// pipeline sees the verdict it asked for. Uses the final findings, so an answer
|
|
184
|
+
// that unlocked a critical still fails the build.
|
|
185
|
+
if (failOn && r.findings.some(f => RANK[f.severity] <= RANK_GATE[failOn])) {
|
|
153
186
|
process.exit(1);
|
|
154
187
|
}
|
|
188
|
+
|
|
189
|
+
// The extra findings the answers unlocked, grouped the same way the main report
|
|
190
|
+
// groups: one line per rule with a count, so a check that fired on many files
|
|
191
|
+
// does not bury the rest.
|
|
192
|
+
function printDelta(fresh, moreRan) {
|
|
193
|
+
const C = { d: '\x1b[2m', b: '\x1b[1m', o: '\x1b[0m', grn: '\x1b[32m', gry: '\x1b[90m' };
|
|
194
|
+
const SEV = { critical: '\x1b[31m', high: '\x1b[33m', medium: '\x1b[34m', low: '\x1b[90m' };
|
|
195
|
+
const p = (s = '') => process.stdout.write(s + '\n');
|
|
196
|
+
|
|
197
|
+
p(`\n ${C.grn}${moreRan} more check${moreRan === 1 ? '' : 's'} ran with your answers.${C.o}`);
|
|
198
|
+
if (!fresh.length) {
|
|
199
|
+
p(` ${C.d}They all passed — nothing new to fix.${C.o}\n`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const groups = [];
|
|
203
|
+
const byId = new Map();
|
|
204
|
+
for (const f of fresh) {
|
|
205
|
+
if (byId.has(f.id)) byId.get(f.id).push(f);
|
|
206
|
+
else { const arr = [f]; byId.set(f.id, arr); groups.push(arr); }
|
|
207
|
+
}
|
|
208
|
+
p(` ${C.d}${fresh.length} new finding${fresh.length === 1 ? '' : 's'}:${C.o}`);
|
|
209
|
+
for (const g of groups) {
|
|
210
|
+
const f = g[0];
|
|
211
|
+
const more = g.length - 1;
|
|
212
|
+
p(`\n ${SEV[f.severity]}${C.b}${f.severity.toUpperCase()}${C.o} ${f.title}${more ? `${C.d} · ${g.length} places${C.o}` : ''}`);
|
|
213
|
+
p(` ${C.gry}${f.file}:${f.line}${C.o}`);
|
|
214
|
+
p(` ${f.detail}`);
|
|
215
|
+
p(` ${C.grn}Fix${C.o} ${f.fix}`);
|
|
216
|
+
}
|
|
217
|
+
p('');
|
|
218
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The optional questions, asked in the terminal instead of made into homework.
|
|
2
|
+
//
|
|
3
|
+
// The report ends with "put these in launchprep.json and run again" - which
|
|
4
|
+
// means writing JSON with keys like "llm_tools_enabled" before the extra checks
|
|
5
|
+
// unlock. For a one-off scan that is friction most people will not pay. This
|
|
6
|
+
// asks the same questions out loud, takes a number or a word, and re-runs with
|
|
7
|
+
// the answers held in memory.
|
|
8
|
+
//
|
|
9
|
+
// READ-ONLY, deliberately. It reads the keyboard and nothing else: no file is
|
|
10
|
+
// written, so launchprep.json is never created behind the user's back and the
|
|
11
|
+
// read-only promise holds. Power users who want the answers saved for CI still
|
|
12
|
+
// write the file themselves - that path is unchanged.
|
|
13
|
+
|
|
14
|
+
import { createInterface } from 'node:readline';
|
|
15
|
+
import { questionFor } from './questions.mjs';
|
|
16
|
+
|
|
17
|
+
const C = { d: '\x1b[2m', b: '\x1b[1m', o: '\x1b[0m', c: '\x1b[36m', g: '\x1b[32m' };
|
|
18
|
+
|
|
19
|
+
// Turn a QUESTION's `answer` example - the exact JSON a user would paste - into
|
|
20
|
+
// the shape of the question: which key it sets, whether it is a yes/no or a
|
|
21
|
+
// choice, and (for a choice) the options. Parsing the example rather than
|
|
22
|
+
// duplicating it keeps one source of truth: questions.mjs.
|
|
23
|
+
export function shapeOf(answerExample) {
|
|
24
|
+
const dash = answerExample.indexOf('—');
|
|
25
|
+
const json = (dash >= 0 ? answerExample.slice(0, dash) : answerExample).trim();
|
|
26
|
+
const rest = dash >= 0 ? answerExample.slice(dash + 1).trim() : '';
|
|
27
|
+
|
|
28
|
+
const nested = json.match(/"([\w-]+)"\s*:\s*\{\s*"([\w-]+)"/); // "stack": { "auth": ... }
|
|
29
|
+
const topKey = json.match(/^"([\w-]+)"/)?.[1] || null;
|
|
30
|
+
const isArray = /:\s*\[/.test(json);
|
|
31
|
+
const isBool = !nested && /:\s*(true|false)\s*$/.test(json) && !rest;
|
|
32
|
+
|
|
33
|
+
const choices = isBool
|
|
34
|
+
? ['yes', 'no']
|
|
35
|
+
: rest.replace(/^any of:\s*/i, '').split(',').map(s => s.trim()).filter(Boolean);
|
|
36
|
+
|
|
37
|
+
// Fold the chosen value into a stated-shaped object, matching whatever the
|
|
38
|
+
// example demonstrated: nested (stack.auth), array (jurisdictions), boolean
|
|
39
|
+
// (has_rag), or a plain string (business_model).
|
|
40
|
+
const apply = (stated, value) => {
|
|
41
|
+
if (nested) stated[nested[1]] = { ...(stated[nested[1]] || {}), [nested[2]]: value };
|
|
42
|
+
else if (isArray) stated[topKey] = [value];
|
|
43
|
+
else if (isBool) stated[topKey] = (value === 'yes' || value === true);
|
|
44
|
+
else stated[topKey] = value;
|
|
45
|
+
return stated;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return { choices, isBool, apply, ok: Boolean(topKey && choices.length) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rlQuestion = (rl, q) => new Promise(res => rl.question(q, res));
|
|
52
|
+
|
|
53
|
+
// Ask up to `facts` in the terminal. Returns a stated-shaped object of the
|
|
54
|
+
// answers given; an empty object if the user answered nothing.
|
|
55
|
+
export async function askQuestions(facts, { input = process.stdin, output = process.stdout, readLine } = {}) {
|
|
56
|
+
const rl = readLine ? null : createInterface({ input, output });
|
|
57
|
+
const ask = readLine || ((prompt) => new Promise(res => rl.question(prompt, res)));
|
|
58
|
+
const stated = {};
|
|
59
|
+
try {
|
|
60
|
+
for (const fact of facts) {
|
|
61
|
+
const q = questionFor(fact);
|
|
62
|
+
if (!q) continue;
|
|
63
|
+
const shape = shapeOf(q.answer);
|
|
64
|
+
if (!shape.ok) continue;
|
|
65
|
+
|
|
66
|
+
output.write(`\n ${C.c}${q.ask}${C.o}\n`);
|
|
67
|
+
const numbered = shape.choices.map((c, i) => `${C.d}${i + 1})${C.o} ${c}`).join(' ');
|
|
68
|
+
output.write(` ${numbered}\n`);
|
|
69
|
+
|
|
70
|
+
// Accept a number, the literal value, or blank to skip. Re-ask on garbage
|
|
71
|
+
// rather than silently dropping it - a wrong answer the user thinks landed
|
|
72
|
+
// is worse than being asked again.
|
|
73
|
+
let value = null;
|
|
74
|
+
while (value === null) {
|
|
75
|
+
const raw = (await ask(` ${C.b}›${C.o} `)).trim().toLowerCase();
|
|
76
|
+
if (raw === '') break; // skip this one
|
|
77
|
+
const n = Number(raw);
|
|
78
|
+
if (Number.isInteger(n) && n >= 1 && n <= shape.choices.length) value = shape.choices[n - 1];
|
|
79
|
+
else if (shape.choices.includes(raw)) value = raw;
|
|
80
|
+
else output.write(` ${C.d}pick a number 1–${shape.choices.length}, or press Enter to skip${C.o}\n`);
|
|
81
|
+
}
|
|
82
|
+
if (value !== null) shape.apply(stated, value);
|
|
83
|
+
}
|
|
84
|
+
} finally {
|
|
85
|
+
rl?.close();
|
|
86
|
+
}
|
|
87
|
+
return stated;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The yes/no gate before any of it. Returns true only on an explicit yes.
|
|
91
|
+
export async function confirm(prompt, { input = process.stdin, output = process.stdout, readLine } = {}) {
|
|
92
|
+
const rl = readLine ? null : createInterface({ input, output });
|
|
93
|
+
const ask = readLine || ((q) => new Promise(res => rl.question(q, res)));
|
|
94
|
+
try {
|
|
95
|
+
const raw = (await ask(prompt)).trim().toLowerCase();
|
|
96
|
+
return raw === '' || raw === 'y' || raw === 'yes'; // default yes
|
|
97
|
+
} finally {
|
|
98
|
+
rl?.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/redact.mjs
CHANGED
|
@@ -28,7 +28,12 @@ const PEM = /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----[\s\S]*?
|
|
|
28
28
|
|
|
29
29
|
// 2. Credentials embedded in a connection URL: scheme://user:pass@host.
|
|
30
30
|
// Keep the scheme and host (useful shape), mask only user:pass.
|
|
31
|
-
|
|
31
|
+
//
|
|
32
|
+
// http and ssh belong here as much as postgres does. A git remote with a
|
|
33
|
+
// token in it (https://bot:ghp_live...@github.com), an SMTP URL holding a
|
|
34
|
+
// provider key, a Grafana or Elasticsearch URL - all were uploaded intact
|
|
35
|
+
// while the privacy page promised the value never leaves the machine.
|
|
36
|
+
const CONN = /\b((?:postgres|postgresql|mysql|mysql2|mongodb(?:\+srv)?|redis|rediss|amqp|amqps|mssql|mariadb|https?|smtps?|ftps?|sftp|ssh|ldaps?|clickhouse|elasticsearch):\/\/)([^\s:@/]+):([^\s@/]+)@/gi;
|
|
32
37
|
|
|
33
38
|
// 3. Provider keys by their published shape. pk_ (Stripe publishable) is left
|
|
34
39
|
// alone on purpose — it is designed to be public, and masking it was one of
|
|
@@ -54,7 +59,7 @@ const SHAPES = [
|
|
|
54
59
|
// and tokens that have no distinctive shape. It fires only on string
|
|
55
60
|
// literals, and holds back where the value is plainly not a secret: an env
|
|
56
61
|
// reference, a template placeholder, an interpolation, an empty string.
|
|
57
|
-
const SECRET_NAME = /(?:pass(?:word|wd)?|pwd|secret|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|token|credentials?|passphrase|dsn|encryption[_-]?key|signing[_-]?key|session[_-]?secret|db[_-]?pass(?:word)?)/i;
|
|
62
|
+
const SECRET_NAME = /(?:pass(?:word|wd)?|pwd|secret[_-]?key|secret|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|auth[_-]?token|token|credentials?|passphrase|dsn|encryption[_-]?key|signing[_-]?key|session[_-]?secret|db[_-]?pass(?:word)?)/i;
|
|
58
63
|
const ASSIGN = new RegExp(
|
|
59
64
|
'(' + SECRET_NAME.source + '["\'`\\]]?\\s*[:=]\\s*)(["\'`])([^"\'`\\n]{6,}?)\\2',
|
|
60
65
|
'gi',
|
package/src/report.mjs
CHANGED
|
@@ -65,14 +65,34 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
|
|
|
65
65
|
const summary = ['critical', 'high', 'medium', 'low']
|
|
66
66
|
.filter(k => counts[k]).map(k => `${counts[k]} ${k}`).join(' · ');
|
|
67
67
|
p(` ${C.dim}${summary}${C.off}`);
|
|
68
|
-
|
|
68
|
+
|
|
69
|
+
// Group by rule. One check firing on 43 tables is ONE issue in 43 places,
|
|
70
|
+
// not 43 issues - listing each pushed the genuinely distinct findings off
|
|
71
|
+
// the bottom into "and N more", the drowning this tool exists to avoid.
|
|
72
|
+
// Findings arrive severity-sorted, so first-seen order keeps that order.
|
|
73
|
+
const groups = [];
|
|
74
|
+
const byId = new Map();
|
|
75
|
+
for (const f of findings) {
|
|
76
|
+
if (byId.has(f.id)) byId.get(f.id).push(f);
|
|
77
|
+
else { const arr = [f]; byId.set(f.id, arr); groups.push(arr); }
|
|
78
|
+
}
|
|
79
|
+
if (groups.length < findings.length)
|
|
80
|
+
p(` ${C.dim}${groups.length} distinct, across ${findings.length} places${C.off}`);
|
|
81
|
+
|
|
82
|
+
for (const g of groups.slice(0, 25)) {
|
|
83
|
+
const f = g[0];
|
|
69
84
|
const [col, label] = SEV[f.severity];
|
|
70
|
-
|
|
85
|
+
const more = g.length - 1;
|
|
86
|
+
p(`\n ${col}${C.bold}${label}${C.off} ${f.title}${more ? `${C.dim} · ${g.length} places${C.off}` : ''}`);
|
|
71
87
|
p(` ${C.gry}${f.file}:${f.line}${C.off}`);
|
|
88
|
+
if (more) {
|
|
89
|
+
const also = g.slice(1, 4).map(x => `${x.file}:${x.line}`).join(' ');
|
|
90
|
+
p(` ${C.dim}${also}${more > 3 ? ` + ${more - 3} more` : ''}${C.off}`);
|
|
91
|
+
}
|
|
72
92
|
p(` ${f.detail}`);
|
|
73
93
|
p(` ${C.grn}Fix${C.off} ${f.fix}`);
|
|
74
94
|
}
|
|
75
|
-
if (
|
|
95
|
+
if (groups.length > 25) p(`\n ${C.dim}… and ${groups.length - 25} more issues${C.off}`);
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
// ---------- checked, but only as far as a program can see ----------
|
package/src/workspace.mjs
CHANGED
|
Binary file
|