@link-foundation/sensitive-data-sanitizer 0.0.0-stage → 0.11.31

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +530 -0
  2. package/LICENSE +24 -0
  3. package/README.md +184 -2
  4. package/bin/legacy-example-package-name.js +95 -0
  5. package/bin/sensitive-data-sanitizer.js +430 -0
  6. package/bin/stream.js +56 -0
  7. package/docs/API.md +336 -0
  8. package/docs/COVERAGE.md +136 -0
  9. package/docs/HISTORY-REMEDIATION.md +41 -0
  10. package/examples/combined-scanners.mjs +23 -0
  11. package/examples/presidio-bridge.py +32 -0
  12. package/examples/realistic-fakes.mjs +18 -0
  13. package/examples/sanitize-session.mjs +27 -0
  14. package/examples/shared-tool-logs.mjs +21 -0
  15. package/examples/stream-jsonl.mjs +17 -0
  16. package/package.json +124 -4
  17. package/src/adapters.js +204 -0
  18. package/src/arithmetic.js +11 -0
  19. package/src/confidence.js +65 -0
  20. package/src/context.js +424 -0
  21. package/src/credentials.js +54 -0
  22. package/src/detection.js +293 -0
  23. package/src/encoded.js +233 -0
  24. package/src/engines.js +348 -0
  25. package/src/entities.js +166 -0
  26. package/src/escape-spans.js +20 -0
  27. package/src/eslint-plugin.d.ts +10 -0
  28. package/src/eslint-plugin.js +193 -0
  29. package/src/external.js +169 -0
  30. package/src/fake-names.js +222 -0
  31. package/src/fake.js +524 -0
  32. package/src/files.js +131 -0
  33. package/src/gitleaks.js +194 -0
  34. package/src/history-rewrite.js +339 -0
  35. package/src/history.js +103 -0
  36. package/src/identity.js +274 -0
  37. package/src/index.d.ts +321 -0
  38. package/src/index.js +40 -0
  39. package/src/jsonl-stream.js +198 -0
  40. package/src/keywords.js +45 -0
  41. package/src/known.js +164 -0
  42. package/src/legacy.d.ts +3 -0
  43. package/src/legacy.js +2 -0
  44. package/src/locales.js +217 -0
  45. package/src/log-bytes.js +85 -0
  46. package/src/names.js +65 -0
  47. package/src/national-checks.js +129 -0
  48. package/src/national.js +128 -0
  49. package/src/outbound.js +28 -0
  50. package/src/personal-context.js +85 -0
  51. package/src/personal.js +248 -0
  52. package/src/projection.js +126 -0
  53. package/src/propagation.js +163 -0
  54. package/src/public-verifier.js +77 -0
  55. package/src/public.js +153 -0
  56. package/src/record-worker.js +20 -0
  57. package/src/rules.js +210 -0
  58. package/src/sanitizer.js +794 -0
  59. package/src/specimens.js +34 -0
  60. package/src/stream-identity.js +129 -0
  61. package/src/stream-worker.js +18 -0
  62. package/src/stream.js +449 -0
  63. package/src/structured.js +278 -0
  64. package/src/transforms.js +192 -0
  65. package/src/transliteration.js +75 -0
  66. package/src/utf8.js +60 -0
  67. package/src/vendor/entities/aws.json +42 -0
  68. package/src/vendor/entities/azure.json +182 -0
  69. package/src/vendor/entities/google.json +219 -0
  70. package/src/vendor/gitleaks/LICENSE +21 -0
  71. package/src/vendor/gitleaks/provenance.json +8 -0
  72. package/src/vendor/gitleaks/rules-0.json +268 -0
  73. package/src/vendor/gitleaks/rules-1.json +1794 -0
  74. package/src/vendor/gitleaks/rules-2.json +292 -0
  75. package/src/vendor/gitleaks/rules-3.json +263 -0
  76. package/src/vendor/gitleaks/rules-4.json +277 -0
  77. package/src/vendor/gitleaks/rules-5.json +154 -0
  78. package/src/worker-options.js +27 -0
