@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,430 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { fileURLToPath, URL } from 'node:url';
4
+ import { resolve } from 'node:path';
5
+ import { createSanitizer } from '../src/engines.js';
6
+ import { failure } from '../src/detection.js';
7
+ import {
8
+ readTextFile,
9
+ atomicWrite,
10
+ listTextCandidates,
11
+ decodeText,
12
+ } from '../src/files.js';
13
+ import { auditGitHistory } from '../src/history.js';
14
+ import { rewriteGitHistory } from '../src/history-rewrite.js';
15
+ import {
16
+ knownSecretsFromGitHubAuth,
17
+ knownSecretsFromEnv,
18
+ knownSecretsFromGitHubConfig,
19
+ } from '../src/known.js';
20
+ import { encodeLogText } from '../src/log-bytes.js';
21
+ import { createWikidataVerifier } from '../src/public-verifier.js';
22
+ import { streamCommand } from './stream.js';
23
+ import { sessionStructuralFields } from '../src/structured.js';
24
+ import {
25
+ createGitleaksDetector,
26
+ createTrufflehogDetector,
27
+ createPresidioDetector,
28
+ } from '../src/external.js';
29
+
30
+ const help = `Usage: sensitive-data-sanitizer <redact|scan|history> [path|-] [options]
31
+
32
+ redact Sanitize one text log or stdin; publish only after verification
33
+ scan Report metadata for a file, directory, or stdin (never source text)
34
+ history Audit local reachable Git blobs, commit/tag metadata, and reflogs
35
+ history rewrite SOURCE --output DIRECTORY [--apply]
36
+ Preview a fresh mirror clone; --apply rewrites only that clone
37
+
38
+ --config PATH JSON knownSecrets, knownPersonal, publicEntities, limits
39
+ --output PATH Write a new private output file atomically (redact only)
40
+ --in-place Atomically replace the input file (redact only)
41
+ --native-only Disable Secretlint; selected local detectors remain required
42
+ --credentials-only Preserve personal/account details; redact credentials only
43
+ --env-secrets Include values of all credential-named environment variables
44
+ --gh-config Include tokens from the local gh hosts.yml credential file
45
+ --paranoid Add entropy detection (can redact innocent identifiers)
46
+ --gitleaks Require locally installed Gitleaks in the detector union
47
+ --trufflehog Require local TruffleHog with verification/updates disabled
48
+ --presidio PATH Require a local Presidio JSON bridge (see examples)
49
+ --python COMMAND Python executable for the bridge (default python3)
50
+ --model NAME Installed model passed to the selected bridge
51
+ --language CODE Model language passed to the selected bridge
52
+ --stream Process text records; files use a bounded worker
53
+ --two-pass Collect identities first, then publish the complete stream
54
+ --json Sanitize JSON strings and keys; preserve session metadata
55
+ --jsonl Sanitize JSONL records; combine with --stream for sessions
56
+ --identity-mask First/last 2 identity mask (publication profile default)
57
+ --profile NAME Publication profile (redact default; config can override)
58
+ --max-record-bytes N Maximum record bytes (text: total limit; JSONL: 8388608)
59
+ --hive-mask Opt-in first/last 3 mask for values longer than 12 characters
60
+ --preserve-encoding Re-encode sanitized encoded payloads with round-trip checks
61
+ --gh-auth Read local gh auth token and mask its exact value
62
+ --verify-public Opt-in Wikidata name lookup (sends candidate public names)
63
+ --apply Apply history rewrite to the fresh clone, then audit it
64
+ --filter-repo PATH git-filter-repo executable for history rewrite
65
+ --max-bytes NUMBER Input byte limit (default 33554432; stream: 1073741824)
66
+ --help Show help
67
+ --version Show package version
68
+
69
+ Exit codes: 0 successful redact / clean scan; 1 findings; 2 error/incomplete.
70
+ Directory scans omit .git and node_modules. Binary files are reported as
71
+ skipped and produce exit 2. History operations never push.`;
72
+
73
+ function parseArgs(argv) {
74
+ const command = argv[0];
75
+ const rewrite = command === 'history' && argv[1] === 'rewrite';
76
+ const config = { command, rewrite, path: '-' };
77
+ const boolean = new Map([
78
+ ['--in-place', 'inPlace'],
79
+ ['--native-only', 'nativeOnly'],
80
+ ['--credentials-only', 'credentialsOnly'],
81
+ ['--env-secrets', 'envSecrets'],
82
+ ['--gh-config', 'ghConfig'],
83
+ ['--paranoid', 'paranoid'],
84
+ ['--gitleaks', 'gitleaks'],
85
+ ['--trufflehog', 'trufflehog'],
86
+ ['--stream', 'stream'],
87
+ ['--two-pass', 'twoPass'],
88
+ ['--json', 'json'],
89
+ ['--jsonl', 'jsonl'],
90
+ ['--identity-mask', 'identityMask'],
91
+ ['--hive-mask', 'hiveMask'],
92
+ ['--preserve-encoding', 'preserveEncoding'],
93
+ ['--gh-auth', 'ghAuth'],
94
+ ['--verify-public', 'verifyPublic'],
95
+ ['--apply', 'apply'],
96
+ ]);
97
+ const valued = new Map([
98
+ ['--config', 'config'],
99
+ ['--output', 'output'],
100
+ ['--max-bytes', 'maxBytes'],
101
+ ['--presidio', 'presidio'],
102
+ ['--python', 'python'],
103
+ ['--model', 'model'],
104
+ ['--language', 'language'],
105
+ ['--max-record-bytes', 'maxRecordBytes'],
106
+ ['--filter-repo', 'filterRepo'],
107
+ ['--profile', 'profile'],
108
+ ]);
109
+ let hasPath = false;
110
+ for (let i = rewrite ? 2 : 1; i < argv.length; i++) {
111
+ const arg = argv[i];
112
+ if (boolean.has(arg)) {
113
+ config[boolean.get(arg)] = true;
114
+ } else if (valued.has(arg)) {
115
+ if (!argv[i + 1] || argv[i + 1].startsWith('--')) {
116
+ throw failure('ERR_ARGUMENT');
117
+ }
118
+ config[valued.get(arg)] = argv[++i];
119
+ } else if (!hasPath && !arg.startsWith('--')) {
120
+ config.path = arg;
121
+ hasPath = true;
122
+ } else {
123
+ throw failure('ERR_ARGUMENT');
124
+ }
125
+ }
126
+ validateArgs(config);
127
+ return config;
128
+ }
129
+
130
+ function validateArgs(config) {
131
+ config.maxBytes = Number(config.maxBytes ?? defaultByteLimit(config));
132
+ if (
133
+ !['redact', 'scan', 'history'].includes(config.command) ||
134
+ !Number.isSafeInteger(config.maxBytes) ||
135
+ config.maxBytes <= 0
136
+ ) {
137
+ throw failure('ERR_ARGUMENT');
138
+ }
139
+ if (
140
+ (config.output && config.inPlace) ||
141
+ ((config.output || config.inPlace) &&
142
+ config.command !== 'redact' &&
143
+ !config.rewrite) ||
144
+ (config.inPlace && config.path === '-') ||
145
+ (config.command === 'history' && config.path === '-')
146
+ ) {
147
+ throw failure('ERR_ARGUMENT');
148
+ }
149
+ validateFeatureArgs(config);
150
+ validateOutput(config);
151
+ validateBridge(config);
152
+ }
153
+
154
+ function validateFeatureArgs(config) {
155
+ config.maxRecordBytes = Number(
156
+ config.maxRecordBytes ?? (config.jsonl ? 8388608 : config.maxBytes)
157
+ );
158
+ if (
159
+ !Number.isSafeInteger(config.maxRecordBytes) ||
160
+ config.maxRecordBytes <= 0 ||
161
+ (config.stream && config.command !== 'redact') ||
162
+ ((config.apply || config.filterRepo) && !config.rewrite) ||
163
+ (config.rewrite &&
164
+ (!config.output || config.inPlace || config.path === '-'))
165
+ ) {
166
+ throw failure('ERR_ARGUMENT');
167
+ }
168
+ validateStructuredArgs(config);
169
+ }
170
+
171
+ function validateStructuredArgs(config) {
172
+ if (config.twoPass && !config.stream) {
173
+ throw failure('ERR_ARGUMENT');
174
+ }
175
+ if ((config.json && config.jsonl) || (config.stream && config.json)) {
176
+ throw failure('ERR_ARGUMENT');
177
+ }
178
+ }
179
+
180
+ function validateBridge(config) {
181
+ if (!config.presidio && (config.python || config.model || config.language)) {
182
+ throw failure('ERR_ARGUMENT');
183
+ }
184
+ }
185
+
186
+ function validateOutput(config) {
187
+ if (
188
+ config.output &&
189
+ config.path !== '-' &&
190
+ resolve(config.output) === resolve(config.path)
191
+ ) {
192
+ throw failure('ERR_OUTPUT');
193
+ }
194
+ }
195
+
196
+ async function stdinText(stream, maxBytes, options) {
197
+ const chunks = [];
198
+ let size = 0;
199
+ for await (const chunk of stream) {
200
+ const buffer = Buffer.from(chunk);
201
+ size += buffer.length;
202
+ if (size > maxBytes) {
203
+ throw failure('ERR_LIMIT');
204
+ }
205
+ chunks.push(buffer);
206
+ }
207
+ return decodeText(Buffer.concat(chunks), options);
208
+ }
209
+
210
+ export async function runCli(
211
+ argv,
212
+ {
213
+ stdin = process.stdin,
214
+ stdout = process.stdout,
215
+ stderr = process.stderr,
216
+ } = {}
217
+ ) {
218
+ try {
219
+ if (!argv.length || ['--help', '-h'].includes(argv[0])) {
220
+ stdout.write(`${help}\n`);
221
+ return 0;
222
+ }
223
+ if (['--version', '-v'].includes(argv[0])) {
224
+ stdout.write(
225
+ `${JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version}\n`
226
+ );
227
+ return 0;
228
+ }
229
+ const config = parseArgs(argv);
230
+ const options = await loadOptions(config);
231
+ config.lossless = !options.structured;
232
+ const engine = createSanitizer(options);
233
+ const context = { stdin, stdout };
234
+ if (config.command === 'history') {
235
+ return await historyCommand(config, engine, context);
236
+ }
237
+ if (config.stream) {
238
+ return await streamCommand(config, engine, options, context);
239
+ }
240
+ if (config.command === 'redact') {
241
+ return await redactCommand(config, engine, context);
242
+ }
243
+ return await scanCommand(config, engine, context);
244
+ } catch (error) {
245
+ const location =
246
+ Number.isSafeInteger(error.record) && Number.isSafeInteger(error.offset)
247
+ ? `; record ${error.record}, byte ${error.offset}`
248
+ : '';
249
+ stderr.write(
250
+ `Sanitization failed; output was blocked (${/^ERR_[A-Z_]+$/.test(error.code) ? error.code : 'ERR_SANITIZATION'}${location}).\n`
251
+ );
252
+ return 2;
253
+ }
254
+ }
255
+
256
+ async function loadOptions(config) {
257
+ let options = {};
258
+ if (config.config) {
259
+ try {
260
+ options = JSON.parse(await readTextFile(config.config, 1024 * 1024));
261
+ } catch {
262
+ throw failure('ERR_CONFIG');
263
+ }
264
+ }
265
+ if (!options || typeof options !== 'object' || Array.isArray(options)) {
266
+ throw failure('ERR_CONFIG');
267
+ }
268
+ options.profile =
269
+ config.profile ??
270
+ options.profile ??
271
+ (config.command === 'redact' ? 'publication' : undefined);
272
+ if (config.nativeOnly) {
273
+ options.secretlint = false;
274
+ }
275
+ if (config.paranoid) {
276
+ options.paranoid = true;
277
+ }
278
+ await featureOptions(config, options);
279
+ options.detectors = selectedDetectors(config, options.detectors);
280
+ options.maxInputLength = Math.min(
281
+ options.maxInputLength ?? config.maxBytes,
282
+ config.maxBytes
283
+ );
284
+ return options;
285
+ }
286
+
287
+ async function featureOptions(config, options) {
288
+ await localCredentials(config, options);
289
+ if (config.credentialsOnly) {
290
+ options.credentialsOnly = true;
291
+ }
292
+ if (config.json || config.jsonl) {
293
+ options.structured = config.jsonl ? 'jsonl' : 'json';
294
+ options.structuralFields ??= sessionStructuralFields;
295
+ }
296
+ if (config.identityMask) {
297
+ options.identityMask = true;
298
+ }
299
+ if (config.hiveMask) {
300
+ options.transformation = { mode: 'hive-mask' };
301
+ }
302
+ if (config.preserveEncoding) {
303
+ options.preserveEncoding = true;
304
+ }
305
+ if (config.verifyPublic) {
306
+ options.verifyPublic = createWikidataVerifier();
307
+ }
308
+ }
309
+
310
+ async function localCredentials(config, options) {
311
+ for (const [flag, load] of [
312
+ ['envSecrets', knownSecretsFromEnv],
313
+ ['ghConfig', knownSecretsFromGitHubConfig],
314
+ ['ghAuth', knownSecretsFromGitHubAuth],
315
+ ]) {
316
+ if (config[flag]) {
317
+ options.knownSecrets = [
318
+ ...(options.knownSecrets ?? []),
319
+ ...(await load()),
320
+ ];
321
+ }
322
+ }
323
+ }
324
+
325
+ function selectedDetectors(config, detectors = []) {
326
+ const selected = [...detectors];
327
+ for (const [flag, factory] of [
328
+ ['gitleaks', createGitleaksDetector],
329
+ ['trufflehog', createTrufflehogDetector],
330
+ ]) {
331
+ if (config[flag]) {
332
+ selected.push(factory());
333
+ }
334
+ }
335
+ if (config.presidio) {
336
+ const args = [resolve(config.presidio)];
337
+ for (const name of ['model', 'language']) {
338
+ if (config[name]) {
339
+ args.push(`--${name}`, config[name]);
340
+ }
341
+ }
342
+ selected.push(
343
+ createPresidioDetector({
344
+ command: config.python ?? 'python3',
345
+ args,
346
+ })
347
+ );
348
+ }
349
+ return selected;
350
+ }
351
+
352
+ function inputText(path, config, stdin) {
353
+ const options = { lossless: config.lossless };
354
+ return path === '-'
355
+ ? stdinText(stdin, config.maxBytes, options)
356
+ : readTextFile(path, config.maxBytes, options);
357
+ }
358
+
359
+ async function redactCommand(config, engine, { stdin, stdout }) {
360
+ const result = await engine.sanitize(
361
+ await inputText(config.path, config, stdin)
362
+ );
363
+ if (config.output || config.inPlace) {
364
+ await atomicWrite(
365
+ config.output ?? config.path,
366
+ encodeLogText(result.text),
367
+ {
368
+ replace: Boolean(config.inPlace),
369
+ }
370
+ );
371
+ } else {
372
+ stdout.write(encodeLogText(result.text));
373
+ }
374
+ return 0;
375
+ }
376
+
377
+ async function historyCommand(config, engine, { stdout }) {
378
+ if (config.rewrite) {
379
+ const result = await rewriteGitHistory(config.path, config.output, {
380
+ sanitizer: engine,
381
+ apply: Boolean(config.apply),
382
+ filterRepoCommand: config.filterRepo ?? 'git-filter-repo',
383
+ maxBytes: config.maxBytes,
384
+ });
385
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
386
+ return 0;
387
+ }
388
+ const result = await auditGitHistory(config.path, {
389
+ sanitizer: engine,
390
+ maxBytes: config.maxBytes,
391
+ });
392
+ stdout.write(`${JSON.stringify(result, null, 2)}\n`);
393
+ return !result.complete ? 2 : result.findings.length ? 1 : 0;
394
+ }
395
+
396
+ async function scanCommand(config, engine, { stdin, stdout }) {
397
+ const files =
398
+ config.path === '-' ? ['-'] : await listTextCandidates(config.path);
399
+ const results = [],
400
+ skipped = [];
401
+ for (const file of files) {
402
+ const path = (await engine.sanitize(file)).text;
403
+ try {
404
+ results.push({
405
+ path,
406
+ findings: await engine.inspect(await inputText(file, config, stdin)),
407
+ });
408
+ } catch (error) {
409
+ if (['ERR_BINARY', 'ERR_ENCODING', 'ERR_LIMIT'].includes(error.code)) {
410
+ skipped.push({ path, reason: error.code });
411
+ } else {
412
+ throw error;
413
+ }
414
+ }
415
+ }
416
+ const complete = skipped.length === 0;
417
+ stdout.write(`${JSON.stringify({ complete, results, skipped }, null, 2)}\n`);
418
+ return !complete ? 2 : results.some((r) => r.findings.length) ? 1 : 0;
419
+ }
420
+
421
+ if (
422
+ process.argv[1] &&
423
+ realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
424
+ ) {
425
+ process.exitCode = await runCli(process.argv.slice(2));
426
+ }
427
+
428
+ function defaultByteLimit(config) {
429
+ return config.stream ? 1073741824 : 33554432;
430
+ }
package/bin/stream.js ADDED
@@ -0,0 +1,56 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises';
2
+ import { createReadStream } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { once } from 'node:events';
6
+ import { sanitizeFileBounded, sanitizeStreamToFile } from '../src/stream.js';
7
+
8
+ export async function streamCommand(
9
+ config,
10
+ engine,
11
+ options,
12
+ { stdin, stdout }
13
+ ) {
14
+ let spool;
15
+ try {
16
+ const destination =
17
+ config.output ?? (config.inPlace ? config.path : undefined);
18
+ if (!destination) {
19
+ spool = await mkdtemp(join(tmpdir(), 'sanitizer-cli-'));
20
+ }
21
+ const target = destination ?? join(spool, 'output');
22
+ const limits = {
23
+ maxRecordBytes: config.maxRecordBytes,
24
+ maxTotalBytes: config.maxBytes,
25
+ replace: Boolean(config.inPlace),
26
+ twoPass: Boolean(config.twoPass),
27
+ };
28
+ if (config.path === '-') {
29
+ await sanitizeStreamToFile(stdin, target, {
30
+ ...limits,
31
+ sanitizer: engine,
32
+ sanitizerOptions: options,
33
+ });
34
+ } else {
35
+ const serializable = !options.detectors?.length && !options.verifyPublic;
36
+ await sanitizeFileBounded(config.path, target, {
37
+ ...limits,
38
+ ...(serializable
39
+ ? { sanitizerOptions: options }
40
+ : { sanitizer: engine, sanitizerOptions: options }),
41
+ });
42
+ }
43
+ if (spool) {
44
+ for await (const chunk of createReadStream(target)) {
45
+ if (stdout.write(chunk) === false) {
46
+ await once(stdout, 'drain');
47
+ }
48
+ }
49
+ }
50
+ return 0;
51
+ } finally {
52
+ if (spool) {
53
+ await rm(spool, { recursive: true, force: true });
54
+ }
55
+ }
56
+ }