fix: fall back to vCard prePath in redactionFields and consolidate privacy phrase lists

- Extend `redactionFields(name, prePath?)` to parse the vCard property from an RFC 9537 `prePath` expression (e.g. `[?(@[0]=='email')]`) when the human-readable name alone doesn't identify fields; add `ADR_INDEX_FIELDS` to narrow a specific address element (`[3][N]`) to the right `ContactField`
- Pass `r.prePath` through in `extractContacts` so redactions with generic names (e.g. `"Redacted"`) still populate `redactedFields` correctly
- Split `PRIVACY_STRONG_KEYWORDS` source into `REDACTION_PHRASES` (text that stands in for a withheld value) and `PRIVACY_SERVICE_PHRASES` (proxy/shield names); derive `PLACEHOLDER_VALUE_PATTERNS` entries from `REDACTION_PHRASES` with `\b…\b` anchoring so the two lists can't drift apart
- Drop `!!registrant?.redacted` from the `privacyEnabled` check in `normalizeRdap`; `isPrivacyContact` and the RFC 9537 redaction scan already cover the same signal without double-counting
- Expand `isPlaceholderValue` test to assert that bare tokens (`"Unknown"`, `"Na"`) only match the whole value and not substrings like `"Unknown Pleasures Ltd"` or `"Na Health Inc"`
- Update `privacyEnabled` JSDoc to clarify it is a coarse signal (an email-only redaction can set it) and point callers to `redactedFields`/`privacyService` for precision
This commit is contained in:
2026-09-20 00:13:44 -04:00
parent adac9d3349
commit 194ce2bfd7
7 changed files with 111 additions and 19 deletions
+1 -1
View File
@@ -574,7 +574,7 @@ interface DomainRecord {
>; // fields that were redacted (and are therefore absent) >; // fields that were redacted (and are therefore absent)
privacyService?: boolean; // name/organization is a privacy/proxy service (text kept), not a redaction notice privacyService?: boolean; // name/organization is a privacy/proxy service (text kept), not a redaction notice
}>; }>;
privacyEnabled?: boolean; // registrant appears privacy-redacted based on name heuristics (privacy-service and redaction phrases) or RFC 9537 redactions privacyEnabled?: boolean; // coarse: some registrant data was redacted or is behind a privacy service (name heuristics or RFC 9537 redactions); see the registrant contact's redactedFields/privacyService for which fields
redactions?: Array<{ redactions?: Array<{
name: string; // e.g. "Registrant Email" name: string; // e.g. "Registrant Email"
prePath?: string; prePath?: string;
+16
View File
@@ -59,6 +59,20 @@ test("redactionFields maps RFC 9537 names to contact fields", () => {
expect(redactionFields("Registrant Postal Code")).toEqual(["postalCode"]); expect(redactionFields("Registrant Postal Code")).toEqual(["postalCode"]);
}); });
test("redactionFields falls back to the vCard property in prePath", () => {
const path = (prop: string, tail = "") =>
`$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='${prop}')]${tail}`;
expect(redactionFields("Redacted", path("email"))).toEqual(["email"]);
expect(redactionFields("REDACTED", path("fn"))).toEqual(["name"]);
expect(redactionFields("x", path("org"))).toEqual(["organization"]);
expect(redactionFields("x", path("tel"))).toEqual(["phone"]);
expect(redactionFields("x", path("adr", "[3][3]"))).toEqual(["city"]);
expect(redactionFields("x", path("adr"))).toContain("street");
expect(redactionFields("x", "$.entities[0]")).toEqual([]);
// The name wins when it identifies fields
expect(redactionFields("Registrant Email", path("fn"))).toEqual(["email"]);
});
test("isPlaceholderValue covers common registry boilerplate", () => { test("isPlaceholderValue covers common registry boilerplate", () => {
for (const v of [ for (const v of [
"Not available from registry", "Not available from registry",
@@ -71,7 +85,9 @@ test("isPlaceholderValue covers common registry boilerplate", () => {
]) { ]) {
expect(isPlaceholderValue(v), v).toBe(true); expect(isPlaceholderValue(v), v).toBe(true);
} }
// Bare tokens only match the whole value
expect(isPlaceholderValue("Unknown Pleasures Ltd")).toBe(false); expect(isPlaceholderValue("Unknown Pleasures Ltd")).toBe(false);
expect(isPlaceholderValue("Na Health Inc")).toBe(false);
}); });
test("package entry exports the contact predicates", () => { test("package entry exports the contact predicates", () => {
+39 -2
View File
@@ -35,8 +35,25 @@ const CLEANED_FIELDS = [
// Secondary fields: cleaned the same way but not reported (no `ContactField` for them). // Secondary fields: cleaned the same way but not reported (no `ContactField` for them).
const CLEANED_EXTRA = ["organizationUnits", "title", "role"] as const; const CLEANED_EXTRA = ["organizationUnits", "title", "role"] as const;
/** Map an RFC 9537 redaction name/path (e.g. "Registrant Email") to the contact fields it covers. */ // vCard property (from a redaction path like `[?(@[0]=='email')]`) to the contact fields it holds.
export function redactionFields(text: string): ContactField[] { const VCARD_PROPERTY_FIELDS: Record<string, ContactField[]> = {
fn: ["name"],
n: ["name"],
org: ["organization"],
email: ["email"],
tel: ["phone"],
adr: ["poBox", "street", "city", "state", "postalCode"],
};
// Position within an `adr` value (`[3][N]`) to the field it holds.
const ADR_INDEX_FIELDS: Record<number, ContactField> = {
0: "poBox",
2: "street",
3: "city",
4: "state",
5: "postalCode",
};
function fieldsFromName(text: string): ContactField[] {
const t = text.toLowerCase(); const t = text.toLowerCase();
const out: ContactField[] = []; const out: ContactField[] = [];
if (/\bname\b|\bfn\b/.test(t) && !/\borg/.test(t)) out.push("name"); if (/\bname\b|\bfn\b/.test(t) && !/\borg/.test(t)) out.push("name");
@@ -52,6 +69,26 @@ export function redactionFields(text: string): ContactField[] {
return out; return out;
} }
function fieldsFromPath(path: string): ContactField[] {
const prop = /@\[0\]\s*==\s*'([a-z]+)'/i.exec(path)?.[1]?.toLowerCase();
if (!prop) return [];
if (prop === "adr") {
const idx = /\[3\]\[(\d+)\]\s*$/.exec(path)?.[1];
const field = idx === undefined ? undefined : ADR_INDEX_FIELDS[Number(idx)];
if (field) return [field];
}
return VCARD_PROPERTY_FIELDS[prop] ?? [];
}
/**
* Contact fields an RFC 9537 redaction covers, from its human-readable name (e.g. "Registrant
* Email") and, failing that, the vCard property in its `prePath`.
*/
export function redactionFields(name: string, prePath?: string): ContactField[] {
const fromName = fieldsFromName(name);
return fromName.length || !prePath ? fromName : fieldsFromPath(prePath);
}
/** /**
* Post-process a parsed contact: drop placeholder values from every field (recording each in * Post-process a parsed contact: drop placeholder values from every field (recording each in
* `redactedFields`), flag privacy-service names (`privacyService`), resolve country/countryCode, * `redactedFields`), flag privacy-service names (`privacyService`), resolve country/countryCode,
+23 -13
View File
@@ -1,20 +1,28 @@
/** /**
* Phrases that indicate privacy/redaction on their own. Matched as case-insensitive substrings. * Redaction notices: text that stands in for a withheld value. Shared by `isPrivacyName` (as
* substrings) and `isPlaceholderValue` (as whole words), so the two can't drift apart.
*/ */
export const PRIVACY_STRONG_KEYWORDS = [ const REDACTION_PHRASES = [
"redacted", // also covers "redacted for privacy", "redacted.forprivacy" "redacted", // also covers "redacted for privacy", "redacted.forprivacy"
"withheld", "withheld",
"not disclosed", "not disclosed",
"privado", // Spanish
"datos privados", // Spanish
"data protected", "data protected",
"gdpr masked", "gdpr masked",
"non-public data",
"statutory masking", "statutory masking",
"registration private", "non-public data",
"private registration",
"hidden upon user request", "hidden upon user request",
"not available from registry", "not available from registry",
];
/**
* Privacy/proxy service names and other phrases that indicate privacy on their own. Unlike
* redaction notices these can be real registrant text worth showing, so they aren't placeholders.
*/
const PRIVACY_SERVICE_PHRASES = [
"privado", // Spanish
"datos privados", // Spanish
"registration private",
"private registration",
"whois privacy", "whois privacy",
"whoisguard", "whoisguard",
"privacy protect", "privacy protect",
@@ -26,6 +34,11 @@ export const PRIVACY_STRONG_KEYWORDS = [
"for privacy", "for privacy",
]; ];
/**
* Phrases that indicate privacy/redaction on their own. Matched as case-insensitive substrings.
*/
export const PRIVACY_STRONG_KEYWORDS = [...REDACTION_PHRASES, ...PRIVACY_SERVICE_PHRASES];
/** /**
* Words too ambiguous to trust alone ("Private Equity LLC", "Protection One"). They only count * Words too ambiguous to trust alone ("Private Equity LLC", "Protection One"). They only count
* when at least two distinct terms from WEAK + CONTEXT appear as whole words. * when at least two distinct terms from WEAK + CONTEXT appear as whole words.
@@ -60,19 +73,16 @@ export function isPrivacyName(value: string): boolean {
return new Set(v.match(CONTEXT_WORD_RE)).size >= 2; return new Set(v.match(CONTEXT_WORD_RE)).size >= 2;
} }
const escapeRe = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Boilerplate that registrars put in email/phone/etc. instead of real values // Boilerplate that registrars put in email/phone/etc. instead of real values
const PLACEHOLDER_VALUE_PATTERNS = [ const PLACEHOLDER_VALUE_PATTERNS = [
/\bredacted\b/i, ...REDACTION_PHRASES.map((p) => new RegExp(`\\b${escapeRe(p)}\\b`, "i")),
/\bwithheld\b/i,
/\bnot disclosed\b/i,
/please query the rdds/i, /please query the rdds/i,
/please query the rdap/i, /please query the rdap/i,
/query the whois/i, /query the whois/i,
/\bcontact (?:the )?registrar\b/i, /\bcontact (?:the )?registrar\b/i,
/\bnot available from registry\b/i,
/\bnot applicable\b/i, /\bnot applicable\b/i,
/\bdata protected\b/i,
/\b(?:statutory|gdpr) mask(?:ing|ed)\b/i,
/select request email form/i, /select request email form/i,
/^(?:-+|n\/a|na|none|null|undefined|unknown)$/i, /^(?:-+|n\/a|na|none|null|undefined|unknown)$/i,
]; ];
+25
View File
@@ -325,3 +325,28 @@ test("normalizeRdap resolves registrar countryCode from the country name", () =>
); );
expect(rec.registrar).toMatchObject({ country: "Canada", countryCode: "CA" }); expect(rec.registrar).toMatchObject({ country: "Canada", countryCode: "CA" });
}); });
test("normalizeRdap ties a redaction path to redactedFields", () => {
const entity = {
roles: ["registrant"],
vcardArray: ["vcard", [["fn", {}, "text", "Jane Doe"]]],
};
const emailOnly = normalizeRdap(
"example.com",
"com",
{
ldhName: "example.com",
redacted: [
{
name: { description: "Registrant Email" },
prePath: "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='email')][3]",
method: "emptyValue",
},
],
entities: [entity],
},
[],
);
expect(emailOnly.contacts?.[0]?.redactedFields).toEqual(["email"]);
expect(emailOnly.contacts?.[0]?.redacted).toBe(true);
});
+1 -2
View File
@@ -53,7 +53,6 @@ export function normalizeRdap(
const registrant = contacts?.find((c) => c.type === "registrant"); const registrant = contacts?.find((c) => c.type === "registrant");
const privacyEnabled = const privacyEnabled =
isPrivacyContact(registrant) || isPrivacyContact(registrant) ||
!!registrant?.redacted ||
!!redactions?.some((r) => redactionTargetsRole(r, "registrant")); !!redactions?.some((r) => redactionTargetsRole(r, "registrant"));
// RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present. // RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
@@ -257,7 +256,7 @@ function extractContacts(entities: unknown, redactions?: Redaction[]): Contact[]
const matching = (redactions ?? []).filter((r) => redactionTargetsRole(r, type.toLowerCase())); const matching = (redactions ?? []).filter((r) => redactionTargetsRole(r, type.toLowerCase()));
// Fields the redaction names, limited to ones actually absent (a present value wasn't hidden). // Fields the redaction names, limited to ones actually absent (a present value wasn't hidden).
const fields = matching const fields = matching
.flatMap((r) => redactionFields(r.name)) .flatMap((r) => redactionFields(r.name, r.prePath))
.filter((f) => !contact[f] || (Array.isArray(contact[f]) && !contact[f]?.length)); .filter((f) => !contact[f] || (Array.isArray(contact[f]) && !contact[f]?.length));
out.push(finalizeContact(contact, fields.length ? fields : matching.length > 0)); out.push(finalizeContact(contact, fields.length ? fields : matching.length > 0));
} }
+6 -1
View File
@@ -240,7 +240,12 @@ export interface DomainRecord {
nameservers?: Nameserver[]; nameservers?: Nameserver[];
/** Contacts (registrant, admin, tech, billing, abuse, etc.) */ /** Contacts (registrant, admin, tech, billing, abuse, etc.) */
contacts?: Contact[]; contacts?: Contact[];
/** Best guess that the registrant is hidden behind a privacy service or redacted, from name/organization phrases or RFC 9537 redactions */ /**
* Coarse signal that some registrant data was redacted or is behind a privacy service, from
* name/organization phrases or RFC 9537 registrant redactions. It does not mean the registrant's
* identity is hidden (an email-only redaction sets it); check the registrant contact's
* `redactedFields` and `privacyService` for precision.
*/
privacyEnabled?: boolean; privacyEnabled?: boolean;
/** RFC 9537 redaction metadata reported by RDAP, if any */ /** RFC 9537 redaction metadata reported by RDAP, if any */
redactions?: Redaction[]; redactions?: Redaction[];