launchprep 0.5.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/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
- console.log(` ${C.d}Run it again and they usually complete.${C.off}\n`);
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.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 && 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",
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
@@ -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;
@@ -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?"
@@ -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
- const isServer = (p) => /\/(api|routes?|server|actions?|controllers?|handlers?)\//.test(p);
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
 
@@ -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);
@@ -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
- /\/(api|routes?|server|actions?|controllers?|handlers?|jobs?|workers?|lib|services?)\//.test(p);
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) => {
@@ -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) =>
@@ -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
- if (!repo.exists('package.json')) return [];
172
- const has = ['package-lock.json','pnpm-lock.yaml','yarn.lock','bun.lockb'].some(l=>repo.exists(l));
173
- return has ? [] : [finding('DEP-010','No lockfile committed','medium','package.json',1,
174
- 'Without a lockfile your production build can install different versions than you tested.',
175
- 'Commit the lockfile your package manager generates.')];
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
- return { ...repo, files,
225
- has: (re) => files.some(f => re.test(f.path)),
226
- find: (re) => files.filter(f => re.test(f.path)),
227
- grep: (re, pathRe = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|sql|prisma)$/) =>
228
- files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
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 evidence and a confidence level.
2
- // LOW confidence never produces a `fail` — the gate turns it into a question.
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, confidence, evidence = []) => ({ value, confidence, evidence });
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 .git/info/exclude.
109
- export function ignoreChain(root) {
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
- return {
161
- root,
162
- files,
163
- has: (re) => files.some(f => re.test(f.path)),
164
- find: (re) => files.filter(f => re.test(f.path)),
165
- grep: (re, pathRe = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|php|erb|sql|prisma|ya?ml)$|(^|\/)(Gemfile|manage\.py|artisan)$/) =>
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: (rel) => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; } },
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/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
- const CONN = /\b((?:postgres|postgresql|mysql|mysql2|mongodb(?:\+srv)?|redis|rediss|amqp|amqps|mssql|mariadb):\/\/)([^\s:@/]+):([^\s@/]+)@/gi;
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/workspace.mjs CHANGED
Binary file