@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.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +27 -0
- package/CHANGELOG.md +1421 -0
- package/LICENSE +202 -0
- package/NOTICE +82 -0
- package/README.md +620 -2
- package/README.zh-CN.md +555 -0
- package/bin/sakk.mjs +1460 -0
- package/integrations/litellm/LICENSE +202 -0
- package/integrations/litellm/NOTICE +82 -0
- package/integrations/litellm/README.md +366 -0
- package/integrations/litellm/config.example.yaml +52 -0
- package/integrations/litellm/sakk_gateway.py +823 -0
- package/package.json +88 -4
- package/policies/guard-default.json +161 -0
- package/policies/permissive-dev.json +22 -0
- package/prices/snapshot.json +1404 -0
- package/src/append.mjs +81 -0
- package/src/archive.mjs +489 -0
- package/src/classify.mjs +914 -0
- package/src/console-login.mjs +374 -0
- package/src/console.mjs +1628 -0
- package/src/gateway-status.mjs +203 -0
- package/src/guard-install.mjs +387 -0
- package/src/hook.mjs +730 -0
- package/src/ledger.mjs +1229 -0
- package/src/match.mjs +52 -0
- package/src/mcp.mjs +299 -0
- package/src/policy.mjs +423 -0
- package/src/prices.mjs +152 -0
- package/src/secret-rules.generated.mjs +225 -0
- package/src/secret-scan.mjs +489 -0
- package/src/spend.mjs +443 -0
|
@@ -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
|
+
}
|