mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 01:25: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:
@@ -235,4 +235,6 @@ export async function isRegistered(domain: string, opts?: LookupOptions): Promis
|
||||
export const lookupDomain = lookup;
|
||||
|
||||
export { getDomainParts, getDomainTld, isLikelyDomain, toRegistrableDomain } from "./lib/domain";
|
||||
export { resolveCountry } from "./lib/countries";
|
||||
export { isPlaceholderValue, isPrivacyName } from "./lib/privacy";
|
||||
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 { isPlaceholderValue, isPrivacyName } from "./privacy";
|
||||
|
||||
function cleanValue(value: string | string[] | undefined): {
|
||||
function cleanValue(
|
||||
value: string | string[] | undefined,
|
||||
keepArray = false,
|
||||
): {
|
||||
value: string | string[] | undefined;
|
||||
dropped: boolean;
|
||||
} {
|
||||
@@ -11,25 +14,94 @@ function cleanValue(value: string | string[] | undefined): {
|
||||
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 };
|
||||
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
|
||||
* country/countryCode, and set `redacted` when any redaction signal is present.
|
||||
* `redactedHint` lets callers pass format-specific signals (e.g. RFC 9537 entries).
|
||||
* Post-process a parsed contact: drop placeholder values from every field (recording each in
|
||||
* `redactedFields`), flag privacy-service names (`privacyService`), resolve country/countryCode,
|
||||
* 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 {
|
||||
let redacted = redactedHint;
|
||||
for (const key of ["email", "phone", "fax"] as const) {
|
||||
const { value, dropped } = cleanValue(contact[key]);
|
||||
contact[key] = value;
|
||||
export function finalizeContact(
|
||||
contact: Contact,
|
||||
redactedHint: boolean | ContactField[] = false,
|
||||
): Contact {
|
||||
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 ([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);
|
||||
contact.country = country;
|
||||
contact.countryCode = countryCode;
|
||||
|
||||
if (redactedFields.size) {
|
||||
contact.redactedFields = CLEANED_FIELDS.filter((f) => redactedFields.has(f));
|
||||
redacted = true;
|
||||
}
|
||||
if (redacted) contact.redacted = true;
|
||||
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,
|
||||
/query the whois/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. */
|
||||
|
||||
@@ -303,6 +303,7 @@ test("normalizeRdap flags contacts with placeholder values or matching redaction
|
||||
expect(registrant?.email).toBeUndefined();
|
||||
expect(registrant?.redacted).toBe(true);
|
||||
expect(registrant?.countryCode).toBe("DE");
|
||||
expect(registrant?.redactedFields).toEqual(["email"]);
|
||||
expect(tech?.redacted).toBe(true);
|
||||
expect(admin?.redacted).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { resolveCountry } from "../lib/countries";
|
||||
import { finalizeContact } from "../lib/contacts";
|
||||
import { finalizeContact, isPrivacyContact, redactionFields } from "../lib/contacts";
|
||||
import { toISO } from "../lib/dates";
|
||||
import { isPrivacyName } from "../lib/privacy";
|
||||
import { asDateLike, asString, asStringArray, uniq } from "../lib/text";
|
||||
import { parseVcard } from "./vcard";
|
||||
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
|
||||
const registrant = contacts?.find((c) => c.type === "registrant");
|
||||
const privacyEnabled =
|
||||
!!(
|
||||
registrant &&
|
||||
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
|
||||
) ||
|
||||
isPrivacyContact(registrant) ||
|
||||
!!registrant?.redacted ||
|
||||
!!redactions?.some((r) => redactionTargetsRole(r, "registrant"));
|
||||
|
||||
@@ -258,8 +254,12 @@ function extractContacts(entities: unknown, redactions?: Redaction[]): Contact[]
|
||||
country: v.country,
|
||||
countryCode: v.countryCode,
|
||||
};
|
||||
const hinted = !!redactions?.some((r) => redactionTargetsRole(r, type.toLowerCase()));
|
||||
out.push(finalizeContact(contact, hinted));
|
||||
const matching = (redactions ?? []).filter((r) => redactionTargetsRole(r, type.toLowerCase()));
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -89,8 +89,28 @@ export interface Contact {
|
||||
countryCode?: string;
|
||||
/** True when any of this contact's data was redacted, withheld, or replaced by a placeholder */
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { toISOFromTokens } from "../lib/dates";
|
||||
import { finalizeContact } from "../lib/contacts";
|
||||
import { isPrivacyName } from "../lib/privacy";
|
||||
import { finalizeContact, isPrivacyContact } from "../lib/contacts";
|
||||
import { parseKeyValueLines, uniq } from "../lib/text";
|
||||
import type { Contact, DomainRecord, Nameserver, RegistrarInfo } from "../types";
|
||||
|
||||
@@ -218,10 +217,7 @@ export function normalizeWhois(
|
||||
|
||||
// Derive privacy flag from registrant name/org keywords
|
||||
const registrant = contacts?.find((c) => c.type === "registrant");
|
||||
const privacyEnabled = !!(
|
||||
registrant &&
|
||||
([registrant.name, registrant.organization].filter(Boolean) as string[]).some(isPrivacyName)
|
||||
);
|
||||
const privacyEnabled = isPrivacyContact(registrant);
|
||||
|
||||
const dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
|
||||
const dnssec = dnssecRaw ? { enabled: /signed|yes|true/.test(dnssecRaw) } : undefined;
|
||||
|
||||
Reference in New Issue
Block a user