@@ -0,0 +1,194 @@
1
+ import { RE2 } from 're2-wasm';
2
+ import { RE2JS } from 're2js';
3
+ import { performance } from 'node:perf_hooks';
4
+ import r0 from './vendor/gitleaks/rules-0.json' with { type: 'json' };
5
+ import r1 from './vendor/gitleaks/rules-1.json' with { type: 'json' };
6
+ import r2 from './vendor/gitleaks/rules-2.json' with { type: 'json' };
7
+ import r3 from './vendor/gitleaks/rules-3.json' with { type: 'json' };
8
+ import r4 from './vendor/gitleaks/rules-4.json' with { type: 'json' };
9
+ import r5 from './vendor/gitleaks/rules-5.json' with { type: 'json' };
10
+ import { entropy, REDACTED } from './detection.js';
11
+ import { keywordMatcher } from './keywords.js';
12
+
13
+ export const gitleaksRules = [...r0, ...r1, ...r2, ...r3, ...r4, ...r5];
14
+ const compiled = new Map();
15
+ const largePatterns = new Map();
16
+ const keywordIndex = keywordMatcher(
17
+ gitleaksRules.flatMap((r) =>
18
+ (r.keywords ?? []).map((word) => word.toLowerCase())
19
+ )
20
+ );
21
+
22
+ export function detectGitleaks(text, emit, trace) {
23
+ const keywords = keywordIndex(text.toLowerCase());
24
+ const large = Buffer.byteLength(text) > 4 * 1024 * 1024;
25
+ for (const rule of gitleaksRules) {
26
+ if (
27
+ !rule.regex ||
28
+ (rule.keywords?.length &&
29
+ !rule.keywords.some((word) => keywords.has(word.toLowerCase())))
30
+ ) {
31
+ continue;
32
+ }
33
+ const started = trace ? performance.now() : 0;
34
+ for (const view of ruleViews(text, rule)) {
35
+ scanRule(view.text, view.start, rule, emit, large);
36
+ }
37
+ trace?.({
38
+ rule: rule.id,
39
+ length: text.length,
40
+ ms: performance.now() - started,
41
+ });
42
+ }
43
+ }
44
+
45
+ function* ruleViews(text, rule) {
46
+ // These bounded rules require a listed keyword within 300 characters of
47
+ // every match. Local views avoid RE2's expensive search/copy over unrelated
48
+ // multi-megabyte image data. Keep the unbounded catalog rules intact.
49
+ const boundedPrefix = {
50
+ 'authress-service-client-access-key': '\\b(?:sc|ext|scauth|authress)_',
51
+ 'atlassian-api-token': '(?:atlassian|confluence|jira)',
52
+ 'etsy-access-token': 'etsy',
53
+ 'new-relic-user-api-key': '(?:new-relic|newrelic|new_relic)',
54
+ 'okta-access-token': 'okta',
55
+ 'snyk-api-token': 'snyk[_.-]?(?:(?:api|oauth)[_.-]?)?(?:key|token)',
56
+ }[rule.id];
57
+ if (boundedPrefix) {
58
+ yield* boundedViews(text, boundedPrefix, rule.id);
59
+ return;
60
+ }
61
+ const targeted = {
62
+ 'generic-api-key':
63
+ /(?:access|auth|api|credential|creds|key|passw(?:or)?d|secret|token)[ \t\w.-]{0,20}[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)/gi,
64
+ 'hashicorp-tf-password': /administrator_login_password|password/gi,
65
+ 'sonar-api-token': /sonar[_.-]?(?:login|token)/gi,
66
+ }[rule.id];
67
+ if (!targeted) {
68
+ yield { text, start: 0 };
69
+ return;
70
+ }
71
+ // These vendored rules have <=50 prefix characters and <=31 characters
72
+ // between their required keyword and secret. Scan near each keyword instead
73
+ // of recopying an entire session into WASM for every match. Extend the only
74
+ // unbounded secret alternative to its real terminator; never truncate it.
75
+ let coveredEnd = -1;
76
+ for (const match of text.matchAll(targeted)) {
77
+ // A keyword inside an already scanned value must not copy/rescan its
78
+ // complete suffix. Base64 can contain thousands of incidental keywords.
79
+ if (match.index + 384 <= coveredEnd || coveredEnd === text.length) {
80
+ continue;
81
+ }
82
+ let start = Math.max(0, match.index - 50),
83
+ end = Math.min(text.length, match.index + 384);
84
+ if (/[\uDC00-\uDFFF]/.test(text[start] ?? '')) {
85
+ start--;
86
+ }
87
+ while (end < text.length && /[\w.=/+-]/.test(text[end])) {
88
+ end++;
89
+ }
90
+ end = Math.min(text.length, end + 3);
91
+ coveredEnd = end;
92
+ yield { text: text.slice(start, end), start };
93
+ }
94
+ }
95
+
96
+ function* boundedViews(text, prefix, id) {
97
+ const words = new RegExp(
98
+ id === 'authress-service-client-access-key'
99
+ ? prefix
100
+ : `${prefix}[ \\t\\w.-]{0,20}[\\s'"]{0,3}(?:=|>|:{1,3}=|\\|\\||:|=>|\\?=|,)${id === 'atlassian-api-token' ? '|ATATT3' : ''}`,
101
+ 'gi'
102
+ );
103
+ let covered = -1;
104
+ for (const match of text.matchAll(words)) {
105
+ if (match.index + 512 <= covered) {
106
+ continue;
107
+ }
108
+ let start = Math.max(0, match.index - 512);
109
+ let end = Math.min(text.length, match.index + 1024);
110
+ if (/[\uDC00-\uDFFF]/.test(text[start] ?? '')) {
111
+ start--;
112
+ }
113
+ if (/[\uDC00-\uDFFF]/.test(text[end] ?? '')) {
114
+ end++;
115
+ }
116
+ covered = end;
117
+ yield { text: text.slice(start, end), start };
118
+ }
119
+ }
120
+
121
+ function scanRule(text, base, rule, emit, large) {
122
+ // re2-wasm 1.0.2 returns codepoint indices despite its RegExp-like API.
123
+ // Convert once per view, and keep its search cursor in codepoints too.
124
+ let offsets;
125
+ if (!large && /[\uD800-\uDBFF]/.test(text)) {
126
+ offsets = [0];
127
+ let offset = 0;
128
+ for (const char of text) {
129
+ offset += char.length;
130
+ offsets.push(offset);
131
+ }
132
+ }
133
+ for (const match of ruleMatches(text, rule, large)) {
134
+ const secret = capturedSecret(rule, match);
135
+ if (!secret) {
136
+ continue;
137
+ }
138
+ // RE2's wrapper exposes full-match offsets, not subgroup indices. If the
139
+ // span includes each occurrence when a captured substring is repeated.
140
+ const offset = match[0].indexOf(secret),
141
+ last = match[0].lastIndexOf(secret);
142
+ emit({
143
+ start: base + (offsets ? offsets[match.index] : match.index) + offset,
144
+ end:
145
+ base +
146
+ (offsets ? offsets[match.index] : match.index) +
147
+ last +
148
+ secret.length,
149
+ type: 'SECRET',
150
+ category: 'credential',
151
+ rule: `gitleaks/${rule.id}`,
152
+ confidence: rule.entropy ? 0.85 : 0.95,
153
+ });
154
+ }
155
+ }
156
+
157
+ function* ruleMatches(text, rule, large) {
158
+ if (large) {
159
+ // re2-wasm has a fixed 16 MiB heap. RE2JS implements the same linear-time
160
+ // syntax without copying the input into WASM. Its indices are UTF-16.
161
+ let linear = largePatterns.get(rule.id);
162
+ if (!linear) {
163
+ linear = RE2JS.compile(rule.regex);
164
+ largePatterns.set(rule.id, linear);
165
+ }
166
+ if (linear.test(text)) {
167
+ yield* linear.matchAll(text);
168
+ }
169
+ return;
170
+ }
171
+ let pattern = compiled.get(rule.id);
172
+ if (!pattern) {
173
+ pattern = new RE2(rule.regex, 'gu');
174
+ compiled.set(rule.id, pattern);
175
+ }
176
+ pattern.lastIndex = 0;
177
+ let match;
178
+ while ((match = pattern.exec(text)) !== null) {
179
+ pattern.lastIndex =
180
+ match.index + Array.from(match[0]).length || pattern.lastIndex + 1;
181
+ yield match;
182
+ }
183
+ }
184
+
185
+ function capturedSecret(rule, match) {
186
+ const secret = rule.secretGroup
187
+ ? match[rule.secretGroup]
188
+ : (match.slice(1).find(Boolean) ?? match[0]);
189
+ return !secret ||
190
+ secret === REDACTED ||
191
+ (rule.entropy && entropy(secret) < rule.entropy)
192
+ ? undefined
193
+ : secret;
194
+ }
@@ -0,0 +1,339 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdir, writeFile, readFile, rm, chmod } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import { createSanitizer } from './engines.js';
5
+ import { auditGitHistory } from './history.js';
6
+ import { decodeText } from './files.js';
7
+ import { failure } from './detection.js';
8
+
9
+ function git(repository, args) {
10
+ try {
11
+ return execFileSync('git', ['-C', repository, ...args], {
12
+ maxBuffer: 100 * 1024 * 1024,
13
+ stdio: ['ignore', 'pipe', 'pipe'],
14
+ });
15
+ } catch {
16
+ throw failure('ERR_GIT');
17
+ }
18
+ }
19
+ async function identity(line, engine) {
20
+ const match = /^(.*) <([^>]*)> (\d+ [+-]\d{4})$/.exec(line);
21
+ if (!match) {
22
+ throw failure('ERR_GIT');
23
+ }
24
+ const name = await engine.sanitize(match[1]),
25
+ email = await engine.sanitize(match[2]);
26
+ const date = (await engine.inspect(match[3])).length ? '0 +0000' : match[3];
27
+ return {
28
+ name: name.text,
29
+ email: email.redactions ? 'REDACTED' : email.text,
30
+ date,
31
+ originalName: match[1],
32
+ originalEmail: match[2],
33
+ };
34
+ }
35
+ function regexp(value) {
36
+ return value
37
+ .replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
38
+ .replaceAll('\n', '\\n')
39
+ .replaceAll('\r', '\\r');
40
+ }
41
+ async function buildPlan(repository, audit, engine, maxPaths) {
42
+ const data = { blobs: {}, commits: {}, tags: {}, paths: {}, refs: {} },
43
+ replacements = new Set(),
44
+ mailmap = new Set();
45
+ for (const entry of audit.findings) {
46
+ const text = decodeText(
47
+ git(repository, ['cat-file', entry.type, entry.object])
48
+ );
49
+ for (const f of entry.findings) {
50
+ const value = text.slice(f.start, f.end);
51
+ replacements.add(
52
+ `regex:${regexp(value).replaceAll('==>', '\\x3d\\x3d\\x3e')}==>[REDACTED]`
53
+ );
54
+ }
55
+ if (entry.type === 'blob') {
56
+ data.blobs[entry.object] = Buffer.from(
57
+ (await engine.sanitize(text)).text
58
+ ).toString('base64');
59
+ continue;
60
+ }
61
+ const split = text.indexOf('\n\n');
62
+ if (split < 0) {
63
+ throw failure('ERR_GIT');
64
+ }
65
+ const details = {
66
+ message: (await engine.sanitize(text.slice(split + 2))).text,
67
+ };
68
+ for (const field of entry.type === 'commit'
69
+ ? ['author', 'committer']
70
+ : ['tagger']) {
71
+ const line = text
72
+ .slice(0, split)
73
+ .split('\n')
74
+ .find((record) => record.startsWith(`${field} `));
75
+ if (!line) {
76
+ continue;
77
+ }
78
+ const person = await identity(line.slice(field.length + 1), engine);
79
+ details[field] = {
80
+ name: person.name,
81
+ email: person.email,
82
+ date: person.date,
83
+ };
84
+ if (
85
+ person.name !== person.originalName ||
86
+ person.email !== person.originalEmail
87
+ ) {
88
+ mailmap.add(
89
+ `${person.name} <${person.email}> ${person.originalName} <${person.originalEmail}>`
90
+ );
91
+ }
92
+ }
93
+ data[entry.type === 'commit' ? 'commits' : 'tags'][entry.object] = details;
94
+ }
95
+ const paths = new Set();
96
+ for (const commit of git(repository, ['rev-list', '--all'])
97
+ .toString()
98
+ .trim()
99
+ .split('\n')
100
+ .filter(Boolean)) {
101
+ for (const path of git(repository, [
102
+ 'ls-tree',
103
+ '-r',
104
+ '-z',
105
+ '--name-only',
106
+ commit,
107
+ ])
108
+ .toString('utf8')
109
+ .split('\0')
110
+ .filter(Boolean)) {
111
+ paths.add(path);
112
+ if (paths.size > maxPaths) {
113
+ throw failure('ERR_LIMIT');
114
+ }
115
+ }
116
+ }
117
+ await pathMappings(paths, data.paths, engine, false);
118
+ const refs = git(repository, ['for-each-ref', '--format=%(refname)'])
119
+ .toString('utf8')
120
+ .trim()
121
+ .split('\n')
122
+ .filter(Boolean);
123
+ await pathMappings(refs, data.refs, engine, true);
124
+ return {
125
+ data,
126
+ replacements: `${[...replacements].join('\n')}\n`,
127
+ mailmap: `${[...mailmap].join('\n')}\n`,
128
+ };
129
+ }
130
+ async function pathMappings(paths, mappings, engine, ref) {
131
+ const targets = new Map();
132
+ for (const path of paths) {
133
+ const result = await engine.sanitize(path),
134
+ replacement = ref
135
+ ? result.text.replaceAll('[REDACTED]', 'REDACTED')
136
+ : result.text;
137
+ if (targets.has(replacement) && targets.get(replacement) !== path) {
138
+ throw failure('ERR_HISTORY_COLLISION');
139
+ }
140
+ targets.set(replacement, path);
141
+ if (result.redactions) {
142
+ mappings[path] = replacement;
143
+ }
144
+ }
145
+ }
146
+ const callbacks = `import base64, json
147
+ from pathlib import Path
148
+ plan = json.loads(Path(__file__).with_name('replacements.json').read_text())
149
+ def blob(blob):
150
+ value = plan['blobs'].get(blob.original_id.decode())
151
+ if value is not None: blob.data = base64.b64decode(value)
152
+ def metadata(obj, group):
153
+ value = plan[group].get(obj.original_id.decode())
154
+ if value is None: return
155
+ obj.message = value['message'].encode()
156
+ for role in ('author', 'committer', 'tagger'):
157
+ if role in value:
158
+ for key in ('name', 'email', 'date'):
159
+ setattr(obj, role + '_' + key, value[role][key].encode())
160
+ def filename(value):
161
+ return plan['paths'].get(value.decode(), value.decode()).encode()
162
+ def refname(value):
163
+ return plan['refs'].get(value.decode(), value.decode()).encode()
164
+ `;
165
+ /** Preview by default; --apply affects only the newly created mirror clone. */
166
+ export async function rewriteGitHistory(
167
+ source,
168
+ destination,
169
+ {
170
+ apply = false,
171
+ sanitizer,
172
+ sanitizerOptions = {},
173
+ filterRepoCommand = 'git-filter-repo',
174
+ maxPaths = 10000,
175
+ ...auditOptions
176
+ } = {}
177
+ ) {
178
+ validateRewriteOptions(apply, maxPaths, filterRepoCommand);
179
+ const directory = resolve(destination),
180
+ repository = join(directory, 'repository.git');
181
+ sanitizer ??= createSanitizer(sanitizerOptions);
182
+ try {
183
+ await mkdir(directory, { mode: 0o700 });
184
+ } catch {
185
+ throw failure('ERR_OUTPUT');
186
+ }
187
+ try {
188
+ execFileSync(
189
+ 'git',
190
+ ['clone', '--mirror', '--no-local', '--', resolve(source), repository],
191
+ { maxBuffer: 4 * 1024 * 1024, stdio: ['ignore', 'pipe', 'pipe'] }
192
+ );
193
+ await chmod(repository, 0o700);
194
+ const before = await auditGitHistory(repository, {
195
+ ...auditOptions,
196
+ sanitizer,
197
+ });
198
+ if (!before.complete) {
199
+ throw failure('ERR_HISTORY_INCOMPLETE');
200
+ }
201
+ const plan = await buildPlan(repository, before, sanitizer, maxPaths);
202
+ for (const [name, content] of Object.entries({
203
+ 'replacements.json': JSON.stringify(plan.data),
204
+ 'replace-text.txt': plan.replacements,
205
+ mailmap: plan.mailmap,
206
+ 'sanitizer_plan.py': callbacks,
207
+ })) {
208
+ await writeFile(join(directory, name), content, {
209
+ flag: 'wx',
210
+ mode: 0o600,
211
+ });
212
+ }
213
+ if (apply) {
214
+ runFilter(repository, directory, filterRepoCommand);
215
+ return await completedRewrite(
216
+ { repository, directory, before, plan },
217
+ sanitizer,
218
+ auditOptions,
219
+ maxPaths
220
+ );
221
+ }
222
+ git(repository, ['remote', 'remove', 'origin']);
223
+ return {
224
+ applied: false,
225
+ repository,
226
+ before,
227
+ changedPaths: Object.keys(plan.data.paths).length,
228
+ changedRefs: Object.keys(plan.data.refs).length,
229
+ };
230
+ } catch (error) {
231
+ await rm(directory, { recursive: true, force: true });
232
+ throw failure(
233
+ /^ERR_[A-Z_]+$/.test(error.code) ? error.code : 'ERR_HISTORY_REWRITE'
234
+ );
235
+ }
236
+ }
237
+ function runFilter(repository, directory, command) {
238
+ try {
239
+ execFileSync(
240
+ command,
241
+ [
242
+ '--blob-callback',
243
+ 'import sanitizer_plan; sanitizer_plan.blob(blob)',
244
+ '--commit-callback',
245
+ "import sanitizer_plan; sanitizer_plan.metadata(commit, 'commits')",
246
+ '--tag-callback',
247
+ "import sanitizer_plan; sanitizer_plan.metadata(tag, 'tags')",
248
+ '--filename-callback',
249
+ 'import sanitizer_plan; return sanitizer_plan.filename(filename)',
250
+ '--refname-callback',
251
+ 'import sanitizer_plan; return sanitizer_plan.refname(refname)',
252
+ ],
253
+ {
254
+ cwd: repository,
255
+ env: { ...process.env, PYTHONPATH: directory },
256
+ maxBuffer: 4 * 1024 * 1024,
257
+ stdio: ['ignore', 'pipe', 'pipe'],
258
+ }
259
+ );
260
+ } catch {
261
+ throw failure('ERR_HISTORY_REWRITE');
262
+ }
263
+ }
264
+ async function checkPaths(repository, sanitizer, maxPaths) {
265
+ const plan = await buildPlan(
266
+ repository,
267
+ { findings: [] },
268
+ sanitizer,
269
+ maxPaths
270
+ );
271
+ if (
272
+ Object.keys(plan.data.paths).length ||
273
+ Object.keys(plan.data.refs).length
274
+ ) {
275
+ throw failure('ERR_HISTORY_RESIDUAL');
276
+ }
277
+ }
278
+ async function removePrivatePlan(directory) {
279
+ for (const name of [
280
+ 'replacements.json',
281
+ 'replace-text.txt',
282
+ 'mailmap',
283
+ 'sanitizer_plan.py',
284
+ '__pycache__',
285
+ ]) {
286
+ await rm(join(directory, name), { recursive: true, force: true });
287
+ }
288
+ const config = await readFile(
289
+ join(directory, 'repository.git', 'config'),
290
+ 'utf8'
291
+ );
292
+ if (config.includes('[remote "origin"]')) {
293
+ git(join(directory, 'repository.git'), ['remote', 'remove', 'origin']);
294
+ }
295
+ }
296
+
297
+ async function completedRewrite(
298
+ { repository, directory, before, plan },
299
+ sanitizer,
300
+ auditOptions,
301
+ maxPaths
302
+ ) {
303
+ const after = await auditGitHistory(repository, {
304
+ ...auditOptions,
305
+ sanitizer,
306
+ });
307
+ if (!after.complete || after.findings.length) {
308
+ throw failure('ERR_HISTORY_RESIDUAL');
309
+ }
310
+ await checkPaths(repository, sanitizer, maxPaths);
311
+ git(repository, ['fsck', '--full']);
312
+ await removePrivatePlan(directory);
313
+ const result = {
314
+ applied: true,
315
+ repository,
316
+ before,
317
+ after,
318
+ changedPaths: Object.keys(plan.data.paths).length,
319
+ changedRefs: Object.keys(plan.data.refs).length,
320
+ };
321
+ await writeFile(
322
+ join(directory, 'manifest.json'),
323
+ `${JSON.stringify(result, null, 2)}\n`,
324
+ { mode: 0o600 }
325
+ );
326
+ return result;
327
+ }
328
+
329
+ function validateRewriteOptions(apply, maxPaths, command) {
330
+ if (
331
+ typeof apply !== 'boolean' ||
332
+ !Number.isSafeInteger(maxPaths) ||
333
+ maxPaths <= 0 ||
334
+ typeof command !== 'string' ||
335
+ !command
336
+ ) {
337
+ throw failure('ERR_CONFIG');
338
+ }
339
+ }
package/src/history.js ADDED
@@ -0,0 +1,103 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { failure } from './detection.js';
3
+ import { decodeText } from './files.js';
4
+ import { createSanitizer } from './engines.js';
5
+
6
+ export async function auditGitHistory(
7
+ repository,
8
+ {
9
+ sanitizer,
10
+ maxObjects = 10000,
11
+ maxBytes = 10 * 1024 * 1024,
12
+ maxTotalBytes = 100 * 1024 * 1024,
13
+ } = {}
14
+ ) {
15
+ sanitizer ??= createSanitizer();
16
+ validateLimits([maxObjects, maxBytes, maxTotalBytes]);
17
+ const git = (args, input) => {
18
+ try {
19
+ return execFileSync('git', ['-C', repository, ...args], {
20
+ input,
21
+ maxBuffer: Math.max(maxBytes + 1024, 4 * 1024 * 1024),
22
+ stdio: ['pipe', 'pipe', 'pipe'],
23
+ });
24
+ } catch {
25
+ throw failure('ERR_GIT');
26
+ }
27
+ };
28
+ const objects = new Set(
29
+ git(['rev-list', '--objects', '--all', '--reflog'])
30
+ .toString('utf8')
31
+ .trim()
32
+ .split('\n')
33
+ .filter(Boolean)
34
+ .map((line) => line.split(' ')[0])
35
+ );
36
+ if (objects.size > maxObjects) {
37
+ throw failure('ERR_LIMIT');
38
+ }
39
+ const metadata = git(
40
+ ['cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)'],
41
+ `${[...objects].join('\n')}\n`
42
+ )
43
+ .toString('utf8')
44
+ .trim()
45
+ .split('\n')
46
+ .filter(Boolean);
47
+ const findings = [],
48
+ skipped = [];
49
+ let scanned = 0,
50
+ total = 0;
51
+ for (const record of metadata) {
52
+ const { object, type, size } = parseRecord(record);
53
+ if (!['blob', 'commit', 'tag'].includes(type)) {
54
+ continue;
55
+ }
56
+ total += size;
57
+ if (size > maxBytes || total > maxTotalBytes) {
58
+ throw failure('ERR_LIMIT');
59
+ }
60
+ const bytes = git(['cat-file', type, object]);
61
+ const decoded = decodeObject(bytes);
62
+ if (decoded.reason) {
63
+ skipped.push({ object, reason: decoded.reason });
64
+ continue;
65
+ }
66
+ const text = decoded.text;
67
+ const detected = await sanitizer.inspect(text);
68
+ scanned++;
69
+ if (detected.length) {
70
+ findings.push({ object, type, findings: detected });
71
+ }
72
+ }
73
+ return { scanned, findings, skipped, complete: skipped.length === 0 };
74
+ }
75
+
76
+ function parseRecord(record) {
77
+ const [object, type, sizeText] = record.split(' ');
78
+ const size = Number(sizeText);
79
+ if (
80
+ !/^[a-f0-9]{40,64}$/.test(object) ||
81
+ !Number.isSafeInteger(size) ||
82
+ size < 0
83
+ ) {
84
+ throw failure('ERR_GIT');
85
+ }
86
+ return { object, type, size };
87
+ }
88
+
89
+ function decodeObject(bytes) {
90
+ try {
91
+ return { text: decodeText(bytes) };
92
+ } catch (error) {
93
+ return { reason: error.code };
94
+ }
95
+ }
96
+
97
+ function validateLimits(limits) {
98
+ for (const limit of limits) {
99
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
100
+ throw failure('ERR_CONFIG');
101
+ }
102
+ }
103
+ }