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/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
- const re=/catch\s*\(\s*(\w+)\s*\)\s*\{[^}]{0,200}?(?:json|send)\s*\(\s*\{[^}]{0,120}?\1(?:\.message|\.stack)?/gs;
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 target = process.argv[2] || process.cwd();
10
- const asJson = process.argv.includes('--json');
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
+ }