@sakk-dev/sakk 0.0.0-stage → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,489 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ //
3
+ // Secret scanner (blueprint P1: "密钥/PII 出站扫描"). Rules that are the
4
+ // whole point:
5
+ //
6
+ // 1. ONE exemption, and it is a declared fiction: a SECRET (the part of a
7
+ // match that carries the credential) is exempt only if it contains
8
+ // TEST_ONLY_FAKE_SECRET_DO_NOT_USE and nothing secret-shaped remains once
9
+ // the token is removed. No localhost pass, never a list of real
10
+ // fragments — the predecessor leaked through exactly such a list.
11
+ // 2. Output never contains a secret: rule, path, line and a keyed 12-hex
12
+ // fingerprint. The key is random per process (set SAKK_SCAN_KEY to make
13
+ // fingerprints comparable across runs — and keep that key private), so a
14
+ // public CI log cannot be dictionary-reversed. A name that itself
15
+ // contains a secret is printed redacted and counted as a finding; control
16
+ // characters in names are escaped so a file name cannot forge output.
17
+ // 3. A scan that could not look everywhere it was told to does not report
18
+ // "clean": missing, unreadable, special or oversize inputs are errors,
19
+ // skips are counted.
20
+ // 4. Every pattern is length-bounded: a hostile line costs linear time.
21
+
22
+ import { execFileSync } from 'node:child_process';
23
+ import { createHmac, randomBytes } from 'node:crypto';
24
+ import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
25
+ import { basename, join } from 'node:path';
26
+ import { GITLEAKS_RULES, GITLEAKS_ALLOWLIST } from './secret-rules.generated.mjs';
27
+
28
+ export const FICTIONAL_EXEMPTION_TOKEN = 'TEST_ONLY_FAKE_SECRET_DO_NOT_USE';
29
+ /** The entire exemption list. A test pins it to exactly this one entry. */
30
+ export const EXEMPTIONS = Object.freeze([FICTIONAL_EXEMPTION_TOKEN]);
31
+ /** Larger blobs/files are reported unreadable (exit 2), never silently passed. */
32
+ export const MAX_SCAN_BYTES = 256 * 1024 * 1024;
33
+
34
+ export class ScanError extends Error {
35
+ constructor(code, message) {
36
+ super(`${code}: ${message}`);
37
+ this.code = code;
38
+ }
39
+ }
40
+
41
+ // `group` names the capture group that holds the secret itself; the
42
+ // exemption is judged on that part only (a token in the USERNAME must not
43
+ // exempt a real password).
44
+ export const RULES = Object.freeze([
45
+ {
46
+ name: 'dsn_with_password',
47
+ re: /(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|rediss?|amqps?|mssql|sqlserver):\/\/[^\s"'@/:]{0,256}:([^\s"'@]{1,256})@/gi,
48
+ group: 1,
49
+ },
50
+ {
51
+ name: 'url_encoded_password',
52
+ re: /:\/\/[^\s"'@/:]{0,256}:((?=[^\s"'@]{0,256}%[0-9a-fA-F]{2})[^\s"'@]{1,256})@/g,
53
+ group: 1,
54
+ },
55
+ { name: 'aws_access_key', re: /\bAKIA[0-9A-Z]{16}\b/g },
56
+ {
57
+ // `sk-…` (OpenAI/Anthropic style) or Stripe-style `sk_live_`/`rk_test_`;
58
+ // a digit is required so identifiers like sk_set_flags never match.
59
+ // Stripe-style `sk_live_`/`rk_test_` prefixes are strong enough alone;
60
+ // a bare `sk-`/`sk_` needs a digit or 40+ chars, so identifiers like
61
+ // sk_set_memalloc_and_flags never match.
62
+ name: 'generic_api_key',
63
+ re: /\b(?:[sr]k_(?:live|test)_[A-Za-z0-9]{20,200}|sk-[A-Za-z0-9_-]{40,200}|(?:sk|rk)[-_](?=[A-Za-z0-9_-]{0,200}\d)[A-Za-z0-9_-]{20,200})/g,
64
+ },
65
+ {
66
+ name: 'github_token',
67
+ re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})/g,
68
+ },
69
+ { name: 'private_key_block', re: /-----BEGIN [A-Z ]{0,40}PRIVATE KEY(?: BLOCK)?-----/g },
70
+ { name: 'bearer_token', re: /\bBearer\s{1,5}[A-Za-z0-9._-]{25,500}/g },
71
+ {
72
+ name: 'password_assignment',
73
+ re: /(?:password|passwd|pwd)["']?\s{0,40}[:=]\s{0,40}["']([^"'\n]{8,256})["']/gi,
74
+ group: 1,
75
+ },
76
+ ]);
77
+
78
+ // --- gitleaks' ported rule set (src/secret-rules.generated.mjs, M3) ---
79
+ // Rules disabled for precision (they matched the benign corpus). Each is a
80
+ // reviewed exclusion the both-sided test keeps honest.
81
+ export const GITLEAKS_DISABLED = new Set([
82
+ // gitleaks' broadest rule: any `…key/secret/token… = "<10+ chars>"`. It
83
+ // flags ordinary code (two hits in this repo) — in a fail-closed guard a
84
+ // false positive denies a legitimate write, so it ships disabled, as it
85
+ // commonly is in gitleaks deployments.
86
+ 'generic-api-key',
87
+ // RE2 is linear; this rule's `(?:.*|.*(?:\r\n.*){1,5})` alternation of
88
+ // unbounded .* is safe in gitleaks' RE2 engine but backtracks catastrophically
89
+ // in JS's RegExp. Disabled until it can be rewritten without the ambiguity.
90
+ 'curl-auth-user',
91
+ ]);
92
+
93
+ const GL_ALLOW = GITLEAKS_ALLOWLIST.map((a) => new RegExp(a.re, a.flags));
94
+ export const GL_RULES = GITLEAKS_RULES.filter((r) => !GITLEAKS_DISABLED.has(r.id)).map((r) => ({
95
+ name: `gl:${r.id}`,
96
+ re: new RegExp(r.re, r.flags.includes('g') ? r.flags : `${r.flags}g`),
97
+ keywords: r.keywords.map((k) => k.toLowerCase()),
98
+ entropy: r.entropy,
99
+ }));
100
+
101
+ /** Shannon entropy (bits per char) — gitleaks' per-rule entropy gate. */
102
+ function shannon(s) {
103
+ if (!s) return 0;
104
+ const freq = new Map();
105
+ for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
106
+ let h = 0;
107
+ for (const n of freq.values()) {
108
+ const p = n / s.length;
109
+ h -= p * Math.log2(p);
110
+ }
111
+ return h;
112
+ }
113
+ const glAllowed = (secret) =>
114
+ GL_ALLOW.some((re) => {
115
+ re.lastIndex = 0;
116
+ return re.test(secret);
117
+ });
118
+
119
+ const KEY = process.env.SAKK_SCAN_KEY ? Buffer.from(process.env.SAKK_SCAN_KEY) : randomBytes(32);
120
+ const fingerprint = (value) => createHmac('sha256', KEY).update(value).digest('hex').slice(0, 12);
121
+
122
+ // A reference to a secret is not a secret: `${REDIS_PASSWORD}`, `$PW`,
123
+ // `%(pw)s`, `{{ pw }}` as the whole secret part of a URL or assignment.
124
+ const PLACEHOLDER =
125
+ /^(?:\$\{[^}\s]{1,200}\}|\$[A-Za-z_][A-Za-z0-9_]{0,100}|%\([A-Za-z_][A-Za-z0-9_]{0,100}\)s|\{\{[^}]{1,200}\}\})$/;
126
+
127
+ function rawFindings(text) {
128
+ const out = [];
129
+ for (const rule of RULES) {
130
+ const re = new RegExp(rule.re.source, rule.re.flags);
131
+ let m;
132
+ while ((m = re.exec(text)) !== null) {
133
+ const secret = rule.group ? m[rule.group] : m[0];
134
+ if (rule.group && PLACEHOLDER.test(secret)) continue;
135
+ out.push({ rule, index: m.index, end: m.index + m[0].length, secret });
136
+ }
137
+ }
138
+ // gitleaks' ported rules: keyword-gated (bounded + precise), group-1 secret,
139
+ // skipped on a placeholder, an allowlist match, or below the rule's entropy.
140
+ const lower = text.toLowerCase();
141
+ for (const rule of GL_RULES) {
142
+ if (rule.keywords.length && !rule.keywords.some((k) => lower.includes(k))) continue;
143
+ rule.re.lastIndex = 0;
144
+ let m;
145
+ while ((m = rule.re.exec(text)) !== null) {
146
+ if (m[0] === '') {
147
+ rule.re.lastIndex += 1; // never loop on a zero-width match
148
+ continue;
149
+ }
150
+ const secret = m[1] ?? m[0];
151
+ if (PLACEHOLDER.test(secret) || glAllowed(secret)) continue;
152
+ if (rule.entropy > 0 && shannon(secret) < rule.entropy) continue;
153
+ out.push({ rule: { name: rule.name }, index: m.index, end: m.index + m[0].length, secret });
154
+ }
155
+ }
156
+ return out;
157
+ }
158
+
159
+ /** The text with every fiction token blanked (same length, so indices hold). */
160
+ const blankTokens = (text) =>
161
+ EXEMPTIONS.reduce((t, tok) => t.split(tok).join(' '.repeat(tok.length)), text);
162
+
163
+ /** Exempt only a declared fiction with nothing secret-shaped left beside the token. */
164
+ function isExempt(secret) {
165
+ if (!EXEMPTIONS.some((t) => secret.includes(t))) return false;
166
+ let rest = secret;
167
+ for (const t of EXEMPTIONS) rest = rest.split(t).join(' ');
168
+ return rawFindings(rest).length === 0;
169
+ }
170
+
171
+ /** Scan one text. Returns { findings: [{rule,path,line,fingerprint}], exempted }. */
172
+ export function scanText(text, path = '(inline)') {
173
+ const findings = [];
174
+ let exempted = 0;
175
+ let newlines = null; // computed once, on the first hit
176
+ const lineOf = (idx) => {
177
+ if (newlines === null) {
178
+ newlines = [];
179
+ for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) newlines.push(i);
180
+ }
181
+ let lo = 0;
182
+ let hi = newlines.length;
183
+ while (lo < hi) {
184
+ const mid = (lo + hi) >> 1;
185
+ if (newlines[mid] < idx) lo = mid + 1;
186
+ else hi = mid;
187
+ }
188
+ return lo + 1;
189
+ };
190
+ const spans = [];
191
+ const add = (h) =>
192
+ findings.push({
193
+ rule: h.rule.name,
194
+ path,
195
+ line: lineOf(h.index),
196
+ fingerprint: fingerprint(h.secret),
197
+ });
198
+ for (const h of rawFindings(text)) {
199
+ if (isExempt(h.secret)) {
200
+ exempted += 1; // counted, never silent
201
+ continue;
202
+ }
203
+ // one secret, one finding: a span already reported (e.g. by a base rule) is
204
+ // not reported again by an overlapping gitleaks rule (F-122).
205
+ if (spans.some(([a, b]) => h.index < b && h.end > a)) continue;
206
+ spans.push([h.index, h.end]);
207
+ add(h);
208
+ }
209
+ // A real key glued to a fiction token (TOKENsk_live_…) hides from `\b`,
210
+ // and a greedy exempt match can swallow a real one (F-63); with the tokens
211
+ // blanked both stand alone. Only an exempt match's span is open to the
212
+ // second pass — a reported finding is never counted twice.
213
+ if (EXEMPTIONS.some((t) => text.includes(t)))
214
+ for (const h of rawFindings(blankTokens(text)))
215
+ if (!spans.some(([a, b]) => h.index < b && h.end > a)) {
216
+ spans.push([h.index, h.end]); // dedup within this pass too (F-122)
217
+ add(h);
218
+ }
219
+ return { findings, exempted };
220
+ }
221
+
222
+ /** Names are output too: escape control characters, redact anything secret-shaped. */
223
+ export function display(p) {
224
+ const hits = rawFindings(p).filter((h) => !isExempt(h.secret));
225
+ if (hits.length > 0) return `[redacted path ${fingerprint(hits[0].secret)}]`;
226
+ // C0, DEL, C1 and the Unicode line separators never reach a terminal raw
227
+ // eslint-disable-next-line no-control-regex
228
+ return p.replace(/[\x00-\x1f\x7f-\x9f\u2028\u2029]/g, (c) => {
229
+ const n = c.charCodeAt(0);
230
+ return n > 0xff
231
+ ? `\\u${n.toString(16).padStart(4, '0')}`
232
+ : `\\x${n.toString(16).padStart(2, '0')}`;
233
+ });
234
+ }
235
+
236
+ /** A secret in a name is a finding — counted once, at the entry that carries it. */
237
+ function nameFindings(name, shown, findings) {
238
+ for (const h of rawFindings(name))
239
+ if (!isExempt(h.secret))
240
+ findings.push({
241
+ rule: h.rule.name,
242
+ path: shown,
243
+ line: 0,
244
+ fingerprint: fingerprint(h.secret),
245
+ });
246
+ }
247
+
248
+ const SKIP_DIR = new Set(['node_modules', '.git', '.next', 'dist']);
249
+ const SKIP_EXT = /\.(png|jpe?g|gif|zip|gz|tgz|woff2?|ico|mp4|bundle)$/i;
250
+
251
+ function utf32(buf, le) {
252
+ let s = '';
253
+ for (let i = 0; i + 3 < buf.length; i += 4) {
254
+ const cp = le ? buf.readUInt32LE(i) : buf.readUInt32BE(i);
255
+ if (cp > 0x10ffff) return null;
256
+ s += String.fromCodePoint(cp);
257
+ }
258
+ return s;
259
+ }
260
+
261
+ /** Decode for scanning: UTF-32/UTF-16 by BOM are text; a NUL in UTF-8 is binary. */
262
+ function asText(buf) {
263
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xfe && buf[2] === 0 && buf[3] === 0)
264
+ return utf32(buf.subarray(4), true);
265
+ if (buf.length >= 4 && buf[0] === 0 && buf[1] === 0 && buf[2] === 0xfe && buf[3] === 0xff)
266
+ return utf32(buf.subarray(4), false);
267
+ const even = (b) => b.subarray(0, b.length - (b.length % 2));
268
+ if (buf.length >= 2 && buf[0] === 0xff && buf[1] === 0xfe)
269
+ return even(buf.subarray(2)).toString('utf16le');
270
+ if (buf.length >= 2 && buf[0] === 0xfe && buf[1] === 0xff) {
271
+ const swapped = Buffer.from(even(buf.subarray(2)));
272
+ swapped.swap16();
273
+ return swapped.toString('utf16le');
274
+ }
275
+ if (buf.subarray(0, 8000).includes(0)) return null;
276
+ return buf.toString('utf8');
277
+ }
278
+
279
+ /**
280
+ * Scan files/directories. Explicit roots must exist (SCAN_PATH_MISSING),
281
+ * are followed if they are links, and are scanned even if their name is in
282
+ * the skip list; a root that is not a file or directory (fifo, device) is
283
+ * unreadable. Only DISCOVERED children are skipped; links among them are
284
+ * never followed. Unreadable entries are collected — the caller must not
285
+ * call the result clean.
286
+ */
287
+ export function scanPaths(paths) {
288
+ const findings = [];
289
+ const unreadable = [];
290
+ const skipped = { symlink: 0, binary: 0, ignoredDir: 0, ignoredExt: 0, special: 0 };
291
+ let exempted = 0;
292
+ let scanned = 0;
293
+ for (const p of paths) {
294
+ try {
295
+ lstatSync(p);
296
+ } catch {
297
+ throw new ScanError(
298
+ 'SCAN_PATH_MISSING',
299
+ `${display(p)} does not exist — a scan of nothing is not a pass`,
300
+ );
301
+ }
302
+ }
303
+ const scanFile = (p, shown, size) => {
304
+ if (size > MAX_SCAN_BYTES) {
305
+ unreadable.push(`${shown} (larger than ${MAX_SCAN_BYTES} bytes)`);
306
+ return;
307
+ }
308
+ let text;
309
+ try {
310
+ text = asText(readFileSync(p));
311
+ } catch {
312
+ unreadable.push(shown);
313
+ return;
314
+ }
315
+ if (text === null) {
316
+ skipped.binary += 1;
317
+ return;
318
+ }
319
+ scanned += 1;
320
+ const r = scanText(text, shown);
321
+ findings.push(...r.findings);
322
+ exempted += r.exempted;
323
+ };
324
+ const walk = (p, explicit) => {
325
+ const shown = display(p);
326
+ nameFindings(explicit ? p : basename(p), shown, findings);
327
+ let st;
328
+ try {
329
+ st = explicit ? statSync(p) : lstatSync(p);
330
+ } catch {
331
+ unreadable.push(shown);
332
+ return;
333
+ }
334
+ if (st.isSymbolicLink()) {
335
+ skipped.symlink += 1;
336
+ return;
337
+ }
338
+ if (st.isDirectory()) {
339
+ if (!explicit && SKIP_DIR.has(basename(p))) {
340
+ skipped.ignoredDir += 1;
341
+ return;
342
+ }
343
+ let names;
344
+ try {
345
+ names = readdirSync(p);
346
+ } catch {
347
+ unreadable.push(shown);
348
+ return;
349
+ }
350
+ for (const name of names) walk(join(p, name), false);
351
+ return;
352
+ }
353
+ if (!st.isFile()) {
354
+ if (explicit) unreadable.push(`${shown} (not a regular file)`);
355
+ else skipped.special += 1;
356
+ return;
357
+ }
358
+ if (!explicit && SKIP_EXT.test(p)) {
359
+ skipped.ignoredExt += 1;
360
+ return;
361
+ }
362
+ scanFile(p, shown, st.size);
363
+ };
364
+ for (const p of paths) walk(p, true);
365
+ return { findings, exempted, scanned, skipped, unreadable };
366
+ }
367
+
368
+ /**
369
+ * Scan exactly what `git commit` is about to record. The blob id and mode of
370
+ * every added/modified/type-changed path come from ONE `git diff --cached
371
+ * --raw -z` record each — no join on decoded names (two names that decode
372
+ * alike, or cwd-relative vs root-relative paths, cannot hide a blob).
373
+ * Gitlinks carry no content here and are skipped, counted; a staged
374
+ * symlink's target string is committed content and is scanned.
375
+ */
376
+ export function scanStaged(cwd = process.cwd()) {
377
+ const git = (args, opts = {}) => {
378
+ try {
379
+ return execFileSync('git', args, {
380
+ cwd,
381
+ maxBuffer: Number.POSITIVE_INFINITY,
382
+ stdio: ['pipe', 'pipe', 'pipe'],
383
+ // refs/replace/* would make cat-file return OTHER bytes than the
384
+ // commit records, under the same sha (review S1)
385
+ env: { ...process.env, GIT_NO_REPLACE_OBJECTS: '1' },
386
+ ...opts,
387
+ });
388
+ } catch (e) {
389
+ if (e.code === 'ENOENT') throw new ScanError('SCAN_GIT_MISSING', 'git is not on PATH');
390
+ throw e;
391
+ }
392
+ };
393
+ try {
394
+ git(['rev-parse', '--git-dir']);
395
+ } catch (e) {
396
+ if (e instanceof ScanError) throw e;
397
+ throw new ScanError('SCAN_NOT_A_REPO', '--staged must run inside a git repository');
398
+ }
399
+ const raw = git([
400
+ 'diff',
401
+ '--cached',
402
+ '--raw',
403
+ '-z',
404
+ '--no-relative', // diff.relative=true would hide files outside the cwd
405
+ '--no-renames',
406
+ '--no-abbrev',
407
+ '--diff-filter=AMT',
408
+ ]);
409
+ const tokens = [];
410
+ for (let start = 0, i = raw.indexOf(0); i !== -1; start = i + 1, i = raw.indexOf(0, start))
411
+ tokens.push(raw.subarray(start, i));
412
+ const records = [];
413
+ for (let i = 0; i < tokens.length; i += 2) {
414
+ const m = tokens[i]
415
+ .toString('latin1')
416
+ .match(/^:\d{6} (\d{6}) [0-9a-f]{40,64} ([0-9a-f]{40,64}) ([AMT])$/);
417
+ if (!m || tokens[i + 1] === undefined)
418
+ throw new ScanError('SCAN_FAILED', 'unexpected `git diff --raw` output — refusing to guess');
419
+ records.push({ mode: m[1], sha: m[2], name: tokens[i + 1].toString('utf8') });
420
+ }
421
+ const findings = [];
422
+ const unreadable = [];
423
+ const skipped = { gitlink: 0, binary: 0 };
424
+ let exempted = 0;
425
+ let scanned = 0;
426
+ const todo = [];
427
+ for (const r of records) {
428
+ const shown = display(r.name);
429
+ nameFindings(r.name, shown, findings);
430
+ if (r.mode === '160000') {
431
+ skipped.gitlink += 1;
432
+ continue;
433
+ }
434
+ todo.push({ ...r, shown });
435
+ }
436
+ if (todo.length === 0) return { findings, exempted, scanned, skipped, unreadable };
437
+ const sizes = git(['cat-file', '--batch-check=%(objectsize)'], {
438
+ input: `${todo.map((t) => t.sha).join('\n')}\n`,
439
+ encoding: 'utf8',
440
+ })
441
+ .trim()
442
+ .split('\n')
443
+ .map(Number);
444
+ if (sizes.length !== todo.length)
445
+ throw new ScanError('SCAN_FAILED', 'git cat-file returned a different number of objects');
446
+ let batch = [];
447
+ let bytes = 0;
448
+ const flush = () => {
449
+ if (batch.length === 0) return;
450
+ const out = git(['cat-file', '--batch'], {
451
+ input: `${batch.map((t) => t.sha).join('\n')}\n`,
452
+ });
453
+ let off = 0;
454
+ for (const t of batch) {
455
+ const nl = out.indexOf(10, off);
456
+ const header = out.subarray(off, nl).toString('latin1').split(' ');
457
+ if (header[0] !== t.sha || header[1] !== 'blob')
458
+ throw new ScanError('SCAN_FAILED', `git cat-file did not return blob ${t.sha}`);
459
+ const size = Number(header[2]);
460
+ const text = asText(out.subarray(nl + 1, nl + 1 + size));
461
+ off = nl + 1 + size + 1;
462
+ if (text === null) {
463
+ skipped.binary += 1;
464
+ continue;
465
+ }
466
+ scanned += 1;
467
+ const r = scanText(text, t.shown);
468
+ findings.push(...r.findings);
469
+ exempted += r.exempted;
470
+ }
471
+ batch = [];
472
+ bytes = 0;
473
+ };
474
+ todo.forEach((t, i) => {
475
+ if (!(sizes[i] >= 0)) {
476
+ unreadable.push(`${t.shown} (object missing from the repository)`);
477
+ return;
478
+ }
479
+ if (sizes[i] > MAX_SCAN_BYTES) {
480
+ unreadable.push(`${t.shown} (larger than ${MAX_SCAN_BYTES} bytes)`);
481
+ return;
482
+ }
483
+ if (bytes + sizes[i] > 64 * 1024 * 1024) flush();
484
+ batch.push(t);
485
+ bytes += sizes[i];
486
+ });
487
+ flush();
488
+ return { findings, exempted, scanned, skipped, unreadable };
489
+ }