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/README.md +21 -0
- 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 +31 -7
- package/src/checks-auth.mjs +1 -4
- 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 +9 -15
- package/src/checks-frameworks.mjs +1 -3
- package/src/checks-injection.mjs +1 -15
- package/src/checks.mjs +83 -14
- package/src/detect.mjs +10 -3
- package/src/fs-scan.mjs +58 -13
- package/src/git-history.mjs +255 -0
- package/src/index.mjs +52 -4
- package/src/placeholder.mjs +72 -0
- package/src/questions.mjs +206 -0
- package/src/redact.mjs +7 -2
- package/src/report.mjs +41 -12
- package/src/rules.json +1 -1
- package/src/workspace.mjs +0 -0
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) {
|
|
@@ -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
|
+
}
|