launchprep 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +31 -0
- package/README.md +17 -2
- package/bin/launchprep.mjs +20 -6
- package/net/client.mjs +2 -1
- package/net/commands.mjs +55 -2
- package/package.json +10 -9
- package/src/brand.mjs +2 -0
- package/src/checks-auth.mjs +7 -3
- package/src/checks-deploy.mjs +23 -3
- package/src/checks-injection.mjs +322 -0
- package/src/checks.mjs +14 -2
- package/src/fs-scan.mjs +94 -1
- package/src/index.mjs +37 -2
- package/src/rules.json +1 -3651
package/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/index.mjs
CHANGED
|
@@ -5,9 +5,38 @@ import { detectProfile, toGateProfile } from './detect.mjs';
|
|
|
5
5
|
import { gate, missingFacts } from './gate.mjs';
|
|
6
6
|
import { runChecks, runRootChecks } from './checks.mjs';
|
|
7
7
|
import { render } from './report.mjs';
|
|
8
|
+
import { existsSync, statSync } from 'node:fs';
|
|
8
9
|
|
|
9
|
-
const
|
|
10
|
-
const
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const target = args.find(a => !a.startsWith('-')) || process.cwd();
|
|
12
|
+
const asJson = args.includes('--json');
|
|
13
|
+
|
|
14
|
+
// --fail-on <severity>: the CI gate. Findings at or above the threshold turn
|
|
15
|
+
// the exit code to 1 so a pipeline can stop the deploy. Opt-in on purpose —
|
|
16
|
+
// a person reading the report in a terminal gets information, a robot gets a
|
|
17
|
+
// verdict only when asked for one.
|
|
18
|
+
const RANK_GATE = { critical: 0, high: 1, medium: 2, low: 3, any: 3 };
|
|
19
|
+
let failOn = null;
|
|
20
|
+
{
|
|
21
|
+
const i = args.indexOf('--fail-on');
|
|
22
|
+
if (i !== -1) {
|
|
23
|
+
failOn = args[i + 1];
|
|
24
|
+
if (!(failOn in RANK_GATE)) {
|
|
25
|
+
process.stderr.write(`\n --fail-on must be one of: critical, high, medium, low, any\n\n`);
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// A path that does not exist must be a loud error, never a clean report. A
|
|
32
|
+
// scan of nothing looks exactly like a scan that found nothing, and telling
|
|
33
|
+
// someone they are safe because they mistyped a folder name is the one thing
|
|
34
|
+
// this tool must never do.
|
|
35
|
+
if (!existsSync(target) || !statSync(target).isDirectory()) {
|
|
36
|
+
process.stderr.write(`\n \x1b[31mThat path does not exist: ${target}\x1b[0m\n`);
|
|
37
|
+
process.stderr.write(` \x1b[2mNothing was scanned. Check the folder name and run it again.\x1b[0m\n\n`);
|
|
38
|
+
process.exit(2);
|
|
39
|
+
}
|
|
11
40
|
|
|
12
41
|
const repo = scanRepo(target, { maxFiles: 12000 });
|
|
13
42
|
const packages = splitWorkspaces(repo);
|
|
@@ -69,3 +98,9 @@ if (asJson) {
|
|
|
69
98
|
} else {
|
|
70
99
|
process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow }));
|
|
71
100
|
}
|
|
101
|
+
|
|
102
|
+
// The gate, after the full report has printed: the human still sees
|
|
103
|
+
// everything, and the pipeline sees the verdict it asked for.
|
|
104
|
+
if (failOn && findings.some(f => RANK[f.severity] <= RANK_GATE[failOn])) {
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|