claude-code-runrate 0.4.0 → 0.5.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/CHANGELOG.md +114 -0
- package/README.md +26 -7
- package/bin/ccr.js +12 -2
- package/package.json +6 -2
- package/src/doctor.js +21 -1
- package/src/history-privacy.js +435 -0
- package/src/launch-win.js +168 -8
- package/src/pane-config.js +40 -6
- package/src/render/economy.js +30 -5
- package/src/render/shared.js +21 -1
- package/src/render/statusline.js +10 -3
- package/src/sidecar-keys.js +16 -3
- package/src/sidecar.js +30 -8
- package/src/state-dir.js +18 -1
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
'use strict';
|
|
3
|
+
// src/history-privacy.js — what a release would publish that is not published
|
|
4
|
+
// yet, read out of the object store rather than out of the working tree.
|
|
5
|
+
//
|
|
6
|
+
// Why this is not simply `git grep` over the working tree: a scrubbed tip says
|
|
7
|
+
// nothing about the blobs behind it. 0.4.0 made the shape concrete — its tree
|
|
8
|
+
// grepped clean while fourteen of the twenty-three commits behind that tip
|
|
9
|
+
// still carried a string the release had deliberately removed. Invisible to
|
|
10
|
+
// every check anyone ran, because every check ran on the tree.
|
|
11
|
+
//
|
|
12
|
+
// That particular string turned out to be publishable after all (ruled
|
|
13
|
+
// 2026-08-07; the flat squash it prompted cost nothing either way). The
|
|
14
|
+
// arithmetic is the lesson rather than the string: fourteen commits, none of
|
|
15
|
+
// them reachable from an inspection of the tip, and nothing in the release path
|
|
16
|
+
// that would have looked.
|
|
17
|
+
//
|
|
18
|
+
// The disclosures this repository has ACTUALLY had were both mail addresses —
|
|
19
|
+
// an owner address throughout early history, and a contributor's real address
|
|
20
|
+
// on a branch that reached the public remote in 2026-06. Neither was in the tip
|
|
21
|
+
// when it mattered. Both are what the real-inbox detector below is for.
|
|
22
|
+
//
|
|
23
|
+
// So the check reads history, not `HEAD`: every commit reachable from the tip
|
|
24
|
+
// and not from the published ref, every blob in each of their trees.
|
|
25
|
+
//
|
|
26
|
+
// TWO KINDS OF FINDING, deliberately different:
|
|
27
|
+
//
|
|
28
|
+
// * DETECTORS (below) are generic and live here in the open, because they
|
|
29
|
+
// describe SHAPES — an email that is not a noreply alias, an absolute home
|
|
30
|
+
// path — not anyone's actual secrets. They cover both disclosures above.
|
|
31
|
+
//
|
|
32
|
+
// * The PRIVATE SUPPLEMENT is a list of literal patterns supplied from
|
|
33
|
+
// OUTSIDE the repository (see loadPrivatePatterns). Whatever belongs on it
|
|
34
|
+
// is by definition a string this file must not contain: writing it into a
|
|
35
|
+
// public file in order to scan for it would be the disclosure it prevents.
|
|
36
|
+
//
|
|
37
|
+
// Detector hits are judged against a BASELINE: whatever the published tree
|
|
38
|
+
// already contains is, by definition, already public and not news. That is why
|
|
39
|
+
// there is no allowlist to maintain — the npm contact alias in package.json is
|
|
40
|
+
// silent because it is already out there, and it would start speaking again
|
|
41
|
+
// the moment it appeared somewhere it had not been. Supplement hits ignore the
|
|
42
|
+
// baseline: those strings are never acceptable, published already or not.
|
|
43
|
+
|
|
44
|
+
const fs = require('node:fs');
|
|
45
|
+
const os = require('node:os');
|
|
46
|
+
const path = require('node:path');
|
|
47
|
+
|
|
48
|
+
const { readObject, parseTree } = require('./git-objects');
|
|
49
|
+
|
|
50
|
+
/** Commits walked before the scan gives up rather than grind. */
|
|
51
|
+
const MAX_COMMITS = 2000;
|
|
52
|
+
/** Distinct blobs read per scan. Trees and blobs are deduplicated by oid, so
|
|
53
|
+
* this counts real content, not path instances. */
|
|
54
|
+
const MAX_BLOBS = 20000;
|
|
55
|
+
/** A blob larger than this is not read. Secrets hide in text, and the object
|
|
56
|
+
* reader has its own ceiling anyway. */
|
|
57
|
+
const MAX_BLOB_BYTES = 1024 * 1024;
|
|
58
|
+
/** Bytes inspected for NUL before calling a blob binary and skipping it. */
|
|
59
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A generic shape worth refusing to publish. `extract` returns the literal
|
|
63
|
+
* strings found, which is what makes baseline comparison possible: the finding
|
|
64
|
+
* is the STRING, so "already public" is a set membership test.
|
|
65
|
+
*
|
|
66
|
+
* @typedef {object} Detector
|
|
67
|
+
* @property {string} name
|
|
68
|
+
* @property {string} why Printed to the operator, so it must explain itself.
|
|
69
|
+
* @property {(text: string) => string[]} extract
|
|
70
|
+
*/
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Addresses that are aliases by construction. This is not an allowlist of
|
|
74
|
+
* anyone's real mail — it is the set of names that CANNOT reach an inbox,
|
|
75
|
+
* which is why a fixture is entitled to use them freely.
|
|
76
|
+
*
|
|
77
|
+
* The reserved names are RFC 2606 and RFC 6761: the `.invalid`, `.test`,
|
|
78
|
+
* `.example` and `.localhost` TLDs, and the `example.com/org/net` domains.
|
|
79
|
+
* They are matched as SUFFIXES, so `oracle@ccr.invalid` and
|
|
80
|
+
* `someone@corp.example.com` are covered — an earlier version anchored on
|
|
81
|
+
* `@invalid$` and flagged this repository's own test fixtures.
|
|
82
|
+
*/
|
|
83
|
+
const ALIAS_MAIL = /(@users\.noreply\.github\.com|@noreply\.[A-Za-z0-9.-]+|^noreply@|\.(invalid|test|example|localhost)$|(^|@|\.)example\.(com|org|net)$)/i;
|
|
84
|
+
|
|
85
|
+
const MAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* `/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\` — an absolute path
|
|
89
|
+
* through somebody's home directory. Generic placeholders are not findings.
|
|
90
|
+
*/
|
|
91
|
+
const HOME_RE = /(?:\/home\/|\/Users\/|[A-Za-z]:\\Users\\)([A-Za-z0-9._-]+)/g;
|
|
92
|
+
const PLACEHOLDER_USER = /^(user|username|you|me|someone|test|runner|ci|root|foo|bar|example|\$\{?\w+\}?)$/i;
|
|
93
|
+
|
|
94
|
+
/** @type {Detector[]} */
|
|
95
|
+
const DETECTORS = [
|
|
96
|
+
{
|
|
97
|
+
name: 'real-inbox',
|
|
98
|
+
why: 'an email address that is not a noreply alias',
|
|
99
|
+
extract: (text) => (text.match(MAIL_RE) || []).filter((m) => !ALIAS_MAIL.test(m)),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
name: 'home-path',
|
|
103
|
+
why: 'an absolute path naming a home directory',
|
|
104
|
+
extract: (text) => {
|
|
105
|
+
/** @type {string[]} */
|
|
106
|
+
const out = [];
|
|
107
|
+
for (const m of text.matchAll(HOME_RE)) {
|
|
108
|
+
if (!PLACEHOLDER_USER.test(m[1])) out.push(m[0]);
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The private supplement: literal ERE-ish patterns that must never appear,
|
|
117
|
+
* supplied from outside the repository so the repository never states them.
|
|
118
|
+
*
|
|
119
|
+
* Sources, first one that exists wins:
|
|
120
|
+
* 1. $CCR_PRIVATE_PATTERNS — patterns inline, comma or newline separated
|
|
121
|
+
* 2. $CCR_PRIVATE_PATTERNS_FILE — a file of them, one per line, `#` comments
|
|
122
|
+
* 3. ~/.config/ccr/private-patterns
|
|
123
|
+
*
|
|
124
|
+
* Absent all three the supplement is empty and `configured` is false, which
|
|
125
|
+
* the gate reports rather than swallows: a check that quietly did not run is
|
|
126
|
+
* worse than one that says so.
|
|
127
|
+
*
|
|
128
|
+
* @param {{ env?: NodeJS.ProcessEnv, homedir?: () => string }} [deps]
|
|
129
|
+
* @returns {{ configured: boolean, source: string|null, patterns: RegExp[], invalid: string[] }}
|
|
130
|
+
*/
|
|
131
|
+
function loadPrivatePatterns(deps = {}) {
|
|
132
|
+
const env = deps.env || process.env;
|
|
133
|
+
const home = (deps.homedir || os.homedir)();
|
|
134
|
+
|
|
135
|
+
/** @type {string|null} */
|
|
136
|
+
let raw = null;
|
|
137
|
+
/** @type {string|null} */
|
|
138
|
+
let source = null;
|
|
139
|
+
|
|
140
|
+
if (env.CCR_PRIVATE_PATTERNS && env.CCR_PRIVATE_PATTERNS.trim()) {
|
|
141
|
+
raw = env.CCR_PRIVATE_PATTERNS;
|
|
142
|
+
source = '$CCR_PRIVATE_PATTERNS';
|
|
143
|
+
} else {
|
|
144
|
+
const file = env.CCR_PRIVATE_PATTERNS_FILE
|
|
145
|
+
|| path.join(home, '.config', 'ccr', 'private-patterns');
|
|
146
|
+
try {
|
|
147
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
148
|
+
source = file;
|
|
149
|
+
} catch {
|
|
150
|
+
raw = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (raw === null) return { configured: false, source: null, patterns: [], invalid: [] };
|
|
155
|
+
|
|
156
|
+
/** @type {RegExp[]} */
|
|
157
|
+
const patterns = [];
|
|
158
|
+
/** @type {string[]} */
|
|
159
|
+
const invalid = [];
|
|
160
|
+
for (const line of raw.split(/[\n,]/)) {
|
|
161
|
+
const s = line.trim();
|
|
162
|
+
if (!s || s.startsWith('#')) continue;
|
|
163
|
+
try {
|
|
164
|
+
patterns.push(new RegExp(s, 'i'));
|
|
165
|
+
} catch {
|
|
166
|
+
invalid.push(s);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { configured: true, source, patterns, invalid };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Commits reachable from `tip` and not from `published`.
|
|
174
|
+
*
|
|
175
|
+
* The exclusion side is walked first and in full, so a commit that is an
|
|
176
|
+
* ancestor of the published ref is never reported no matter which order the
|
|
177
|
+
* tip walk reaches it in.
|
|
178
|
+
*
|
|
179
|
+
* A commit whose object cannot be read is REPORTED, not skipped. Dropping it
|
|
180
|
+
* would shrink the answer silently: an unreadable tip would walk to an empty
|
|
181
|
+
* list, and an empty list of unpublished commits is indistinguishable from
|
|
182
|
+
* having nothing to disclose. The caller must be able to tell those apart.
|
|
183
|
+
*
|
|
184
|
+
* @param {string} gitDir
|
|
185
|
+
* @param {string} tip
|
|
186
|
+
* @param {string|null} published Null means "nothing is published yet".
|
|
187
|
+
* @returns {{ commits: string[], unreadable: string[], truncated: boolean }}
|
|
188
|
+
*/
|
|
189
|
+
function unpublishedCommits(gitDir, tip, published) {
|
|
190
|
+
const parentsOf = (/** @type {string} */ oid) => {
|
|
191
|
+
const obj = readObject(gitDir, oid);
|
|
192
|
+
if (obj === null || obj.type !== 'commit') return null;
|
|
193
|
+
const header = obj.data.toString('utf8', 0, Math.min(obj.data.length, 8192));
|
|
194
|
+
const end = header.indexOf('\n\n');
|
|
195
|
+
const head = end === -1 ? header : header.slice(0, end);
|
|
196
|
+
return [...head.matchAll(/^parent ([0-9a-f]{40}|[0-9a-f]{64})$/gm)].map((m) => m[1]);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** @type {Set<string>} */
|
|
200
|
+
const excluded = new Set();
|
|
201
|
+
if (published !== null) {
|
|
202
|
+
const stack = [published];
|
|
203
|
+
while (stack.length > 0 && excluded.size < MAX_COMMITS) {
|
|
204
|
+
const oid = /** @type {string} */ (stack.pop());
|
|
205
|
+
if (excluded.has(oid)) continue;
|
|
206
|
+
excluded.add(oid);
|
|
207
|
+
const ps = parentsOf(oid);
|
|
208
|
+
if (ps !== null) stack.push(...ps);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** @type {string[]} */
|
|
213
|
+
const commits = [];
|
|
214
|
+
/** @type {string[]} */
|
|
215
|
+
const unreadable = [];
|
|
216
|
+
/** @type {Set<string>} */
|
|
217
|
+
const seen = new Set();
|
|
218
|
+
const stack = [tip];
|
|
219
|
+
let truncated = false;
|
|
220
|
+
while (stack.length > 0) {
|
|
221
|
+
const oid = /** @type {string} */ (stack.pop());
|
|
222
|
+
if (seen.has(oid) || excluded.has(oid)) continue;
|
|
223
|
+
seen.add(oid);
|
|
224
|
+
if (commits.length >= MAX_COMMITS) { truncated = true; break; }
|
|
225
|
+
const ps = parentsOf(oid);
|
|
226
|
+
if (ps === null) { unreadable.push(oid); continue; }
|
|
227
|
+
commits.push(oid);
|
|
228
|
+
stack.push(...ps);
|
|
229
|
+
}
|
|
230
|
+
return { commits, unreadable, truncated };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Every blob in a commit's tree, as `path -> oid`, following subtrees and
|
|
235
|
+
* skipping gitlinks (mode 0o160000 — a submodule pointer names a commit in
|
|
236
|
+
* another repository, whose contents are not ours to read).
|
|
237
|
+
*
|
|
238
|
+
* @param {string} gitDir
|
|
239
|
+
* @param {string} treeOid
|
|
240
|
+
* @param {Set<string>} treesSeen Shared across commits: sibling releases share
|
|
241
|
+
* almost all of their trees, and re-walking them is the whole cost.
|
|
242
|
+
* @param {(path: string, oid: string) => void} onBlob
|
|
243
|
+
*/
|
|
244
|
+
function walkTree(gitDir, treeOid, treesSeen, onBlob, prefix = '') {
|
|
245
|
+
if (treesSeen.has(treeOid)) return;
|
|
246
|
+
treesSeen.add(treeOid);
|
|
247
|
+
const obj = readObject(gitDir, treeOid);
|
|
248
|
+
if (obj === null || obj.type !== 'tree') return;
|
|
249
|
+
const entries = parseTree(obj.data, treeOid.length / 2);
|
|
250
|
+
if (entries === null) return;
|
|
251
|
+
for (const e of entries) {
|
|
252
|
+
const full = prefix ? `${prefix}/${e.name}` : e.name;
|
|
253
|
+
if (e.mode === 0o160000) continue;
|
|
254
|
+
if (e.mode === 0o40000) walkTree(gitDir, e.oid, treesSeen, onBlob, full);
|
|
255
|
+
else onBlob(full, e.oid);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Read a blob as text, or null when it is missing, oversized or binary.
|
|
261
|
+
* @param {string} gitDir
|
|
262
|
+
* @param {string} oid
|
|
263
|
+
*/
|
|
264
|
+
function readTextBlob(gitDir, oid) {
|
|
265
|
+
const obj = readObject(gitDir, oid);
|
|
266
|
+
if (obj === null || obj.type !== 'blob') return null;
|
|
267
|
+
if (obj.data.length > MAX_BLOB_BYTES) return null;
|
|
268
|
+
if (obj.data.indexOf(0, 0) !== -1
|
|
269
|
+
&& obj.data.indexOf(0, 0) < BINARY_SNIFF_BYTES) return null;
|
|
270
|
+
return obj.data.toString('utf8');
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Every literal a detector finds anywhere in a tree — the baseline of what is
|
|
275
|
+
* already public, so the same string appearing again is not a new disclosure.
|
|
276
|
+
*
|
|
277
|
+
* @param {string} gitDir
|
|
278
|
+
* @param {string} treeOid
|
|
279
|
+
* @returns {Set<string>}
|
|
280
|
+
*/
|
|
281
|
+
function baselineLiterals(gitDir, treeOid) {
|
|
282
|
+
/** @type {Set<string>} */
|
|
283
|
+
const found = new Set();
|
|
284
|
+
/** @type {Set<string>} */
|
|
285
|
+
const trees = new Set();
|
|
286
|
+
/** @type {Set<string>} */
|
|
287
|
+
const blobs = new Set();
|
|
288
|
+
walkTree(gitDir, treeOid, trees, (_p, oid) => {
|
|
289
|
+
if (blobs.has(oid) || blobs.size >= MAX_BLOBS) return;
|
|
290
|
+
blobs.add(oid);
|
|
291
|
+
const text = readTextBlob(gitDir, oid);
|
|
292
|
+
if (text === null) return;
|
|
293
|
+
for (const d of DETECTORS) for (const lit of d.extract(text)) found.add(lit);
|
|
294
|
+
});
|
|
295
|
+
return found;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* @typedef {object} PrivacyHit
|
|
300
|
+
* @property {string} commit Oid of the commit whose tree carries it.
|
|
301
|
+
* @property {string} path Path within that tree.
|
|
302
|
+
* @property {string} kind Detector name, or 'private-pattern'.
|
|
303
|
+
* @property {string} why Human sentence for the operator.
|
|
304
|
+
* @property {string} [literal] The offending string, when it is safe to print
|
|
305
|
+
* (detector hits only — a supplement hit prints its pattern, never its match).
|
|
306
|
+
*/
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Scan the unpublished history.
|
|
310
|
+
*
|
|
311
|
+
* @param {string} gitDir
|
|
312
|
+
* @param {object} opts
|
|
313
|
+
* @param {string} opts.tip Commit about to be published from.
|
|
314
|
+
* @param {string|null} opts.published Commit currently public, or null.
|
|
315
|
+
* @param {RegExp[]} [opts.privatePatterns]
|
|
316
|
+
* @param {string[]} [opts.allow] Literals known to be invented, joined to
|
|
317
|
+
* the baseline. Detector hits only — a private pattern is never waived here.
|
|
318
|
+
* @returns {{ state: 'clean'|'hits'|'unavailable', hits: PrivacyHit[],
|
|
319
|
+
* commitsScanned: number, blobsScanned: number, truncated: boolean }}
|
|
320
|
+
*/
|
|
321
|
+
function scanHistory(gitDir, opts) {
|
|
322
|
+
const priv = opts.privatePatterns || [];
|
|
323
|
+
const walk = unpublishedCommits(gitDir, opts.tip, opts.published);
|
|
324
|
+
const { commits, truncated } = walk;
|
|
325
|
+
|
|
326
|
+
// ANY commit the object store could not answer for makes the whole scan
|
|
327
|
+
// inconclusive, and inconclusive is not clean. A gate that cleared a release
|
|
328
|
+
// because it failed to read the evidence would be worse than no gate: it
|
|
329
|
+
// would report success in exactly the situation it exists to catch.
|
|
330
|
+
if (walk.unreadable.length > 0) {
|
|
331
|
+
return {
|
|
332
|
+
state: 'unavailable', hits: [], commitsScanned: 0, blobsScanned: 0, truncated,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (commits.length === 0) {
|
|
337
|
+
return { state: 'clean', hits: [], commitsScanned: 0, blobsScanned: 0, truncated };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// What the published tree already says. Absent a published ref there is no
|
|
341
|
+
// baseline and every detector hit is news, which is the correct reading of
|
|
342
|
+
// a first publication.
|
|
343
|
+
/** @type {Set<string>} */
|
|
344
|
+
let baseline = new Set(opts.allow || []);
|
|
345
|
+
if (opts.published !== null) {
|
|
346
|
+
const obj = readObject(gitDir, opts.published);
|
|
347
|
+
if (obj !== null && obj.type === 'commit') {
|
|
348
|
+
const m = /^tree ([0-9a-f]{40}|[0-9a-f]{64})$/m.exec(
|
|
349
|
+
obj.data.toString('latin1', 0, Math.min(obj.data.length, 256)));
|
|
350
|
+
// Merged, not assigned: the allow list must survive the published tree's
|
|
351
|
+
// own literals being read in on top of it.
|
|
352
|
+
if (m) for (const lit of baselineLiterals(gitDir, m[1])) baseline.add(lit);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** @type {PrivacyHit[]} */
|
|
357
|
+
const hits = [];
|
|
358
|
+
let unreadable = 0;
|
|
359
|
+
let blobCeiling = false;
|
|
360
|
+
|
|
361
|
+
// Reading and matching a blob is the expensive half and is done ONCE per
|
|
362
|
+
// oid. Attribution is the cheap half and is done per commit: the same blob
|
|
363
|
+
// usually survives many commits, and an operator who is told only about the
|
|
364
|
+
// first one would accept that commit and publish the other nine. The earlier
|
|
365
|
+
// shape of this function made exactly that mistake.
|
|
366
|
+
/** @type {Map<string, Array<{ kind: string, why: string, literal?: string }>>} */
|
|
367
|
+
const verdicts = new Map();
|
|
368
|
+
const verdictFor = (/** @type {string} */ oid) => {
|
|
369
|
+
const cached = verdicts.get(oid);
|
|
370
|
+
if (cached !== undefined) return cached;
|
|
371
|
+
if (verdicts.size >= MAX_BLOBS) { blobCeiling = true; return []; }
|
|
372
|
+
/** @type {Array<{ kind: string, why: string, literal?: string }>} */
|
|
373
|
+
const found = [];
|
|
374
|
+
const text = readTextBlob(gitDir, oid);
|
|
375
|
+
if (text !== null) {
|
|
376
|
+
// One finding per distinct literal per blob. A string repeated forty
|
|
377
|
+
// times in a file is one disclosure, and forty lines of it would bury
|
|
378
|
+
// the other findings the operator needs to see.
|
|
379
|
+
/** @type {Set<string>} */
|
|
380
|
+
const already = new Set();
|
|
381
|
+
for (const d of DETECTORS) {
|
|
382
|
+
for (const lit of d.extract(text)) {
|
|
383
|
+
if (baseline.has(lit) || already.has(lit)) continue;
|
|
384
|
+
already.add(lit);
|
|
385
|
+
found.push({ kind: d.name, why: d.why, literal: lit });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
for (const re of priv) {
|
|
389
|
+
if (re.test(text)) {
|
|
390
|
+
found.push({ kind: 'private-pattern', why: `matches the private pattern /${re.source}/` });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
verdicts.set(oid, found);
|
|
395
|
+
return found;
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
for (const commit of commits) {
|
|
399
|
+
const obj = readObject(gitDir, commit);
|
|
400
|
+
if (obj === null || obj.type !== 'commit') { unreadable += 1; continue; }
|
|
401
|
+
const m = /^tree ([0-9a-f]{40}|[0-9a-f]{64})$/m.exec(
|
|
402
|
+
obj.data.toString('latin1', 0, Math.min(obj.data.length, 256)));
|
|
403
|
+
if (!m) { unreadable += 1; continue; }
|
|
404
|
+
|
|
405
|
+
// Per-commit, so a tree shared with an already-walked commit is still
|
|
406
|
+
// attributed to this one. Within a commit it still collapses duplicates.
|
|
407
|
+
/** @type {Set<string>} */
|
|
408
|
+
const treesSeen = new Set();
|
|
409
|
+
walkTree(gitDir, m[1], treesSeen, (p, oid) => {
|
|
410
|
+
for (const v of verdictFor(oid)) hits.push({ commit, path: p, ...v });
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Same rule one level down: a commit that resolved but whose tree header did
|
|
415
|
+
// not parse leaves part of the history unexamined.
|
|
416
|
+
if (unreadable > 0) {
|
|
417
|
+
return {
|
|
418
|
+
state: 'unavailable', hits: [], commitsScanned: 0,
|
|
419
|
+
blobsScanned: verdicts.size, truncated: truncated || blobCeiling,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
return {
|
|
424
|
+
state: hits.length > 0 ? 'hits' : 'clean',
|
|
425
|
+
hits,
|
|
426
|
+
commitsScanned: commits.length,
|
|
427
|
+
blobsScanned: verdicts.size,
|
|
428
|
+
truncated: truncated || blobCeiling,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
module.exports = {
|
|
433
|
+
scanHistory, unpublishedCommits, loadPrivatePatterns, walkTree, baselineLiterals,
|
|
434
|
+
DETECTORS, MAX_COMMITS, MAX_BLOBS, MAX_BLOB_BYTES,
|
|
435
|
+
};
|