diff --git a/README.md b/README.md index 96ab73d..e789326 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ interface DomainRecord { >; // fields that were redacted (and are therefore absent) 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<{ name: string; // e.g. "Registrant Email" prePath?: string; diff --git a/src/lib/contacts.test.ts b/src/lib/contacts.test.ts index c60ef24..ae029b2 100644 --- a/src/lib/contacts.test.ts +++ b/src/lib/contacts.test.ts @@ -59,6 +59,20 @@ test("redactionFields maps RFC 9537 names to contact fields", () => { 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", () => { for (const v of [ "Not available from registry", @@ -71,7 +85,9 @@ test("isPlaceholderValue covers common registry boilerplate", () => { ]) { expect(isPlaceholderValue(v), v).toBe(true); } + // Bare tokens only match the whole value expect(isPlaceholderValue("Unknown Pleasures Ltd")).toBe(false); + expect(isPlaceholderValue("Na Health Inc")).toBe(false); }); test("package entry exports the contact predicates", () => { diff --git a/src/lib/contacts.ts b/src/lib/contacts.ts index b526ee4..e1fb4e9 100644 --- a/src/lib/contacts.ts +++ b/src/lib/contacts.ts @@ -35,8 +35,25 @@ const CLEANED_FIELDS = [ // Secondary fields: cleaned the same way but not reported (no `ContactField` for them). 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. */ -export function redactionFields(text: string): ContactField[] { +// vCard property (from a redaction path like `[?(@[0]=='email')]`) to the contact fields it holds. +const VCARD_PROPERTY_FIELDS: Record = { + 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 = { + 0: "poBox", + 2: "street", + 3: "city", + 4: "state", + 5: "postalCode", +}; + +function fieldsFromName(text: string): ContactField[] { const t = text.toLowerCase(); const out: ContactField[] = []; 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; } +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 * `redactedFields`), flag privacy-service names (`privacyService`), resolve country/countryCode, diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index cd1ba99..b2f9136 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -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" "withheld", "not disclosed", - "privado", // Spanish - "datos privados", // Spanish "data protected", "gdpr masked", - "non-public data", "statutory masking", - "registration private", - "private registration", + "non-public data", "hidden upon user request", "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", "whoisguard", "privacy protect", @@ -26,6 +34,11 @@ export const PRIVACY_STRONG_KEYWORDS = [ "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 * 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; } +const escapeRe = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + // Boilerplate that registrars put in email/phone/etc. instead of real values const PLACEHOLDER_VALUE_PATTERNS = [ - /\bredacted\b/i, - /\bwithheld\b/i, - /\bnot disclosed\b/i, + ...REDACTION_PHRASES.map((p) => new RegExp(`\\b${escapeRe(p)}\\b`, "i")), /please query the rdds/i, /please query the rdap/i, /query the whois/i, /\bcontact (?:the )?registrar\b/i, - /\bnot available from registry\b/i, /\bnot applicable\b/i, - /\bdata protected\b/i, - /\b(?:statutory|gdpr) mask(?:ing|ed)\b/i, /select request email form/i, /^(?:-+|n\/a|na|none|null|undefined|unknown)$/i, ]; diff --git a/src/rdap/normalize.test.ts b/src/rdap/normalize.test.ts index 1e3e2ff..c17f123 100644 --- a/src/rdap/normalize.test.ts +++ b/src/rdap/normalize.test.ts @@ -325,3 +325,28 @@ test("normalizeRdap resolves registrar countryCode from the country name", () => ); 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); +}); diff --git a/src/rdap/normalize.ts b/src/rdap/normalize.ts index 8ee1a18..28eafc0 100644 --- a/src/rdap/normalize.ts +++ b/src/rdap/normalize.ts @@ -53,7 +53,6 @@ export function normalizeRdap( const registrant = contacts?.find((c) => c.type === "registrant"); const privacyEnabled = isPrivacyContact(registrant) || - !!registrant?.redacted || !!redactions?.some((r) => redactionTargetsRole(r, "registrant")); // 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())); // Fields the redaction names, limited to ones actually absent (a present value wasn't hidden). 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)); out.push(finalizeContact(contact, fields.length ? fields : matching.length > 0)); } diff --git a/src/types.ts b/src/types.ts index 792e8b3..561e846 100644 --- a/src/types.ts +++ b/src/types.ts @@ -240,7 +240,12 @@ export interface DomainRecord { nameservers?: Nameserver[]; /** Contacts (registrant, admin, tech, billing, abuse, etc.) */ 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; /** RFC 9537 redaction metadata reported by RDAP, if any */ redactions?: Redaction[];