mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 00:15:31 -04:00
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:
@@ -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<{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user