@abhishekvardanbotta/mask 0.0.0-stage → 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 +21 -0
- package/README.md +142 -3
- package/dist/crypto.d.ts +23 -0
- package/dist/crypto.js +45 -0
- package/dist/detectors/recognizers.d.ts +24 -0
- package/dist/detectors/recognizers.js +274 -0
- package/dist/detectors/validators.d.ts +29 -0
- package/dist/detectors/validators.js +209 -0
- package/dist/fields.d.ts +39 -0
- package/dist/fields.js +219 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +8 -0
- package/dist/policy.d.ts +36 -0
- package/dist/policy.js +116 -0
- package/dist/privacy.d.ts +152 -0
- package/dist/privacy.js +596 -0
- package/dist/types.d.ts +98 -0
- package/dist/types.js +8 -0
- package/dist/vault.d.ts +30 -0
- package/dist/vault.js +29 -0
- package/package.json +52 -6
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { isIPv4, isIPv6 } from "node:net";
|
|
2
|
+
/** ISO/IEC 7812-1 Annex B (Luhn mod 10). */
|
|
3
|
+
export function luhnValid(digits) {
|
|
4
|
+
let sum = 0;
|
|
5
|
+
let double = false;
|
|
6
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
7
|
+
let d = digits.charCodeAt(i) - 48;
|
|
8
|
+
if (d < 0 || d > 9)
|
|
9
|
+
return false;
|
|
10
|
+
if (double) {
|
|
11
|
+
d *= 2;
|
|
12
|
+
if (d > 9)
|
|
13
|
+
d -= 9;
|
|
14
|
+
}
|
|
15
|
+
sum += d;
|
|
16
|
+
double = !double;
|
|
17
|
+
}
|
|
18
|
+
return digits.length > 0 && sum % 10 === 0;
|
|
19
|
+
}
|
|
20
|
+
/** Card brand from the issuer identification number (IIN) ranges published by the networks. Undefined if unknown. */
|
|
21
|
+
export function cardBrand(digits) {
|
|
22
|
+
const n2 = Number(digits.slice(0, 2));
|
|
23
|
+
const n3 = Number(digits.slice(0, 3));
|
|
24
|
+
const n4 = Number(digits.slice(0, 4));
|
|
25
|
+
const n6 = Number(digits.slice(0, 6));
|
|
26
|
+
const len = digits.length;
|
|
27
|
+
if (digits[0] === "4" && (len === 13 || len === 16 || len === 19))
|
|
28
|
+
return "visa";
|
|
29
|
+
if (((n2 >= 51 && n2 <= 55) || (n4 >= 2221 && n4 <= 2720)) && len === 16)
|
|
30
|
+
return "mastercard";
|
|
31
|
+
if ((n2 === 34 || n2 === 37) && len === 15)
|
|
32
|
+
return "amex";
|
|
33
|
+
if ((n4 === 6011 || n2 === 65 || (n3 >= 644 && n3 <= 649) || (n6 >= 622126 && n6 <= 622925)) && len >= 16 && len <= 19)
|
|
34
|
+
return "discover";
|
|
35
|
+
if (n4 >= 3528 && n4 <= 3589 && len >= 16 && len <= 19)
|
|
36
|
+
return "jcb";
|
|
37
|
+
if (((n3 >= 300 && n3 <= 305) || n2 === 36 || n2 === 38 || n2 === 39) && len >= 14 && len <= 19)
|
|
38
|
+
return "diners";
|
|
39
|
+
if ((n2 === 60 || n2 === 81 || n2 === 82 || n3 === 508) && len === 16)
|
|
40
|
+
return "rupay";
|
|
41
|
+
if ((n2 === 50 || (n2 >= 56 && n2 <= 69)) && len >= 12 && len <= 19)
|
|
42
|
+
return "maestro";
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
/** IBAN lengths per country, ISO 13616 registry (SWIFT IBAN Registry). */
|
|
46
|
+
const IBAN_LENGTHS = {
|
|
47
|
+
AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BR: 29, BY: 28, CH: 21, CR: 22, CY: 28,
|
|
48
|
+
CZ: 24, DE: 22, DK: 18, DO: 28, EE: 20, EG: 29, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23, GL: 18,
|
|
49
|
+
GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27, JO: 30, KW: 30, KZ: 20, LB: 28, LC: 32,
|
|
50
|
+
LI: 21, LT: 20, LU: 20, LV: 21, MC: 27, MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30, NL: 18, NO: 15, PK: 24,
|
|
51
|
+
PL: 28, PS: 29, PT: 25, QA: 29, RO: 24, RS: 22, SA: 24, SC: 31, SE: 24, SI: 19, SK: 24, SM: 27, ST: 25, SV: 28,
|
|
52
|
+
TL: 23, TN: 24, TR: 26, UA: 29, VA: 22, VG: 24, XK: 20,
|
|
53
|
+
};
|
|
54
|
+
/** ISO 13616 / ISO 7064 MOD 97-10: country length check plus the mod-97 checksum. */
|
|
55
|
+
export function ibanValid(raw) {
|
|
56
|
+
const iban = raw.replace(/\s+/g, "").toUpperCase();
|
|
57
|
+
if (!/^[A-Z]{2}\d{2}[A-Z0-9]+$/.test(iban))
|
|
58
|
+
return false;
|
|
59
|
+
if (IBAN_LENGTHS[iban.slice(0, 2)] !== iban.length)
|
|
60
|
+
return false;
|
|
61
|
+
const rearranged = iban.slice(4) + iban.slice(0, 4);
|
|
62
|
+
let remainder = 0;
|
|
63
|
+
for (const ch of rearranged) {
|
|
64
|
+
const value = ch >= "A" && ch <= "Z" ? String(ch.charCodeAt(0) - 55) : ch;
|
|
65
|
+
for (const digit of value)
|
|
66
|
+
remainder = (remainder * 10 + (digit.charCodeAt(0) - 48)) % 97;
|
|
67
|
+
}
|
|
68
|
+
return remainder === 1;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* US SSN structural rules (SSA): area not 000, 666 or 900-999; group not 00; serial not 0000. Also rejects the
|
|
72
|
+
* well-known advertising/test numbers that are frequently used as fake data.
|
|
73
|
+
*/
|
|
74
|
+
export function ssnValid(digits) {
|
|
75
|
+
if (!/^\d{9}$/.test(digits))
|
|
76
|
+
return false;
|
|
77
|
+
const area = Number(digits.slice(0, 3));
|
|
78
|
+
const group = Number(digits.slice(3, 5));
|
|
79
|
+
const serial = Number(digits.slice(5));
|
|
80
|
+
if (area === 0 || area === 666 || area >= 900 || group === 0 || serial === 0)
|
|
81
|
+
return false;
|
|
82
|
+
if (digits === "078051120" || digits === "219099999")
|
|
83
|
+
return false;
|
|
84
|
+
return !/^(\d)\1{8}$/.test(digits);
|
|
85
|
+
}
|
|
86
|
+
const VERHOEFF_D = [
|
|
87
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 2, 3, 4, 0, 6, 7, 8, 9, 5], [2, 3, 4, 0, 1, 7, 8, 9, 5, 6],
|
|
88
|
+
[3, 4, 0, 1, 2, 8, 9, 5, 6, 7], [4, 0, 1, 2, 3, 9, 5, 6, 7, 8], [5, 9, 8, 7, 6, 0, 4, 3, 2, 1],
|
|
89
|
+
[6, 5, 9, 8, 7, 1, 0, 4, 3, 2], [7, 6, 5, 9, 8, 2, 1, 0, 4, 3], [8, 7, 6, 5, 9, 3, 2, 1, 0, 4],
|
|
90
|
+
[9, 8, 7, 6, 5, 4, 3, 2, 1, 0],
|
|
91
|
+
];
|
|
92
|
+
const VERHOEFF_P = [
|
|
93
|
+
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9], [1, 5, 7, 6, 2, 8, 3, 0, 9, 4], [5, 8, 0, 3, 7, 9, 6, 1, 4, 2],
|
|
94
|
+
[8, 9, 1, 6, 0, 4, 3, 5, 2, 7], [9, 4, 5, 3, 1, 2, 6, 8, 7, 0], [4, 2, 8, 6, 5, 7, 3, 9, 0, 1],
|
|
95
|
+
[2, 7, 9, 3, 8, 0, 6, 4, 1, 5], [7, 0, 4, 6, 9, 1, 3, 2, 5, 8],
|
|
96
|
+
];
|
|
97
|
+
/** Verhoeff checksum — the check digit UIDAI uses for Aadhaar numbers. */
|
|
98
|
+
export function verhoeffValid(digits) {
|
|
99
|
+
let c = 0;
|
|
100
|
+
const reversed = [...digits].reverse();
|
|
101
|
+
for (let i = 0; i < reversed.length; i++)
|
|
102
|
+
c = VERHOEFF_D[c][VERHOEFF_P[i % 8][reversed[i].charCodeAt(0) - 48]];
|
|
103
|
+
return c === 0;
|
|
104
|
+
}
|
|
105
|
+
/** Aadhaar: 12 digits, first digit 2-9, valid Verhoeff check digit, not a repeated digit. */
|
|
106
|
+
export function aadhaarValid(digits) {
|
|
107
|
+
return /^[2-9]\d{11}$/.test(digits) && !/^(\d)\1{11}$/.test(digits) && verhoeffValid(digits);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* RFC 5321/5322 structural limits: local part at most 64 octets, address at most 254, no leading, trailing or
|
|
111
|
+
* doubled dots in the local part, each domain label 1-63 chars without edge hyphens, and an alphabetic TLD.
|
|
112
|
+
*/
|
|
113
|
+
export function emailValid(candidate) {
|
|
114
|
+
if (candidate.length > 254)
|
|
115
|
+
return false;
|
|
116
|
+
const at = candidate.lastIndexOf("@");
|
|
117
|
+
if (at <= 0)
|
|
118
|
+
return false;
|
|
119
|
+
const local = candidate.slice(0, at);
|
|
120
|
+
const domain = candidate.slice(at + 1);
|
|
121
|
+
if (local.length > 64 || local.startsWith(".") || local.endsWith(".") || local.includes(".."))
|
|
122
|
+
return false;
|
|
123
|
+
const labels = domain.split(".");
|
|
124
|
+
if (labels.length < 2)
|
|
125
|
+
return false;
|
|
126
|
+
for (const label of labels) {
|
|
127
|
+
if (!label || label.length > 63 || label.startsWith("-") || label.endsWith("-"))
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const tld = labels[labels.length - 1];
|
|
131
|
+
return /^[A-Za-z]{2,63}$/.test(tld) || /^xn--[A-Za-z0-9-]+$/.test(tld);
|
|
132
|
+
}
|
|
133
|
+
export function ipVersion(candidate) {
|
|
134
|
+
if (isIPv4(candidate))
|
|
135
|
+
return 4;
|
|
136
|
+
if (isIPv6(candidate))
|
|
137
|
+
return 6;
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
/** RFC 1918 private, loopback, link-local, CGNAT and unspecified IPv4 ranges — less identifying than public addresses. */
|
|
141
|
+
export function isNonPublicIPv4(ip) {
|
|
142
|
+
const [a, b] = ip.split(".").map(Number);
|
|
143
|
+
return a === 10 || a === 127 || a === 0 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) || (a === 100 && b >= 64 && b <= 127);
|
|
144
|
+
}
|
|
145
|
+
/** Shannon entropy in bits per character. */
|
|
146
|
+
export function shannonEntropy(text) {
|
|
147
|
+
if (!text)
|
|
148
|
+
return 0;
|
|
149
|
+
const counts = new Map();
|
|
150
|
+
for (const ch of text)
|
|
151
|
+
counts.set(ch, (counts.get(ch) ?? 0) + 1);
|
|
152
|
+
let entropy = 0;
|
|
153
|
+
for (const count of counts.values()) {
|
|
154
|
+
const p = count / text.length;
|
|
155
|
+
entropy -= p * Math.log2(p);
|
|
156
|
+
}
|
|
157
|
+
return entropy;
|
|
158
|
+
}
|
|
159
|
+
/** RFC 7519: a JWT's first segment is base64url JSON with an `alg` member. */
|
|
160
|
+
export function jwtValid(candidate) {
|
|
161
|
+
const [header] = candidate.split(".");
|
|
162
|
+
try {
|
|
163
|
+
const decoded = JSON.parse(Buffer.from(header, "base64url").toString("utf8"));
|
|
164
|
+
return typeof decoded === "object" && decoded !== null && "alg" in decoded;
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
171
|
+
/** Parses the common written date shapes; returns undefined for anything that is not a real calendar date. */
|
|
172
|
+
export function parseDate(text) {
|
|
173
|
+
let y, m, d;
|
|
174
|
+
let match;
|
|
175
|
+
if ((match = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/))) {
|
|
176
|
+
y = +match[1];
|
|
177
|
+
m = +match[2];
|
|
178
|
+
d = +match[3];
|
|
179
|
+
}
|
|
180
|
+
else if ((match = text.match(/^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$/))) {
|
|
181
|
+
// Ambiguous DD/MM vs MM/DD: accept if either reading is a real date.
|
|
182
|
+
y = +match[3];
|
|
183
|
+
const a = +match[1], b = +match[2];
|
|
184
|
+
if (a > 12) {
|
|
185
|
+
d = a;
|
|
186
|
+
m = b;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
m = a;
|
|
190
|
+
d = b;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
else if ((match = text.match(/^(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]{3,9})\.?,?\s+(\d{4})$/))) {
|
|
194
|
+
d = +match[1];
|
|
195
|
+
m = MONTHS.indexOf(match[2].slice(0, 3).toLowerCase()) + 1;
|
|
196
|
+
y = +match[3];
|
|
197
|
+
}
|
|
198
|
+
else if ((match = text.match(/^([A-Za-z]{3,9})\.?\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{4})$/))) {
|
|
199
|
+
m = MONTHS.indexOf(match[1].slice(0, 3).toLowerCase()) + 1;
|
|
200
|
+
d = +match[2];
|
|
201
|
+
y = +match[3];
|
|
202
|
+
}
|
|
203
|
+
else
|
|
204
|
+
return undefined;
|
|
205
|
+
if (m < 1 || m > 12 || d < 1 || d > 31 || y < 1000)
|
|
206
|
+
return undefined;
|
|
207
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
208
|
+
return date.getUTCMonth() === m - 1 && date.getUTCDate() === d ? date : undefined;
|
|
209
|
+
}
|
package/dist/fields.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Classification, EntityType, FieldClass } from "./types.js";
|
|
2
|
+
export declare function isClassification(value: unknown): value is Classification;
|
|
3
|
+
/** Splits `billingEmail`, `billing_email`, `billing-email`, `BillingEMAIL` into lowercase words. */
|
|
4
|
+
export declare function keyWords(key: string): string[];
|
|
5
|
+
/** Infers what a field holds from its key (and, for a bare `name`, its parent key). Undefined if it cannot tell. */
|
|
6
|
+
export declare function inferFieldType(key: string, parentKey?: string): EntityType | undefined;
|
|
7
|
+
/** `medicalHistory` -> `MEDICAL_HISTORY`, for custom types derived from a field's own name. */
|
|
8
|
+
export declare function typeFromKey(key: string): EntityType;
|
|
9
|
+
export interface CompiledFieldRule {
|
|
10
|
+
pattern: string;
|
|
11
|
+
segments: string[];
|
|
12
|
+
value: FieldClass;
|
|
13
|
+
}
|
|
14
|
+
export declare function compileFieldRules(fields: Record<string, Classification | FieldClass> | undefined): CompiledFieldRule[];
|
|
15
|
+
export declare function matchFieldRule(rules: readonly CompiledFieldRule[], path: string[]): FieldClass | undefined;
|
|
16
|
+
type SchemaNode = Record<string, unknown>;
|
|
17
|
+
declare function asNode(value: unknown): SchemaNode | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Reads a schema node's own privacy annotation, in order of precedence:
|
|
20
|
+
* 1. `"x-privacy": "SENSITIVE"` or `"x-privacy": { "classification": "SENSITIVE", "type": "HEALTH" }` (plus `"x-pii-type"`)
|
|
21
|
+
* 2. a `privacy:CLASS[:TYPE]` tag in `description` — what zod's `.describe("privacy:PII:EMAIL")` produces
|
|
22
|
+
* 3. standard JSON Schema `format`: `email`/`idn-email` -> EMAIL, `ipv4`/`ipv6` -> IP_ADDRESS
|
|
23
|
+
* The classification of a format-derived type comes from the policy, so it is returned without one.
|
|
24
|
+
*/
|
|
25
|
+
export declare function schemaAnnotation(node: SchemaNode | undefined): (Partial<FieldClass> & {
|
|
26
|
+
type?: EntityType;
|
|
27
|
+
}) | undefined;
|
|
28
|
+
/** Resolves local `$ref`s (`#/definitions/...`, `#/$defs/...`) against the root schema. */
|
|
29
|
+
export declare function resolveRef(root: SchemaNode, node: SchemaNode | undefined, depth?: number): SchemaNode | undefined;
|
|
30
|
+
/** The subschema for property `key` of an object node. */
|
|
31
|
+
export declare function propertySchema(root: SchemaNode, node: SchemaNode | undefined, key: string): SchemaNode | undefined;
|
|
32
|
+
/** The subschema for element `index` of an array node. */
|
|
33
|
+
export declare function itemSchema(root: SchemaNode, node: SchemaNode | undefined, index: number): SchemaNode | undefined;
|
|
34
|
+
/** A node's annotation, looking through allOf/anyOf/oneOf (zod-to-json-schema wraps `.optional()`/`.nullable()` that way). */
|
|
35
|
+
export declare function nodeAnnotation(root: SchemaNode, node: SchemaNode | undefined): (Partial<FieldClass> & {
|
|
36
|
+
type?: EntityType;
|
|
37
|
+
}) | undefined;
|
|
38
|
+
export { asNode };
|
|
39
|
+
export type { SchemaNode };
|
package/dist/fields.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
const CLASSIFICATIONS = new Set(["PII", "SENSITIVE", "PUBLIC", "BLOCK"]);
|
|
2
|
+
export function isClassification(value) {
|
|
3
|
+
return typeof value === "string" && CLASSIFICATIONS.has(value);
|
|
4
|
+
}
|
|
5
|
+
/** Splits `billingEmail`, `billing_email`, `billing-email`, `BillingEMAIL` into lowercase words. */
|
|
6
|
+
export function keyWords(key) {
|
|
7
|
+
return key
|
|
8
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
9
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
10
|
+
.split(/[^A-Za-z0-9]+/)
|
|
11
|
+
.filter(Boolean)
|
|
12
|
+
.map((w) => w.toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Field-name vocabulary: the joined word sequence a key ends with, mapped to the entity type it names. Matching is
|
|
16
|
+
* on the key's trailing words, so `customerEmail` and `billing_email` match `email` while `emailVerified`,
|
|
17
|
+
* `emailCount` and `phoneType` do not — the last word decides what the field holds.
|
|
18
|
+
*/
|
|
19
|
+
const FIELD_VOCABULARY = {
|
|
20
|
+
email: "EMAIL", emailaddress: "EMAIL", mail: "EMAIL", emailid: "EMAIL",
|
|
21
|
+
phone: "PHONE", phonenumber: "PHONE", phoneno: "PHONE", mobile: "PHONE", mobilenumber: "PHONE",
|
|
22
|
+
cellphone: "PHONE", telephone: "PHONE", tel: "PHONE", msisdn: "PHONE", whatsapp: "PHONE", fax: "PHONE",
|
|
23
|
+
firstname: "PERSON", lastname: "PERSON", fullname: "PERSON", givenname: "PERSON", familyname: "PERSON", surname: "PERSON",
|
|
24
|
+
middlename: "PERSON", maidenname: "PERSON", legalname: "PERSON", displayname: "PERSON", nickname: "PERSON",
|
|
25
|
+
username: "USERNAME", screenname: "USERNAME",
|
|
26
|
+
address: "ADDRESS", streetaddress: "ADDRESS", street: "ADDRESS", addressline: "ADDRESS", addressline1: "ADDRESS",
|
|
27
|
+
addressline2: "ADDRESS", postalcode: "ADDRESS", postcode: "ADDRESS", zipcode: "ADDRESS", zip: "ADDRESS", pincode: "ADDRESS",
|
|
28
|
+
latitude: "LOCATION", longitude: "LOCATION", lat: "LOCATION", lng: "LOCATION", lon: "LOCATION", geolocation: "LOCATION", coordinates: "LOCATION",
|
|
29
|
+
dob: "DATE_OF_BIRTH", dateofbirth: "DATE_OF_BIRTH", birthdate: "DATE_OF_BIRTH", birthday: "DATE_OF_BIRTH",
|
|
30
|
+
ip: "IP_ADDRESS", ipaddress: "IP_ADDRESS", ipaddr: "IP_ADDRESS", remoteaddr: "IP_ADDRESS", remoteaddress: "IP_ADDRESS",
|
|
31
|
+
creditcard: "CREDIT_CARD", cardnumber: "CREDIT_CARD", ccnumber: "CREDIT_CARD", cardno: "CREDIT_CARD", pan: "CREDIT_CARD",
|
|
32
|
+
cvv: "CARD_SECURITY_CODE", cvc: "CARD_SECURITY_CODE", cvv2: "CARD_SECURITY_CODE", securitycode: "CARD_SECURITY_CODE", cardpin: "CARD_SECURITY_CODE",
|
|
33
|
+
iban: "IBAN", bankaccount: "BANK_ACCOUNT", accountnumber: "BANK_ACCOUNT", bankaccountnumber: "BANK_ACCOUNT", routingnumber: "BANK_ACCOUNT", sortcode: "BANK_ACCOUNT", ifsc: "BANK_ACCOUNT",
|
|
34
|
+
ssn: "US_SSN", socialsecuritynumber: "US_SSN", socialsecurity: "US_SSN",
|
|
35
|
+
aadhaar: "IN_AADHAAR", aadhar: "IN_AADHAAR", aadhaarnumber: "IN_AADHAAR", pannumber: "IN_PAN",
|
|
36
|
+
nationalid: "NATIONAL_ID", nationalidnumber: "NATIONAL_ID", taxid: "NATIONAL_ID", tin: "NATIONAL_ID", nin: "NATIONAL_ID", sin: "NATIONAL_ID", nric: "NATIONAL_ID",
|
|
37
|
+
passport: "PASSPORT", passportnumber: "PASSPORT", passportno: "PASSPORT",
|
|
38
|
+
driverslicense: "DRIVERS_LICENSE", driverlicense: "DRIVERS_LICENSE", drivinglicense: "DRIVERS_LICENSE", licensenumber: "DRIVERS_LICENSE",
|
|
39
|
+
diagnosis: "HEALTH", diagnoses: "HEALTH", medicalhistory: "HEALTH", medicalrecord: "HEALTH", medication: "HEALTH", medications: "HEALTH",
|
|
40
|
+
prescription: "HEALTH", allergies: "HEALTH", allergy: "HEALTH", bloodtype: "HEALTH", disability: "HEALTH", healthcondition: "HEALTH",
|
|
41
|
+
symptoms: "HEALTH",
|
|
42
|
+
religion: "SPECIAL_CATEGORY", ethnicity: "SPECIAL_CATEGORY", race: "SPECIAL_CATEGORY", sexualorientation: "SPECIAL_CATEGORY",
|
|
43
|
+
politicalaffiliation: "SPECIAL_CATEGORY", politicalopinion: "SPECIAL_CATEGORY", tradeunion: "SPECIAL_CATEGORY",
|
|
44
|
+
biometric: "SPECIAL_CATEGORY", biometricdata: "SPECIAL_CATEGORY", genetic: "SPECIAL_CATEGORY", geneticdata: "SPECIAL_CATEGORY",
|
|
45
|
+
password: "CREDENTIAL", passwd: "CREDENTIAL", passphrase: "CREDENTIAL", secret: "CREDENTIAL", clientsecret: "CREDENTIAL",
|
|
46
|
+
apikey: "CREDENTIAL", apisecret: "CREDENTIAL", accesstoken: "CREDENTIAL", refreshtoken: "CREDENTIAL", idtoken: "CREDENTIAL",
|
|
47
|
+
authtoken: "CREDENTIAL", bearertoken: "CREDENTIAL", sessiontoken: "CREDENTIAL", privatekey: "CREDENTIAL", accesskey: "CREDENTIAL",
|
|
48
|
+
secretkey: "CREDENTIAL", otp: "CREDENTIAL", pin: "CREDENTIAL",
|
|
49
|
+
};
|
|
50
|
+
/** Parent keys that make a bare `name` field a person's name (`customer.name`, `patients[].name`). */
|
|
51
|
+
const PERSON_CONTAINERS = new Set([
|
|
52
|
+
"customer", "customers", "user", "users", "patient", "patients", "person", "people", "persons", "contact", "contacts",
|
|
53
|
+
"employee", "employees", "member", "members", "client", "clients", "owner", "recipient", "sender", "author", "applicant",
|
|
54
|
+
"holder", "cardholder", "guest", "passenger", "student", "candidate", "beneficiary", "account", "profile", "lead", "buyer", "seller",
|
|
55
|
+
]);
|
|
56
|
+
/** Words that, placed before `name`, make it a person's name (`customerName`, `patient_name`). */
|
|
57
|
+
const PERSON_QUALIFIERS = new Set([...PERSON_CONTAINERS, "first", "last", "full", "given", "family", "middle", "maiden", "legal", "contact", "billing", "shipping", "emergency"]);
|
|
58
|
+
/** Infers what a field holds from its key (and, for a bare `name`, its parent key). Undefined if it cannot tell. */
|
|
59
|
+
export function inferFieldType(key, parentKey) {
|
|
60
|
+
const words = keyWords(key);
|
|
61
|
+
if (!words.length)
|
|
62
|
+
return undefined;
|
|
63
|
+
for (let i = 0; i < words.length; i++) {
|
|
64
|
+
const tail = words.slice(i).join("");
|
|
65
|
+
const type = FIELD_VOCABULARY[tail];
|
|
66
|
+
if (type)
|
|
67
|
+
return type;
|
|
68
|
+
}
|
|
69
|
+
if (words[words.length - 1] === "name") {
|
|
70
|
+
if (words.length > 1 && PERSON_QUALIFIERS.has(words[words.length - 2]))
|
|
71
|
+
return "PERSON";
|
|
72
|
+
if (words.length === 1 && parentKey !== undefined) {
|
|
73
|
+
const parentWords = keyWords(parentKey);
|
|
74
|
+
if (parentWords.length && PERSON_CONTAINERS.has(parentWords[parentWords.length - 1]))
|
|
75
|
+
return "PERSON";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
/** `medicalHistory` -> `MEDICAL_HISTORY`, for custom types derived from a field's own name. */
|
|
81
|
+
export function typeFromKey(key) {
|
|
82
|
+
const words = keyWords(key);
|
|
83
|
+
return (words.length ? words.join("_") : "FIELD").toUpperCase();
|
|
84
|
+
}
|
|
85
|
+
export function compileFieldRules(fields) {
|
|
86
|
+
const rules = [];
|
|
87
|
+
for (const [pattern, raw] of Object.entries(fields ?? {})) {
|
|
88
|
+
const value = typeof raw === "string" ? { classification: raw } : raw;
|
|
89
|
+
if (!isClassification(value.classification))
|
|
90
|
+
throw new TypeError(`mask: field "${pattern}" has an invalid classification ${JSON.stringify(value.classification)}`);
|
|
91
|
+
const segments = pattern.split(".").filter(Boolean);
|
|
92
|
+
// A bare key ("ssn") means that key at any depth.
|
|
93
|
+
rules.push({ pattern, segments: segments.length === 1 && segments[0] !== "**" ? ["**", segments[0]] : segments, value });
|
|
94
|
+
}
|
|
95
|
+
// More specific (longer, fewer wildcards) rules first.
|
|
96
|
+
return rules.sort((a, b) => specificity(b.segments) - specificity(a.segments));
|
|
97
|
+
}
|
|
98
|
+
function specificity(segments) {
|
|
99
|
+
return segments.reduce((n, s) => n + (s === "**" ? 0 : s === "*" ? 1 : 3), 0);
|
|
100
|
+
}
|
|
101
|
+
function matchSegments(pattern, path, pi = 0, si = 0) {
|
|
102
|
+
if (pi === pattern.length)
|
|
103
|
+
return si === path.length;
|
|
104
|
+
const seg = pattern[pi];
|
|
105
|
+
if (seg === "**") {
|
|
106
|
+
for (let k = si; k <= path.length; k++)
|
|
107
|
+
if (matchSegments(pattern, path, pi + 1, k))
|
|
108
|
+
return true;
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (si === path.length)
|
|
112
|
+
return false;
|
|
113
|
+
return (seg === "*" || seg === path[si]) && matchSegments(pattern, path, pi + 1, si + 1);
|
|
114
|
+
}
|
|
115
|
+
export function matchFieldRule(rules, path) {
|
|
116
|
+
for (const rule of rules)
|
|
117
|
+
if (matchSegments(rule.segments, path))
|
|
118
|
+
return rule.value;
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
function asNode(value) {
|
|
122
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
123
|
+
}
|
|
124
|
+
const DESCRIPTION_TAG = /\bprivacy\s*:\s*(PII|SENSITIVE|PUBLIC|BLOCK)(?:\s*:\s*([A-Za-z][A-Za-z0-9_]*))?/i;
|
|
125
|
+
/**
|
|
126
|
+
* Reads a schema node's own privacy annotation, in order of precedence:
|
|
127
|
+
* 1. `"x-privacy": "SENSITIVE"` or `"x-privacy": { "classification": "SENSITIVE", "type": "HEALTH" }` (plus `"x-pii-type"`)
|
|
128
|
+
* 2. a `privacy:CLASS[:TYPE]` tag in `description` — what zod's `.describe("privacy:PII:EMAIL")` produces
|
|
129
|
+
* 3. standard JSON Schema `format`: `email`/`idn-email` -> EMAIL, `ipv4`/`ipv6` -> IP_ADDRESS
|
|
130
|
+
* The classification of a format-derived type comes from the policy, so it is returned without one.
|
|
131
|
+
*/
|
|
132
|
+
export function schemaAnnotation(node) {
|
|
133
|
+
if (!node)
|
|
134
|
+
return undefined;
|
|
135
|
+
const ext = node["x-privacy"];
|
|
136
|
+
const extType = typeof node["x-pii-type"] === "string" ? node["x-pii-type"].toUpperCase() : undefined;
|
|
137
|
+
if (isClassification(ext))
|
|
138
|
+
return { classification: ext, type: extType };
|
|
139
|
+
if (ext && typeof ext === "object" && isClassification(ext.classification)) {
|
|
140
|
+
const declared = ext.type;
|
|
141
|
+
return { classification: ext.classification, type: declared ? declared.toUpperCase() : extType };
|
|
142
|
+
}
|
|
143
|
+
if (typeof node.description === "string") {
|
|
144
|
+
const tag = DESCRIPTION_TAG.exec(node.description);
|
|
145
|
+
if (tag)
|
|
146
|
+
return { classification: tag[1].toUpperCase(), type: tag[2]?.toUpperCase() ?? extType };
|
|
147
|
+
}
|
|
148
|
+
if (node.format === "email" || node.format === "idn-email")
|
|
149
|
+
return { type: "EMAIL" };
|
|
150
|
+
if (node.format === "ipv4" || node.format === "ipv6")
|
|
151
|
+
return { type: "IP_ADDRESS" };
|
|
152
|
+
if (extType)
|
|
153
|
+
return { type: extType };
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
/** Resolves local `$ref`s (`#/definitions/...`, `#/$defs/...`) against the root schema. */
|
|
157
|
+
export function resolveRef(root, node, depth = 0) {
|
|
158
|
+
if (!node || depth > 32)
|
|
159
|
+
return node;
|
|
160
|
+
const ref = node.$ref;
|
|
161
|
+
if (typeof ref !== "string" || !ref.startsWith("#"))
|
|
162
|
+
return node;
|
|
163
|
+
let target = root;
|
|
164
|
+
for (const part of ref.slice(1).split("/").filter(Boolean)) {
|
|
165
|
+
target = asNode(target)?.[part.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
166
|
+
}
|
|
167
|
+
return resolveRef(root, asNode(target), depth + 1);
|
|
168
|
+
}
|
|
169
|
+
/** The variants a node allows — itself plus its allOf/anyOf/oneOf members, refs resolved. */
|
|
170
|
+
function variants(root, node) {
|
|
171
|
+
const resolved = resolveRef(root, node);
|
|
172
|
+
if (!resolved)
|
|
173
|
+
return [];
|
|
174
|
+
const out = [resolved];
|
|
175
|
+
for (const key of ["allOf", "anyOf", "oneOf"]) {
|
|
176
|
+
const list = resolved[key];
|
|
177
|
+
if (Array.isArray(list))
|
|
178
|
+
for (const member of list)
|
|
179
|
+
out.push(...variants(root, asNode(member)));
|
|
180
|
+
}
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
/** The subschema for property `key` of an object node. */
|
|
184
|
+
export function propertySchema(root, node, key) {
|
|
185
|
+
for (const variant of variants(root, node)) {
|
|
186
|
+
const props = asNode(variant.properties);
|
|
187
|
+
if (props && key in props)
|
|
188
|
+
return resolveRef(root, asNode(props[key]));
|
|
189
|
+
}
|
|
190
|
+
for (const variant of variants(root, node)) {
|
|
191
|
+
const extra = asNode(variant.additionalProperties);
|
|
192
|
+
if (extra)
|
|
193
|
+
return resolveRef(root, extra);
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
/** The subschema for element `index` of an array node. */
|
|
198
|
+
export function itemSchema(root, node, index) {
|
|
199
|
+
for (const variant of variants(root, node)) {
|
|
200
|
+
if (Array.isArray(variant.prefixItems) && index < variant.prefixItems.length)
|
|
201
|
+
return resolveRef(root, asNode(variant.prefixItems[index]));
|
|
202
|
+
if (Array.isArray(variant.items))
|
|
203
|
+
return resolveRef(root, asNode(variant.items[index]));
|
|
204
|
+
const items = asNode(variant.items);
|
|
205
|
+
if (items)
|
|
206
|
+
return resolveRef(root, items);
|
|
207
|
+
}
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
/** A node's annotation, looking through allOf/anyOf/oneOf (zod-to-json-schema wraps `.optional()`/`.nullable()` that way). */
|
|
211
|
+
export function nodeAnnotation(root, node) {
|
|
212
|
+
for (const variant of variants(root, node)) {
|
|
213
|
+
const annotation = schemaAnnotation(variant);
|
|
214
|
+
if (annotation)
|
|
215
|
+
return annotation;
|
|
216
|
+
}
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
export { asNode };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { createPrivacy, Privacy, PrivacySession, StreamRestorer } from "./privacy.js";
|
|
2
|
+
export type { PrivacyOptions } from "./privacy.js";
|
|
3
|
+
export { DEFAULT_POLICIES, compilePolicy, isGranted, maskValue, mergePolicyConfig } from "./policy.js";
|
|
4
|
+
export type { CompiledPolicy } from "./policy.js";
|
|
5
|
+
export { BUILT_IN_RECOGNIZERS } from "./detectors/recognizers.js";
|
|
6
|
+
export type { Recognizer } from "./detectors/recognizers.js";
|
|
7
|
+
export { aadhaarValid, cardBrand, emailValid, ibanValid, luhnValid, shannonEntropy, ssnValid, verhoeffValid } from "./detectors/validators.js";
|
|
8
|
+
export { inferFieldType } from "./fields.js";
|
|
9
|
+
export { generateKey } from "./crypto.js";
|
|
10
|
+
export { InMemoryVaultStore } from "./vault.js";
|
|
11
|
+
export type { VaultRecord, VaultStore } from "./vault.js";
|
|
12
|
+
export { PrivacyError } from "./types.js";
|
|
13
|
+
export type { AuditEvent, BuiltInEntityType, Classification, DeniedMode, Detection, Detector, EntityType, FieldClass, Grant, PolicyConfig, PrivacySummary, ProtectOptions, RestoreOptions, } from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createPrivacy, Privacy, PrivacySession, StreamRestorer } from "./privacy.js";
|
|
2
|
+
export { DEFAULT_POLICIES, compilePolicy, isGranted, maskValue, mergePolicyConfig } from "./policy.js";
|
|
3
|
+
export { BUILT_IN_RECOGNIZERS } from "./detectors/recognizers.js";
|
|
4
|
+
export { aadhaarValid, cardBrand, emailValid, ibanValid, luhnValid, shannonEntropy, ssnValid, verhoeffValid } from "./detectors/validators.js";
|
|
5
|
+
export { inferFieldType } from "./fields.js";
|
|
6
|
+
export { generateKey } from "./crypto.js";
|
|
7
|
+
export { InMemoryVaultStore } from "./vault.js";
|
|
8
|
+
export { PrivacyError } from "./types.js";
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type Recognizer } from "./detectors/recognizers.js";
|
|
2
|
+
import { type CompiledFieldRule } from "./fields.js";
|
|
3
|
+
import type { Classification, DeniedMode, Detector, EntityType, Grant, PolicyConfig } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Default handling per entity type, following NIST SP 800-122's confidentiality-impact guidance, GDPR Art. 9
|
|
6
|
+
* special categories, and PCI DSS v4.0:
|
|
7
|
+
* - direct identifiers and contact data -> PII
|
|
8
|
+
* - government IDs, financial accounts, card numbers, health and Art. 9 data -> SENSITIVE
|
|
9
|
+
* - card security codes (sensitive authentication data, which PCI DSS 3.3.1 forbids storing) and credentials -> BLOCK
|
|
10
|
+
* Types not listed (custom ones) default to PII.
|
|
11
|
+
*/
|
|
12
|
+
export declare const DEFAULT_POLICIES: Readonly<Record<string, Classification>>;
|
|
13
|
+
export interface CompiledPolicy {
|
|
14
|
+
readonly threshold: number;
|
|
15
|
+
readonly recognizers: readonly Recognizer[];
|
|
16
|
+
readonly detectors: readonly Detector[];
|
|
17
|
+
readonly fieldRules: readonly CompiledFieldRule[];
|
|
18
|
+
readonly inferFromFieldNames: boolean;
|
|
19
|
+
readonly onBlock: "redact" | "throw";
|
|
20
|
+
classify(type: EntityType): Classification;
|
|
21
|
+
allowlisted(value: string): boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Layers `override` on top of `base`: maps merge, lists concatenate, scalars replace. */
|
|
24
|
+
export declare function mergePolicyConfig(base?: PolicyConfig, override?: PolicyConfig): PolicyConfig;
|
|
25
|
+
export declare function compilePolicy(config?: PolicyConfig): CompiledPolicy;
|
|
26
|
+
/**
|
|
27
|
+
* Whether a principal holding `grants` may see a value. `*` covers PII only: SENSITIVE always needs an explicit
|
|
28
|
+
* `SENSITIVE` or entity-type grant, so a broad grant can never silently widen to high-impact data.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isGranted(grants: readonly Grant[] | undefined, type: EntityType, classification: Classification): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Display masks. Card numbers show at most the last four digits (PCI DSS 3.4.1 permits up to BIN + last four);
|
|
33
|
+
* Aadhaar follows UIDAI's masked-Aadhaar format; anything without a well-known mask is fully redacted.
|
|
34
|
+
*/
|
|
35
|
+
export declare function maskValue(type: EntityType, value: string): string;
|
|
36
|
+
export declare function deniedReplacement(mode: DeniedMode, type: EntityType, value: string, token: string): string;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { BUILT_IN_RECOGNIZERS } from "./detectors/recognizers.js";
|
|
2
|
+
import { compileFieldRules, isClassification } from "./fields.js";
|
|
3
|
+
import { PrivacyError } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* Default handling per entity type, following NIST SP 800-122's confidentiality-impact guidance, GDPR Art. 9
|
|
6
|
+
* special categories, and PCI DSS v4.0:
|
|
7
|
+
* - direct identifiers and contact data -> PII
|
|
8
|
+
* - government IDs, financial accounts, card numbers, health and Art. 9 data -> SENSITIVE
|
|
9
|
+
* - card security codes (sensitive authentication data, which PCI DSS 3.3.1 forbids storing) and credentials -> BLOCK
|
|
10
|
+
* Types not listed (custom ones) default to PII.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_POLICIES = Object.freeze({
|
|
13
|
+
EMAIL: "PII", PHONE: "PII", PERSON: "PII", ADDRESS: "PII", LOCATION: "PII", DATE_OF_BIRTH: "PII", IP_ADDRESS: "PII", USERNAME: "PII",
|
|
14
|
+
CREDIT_CARD: "SENSITIVE", IBAN: "SENSITIVE", BANK_ACCOUNT: "SENSITIVE", US_SSN: "SENSITIVE", IN_AADHAAR: "SENSITIVE", IN_PAN: "SENSITIVE",
|
|
15
|
+
NATIONAL_ID: "SENSITIVE", PASSPORT: "SENSITIVE", DRIVERS_LICENSE: "SENSITIVE", HEALTH: "SENSITIVE", SPECIAL_CATEGORY: "SENSITIVE",
|
|
16
|
+
CARD_SECURITY_CODE: "BLOCK", CREDENTIAL: "BLOCK",
|
|
17
|
+
});
|
|
18
|
+
const TYPE_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
19
|
+
/** Layers `override` on top of `base`: maps merge, lists concatenate, scalars replace. */
|
|
20
|
+
export function mergePolicyConfig(base = {}, override = {}) {
|
|
21
|
+
return {
|
|
22
|
+
policies: { ...base.policies, ...override.policies },
|
|
23
|
+
fields: { ...base.fields, ...override.fields },
|
|
24
|
+
detectors: [...(base.detectors ?? []), ...(override.detectors ?? [])],
|
|
25
|
+
disable: [...(base.disable ?? []), ...(override.disable ?? [])],
|
|
26
|
+
threshold: override.threshold ?? base.threshold,
|
|
27
|
+
inferFromFieldNames: override.inferFromFieldNames ?? base.inferFromFieldNames,
|
|
28
|
+
allowlist: [...(base.allowlist ?? []), ...(override.allowlist ?? [])],
|
|
29
|
+
onBlock: override.onBlock ?? base.onBlock,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export function compilePolicy(config = {}) {
|
|
33
|
+
const threshold = config.threshold ?? 0.5;
|
|
34
|
+
if (!(threshold >= 0 && threshold <= 1))
|
|
35
|
+
throw new PrivacyError(`mask: threshold must be within [0, 1] (got ${threshold}).`, "config");
|
|
36
|
+
const policies = { ...DEFAULT_POLICIES };
|
|
37
|
+
for (const [type, classification] of Object.entries(config.policies ?? {})) {
|
|
38
|
+
if (!TYPE_NAME.test(type))
|
|
39
|
+
throw new PrivacyError(`mask: entity type "${type}" must be UPPER_SNAKE_CASE.`, "config");
|
|
40
|
+
if (!isClassification(classification))
|
|
41
|
+
throw new PrivacyError(`mask: "${type}" has an invalid classification ${JSON.stringify(classification)}.`, "config");
|
|
42
|
+
policies[type] = classification;
|
|
43
|
+
}
|
|
44
|
+
const disabled = new Set(config.disable ?? []);
|
|
45
|
+
const known = new Set(BUILT_IN_RECOGNIZERS.map((r) => r.name));
|
|
46
|
+
for (const name of disabled)
|
|
47
|
+
if (!known.has(name))
|
|
48
|
+
throw new PrivacyError(`mask: no built-in recognizer named "${name}". Known: ${[...known].join(", ")}.`, "config");
|
|
49
|
+
const exact = new Set();
|
|
50
|
+
const patterns = [];
|
|
51
|
+
for (const entry of config.allowlist ?? [])
|
|
52
|
+
(typeof entry === "string" ? exact.add(entry) : patterns.push(entry));
|
|
53
|
+
let fieldRules;
|
|
54
|
+
try {
|
|
55
|
+
fieldRules = compileFieldRules(config.fields);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
throw new PrivacyError(error.message, "config");
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
threshold,
|
|
62
|
+
recognizers: BUILT_IN_RECOGNIZERS.filter((r) => !disabled.has(r.name)),
|
|
63
|
+
detectors: config.detectors ?? [],
|
|
64
|
+
fieldRules,
|
|
65
|
+
inferFromFieldNames: config.inferFromFieldNames ?? true,
|
|
66
|
+
onBlock: config.onBlock ?? "redact",
|
|
67
|
+
classify: (type) => policies[type] ?? "PII",
|
|
68
|
+
allowlisted: (value) => exact.has(value) || patterns.some((re) => { re.lastIndex = 0; return re.test(value); }),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether a principal holding `grants` may see a value. `*` covers PII only: SENSITIVE always needs an explicit
|
|
73
|
+
* `SENSITIVE` or entity-type grant, so a broad grant can never silently widen to high-impact data.
|
|
74
|
+
*/
|
|
75
|
+
export function isGranted(grants, type, classification) {
|
|
76
|
+
if (!grants?.length || classification === "BLOCK")
|
|
77
|
+
return false;
|
|
78
|
+
for (const grant of grants) {
|
|
79
|
+
if (grant === type || grant === classification)
|
|
80
|
+
return true;
|
|
81
|
+
if (grant === "*" && classification === "PII")
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Display masks. Card numbers show at most the last four digits (PCI DSS 3.4.1 permits up to BIN + last four);
|
|
88
|
+
* Aadhaar follows UIDAI's masked-Aadhaar format; anything without a well-known mask is fully redacted.
|
|
89
|
+
*/
|
|
90
|
+
export function maskValue(type, value) {
|
|
91
|
+
const digits = value.replace(/\D/g, "");
|
|
92
|
+
switch (type) {
|
|
93
|
+
case "EMAIL": {
|
|
94
|
+
const at = value.lastIndexOf("@");
|
|
95
|
+
return at > 0 ? `${value[0]}***@${value.slice(at + 1)}` : "***";
|
|
96
|
+
}
|
|
97
|
+
case "PHONE": return digits.length >= 6 ? `***-***-${digits.slice(-4)}` : "***";
|
|
98
|
+
case "CREDIT_CARD": return `**** **** **** ${digits.slice(-4)}`;
|
|
99
|
+
case "US_SSN": return `***-**-${digits.slice(-4)}`;
|
|
100
|
+
case "IN_AADHAAR": return `XXXX XXXX ${digits.slice(-4)}`;
|
|
101
|
+
case "IBAN": {
|
|
102
|
+
const compact = value.replace(/\s+/g, "");
|
|
103
|
+
return `${compact.slice(0, 2)}** **** ${compact.slice(-4)}`;
|
|
104
|
+
}
|
|
105
|
+
case "BANK_ACCOUNT": return digits.length >= 4 ? `****${digits.slice(-4)}` : "****";
|
|
106
|
+
case "PERSON": return value.split(/\s+/).filter(Boolean).map((part) => `${part[0]}.`).join(" ");
|
|
107
|
+
default: return `[REDACTED:${type}]`;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
export function deniedReplacement(mode, type, value, token) {
|
|
111
|
+
if (mode === "token")
|
|
112
|
+
return `[${token}]`;
|
|
113
|
+
if (mode === "mask")
|
|
114
|
+
return maskValue(type, value);
|
|
115
|
+
return `[REDACTED:${type}]`;
|
|
116
|
+
}
|