From 89fa13ffeff5ea7fdf355b46185c644127d02137 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 19 Sep 2026 23:07:38 -0400 Subject: [PATCH] feat: add contact `redacted` flag, placeholder detection, and country name/code resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `redacted?: boolean` to `Contact`; set when any field was dropped as a placeholder, the name matches a privacy-service heuristic, or an RFC 9537 redaction targets that entity's role - Add `isPlaceholderValue` to `privacy.ts` to identify boilerplate registrar strings (`"Please query the RDDS…"`, `"redacted"`, `"n/a"`, etc.) used in place of real email/phone/fax values; drop them from the contact during finalization - Add `countries.ts` backed by `Intl.DisplayNames` (with a curated `ALIASES` table for common alternate names) providing `countryNameFromCode`, `countryCodeFromName`, and `resolveCountry`; a two-letter `country` value is now treated as a code and expanded to a full name - Introduce `finalizeContact` in `contacts.ts` to centralise all contact post-processing (placeholder stripping, country resolution, `redacted` flag); call it from both RDAP and WHOIS normalizers - Wire RFC 9537 redaction role-matching into `extractContacts` via a new `redactionTargetsRole` helper so entities targeted by a `redacted` entry automatically receive `redacted: true` - Add `countries.test.ts` and extend RDAP/WHOIS normalizer tests to cover placeholder stripping, country fill-in, and redaction flag propagation --- README.md | 3 +- src/lib/contacts.ts | 35 +++++++++++ src/lib/countries.test.ts | 21 +++++++ src/lib/countries.ts | 113 ++++++++++++++++++++++++++++++++++++ src/lib/privacy.ts | 18 ++++++ src/rdap/normalize.test.ts | 39 +++++++++++++ src/rdap/normalize.ts | 25 +++++--- src/types.ts | 3 + src/whois/normalize.test.ts | 21 +++++++ src/whois/normalize.ts | 31 +++++----- 10 files changed, 286 insertions(+), 23 deletions(-) create mode 100644 src/lib/contacts.ts create mode 100644 src/lib/countries.test.ts create mode 100644 src/lib/countries.ts diff --git a/README.md b/README.md index 0103544..f19e85d 100644 --- a/README.md +++ b/README.md @@ -553,7 +553,8 @@ interface DomainRecord { state?: string; postalCode?: string; country?: string; - countryCode?: 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 }>; 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/lib/contacts.ts b/src/lib/contacts.ts new file mode 100644 index 0000000..2372d6a --- /dev/null +++ b/src/lib/contacts.ts @@ -0,0 +1,35 @@ +import type { Contact } from "../types"; +import { resolveCountry } from "./countries"; +import { isPlaceholderValue, isPrivacyName } from "./privacy"; + +function cleanValue(value: string | string[] | undefined): { + value: string | string[] | undefined; + dropped: boolean; +} { + if (value === undefined) return { value, dropped: false }; + const list = Array.isArray(value) ? value : [value]; + 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 }; +} + +/** + * 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). + */ +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; + if (dropped) redacted = true; + } + if ([contact.name, contact.organization].some((v) => v && isPrivacyName(v))) redacted = true; + const { country, countryCode } = resolveCountry(contact.country, contact.countryCode); + contact.country = country; + contact.countryCode = countryCode; + if (redacted) contact.redacted = true; + return contact; +} diff --git a/src/lib/countries.test.ts b/src/lib/countries.test.ts new file mode 100644 index 0000000..81db170 --- /dev/null +++ b/src/lib/countries.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "vitest"; +import { countryCodeFromName, countryNameFromCode, resolveCountry } from "./countries"; + +test("country name <-> code resolution", () => { + expect(countryNameFromCode("us")).toBe("United States"); + expect(countryNameFromCode("ZZ")).toBeUndefined(); + expect(countryCodeFromName("Iceland")).toBe("IS"); + expect(countryCodeFromName("United States of America")).toBe("US"); + expect(countryCodeFromName("Türkiye")).toBe("TR"); + expect(countryCodeFromName("Atlantis")).toBeUndefined(); +}); + +test("resolveCountry fills whichever side is missing", () => { + expect(resolveCountry("IS", undefined)).toEqual({ country: "Iceland", countryCode: "IS" }); + expect(resolveCountry("Canada", undefined)).toEqual({ country: "Canada", countryCode: "CA" }); + expect(resolveCountry(undefined, "de")).toEqual({ country: "Germany", countryCode: "DE" }); + expect(resolveCountry("Nowhere", undefined)).toEqual({ + country: "Nowhere", + countryCode: undefined, + }); +}); diff --git a/src/lib/countries.ts b/src/lib/countries.ts new file mode 100644 index 0000000..b7f7013 --- /dev/null +++ b/src/lib/countries.ts @@ -0,0 +1,113 @@ +// Country name <-> ISO 3166-1 alpha-2 resolution, backed by the runtime's ICU data. + +const ALIASES: Record = { + usa: "US", + "u.s.a.": "US", + "united states of america": "US", + america: "US", + uk: "GB", + "great britain": "GB", + england: "GB", + scotland: "GB", + wales: "GB", + russia: "RU", + "russian federation": "RU", + korea: "KR", + "south korea": "KR", + "republic of korea": "KR", + "north korea": "KP", + vietnam: "VN", + "viet nam": "VN", + czechia: "CZ", + "czech republic": "CZ", + turkey: "TR", + turkiye: "TR", + iran: "IR", + "islamic republic of iran": "IR", + syria: "SY", + taiwan: "TW", + "hong kong sar": "HK", + "hong kong sar china": "HK", + "the netherlands": "NL", + holland: "NL", + uae: "AE", + burma: "MM", + "ivory coast": "CI", + "cote d'ivoire": "CI", + laos: "LA", + moldova: "MD", + macedonia: "MK", + "north macedonia": "MK", + palestine: "PS", + "cape verde": "CV", + swaziland: "SZ", +}; + +const normalize = (s: string) => + s + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .toLowerCase() + .replace(/\s+/g, " ") + .trim(); + +let displayNames: Intl.DisplayNames | undefined; +let nameToCode: Map | undefined; + +function getDisplayNames(): Intl.DisplayNames | undefined { + if (displayNames) return displayNames; + try { + displayNames = new Intl.DisplayNames(["en"], { type: "region" }); + } catch { + // Runtime without Intl.DisplayNames support + } + return displayNames; +} + +/** English country name for an ISO 3166-1 alpha-2 code, or undefined if unknown. */ +export function countryNameFromCode(code: string): string | undefined { + const c = code.trim().toUpperCase(); + if (!/^[A-Z]{2}$/.test(c)) return undefined; + try { + const name = getDisplayNames()?.of(c); + return name && name !== c && name !== "Unknown Region" ? name : undefined; + } catch { + return undefined; + } +} + +/** ISO 3166-1 alpha-2 code for a country name (or the code itself), or undefined if unknown. */ +export function countryCodeFromName(value: string): string | undefined { + const v = value.trim(); + if (!v) return undefined; + if (/^[A-Za-z]{2}$/.test(v)) return countryNameFromCode(v) ? v.toUpperCase() : undefined; + if (!nameToCode) { + nameToCode = new Map(); + for (let a = 65; a <= 90; a++) { + for (let b = 65; b <= 90; b++) { + const code = String.fromCharCode(a, b); + const name = countryNameFromCode(code); + if (name) nameToCode.set(normalize(name), code); + } + } + } + const key = normalize(v); + return nameToCode.get(key) ?? ALIASES[key]; +} + +/** + * Fill in whichever of country/countryCode is missing. A 2-letter `country` is treated as a code. + */ +export function resolveCountry( + country: string | undefined, + countryCode: string | undefined, +): { country?: string; countryCode?: string } { + let name = country?.trim() || undefined; + let code = countryCode?.trim().toUpperCase() || undefined; + if (!code && name) { + code = countryCodeFromName(name); + if (code && /^[A-Za-z]{2}$/.test(name)) name = undefined; // value was just the code + } + if (!name && code) name = countryNameFromCode(code); + return { country: name, countryCode: code }; +} diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index 27d608d..99990e2 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -59,3 +59,21 @@ export function isPrivacyName(value: string): boolean { WEAK_WORD_RE.lastIndex = 0; return new Set(v.match(CONTEXT_WORD_RE)).size >= 2; } + +// 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, + /please query the rdds/i, + /please query the rdap/i, + /query the whois/i, + /\bcontact (?:the )?registrar\b/i, + /^(?:-+|n\/a|na|none|null|undefined)$/i, +]; + +/** True when a contact field value (email, phone, ...) is a placeholder rather than real data. */ +export function isPlaceholderValue(value: string): boolean { + const v = value.trim(); + return !v || PLACEHOLDER_VALUE_PATTERNS.some((re) => re.test(v)); +} diff --git a/src/rdap/normalize.test.ts b/src/rdap/normalize.test.ts index 4efeb00..5731f2e 100644 --- a/src/rdap/normalize.test.ts +++ b/src/rdap/normalize.test.ts @@ -267,3 +267,42 @@ test("normalizeRdap maps registrar adr and cc", () => { countryCode: "CA", }); }); + +test("normalizeRdap flags contacts with placeholder values or matching redactions", () => { + const rec = normalizeRdap( + "example.com", + "com", + { + ldhName: "example.com", + redacted: [ + { + name: { description: "Technical Email" }, + prePath: "$.entities[?(@.roles[0]=='technical')].vcardArray[1][?(@[0]=='email')][3]", + method: "emptyValue", + }, + ], + entities: [ + { + roles: ["registrant"], + vcardArray: [ + "vcard", + [ + ["fn", {}, "text", "Jane Doe"], + ["email", {}, "text", "Please query the RDDS service of the Registrar of Record"], + ["adr", {}, "text", ["", "", "", "", "", "", "Germany"]], + ], + ], + }, + { roles: ["technical"], vcardArray: ["vcard", [["fn", {}, "text", "Tech Person"]]] }, + { roles: ["administrative"], vcardArray: ["vcard", [["fn", {}, "text", "Admin Person"]]] }, + ], + }, + [], + ); + const [registrant, tech, admin] = rec.contacts ?? []; + expect(registrant?.email).toBeUndefined(); + expect(registrant?.redacted).toBe(true); + expect(registrant?.countryCode).toBe("DE"); + expect(tech?.redacted).toBe(true); + expect(admin?.redacted).toBeUndefined(); +}); diff --git a/src/rdap/normalize.ts b/src/rdap/normalize.ts index 3577597..6c489e4 100644 --- a/src/rdap/normalize.ts +++ b/src/rdap/normalize.ts @@ -1,3 +1,4 @@ +import { finalizeContact } from "../lib/contacts"; import { toISO } from "../lib/dates"; import { isPrivacyName } from "../lib/privacy"; import { asDateLike, asString, asStringArray, uniq } from "../lib/text"; @@ -41,19 +42,21 @@ export function normalizeRdap( .filter((n) => !!n.host) : undefined; - // Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse - const contacts: Contact[] | undefined = extractContacts(doc.entities as unknown); - // RFC 9537 redaction metadata const redactions = extractRedactions(doc.redacted); + // Contacts: RDAP entities include roles like registrant, administrative, technical, billing, abuse + const contacts: Contact[] | undefined = extractContacts(doc.entities as unknown, redactions); + // 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) - ) || !!redactions?.some((r) => /registrant/i.test(`${r.prePath ?? ""} ${r.name}`)); + ) || + !!registrant?.redacted || + !!redactions?.some((r) => redactionTargetsRole(r, "registrant")); // RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present. const statuses = Array.isArray(doc.status) @@ -208,7 +211,13 @@ function extractRegistrar(entities: unknown): RegistrarInfo | undefined { return undefined; } -function extractContacts(entities: unknown): Contact[] | undefined { +/** Does an RFC 9537 redaction refer to the entity with this RDAP role (e.g. "registrant")? */ +function redactionTargetsRole(r: Redaction, role: string): boolean { + const re = new RegExp(`\\b${role}\\b`, "i"); + return re.test(r.prePath ?? "") || re.test(r.name); +} + +function extractContacts(entities: unknown, redactions?: Redaction[]): Contact[] | undefined { if (!Array.isArray(entities)) return undefined; const out: Contact[] = []; for (const ent of entities) { @@ -229,7 +238,7 @@ function extractContacts(entities: unknown): Contact[] | undefined { reseller: "reseller", } as const; const roleKey = (map[type.toLowerCase()] ?? "unknown") as Contact["type"]; - out.push({ + const contact: Contact = { type: roleKey, name: v.fn, organization: v.org, @@ -242,7 +251,9 @@ function extractContacts(entities: unknown): Contact[] | undefined { postalCode: v.postcode, country: v.country, countryCode: v.countryCode, - }); + }; + const hinted = !!redactions?.some((r) => redactionTargetsRole(r, type.toLowerCase())); + out.push(finalizeContact(contact, hinted)); } return out.length ? out : undefined; } diff --git a/src/types.ts b/src/types.ts index 3fdcbf8..bacb194 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,7 +60,10 @@ export interface Contact { state?: string; postalCode?: string; country?: string; + /** ISO 3166-1 alpha-2 country code */ countryCode?: string; + /** True when some of this contact's data was redacted, withheld, or replaced by a placeholder */ + redacted?: boolean; } /** diff --git a/src/whois/normalize.test.ts b/src/whois/normalize.test.ts index ac08ca3..a43db5b 100644 --- a/src/whois/normalize.test.ts +++ b/src/whois/normalize.test.ts @@ -280,3 +280,24 @@ Name servers: expect(rec.registrar).toEqual({ name: "epag", url: "http://www.epag.de" }); expect(rec.nameservers?.map((n) => n.host)).toEqual(["ns1.vercel-dns.com", "ns2.vercel-dns.com"]); }); + +test("WHOIS resolves country code/name and flags placeholder contact values", () => { + const rec = normalizeWhois( + "example.com", + "com", + `Domain Name: EXAMPLE.COM +Registrant Name: Jane Doe +Registrant Email: Please query the RDDS service of the Registrar of Record identified in this output +Registrant Country: DE +Admin Name: John Roe +Admin Country: Canada +`, + "whois.example", + ); + const registrant = rec.contacts?.find((c) => c.type === "registrant"); + expect(registrant).toMatchObject({ country: "Germany", countryCode: "DE", redacted: true }); + expect(registrant?.email).toBeUndefined(); + const admin = rec.contacts?.find((c) => c.type === "admin"); + expect(admin).toMatchObject({ country: "Canada", countryCode: "CA" }); + expect(admin?.redacted).toBeUndefined(); +}); diff --git a/src/whois/normalize.ts b/src/whois/normalize.ts index 253edb0..74efd91 100644 --- a/src/whois/normalize.ts +++ b/src/whois/normalize.ts @@ -1,4 +1,5 @@ import { toISOFromTokens } from "../lib/dates"; +import { finalizeContact } from "../lib/contacts"; import { isPrivacyName } from "../lib/privacy"; import { parseKeyValueLines, uniq } from "../lib/text"; import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types"; @@ -358,21 +359,21 @@ function collectContacts(map: Record): Contact[] | undefined { const country = anyValue(map, countryKeys); if (name || org || email || phone || street?.length) { - contacts.push({ - type: r.role, - name: name || undefined, - organization: org || undefined, - email: email || undefined, - phone: phone || undefined, - fax: fax || undefined, - street: street, - city: city || undefined, - state: state || undefined, - postalCode: postalCode || undefined, - country: country || undefined, - // Many registries print the ISO 3166-1 alpha-2 code directly - countryCode: country && /^[A-Za-z]{2}$/.test(country) ? country.toUpperCase() : undefined, - }); + contacts.push( + finalizeContact({ + type: r.role, + name: name || undefined, + organization: org || undefined, + email: email || undefined, + phone: phone || undefined, + fax: fax || undefined, + street: street, + city: city || undefined, + state: state || undefined, + postalCode: postalCode || undefined, + country: country || undefined, + }), + ); } } return contacts.length ? contacts : undefined;