feat: add redactedFields, privacyService, and ContactField to contact post-processing

- Add `ContactField` union type and `redactedFields?: ContactField[]` to `Contact` so callers can see exactly which fields were dropped as placeholders or known-redacted, rather than just a boolean flag
- Add `privacyService?: boolean` to `Contact`, set by `finalizeContact` when the name/organization text identifies a privacy proxy service (value is kept; `redacted` is still set)
- Expand `finalizeContact` to clean all address/identity fields (not just email/phone/fax), record each dropped field in `redactedFields`, and accept `ContactField[]` as `redactedHint` so RFC 9537 field names can be passed through directly
- Add `redactionFields(text)` to map RFC 9537 redaction names (e.g. `"Registrant Email"`) to the corresponding `ContactField` entries; use it in `extractContacts` to narrow `redactedHint` to only fields that are actually absent
- Add `isPrivacyContact` helper and switch both RDAP and WHOIS normalizers to use it instead of inlining `isPrivacyName` checks
- Expand `PLACEHOLDER_VALUE_PATTERNS` in `privacy.ts` to cover `"Not available from registry"`, `"Not applicable"`, `"Data Protected"`, `"STATUTORY/GDPR Masking"`, `"Select Request Email Form"`, and `"Unknown"` (as an exact word)
- Export `resolveCountry`, `isPlaceholderValue`, and `isPrivacyName` from the package entry point
- Add `contacts.test.ts` covering placeholder-dropping, `redactedFields` population, `privacyService` flag, hint merging, `redactionFields` mapping, and the new placeholder patterns
This commit is contained in:
2026-09-20 00:04:25 -04:00
parent 7ab25ceecc
commit adac9d3349
9 changed files with 220 additions and 27 deletions
+13
View File
@@ -560,6 +560,19 @@ interface DomainRecord {
country?: string; country?: string;
countryCode?: string; // ISO 3166-1 alpha-2; country/countryCode are resolved from each other when possible 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 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 privacyEnabled?: boolean; // registrant appears privacy-redacted based on name heuristics (privacy-service and redaction phrases) or RFC 9537 redactions
redactions?: Array<{ redactions?: Array<{
+2
View File
@@ -235,4 +235,6 @@ export async function isRegistered(domain: string, opts?: LookupOptions): Promis
export const lookupDomain = lookup; export const lookupDomain = lookup;
export { getDomainParts, getDomainTld, isLikelyDomain, toRegistrableDomain } from "./lib/domain"; export { getDomainParts, getDomainTld, isLikelyDomain, toRegistrableDomain } from "./lib/domain";
export { resolveCountry } from "./lib/countries";
export { isPlaceholderValue, isPrivacyName } from "./lib/privacy";
export type * from "./types"; export type * from "./types";
+84
View File
@@ -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",
});
});
+84 -12
View File
@@ -1,8 +1,11 @@
import type { Contact } from "../types"; import type { Contact, ContactField } from "../types";
import { resolveCountry } from "./countries"; import { resolveCountry } from "./countries";
import { isPlaceholderValue, isPrivacyName } from "./privacy"; import { isPlaceholderValue, isPrivacyName } from "./privacy";
function cleanValue(value: string | string[] | undefined): { function cleanValue(
value: string | string[] | undefined,
keepArray = false,
): {
value: string | string[] | undefined; value: string | string[] | undefined;
dropped: boolean; dropped: boolean;
} { } {
@@ -11,25 +14,94 @@ function cleanValue(value: string | string[] | undefined): {
const kept = list.filter((v) => !isPlaceholderValue(v)); const kept = list.filter((v) => !isPlaceholderValue(v));
const dropped = kept.length !== list.length; const dropped = kept.length !== list.length;
if (!kept.length) return { value: undefined, dropped }; 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 * Post-process a parsed contact: drop placeholder values from every field (recording each in
* country/countryCode, and set `redacted` when any redaction signal is present. * `redactedFields`), flag privacy-service names (`privacyService`), resolve country/countryCode,
* `redactedHint` lets callers pass format-specific signals (e.g. RFC 9537 entries). * 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 { export function finalizeContact(
let redacted = redactedHint; contact: Contact,
for (const key of ["email", "phone", "fax"] as const) { redactedHint: boolean | ContactField[] = false,
const { value, dropped } = cleanValue(contact[key]); ): Contact {
contact[key] = value; const redactedFields = new Set<ContactField>();
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<string, string | string[] | undefined>;
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 (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); const { country, countryCode } = resolveCountry(contact.country, contact.countryCode);
contact.country = country; contact.country = country;
contact.countryCode = countryCode; contact.countryCode = countryCode;
if (redactedFields.size) {
contact.redactedFields = CLEANED_FIELDS.filter((f) => redactedFields.has(f));
redacted = true;
}
if (redacted) contact.redacted = true; if (redacted) contact.redacted = true;
return contact; 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")
);
}
+6 -1
View File
@@ -69,7 +69,12 @@ const PLACEHOLDER_VALUE_PATTERNS = [
/please query the rdap/i, /please query the rdap/i,
/query the whois/i, /query the whois/i,
/\bcontact (?:the )?registrar\b/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. */ /** True when a contact field value (email, phone, ...) is a placeholder rather than real data. */
+1
View File
@@ -303,6 +303,7 @@ test("normalizeRdap flags contacts with placeholder values or matching redaction
expect(registrant?.email).toBeUndefined(); expect(registrant?.email).toBeUndefined();
expect(registrant?.redacted).toBe(true); expect(registrant?.redacted).toBe(true);
expect(registrant?.countryCode).toBe("DE"); expect(registrant?.countryCode).toBe("DE");
expect(registrant?.redactedFields).toEqual(["email"]);
expect(tech?.redacted).toBe(true); expect(tech?.redacted).toBe(true);
expect(admin?.redacted).toBeUndefined(); expect(admin?.redacted).toBeUndefined();
}); });
+8 -8
View File
@@ -1,7 +1,6 @@
import { resolveCountry } from "../lib/countries"; import { resolveCountry } from "../lib/countries";
import { finalizeContact } from "../lib/contacts"; import { finalizeContact, isPrivacyContact, redactionFields } from "../lib/contacts";
import { toISO } from "../lib/dates"; import { toISO } from "../lib/dates";
import { isPrivacyName } from "../lib/privacy";
import { asDateLike, asString, asStringArray, uniq } from "../lib/text"; import { asDateLike, asString, asStringArray, uniq } from "../lib/text";
import { parseVcard } from "./vcard"; import { parseVcard } from "./vcard";
import type { Contact, DomainRecord, Nameserver, Redaction, RegistrarInfo } from "../types"; 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 // 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 =
!!( isPrivacyContact(registrant) ||
registrant &&
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
) ||
!!registrant?.redacted || !!registrant?.redacted ||
!!redactions?.some((r) => redactionTargetsRole(r, "registrant")); !!redactions?.some((r) => redactionTargetsRole(r, "registrant"));
@@ -258,8 +254,12 @@ function extractContacts(entities: unknown, redactions?: Redaction[]): Contact[]
country: v.country, country: v.country,
countryCode: v.countryCode, countryCode: v.countryCode,
}; };
const hinted = !!redactions?.some((r) => redactionTargetsRole(r, type.toLowerCase())); const matching = (redactions ?? []).filter((r) => redactionTargetsRole(r, type.toLowerCase()));
out.push(finalizeContact(contact, hinted)); // 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; return out.length ? out : undefined;
} }
+20
View File
@@ -89,8 +89,28 @@ export interface Contact {
countryCode?: string; countryCode?: string;
/** True when any of this contact's data was redacted, withheld, or replaced by a placeholder */ /** True when any of this contact's data was redacted, withheld, or replaced by a placeholder */
redacted?: boolean; 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. * An RFC 9537 redaction entry describing a field the registry withheld.
*/ */
+2 -6
View File
@@ -1,6 +1,5 @@
import { toISOFromTokens } from "../lib/dates"; import { toISOFromTokens } from "../lib/dates";
import { finalizeContact } from "../lib/contacts"; import { finalizeContact, isPrivacyContact } from "../lib/contacts";
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";
@@ -218,10 +217,7 @@ export function normalizeWhois(
// Derive privacy flag from registrant name/org keywords // Derive privacy flag from registrant name/org keywords
const registrant = contacts?.find((c) => c.type === "registrant"); const registrant = contacts?.find((c) => c.type === "registrant");
const privacyEnabled = !!( const privacyEnabled = isPrivacyContact(registrant);
registrant &&
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
);
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase(); const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : undefined; const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : undefined;