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