@gnldev/processors 0.1.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/LICENSE +201 -0
- package/README.md +121 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/moderation.d.ts +30 -0
- package/dist/moderation.js +72 -0
- package/dist/moderation.js.map +1 -0
- package/dist/pii.d.ts +86 -0
- package/dist/pii.js +189 -0
- package/dist/pii.js.map +1 -0
- package/dist/redact.d.ts +102 -0
- package/dist/redact.js +222 -0
- package/dist/redact.js.map +1 -0
- package/dist/safety.d.ts +37 -0
- package/dist/safety.js +133 -0
- package/dist/safety.js.map +1 -0
- package/dist/token-limiter.d.ts +34 -0
- package/dist/token-limiter.js +88 -0
- package/dist/token-limiter.js.map +1 -0
- package/dist/tool-filter.d.ts +15 -0
- package/dist/tool-filter.js +26 -0
- package/dist/tool-filter.js.map +1 -0
- package/dist/tool-search.d.ts +17 -0
- package/dist/tool-search.js +68 -0
- package/dist/tool-search.js.map +1 -0
- package/package.json +58 -0
package/dist/pii.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { recordProcessorReport } from '@gnldev/durable';
|
|
2
|
+
import { redactString, redactMessages, applyRedactions, PII_PATTERNS, } from './redact.js';
|
|
3
|
+
const DEFAULT_TYPES = ['email', 'phone', 'creditCard', 'ssn', 'ip', 'iban'];
|
|
4
|
+
/**
|
|
5
|
+
* Rejects a configuration that would mask less than the caller thinks it does.
|
|
6
|
+
*
|
|
7
|
+
* A name with no built-in behind it used to be dropped in silence — `types: ['iban']` returned the
|
|
8
|
+
* text untouched and reported nothing, so a deployment could believe a type was covered while the
|
|
9
|
+
* value went through in the clear. TypeScript catches it only for TS callers passing a literal;
|
|
10
|
+
* Config read from JS, JSON or an env var reaches here unchecked. Both entry points run this at
|
|
11
|
+
* CONSTRUCTION — config time, not per-run — so a mistake surfaces at startup instead of inside a
|
|
12
|
+
* run that has already touched the data.
|
|
13
|
+
*/
|
|
14
|
+
function assertUsablePatterns(types, extra) {
|
|
15
|
+
const unknown = types.filter((t) => !(t in PII_PATTERNS));
|
|
16
|
+
if (unknown.length) {
|
|
17
|
+
throw new Error(`piiRedactor: unknown PII type(s) ${unknown.map((u) => JSON.stringify(u)).join(', ')} — these would mask nothing. ` +
|
|
18
|
+
`Built-in types are ${Object.keys(PII_PATTERNS).join(', ')}; anything else goes in extraPatterns.`);
|
|
19
|
+
}
|
|
20
|
+
for (const p of extra) {
|
|
21
|
+
if (!p?.name || !(p.pattern instanceof RegExp)) {
|
|
22
|
+
throw new Error(`piiRedactor: extraPatterns entries need a non-empty \`name\` and a RegExp \`pattern\` (got ${JSON.stringify(p)}).`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The same masking as `piiRedactor`, as a plain string function, for the text that never passes
|
|
28
|
+
* through a processor at all.
|
|
29
|
+
*
|
|
30
|
+
* A `Processor` only sees `processInput`/`processOutput`/`processToolResult`. A run's failure
|
|
31
|
+
* message is written from `recordRunOutcome` in @gnldev/durable, which no processor is consulted
|
|
32
|
+
* about, and it is not always the host's own text: a provider that refuses a request commonly
|
|
33
|
+
* echoes the offending input back inside the message. Anything that ships that message onward —
|
|
34
|
+
* `@gnldev/otel`'s span attributes are the case this was added for — needs the same mask, and had
|
|
35
|
+
* no way to reuse it because `DEFAULT_TYPES` is private to this module.
|
|
36
|
+
*
|
|
37
|
+
* Shares that constant and the default mask with `piiRedactor` deliberately: two copies of the
|
|
38
|
+
* defaults is exactly how the redacted path and the un-redacted one drift apart.
|
|
39
|
+
*
|
|
40
|
+
* import { piiTextRedactor } from '@gnldev/processors';
|
|
41
|
+
* await exportRun(journal, runId, { endpoint, redact: piiTextRedactor() });
|
|
42
|
+
*/
|
|
43
|
+
export function piiTextRedactor(opts = {}) {
|
|
44
|
+
const types = opts.types ?? DEFAULT_TYPES;
|
|
45
|
+
const mask = opts.mask ?? ((t) => `[REDACTED_${t.toUpperCase()}]`);
|
|
46
|
+
const extra = opts.extraPatterns ?? [];
|
|
47
|
+
const validate = opts.validate !== false;
|
|
48
|
+
assertUsablePatterns(types, extra);
|
|
49
|
+
return (text) => (typeof text === 'string' ? redactString(text, types, mask, extra, validate) : text);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* For the audit report (`recordProcessorReport`): the counts come off the SAME call that performs the
|
|
53
|
+
* redaction (`applyRedactions`), so the report cannot describe a masking that did not run. It used to
|
|
54
|
+
* be a second, independent walk over the same text with the same order copied by hand — which is
|
|
55
|
+
* exactly how the two drift once a validator or a new pattern lands in only one of them.
|
|
56
|
+
*/
|
|
57
|
+
function countRedactions(text, types, mask, extra = [], validate = true) {
|
|
58
|
+
const { total, types: hitTypes } = applyRedactions(text, types, mask, { extra, validate });
|
|
59
|
+
return { total, types: hitTypes };
|
|
60
|
+
}
|
|
61
|
+
/** Aggregates redactions across ALL text fields of a ProcessorInput/ProcessorOutput (system/prompt/messages
|
|
62
|
+
* are counted separately — same granularity as redactMessages, which redacts each message/part INDEPENDENTLY). */
|
|
63
|
+
function countInputRedactions(fields, types, mask, extra = [], validate = true) {
|
|
64
|
+
let total = 0;
|
|
65
|
+
const typeSet = new Set();
|
|
66
|
+
const add = (r) => { total += r.total; for (const t of r.types)
|
|
67
|
+
typeSet.add(t); };
|
|
68
|
+
if (typeof fields.system === 'string')
|
|
69
|
+
add(countRedactions(fields.system, types, mask, extra, validate));
|
|
70
|
+
if (typeof fields.prompt === 'string')
|
|
71
|
+
add(countRedactions(fields.prompt, types, mask, extra, validate));
|
|
72
|
+
for (const m of fields.messages ?? []) {
|
|
73
|
+
if (typeof m?.content === 'string')
|
|
74
|
+
add(countRedactions(m.content, types, mask, extra, validate));
|
|
75
|
+
else if (Array.isArray(m?.content)) {
|
|
76
|
+
for (const part of m.content)
|
|
77
|
+
if (typeof part?.text === 'string')
|
|
78
|
+
add(countRedactions(part.text, types, mask, extra, validate));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { total, types: [...typeSet] };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* PII redaction processor — pure regex (email/phone/credit-card/ssn/ip). Deterministic, so no
|
|
85
|
+
* journaling is needed: produces the same masking on resume. The input side runs BEFORE persistInput
|
|
86
|
+
* → masked content is written to the journal, the model NEVER sees raw PII.
|
|
87
|
+
*
|
|
88
|
+
* HONEST WARNING (naive regex matching): These regexes are best-effort, they do NOT provide an
|
|
89
|
+
* AUDIT/COMPLIANCE-grade (GDPR/HIPAA/PCI-DSS etc.) PII DETECTION GUARANTEE. Known limits: only
|
|
90
|
+
* matches specific formats (e.g. US/generic-format phone numbers, plain 16-digit card numbers) —
|
|
91
|
+
* international/local formats, unstructured PII like name/address, or unusual formatting (line
|
|
92
|
+
* breaks, different separators) can slip through; it can also produce false positives (e.g. a
|
|
93
|
+
* random 16-digit number). Use it as a noise-reduction / first-line-of-defense layer, not as a real
|
|
94
|
+
* compliance/security boundary.
|
|
95
|
+
*
|
|
96
|
+
* SCOPE, measured — WHAT THE OUTPUT SIDE DOES NOT COVER: `processOutput` transforms the
|
|
97
|
+
* `{text, messages}` view, so `result.text` and `result.response.messages` come back masked on both
|
|
98
|
+
* `runDurable` and `streamDurable`, and so does persisted thread memory. `result.steps` and
|
|
99
|
+
* `result.content` DO NOT — they still hold the model's raw output, and a caller reading either one
|
|
100
|
+
* sees unredacted PII. This is not fixable inside the hook: a processor's output arity is
|
|
101
|
+
* unconstrained (a summariser legally returns one message for a turn that produced three), so no
|
|
102
|
+
* mapping back onto per-step records exists, and `content` is a parts array rather than messages.
|
|
103
|
+
* Read `text`/`response.messages`; treat `steps`/`content` as raw. On the STREAM path the
|
|
104
|
+
* `textStream`/`fullStream` deltas are raw as well — they reach the client before the turn ends, so
|
|
105
|
+
* use `on: 'input'` (or `runDurable`) if the wire itself must never carry it.
|
|
106
|
+
*/
|
|
107
|
+
export function piiRedactor(opts = {}) {
|
|
108
|
+
const types = opts.types ?? DEFAULT_TYPES;
|
|
109
|
+
const mask = opts.mask ?? ((t) => `[REDACTED_${t.toUpperCase()}]`);
|
|
110
|
+
const extra = opts.extraPatterns ?? [];
|
|
111
|
+
const validate = opts.validate !== false;
|
|
112
|
+
assertUsablePatterns(types, extra);
|
|
113
|
+
const on = opts.on ?? 'both';
|
|
114
|
+
const proc = { name: 'pii-redactor' };
|
|
115
|
+
if (on === 'input' || on === 'both') {
|
|
116
|
+
// NOT async (behavior must stay the same — callers may call processInput synchronously and
|
|
117
|
+
// read the returned object directly). The audit report (recordProcessorReport) is BEST-EFFORT +
|
|
118
|
+
// fire-and-forget: it does NOT CHANGE the transform/synchronous-return contract, it's only an
|
|
119
|
+
// EXTRA record.
|
|
120
|
+
proc.processInput = (input, ctx) => {
|
|
121
|
+
const out = {
|
|
122
|
+
system: typeof input.system === 'string' ? redactString(input.system, types, mask, extra, validate) : input.system,
|
|
123
|
+
prompt: typeof input.prompt === 'string' ? redactString(input.prompt, types, mask, extra, validate) : input.prompt,
|
|
124
|
+
messages: input.messages ? redactMessages(input.messages, types, mask, extra, validate) : input.messages,
|
|
125
|
+
};
|
|
126
|
+
const { total, types: hitTypes } = countInputRedactions(input, types, mask, extra, validate);
|
|
127
|
+
if (total > 0)
|
|
128
|
+
void recordProcessorReport(ctx, 'pii-redactor', 'input', { redactedCount: total, types: hitTypes });
|
|
129
|
+
return out;
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (on === 'output' || on === 'both') {
|
|
133
|
+
proc.processOutput = (output, ctx) => {
|
|
134
|
+
const out = {
|
|
135
|
+
...output,
|
|
136
|
+
text: typeof output.text === 'string' ? redactString(output.text, types, mask, extra, validate) : output.text,
|
|
137
|
+
messages: output.messages ? redactMessages(output.messages, types, mask, extra, validate) : output.messages,
|
|
138
|
+
};
|
|
139
|
+
const { total, types: hitTypes } = countInputRedactions({ prompt: output.text, messages: output.messages }, types, mask, extra, validate);
|
|
140
|
+
if (total > 0)
|
|
141
|
+
void recordProcessorReport(ctx, 'pii-redactor', 'output', { redactedCount: total, types: hitTypes });
|
|
142
|
+
return out;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
// AUDIT TASK: opt-in hook so tool output doesn't write plain PII to the journal. String output is
|
|
146
|
+
// redacted directly; object output is redacted via the JSON.stringify → redact → JSON.parse chain —
|
|
147
|
+
// if stringify/parse fails (circular structure or JSON broken by redaction), the original output is
|
|
148
|
+
// NOT TOUCHED (same "deterministic, pure transform only" principle as processInput/processOutput;
|
|
149
|
+
// the raw value still gets written to the journal, but at least the framework doesn't silently
|
|
150
|
+
// corrupt data).
|
|
151
|
+
if (opts.redactToolResults) {
|
|
152
|
+
// durable-tool.ts ALWAYS awaits THIS HOOK (`await proc.processToolResult(...)`) →
|
|
153
|
+
// making it async doesn't break the existing contract (existing tests already await it too).
|
|
154
|
+
proc.processToolResult = async (res, ctx) => {
|
|
155
|
+
const { output } = res;
|
|
156
|
+
// `string[]`, not `PiiType[]`: a custom pattern's name belongs in the audit report too, or the
|
|
157
|
+
// report would say fewer types were hit than the redaction actually masked.
|
|
158
|
+
const report = (r) => {
|
|
159
|
+
if (r.total > 0)
|
|
160
|
+
void recordProcessorReport(ctx, 'pii-redactor', 'tool', { redactedCount: r.total, types: r.types });
|
|
161
|
+
};
|
|
162
|
+
if (typeof output === 'string') {
|
|
163
|
+
report(countRedactions(output, types, mask, extra, validate));
|
|
164
|
+
return { output: redactString(output, types, mask, extra, validate) };
|
|
165
|
+
}
|
|
166
|
+
if (output !== null && typeof output === 'object') {
|
|
167
|
+
let json;
|
|
168
|
+
try {
|
|
169
|
+
json = JSON.stringify(output);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return { output }; // circular structure etc. → cannot serialize, NOT TOUCHED
|
|
173
|
+
}
|
|
174
|
+
const redacted = redactString(json, types, mask, extra, validate);
|
|
175
|
+
try {
|
|
176
|
+
const parsed = JSON.parse(redacted);
|
|
177
|
+
report(countRedactions(json, types, mask, extra, validate));
|
|
178
|
+
return { output: parsed };
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return { output }; // redaction broke the JSON → original returned UNTOUCHED
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { output }; // number/boolean/null/undefined → cannot contain PII
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return proc;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=pii.js.map
|
package/dist/pii.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pii.js","sourceRoot":"","sources":["../src/pii.ts"],"names":[],"mappings":"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","sourcesContent":["import { recordProcessorReport } from '@gnldev/durable';\nimport type { Processor, ProcessorCtx, ProcessorInput, ProcessorOutput, ProcessorToolResult } from '@gnldev/durable';\nimport {\n redactString, redactMessages, applyRedactions, PII_PATTERNS,\n type PiiType, type PiiPattern,\n} from './redact.js';\n\nexport interface PiiRedactorOptions {\n /** Which PII types to mask (default: all). */\n types?: PiiType[];\n /** Mask generator (default: `[REDACTED_<TYPE>]`). */\n mask?: (type: PiiType) => string;\n /** input/output/both (default: 'both'). */\n on?: 'input' | 'output' | 'both';\n /**\n * Also redact the tool execute result (processToolResult hook) — default: false.\n * known limitation: tool output (external API/DB/file result) was being written to the journal as\n * PLAIN TEXT; it may contain PII. Left as opt-in so existing piiRedactor users' behavior does NOT\n * CHANGE — this option only masks tool output when explicitly set to `true`.\n */\n redactToolResults?: boolean;\n /**\n * Extra patterns, for the identifiers the five built-in types cannot name.\n *\n * The built-in list is US-shaped (`ssn` exists nowhere else), so a national id, an IBAN, a patient\n * record number or an internal customer id has no type that matches it. Dropping a built-in from\n * `types` removes coverage rather than adding any, and mutating the exported `PII_PATTERNS` changes\n * behavior for every redactor in the process — neither is a way to add your own.\n *\n * These run BEFORE the built-ins, deliberately: the built-in `phone` pattern is greedy enough to\n * swallow a national id (measured — `12345678901` comes back `[REDACTED_PHONE]`), so a pattern that\n * ran afterwards would find its text already masked under the wrong name.\n *\n * piiRedactor({ extraPatterns: [{ name: 'iban', pattern: /TR\\d{24}/g }] })\n */\n extraPatterns?: PiiPattern[];\n /**\n * Run each identifier's own checksum before masking — Luhn for `creditCard`, mod-97 for `iban`,\n * and any `test` on a custom pattern. Default: true.\n *\n * It is what separates a card number from any sixteen digits: measured, the pattern alone masked\n * `order 1234567812345678` as a card, which loses the order number without protecting anything.\n * The trade is real and worth stating — a card typed with a wrong digit fails Luhn and is then left\n * alone. Set to `false` to mask on shape only, which is the older, blunter behavior: it corrupts\n * more text but cannot be talked out of masking anything.\n */\n validate?: boolean;\n}\n\nconst DEFAULT_TYPES: PiiType[] = ['email', 'phone', 'creditCard', 'ssn', 'ip', 'iban'];\n\n/**\n * Rejects a configuration that would mask less than the caller thinks it does.\n *\n * A name with no built-in behind it used to be dropped in silence — `types: ['iban']` returned the\n * text untouched and reported nothing, so a deployment could believe a type was covered while the\n * value went through in the clear. TypeScript catches it only for TS callers passing a literal;\n * Config read from JS, JSON or an env var reaches here unchecked. Both entry points run this at\n * CONSTRUCTION — config time, not per-run — so a mistake surfaces at startup instead of inside a\n * run that has already touched the data.\n */\nfunction assertUsablePatterns(types: PiiType[], extra: PiiPattern[]): void {\n const unknown = types.filter((t) => !(t in PII_PATTERNS));\n if (unknown.length) {\n throw new Error(\n `piiRedactor: unknown PII type(s) ${unknown.map((u) => JSON.stringify(u)).join(', ')} — these would mask nothing. ` +\n `Built-in types are ${Object.keys(PII_PATTERNS).join(', ')}; anything else goes in extraPatterns.`,\n );\n }\n for (const p of extra) {\n if (!p?.name || !(p.pattern instanceof RegExp)) {\n throw new Error(`piiRedactor: extraPatterns entries need a non-empty \\`name\\` and a RegExp \\`pattern\\` (got ${JSON.stringify(p)}).`);\n }\n }\n}\n\n/**\n * The same masking as `piiRedactor`, as a plain string function, for the text that never passes\n * through a processor at all.\n *\n * A `Processor` only sees `processInput`/`processOutput`/`processToolResult`. A run's failure\n * message is written from `recordRunOutcome` in @gnldev/durable, which no processor is consulted\n * about, and it is not always the host's own text: a provider that refuses a request commonly\n * echoes the offending input back inside the message. Anything that ships that message onward —\n * `@gnldev/otel`'s span attributes are the case this was added for — needs the same mask, and had\n * no way to reuse it because `DEFAULT_TYPES` is private to this module.\n *\n * Shares that constant and the default mask with `piiRedactor` deliberately: two copies of the\n * defaults is exactly how the redacted path and the un-redacted one drift apart.\n *\n * import { piiTextRedactor } from '@gnldev/processors';\n * await exportRun(journal, runId, { endpoint, redact: piiTextRedactor() });\n */\nexport function piiTextRedactor(\n opts: Pick<PiiRedactorOptions, 'types' | 'mask' | 'extraPatterns' | 'validate'> = {},\n): (text: string) => string {\n const types = opts.types ?? DEFAULT_TYPES;\n const mask = opts.mask ?? ((t: PiiType) => `[REDACTED_${t.toUpperCase()}]`);\n const extra = opts.extraPatterns ?? [];\n const validate = opts.validate !== false;\n assertUsablePatterns(types, extra);\n return (text: string) => (typeof text === 'string' ? redactString(text, types, mask, extra, validate) : text);\n}\n\n/**\n * For the audit report (`recordProcessorReport`): the counts come off the SAME call that performs the\n * redaction (`applyRedactions`), so the report cannot describe a masking that did not run. It used to\n * be a second, independent walk over the same text with the same order copied by hand — which is\n * exactly how the two drift once a validator or a new pattern lands in only one of them.\n */\nfunction countRedactions(\n text: string,\n types: PiiType[],\n mask: (t: PiiType) => string,\n extra: PiiPattern[] = [],\n validate = true,\n): { total: number; types: string[] } {\n const { total, types: hitTypes } = applyRedactions(text, types, mask, { extra, validate });\n return { total, types: hitTypes };\n}\n\n/** Aggregates redactions across ALL text fields of a ProcessorInput/ProcessorOutput (system/prompt/messages\n * are counted separately — same granularity as redactMessages, which redacts each message/part INDEPENDENTLY). */\nfunction countInputRedactions(\n fields: { system?: unknown; prompt?: unknown; messages?: any[] },\n types: PiiType[],\n mask: (t: PiiType) => string,\n extra: PiiPattern[] = [],\n validate = true,\n): { total: number; types: string[] } {\n let total = 0;\n const typeSet = new Set<string>();\n const add = (r: { total: number; types: string[] }) => { total += r.total; for (const t of r.types) typeSet.add(t); };\n if (typeof fields.system === 'string') add(countRedactions(fields.system, types, mask, extra, validate));\n if (typeof fields.prompt === 'string') add(countRedactions(fields.prompt, types, mask, extra, validate));\n for (const m of fields.messages ?? []) {\n if (typeof m?.content === 'string') add(countRedactions(m.content, types, mask, extra, validate));\n else if (Array.isArray(m?.content)) {\n for (const part of m.content) if (typeof part?.text === 'string') add(countRedactions(part.text, types, mask, extra, validate));\n }\n }\n return { total, types: [...typeSet] };\n}\n\n/**\n * PII redaction processor — pure regex (email/phone/credit-card/ssn/ip). Deterministic, so no\n * journaling is needed: produces the same masking on resume. The input side runs BEFORE persistInput\n * → masked content is written to the journal, the model NEVER sees raw PII.\n *\n * HONEST WARNING (naive regex matching): These regexes are best-effort, they do NOT provide an\n * AUDIT/COMPLIANCE-grade (GDPR/HIPAA/PCI-DSS etc.) PII DETECTION GUARANTEE. Known limits: only\n * matches specific formats (e.g. US/generic-format phone numbers, plain 16-digit card numbers) —\n * international/local formats, unstructured PII like name/address, or unusual formatting (line\n * breaks, different separators) can slip through; it can also produce false positives (e.g. a\n * random 16-digit number). Use it as a noise-reduction / first-line-of-defense layer, not as a real\n * compliance/security boundary.\n *\n * SCOPE, measured — WHAT THE OUTPUT SIDE DOES NOT COVER: `processOutput` transforms the\n * `{text, messages}` view, so `result.text` and `result.response.messages` come back masked on both\n * `runDurable` and `streamDurable`, and so does persisted thread memory. `result.steps` and\n * `result.content` DO NOT — they still hold the model's raw output, and a caller reading either one\n * sees unredacted PII. This is not fixable inside the hook: a processor's output arity is\n * unconstrained (a summariser legally returns one message for a turn that produced three), so no\n * mapping back onto per-step records exists, and `content` is a parts array rather than messages.\n * Read `text`/`response.messages`; treat `steps`/`content` as raw. On the STREAM path the\n * `textStream`/`fullStream` deltas are raw as well — they reach the client before the turn ends, so\n * use `on: 'input'` (or `runDurable`) if the wire itself must never carry it.\n */\nexport function piiRedactor(opts: PiiRedactorOptions = {}): Processor {\n const types = opts.types ?? DEFAULT_TYPES;\n const mask = opts.mask ?? ((t: PiiType) => `[REDACTED_${t.toUpperCase()}]`);\n const extra = opts.extraPatterns ?? [];\n const validate = opts.validate !== false;\n assertUsablePatterns(types, extra);\n const on = opts.on ?? 'both';\n\n const proc: Processor = { name: 'pii-redactor' };\n\n if (on === 'input' || on === 'both') {\n // NOT async (behavior must stay the same — callers may call processInput synchronously and\n // read the returned object directly). The audit report (recordProcessorReport) is BEST-EFFORT +\n // fire-and-forget: it does NOT CHANGE the transform/synchronous-return contract, it's only an\n // EXTRA record.\n proc.processInput = (input: ProcessorInput, ctx: ProcessorCtx) => {\n const out: ProcessorInput = {\n system: typeof input.system === 'string' ? redactString(input.system, types, mask, extra, validate) : input.system,\n prompt: typeof input.prompt === 'string' ? redactString(input.prompt, types, mask, extra, validate) : input.prompt,\n messages: input.messages ? redactMessages(input.messages, types, mask, extra, validate) : input.messages,\n };\n const { total, types: hitTypes } = countInputRedactions(input, types, mask, extra, validate);\n if (total > 0) void recordProcessorReport(ctx, 'pii-redactor', 'input', { redactedCount: total, types: hitTypes });\n return out;\n };\n }\n\n if (on === 'output' || on === 'both') {\n proc.processOutput = (output: ProcessorOutput, ctx: ProcessorCtx) => {\n const out: ProcessorOutput = {\n ...output,\n text: typeof output.text === 'string' ? redactString(output.text, types, mask, extra, validate) : output.text,\n messages: output.messages ? redactMessages(output.messages, types, mask, extra, validate) : output.messages,\n };\n const { total, types: hitTypes } = countInputRedactions(\n { prompt: output.text, messages: output.messages }, types, mask, extra, validate,\n );\n if (total > 0) void recordProcessorReport(ctx, 'pii-redactor', 'output', { redactedCount: total, types: hitTypes });\n return out;\n };\n }\n\n // AUDIT TASK: opt-in hook so tool output doesn't write plain PII to the journal. String output is\n // redacted directly; object output is redacted via the JSON.stringify → redact → JSON.parse chain —\n // if stringify/parse fails (circular structure or JSON broken by redaction), the original output is\n // NOT TOUCHED (same \"deterministic, pure transform only\" principle as processInput/processOutput;\n // the raw value still gets written to the journal, but at least the framework doesn't silently\n // corrupt data).\n if (opts.redactToolResults) {\n // durable-tool.ts ALWAYS awaits THIS HOOK (`await proc.processToolResult(...)`) →\n // making it async doesn't break the existing contract (existing tests already await it too).\n proc.processToolResult = async (res: ProcessorToolResult, ctx: ProcessorCtx) => {\n const { output } = res;\n // `string[]`, not `PiiType[]`: a custom pattern's name belongs in the audit report too, or the\n // report would say fewer types were hit than the redaction actually masked.\n const report = (r: { total: number; types: string[] }) => {\n if (r.total > 0) void recordProcessorReport(ctx, 'pii-redactor', 'tool', { redactedCount: r.total, types: r.types });\n };\n if (typeof output === 'string') {\n report(countRedactions(output, types, mask, extra, validate));\n return { output: redactString(output, types, mask, extra, validate) };\n }\n if (output !== null && typeof output === 'object') {\n let json: string;\n try {\n json = JSON.stringify(output);\n } catch {\n return { output }; // circular structure etc. → cannot serialize, NOT TOUCHED\n }\n const redacted = redactString(json, types, mask, extra, validate);\n try {\n const parsed = JSON.parse(redacted);\n report(countRedactions(json, types, mask, extra, validate));\n return { output: parsed };\n } catch {\n return { output }; // redaction broke the JSON → original returned UNTOUCHED\n }\n }\n return { output }; // number/boolean/null/undefined → cannot contain PII\n };\n }\n\n return proc;\n}\n"]}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
export type PiiType = 'email' | 'phone' | 'creditCard' | 'ssn' | 'ip' | 'iban';
|
|
2
|
+
export declare const PII_PATTERNS: Record<PiiType, RegExp>;
|
|
3
|
+
/**
|
|
4
|
+
* The self-check some identifiers carry: a digit computed from the others.
|
|
5
|
+
*
|
|
6
|
+
* It is what separates a card number from any sixteen digits — measured, the pattern alone masked
|
|
7
|
+
* `1234 5678 9012 3456` and `order 1234567812345678` as cards, which is text corruption rather than
|
|
8
|
+
* privacy. Where a validator exists the answer is arithmetic, not a guess, so the precision it buys
|
|
9
|
+
* costs nothing in certainty. Types with no entry here (email, ip, phone, ssn) have nothing to check
|
|
10
|
+
* against and are matched on shape alone, exactly as before.
|
|
11
|
+
*/
|
|
12
|
+
export declare const PII_VALIDATORS: Partial<Record<PiiType, (match: string) => boolean>>;
|
|
13
|
+
/**
|
|
14
|
+
* No checksum exists for a phone number, so this is a shape rule rather than a proof: 7–15 digits
|
|
15
|
+
* (E.164's ceiling), and once a number is split at all, its parts are short — an area code or a
|
|
16
|
+
* block, never an eight-digit run.
|
|
17
|
+
*
|
|
18
|
+
* It is what stops the digit-hungry cases the pattern alone still reaches: a 16-digit order number
|
|
19
|
+
* and a `20240115 093012` run id both clear the pattern, and both are text this has no business
|
|
20
|
+
* touching. Safe to REFUSE a match here, unlike on a loose candidate, because the pattern is already
|
|
21
|
+
* structurally narrow — measured on `log 1234-56-78 555-123-4567`, the refused span does not extend
|
|
22
|
+
* over the neighbouring phone number, so nothing is swallowed and released.
|
|
23
|
+
*/
|
|
24
|
+
export declare function looksLikePhone(s: string): boolean;
|
|
25
|
+
/** Card check digit (Luhn). Rejects runs too short to be a card so a stray 12-digit id cannot pass. */
|
|
26
|
+
export declare function luhn(s: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* IBAN check (ISO 13616 mod-97): move the first four characters to the end, map letters to numbers,
|
|
29
|
+
* the remainder against 97 must be 1. Computed digit by digit because the value is far wider than a
|
|
30
|
+
* JS number can hold exactly — `Number(...) % 97` on a 30-digit string is silently wrong.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ibanMod97(s: string): boolean;
|
|
33
|
+
export declare const ORDER: PiiType[];
|
|
34
|
+
/**
|
|
35
|
+
* A caller-supplied pattern, for the identifiers the five built-ins cannot name.
|
|
36
|
+
*
|
|
37
|
+
* The built-in list is US-shaped — `ssn` exists nowhere else — so a deployment that has to mask a
|
|
38
|
+
* national id, an IBAN, a patient record number or an internal customer id has no built-in that
|
|
39
|
+
* matches it. Before this existed the only options were to drop a built-in type (which removes
|
|
40
|
+
* coverage rather than adding it) or to mutate the shared `PII_PATTERNS` object process-wide.
|
|
41
|
+
*
|
|
42
|
+
* Carries its own replacement rather than going through the `mask` callback, so adding a custom
|
|
43
|
+
* pattern does not widen `mask`'s parameter from `PiiType` to `string` — which would break every
|
|
44
|
+
* existing `(t: PiiType) => string` callback under `strictFunctionTypes`.
|
|
45
|
+
*/
|
|
46
|
+
export interface PiiPattern {
|
|
47
|
+
/** Used in the default mask and in the audit report, e.g. 'iban' → `[REDACTED_IBAN]`. */
|
|
48
|
+
name: string;
|
|
49
|
+
/** Matched against the text. A missing `g` flag is added rather than silently matching once. */
|
|
50
|
+
pattern: RegExp;
|
|
51
|
+
/** Replacement text. Defaults to `[REDACTED_<NAME>]`. */
|
|
52
|
+
mask?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Runs on each match; returning false leaves the text alone. This is where a national id's own
|
|
55
|
+
* checksum goes, and it exists so a caller-supplied pattern has the same power the built-ins do —
|
|
56
|
+
* `creditCard` and `iban` are validated by `PII_VALIDATORS`, and shipping that for ourselves while
|
|
57
|
+
* handing callers a bare regex would be the asymmetry, not a simplification.
|
|
58
|
+
*/
|
|
59
|
+
test?: (match: string) => boolean;
|
|
60
|
+
}
|
|
61
|
+
/** `g` is what makes `String.replace` replace every occurrence; without it only the first is masked. */
|
|
62
|
+
export declare function globalize(re: RegExp): RegExp;
|
|
63
|
+
/**
|
|
64
|
+
* Replaces every match that passes `test`, and — the point of writing this by hand rather than using
|
|
65
|
+
* `String.replace` with a callback — does NOT consume the ones that fail.
|
|
66
|
+
*
|
|
67
|
+
* A rejected match is a span the scanner looked at and declined, not a span it has dealt with.
|
|
68
|
+
* `String.replace` advances past it regardless, so a candidate that swallowed a real identifier and
|
|
69
|
+
* then failed its own checksum took that identifier out of reach of every later pattern. Measured,
|
|
70
|
+
* with the checksum enabled and by default:
|
|
71
|
+
*
|
|
72
|
+
* `ref 1111 2222 4111 1111 1111 1111` → the valid card inside came back unmasked
|
|
73
|
+
* `id 1234567890 555-123-4567` → nothing at all was masked
|
|
74
|
+
*
|
|
75
|
+
* Both are the failure mode a validator is supposed to prevent, caused by the validator. Resuming at
|
|
76
|
+
* `index + 1` costs a rescan of the rejected span and removes the class: whatever real identifier
|
|
77
|
+
* starts inside it is still found. Everything else about the scan is unchanged — matches that pass
|
|
78
|
+
* are replaced exactly as before, and a pattern with no `test` behaves identically to `replace`.
|
|
79
|
+
*/
|
|
80
|
+
export declare function replaceValidated(s: string, re: RegExp, replacement: string, test?: (match: string) => boolean): {
|
|
81
|
+
text: string;
|
|
82
|
+
count: number;
|
|
83
|
+
};
|
|
84
|
+
export declare const patternMask: (p: PiiPattern) => string;
|
|
85
|
+
export interface RedactOptions {
|
|
86
|
+
extra?: PiiPattern[];
|
|
87
|
+
/** Run the checksums in `PII_VALIDATORS` and each pattern's own `test`. Default: true. */
|
|
88
|
+
validate?: boolean;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The ONE place patterns are applied. `redactString` reads the text off it and the audit report reads
|
|
92
|
+
* the counts, so the two cannot describe different redactions — they used to be separate walks over
|
|
93
|
+
* the same text, which is precisely how a report starts claiming a masking that never ran.
|
|
94
|
+
*/
|
|
95
|
+
export declare function applyRedactions(s: string, types: PiiType[], mask: (t: PiiType) => string, opts?: RedactOptions): {
|
|
96
|
+
text: string;
|
|
97
|
+
total: number;
|
|
98
|
+
types: string[];
|
|
99
|
+
};
|
|
100
|
+
export declare function redactString(s: string, types: PiiType[], mask: (t: PiiType) => string, extra?: PiiPattern[], validate?: boolean): string;
|
|
101
|
+
/** Redacts a message list (string or parts-array content); returns a copy. */
|
|
102
|
+
export declare function redactMessages(messages: any[], types: PiiType[], mask: (t: PiiType) => string, extra?: PiiPattern[], validate?: boolean): any[];
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// PII regexes + message/string redaction helpers. Pure (deterministic) → no journaling needed.
|
|
2
|
+
//
|
|
3
|
+
// HONEST WARNING (naive regex matching): These patterns are best-effort — they do NOT provide a real
|
|
4
|
+
// PII DETECTION/compliance (GDPR/HIPAA/PCI-DSS) guarantee, they can be easily missed (unusual format,
|
|
5
|
+
// international format, unstructured PII like name/address) or produce false positives. Use as a
|
|
6
|
+
// noise-reduction / first-line-of-defense layer, do NOT RELY on it as the SOLE mechanism for critical
|
|
7
|
+
// compliance decisions.
|
|
8
|
+
export const PII_PATTERNS = {
|
|
9
|
+
// The lookbehind is a performance fix, not a semantic one — it matches exactly what the pattern
|
|
10
|
+
// matched before (checked across ordinary addresses, tagged locals, multi-label domains, hyphens
|
|
11
|
+
// and near-misses). Without it the local-part `[\w.+-]+` retries from EVERY position inside a run
|
|
12
|
+
// of word characters, consuming the whole run each time before failing to find `@`: quadratic, and
|
|
13
|
+
// measured on a single line with no address in it at all — 10k chars 78ms, 20k 321ms, 40k 1284ms,
|
|
14
|
+
// 80k 5290ms. A model transcript or a tool result is exactly where a long unbroken run turns up.
|
|
15
|
+
// Anchoring the start means a failure at one position rules out every position inside the run:
|
|
16
|
+
// The same inputs now measure 0ms.
|
|
17
|
+
email: /(?<![\w.+-])[\w.+-]+@[\w-]+\.[\w.-]+/g,
|
|
18
|
+
// 16-digit card (grouped with spaces/dashes): 4-4-4-4
|
|
19
|
+
creditCard: /\b\d{4}[ -]?\d{4}[ -]?\d{4}[ -]?\d{4}\b/g,
|
|
20
|
+
ssn: /\b\d{3}-\d{2}-\d{4}\b/g,
|
|
21
|
+
ip: /\b(?:\d{1,3}\.){3}\d{1,3}\b/g,
|
|
22
|
+
// E.g. +90 555 123 4567 / (212) 555-1234 / 0532 111 22 33 / 5551234567.
|
|
23
|
+
//
|
|
24
|
+
// Structural on purpose. The previous form (`\+?\d[\d\s().-]{7,}\d`) counted CHARACTERS, and its
|
|
25
|
+
// class held spaces and dashes, so three digits spread over a wide span cleared the bar: measured,
|
|
26
|
+
// it masked `2024-01-15 10` out of a timestamp, the whole of `1.2.3 - 4.5.6`, a run id, and two
|
|
27
|
+
// digits separated by eight spaces. Error messages and logs carry timestamps, so that is text
|
|
28
|
+
// corruption in the payload this most often sees.
|
|
29
|
+
//
|
|
30
|
+
// Digit GROUPS are bounded instead, separators must be single characters, and an ISO date cannot
|
|
31
|
+
// start a match; `looksLikePhone` then rules on the digits.
|
|
32
|
+
//
|
|
33
|
+
// Measured against 24 written forms across a dozen countries, and against non-PII text that must
|
|
34
|
+
// survive. An earlier version of this pattern was checked against eleven formats CHOSEN AFTER the
|
|
35
|
+
// rule was written, all of them shapes it already accepted — it passed, while `+49 30 12345678`,
|
|
36
|
+
// `+90 5321112233`, `0212 5551234` and `(212) 5551234` went through unmasked, and adding one space
|
|
37
|
+
// to `+905321112233` was enough to turn its masking off. A set assembled to fit the rule measures
|
|
38
|
+
// the rule against itself; the list in `builtins.test.ts` is now deliberately wider than the rule.
|
|
39
|
+
//
|
|
40
|
+
// The refusal is safe here only because `replaceValidated` does not consume a rejected span — see
|
|
41
|
+
// its note. With `String.replace`, a candidate that spanned `1234567890 555-123-4567`, failed the
|
|
42
|
+
// digit count and was skipped took the real number with it.
|
|
43
|
+
phone: /(?!\d{4}-\d{2}-\d{2})(?<![\d(\w])\(?\+?\d{1,4}\)?(?:[ .-]?\(?\d{1,4}\)?){0,6}(?![\w])/g,
|
|
44
|
+
// Country + check digits + up to 30 alphanumerics, printed either compact or in 4-char groups.
|
|
45
|
+
iban: /\b[A-Z]{2}\d{2}(?:[ -]?[A-Z0-9]{4}){2,7}(?:[ -]?[A-Z0-9]{1,3})?\b/g,
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* The self-check some identifiers carry: a digit computed from the others.
|
|
49
|
+
*
|
|
50
|
+
* It is what separates a card number from any sixteen digits — measured, the pattern alone masked
|
|
51
|
+
* `1234 5678 9012 3456` and `order 1234567812345678` as cards, which is text corruption rather than
|
|
52
|
+
* privacy. Where a validator exists the answer is arithmetic, not a guess, so the precision it buys
|
|
53
|
+
* costs nothing in certainty. Types with no entry here (email, ip, phone, ssn) have nothing to check
|
|
54
|
+
* against and are matched on shape alone, exactly as before.
|
|
55
|
+
*/
|
|
56
|
+
export const PII_VALIDATORS = {
|
|
57
|
+
creditCard: luhn,
|
|
58
|
+
iban: ibanMod97,
|
|
59
|
+
phone: looksLikePhone,
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* No checksum exists for a phone number, so this is a shape rule rather than a proof: 7–15 digits
|
|
63
|
+
* (E.164's ceiling), and once a number is split at all, its parts are short — an area code or a
|
|
64
|
+
* block, never an eight-digit run.
|
|
65
|
+
*
|
|
66
|
+
* It is what stops the digit-hungry cases the pattern alone still reaches: a 16-digit order number
|
|
67
|
+
* and a `20240115 093012` run id both clear the pattern, and both are text this has no business
|
|
68
|
+
* touching. Safe to REFUSE a match here, unlike on a loose candidate, because the pattern is already
|
|
69
|
+
* structurally narrow — measured on `log 1234-56-78 555-123-4567`, the refused span does not extend
|
|
70
|
+
* over the neighbouring phone number, so nothing is swallowed and released.
|
|
71
|
+
*/
|
|
72
|
+
export function looksLikePhone(s) {
|
|
73
|
+
const groups = s.match(/\d+/g) ?? [];
|
|
74
|
+
const digits = groups.reduce((n, g) => n + g.length, 0);
|
|
75
|
+
if (digits < 7 || digits > 15)
|
|
76
|
+
return false;
|
|
77
|
+
if (/\s{2,}/.test(s))
|
|
78
|
+
return false; // a run of spaces joins two unrelated numbers, not a number
|
|
79
|
+
// Only the LAST group may be long: what precedes a subscriber number is a country or area code,
|
|
80
|
+
// which is short. An earlier rule required EVERY group to be short, which is how the most common
|
|
81
|
+
// written forms in several countries stopped being masked — `+49 30 12345678`, `+90 5321112233`,
|
|
82
|
+
// `0212 5551234`, `(212) 5551234` all leaked, and adding one space to `+905321112233` was enough to
|
|
83
|
+
// turn its masking off. Measured against 24 real formats rather than a set chosen to fit the rule.
|
|
84
|
+
return groups.slice(0, -1).every((g) => g.length <= 4);
|
|
85
|
+
}
|
|
86
|
+
/** Card check digit (Luhn). Rejects runs too short to be a card so a stray 12-digit id cannot pass. */
|
|
87
|
+
export function luhn(s) {
|
|
88
|
+
const d = s.replace(/\D/g, '');
|
|
89
|
+
if (d.length < 12 || d.length > 19)
|
|
90
|
+
return false;
|
|
91
|
+
let sum = 0;
|
|
92
|
+
let alt = false;
|
|
93
|
+
for (let i = d.length - 1; i >= 0; i--) {
|
|
94
|
+
let n = Number(d[i]);
|
|
95
|
+
if (alt) {
|
|
96
|
+
n *= 2;
|
|
97
|
+
if (n > 9)
|
|
98
|
+
n -= 9;
|
|
99
|
+
}
|
|
100
|
+
sum += n;
|
|
101
|
+
alt = !alt;
|
|
102
|
+
}
|
|
103
|
+
return sum % 10 === 0;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* IBAN check (ISO 13616 mod-97): move the first four characters to the end, map letters to numbers,
|
|
107
|
+
* the remainder against 97 must be 1. Computed digit by digit because the value is far wider than a
|
|
108
|
+
* JS number can hold exactly — `Number(...) % 97` on a 30-digit string is silently wrong.
|
|
109
|
+
*/
|
|
110
|
+
export function ibanMod97(s) {
|
|
111
|
+
const v = s.replace(/[\s-]/g, '').toUpperCase();
|
|
112
|
+
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(v))
|
|
113
|
+
return false;
|
|
114
|
+
const rearranged = v.slice(4) + v.slice(0, 4);
|
|
115
|
+
let rem = 0;
|
|
116
|
+
for (const ch of rearranged) {
|
|
117
|
+
const code = ch >= 'A' && ch <= 'Z' ? String(ch.charCodeAt(0) - 55) : ch;
|
|
118
|
+
for (const digit of code)
|
|
119
|
+
rem = (rem * 10 + Number(digit)) % 97;
|
|
120
|
+
}
|
|
121
|
+
return rem === 1;
|
|
122
|
+
}
|
|
123
|
+
// Apply creditCard/ssn/ip BEFORE phone (the phone pattern can also catch them).
|
|
124
|
+
// EXPORT: used by pii.ts's audit report (recordProcessorReport) to derive the redaction COUNT in the
|
|
125
|
+
// same order — shares the same application order WITHOUT CHANGING redactString's behavior.
|
|
126
|
+
// `iban` leads: it is the longest and most specific shape here, and leaving it until after the
|
|
127
|
+
// digit-hungry patterns would hand them its account number first.
|
|
128
|
+
export const ORDER = ['iban', 'email', 'creditCard', 'ssn', 'ip', 'phone'];
|
|
129
|
+
/** `g` is what makes `String.replace` replace every occurrence; without it only the first is masked. */
|
|
130
|
+
export function globalize(re) {
|
|
131
|
+
return new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Replaces every match that passes `test`, and — the point of writing this by hand rather than using
|
|
135
|
+
* `String.replace` with a callback — does NOT consume the ones that fail.
|
|
136
|
+
*
|
|
137
|
+
* A rejected match is a span the scanner looked at and declined, not a span it has dealt with.
|
|
138
|
+
* `String.replace` advances past it regardless, so a candidate that swallowed a real identifier and
|
|
139
|
+
* then failed its own checksum took that identifier out of reach of every later pattern. Measured,
|
|
140
|
+
* with the checksum enabled and by default:
|
|
141
|
+
*
|
|
142
|
+
* `ref 1111 2222 4111 1111 1111 1111` → the valid card inside came back unmasked
|
|
143
|
+
* `id 1234567890 555-123-4567` → nothing at all was masked
|
|
144
|
+
*
|
|
145
|
+
* Both are the failure mode a validator is supposed to prevent, caused by the validator. Resuming at
|
|
146
|
+
* `index + 1` costs a rescan of the rejected span and removes the class: whatever real identifier
|
|
147
|
+
* starts inside it is still found. Everything else about the scan is unchanged — matches that pass
|
|
148
|
+
* are replaced exactly as before, and a pattern with no `test` behaves identically to `replace`.
|
|
149
|
+
*/
|
|
150
|
+
export function replaceValidated(s, re, replacement, test) {
|
|
151
|
+
const g = globalize(re);
|
|
152
|
+
let out = '';
|
|
153
|
+
let last = 0;
|
|
154
|
+
let count = 0;
|
|
155
|
+
let m;
|
|
156
|
+
while ((m = g.exec(s)) !== null) {
|
|
157
|
+
if (m[0] === '') {
|
|
158
|
+
g.lastIndex++;
|
|
159
|
+
continue;
|
|
160
|
+
} // a zero-width match would spin forever
|
|
161
|
+
if (test && !test(m[0])) {
|
|
162
|
+
g.lastIndex = m.index + 1;
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
out += s.slice(last, m.index) + replacement;
|
|
166
|
+
last = m.index + m[0].length;
|
|
167
|
+
count++;
|
|
168
|
+
}
|
|
169
|
+
return { text: out + s.slice(last), count };
|
|
170
|
+
}
|
|
171
|
+
export const patternMask = (p) => p.mask ?? `[REDACTED_${p.name.toUpperCase()}]`;
|
|
172
|
+
/**
|
|
173
|
+
* The ONE place patterns are applied. `redactString` reads the text off it and the audit report reads
|
|
174
|
+
* the counts, so the two cannot describe different redactions — they used to be separate walks over
|
|
175
|
+
* the same text, which is precisely how a report starts claiming a masking that never ran.
|
|
176
|
+
*/
|
|
177
|
+
export function applyRedactions(s, types, mask, opts = {}) {
|
|
178
|
+
const extra = opts.extra ?? [];
|
|
179
|
+
const validate = opts.validate !== false;
|
|
180
|
+
let out = s;
|
|
181
|
+
let total = 0;
|
|
182
|
+
const hit = [];
|
|
183
|
+
const step = (name, re, replacement, test) => {
|
|
184
|
+
const { text, count } = replaceValidated(out, re, replacement, validate ? test : undefined);
|
|
185
|
+
out = text;
|
|
186
|
+
if (count > 0) {
|
|
187
|
+
hit.push(name);
|
|
188
|
+
total += count;
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
// Custom patterns run FIRST, and that order is load-bearing rather than cosmetic: the built-in
|
|
192
|
+
// `phone` is greedy enough to swallow a national id or an ISO date (measured), so a caller-supplied
|
|
193
|
+
// pattern that ran after it would find its text already masked — under the wrong name.
|
|
194
|
+
for (const p of extra)
|
|
195
|
+
step(p.name, p.pattern, patternMask(p), p.test);
|
|
196
|
+
// iban first, then creditCard/ssn/ip BEFORE phone (the phone pattern can also catch them).
|
|
197
|
+
for (const t of ORDER) {
|
|
198
|
+
if (!types.includes(t))
|
|
199
|
+
continue;
|
|
200
|
+
step(t, PII_PATTERNS[t], mask(t), PII_VALIDATORS[t]);
|
|
201
|
+
}
|
|
202
|
+
return { text: out, total, types: hit };
|
|
203
|
+
}
|
|
204
|
+
export function redactString(s, types, mask, extra = [], validate = true) {
|
|
205
|
+
return applyRedactions(s, types, mask, { extra, validate }).text;
|
|
206
|
+
}
|
|
207
|
+
/** Redacts a message list (string or parts-array content); returns a copy. */
|
|
208
|
+
export function redactMessages(messages, types, mask, extra = [], validate = true) {
|
|
209
|
+
return messages.map((m) => {
|
|
210
|
+
if (typeof m?.content === 'string') {
|
|
211
|
+
return { ...m, content: redactString(m.content, types, mask, extra, validate) };
|
|
212
|
+
}
|
|
213
|
+
if (Array.isArray(m?.content)) {
|
|
214
|
+
return {
|
|
215
|
+
...m,
|
|
216
|
+
content: m.content.map((part) => typeof part?.text === 'string' ? { ...part, text: redactString(part.text, types, mask, extra, validate) } : part),
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return m;
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
//# sourceMappingURL=redact.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redact.js","sourceRoot":"","sources":["../src/redact.ts"],"names":[],"mappings":"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","sourcesContent":["// PII regexes + message/string redaction helpers. Pure (deterministic) → no journaling needed.\n//\n// HONEST WARNING (naive regex matching): These patterns are best-effort — they do NOT provide a real\n// PII DETECTION/compliance (GDPR/HIPAA/PCI-DSS) guarantee, they can be easily missed (unusual format,\n// international format, unstructured PII like name/address) or produce false positives. Use as a\n// noise-reduction / first-line-of-defense layer, do NOT RELY on it as the SOLE mechanism for critical\n// compliance decisions.\n\nexport type PiiType = 'email' | 'phone' | 'creditCard' | 'ssn' | 'ip' | 'iban';\n\nexport const PII_PATTERNS: Record<PiiType, RegExp> = {\n // The lookbehind is a performance fix, not a semantic one — it matches exactly what the pattern\n // matched before (checked across ordinary addresses, tagged locals, multi-label domains, hyphens\n // and near-misses). Without it the local-part `[\\w.+-]+` retries from EVERY position inside a run\n // of word characters, consuming the whole run each time before failing to find `@`: quadratic, and\n // measured on a single line with no address in it at all — 10k chars 78ms, 20k 321ms, 40k 1284ms,\n // 80k 5290ms. A model transcript or a tool result is exactly where a long unbroken run turns up.\n // Anchoring the start means a failure at one position rules out every position inside the run:\n // The same inputs now measure 0ms.\n email: /(?<![\\w.+-])[\\w.+-]+@[\\w-]+\\.[\\w.-]+/g,\n // 16-digit card (grouped with spaces/dashes): 4-4-4-4\n creditCard: /\\b\\d{4}[ -]?\\d{4}[ -]?\\d{4}[ -]?\\d{4}\\b/g,\n ssn: /\\b\\d{3}-\\d{2}-\\d{4}\\b/g,\n ip: /\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b/g,\n // E.g. +90 555 123 4567 / (212) 555-1234 / 0532 111 22 33 / 5551234567.\n //\n // Structural on purpose. The previous form (`\\+?\\d[\\d\\s().-]{7,}\\d`) counted CHARACTERS, and its\n // class held spaces and dashes, so three digits spread over a wide span cleared the bar: measured,\n // it masked `2024-01-15 10` out of a timestamp, the whole of `1.2.3 - 4.5.6`, a run id, and two\n // digits separated by eight spaces. Error messages and logs carry timestamps, so that is text\n // corruption in the payload this most often sees.\n //\n // Digit GROUPS are bounded instead, separators must be single characters, and an ISO date cannot\n // start a match; `looksLikePhone` then rules on the digits.\n //\n // Measured against 24 written forms across a dozen countries, and against non-PII text that must\n // survive. An earlier version of this pattern was checked against eleven formats CHOSEN AFTER the\n // rule was written, all of them shapes it already accepted — it passed, while `+49 30 12345678`,\n // `+90 5321112233`, `0212 5551234` and `(212) 5551234` went through unmasked, and adding one space\n // to `+905321112233` was enough to turn its masking off. A set assembled to fit the rule measures\n // the rule against itself; the list in `builtins.test.ts` is now deliberately wider than the rule.\n //\n // The refusal is safe here only because `replaceValidated` does not consume a rejected span — see\n // its note. With `String.replace`, a candidate that spanned `1234567890 555-123-4567`, failed the\n // digit count and was skipped took the real number with it.\n phone: /(?!\\d{4}-\\d{2}-\\d{2})(?<![\\d(\\w])\\(?\\+?\\d{1,4}\\)?(?:[ .-]?\\(?\\d{1,4}\\)?){0,6}(?![\\w])/g,\n // Country + check digits + up to 30 alphanumerics, printed either compact or in 4-char groups.\n iban: /\\b[A-Z]{2}\\d{2}(?:[ -]?[A-Z0-9]{4}){2,7}(?:[ -]?[A-Z0-9]{1,3})?\\b/g,\n};\n\n/**\n * The self-check some identifiers carry: a digit computed from the others.\n *\n * It is what separates a card number from any sixteen digits — measured, the pattern alone masked\n * `1234 5678 9012 3456` and `order 1234567812345678` as cards, which is text corruption rather than\n * privacy. Where a validator exists the answer is arithmetic, not a guess, so the precision it buys\n * costs nothing in certainty. Types with no entry here (email, ip, phone, ssn) have nothing to check\n * against and are matched on shape alone, exactly as before.\n */\nexport const PII_VALIDATORS: Partial<Record<PiiType, (match: string) => boolean>> = {\n creditCard: luhn,\n iban: ibanMod97,\n phone: looksLikePhone,\n};\n\n/**\n * No checksum exists for a phone number, so this is a shape rule rather than a proof: 7–15 digits\n * (E.164's ceiling), and once a number is split at all, its parts are short — an area code or a\n * block, never an eight-digit run.\n *\n * It is what stops the digit-hungry cases the pattern alone still reaches: a 16-digit order number\n * and a `20240115 093012` run id both clear the pattern, and both are text this has no business\n * touching. Safe to REFUSE a match here, unlike on a loose candidate, because the pattern is already\n * structurally narrow — measured on `log 1234-56-78 555-123-4567`, the refused span does not extend\n * over the neighbouring phone number, so nothing is swallowed and released.\n */\nexport function looksLikePhone(s: string): boolean {\n const groups = s.match(/\\d+/g) ?? [];\n const digits = groups.reduce((n, g) => n + g.length, 0);\n if (digits < 7 || digits > 15) return false;\n if (/\\s{2,}/.test(s)) return false; // a run of spaces joins two unrelated numbers, not a number\n // Only the LAST group may be long: what precedes a subscriber number is a country or area code,\n // which is short. An earlier rule required EVERY group to be short, which is how the most common\n // written forms in several countries stopped being masked — `+49 30 12345678`, `+90 5321112233`,\n // `0212 5551234`, `(212) 5551234` all leaked, and adding one space to `+905321112233` was enough to\n // turn its masking off. Measured against 24 real formats rather than a set chosen to fit the rule.\n return groups.slice(0, -1).every((g) => g.length <= 4);\n}\n\n/** Card check digit (Luhn). Rejects runs too short to be a card so a stray 12-digit id cannot pass. */\nexport function luhn(s: string): boolean {\n const d = s.replace(/\\D/g, '');\n if (d.length < 12 || d.length > 19) return false;\n let sum = 0;\n let alt = false;\n for (let i = d.length - 1; i >= 0; i--) {\n let n = Number(d[i]);\n if (alt) { n *= 2; if (n > 9) n -= 9; }\n sum += n;\n alt = !alt;\n }\n return sum % 10 === 0;\n}\n\n/**\n * IBAN check (ISO 13616 mod-97): move the first four characters to the end, map letters to numbers,\n * the remainder against 97 must be 1. Computed digit by digit because the value is far wider than a\n * JS number can hold exactly — `Number(...) % 97` on a 30-digit string is silently wrong.\n */\nexport function ibanMod97(s: string): boolean {\n const v = s.replace(/[\\s-]/g, '').toUpperCase();\n if (!/^[A-Z]{2}\\d{2}[A-Z0-9]{10,30}$/.test(v)) return false;\n const rearranged = v.slice(4) + v.slice(0, 4);\n let rem = 0;\n for (const ch of rearranged) {\n const code = ch >= 'A' && ch <= 'Z' ? String(ch.charCodeAt(0) - 55) : ch;\n for (const digit of code) rem = (rem * 10 + Number(digit)) % 97;\n }\n return rem === 1;\n}\n\n// Apply creditCard/ssn/ip BEFORE phone (the phone pattern can also catch them).\n// EXPORT: used by pii.ts's audit report (recordProcessorReport) to derive the redaction COUNT in the\n// same order — shares the same application order WITHOUT CHANGING redactString's behavior.\n// `iban` leads: it is the longest and most specific shape here, and leaving it until after the\n// digit-hungry patterns would hand them its account number first.\nexport const ORDER: PiiType[] = ['iban', 'email', 'creditCard', 'ssn', 'ip', 'phone'];\n\n/**\n * A caller-supplied pattern, for the identifiers the five built-ins cannot name.\n *\n * The built-in list is US-shaped — `ssn` exists nowhere else — so a deployment that has to mask a\n * national id, an IBAN, a patient record number or an internal customer id has no built-in that\n * matches it. Before this existed the only options were to drop a built-in type (which removes\n * coverage rather than adding it) or to mutate the shared `PII_PATTERNS` object process-wide.\n *\n * Carries its own replacement rather than going through the `mask` callback, so adding a custom\n * pattern does not widen `mask`'s parameter from `PiiType` to `string` — which would break every\n * existing `(t: PiiType) => string` callback under `strictFunctionTypes`.\n */\nexport interface PiiPattern {\n /** Used in the default mask and in the audit report, e.g. 'iban' → `[REDACTED_IBAN]`. */\n name: string;\n /** Matched against the text. A missing `g` flag is added rather than silently matching once. */\n pattern: RegExp;\n /** Replacement text. Defaults to `[REDACTED_<NAME>]`. */\n mask?: string;\n /**\n * Runs on each match; returning false leaves the text alone. This is where a national id's own\n * checksum goes, and it exists so a caller-supplied pattern has the same power the built-ins do —\n * `creditCard` and `iban` are validated by `PII_VALIDATORS`, and shipping that for ourselves while\n * handing callers a bare regex would be the asymmetry, not a simplification.\n */\n test?: (match: string) => boolean;\n}\n\n/** `g` is what makes `String.replace` replace every occurrence; without it only the first is masked. */\nexport function globalize(re: RegExp): RegExp {\n return new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);\n}\n\n/**\n * Replaces every match that passes `test`, and — the point of writing this by hand rather than using\n * `String.replace` with a callback — does NOT consume the ones that fail.\n *\n * A rejected match is a span the scanner looked at and declined, not a span it has dealt with.\n * `String.replace` advances past it regardless, so a candidate that swallowed a real identifier and\n * then failed its own checksum took that identifier out of reach of every later pattern. Measured,\n * with the checksum enabled and by default:\n *\n * `ref 1111 2222 4111 1111 1111 1111` → the valid card inside came back unmasked\n * `id 1234567890 555-123-4567` → nothing at all was masked\n *\n * Both are the failure mode a validator is supposed to prevent, caused by the validator. Resuming at\n * `index + 1` costs a rescan of the rejected span and removes the class: whatever real identifier\n * starts inside it is still found. Everything else about the scan is unchanged — matches that pass\n * are replaced exactly as before, and a pattern with no `test` behaves identically to `replace`.\n */\nexport function replaceValidated(\n s: string,\n re: RegExp,\n replacement: string,\n test?: (match: string) => boolean,\n): { text: string; count: number } {\n const g = globalize(re);\n let out = '';\n let last = 0;\n let count = 0;\n let m: RegExpExecArray | null;\n while ((m = g.exec(s)) !== null) {\n if (m[0] === '') { g.lastIndex++; continue; } // a zero-width match would spin forever\n if (test && !test(m[0])) { g.lastIndex = m.index + 1; continue; }\n out += s.slice(last, m.index) + replacement;\n last = m.index + m[0].length;\n count++;\n }\n return { text: out + s.slice(last), count };\n}\n\nexport const patternMask = (p: PiiPattern): string => p.mask ?? `[REDACTED_${p.name.toUpperCase()}]`;\n\nexport interface RedactOptions {\n extra?: PiiPattern[];\n /** Run the checksums in `PII_VALIDATORS` and each pattern's own `test`. Default: true. */\n validate?: boolean;\n}\n\n/**\n * The ONE place patterns are applied. `redactString` reads the text off it and the audit report reads\n * the counts, so the two cannot describe different redactions — they used to be separate walks over\n * the same text, which is precisely how a report starts claiming a masking that never ran.\n */\nexport function applyRedactions(\n s: string,\n types: PiiType[],\n mask: (t: PiiType) => string,\n opts: RedactOptions = {},\n): { text: string; total: number; types: string[] } {\n const extra = opts.extra ?? [];\n const validate = opts.validate !== false;\n let out = s;\n let total = 0;\n const hit: string[] = [];\n\n const step = (name: string, re: RegExp, replacement: string, test?: (m: string) => boolean) => {\n const { text, count } = replaceValidated(out, re, replacement, validate ? test : undefined);\n out = text;\n if (count > 0) { hit.push(name); total += count; }\n };\n\n // Custom patterns run FIRST, and that order is load-bearing rather than cosmetic: the built-in\n // `phone` is greedy enough to swallow a national id or an ISO date (measured), so a caller-supplied\n // pattern that ran after it would find its text already masked — under the wrong name.\n for (const p of extra) step(p.name, p.pattern, patternMask(p), p.test);\n // iban first, then creditCard/ssn/ip BEFORE phone (the phone pattern can also catch them).\n for (const t of ORDER) {\n if (!types.includes(t)) continue;\n step(t, PII_PATTERNS[t], mask(t), PII_VALIDATORS[t]);\n }\n return { text: out, total, types: hit };\n}\n\nexport function redactString(\n s: string,\n types: PiiType[],\n mask: (t: PiiType) => string,\n extra: PiiPattern[] = [],\n validate = true,\n): string {\n return applyRedactions(s, types, mask, { extra, validate }).text;\n}\n\n/** Redacts a message list (string or parts-array content); returns a copy. */\nexport function redactMessages(\n messages: any[],\n types: PiiType[],\n mask: (t: PiiType) => string,\n extra: PiiPattern[] = [],\n validate = true,\n): any[] {\n return messages.map((m) => {\n if (typeof m?.content === 'string') {\n return { ...m, content: redactString(m.content, types, mask, extra, validate) };\n }\n if (Array.isArray(m?.content)) {\n return {\n ...m,\n content: m.content.map((part: any) =>\n typeof part?.text === 'string' ? { ...part, text: redactString(part.text, types, mask, extra, validate) } : part,\n ),\n };\n }\n return m;\n });\n}\n"]}
|