fix: read the registrar abuse contact nested in RDAP registrar entities

gTLD registries (per the ICANN RDAP profile) and CIRA nest the registrar's
abuse contact inside the registrar entity, which normalizeRdap never looked
at, so RDAP records lacked the email/phone that WHOIS records carry. Fall back
to that nested abuse entity when the registrar's own vCard has neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jakeandClaude Opus 5.5 committed 2026-10-03 14:36:58 -04:00
1 parent 8f601194fa
commit 525ef4b7b1
3 files changed
+111 -13

No files matched your search

+85
View File
@@ -350,3 +350,88 @@ test("normalizeRdap ties a redaction path to redactedFields", () => {
expect(emailOnly.contacts?.[0]?.redactedFields).toEqual(["email"]);
expect(emailOnly.contacts?.[0]?.redacted).toBe(true);
});
test("normalizeRdap takes registrar email/phone from the nested abuse entity (gTLD profile)", () => {
// Shape of Verisign's registrar entity for google.com
const rec = normalizeRdap(
"google.com",
"com",
{
ldhName: "GOOGLE.COM",
entities: [
{
roles: ["registrar"],
handle: "292",
publicIds: [{ type: "IANA Registrar ID", identifier: "292" }],
vcardArray: [
"vcard",
[
["version", {}, "text", "4.0"],
["fn", {}, "text", "MarkMonitor Inc."],
],
],
entities: [
{
roles: ["abuse"],
vcardArray: [
"vcard",
[
["version", {}, "text", "4.0"],
["fn", {}, "text", ""],
["tel", { type: "voice" }, "uri", "tel:+1.2086851750"],
["email", {}, "text", "abusecomplaints@markmonitor.com"],
],
],
},
],
},
],
},
[],
);
expect(rec.registrar).toMatchObject({
name: "MarkMonitor Inc.",
ianaId: "292",
email: "abusecomplaints@markmonitor.com",
phone: "+1.2086851750",
});
// The nested abuse entity belongs to the registrar, not the domain's own contacts
expect(rec.contacts).toBeUndefined();
});
test("normalizeRdap prefers the registrar's own email over its abuse contact", () => {
const rec = normalizeRdap(
"example.com",
"com",
{
ldhName: "example.com",
entities: [
{
roles: ["registrar"],
vcardArray: [
"vcard",
[
["fn", {}, "text", "Registrar LLC"],
["email", {}, "text", "support@registrar.test"],
],
],
entities: [
{
roles: ["abuse"],
vcardArray: [
"vcard",
[
["email", {}, "text", "abuse@registrar.test"],
["tel", {}, "text", "+1.5555550100"],
],
],
},
],
},
],
},
[],
);
expect(rec.registrar?.email).toBe("support@registrar.test");
expect(rec.registrar?.phone).toBe("+1.5555550100");
});
+24 -11
View File
@@ -2,7 +2,7 @@ import { resolveCountry } from "../lib/countries";
import { finalizeContact, isPrivacyContact, redactionFields } from "../lib/contacts";
import { toISO } from "../lib/dates";
import { asDateLike, asString, asStringArray, uniq } from "../lib/text";
import { parseVcard } from "./vcard";
import { type ParsedVCard, parseVcard } from "./vcard";
import type { Contact, DomainRecord, Nameserver, Redaction, RegistrarInfo } from "../types";
type RdapDoc = Record<string, unknown>;
@@ -178,14 +178,29 @@ function extractRedactions(redacted: unknown): Redaction[] | undefined {
return out.length ? out : undefined;
}
/** An RDAP entity's string roles. */
function rolesOf(ent: unknown): string[] {
const roles = (ent as RdapDoc)?.roles;
return Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [];
}
/** vCard of the abuse contact nested in a registrar entity, where gTLD registries put it. */
function nestedAbuseVcard(entities: unknown): ParsedVCard | undefined {
if (!Array.isArray(entities)) return undefined;
for (const ent of entities) {
if (!rolesOf(ent).some((r) => /^abuse$/i.test(r))) continue;
const v = parseVcard((ent as RdapDoc)?.vcardArray);
if (v.email?.length || v.tel?.length) return v;
}
return undefined;
}
function extractRegistrar(entities: unknown): RegistrarInfo | undefined {
if (!Array.isArray(entities)) return undefined;
for (const ent of entities) {
const roles: string[] = Array.isArray((ent as RdapDoc)?.roles)
? ((ent as RdapDoc).roles as unknown[]).filter((r): r is string => typeof r === "string")
: [];
if (!roles.some((r) => /registrar/i.test(r))) continue;
if (!rolesOf(ent).some((r) => /registrar/i.test(r))) continue;
const v = parseVcard((ent as RdapDoc)?.vcardArray);
const abuse = nestedAbuseVcard((ent as RdapDoc)?.entities);
const ianaId = Array.isArray((ent as RdapDoc)?.publicIds)
? ((ent as RdapDoc).publicIds as Array<RdapDoc>).find((id) =>
/iana\s*registrar\s*id/i.test(String(id?.type)),
@@ -195,8 +210,9 @@ function extractRegistrar(entities: unknown): RegistrarInfo | undefined {
name: v.fn || v.org || asString((ent as RdapDoc)?.handle) || undefined,
ianaId: asString(ianaId),
url: v.url ?? undefined,
email: v.email?.[0],
phone: v.tel?.[0],
// Fall back to the abuse contact, matching WHOIS "Registrar Abuse Contact Email/Phone"
email: v.email?.[0] ?? abuse?.email?.[0],
phone: v.tel?.[0] ?? abuse?.tel?.[0],
street: v.street,
city: v.locality,
state: v.region,
@@ -217,11 +233,8 @@ function extractContacts(entities: unknown, redactions?: Redaction[]): Contact[]
if (!Array.isArray(entities)) return undefined;
const out: Contact[] = [];
for (const ent of entities) {
const roles: string[] = Array.isArray((ent as RdapDoc)?.roles)
? ((ent as RdapDoc).roles as unknown[]).filter((r): r is string => typeof r === "string")
: [];
const v = parseVcard((ent as RdapDoc)?.vcardArray);
const type = roles.find((r) =>
const type = rolesOf(ent).find((r) =>
/registrant|administrative|technical|billing|abuse|reseller/i.test(r),
);
if (!type) continue;
+2 -2
View File
@@ -19,9 +19,9 @@ export interface RegistrarInfo {
ianaId?: string;
/** Registrar website URL */
url?: string;
/** Registrar contact email address */
/** Registrar contact email address, falling back to the registrar's abuse contact */
email?: string;
/** Registrar contact phone number */
/** Registrar contact phone number, falling back to the registrar's abuse contact */
phone?: string;
/** Street address lines (RDAP only) */
street?: string[];