@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.
- package/CHANGELOG.md +530 -0
- package/LICENSE +24 -0
- package/README.md +184 -2
- package/bin/legacy-example-package-name.js +95 -0
- package/bin/sensitive-data-sanitizer.js +430 -0
- package/bin/stream.js +56 -0
- package/docs/API.md +336 -0
- package/docs/COVERAGE.md +136 -0
- package/docs/HISTORY-REMEDIATION.md +41 -0
- package/examples/combined-scanners.mjs +23 -0
- package/examples/presidio-bridge.py +32 -0
- package/examples/realistic-fakes.mjs +18 -0
- package/examples/sanitize-session.mjs +27 -0
- package/examples/shared-tool-logs.mjs +21 -0
- package/examples/stream-jsonl.mjs +17 -0
- package/package.json +124 -4
- package/src/adapters.js +204 -0
- package/src/arithmetic.js +11 -0
- package/src/confidence.js +65 -0
- package/src/context.js +424 -0
- package/src/credentials.js +54 -0
- package/src/detection.js +293 -0
- package/src/encoded.js +233 -0
- package/src/engines.js +348 -0
- package/src/entities.js +166 -0
- package/src/escape-spans.js +20 -0
- package/src/eslint-plugin.d.ts +10 -0
- package/src/eslint-plugin.js +193 -0
- package/src/external.js +169 -0
- package/src/fake-names.js +222 -0
- package/src/fake.js +524 -0
- package/src/files.js +131 -0
- package/src/gitleaks.js +194 -0
- package/src/history-rewrite.js +339 -0
- package/src/history.js +103 -0
- package/src/identity.js +274 -0
- package/src/index.d.ts +321 -0
- package/src/index.js +40 -0
- package/src/jsonl-stream.js +198 -0
- package/src/keywords.js +45 -0
- package/src/known.js +164 -0
- package/src/legacy.d.ts +3 -0
- package/src/legacy.js +2 -0
- package/src/locales.js +217 -0
- package/src/log-bytes.js +85 -0
- package/src/names.js +65 -0
- package/src/national-checks.js +129 -0
- package/src/national.js +128 -0
- package/src/outbound.js +28 -0
- package/src/personal-context.js +85 -0
- package/src/personal.js +248 -0
- package/src/projection.js +126 -0
- package/src/propagation.js +163 -0
- package/src/public-verifier.js +77 -0
- package/src/public.js +153 -0
- package/src/record-worker.js +20 -0
- package/src/rules.js +210 -0
- package/src/sanitizer.js +794 -0
- package/src/specimens.js +34 -0
- package/src/stream-identity.js +129 -0
- package/src/stream-worker.js +18 -0
- package/src/stream.js +449 -0
- package/src/structured.js +278 -0
- package/src/transforms.js +192 -0
- package/src/transliteration.js +75 -0
- package/src/utf8.js +60 -0
- package/src/vendor/entities/aws.json +42 -0
- package/src/vendor/entities/azure.json +182 -0
- package/src/vendor/entities/google.json +219 -0
- package/src/vendor/gitleaks/LICENSE +21 -0
- package/src/vendor/gitleaks/provenance.json +8 -0
- package/src/vendor/gitleaks/rules-0.json +268 -0
- package/src/vendor/gitleaks/rules-1.json +1794 -0
- package/src/vendor/gitleaks/rules-2.json +292 -0
- package/src/vendor/gitleaks/rules-3.json +263 -0
- package/src/vendor/gitleaks/rules-4.json +277 -0
- package/src/vendor/gitleaks/rules-5.json +154 -0
- package/src/worker-options.js +27 -0
package/src/gitleaks.js
ADDED
|
@@ -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
|
+
}
|