feat: add contact redacted flag, placeholder detection, and country name/code resolution

- 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
This commit is contained in:
2026-09-19 23:07:38 -04:00
parent 1d04be5c74
commit 89fa13ffef
10 changed files with 286 additions and 23 deletions
+2 -1
View File
@@ -553,7 +553,8 @@ interface DomainRecord {
state?: string; state?: string;
postalCode?: string; postalCode?: string;
country?: 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 privacyEnabled?: boolean; // registrant appears privacy-redacted based on name heuristics (privacy-service and redaction phrases) or RFC 9537 redactions
redactions?: Array<{ redactions?: Array<{
+35
View File
@@ -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;
}
+21
View File
@@ -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,
});
});
+113
View File
@@ -0,0 +1,113 @@
// Country name <-> ISO 3166-1 alpha-2 resolution, backed by the runtime's ICU data.
const ALIASES: Record<string, string> = {
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<string, string> | 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 };
}
+18
View File
@@ -59,3 +59,21 @@ export function isPrivacyName(value: string): boolean {
WEAK_WORD_RE.lastIndex = 0; WEAK_WORD_RE.lastIndex = 0;
return new Set(v.match(CONTEXT_WORD_RE)).size >= 2; 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));
}
+39
View File
@@ -267,3 +267,42 @@ test("normalizeRdap maps registrar adr and cc", () => {
countryCode: "CA", 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();
});
+18 -7
View File
@@ -1,3 +1,4 @@
import { finalizeContact } from "../lib/contacts";
import { toISO } from "../lib/dates"; import { toISO } from "../lib/dates";
import { isPrivacyName } from "../lib/privacy"; import { isPrivacyName } from "../lib/privacy";
import { asDateLike, asString, asStringArray, uniq } from "../lib/text"; import { asDateLike, asString, asStringArray, uniq } from "../lib/text";
@@ -41,19 +42,21 @@ export function normalizeRdap(
.filter((n) => !!n.host) .filter((n) => !!n.host)
: undefined; : 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 // RFC 9537 redaction metadata
const redactions = extractRedactions(doc.redacted); 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 // Derive privacy flag from registrant name/org keywords or RFC 9537 registrant redactions
const registrant = contacts?.find((c) => c.type === "registrant"); const registrant = contacts?.find((c) => c.type === "registrant");
const privacyEnabled = const privacyEnabled =
!!( !!(
registrant && registrant &&
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName) ([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. // RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
const statuses = Array.isArray(doc.status) const statuses = Array.isArray(doc.status)
@@ -208,7 +211,13 @@ function extractRegistrar(entities: unknown): RegistrarInfo | undefined {
return 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; if (!Array.isArray(entities)) return undefined;
const out: Contact[] = []; const out: Contact[] = [];
for (const ent of entities) { for (const ent of entities) {
@@ -229,7 +238,7 @@ function extractContacts(entities: unknown): Contact[] | undefined {
reseller: "reseller", reseller: "reseller",
} as const; } as const;
const roleKey = (map[type.toLowerCase()] ?? "unknown") as Contact["type"]; const roleKey = (map[type.toLowerCase()] ?? "unknown") as Contact["type"];
out.push({ const contact: Contact = {
type: roleKey, type: roleKey,
name: v.fn, name: v.fn,
organization: v.org, organization: v.org,
@@ -242,7 +251,9 @@ function extractContacts(entities: unknown): Contact[] | undefined {
postalCode: v.postcode, postalCode: v.postcode,
country: v.country, country: v.country,
countryCode: v.countryCode, countryCode: v.countryCode,
}); };
const hinted = !!redactions?.some((r) => redactionTargetsRole(r, type.toLowerCase()));
out.push(finalizeContact(contact, hinted));
} }
return out.length ? out : undefined; return out.length ? out : undefined;
} }
+3
View File
@@ -60,7 +60,10 @@ export interface Contact {
state?: string; state?: string;
postalCode?: string; postalCode?: string;
country?: string; country?: string;
/** ISO 3166-1 alpha-2 country code */
countryCode?: string; countryCode?: string;
/** True when some of this contact's data was redacted, withheld, or replaced by a placeholder */
redacted?: boolean;
} }
/** /**
+21
View File
@@ -280,3 +280,24 @@ Name servers:
expect(rec.registrar).toEqual({ name: "epag", url: "http://www.epag.de" }); 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"]); 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();
});
+5 -4
View File
@@ -1,4 +1,5 @@
import { toISOFromTokens } from "../lib/dates"; import { toISOFromTokens } from "../lib/dates";
import { finalizeContact } from "../lib/contacts";
import { isPrivacyName } from "../lib/privacy"; import { isPrivacyName } from "../lib/privacy";
import { parseKeyValueLines, uniq } from "../lib/text"; import { parseKeyValueLines, uniq } from "../lib/text";
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types"; import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
@@ -358,7 +359,8 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
const country = anyValue(map, countryKeys); const country = anyValue(map, countryKeys);
if (name || org || email || phone || street?.length) { if (name || org || email || phone || street?.length) {
contacts.push({ contacts.push(
finalizeContact({
type: r.role, type: r.role,
name: name || undefined, name: name || undefined,
organization: org || undefined, organization: org || undefined,
@@ -370,9 +372,8 @@ function collectContacts(map: Record<string, string[]>): Contact[] | undefined {
state: state || undefined, state: state || undefined,
postalCode: postalCode || undefined, postalCode: postalCode || undefined,
country: country || 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, );
});
} }
} }
return contacts.length ? contacts : undefined; return contacts.length ? contacts : undefined;