@link-foundation/sensitive-data-sanitizer 0.21.1 → 0.21.3
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 +12 -0
- package/docs/API.md +6 -1
- package/package.json +1 -1
- package/src/credential-spans.js +13 -1
- package/src/credentials.js +4 -1
- package/src/encoded.js +57 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.21.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ee1ec36: Preserve harmless wrapped base64 binary within a bounded complete inspection, while retaining embedded credential detection and fail-closed handling of uninspected oversized payloads. Add regression coverage for complete JSON re-encoding after surrounding prose through sync, async and streaming sanitization.
|
|
8
|
+
|
|
9
|
+
## 0.21.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- b5bc2e1: Preserve prose after an existing credential mask on repeated plaintext and encoded sanitization passes, while continuing to detect later credentials and exact known secrets.
|
|
14
|
+
|
|
3
15
|
## 0.21.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/docs/API.md
CHANGED
|
@@ -365,7 +365,12 @@ status identifiers. Explicit `knownPersonal` values override these heuristics.
|
|
|
365
365
|
|
|
366
366
|
Oversized encoded runs receive a conservative complete-run `ENCODED_LIMIT`
|
|
367
367
|
redaction rather than aborting the record. Decoding probes are capped at 4,096
|
|
368
|
-
characters;
|
|
368
|
+
characters; ordinary text decoding stays capped at 8,192 characters. Uniform
|
|
369
|
+
wrapped binary base64 up to 256 KiB of encoded input is decoded completely
|
|
370
|
+
and its embedded text is inspected. Harmless binary bytes are preserved; a
|
|
371
|
+
sensitive binary payload is masked as a whole because a text re-encoding
|
|
372
|
+
cannot preserve its bytes. Larger uninspected wrapped binary runs still
|
|
373
|
+
receive `ENCODED_LIMIT`. A multi-line run is
|
|
369
374
|
treated as one wrapped payload only when its probe decodes as text or its lines
|
|
370
375
|
share one base64 width (a multiple of four, standard alphabet, not plain hex);
|
|
371
376
|
lists of identifiers or commit hashes are scanned line by line. Up to 4,096
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-foundation/sensitive-data-sanitizer",
|
|
3
|
-
"version": "0.21.
|
|
3
|
+
"version": "0.21.3",
|
|
4
4
|
"description": "Offline sensitive-text sanitization with credential, multilingual personal-data and scanner union detection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/credential-spans.js
CHANGED
|
@@ -107,13 +107,25 @@ function narrowed(text, finding) {
|
|
|
107
107
|
return parts ? parts.map((part) => ({ ...finding, ...part })) : [finding];
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// A complete existing mask bounds context too: vendor rules no longer match
|
|
111
|
+
// it on the next pass. Only strip sentence punctuation, never a glued suffix.
|
|
112
|
+
function maskedPrefix(text, start, end) {
|
|
113
|
+
const token = /^[^\s]+/u.exec(text.slice(start, end))?.[0] ?? '';
|
|
114
|
+
let length = token.length;
|
|
115
|
+
while (length > 0 && /[.,;:!?]/.test(token[length - 1])) {
|
|
116
|
+
length--;
|
|
117
|
+
}
|
|
118
|
+
const value = token.slice(0, length);
|
|
119
|
+
return maskedValue(value) ? start + value.length : end;
|
|
120
|
+
}
|
|
121
|
+
|
|
110
122
|
// `token: ghp_… . Next sentence` — a context value that a vendor rule ends
|
|
111
123
|
// earlier stops at that end when only punctuation or a space follows.
|
|
112
124
|
function bounded(text, finding, findings) {
|
|
113
125
|
if (!openEnded.has(finding.rule) || finding.category !== 'credential') {
|
|
114
126
|
return finding;
|
|
115
127
|
}
|
|
116
|
-
let end = finding.end;
|
|
128
|
+
let end = maskedPrefix(text, finding.start, finding.end);
|
|
117
129
|
for (const other of findings) {
|
|
118
130
|
if (
|
|
119
131
|
other.category === 'credential' &&
|
package/src/credentials.js
CHANGED
|
@@ -56,7 +56,10 @@ export function templatedCredential(text, finding) {
|
|
|
56
56
|
}
|
|
57
57
|
// A template glued to literal characters (`<x>abc`, `[A]x9`) is not one.
|
|
58
58
|
const after = text[finding.start + value.length] ?? '';
|
|
59
|
-
|
|
59
|
+
const sentenceEnd = /^[.,;:!?]+(?:\s|$)/.test(
|
|
60
|
+
text.slice(finding.start + value.length)
|
|
61
|
+
);
|
|
62
|
+
return (!/[\w$%+/=.-]/.test(after) || sentenceEnd) && templatedValue(value);
|
|
60
63
|
}
|
|
61
64
|
// Only variable references and the documented gh lookup are exempt. Arbitrary
|
|
62
65
|
// shell commands and Actions expressions can contain literal credentials.
|
package/src/encoded.js
CHANGED
|
@@ -7,6 +7,9 @@ import { jsonStringRuns } from './json-string-runs.js';
|
|
|
7
7
|
// remaining candidates as `encoded-limit` runs, without blocking the text.
|
|
8
8
|
const WINDOW = 65536;
|
|
9
9
|
const WINDOW_CANDIDATES = 4096;
|
|
10
|
+
// Inspect a complete bounded binary payload, including any embedded text.
|
|
11
|
+
// Ordinary text detection retains its 8 KiB cap.
|
|
12
|
+
const WRAPPED_BINARY_LIMIT = 256 * 1024;
|
|
10
13
|
|
|
11
14
|
const WRAPPED =
|
|
12
15
|
/(?<![A-Za-z0-9_+/-])[A-Za-z0-9_+/-]{16,128}(?:\r?\n[A-Za-z0-9_+/-]{1,128})+={0,2}(?![A-Za-z0-9_+/-])/g;
|
|
@@ -56,7 +59,8 @@ function* matchRuns(match, wrapped, scan) {
|
|
|
56
59
|
const start = match.index;
|
|
57
60
|
const end = start + raw.length;
|
|
58
61
|
const limited = { start, end, limited: true, depth: scan.depth };
|
|
59
|
-
|
|
62
|
+
const candidates = runCandidates(raw, wrapped);
|
|
63
|
+
if (!candidates) {
|
|
60
64
|
if (isLimitedTextRun(raw, wrapped)) {
|
|
61
65
|
scan.covered.push(limited);
|
|
62
66
|
yield limited;
|
|
@@ -64,7 +68,7 @@ function* matchRuns(match, wrapped, scan) {
|
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
70
|
const window = Math.floor(start / WINDOW);
|
|
67
|
-
for (const candidate of
|
|
71
|
+
for (const candidate of candidates) {
|
|
68
72
|
const { text: decoded, encoding } = candidate;
|
|
69
73
|
const key = `${start}:${raw.length}:${decoded}`;
|
|
70
74
|
if (scan.seen.has(key) || !decodedText(raw, decoded)) {
|
|
@@ -91,11 +95,48 @@ function* matchRuns(match, wrapped, scan) {
|
|
|
91
95
|
};
|
|
92
96
|
yield run;
|
|
93
97
|
for (const inner of decodedRuns(decoded, scan.depth + 1)) {
|
|
94
|
-
yield
|
|
98
|
+
yield inner.limited
|
|
99
|
+
? { ...limited, depth: inner.depth }
|
|
100
|
+
: { ...run, text: inner.text, depth: inner.depth };
|
|
95
101
|
}
|
|
96
102
|
}
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
function runCandidates(raw, wrapped) {
|
|
106
|
+
if (raw.length <= 8192) {
|
|
107
|
+
return decodeCandidates(raw);
|
|
108
|
+
}
|
|
109
|
+
const binary = wrapped && boundedWrappedBinary(raw);
|
|
110
|
+
return binary ? [binary] : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function boundedWrappedBinary(raw) {
|
|
114
|
+
if (raw.length > WRAPPED_BINARY_LIMIT || !isWrappedBinary(raw)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const bytes = base64Bytes(raw.replace(/\s/g, ''));
|
|
118
|
+
if (!bytes) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
123
|
+
if (decodedText(raw, decoded)) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
/* Binary content still needs an embedded-text detection view. */
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
encoding: 'binary',
|
|
131
|
+
// Preserve text offsets within the view while separating binary controls
|
|
132
|
+
// and invalid UTF-8. This view is never used to reconstruct binary bytes.
|
|
133
|
+
text: bytes
|
|
134
|
+
.toString('utf8')
|
|
135
|
+
// eslint-disable-next-line no-control-regex -- Replace binary controls in the text detection view.
|
|
136
|
+
.replace(/[\x00-\x08\x0e-\x1f\x7f\ufffd]/g, ' '),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
99
140
|
function candidateMatches(text, pattern) {
|
|
100
141
|
return pattern ? text.matchAll(pattern) : jsonStringRuns(text);
|
|
101
142
|
}
|
|
@@ -211,11 +252,8 @@ function decodeCandidates(raw, partial) {
|
|
|
211
252
|
} else {
|
|
212
253
|
const compact = raw.replace(/\s/g, '');
|
|
213
254
|
// Decode canonical base64 text only.
|
|
214
|
-
const bytes =
|
|
215
|
-
if (
|
|
216
|
-
bytes.toString('base64url') ===
|
|
217
|
-
compact.replace(/=+$/, '').replaceAll('+', '-').replaceAll('/', '_')
|
|
218
|
-
) {
|
|
255
|
+
const bytes = base64Bytes(compact);
|
|
256
|
+
if (bytes) {
|
|
219
257
|
try {
|
|
220
258
|
candidates.push({
|
|
221
259
|
encoding:
|
|
@@ -248,7 +286,18 @@ function decodeCandidates(raw, partial) {
|
|
|
248
286
|
return candidates;
|
|
249
287
|
}
|
|
250
288
|
|
|
289
|
+
function base64Bytes(compact) {
|
|
290
|
+
const bytes = Buffer.from(compact, 'base64url');
|
|
291
|
+
return bytes.toString('base64url') ===
|
|
292
|
+
compact.replace(/=+$/, '').replaceAll('+', '-').replaceAll('/', '_')
|
|
293
|
+
? bytes
|
|
294
|
+
: undefined;
|
|
295
|
+
}
|
|
296
|
+
|
|
251
297
|
export function encodeRun(run, text) {
|
|
298
|
+
if (run.encoding === 'binary') {
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
252
301
|
const bytes = Buffer.from(text, 'utf8');
|
|
253
302
|
let encoded;
|
|
254
303
|
if (run.encoding === 'json-content') {
|