launchprep 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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) {
@@ -0,0 +1,255 @@
1
+ // Read git's object store to find secrets that were committed and later
2
+ // "removed".
3
+ //
4
+ // SEC-001 and SEC-003 read the working tree. That misses the mistake people
5
+ // actually make: commit a key, notice, delete the line, commit again, and
6
+ // believe it is gone. It is not. Every old version of every file is still in
7
+ // .git, and it travels with every clone, every fork and every fetch. The
8
+ // scanner's own finding text already said "deleting it later does not remove it
9
+ // from git history" — and then did not look there.
10
+ //
11
+ // Read-only, and directly: verify-readonly.mjs forbids shelling out, so there
12
+ // is no `git log` here. Loose objects are zlib streams on disk; packed objects
13
+ // live in a packfile with its own index. Both are just reads.
14
+ //
15
+ // This is deliberately NOT a full git implementation. It reads blobs — the file
16
+ // contents — and never needs to walk commits, resolve trees or understand
17
+ // branches, because the question is only "does any version of anything in this
18
+ // repository contain a secret", and every version of everything is a blob.
19
+ import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
20
+ import { inflateSync, inflateRawSync } from 'node:zlib';
21
+ import { join } from 'node:path';
22
+
23
+ // ONLY credentials whose format means one thing and cannot mean another.
24
+ //
25
+ // This list was cut down after measuring, and the cut is the whole reason this
26
+ // check is shippable. The first version looked for private keys, database URLs
27
+ // and AWS/Google/Slack keys too, and fired on 15 of 68 real repositories — every
28
+ // single hit a test fixture, a docker-compose service, or documentation. Five
29
+ // rounds of filters got that to 22% and no further, because of one fact that no
30
+ // amount of pattern-matching can get around:
31
+ //
32
+ // A test private key and a production private key are BYTE-IDENTICAL. So are
33
+ // a docker-compose password and a real one. Only intent separates them, and
34
+ // intent is not in the value.
35
+ //
36
+ // A vendor-prefixed key is different: `sk_live_` is issued by Stripe and means
37
+ // a live secret key, always. Nobody's test fixture has a real one. Narrowed to
38
+ // these, the check fires on 2 of 21 repositories — and one of those two is
39
+ // GitLab's own repository, which contains GitLab test tokens.
40
+ //
41
+ // The dropped types are not a gap we are hiding: SEC-001 and SEC-003 still find
42
+ // all of them in the working tree. What is given up is only finding THOSE kinds
43
+ // in deleted history, which is the exact case where they cannot be judged.
44
+ const SECRETS = [
45
+ [/\bsk_live_[A-Za-z0-9]{20,}/g, 'Stripe live secret key'],
46
+ [/\brk_live_[A-Za-z0-9]{20,}/g, 'Stripe restricted live key'],
47
+ [/\bwhsec_[A-Za-z0-9]{24,}/g, 'Stripe webhook signing secret'],
48
+ [/\bsk-ant-[A-Za-z0-9_-]{24,}/g, 'Anthropic API key'],
49
+ [/\bsk-proj-[A-Za-z0-9_-]{24,}/g, 'OpenAI project key'],
50
+ [/\bghp_[A-Za-z0-9]{36,}/g, 'GitHub personal access token'],
51
+ [/\bgho_[A-Za-z0-9]{36,}/g, 'GitHub OAuth token'],
52
+ [/\bglpat-[A-Za-z0-9_-]{20,}/g, 'GitLab personal access token'],
53
+ [/\bSG\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}/g, 'SendGrid API key'],
54
+ ];
55
+
56
+ // The first audit of this check fired on 7 of 8 major repositories and every
57
+ // single hit was documentation, a test fixture or a secretlint allowlist — the
58
+ // exact "matched a shape instead of judging the context" failure this codebase
59
+ // has already made seven times. Three filters, in order of how much they caught:
60
+ //
61
+ // 1. The VALUE itself is a sample. `sk_live_abcdefghijk...` and
62
+ // `redis://username:password@host:port/db` are not credentials, they are
63
+ // the shape of one written down. A real secret is high-entropy; a fake one
64
+ // spells words, runs the alphabet, or literally says "password".
65
+ // 2. The surrounding LINE is prose or a fixture — a docs code fence, an
66
+ // `expect(...)`, an allowlist entry.
67
+ // 3. The blob is a documentation or test FILE.
68
+ const FAKE_VALUE = [
69
+ // No \b: a fixture key spells the word INSIDE the token, e.g.
70
+ // sk_live_51H8xExAmPlEkEyAbCdEf123. Word boundaries never match there, and
71
+ // that gap let this check fire on its own repository's test file.
72
+ /(example|sample|dummy|placeholder|fake|changeme|redacted|notreal|donotuse)/i,
73
+ /\b(your[_-]?key|my[_-]?secret|xxx+)\b/i,
74
+ /abcdefghij|0123456789|qwerty|lorem/i,
75
+ // A substitution of ANY form means the real value is not here: ${VAR},
76
+ // {{var}}, [password], <password>, %s, $VAR. Every remaining false positive
77
+ // in the first two audits was one of these.
78
+ /[$%]\{|\{\{|\[[a-z][a-z0-9_.-]*\]|<[a-z][a-z0-9_.-]*>|\$[A-Z_][A-Z0-9_]*/i,
79
+ // credentials that are the words for credentials
80
+ /:\/\/(?:user|username|admin|root|foo|bar|test|me|you)[^:@/]*:(?:pass|password|passwd|secret|hunter2|foo|bar|test)/i,
81
+ // A password containing "test", "demo", "local" or "dev" is a fixture. The
82
+ // corpus is full of mostest_password, knextest, devpassword.
83
+ /:\/\/[^:@/]+:[^@/]*(?:test|demo|local|dev|dummy|ci)[^@/]*@/i,
84
+ // A password that merely repeats the username or the service is a default,
85
+ // not a secret: postgres:postgres@, mysql:mysql@, redis:redis@.
86
+ /:\/\/([a-z0-9_-]+):\1@/i,
87
+ // The host is an internal service name from a docker-compose or CI network —
88
+ // a single label with no dot, e.g. @postgres:5432, @db, @mysql. A production
89
+ // database is reached at a real hostname.
90
+ /@(?:postgres|postgresql|mysql|mariadb|mongo|mongodb|redis|db|database|cache|queue)(?:[-_][a-z0-9-]+)?(?::\d+)?(?:[/?]|$)/i,
91
+ // a placeholder host is a placeholder URL
92
+ /@(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host|hostname|example\.(?:com|org|net)|your[^\s/]*)(?:[:/]|$)/i,
93
+ // no dot in the host at all: not a routable production endpoint
94
+ /:\/\/[^@/]+@[a-z0-9_-]+(?::\d+)?(?:[/?]|$)/i,
95
+ // AWS's own documentation key
96
+ /^AKIAIOSFODNN7EXAMPLE$/,
97
+ ];
98
+ // Entropy: a real key is random. `sk_live_abcdef...` is not. Measured over the
99
+ // secret's payload, after the recognisable prefix.
100
+ function looksRandom(value) {
101
+ const body = value.replace(/^(sk_live_|rk_live_|whsec_|sk-ant-|sk-proj-|AKIA|AIza|gh[pousr]_|glpat-|xox[baprs]-|SG\.)/, '');
102
+ if (body.length < 12) return true; // too short to judge; keep it
103
+ const set = new Set(body.replace(/[^A-Za-z0-9]/g, ''));
104
+ if (set.size < 8) return false; // e.g. all a-f, or one repeated run
105
+ // sequential alphabet or digits is the signature of a hand-typed placeholder
106
+ let runs = 0;
107
+ for (let i = 1; i < body.length; i++) if (body.charCodeAt(i) === body.charCodeAt(i - 1) + 1) runs++;
108
+ return runs < body.length * 0.3;
109
+ }
110
+ // The line the secret sits on. Docs, assertions and allowlists are not leaks.
111
+ const FAKE_LINE = /(^\s*[#*]|^\s*\/\/|\.\. |::$|`{1,3}|expect\(|assert|describe\(|it\(|test\(|allows?"?\s*:|allowlist|secretlint|gitleaks|trufflehog|\.md:|<\/?[a-z]+>)/i;
112
+
113
+ // What the secret was ASSIGNED TO, looking back from the match. A real key and
114
+ // a test key are byte-identical — the only difference is intent, and intent is
115
+ // written in the name above it. `EXAMPLE_PRIVATE_KEY = """`, `KEY1 = """` under
116
+ // a "Keys and certificates for tests" docstring, `TEST_CERT`, `FIXTURE_KEY`.
117
+ // This was the last class of false positive left in the corpus audit.
118
+ const FAKE_DECLARATION = /\b(example|sample|test|tests|testing|fixture|mock|stub|dummy|fake|demo|placeholder|invalid|expired|revoked|specimen)[a-z0-9_]*\s*[:=]\s*(?:"""|'''|["'`]|$)/i;
119
+ // The blob is documentation or a fixture. Detected from the content, since a
120
+ // blob does not carry its path.
121
+ //
122
+ // The strongest signal is that the blob IS a test file. A test asserting that
123
+ // secret-handling works necessarily contains secret-shaped strings — this
124
+ // scanner's own test/redact.mjs does, and that is what made SEC-009 fire on
125
+ // Launchprep's own repository the first time it ran. A key inside a test file
126
+ // is a fixture; a real one would never be committed there deliberately.
127
+ const FAKE_BLOB = /(^|\n)(#{1,3} |\.\. _|={3,}$|-{3,}$)|describe\(|it\(['"`]|def test_|class Test|@pytest|\bfixtures?\b/i;
128
+ const IS_TEST_FILE = /(^|\n)\s*(?:import|from|const|let|require)[^\n]*\b(?:assert|chai|jest|mocha|vitest|pytest|unittest|testing)\b|(^|\n)\s*(?:describe|it|test)\s*\(|(^|\n)\s*(?:def test_|class Test)|\bok\(['"`]|expect\(/;
129
+
130
+ // A blob big enough to be a build artefact or a vendored bundle is not where a
131
+ // human pasted a key, and inflating hundreds of them is the whole cost here.
132
+ const MAX_BLOB = 400_000;
133
+ // A ceiling on total work so an enormous repository cannot make a scan hang.
134
+ // Reached only on repositories with very deep history; reported honestly when
135
+ // it happens rather than silently truncating.
136
+ const MAX_OBJECTS = 20_000;
137
+
138
+ function looseObjects(objDir, budget) {
139
+ const out = [];
140
+ let buckets;
141
+ try { buckets = readdirSync(objDir); } catch { return out; }
142
+ for (const b of buckets) {
143
+ if (!/^[0-9a-f]{2}$/.test(b)) continue;
144
+ let files;
145
+ try { files = readdirSync(join(objDir, b)); } catch { continue; }
146
+ for (const f of files) {
147
+ if (out.length >= budget) return out;
148
+ out.push({ id: b + f, path: join(objDir, b, f) });
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+
154
+ // A packfile stores objects as a stream of zlib chunks with no per-object
155
+ // offsets available without parsing .idx. Rather than implement idx parsing and
156
+ // delta reconstruction — a large amount of code for a small gain — the packfile
157
+ // is scanned for the zlib streams it contains and each is inflated where it
158
+ // can be. Objects stored as deltas against another object are skipped: they are
159
+ // modifications of a base, and a secret introduced in one shows up in the full
160
+ // copy of some version anyway. Reported in `partial` so a clean result on a
161
+ // packed repository never silently claims more than it checked.
162
+ function packedBlobs(packDir, budget, onBlob) {
163
+ let files;
164
+ try { files = readdirSync(packDir); } catch { return { scanned: 0, partial: false }; }
165
+ let scanned = 0, partial = false;
166
+ for (const f of files.filter(x => x.endsWith('.pack'))) {
167
+ let buf;
168
+ try { buf = readFileSync(join(packDir, f)); } catch { continue; }
169
+ // header: 'PACK', version, count
170
+ if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'PACK') continue;
171
+ // Walk looking for zlib stream starts (0x78 followed by a valid check byte).
172
+ // Inflating from a wrong offset simply throws and is skipped, so a false
173
+ // start costs nothing but a try/catch.
174
+ for (let i = 12; i < buf.length - 2; i++) {
175
+ if (scanned >= budget) { partial = true; return { scanned, partial }; }
176
+ if (buf[i] !== 0x78) continue;
177
+ const b1 = buf[i + 1];
178
+ if (((buf[i] << 8) | b1) % 31 !== 0) continue;
179
+ try {
180
+ const inflated = inflateSync(buf.subarray(i), { finishFlush: 2 /* Z_SYNC_FLUSH */ });
181
+ if (inflated.length && inflated.length <= MAX_BLOB) {
182
+ scanned++;
183
+ onBlob(inflated.toString('utf8'), `${f}@${i}`);
184
+ }
185
+ } catch { /* not a stream start, or a delta we do not reconstruct */ }
186
+ }
187
+ }
188
+ return { scanned, partial };
189
+ }
190
+
191
+ // Returns { findings, objectsScanned, partial, reason }.
192
+ // `reason` is set when history could not be read at all, so the caller can say
193
+ // so rather than implying a clean history.
194
+ export function scanGitHistory(root) {
195
+ const gitDir = join(root, '.git');
196
+ if (!existsSync(gitDir)) return { findings: [], objectsScanned: 0, partial: false, reason: 'no-git' };
197
+ // A worktree or submodule has .git as a FILE pointing elsewhere. Not followed:
198
+ // the pointer can lead outside the directory the user asked us to scan, and
199
+ // reading outside it is not ours to do.
200
+ try { if (!statSync(gitDir).isDirectory()) return { findings: [], objectsScanned: 0, partial: false, reason: 'git-is-a-file' }; }
201
+ catch { return { findings: [], objectsScanned: 0, partial: false, reason: 'no-git' }; }
202
+
203
+ const seen = new Set();
204
+ const findings = [];
205
+ let objectsScanned = 0;
206
+
207
+ const inspect = (text, where) => {
208
+ if (!text || text.length > MAX_BLOB) return;
209
+ // A documentation page or a test file can contain any number of
210
+ // key-shaped strings and none of them are leaks.
211
+ const docish = FAKE_BLOB.test(text.slice(0, 2000)) || IS_TEST_FILE.test(text.slice(0, 4000));
212
+ for (const [re, label] of SECRETS) {
213
+ re.lastIndex = 0;
214
+ let m;
215
+ while ((m = re.exec(text))) {
216
+ const value = m[0];
217
+ if (FAKE_VALUE.some(f => f.test(value))) continue;
218
+ if (!looksRandom(value)) continue;
219
+ // the line it sits on decides as much as the value does
220
+ const ls = text.lastIndexOf('\n', m.index) + 1;
221
+ let le = text.indexOf('\n', m.index); if (le < 0) le = text.length;
222
+ const line = text.slice(ls, le);
223
+ if (FAKE_LINE.test(line)) continue;
224
+ // Look back a few hundred characters for the declaration this value was
225
+ // assigned to, and for a test/fixture docstring above it.
226
+ const before = text.slice(Math.max(0, m.index - 300), m.index);
227
+ if (FAKE_DECLARATION.test(before)) continue;
228
+ if (/\b(for tests?|test (?:keys?|certs?|fixtures?|data)|do not use|not a real)\b/i.test(before)) continue;
229
+ if (docish) continue;
230
+ // One finding per distinct secret, however many old commits carry it.
231
+ const key = label + ':' + value.slice(0, 24);
232
+ if (seen.has(key)) continue;
233
+ seen.add(key);
234
+ findings.push({ label, where, hint: value.slice(0, 7) + '…' });
235
+ }
236
+ }
237
+ };
238
+
239
+ const objDir = join(gitDir, 'objects');
240
+ for (const o of looseObjects(objDir, MAX_OBJECTS)) {
241
+ try {
242
+ const raw = inflateSync(readFileSync(o.path));
243
+ const nul = raw.indexOf(0);
244
+ if (nul < 0) continue;
245
+ if (!raw.subarray(0, nul).toString('latin1').startsWith('blob')) continue;
246
+ objectsScanned++;
247
+ inspect(raw.subarray(nul + 1).toString('utf8'), o.id.slice(0, 8));
248
+ } catch { /* unreadable object; skipped */ }
249
+ }
250
+
251
+ const packed = packedBlobs(join(objDir, 'pack'), MAX_OBJECTS - objectsScanned, inspect);
252
+ objectsScanned += packed.scanned;
253
+
254
+ return { findings, objectsScanned, partial: packed.partial, reason: null };
255
+ }
package/src/index.mjs CHANGED
@@ -3,7 +3,7 @@ import { scanRepo } from './fs-scan.mjs';
3
3
  import { splitWorkspaces } from './workspace.mjs';
4
4
  import { detectProfile, toGateProfile } from './detect.mjs';
5
5
  import { gate, missingFacts } from './gate.mjs';
6
- import { runChecks, runRootChecks } from './checks.mjs';
6
+ import { runChecks, runRootChecks, CHECKS } from './checks.mjs';
7
7
  import { render } from './report.mjs';
8
8
  import { existsSync, statSync } from 'node:fs';
9
9
 
@@ -41,11 +41,48 @@ if (!existsSync(target) || !statSync(target).isDirectory()) {
41
41
  const repo = scanRepo(target, { maxFiles: 12000 });
42
42
  const packages = splitWorkspaces(repo);
43
43
 
44
+ // What the user told us about their own app, which beats what we guessed.
45
+ //
46
+ // The scanner works most things out from the code, but some facts are simply
47
+ // not in the code — where the users live, whether the AI is allowed to act,
48
+ // how many people are expected. Those checks were skipped and the report said
49
+ // "Correct it and the checks adjust", which was false: there was no way to
50
+ // correct anything. This is that way.
51
+ //
52
+ // Reading a file the user wrote is still read-only. Nothing is written.
53
+ const stated = (() => {
54
+ const raw = repo.read('launchprep.json');
55
+ if (raw === null) return null;
56
+ try {
57
+ const j = JSON.parse(raw);
58
+ return (j && typeof j === 'object' && !Array.isArray(j)) ? j : null;
59
+ } catch (e) {
60
+ // A broken file must be loud. Silently ignoring it would mean the user
61
+ // answers the questions, sees no change, and concludes the feature is fake.
62
+ process.stderr.write(`\n \x1b[31mlaunchprep.json could not be read: ${e.message}\x1b[0m\n`);
63
+ process.stderr.write(` \x1b[2mIt must be valid JSON. Nothing from it was used.\x1b[0m\n\n`);
64
+ return null;
65
+ }
66
+ })();
67
+
68
+ // Merge one level deep so { "stack": { "auth": "clerk" } } overrides only auth
69
+ // and leaves the detected framework and database alone.
70
+ const applyStated = (profile) => {
71
+ if (!stated) return profile;
72
+ const out = { ...profile };
73
+ for (const [k, v] of Object.entries(stated)) {
74
+ if (v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object')
75
+ out[k] = { ...out[k], ...v };
76
+ else out[k] = v;
77
+ }
78
+ return out;
79
+ };
80
+
44
81
  // Profile and gate every app in the repo. Libraries are profiled too - they
45
82
  // simply match very few rules, which is the correct outcome, not a bug.
46
83
  const scanned = packages.map(w => {
47
84
  const full = detectProfile(w.view, { root: repo });
48
- const profile = toGateProfile(full);
85
+ const profile = applyStated(toGateProfile(full));
49
86
  const g = gate(profile);
50
87
  const applicableIds = new Set(g.evaluated.map(r => r.id));
51
88
  const findings = runChecks(w.view, profile, applicableIds)
@@ -86,17 +123,28 @@ const shallow = lead.gate.evaluated
86
123
 
87
124
  const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
88
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
+ const implemented = new Set(CHECKS.map(c => c.id));
132
+ const coverage = {
133
+ ran: lead.gate.evaluated.filter(r => implemented.has(r.id)).length,
134
+ needsDeep: lead.gate.evaluated.filter(r => !implemented.has(r.id)).length,
135
+ };
136
+
89
137
  if (asJson) {
90
138
  console.log(JSON.stringify({
91
139
  packages: scanned.map(s => ({
92
140
  name: s.name, profile: s.profile,
93
141
  evaluated: s.gate.evaluated.length, skipped: s.gate.skipped.length, unknown: s.gate.unknown.length,
94
142
  })),
95
- findings, questions,
143
+ findings, questions, coverage, stated,
96
144
  shallow: shallow.map(r => ({ id: r.id, title: r.title, severity: r.severity })),
97
145
  }, null, 2));
98
146
  } else {
99
- process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow }));
147
+ process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow, coverage, stated }));
100
148
  }
101
149
 
102
150
  // The gate, after the full report has printed: the human still sees
@@ -0,0 +1,72 @@
1
+ // Is this value a real credential, or something that only looks like one?
2
+ //
3
+ // This is the seventh-and-eighth instance of the mistake this codebase keeps
4
+ // making: matching a SHAPE and calling it a secret. Scanning its own repository
5
+ // produced five CRITICALs and every one was this —
6
+ //
7
+ // .github/workflows/ci.yml postgresql://postgres:postgres@localhost/..._test
8
+ // cli/src/git-history.mjs /^AKIAIOSFODNN7EXAMPLE$/ (a detection pattern)
9
+ // cli/src/checks-*.mjs the regexes that look for model output
10
+ //
11
+ // None is a credential. The first is the throwaway database every project with
12
+ // CI tests spins up; the second is AWS's own published example; the third is
13
+ // source code that describes a pattern rather than containing a secret.
14
+ //
15
+ // The discipline is the one written at the top of the project guide: read the
16
+ // VALUE, and the line it sits on, not the shape. A leaked key is high-entropy,
17
+ // points at a real host, and is not sitting inside a regex.
18
+ //
19
+ // The redaction pass in redact.mjs learned the same lesson separately. This is
20
+ // the shared version so a fix in one place is a fix in both.
21
+
22
+ // The value spells out that it is not real.
23
+ const FAKE_WORDS = /(example|sample|dummy|placeholder|fake|changeme|redacted|notreal|donotuse|yourkey|your[_-]key|xxx+|test[_-]?key|foobar)/i;
24
+
25
+ // Sequences a human types when inventing a key.
26
+ const TYPED_BY_HAND = /abcdefghij|0123456789|qwerty|lorem|(.)\1{7,}/i;
27
+
28
+ // A substitution means the real value is somewhere else: ${VAR}, {{var}},
29
+ // [password], <password>, $VAR, %s.
30
+ const SUBSTITUTION = /[$%]\{|\{\{|\[[a-z][a-z0-9_.-]*\]|<[a-z][a-z0-9_.-]*>|\$[A-Z_][A-Z0-9_]*/i;
31
+
32
+ // A connection string that points nowhere real.
33
+ const LOCAL_OR_SERVICE_HOST =
34
+ /@(?:localhost|127\.0\.0\.1|0\.0\.0\.0|host|hostname|example\.(?:com|org|net)|postgres|postgresql|mysql|mariadb|mongo|mongodb|redis|db|database)(?:[-_][a-z0-9-]+)?(?::\d+)?(?:[/?]|$)/i;
35
+ // user and password are the same word, or are the words for themselves.
36
+ const DEFAULT_CREDENTIALS =
37
+ /:\/\/([a-z0-9_-]+):\1@|:\/\/(?:user|username|admin|root|foo|bar|test|me)[^:@/]*:(?:pass|password|passwd|secret|hunter2|foo|bar|test)/i;
38
+ // a password that announces it is for testing
39
+ const TEST_CREDENTIALS = /:\/\/[^:@/]+:[^@/]*(?:test|demo|local|dev|dummy|ci|example)[^@/]*@/i;
40
+
41
+ // AWS publishes these two in its own documentation. They are in a thousand
42
+ // READMEs and are not anyone's key.
43
+ const AWS_DOC_KEYS = /^(AKIAIOSFODNN7EXAMPLE|ASIAIOSFODNN7EXAMPLE)$/;
44
+
45
+ /**
46
+ * @param value the matched string
47
+ * @param line the whole line it appeared on (optional, but much better with it)
48
+ * @param path the file path (optional)
49
+ */
50
+ export function looksLikePlaceholder(value, line = '', path = '') {
51
+ const v = String(value);
52
+
53
+ if (FAKE_WORDS.test(v) || TYPED_BY_HAND.test(v) || SUBSTITUTION.test(v)) return true;
54
+ if (AWS_DOC_KEYS.test(v)) return true;
55
+ if (LOCAL_OR_SERVICE_HOST.test(v) || DEFAULT_CREDENTIALS.test(v) || TEST_CREDENTIALS.test(v)) return true;
56
+
57
+ const l = String(line);
58
+ // The line DEFINES a pattern rather than holding a secret: a regex literal,
59
+ // a list of shapes to look for. This is what made the scanner flag itself.
60
+ if (/\/\^|\^.*\$\/|\\b|\\d|\\w|\[A-Za-z0-9|\[0-9A-Z|RegExp\(|\.test\(|\.match\(/.test(l)) return true;
61
+ // A comment, or documentation showing the format.
62
+ if (/^\s*(?:[#*]|\/\/|--)/.test(l)) return true;
63
+ // An assertion or fixture.
64
+ if (/expect\(|assert|describe\(|it\(['"`]|toBe\(|toEqual\(/.test(l)) return true;
65
+
66
+ const p = String(path);
67
+ // A test file or a fixture directory is not where real credentials live.
68
+ if (/(^|\/)(test|tests|__tests__|spec|e2e|cypress|fixtures?|mocks?|__mocks__|examples?)\//.test(p)) return true;
69
+ if (/\.(test|spec)\.[a-z]+$/.test(p)) return true;
70
+
71
+ return false;
72
+ }