@n8n/utils 1.44.0 → 1.46.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/dist/errors/error-chain.cjs +36 -0
- package/dist/errors/error-chain.cjs.map +1 -0
- package/dist/errors/error-chain.d.cts +7 -0
- package/dist/errors/error-chain.d.mts +7 -0
- package/dist/errors/error-chain.mjs +34 -0
- package/dist/errors/error-chain.mjs.map +1 -0
- package/dist/format-pem-block.cjs +5 -2
- package/dist/format-pem-block.cjs.map +1 -1
- package/dist/format-pem-block.mjs +5 -2
- package/dist/format-pem-block.mjs.map +1 -1
- package/dist/number/bytes.cjs +21 -0
- package/dist/number/bytes.cjs.map +1 -0
- package/dist/number/bytes.d.cts +6 -0
- package/dist/number/bytes.d.mts +6 -0
- package/dist/number/bytes.mjs +19 -0
- package/dist/number/bytes.mjs.map +1 -0
- package/dist/redaction/pii-patterns.cjs +188 -0
- package/dist/redaction/pii-patterns.cjs.map +1 -0
- package/dist/redaction/pii-patterns.d.cts +23 -0
- package/dist/redaction/pii-patterns.d.mts +23 -0
- package/dist/redaction/pii-patterns.mjs +180 -0
- package/dist/redaction/pii-patterns.mjs.map +1 -0
- package/dist/redaction/redact-text.cjs +152 -0
- package/dist/redaction/redact-text.cjs.map +1 -0
- package/dist/redaction/redact-text.d.cts +29 -0
- package/dist/redaction/redact-text.d.mts +29 -0
- package/dist/redaction/redact-text.mjs +148 -0
- package/dist/redaction/redact-text.mjs.map +1 -0
- package/dist/scrub-secrets.cjs +2 -0
- package/dist/scrub-secrets.cjs.map +1 -1
- package/dist/scrub-secrets.mjs +2 -0
- package/dist/scrub-secrets.mjs.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { SECRET_VALUE_PATTERNS } from "../scrub-secrets.mjs";
|
|
2
|
+
//#region src/redaction/pii-patterns.ts
|
|
3
|
+
/** Compile a global regex once, adding the `g` flag if the source omits it. */
|
|
4
|
+
function globalRegex(source, flags = "") {
|
|
5
|
+
return new RegExp(source, flags.includes("g") ? flags : `${flags}g`);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Secret/credential patterns, sourced from {@link SECRET_VALUE_PATTERNS} so
|
|
9
|
+
* there is a single place that defines what a credential looks like.
|
|
10
|
+
*/
|
|
11
|
+
const SECRET_PATTERNS = SECRET_VALUE_PATTERNS.map((re) => ({
|
|
12
|
+
category: "secret",
|
|
13
|
+
regex: globalRegex(re.source, re.flags)
|
|
14
|
+
}));
|
|
15
|
+
/** Luhn checksum — used to keep credit-card redaction from firing on any long digit run. */
|
|
16
|
+
function passesLuhn(candidate) {
|
|
17
|
+
const digits = candidate.replace(/\D/g, "");
|
|
18
|
+
if (digits.length < 13 || digits.length > 19) return false;
|
|
19
|
+
let sum = 0;
|
|
20
|
+
let double = false;
|
|
21
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
22
|
+
let digit = digits.charCodeAt(i) - 48;
|
|
23
|
+
if (double) {
|
|
24
|
+
digit *= 2;
|
|
25
|
+
if (digit > 9) digit -= 9;
|
|
26
|
+
}
|
|
27
|
+
sum += digit;
|
|
28
|
+
double = !double;
|
|
29
|
+
}
|
|
30
|
+
return sum % 10 === 0;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Confidence gate for phone candidates, encoding the **E.164** standard: a
|
|
34
|
+
* leading `+`, a non-zero country code, and 7–15 digits total. Runs on the
|
|
35
|
+
* digit/`+`-only normalized form (separators stripped).
|
|
36
|
+
*/
|
|
37
|
+
function passesE164(candidate) {
|
|
38
|
+
return /^\+[1-9]\d{6,14}$/.test(candidate.replace(/[^\d+]/g, ""));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* IBAN mod-97 checksum (ISO 13616): drop spaces, move the first 4 chars to the
|
|
42
|
+
* end, map letters A–Z → 10–35, and confirm the big-integer value mod 97 === 1.
|
|
43
|
+
*/
|
|
44
|
+
function passesIbanChecksum(candidate) {
|
|
45
|
+
const compact = candidate.replace(/\s/g, "").toUpperCase();
|
|
46
|
+
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(compact)) return false;
|
|
47
|
+
const rearranged = compact.slice(4) + compact.slice(0, 4);
|
|
48
|
+
let remainder = 0;
|
|
49
|
+
for (let i = 0; i < rearranged.length; i++) {
|
|
50
|
+
const code = rearranged.charCodeAt(i);
|
|
51
|
+
const value = code >= 65 ? code - 55 : code - 48;
|
|
52
|
+
remainder = value > 9 ? (remainder * 100 + value) % 97 : (remainder * 10 + value) % 97;
|
|
53
|
+
}
|
|
54
|
+
return remainder === 1;
|
|
55
|
+
}
|
|
56
|
+
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
57
|
+
function base58Decode(input) {
|
|
58
|
+
const bytes = [];
|
|
59
|
+
for (let i = 0; i < input.length; i++) {
|
|
60
|
+
let carry = BASE58_ALPHABET.indexOf(input[i]);
|
|
61
|
+
if (carry === -1) return void 0;
|
|
62
|
+
for (let j = 0; j < bytes.length; j++) {
|
|
63
|
+
carry += bytes[j] * 58;
|
|
64
|
+
bytes[j] = carry & 255;
|
|
65
|
+
carry >>= 8;
|
|
66
|
+
}
|
|
67
|
+
while (carry > 0) {
|
|
68
|
+
bytes.push(carry & 255);
|
|
69
|
+
carry >>= 8;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
for (let i = 0; i < input.length && input[i] === "1"; i++) bytes.push(0);
|
|
73
|
+
return Uint8Array.from(bytes.reverse());
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Ethereum (`0x`+40 hex) or Bitcoin bech32 (`bc1`/`tb1`) — both distinctive
|
|
77
|
+
* enough to accept on shape alone.
|
|
78
|
+
*/
|
|
79
|
+
function isDistinctiveWalletShape(match) {
|
|
80
|
+
if (/^0x[0-9a-fA-F]{40}$/.test(match)) return true;
|
|
81
|
+
return /^(?:bc1|tb1)[023456789acdefghjklmnpqrstuvwxyz]{11,71}$/.test(match);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Default legacy-address gate: a Base58Check payload decodes to exactly 25
|
|
85
|
+
* bytes (1 version + 20 hash + 4 checksum). Verifying the checksum itself needs
|
|
86
|
+
* SHA-256, which has no synchronous cross-platform primitive — Node callers
|
|
87
|
+
* inject the stricter check via `createPiiPatterns`. Erring toward redaction is
|
|
88
|
+
* the safe direction: an unvalidated Base58 blob of that length is far more
|
|
89
|
+
* likely to be a credential than prose.
|
|
90
|
+
*/
|
|
91
|
+
function isLegacyWalletShape(match) {
|
|
92
|
+
return base58Decode(match)?.length === 25;
|
|
93
|
+
}
|
|
94
|
+
/** Ethereum, Bitcoin bech32, or a legacy Base58 address of plausible length. */
|
|
95
|
+
function isCryptoWalletShape(match) {
|
|
96
|
+
return isDistinctiveWalletShape(match) || isLegacyWalletShape(match);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Conservative, high-confidence PII patterns. Phone detection is best-effort:
|
|
100
|
+
* only well-structured (E.164) formats are matched. New {@link PiiDetectionType}
|
|
101
|
+
* categories slot in here; a category may map to `undefined` to declare it
|
|
102
|
+
* before a pattern exists, in which case it is excluded from detection.
|
|
103
|
+
*
|
|
104
|
+
* `overrides` swaps individual entries — used by `@n8n/agents` to layer its
|
|
105
|
+
* Node-only Base58Check validator onto `crypto-wallet`.
|
|
106
|
+
*/
|
|
107
|
+
function createPiiPatterns(overrides = {}) {
|
|
108
|
+
return {
|
|
109
|
+
email: {
|
|
110
|
+
category: "email",
|
|
111
|
+
regex: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
|
|
112
|
+
},
|
|
113
|
+
"credit-card": {
|
|
114
|
+
category: "credit-card",
|
|
115
|
+
regex: /\b\d(?:[ -]?\d){12,18}\b/g,
|
|
116
|
+
validate: passesLuhn
|
|
117
|
+
},
|
|
118
|
+
"ssn-us": {
|
|
119
|
+
category: "ssn-us",
|
|
120
|
+
regex: /\b\d{3}-\d{2}-\d{4}\b/g
|
|
121
|
+
},
|
|
122
|
+
phone: {
|
|
123
|
+
category: "phone",
|
|
124
|
+
regex: /\+\d(?:[\s().-]*\d){6,14}\b/g,
|
|
125
|
+
validate: passesE164
|
|
126
|
+
},
|
|
127
|
+
iban: {
|
|
128
|
+
category: "iban",
|
|
129
|
+
regex: /\b[A-Za-z]{2}\d{2}[A-Za-z0-9]{11,30}\b|\b[A-Z]{2}\d{2}(?: [A-Z0-9]{1,4}){2,8}\b/g,
|
|
130
|
+
validate: passesIbanChecksum
|
|
131
|
+
},
|
|
132
|
+
"crypto-wallet": {
|
|
133
|
+
category: "crypto-wallet",
|
|
134
|
+
regex: /\b(?:0x[0-9a-fA-F]{40}|(?:bc1|tb1)[023456789acdefghjklmnpqrstuvwxyz]{11,71}|[13][1-9A-HJ-NP-Za-km-z]{25,34})\b/g,
|
|
135
|
+
validate: isCryptoWalletShape
|
|
136
|
+
},
|
|
137
|
+
mac: {
|
|
138
|
+
category: "mac",
|
|
139
|
+
regex: /\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\b/g
|
|
140
|
+
},
|
|
141
|
+
ip: {
|
|
142
|
+
category: "ip",
|
|
143
|
+
regex: /\b(?:\d{1,3}\.){3}\d{1,3}\b|\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\b|\b(?:[A-Fa-f0-9]{1,4}:){1,7}:(?:[A-Fa-f0-9]{1,4})?\b/g,
|
|
144
|
+
validate: isIpAddress
|
|
145
|
+
},
|
|
146
|
+
url: {
|
|
147
|
+
category: "url",
|
|
148
|
+
regex: /\bhttps?:\/\/[^\s<>"')\]}]+/g
|
|
149
|
+
},
|
|
150
|
+
...overrides
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** IPv4 with octets ≤ 255, or a colon-delimited IPv6 (shape already constrained by the regex). */
|
|
154
|
+
function isIpAddress(match) {
|
|
155
|
+
if (match.includes(":")) return true;
|
|
156
|
+
const octets = match.split(".");
|
|
157
|
+
return octets.length === 4 && octets.every((o) => /^\d{1,3}$/.test(o) && Number(o) <= 255);
|
|
158
|
+
}
|
|
159
|
+
/** Browser-safe default table. Node callers layer stricter validators on top. */
|
|
160
|
+
const PII_PATTERNS = createPiiPatterns();
|
|
161
|
+
/**
|
|
162
|
+
* PII categories that actually have a detection pattern today — the source of
|
|
163
|
+
* truth for what redaction can detect. Any {@link PiiDetectionType} mapped to
|
|
164
|
+
* `undefined` in the table (declared but not yet implemented) is excluded here.
|
|
165
|
+
*/
|
|
166
|
+
const SUPPORTED_PII_CATEGORIES = Object.keys(PII_PATTERNS).filter((type) => PII_PATTERNS[type] !== void 0);
|
|
167
|
+
/** Resolve the active pattern set for the given options. */
|
|
168
|
+
function resolvePatterns(opts, piiPatterns = PII_PATTERNS) {
|
|
169
|
+
const patterns = [];
|
|
170
|
+
if (opts.secrets) patterns.push(...SECRET_PATTERNS);
|
|
171
|
+
for (const type of opts.detect) {
|
|
172
|
+
const pattern = piiPatterns[type];
|
|
173
|
+
if (pattern) patterns.push(pattern);
|
|
174
|
+
}
|
|
175
|
+
return patterns;
|
|
176
|
+
}
|
|
177
|
+
//#endregion
|
|
178
|
+
export { PII_PATTERNS, SUPPORTED_PII_CATEGORIES, base58Decode, createPiiPatterns, isCryptoWalletShape, passesIbanChecksum, passesLuhn, resolvePatterns };
|
|
179
|
+
|
|
180
|
+
//# sourceMappingURL=pii-patterns.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pii-patterns.mjs","names":[],"sources":["../../src/redaction/pii-patterns.ts"],"sourcesContent":["import { SECRET_VALUE_PATTERNS } from '../scrub-secrets';\n\n/**\n * PII categories the detection vocabulary knows about. A category may be\n * declared here before a pattern exists for it — see {@link PII_PATTERNS}.\n */\nexport type PiiDetectionType =\n\t| 'email'\n\t| 'phone'\n\t| 'credit-card'\n\t| 'ssn-us'\n\t| 'iban'\n\t| 'crypto-wallet'\n\t| 'ip'\n\t| 'mac'\n\t| 'url';\n\n/**\n * A category attached to every redaction match so callers can log *what kind*\n * of sensitive content was removed without ever handling the value itself.\n * `'secret'` covers credential/token patterns; the rest mirror\n * {@link PiiDetectionType}.\n */\nexport type RedactionCategory = 'secret' | PiiDetectionType;\n\nexport interface RedactionPattern {\n\treadonly category: RedactionCategory;\n\t/**\n\t * Precompiled regex matching the sensitive value. Always global — the\n\t * redactor relies on `g` both for replace-all and for the `exec` scan loop.\n\t * Compiled once at module load; callers reset `lastIndex` before reuse.\n\t */\n\treadonly regex: RegExp;\n\t/**\n\t * Optional gate: a candidate match is only redacted when this returns\n\t * `true`. Used to suppress false positives (e.g. Luhn check for cards).\n\t */\n\treadonly validate?: (match: string) => boolean;\n}\n\nexport type PiiPatternTable = Readonly<Record<PiiDetectionType, RedactionPattern | undefined>>;\n\n/** Compile a global regex once, adding the `g` flag if the source omits it. */\nfunction globalRegex(source: string, flags = ''): RegExp {\n\treturn new RegExp(source, flags.includes('g') ? flags : `${flags}g`);\n}\n\n/**\n * Secret/credential patterns, sourced from {@link SECRET_VALUE_PATTERNS} so\n * there is a single place that defines what a credential looks like.\n */\nconst SECRET_PATTERNS: readonly RedactionPattern[] = SECRET_VALUE_PATTERNS.map((re) => ({\n\tcategory: 'secret',\n\tregex: globalRegex(re.source, re.flags),\n}));\n\n/** Luhn checksum — used to keep credit-card redaction from firing on any long digit run. */\nexport function passesLuhn(candidate: string): boolean {\n\tconst digits = candidate.replace(/\\D/g, '');\n\tif (digits.length < 13 || digits.length > 19) return false;\n\n\tlet sum = 0;\n\tlet double = false;\n\tfor (let i = digits.length - 1; i >= 0; i--) {\n\t\tlet digit = digits.charCodeAt(i) - 48;\n\t\tif (double) {\n\t\t\tdigit *= 2;\n\t\t\tif (digit > 9) digit -= 9;\n\t\t}\n\t\tsum += digit;\n\t\tdouble = !double;\n\t}\n\treturn sum % 10 === 0;\n}\n\n/**\n * Confidence gate for phone candidates, encoding the **E.164** standard: a\n * leading `+`, a non-zero country code, and 7–15 digits total. Runs on the\n * digit/`+`-only normalized form (separators stripped).\n */\nfunction passesE164(candidate: string): boolean {\n\treturn /^\\+[1-9]\\d{6,14}$/.test(candidate.replace(/[^\\d+]/g, ''));\n}\n\n/**\n * IBAN mod-97 checksum (ISO 13616): drop spaces, move the first 4 chars to the\n * end, map letters A–Z → 10–35, and confirm the big-integer value mod 97 === 1.\n */\nexport function passesIbanChecksum(candidate: string): boolean {\n\tconst compact = candidate.replace(/\\s/g, '').toUpperCase();\n\tif (!/^[A-Z]{2}\\d{2}[A-Z0-9]{11,30}$/.test(compact)) return false;\n\n\tconst rearranged = compact.slice(4) + compact.slice(0, 4);\n\tlet remainder = 0;\n\tfor (let i = 0; i < rearranged.length; i++) {\n\t\tconst code = rearranged.charCodeAt(i);\n\t\tconst value = code >= 65 ? code - 55 : code - 48; // 'A'→10 … 'Z'→35, '0'→0 … '9'→9\n\t\tremainder = value > 9 ? (remainder * 100 + value) % 97 : (remainder * 10 + value) % 97;\n\t}\n\treturn remainder === 1;\n}\n\nconst BASE58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';\n\nexport function base58Decode(input: string): Uint8Array | undefined {\n\tconst bytes: number[] = [];\n\tfor (let i = 0; i < input.length; i++) {\n\t\tlet carry = BASE58_ALPHABET.indexOf(input[i]);\n\t\tif (carry === -1) return undefined;\n\t\tfor (let j = 0; j < bytes.length; j++) {\n\t\t\tcarry += bytes[j] * 58;\n\t\t\tbytes[j] = carry & 0xff;\n\t\t\tcarry >>= 8;\n\t\t}\n\t\twhile (carry > 0) {\n\t\t\tbytes.push(carry & 0xff);\n\t\t\tcarry >>= 8;\n\t\t}\n\t}\n\tfor (let i = 0; i < input.length && input[i] === '1'; i++) bytes.push(0);\n\treturn Uint8Array.from(bytes.reverse());\n}\n\n/**\n * Ethereum (`0x`+40 hex) or Bitcoin bech32 (`bc1`/`tb1`) — both distinctive\n * enough to accept on shape alone.\n */\nfunction isDistinctiveWalletShape(match: string): boolean {\n\tif (/^0x[0-9a-fA-F]{40}$/.test(match)) return true;\n\treturn /^(?:bc1|tb1)[023456789acdefghjklmnpqrstuvwxyz]{11,71}$/.test(match);\n}\n\n/**\n * Default legacy-address gate: a Base58Check payload decodes to exactly 25\n * bytes (1 version + 20 hash + 4 checksum). Verifying the checksum itself needs\n * SHA-256, which has no synchronous cross-platform primitive — Node callers\n * inject the stricter check via `createPiiPatterns`. Erring toward redaction is\n * the safe direction: an unvalidated Base58 blob of that length is far more\n * likely to be a credential than prose.\n */\nfunction isLegacyWalletShape(match: string): boolean {\n\treturn base58Decode(match)?.length === 25;\n}\n\n/** Ethereum, Bitcoin bech32, or a legacy Base58 address of plausible length. */\nexport function isCryptoWalletShape(match: string): boolean {\n\treturn isDistinctiveWalletShape(match) || isLegacyWalletShape(match);\n}\n\n/**\n * Conservative, high-confidence PII patterns. Phone detection is best-effort:\n * only well-structured (E.164) formats are matched. New {@link PiiDetectionType}\n * categories slot in here; a category may map to `undefined` to declare it\n * before a pattern exists, in which case it is excluded from detection.\n *\n * `overrides` swaps individual entries — used by `@n8n/agents` to layer its\n * Node-only Base58Check validator onto `crypto-wallet`.\n */\nexport function createPiiPatterns(\n\toverrides: Partial<Record<PiiDetectionType, RedactionPattern>> = {},\n): PiiPatternTable {\n\t/* eslint-disable @typescript-eslint/naming-convention -- category ids are the\n\t public `PiiDetectionType` vocabulary, which is kebab-case */\n\treturn {\n\t\temail: {\n\t\t\tcategory: 'email',\n\t\t\tregex: /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}/g,\n\t\t},\n\t\t'credit-card': {\n\t\t\tcategory: 'credit-card',\n\t\t\t// 13-19 digits, optionally grouped by single spaces or dashes.\n\t\t\tregex: /\\b\\d(?:[ -]?\\d){12,18}\\b/g,\n\t\t\tvalidate: passesLuhn,\n\t\t},\n\t\t'ssn-us': {\n\t\t\tcategory: 'ssn-us',\n\t\t\t// US Social Security Number, dashed form only (123-45-6789). Bare 9-digit\n\t\t\t// runs are intentionally not matched (too false-positive-prone). Per-country\n\t\t\t// national IDs each get their own `ssn-<cc>` category (e.g. a future `ssn-uk`).\n\t\t\tregex: /\\b\\d{3}-\\d{2}-\\d{4}\\b/g,\n\t\t},\n\t\tphone: {\n\t\t\tcategory: 'phone',\n\t\t\t// Best-effort, E.164 only: a leading `+` then 7–15 digits, tolerating\n\t\t\t// the spaces/parens/dots/dashes people write between groups\n\t\t\t// (e.g. `+1 (555) 123-4567`). Requiring the `+` keeps false positives\n\t\t\t// low — bare digit runs (IDs, dates, NANP without `+`) are not matched.\n\t\t\tregex: /\\+\\d(?:[\\s().-]*\\d){6,14}\\b/g,\n\t\t\tvalidate: passesE164,\n\t\t},\n\t\tiban: {\n\t\t\tcategory: 'iban',\n\t\t\t// Two forms: the compact (un-spaced) IBAN is matched case-insensitively so\n\t\t\t// lower/mixed-case IBANs are caught — with no internal spaces it can't bleed\n\t\t\t// into a following word. The spaced, group-of-4 form is matched upper-case\n\t\t\t// only: spaced IBANs are written upper-case by convention, and that keeps the\n\t\t\t// greedy body from swallowing following lower-case prose (which would fail the\n\t\t\t// checksum and suppress redaction, since the engine doesn't retry sub-matches).\n\t\t\t// `passesIbanChecksum` upper-cases, strips spaces, and verifies mod-97.\n\t\t\tregex: /\\b[A-Za-z]{2}\\d{2}[A-Za-z0-9]{11,30}\\b|\\b[A-Z]{2}\\d{2}(?: [A-Z0-9]{1,4}){2,8}\\b/g,\n\t\t\tvalidate: passesIbanChecksum,\n\t\t},\n\t\t'crypto-wallet': {\n\t\t\tcategory: 'crypto-wallet',\n\t\t\t// Ethereum `0x…40hex`, Bitcoin bech32 `bc1…`/`tb1…`, or Bitcoin Base58Check.\n\t\t\tregex:\n\t\t\t\t/\\b(?:0x[0-9a-fA-F]{40}|(?:bc1|tb1)[023456789acdefghjklmnpqrstuvwxyz]{11,71}|[13][1-9A-HJ-NP-Za-km-z]{25,34})\\b/g,\n\t\t\tvalidate: isCryptoWalletShape,\n\t\t},\n\t\t// `mac` is declared before `ip`: a MAC is colon-delimited hex and would also\n\t\t// match the IPv6 branch, so matching it as `mac` first keeps the category right.\n\t\tmac: {\n\t\t\tcategory: 'mac',\n\t\t\tregex: /\\b(?:[0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b/g,\n\t\t},\n\t\tip: {\n\t\t\tcategory: 'ip',\n\t\t\t// IPv4 (octets validated) or IPv6 (full and `::`-compressed forms).\n\t\t\tregex:\n\t\t\t\t/\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b|\\b(?:[A-Fa-f0-9]{1,4}:){2,7}[A-Fa-f0-9]{1,4}\\b|\\b(?:[A-Fa-f0-9]{1,4}:){1,7}:(?:[A-Fa-f0-9]{1,4})?\\b/g,\n\t\t\tvalidate: isIpAddress,\n\t\t},\n\t\turl: {\n\t\t\tcategory: 'url',\n\t\t\t// Whole http(s) URL. Stops at whitespace and common trailing delimiters.\n\t\t\tregex: /\\bhttps?:\\/\\/[^\\s<>\"')\\]}]+/g,\n\t\t},\n\t\t...overrides,\n\t};\n\t/* eslint-enable @typescript-eslint/naming-convention */\n}\n\n/** IPv4 with octets ≤ 255, or a colon-delimited IPv6 (shape already constrained by the regex). */\nfunction isIpAddress(match: string): boolean {\n\tif (match.includes(':')) return true;\n\tconst octets = match.split('.');\n\treturn octets.length === 4 && octets.every((o) => /^\\d{1,3}$/.test(o) && Number(o) <= 255);\n}\n\n/** Browser-safe default table. Node callers layer stricter validators on top. */\nexport const PII_PATTERNS = createPiiPatterns();\n\n/**\n * PII categories that actually have a detection pattern today — the source of\n * truth for what redaction can detect. Any {@link PiiDetectionType} mapped to\n * `undefined` in the table (declared but not yet implemented) is excluded here.\n */\nexport const SUPPORTED_PII_CATEGORIES: PiiDetectionType[] = (\n\tObject.keys(PII_PATTERNS) as PiiDetectionType[]\n).filter((type) => PII_PATTERNS[type] !== undefined);\n\n/** Resolve the active pattern set for the given options. */\nexport function resolvePatterns(\n\topts: {\n\t\tsecrets: boolean;\n\t\tdetect: readonly PiiDetectionType[];\n\t},\n\tpiiPatterns: PiiPatternTable = PII_PATTERNS,\n): RedactionPattern[] {\n\tconst patterns: RedactionPattern[] = [];\n\tif (opts.secrets) patterns.push(...SECRET_PATTERNS);\n\tfor (const type of opts.detect) {\n\t\tconst pattern = piiPatterns[type];\n\t\tif (pattern) patterns.push(pattern);\n\t}\n\treturn patterns;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_redaction_pii_patterns = require("./pii-patterns.cjs");
|
|
3
|
+
//#region src/redaction/redact-text.ts
|
|
4
|
+
const DEFAULT_PLACEHOLDER = "[REDACTED]";
|
|
5
|
+
/**
|
|
6
|
+
* Redact secret/PII patterns from a complete string. Pure and idempotent —
|
|
7
|
+
* already-redacted placeholders are left untouched by the underlying patterns.
|
|
8
|
+
*/
|
|
9
|
+
function redactText(input, opts = {}) {
|
|
10
|
+
const placeholder = opts.placeholder ?? "[REDACTED]";
|
|
11
|
+
const patterns = require_redaction_pii_patterns.resolvePatterns({
|
|
12
|
+
secrets: opts.secrets ?? true,
|
|
13
|
+
detect: opts.detect ?? []
|
|
14
|
+
}, opts.piiPatterns);
|
|
15
|
+
const ordered = opts.preserveUrlStructure ? [...patterns.filter((pattern) => pattern.category === "url"), ...patterns.filter((pattern) => pattern.category !== "url")] : patterns;
|
|
16
|
+
const matches = [];
|
|
17
|
+
let text = input;
|
|
18
|
+
for (const pattern of ordered) text = text.replace(pattern.regex, (match) => {
|
|
19
|
+
if (pattern.validate && !pattern.validate(match)) return match;
|
|
20
|
+
if (opts.preserveUrlStructure && pattern.category === "url") {
|
|
21
|
+
const rebuilt = stripUrlSensitiveParts(match, placeholder);
|
|
22
|
+
if (rebuilt !== match) matches.push({ category: pattern.category });
|
|
23
|
+
return rebuilt;
|
|
24
|
+
}
|
|
25
|
+
matches.push({ category: pattern.category });
|
|
26
|
+
return placeholder;
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
text,
|
|
30
|
+
matches
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** True for a path segment that looks like an embedded token — webhook-style
|
|
34
|
+
* services (Slack/Discord/Telegram, …) carry their secret as a path segment.
|
|
35
|
+
* Shape-based on purpose: per-service URL grammars don't scale across hundreds
|
|
36
|
+
* of integrations. Conservative: words, readable slugs and digit-only ids are
|
|
37
|
+
* kept. */
|
|
38
|
+
function isTokenLikeSegment(segment) {
|
|
39
|
+
if (segment.length >= 16 && /[A-Za-z]/.test(segment) && /\d/.test(segment)) return true;
|
|
40
|
+
return segment.length >= 24 && /^[A-Za-z]+$/.test(segment);
|
|
41
|
+
}
|
|
42
|
+
/** Keep origin + path (token-like segments redacted) + query names; redact
|
|
43
|
+
* query values, drop userinfo and fragment. The replacement is URL-safe (no
|
|
44
|
+
* `]`, which the url regex stops at) so re-scrubbing is stable. Unparseable ⇒
|
|
45
|
+
* fully redacted. */
|
|
46
|
+
function stripUrlSensitiveParts(match, placeholder) {
|
|
47
|
+
const urlPlaceholder = placeholder.replace(/[^A-Za-z0-9_.~-]/g, "") || "REDACTED";
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(match);
|
|
50
|
+
const pathname = url.pathname.split("/").map((segment) => isTokenLikeSegment(segment) ? urlPlaceholder : segment).join("/");
|
|
51
|
+
const names = [...url.searchParams.keys()];
|
|
52
|
+
const query = names.length > 0 ? `?${names.map((name) => `${encodeURIComponent(name)}=${urlPlaceholder}`).join("&")}` : "";
|
|
53
|
+
return `${url.origin}${pathname}${query}`;
|
|
54
|
+
} catch {
|
|
55
|
+
return placeholder;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Find the `[start, end)` ranges of every (validated) match in `input`. Used by
|
|
60
|
+
* the streaming redactor to avoid emitting through the middle of a complete
|
|
61
|
+
* match that contains internal whitespace (e.g. a spaced credit-card number).
|
|
62
|
+
*/
|
|
63
|
+
function findMatchRanges(input, opts = {}) {
|
|
64
|
+
const patterns = require_redaction_pii_patterns.resolvePatterns({
|
|
65
|
+
secrets: opts.secrets ?? true,
|
|
66
|
+
detect: opts.detect ?? []
|
|
67
|
+
}, opts.piiPatterns);
|
|
68
|
+
const ranges = [];
|
|
69
|
+
for (const pattern of patterns) {
|
|
70
|
+
const { regex } = pattern;
|
|
71
|
+
regex.lastIndex = 0;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = regex.exec(input)) !== null) {
|
|
74
|
+
if (match[0].length === 0) {
|
|
75
|
+
regex.lastIndex++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (pattern.validate && !pattern.validate(match[0])) continue;
|
|
79
|
+
ranges.push([match.index, match.index + match[0].length]);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return ranges;
|
|
83
|
+
}
|
|
84
|
+
const MAX_DEEP_DEPTH = 8;
|
|
85
|
+
const SENSITIVE_KEY_PATTERN = /(api[_-]?key|private[_-]?key|authorization|bearer|cookie|credentials?|password|secret|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|auth[_-]?token|(?:^|[._-])token$)/i;
|
|
86
|
+
/**
|
|
87
|
+
* Recursively redact string values inside an arbitrary JSON-like value
|
|
88
|
+
* (tool results, structured payloads). Object keys are left intact; only
|
|
89
|
+
* string values are scanned. Recursion is depth-bounded as a cheap guard
|
|
90
|
+
* against pathological/cyclic structures.
|
|
91
|
+
*/
|
|
92
|
+
function redactDeep(value, opts = {}, depth = 0) {
|
|
93
|
+
return redactDeepValue(value, opts, depth);
|
|
94
|
+
}
|
|
95
|
+
function redactDeepValue(value, opts, depth, key) {
|
|
96
|
+
if (opts.redactSensitiveKeys && key && SENSITIVE_KEY_PATTERN.test(key)) return {
|
|
97
|
+
value: opts.placeholder ?? "[REDACTED]",
|
|
98
|
+
matches: [{ category: "secret" }]
|
|
99
|
+
};
|
|
100
|
+
if (typeof value === "string") {
|
|
101
|
+
const { text, matches } = redactText(value, opts);
|
|
102
|
+
return {
|
|
103
|
+
value: text,
|
|
104
|
+
matches
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (depth >= MAX_DEEP_DEPTH) {
|
|
108
|
+
if (value !== null && typeof value === "object") return {
|
|
109
|
+
value: opts.placeholder ?? "[REDACTED]",
|
|
110
|
+
matches: [{ category: "secret" }]
|
|
111
|
+
};
|
|
112
|
+
return {
|
|
113
|
+
value,
|
|
114
|
+
matches: []
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
if (Array.isArray(value)) {
|
|
118
|
+
const matches = [];
|
|
119
|
+
return {
|
|
120
|
+
value: value.map((item) => {
|
|
121
|
+
const result = redactDeepValue(item, opts, depth + 1, key);
|
|
122
|
+
matches.push(...result.matches);
|
|
123
|
+
return result.value;
|
|
124
|
+
}),
|
|
125
|
+
matches
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
if (value !== null && typeof value === "object") {
|
|
129
|
+
const matches = [];
|
|
130
|
+
const next = {};
|
|
131
|
+
for (const [key, item] of Object.entries(value)) {
|
|
132
|
+
const result = redactDeepValue(item, opts, depth + 1, key);
|
|
133
|
+
matches.push(...result.matches);
|
|
134
|
+
next[key] = result.value;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
value: next,
|
|
138
|
+
matches
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
value,
|
|
143
|
+
matches: []
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
exports.DEFAULT_PLACEHOLDER = DEFAULT_PLACEHOLDER;
|
|
148
|
+
exports.findMatchRanges = findMatchRanges;
|
|
149
|
+
exports.redactDeep = redactDeep;
|
|
150
|
+
exports.redactText = redactText;
|
|
151
|
+
|
|
152
|
+
//# sourceMappingURL=redact-text.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redact-text.cjs","names":["resolvePatterns"],"sources":["../../src/redaction/redact-text.ts"],"sourcesContent":["import type { PiiDetectionType, PiiPatternTable, RedactionCategory } from './pii-patterns';\nimport { resolvePatterns } from './pii-patterns';\n\nexport const DEFAULT_PLACEHOLDER = '[REDACTED]';\n\nexport interface RedactionOptions {\n\t/** Scan for credential/secret patterns. Defaults to `true`. */\n\tsecrets?: boolean;\n\t/** PII categories to scan for. Defaults to none. */\n\tdetect?: readonly PiiDetectionType[];\n\t/** Replacement text for a match. Defaults to `[REDACTED]`. */\n\tplaceholder?: string;\n\t/** For `url` matches, keep origin + path + query names and redact the\n\t * value-bearing parts: query values, token-like path segments (webhook\n\t * secrets), userinfo, fragment. Off by default so guardrail behavior is\n\t * unchanged; telemetry/trace scrubbing opts in. */\n\tpreserveUrlStructure?: boolean;\n\t/** Replace values under secret-shaped object keys. */\n\tredactSensitiveKeys?: boolean;\n\t/**\n\t * Detection table to resolve PII categories against. Defaults to the\n\t * browser-safe {@link PII_PATTERNS}; `@n8n/agents` passes a table whose\n\t * `crypto-wallet` entry carries the Node-only Base58Check validator.\n\t */\n\tpiiPatterns?: PiiPatternTable;\n}\n\nexport interface RedactionResult {\n\t/** The input with every detected match replaced by the placeholder. */\n\ttext: string;\n\t/** One entry per replaced match (category only — never the value). */\n\tmatches: Array<{ category: RedactionCategory }>;\n}\n\n/**\n * Redact secret/PII patterns from a complete string. Pure and idempotent —\n * already-redacted placeholders are left untouched by the underlying patterns.\n */\nexport function redactText(input: string, opts: RedactionOptions = {}): RedactionResult {\n\tconst placeholder = opts.placeholder ?? DEFAULT_PLACEHOLDER;\n\tconst patterns = resolvePatterns(\n\t\t{\n\t\t\tsecrets: opts.secrets ?? true,\n\t\t\tdetect: opts.detect ?? [],\n\t\t},\n\t\topts.piiPatterns,\n\t);\n\t// In preserve mode the url pass runs FIRST: it rewrites URLs with a URL-safe\n\t// placeholder before other patterns can plant one containing `]` mid-URL —\n\t// `]` stops the url regex, which would hide the URL's tail (and any secrets\n\t// in it) from this pass entirely.\n\tconst ordered = opts.preserveUrlStructure\n\t\t? [\n\t\t\t\t...patterns.filter((pattern) => pattern.category === 'url'),\n\t\t\t\t...patterns.filter((pattern) => pattern.category !== 'url'),\n\t\t\t]\n\t\t: patterns;\n\n\tconst matches: Array<{ category: RedactionCategory }> = [];\n\tlet text = input;\n\n\tfor (const pattern of ordered) {\n\t\t// `replace` with a global regex scans from 0 and resets lastIndex, so the\n\t\t// shared precompiled regex is safe to reuse across calls.\n\t\ttext = text.replace(pattern.regex, (match) => {\n\t\t\tif (pattern.validate && !pattern.validate(match)) return match;\n\t\t\tif (opts.preserveUrlStructure && pattern.category === 'url') {\n\t\t\t\tconst rebuilt = stripUrlSensitiveParts(match, placeholder);\n\t\t\t\tif (rebuilt !== match) matches.push({ category: pattern.category });\n\t\t\t\treturn rebuilt;\n\t\t\t}\n\t\t\tmatches.push({ category: pattern.category });\n\t\t\treturn placeholder;\n\t\t});\n\t}\n\n\treturn { text, matches };\n}\n\n/** True for a path segment that looks like an embedded token — webhook-style\n * services (Slack/Discord/Telegram, …) carry their secret as a path segment.\n * Shape-based on purpose: per-service URL grammars don't scale across hundreds\n * of integrations. Conservative: words, readable slugs and digit-only ids are\n * kept. */\nfunction isTokenLikeSegment(segment: string): boolean {\n\tif (segment.length >= 16 && /[A-Za-z]/.test(segment) && /\\d/.test(segment)) return true;\n\t// Long single-class opaque blob (e.g. a letters-only token) — real words stay\n\t// shorter and readable slugs contain separators.\n\treturn segment.length >= 24 && /^[A-Za-z]+$/.test(segment);\n}\n\n/** Keep origin + path (token-like segments redacted) + query names; redact\n * query values, drop userinfo and fragment. The replacement is URL-safe (no\n * `]`, which the url regex stops at) so re-scrubbing is stable. Unparseable ⇒\n * fully redacted. */\nfunction stripUrlSensitiveParts(match: string, placeholder: string): string {\n\tconst urlPlaceholder = placeholder.replace(/[^A-Za-z0-9_.~-]/g, '') || 'REDACTED';\n\ttry {\n\t\tconst url = new URL(match);\n\t\tconst pathname = url.pathname\n\t\t\t.split('/')\n\t\t\t.map((segment) => (isTokenLikeSegment(segment) ? urlPlaceholder : segment))\n\t\t\t.join('/');\n\t\tconst names = [...url.searchParams.keys()];\n\t\tconst query =\n\t\t\tnames.length > 0\n\t\t\t\t? `?${names.map((name) => `${encodeURIComponent(name)}=${urlPlaceholder}`).join('&')}`\n\t\t\t\t: '';\n\t\treturn `${url.origin}${pathname}${query}`;\n\t} catch {\n\t\treturn placeholder;\n\t}\n}\n\n/**\n * Find the `[start, end)` ranges of every (validated) match in `input`. Used by\n * the streaming redactor to avoid emitting through the middle of a complete\n * match that contains internal whitespace (e.g. a spaced credit-card number).\n */\nexport function findMatchRanges(\n\tinput: string,\n\topts: RedactionOptions = {},\n): Array<[number, number]> {\n\tconst patterns = resolvePatterns(\n\t\t{\n\t\t\tsecrets: opts.secrets ?? true,\n\t\t\tdetect: opts.detect ?? [],\n\t\t},\n\t\topts.piiPatterns,\n\t);\n\n\tconst ranges: Array<[number, number]> = [];\n\tfor (const pattern of patterns) {\n\t\tconst { regex } = pattern;\n\t\t// Reset before the scan loop; reusing the shared global regex is safe\n\t\t// because usage is synchronous and the loop always runs to completion.\n\t\tregex.lastIndex = 0;\n\t\tlet match: RegExpExecArray | null;\n\t\twhile ((match = regex.exec(input)) !== null) {\n\t\t\tif (match[0].length === 0) {\n\t\t\t\tregex.lastIndex++;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (pattern.validate && !pattern.validate(match[0])) continue;\n\t\t\tranges.push([match.index, match.index + match[0].length]);\n\t\t}\n\t}\n\treturn ranges;\n}\n\nconst MAX_DEEP_DEPTH = 8;\nconst SENSITIVE_KEY_PATTERN =\n\t/(api[_-]?key|private[_-]?key|authorization|bearer|cookie|credentials?|password|secret|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|auth[_-]?token|(?:^|[._-])token$)/i;\n\nexport interface DeepRedactionResult {\n\tvalue: unknown;\n\tmatches: Array<{ category: RedactionCategory }>;\n}\n\n/**\n * Recursively redact string values inside an arbitrary JSON-like value\n * (tool results, structured payloads). Object keys are left intact; only\n * string values are scanned. Recursion is depth-bounded as a cheap guard\n * against pathological/cyclic structures.\n */\nexport function redactDeep(\n\tvalue: unknown,\n\topts: RedactionOptions = {},\n\tdepth = 0,\n): DeepRedactionResult {\n\treturn redactDeepValue(value, opts, depth);\n}\n\nfunction redactDeepValue(\n\tvalue: unknown,\n\topts: RedactionOptions,\n\tdepth: number,\n\tkey?: string,\n): DeepRedactionResult {\n\tif (opts.redactSensitiveKeys && key && SENSITIVE_KEY_PATTERN.test(key)) {\n\t\treturn { value: opts.placeholder ?? DEFAULT_PLACEHOLDER, matches: [{ category: 'secret' }] };\n\t}\n\n\tif (typeof value === 'string') {\n\t\tconst { text, matches } = redactText(value, opts);\n\t\treturn { value: text, matches };\n\t}\n\n\t// Fail closed at the recursion bound: a subtree we refuse to walk is withheld\n\t// rather than passed through unscanned. Real payloads don't nest this deep,\n\t// so the only things reaching here are pathological or cyclic.\n\tif (depth >= MAX_DEEP_DEPTH) {\n\t\tif (value !== null && typeof value === 'object') {\n\t\t\treturn { value: opts.placeholder ?? DEFAULT_PLACEHOLDER, matches: [{ category: 'secret' }] };\n\t\t}\n\t\treturn { value, matches: [] };\n\t}\n\n\tif (Array.isArray(value)) {\n\t\tconst matches: Array<{ category: RedactionCategory }> = [];\n\t\tconst next = value.map((item) => {\n\t\t\tconst result = redactDeepValue(item, opts, depth + 1, key);\n\t\t\tmatches.push(...result.matches);\n\t\t\treturn result.value;\n\t\t});\n\t\treturn { value: next, matches };\n\t}\n\n\tif (value !== null && typeof value === 'object') {\n\t\tconst matches: Array<{ category: RedactionCategory }> = [];\n\t\tconst next: Record<string, unknown> = {};\n\t\tfor (const [key, item] of Object.entries(value)) {\n\t\t\tconst result = redactDeepValue(item, opts, depth + 1, key);\n\t\t\tmatches.push(...result.matches);\n\t\t\tnext[key] = result.value;\n\t\t}\n\t\treturn { value: next, matches };\n\t}\n\n\treturn { value, matches: [] };\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { PiiDetectionType, PiiPatternTable, RedactionCategory } from "./pii-patterns.cjs";
|
|
2
|
+
//#region src/redaction/redact-text.d.ts
|
|
3
|
+
declare const DEFAULT_PLACEHOLDER = "[REDACTED]";
|
|
4
|
+
interface RedactionOptions {
|
|
5
|
+
secrets?: boolean;
|
|
6
|
+
detect?: readonly PiiDetectionType[];
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
preserveUrlStructure?: boolean;
|
|
9
|
+
redactSensitiveKeys?: boolean;
|
|
10
|
+
piiPatterns?: PiiPatternTable;
|
|
11
|
+
}
|
|
12
|
+
interface RedactionResult {
|
|
13
|
+
text: string;
|
|
14
|
+
matches: Array<{
|
|
15
|
+
category: RedactionCategory;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
declare function redactText(input: string, opts?: RedactionOptions): RedactionResult;
|
|
19
|
+
declare function findMatchRanges(input: string, opts?: RedactionOptions): Array<[number, number]>;
|
|
20
|
+
interface DeepRedactionResult {
|
|
21
|
+
value: unknown;
|
|
22
|
+
matches: Array<{
|
|
23
|
+
category: RedactionCategory;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
declare function redactDeep(value: unknown, opts?: RedactionOptions, depth?: number): DeepRedactionResult;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { DEFAULT_PLACEHOLDER, DeepRedactionResult, RedactionOptions, RedactionResult, findMatchRanges, redactDeep, redactText };
|
|
29
|
+
//# sourceMappingURL=redact-text.d.cts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { PiiDetectionType, PiiPatternTable, RedactionCategory } from "./pii-patterns.mjs";
|
|
2
|
+
//#region src/redaction/redact-text.d.ts
|
|
3
|
+
declare const DEFAULT_PLACEHOLDER = "[REDACTED]";
|
|
4
|
+
interface RedactionOptions {
|
|
5
|
+
secrets?: boolean;
|
|
6
|
+
detect?: readonly PiiDetectionType[];
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
preserveUrlStructure?: boolean;
|
|
9
|
+
redactSensitiveKeys?: boolean;
|
|
10
|
+
piiPatterns?: PiiPatternTable;
|
|
11
|
+
}
|
|
12
|
+
interface RedactionResult {
|
|
13
|
+
text: string;
|
|
14
|
+
matches: Array<{
|
|
15
|
+
category: RedactionCategory;
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
declare function redactText(input: string, opts?: RedactionOptions): RedactionResult;
|
|
19
|
+
declare function findMatchRanges(input: string, opts?: RedactionOptions): Array<[number, number]>;
|
|
20
|
+
interface DeepRedactionResult {
|
|
21
|
+
value: unknown;
|
|
22
|
+
matches: Array<{
|
|
23
|
+
category: RedactionCategory;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
declare function redactDeep(value: unknown, opts?: RedactionOptions, depth?: number): DeepRedactionResult;
|
|
27
|
+
//#endregion
|
|
28
|
+
export { DEFAULT_PLACEHOLDER, DeepRedactionResult, RedactionOptions, RedactionResult, findMatchRanges, redactDeep, redactText };
|
|
29
|
+
//# sourceMappingURL=redact-text.d.mts.map
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { resolvePatterns } from "./pii-patterns.mjs";
|
|
2
|
+
//#region src/redaction/redact-text.ts
|
|
3
|
+
const DEFAULT_PLACEHOLDER = "[REDACTED]";
|
|
4
|
+
/**
|
|
5
|
+
* Redact secret/PII patterns from a complete string. Pure and idempotent —
|
|
6
|
+
* already-redacted placeholders are left untouched by the underlying patterns.
|
|
7
|
+
*/
|
|
8
|
+
function redactText(input, opts = {}) {
|
|
9
|
+
const placeholder = opts.placeholder ?? "[REDACTED]";
|
|
10
|
+
const patterns = resolvePatterns({
|
|
11
|
+
secrets: opts.secrets ?? true,
|
|
12
|
+
detect: opts.detect ?? []
|
|
13
|
+
}, opts.piiPatterns);
|
|
14
|
+
const ordered = opts.preserveUrlStructure ? [...patterns.filter((pattern) => pattern.category === "url"), ...patterns.filter((pattern) => pattern.category !== "url")] : patterns;
|
|
15
|
+
const matches = [];
|
|
16
|
+
let text = input;
|
|
17
|
+
for (const pattern of ordered) text = text.replace(pattern.regex, (match) => {
|
|
18
|
+
if (pattern.validate && !pattern.validate(match)) return match;
|
|
19
|
+
if (opts.preserveUrlStructure && pattern.category === "url") {
|
|
20
|
+
const rebuilt = stripUrlSensitiveParts(match, placeholder);
|
|
21
|
+
if (rebuilt !== match) matches.push({ category: pattern.category });
|
|
22
|
+
return rebuilt;
|
|
23
|
+
}
|
|
24
|
+
matches.push({ category: pattern.category });
|
|
25
|
+
return placeholder;
|
|
26
|
+
});
|
|
27
|
+
return {
|
|
28
|
+
text,
|
|
29
|
+
matches
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** True for a path segment that looks like an embedded token — webhook-style
|
|
33
|
+
* services (Slack/Discord/Telegram, …) carry their secret as a path segment.
|
|
34
|
+
* Shape-based on purpose: per-service URL grammars don't scale across hundreds
|
|
35
|
+
* of integrations. Conservative: words, readable slugs and digit-only ids are
|
|
36
|
+
* kept. */
|
|
37
|
+
function isTokenLikeSegment(segment) {
|
|
38
|
+
if (segment.length >= 16 && /[A-Za-z]/.test(segment) && /\d/.test(segment)) return true;
|
|
39
|
+
return segment.length >= 24 && /^[A-Za-z]+$/.test(segment);
|
|
40
|
+
}
|
|
41
|
+
/** Keep origin + path (token-like segments redacted) + query names; redact
|
|
42
|
+
* query values, drop userinfo and fragment. The replacement is URL-safe (no
|
|
43
|
+
* `]`, which the url regex stops at) so re-scrubbing is stable. Unparseable ⇒
|
|
44
|
+
* fully redacted. */
|
|
45
|
+
function stripUrlSensitiveParts(match, placeholder) {
|
|
46
|
+
const urlPlaceholder = placeholder.replace(/[^A-Za-z0-9_.~-]/g, "") || "REDACTED";
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(match);
|
|
49
|
+
const pathname = url.pathname.split("/").map((segment) => isTokenLikeSegment(segment) ? urlPlaceholder : segment).join("/");
|
|
50
|
+
const names = [...url.searchParams.keys()];
|
|
51
|
+
const query = names.length > 0 ? `?${names.map((name) => `${encodeURIComponent(name)}=${urlPlaceholder}`).join("&")}` : "";
|
|
52
|
+
return `${url.origin}${pathname}${query}`;
|
|
53
|
+
} catch {
|
|
54
|
+
return placeholder;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Find the `[start, end)` ranges of every (validated) match in `input`. Used by
|
|
59
|
+
* the streaming redactor to avoid emitting through the middle of a complete
|
|
60
|
+
* match that contains internal whitespace (e.g. a spaced credit-card number).
|
|
61
|
+
*/
|
|
62
|
+
function findMatchRanges(input, opts = {}) {
|
|
63
|
+
const patterns = resolvePatterns({
|
|
64
|
+
secrets: opts.secrets ?? true,
|
|
65
|
+
detect: opts.detect ?? []
|
|
66
|
+
}, opts.piiPatterns);
|
|
67
|
+
const ranges = [];
|
|
68
|
+
for (const pattern of patterns) {
|
|
69
|
+
const { regex } = pattern;
|
|
70
|
+
regex.lastIndex = 0;
|
|
71
|
+
let match;
|
|
72
|
+
while ((match = regex.exec(input)) !== null) {
|
|
73
|
+
if (match[0].length === 0) {
|
|
74
|
+
regex.lastIndex++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (pattern.validate && !pattern.validate(match[0])) continue;
|
|
78
|
+
ranges.push([match.index, match.index + match[0].length]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return ranges;
|
|
82
|
+
}
|
|
83
|
+
const MAX_DEEP_DEPTH = 8;
|
|
84
|
+
const SENSITIVE_KEY_PATTERN = /(api[_-]?key|private[_-]?key|authorization|bearer|cookie|credentials?|password|secret|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|auth[_-]?token|(?:^|[._-])token$)/i;
|
|
85
|
+
/**
|
|
86
|
+
* Recursively redact string values inside an arbitrary JSON-like value
|
|
87
|
+
* (tool results, structured payloads). Object keys are left intact; only
|
|
88
|
+
* string values are scanned. Recursion is depth-bounded as a cheap guard
|
|
89
|
+
* against pathological/cyclic structures.
|
|
90
|
+
*/
|
|
91
|
+
function redactDeep(value, opts = {}, depth = 0) {
|
|
92
|
+
return redactDeepValue(value, opts, depth);
|
|
93
|
+
}
|
|
94
|
+
function redactDeepValue(value, opts, depth, key) {
|
|
95
|
+
if (opts.redactSensitiveKeys && key && SENSITIVE_KEY_PATTERN.test(key)) return {
|
|
96
|
+
value: opts.placeholder ?? "[REDACTED]",
|
|
97
|
+
matches: [{ category: "secret" }]
|
|
98
|
+
};
|
|
99
|
+
if (typeof value === "string") {
|
|
100
|
+
const { text, matches } = redactText(value, opts);
|
|
101
|
+
return {
|
|
102
|
+
value: text,
|
|
103
|
+
matches
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (depth >= MAX_DEEP_DEPTH) {
|
|
107
|
+
if (value !== null && typeof value === "object") return {
|
|
108
|
+
value: opts.placeholder ?? "[REDACTED]",
|
|
109
|
+
matches: [{ category: "secret" }]
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
value,
|
|
113
|
+
matches: []
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
if (Array.isArray(value)) {
|
|
117
|
+
const matches = [];
|
|
118
|
+
return {
|
|
119
|
+
value: value.map((item) => {
|
|
120
|
+
const result = redactDeepValue(item, opts, depth + 1, key);
|
|
121
|
+
matches.push(...result.matches);
|
|
122
|
+
return result.value;
|
|
123
|
+
}),
|
|
124
|
+
matches
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (value !== null && typeof value === "object") {
|
|
128
|
+
const matches = [];
|
|
129
|
+
const next = {};
|
|
130
|
+
for (const [key, item] of Object.entries(value)) {
|
|
131
|
+
const result = redactDeepValue(item, opts, depth + 1, key);
|
|
132
|
+
matches.push(...result.matches);
|
|
133
|
+
next[key] = result.value;
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
value: next,
|
|
137
|
+
matches
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
value,
|
|
142
|
+
matches: []
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
export { DEFAULT_PLACEHOLDER, findMatchRanges, redactDeep, redactText };
|
|
147
|
+
|
|
148
|
+
//# sourceMappingURL=redact-text.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redact-text.mjs","names":[],"sources":["../../src/redaction/redact-text.ts"],"sourcesContent":["import type { PiiDetectionType, PiiPatternTable, RedactionCategory } from './pii-patterns';\nimport { resolvePatterns } from './pii-patterns';\n\nexport const DEFAULT_PLACEHOLDER = '[REDACTED]';\n\nexport interface RedactionOptions {\n\t/** Scan for credential/secret patterns. Defaults to `true`. */\n\tsecrets?: boolean;\n\t/** PII categories to scan for. Defaults to none. */\n\tdetect?: readonly PiiDetectionType[];\n\t/** Replacement text for a match. Defaults to `[REDACTED]`. */\n\tplaceholder?: string;\n\t/** For `url` matches, keep origin + path + query names and redact the\n\t * value-bearing parts: query values, token-like path segments (webhook\n\t * secrets), userinfo, fragment. Off by default so guardrail behavior is\n\t * unchanged; telemetry/trace scrubbing opts in. */\n\tpreserveUrlStructure?: boolean;\n\t/** Replace values under secret-shaped object keys. */\n\tredactSensitiveKeys?: boolean;\n\t/**\n\t * Detection table to resolve PII categories against. Defaults to the\n\t * browser-safe {@link PII_PATTERNS}; `@n8n/agents` passes a table whose\n\t * `crypto-wallet` entry carries the Node-only Base58Check validator.\n\t */\n\tpiiPatterns?: PiiPatternTable;\n}\n\nexport interface RedactionResult {\n\t/** The input with every detected match replaced by the placeholder. */\n\ttext: string;\n\t/** One entry per replaced match (category only — never the value). */\n\tmatches: Array<{ category: RedactionCategory }>;\n}\n\n/**\n * Redact secret/PII patterns from a complete string. Pure and idempotent —\n * already-redacted placeholders are left untouched by the underlying patterns.\n */\nexport function redactText(input: string, opts: RedactionOptions = {}): RedactionResult {\n\tconst placeholder = opts.placeholder ?? DEFAULT_PLACEHOLDER;\n\tconst patterns = resolvePatterns(\n\t\t{\n\t\t\tsecrets: opts.secrets ?? true,\n\t\t\tdetect: opts.detect ?? [],\n\t\t},\n\t\topts.piiPatterns,\n\t);\n\t// In preserve mode the url pass runs FIRST: it rewrites URLs with a URL-safe\n\t// placeholder before other patterns can plant one containing `]` mid-URL —\n\t// `]` stops the url regex, which would hide the URL's tail (and any secrets\n\t// in it) from this pass entirely.\n\tconst ordered = opts.preserveUrlStructure\n\t\t? [\n\t\t\t\t...patterns.filter((pattern) => pattern.category === 'url'),\n\t\t\t\t...patterns.filter((pattern) => pattern.category !== 'url'),\n\t\t\t]\n\t\t: patterns;\n\n\tconst matches: Array<{ category: RedactionCategory }> = [];\n\tlet text = input;\n\n\tfor (const pattern of ordered) {\n\t\t// `replace` with a global regex scans from 0 and resets lastIndex, so the\n\t\t// shared precompiled regex is safe to reuse across calls.\n\t\ttext = text.replace(pattern.regex, (match) => {\n\t\t\tif (pattern.validate && !pattern.validate(match)) return match;\n\t\t\tif (opts.preserveUrlStructure && pattern.category === 'url') {\n\t\t\t\tconst rebuilt = stripUrlSensitiveParts(match, placeholder);\n\t\t\t\tif (rebuilt !== match) matches.push({ category: pattern.category });\n\t\t\t\treturn rebuilt;\n\t\t\t}\n\t\t\tmatches.push({ category: pattern.category });\n\t\t\treturn placeholder;\n\t\t});\n\t}\n\n\treturn { text, matches };\n}\n\n/** True for a path segment that looks like an embedded token — webhook-style\n * services (Slack/Discord/Telegram, …) carry their secret as a path segment.\n * Shape-based on purpose: per-service URL grammars don't scale across hundreds\n * of integrations. Conservative: words, readable slugs and digit-only ids are\n * kept. */\nfunction isTokenLikeSegment(segment: string): boolean {\n\tif (segment.length >= 16 && /[A-Za-z]/.test(segment) && /\\d/.test(segment)) return true;\n\t// Long single-class opaque blob (e.g. a letters-only token) — real words stay\n\t// shorter and readable slugs contain separators.\n\treturn segment.length >= 24 && /^[A-Za-z]+$/.test(segment);\n}\n\n/** Keep origin + path (token-like segments redacted) + query names; redact\n * query values, drop userinfo and fragment. The replacement is URL-safe (no\n * `]`, which the url regex stops at) so re-scrubbing is stable. Unparseable ⇒\n * fully redacted. */\nfunction stripUrlSensitiveParts(match: string, placeholder: string): string {\n\tconst urlPlaceholder = placeholder.replace(/[^A-Za-z0-9_.~-]/g, '') || 'REDACTED';\n\ttry {\n\t\tconst url = new URL(match);\n\t\tconst pathname = url.pathname\n\t\t\t.split('/')\n\t\t\t.map((segment) => (isTokenLikeSegment(segment) ? urlPlaceholder : segment))\n\t\t\t.join('/');\n\t\tconst names = [...url.searchParams.keys()];\n\t\tconst query =\n\t\t\tnames.length > 0\n\t\t\t\t? `?${names.map((name) => `${encodeURIComponent(name)}=${urlPlaceholder}`).join('&')}`\n\t\t\t\t: '';\n\t\treturn `${url.origin}${pathname}${query}`;\n\t} catch {\n\t\treturn placeholder;\n\t}\n}\n\n/**\n * Find the `[start, end)` ranges of every (validated) match in `input`. Used by\n * the streaming redactor to avoid emitting through the middle of a complete\n * match that contains internal whitespace (e.g. a spaced credit-card number).\n */\nexport function findMatchRanges(\n\tinput: string,\n\topts: RedactionOptions = {},\n): Array<[number, number]> {\n\tconst patterns = resolvePatterns(\n\t\t{\n\t\t\tsecrets: opts.secrets ?? true,\n\t\t\tdetect: opts.detect ?? [],\n\t\t},\n\t\topts.piiPatterns,\n\t);\n\n\tconst ranges: Array<[number, number]> = [];\n\tfor (const pattern of patterns) {\n\t\tconst { regex } = pattern;\n\t\t// Reset before the scan loop; reusing the shared global regex is safe\n\t\t// because usage is synchronous and the loop always runs to completion.\n\t\tregex.lastIndex = 0;\n\t\tlet match: RegExpExecArray | null;\n\t\twhile ((match = regex.exec(input)) !== null) {\n\t\t\tif (match[0].length === 0) {\n\t\t\t\tregex.lastIndex++;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (pattern.validate && !pattern.validate(match[0])) continue;\n\t\t\tranges.push([match.index, match.index + match[0].length]);\n\t\t}\n\t}\n\treturn ranges;\n}\n\nconst MAX_DEEP_DEPTH = 8;\nconst SENSITIVE_KEY_PATTERN =\n\t/(api[_-]?key|private[_-]?key|authorization|bearer|cookie|credentials?|password|secret|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|auth[_-]?token|(?:^|[._-])token$)/i;\n\nexport interface DeepRedactionResult {\n\tvalue: unknown;\n\tmatches: Array<{ category: RedactionCategory }>;\n}\n\n/**\n * Recursively redact string values inside an arbitrary JSON-like value\n * (tool results, structured payloads). Object keys are left intact; only\n * string values are scanned. Recursion is depth-bounded as a cheap guard\n * against pathological/cyclic structures.\n */\nexport function redactDeep(\n\tvalue: unknown,\n\topts: RedactionOptions = {},\n\tdepth = 0,\n): DeepRedactionResult {\n\treturn redactDeepValue(value, opts, depth);\n}\n\nfunction redactDeepValue(\n\tvalue: unknown,\n\topts: RedactionOptions,\n\tdepth: number,\n\tkey?: string,\n): DeepRedactionResult {\n\tif (opts.redactSensitiveKeys && key && SENSITIVE_KEY_PATTERN.test(key)) {\n\t\treturn { value: opts.placeholder ?? DEFAULT_PLACEHOLDER, matches: [{ category: 'secret' }] };\n\t}\n\n\tif (typeof value === 'string') {\n\t\tconst { text, matches } = redactText(value, opts);\n\t\treturn { value: text, matches };\n\t}\n\n\t// Fail closed at the recursion bound: a subtree we refuse to walk is withheld\n\t// rather than passed through unscanned. Real payloads don't nest this deep,\n\t// so the only things reaching here are pathological or cyclic.\n\tif (depth >= MAX_DEEP_DEPTH) {\n\t\tif (value !== null && typeof value === 'object') {\n\t\t\treturn { value: opts.placeholder ?? DEFAULT_PLACEHOLDER, matches: [{ category: 'secret' }] };\n\t\t}\n\t\treturn { value, matches: [] };\n\t}\n\n\tif (Array.isArray(value)) {\n\t\tconst matches: Array<{ category: RedactionCategory }> = [];\n\t\tconst next = value.map((item) => {\n\t\t\tconst result = redactDeepValue(item, opts, depth + 1, key);\n\t\t\tmatches.push(...result.matches);\n\t\t\treturn result.value;\n\t\t});\n\t\treturn { value: next, matches };\n\t}\n\n\tif (value !== null && typeof value === 'object') {\n\t\tconst matches: Array<{ category: RedactionCategory }> = [];\n\t\tconst next: Record<string, unknown> = {};\n\t\tfor (const [key, item] of Object.entries(value)) {\n\t\t\tconst result = redactDeepValue(item, opts, depth + 1, key);\n\t\t\tmatches.push(...result.matches);\n\t\t\tnext[key] = result.value;\n\t\t}\n\t\treturn { value: next, matches };\n\t}\n\n\treturn { value, matches: [] };\n}\n"],"mappings":"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"}
|
package/dist/scrub-secrets.cjs
CHANGED
|
@@ -27,6 +27,8 @@ const SECRET_VALUE_PATTERNS = [
|
|
|
27
27
|
/\bgithub_pat_[A-Za-z0-9_]{22,}/g,
|
|
28
28
|
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
29
29
|
/\b(?:bot)?\d{8,10}:[A-Za-z0-9_-]{35}\b/g,
|
|
30
|
+
/\b[MNO][A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}\b/g,
|
|
31
|
+
/\blin_(?:api|oauth)_[A-Za-z0-9]{20,}/g,
|
|
30
32
|
/(?<=:\/\/)[^\s:/@]+:[^\s:/@]+(?=@)/g,
|
|
31
33
|
new RegExp(`"(?:${SECRET_KEYS})"\\s*:\\s*"(?!\\[(?:redacted|REDACTED)(?::[^"\\]]*)?\\]")(?:[^"\\\\\\r\\n]|\\\\.)*"`, "gi"),
|
|
32
34
|
new RegExp(`'(?:${SECRET_KEYS})'\\s*:\\s*'(?!\\[(?:redacted|REDACTED)(?::[^'\\]]*)?\\]')(?:[^'\\\\\\r\\n]|\\\\.)*'`, "gi"),
|