diff --git a/README.md b/README.md index 384f3b6..96ab73d 100644 --- a/README.md +++ b/README.md @@ -560,6 +560,19 @@ interface DomainRecord { country?: string; countryCode?: string; // ISO 3166-1 alpha-2; country/countryCode are resolved from each other when possible redacted?: boolean; // some contact data was redacted/withheld or replaced by a placeholder + redactedFields?: Array< + | "name" + | "organization" + | "email" + | "phone" + | "fax" + | "street" + | "city" + | "state" + | "postalCode" + | "poBox" + >; // 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 redactions?: Array<{ diff --git a/src/index.ts b/src/index.ts index 6c7939e..1ef2eb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -235,4 +235,6 @@ export async function isRegistered(domain: string, opts?: LookupOptions): Promis export const lookupDomain = lookup; export { getDomainParts, getDomainTld, isLikelyDomain, toRegistrableDomain } from "./lib/domain"; +export { resolveCountry } from "./lib/countries"; +export { isPlaceholderValue, isPrivacyName } from "./lib/privacy"; export type * from "./types"; diff --git a/src/lib/contacts.test.ts b/src/lib/contacts.test.ts new file mode 100644 index 0000000..c60ef24 --- /dev/null +++ b/src/lib/contacts.test.ts @@ -0,0 +1,84 @@ +import { expect, test } from "vitest"; +import { finalizeContact, redactionFields } from "./contacts"; +import { isPlaceholderValue } from "./privacy"; +import * as api from "../index"; + +test("finalizeContact drops placeholder name/organization and records redactedFields", () => { + const c = finalizeContact({ + type: "registrant", + name: "REDACTED FOR PRIVACY", + organization: "Data Protected", + email: "Please query the RDDS service", + street: ["REDACTED FOR PRIVACY"], + city: "Berlin", + }); + expect(c.name).toBeUndefined(); + expect(c.organization).toBeUndefined(); + expect(c.street).toBeUndefined(); + expect(c.city).toBe("Berlin"); + expect(c.redactedFields).toEqual(["name", "organization", "email", "street"]); + expect(c.redacted).toBe(true); + expect(c.privacyService).toBeUndefined(); +}); + +test("finalizeContact keeps privacy-service names and flags privacyService", () => { + const c = finalizeContact({ type: "registrant", name: "Domains By Proxy, LLC" }); + expect(c.name).toBe("Domains By Proxy, LLC"); + expect(c.privacyService).toBe(true); + expect(c.redacted).toBe(true); + expect(c.redactedFields).toBeUndefined(); +}); + +test("finalizeContact cleans address fields and leaves real contacts untouched", () => { + const c = finalizeContact({ + type: "admin", + name: "Jane Doe", + state: "REDACTED FOR PRIVACY", + postalCode: "REDACTED FOR PRIVACY", + title: "REDACTED", + }); + expect(c.redactedFields).toEqual(["state", "postalCode"]); + expect(c.title).toBeUndefined(); + const clean = finalizeContact({ type: "tech", name: "John Roe", city: "Paris" }); + expect(clean.redacted).toBeUndefined(); + expect(clean.redactedFields).toBeUndefined(); +}); + +test("finalizeContact merges field hints", () => { + const c = finalizeContact({ type: "tech" }, ["email", "phone"]); + expect(c.redactedFields).toEqual(["email", "phone"]); + expect(c.redacted).toBe(true); +}); + +test("redactionFields maps RFC 9537 names to contact fields", () => { + expect(redactionFields("Registrant Email")).toEqual(["email"]); + expect(redactionFields("Registrant Name")).toEqual(["name"]); + expect(redactionFields("Registrant Organization")).toEqual(["organization"]); + expect(redactionFields("Tech Phone Ext")).toEqual(["phone"]); + expect(redactionFields("Registrant Street")).toEqual(["street"]); + expect(redactionFields("Registrant Postal Code")).toEqual(["postalCode"]); +}); + +test("isPlaceholderValue covers common registry boilerplate", () => { + for (const v of [ + "Not available from registry", + "Not Applicable", + "Data Protected", + "STATUTORY MASKING ENABLED", + "GDPR Masked", + "Select Request Email Form", + "Unknown", + ]) { + expect(isPlaceholderValue(v), v).toBe(true); + } + expect(isPlaceholderValue("Unknown Pleasures Ltd")).toBe(false); +}); + +test("package entry exports the contact predicates", () => { + expect(typeof api.isPrivacyName).toBe("function"); + expect(typeof api.isPlaceholderValue).toBe("function"); + expect(api.resolveCountry("Germany", undefined)).toEqual({ + country: "Germany", + countryCode: "DE", + }); +}); diff --git a/src/lib/contacts.ts b/src/lib/contacts.ts index 2372d6a..b526ee4 100644 --- a/src/lib/contacts.ts +++ b/src/lib/contacts.ts @@ -1,8 +1,11 @@ -import type { Contact } from "../types"; +import type { Contact, ContactField } from "../types"; import { resolveCountry } from "./countries"; import { isPlaceholderValue, isPrivacyName } from "./privacy"; -function cleanValue(value: string | string[] | undefined): { +function cleanValue( + value: string | string[] | undefined, + keepArray = false, +): { value: string | string[] | undefined; dropped: boolean; } { @@ -11,25 +14,94 @@ function cleanValue(value: string | string[] | undefined): { const kept = list.filter((v) => !isPlaceholderValue(v)); const dropped = kept.length !== list.length; if (!kept.length) return { value: undefined, dropped }; - return { value: Array.isArray(value) && kept.length > 1 ? kept : kept[0], dropped }; + const asArray = Array.isArray(value) && (keepArray || kept.length > 1); + return { value: asArray ? kept : kept[0], dropped }; +} + +// Contact fields cleaned of placeholder text, and reported in `redactedFields` when dropped. +const CLEANED_FIELDS = [ + "name", + "organization", + "email", + "phone", + "fax", + "street", + "city", + "state", + "postalCode", + "poBox", +] as const satisfies readonly ContactField[]; + +// 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[] { + const t = text.toLowerCase(); + const out: ContactField[] = []; + if (/\bname\b|\bfn\b/.test(t) && !/\borg/.test(t)) out.push("name"); + if (/\borg/.test(t)) out.push("organization"); + if (/e-?mail/.test(t)) out.push("email"); + if (/phone|\btel\b/.test(t) && !/fax/.test(t)) out.push("phone"); + if (/\bfax\b/.test(t)) out.push("fax"); + if (/street|address/.test(t)) out.push("street"); + if (/city|locality/.test(t)) out.push("city"); + if (/state|province|region/.test(t)) out.push("state"); + if (/postal|post code|postcode|zip/.test(t)) out.push("postalCode"); + if (/\bpo box\b|\bpobox\b/.test(t)) out.push("poBox"); + return out; } /** - * Post-process a parsed contact: drop placeholder email/phone/fax values, resolve - * country/countryCode, and set `redacted` when any redaction signal is present. - * `redactedHint` lets callers pass format-specific signals (e.g. RFC 9537 entries). + * Post-process a parsed contact: drop placeholder values from every field (recording each in + * `redactedFields`), flag privacy-service names (`privacyService`), resolve country/countryCode, + * and set `redacted` when any redaction signal is present. + * + * `redactedHint` lets callers pass format-specific signals (e.g. RFC 9537 entries): `true` when + * something was redacted but the fields are unknown, or the list of fields known to be redacted. */ -export function finalizeContact(contact: Contact, redactedHint = false): Contact { - let redacted = redactedHint; - for (const key of ["email", "phone", "fax"] as const) { - const { value, dropped } = cleanValue(contact[key]); - contact[key] = value; +export function finalizeContact( + contact: Contact, + redactedHint: boolean | ContactField[] = false, +): Contact { + const redactedFields = new Set(); + let redacted = redactedHint === true || (Array.isArray(redactedHint) && redactedHint.length > 0); + if (Array.isArray(redactedHint)) for (const f of redactedHint) redactedFields.add(f); + + const record = contact as unknown as Record; + for (const key of CLEANED_FIELDS) { + const { value, dropped } = cleanValue(record[key], key === "street"); + record[key] = value; + if (dropped) redactedFields.add(key); + } + for (const key of CLEANED_EXTRA) { + const { value, dropped } = cleanValue(record[key]); + record[key] = value; if (dropped) redacted = true; } - if ([contact.name, contact.organization].some((v) => v && isPrivacyName(v))) redacted = true; + + // Remaining name/organization text that names a privacy service is kept, since it is useful to show. + if ([contact.name, contact.organization].some((v) => v && isPrivacyName(v))) { + contact.privacyService = true; + redacted = true; + } + const { country, countryCode } = resolveCountry(contact.country, contact.countryCode); contact.country = country; contact.countryCode = countryCode; + + if (redactedFields.size) { + contact.redactedFields = CLEANED_FIELDS.filter((f) => redactedFields.has(f)); + redacted = true; + } if (redacted) contact.redacted = true; return contact; } + +/** True when a registrant contact's name/organization is a privacy service or was redacted. */ +export function isPrivacyContact(contact: Contact | undefined): boolean { + return ( + !!contact?.privacyService || + !!contact?.redactedFields?.some((f) => f === "name" || f === "organization") + ); +} diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index 99990e2..cd1ba99 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -69,7 +69,12 @@ const PLACEHOLDER_VALUE_PATTERNS = [ /please query the rdap/i, /query the whois/i, /\bcontact (?:the )?registrar\b/i, - /^(?:-+|n\/a|na|none|null|undefined)$/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, ]; /** True when a contact field value (email, phone, ...) is a placeholder rather than real data. */ diff --git a/src/rdap/normalize.test.ts b/src/rdap/normalize.test.ts index 66d5139..1e3e2ff 100644 --- a/src/rdap/normalize.test.ts +++ b/src/rdap/normalize.test.ts @@ -303,6 +303,7 @@ test("normalizeRdap flags contacts with placeholder values or matching redaction expect(registrant?.email).toBeUndefined(); expect(registrant?.redacted).toBe(true); expect(registrant?.countryCode).toBe("DE"); + expect(registrant?.redactedFields).toEqual(["email"]); expect(tech?.redacted).toBe(true); expect(admin?.redacted).toBeUndefined(); }); diff --git a/src/rdap/normalize.ts b/src/rdap/normalize.ts index 5f2b523..8ee1a18 100644 --- a/src/rdap/normalize.ts +++ b/src/rdap/normalize.ts @@ -1,7 +1,6 @@ import { resolveCountry } from "../lib/countries"; -import { finalizeContact } from "../lib/contacts"; +import { finalizeContact, isPrivacyContact, redactionFields } from "../lib/contacts"; import { toISO } from "../lib/dates"; -import { isPrivacyName } from "../lib/privacy"; import { asDateLike, asString, asStringArray, uniq } from "../lib/text"; import { parseVcard } from "./vcard"; import type { Contact, DomainRecord, Nameserver, Redaction, RegistrarInfo } from "../types"; @@ -53,10 +52,7 @@ export function normalizeRdap( // Derive privacy flag from registrant name/org keywords or RFC 9537 registrant redactions const registrant = contacts?.find((c) => c.type === "registrant"); const privacyEnabled = - !!( - registrant && - ([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName) - ) || + isPrivacyContact(registrant) || !!registrant?.redacted || !!redactions?.some((r) => redactionTargetsRole(r, "registrant")); @@ -258,8 +254,12 @@ function extractContacts(entities: unknown, redactions?: Redaction[]): Contact[] country: v.country, countryCode: v.countryCode, }; - const hinted = !!redactions?.some((r) => redactionTargetsRole(r, type.toLowerCase())); - out.push(finalizeContact(contact, hinted)); + 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)) + .filter((f) => !contact[f] || (Array.isArray(contact[f]) && !contact[f]?.length)); + out.push(finalizeContact(contact, fields.length ? fields : matching.length > 0)); } return out.length ? out : undefined; } diff --git a/src/types.ts b/src/types.ts index d81e6b5..792e8b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -89,8 +89,28 @@ export interface Contact { countryCode?: string; /** True when any of this contact's data was redacted, withheld, or replaced by a placeholder */ redacted?: boolean; + /** + * Fields the registry redacted or replaced with placeholder text (the field itself is then + * absent). Empty or omitted when the redaction could not be tied to specific fields. + */ + redactedFields?: ContactField[]; + /** True when `name`/`organization` names a privacy or proxy service rather than the registrant */ + privacyService?: boolean; } +/** Contact fields that can be reported as redacted in `Contact.redactedFields`. */ +export type ContactField = + | "name" + | "organization" + | "email" + | "phone" + | "fax" + | "street" + | "city" + | "state" + | "postalCode" + | "poBox"; + /** * An RFC 9537 redaction entry describing a field the registry withheld. */ diff --git a/src/whois/normalize.ts b/src/whois/normalize.ts index 74efd91..9eca8df 100644 --- a/src/whois/normalize.ts +++ b/src/whois/normalize.ts @@ -1,6 +1,5 @@ import { toISOFromTokens } from "../lib/dates"; -import { finalizeContact } from "../lib/contacts"; -import { isPrivacyName } from "../lib/privacy"; +import { finalizeContact, isPrivacyContact } from "../lib/contacts"; import { parseKeyValueLines, uniq } from "../lib/text"; import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types"; @@ -218,10 +217,7 @@ export function normalizeWhois( // Derive privacy flag from registrant name/org keywords const registrant = contacts?.find((c) => c.type === "registrant"); - const privacyEnabled = !!( - registrant && - ([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName) - ); + const privacyEnabled = isPrivacyContact(registrant); const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase(); const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : undefined;