mirror of
https://github.com/jakejarvis/rdapper.git
synced 2026-09-23 00:15:31 -04:00
fix: fall back to vCard prePath in redactionFields and consolidate privacy phrase lists
- Extend `redactionFields(name, prePath?)` to parse the vCard property from an RFC 9537 `prePath` expression (e.g. `[?(@[0]=='email')]`) when the human-readable name alone doesn't identify fields; add `ADR_INDEX_FIELDS` to narrow a specific address element (`[3][N]`) to the right `ContactField` - Pass `r.prePath` through in `extractContacts` so redactions with generic names (e.g. `"Redacted"`) still populate `redactedFields` correctly - Split `PRIVACY_STRONG_KEYWORDS` source into `REDACTION_PHRASES` (text that stands in for a withheld value) and `PRIVACY_SERVICE_PHRASES` (proxy/shield names); derive `PLACEHOLDER_VALUE_PATTERNS` entries from `REDACTION_PHRASES` with `\b…\b` anchoring so the two lists can't drift apart - Drop `!!registrant?.redacted` from the `privacyEnabled` check in `normalizeRdap`; `isPrivacyContact` and the RFC 9537 redaction scan already cover the same signal without double-counting - Expand `isPlaceholderValue` test to assert that bare tokens (`"Unknown"`, `"Na"`) only match the whole value and not substrings like `"Unknown Pleasures Ltd"` or `"Na Health Inc"` - Update `privacyEnabled` JSDoc to clarify it is a coarse signal (an email-only redaction can set it) and point callers to `redactedFields`/`privacyService` for precision
This commit is contained in:
@@ -574,7 +574,7 @@ interface DomainRecord {
|
|||||||
>; // fields that were redacted (and are therefore absent)
|
>; // fields that were redacted (and are therefore absent)
|
||||||
privacyService?: boolean; // name/organization is a privacy/proxy service (text kept), not a redaction notice
|
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; // coarse: some registrant data was redacted or is behind a privacy service (name heuristics or RFC 9537 redactions); see the registrant contact's redactedFields/privacyService for which fields
|
||||||
redactions?: Array<{
|
redactions?: Array<{
|
||||||
name: string; // e.g. "Registrant Email"
|
name: string; // e.g. "Registrant Email"
|
||||||
prePath?: string;
|
prePath?: string;
|
||||||
|
|||||||
@@ -59,6 +59,20 @@ test("redactionFields maps RFC 9537 names to contact fields", () => {
|
|||||||
expect(redactionFields("Registrant Postal Code")).toEqual(["postalCode"]);
|
expect(redactionFields("Registrant Postal Code")).toEqual(["postalCode"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("redactionFields falls back to the vCard property in prePath", () => {
|
||||||
|
const path = (prop: string, tail = "") =>
|
||||||
|
`$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='${prop}')]${tail}`;
|
||||||
|
expect(redactionFields("Redacted", path("email"))).toEqual(["email"]);
|
||||||
|
expect(redactionFields("REDACTED", path("fn"))).toEqual(["name"]);
|
||||||
|
expect(redactionFields("x", path("org"))).toEqual(["organization"]);
|
||||||
|
expect(redactionFields("x", path("tel"))).toEqual(["phone"]);
|
||||||
|
expect(redactionFields("x", path("adr", "[3][3]"))).toEqual(["city"]);
|
||||||
|
expect(redactionFields("x", path("adr"))).toContain("street");
|
||||||
|
expect(redactionFields("x", "$.entities[0]")).toEqual([]);
|
||||||
|
// The name wins when it identifies fields
|
||||||
|
expect(redactionFields("Registrant Email", path("fn"))).toEqual(["email"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("isPlaceholderValue covers common registry boilerplate", () => {
|
test("isPlaceholderValue covers common registry boilerplate", () => {
|
||||||
for (const v of [
|
for (const v of [
|
||||||
"Not available from registry",
|
"Not available from registry",
|
||||||
@@ -71,7 +85,9 @@ test("isPlaceholderValue covers common registry boilerplate", () => {
|
|||||||
]) {
|
]) {
|
||||||
expect(isPlaceholderValue(v), v).toBe(true);
|
expect(isPlaceholderValue(v), v).toBe(true);
|
||||||
}
|
}
|
||||||
|
// Bare tokens only match the whole value
|
||||||
expect(isPlaceholderValue("Unknown Pleasures Ltd")).toBe(false);
|
expect(isPlaceholderValue("Unknown Pleasures Ltd")).toBe(false);
|
||||||
|
expect(isPlaceholderValue("Na Health Inc")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("package entry exports the contact predicates", () => {
|
test("package entry exports the contact predicates", () => {
|
||||||
|
|||||||
+39
-2
@@ -35,8 +35,25 @@ const CLEANED_FIELDS = [
|
|||||||
// Secondary fields: cleaned the same way but not reported (no `ContactField` for them).
|
// Secondary fields: cleaned the same way but not reported (no `ContactField` for them).
|
||||||
const CLEANED_EXTRA = ["organizationUnits", "title", "role"] as const;
|
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. */
|
// vCard property (from a redaction path like `[?(@[0]=='email')]`) to the contact fields it holds.
|
||||||
export function redactionFields(text: string): ContactField[] {
|
const VCARD_PROPERTY_FIELDS: Record<string, ContactField[]> = {
|
||||||
|
fn: ["name"],
|
||||||
|
n: ["name"],
|
||||||
|
org: ["organization"],
|
||||||
|
email: ["email"],
|
||||||
|
tel: ["phone"],
|
||||||
|
adr: ["poBox", "street", "city", "state", "postalCode"],
|
||||||
|
};
|
||||||
|
// Position within an `adr` value (`[3][N]`) to the field it holds.
|
||||||
|
const ADR_INDEX_FIELDS: Record<number, ContactField> = {
|
||||||
|
0: "poBox",
|
||||||
|
2: "street",
|
||||||
|
3: "city",
|
||||||
|
4: "state",
|
||||||
|
5: "postalCode",
|
||||||
|
};
|
||||||
|
|
||||||
|
function fieldsFromName(text: string): ContactField[] {
|
||||||
const t = text.toLowerCase();
|
const t = text.toLowerCase();
|
||||||
const out: ContactField[] = [];
|
const out: ContactField[] = [];
|
||||||
if (/\bname\b|\bfn\b/.test(t) && !/\borg/.test(t)) out.push("name");
|
if (/\bname\b|\bfn\b/.test(t) && !/\borg/.test(t)) out.push("name");
|
||||||
@@ -52,6 +69,26 @@ export function redactionFields(text: string): ContactField[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldsFromPath(path: string): ContactField[] {
|
||||||
|
const prop = /@\[0\]\s*==\s*'([a-z]+)'/i.exec(path)?.[1]?.toLowerCase();
|
||||||
|
if (!prop) return [];
|
||||||
|
if (prop === "adr") {
|
||||||
|
const idx = /\[3\]\[(\d+)\]\s*$/.exec(path)?.[1];
|
||||||
|
const field = idx === undefined ? undefined : ADR_INDEX_FIELDS[Number(idx)];
|
||||||
|
if (field) return [field];
|
||||||
|
}
|
||||||
|
return VCARD_PROPERTY_FIELDS[prop] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contact fields an RFC 9537 redaction covers, from its human-readable name (e.g. "Registrant
|
||||||
|
* Email") and, failing that, the vCard property in its `prePath`.
|
||||||
|
*/
|
||||||
|
export function redactionFields(name: string, prePath?: string): ContactField[] {
|
||||||
|
const fromName = fieldsFromName(name);
|
||||||
|
return fromName.length || !prePath ? fromName : fieldsFromPath(prePath);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Post-process a parsed contact: drop placeholder values from every field (recording each in
|
* Post-process a parsed contact: drop placeholder values from every field (recording each in
|
||||||
* `redactedFields`), flag privacy-service names (`privacyService`), resolve country/countryCode,
|
* `redactedFields`), flag privacy-service names (`privacyService`), resolve country/countryCode,
|
||||||
|
|||||||
+23
-13
@@ -1,20 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* Phrases that indicate privacy/redaction on their own. Matched as case-insensitive substrings.
|
* Redaction notices: text that stands in for a withheld value. Shared by `isPrivacyName` (as
|
||||||
|
* substrings) and `isPlaceholderValue` (as whole words), so the two can't drift apart.
|
||||||
*/
|
*/
|
||||||
export const PRIVACY_STRONG_KEYWORDS = [
|
const REDACTION_PHRASES = [
|
||||||
"redacted", // also covers "redacted for privacy", "redacted.forprivacy"
|
"redacted", // also covers "redacted for privacy", "redacted.forprivacy"
|
||||||
"withheld",
|
"withheld",
|
||||||
"not disclosed",
|
"not disclosed",
|
||||||
"privado", // Spanish
|
|
||||||
"datos privados", // Spanish
|
|
||||||
"data protected",
|
"data protected",
|
||||||
"gdpr masked",
|
"gdpr masked",
|
||||||
"non-public data",
|
|
||||||
"statutory masking",
|
"statutory masking",
|
||||||
"registration private",
|
"non-public data",
|
||||||
"private registration",
|
|
||||||
"hidden upon user request",
|
"hidden upon user request",
|
||||||
"not available from registry",
|
"not available from registry",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privacy/proxy service names and other phrases that indicate privacy on their own. Unlike
|
||||||
|
* redaction notices these can be real registrant text worth showing, so they aren't placeholders.
|
||||||
|
*/
|
||||||
|
const PRIVACY_SERVICE_PHRASES = [
|
||||||
|
"privado", // Spanish
|
||||||
|
"datos privados", // Spanish
|
||||||
|
"registration private",
|
||||||
|
"private registration",
|
||||||
"whois privacy",
|
"whois privacy",
|
||||||
"whoisguard",
|
"whoisguard",
|
||||||
"privacy protect",
|
"privacy protect",
|
||||||
@@ -26,6 +34,11 @@ export const PRIVACY_STRONG_KEYWORDS = [
|
|||||||
"for privacy",
|
"for privacy",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phrases that indicate privacy/redaction on their own. Matched as case-insensitive substrings.
|
||||||
|
*/
|
||||||
|
export const PRIVACY_STRONG_KEYWORDS = [...REDACTION_PHRASES, ...PRIVACY_SERVICE_PHRASES];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Words too ambiguous to trust alone ("Private Equity LLC", "Protection One"). They only count
|
* Words too ambiguous to trust alone ("Private Equity LLC", "Protection One"). They only count
|
||||||
* when at least two distinct terms from WEAK + CONTEXT appear as whole words.
|
* when at least two distinct terms from WEAK + CONTEXT appear as whole words.
|
||||||
@@ -60,19 +73,16 @@ export function isPrivacyName(value: string): boolean {
|
|||||||
return new Set(v.match(CONTEXT_WORD_RE)).size >= 2;
|
return new Set(v.match(CONTEXT_WORD_RE)).size >= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escapeRe = (v: string) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
|
||||||
// Boilerplate that registrars put in email/phone/etc. instead of real values
|
// Boilerplate that registrars put in email/phone/etc. instead of real values
|
||||||
const PLACEHOLDER_VALUE_PATTERNS = [
|
const PLACEHOLDER_VALUE_PATTERNS = [
|
||||||
/\bredacted\b/i,
|
...REDACTION_PHRASES.map((p) => new RegExp(`\\b${escapeRe(p)}\\b`, "i")),
|
||||||
/\bwithheld\b/i,
|
|
||||||
/\bnot disclosed\b/i,
|
|
||||||
/please query the rdds/i,
|
/please query the rdds/i,
|
||||||
/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,
|
||||||
/\bnot available from registry\b/i,
|
|
||||||
/\bnot applicable\b/i,
|
/\bnot applicable\b/i,
|
||||||
/\bdata protected\b/i,
|
|
||||||
/\b(?:statutory|gdpr) mask(?:ing|ed)\b/i,
|
|
||||||
/select request email form/i,
|
/select request email form/i,
|
||||||
/^(?:-+|n\/a|na|none|null|undefined|unknown)$/i,
|
/^(?:-+|n\/a|na|none|null|undefined|unknown)$/i,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -325,3 +325,28 @@ test("normalizeRdap resolves registrar countryCode from the country name", () =>
|
|||||||
);
|
);
|
||||||
expect(rec.registrar).toMatchObject({ country: "Canada", countryCode: "CA" });
|
expect(rec.registrar).toMatchObject({ country: "Canada", countryCode: "CA" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("normalizeRdap ties a redaction path to redactedFields", () => {
|
||||||
|
const entity = {
|
||||||
|
roles: ["registrant"],
|
||||||
|
vcardArray: ["vcard", [["fn", {}, "text", "Jane Doe"]]],
|
||||||
|
};
|
||||||
|
const emailOnly = normalizeRdap(
|
||||||
|
"example.com",
|
||||||
|
"com",
|
||||||
|
{
|
||||||
|
ldhName: "example.com",
|
||||||
|
redacted: [
|
||||||
|
{
|
||||||
|
name: { description: "Registrant Email" },
|
||||||
|
prePath: "$.entities[?(@.roles[0]=='registrant')].vcardArray[1][?(@[0]=='email')][3]",
|
||||||
|
method: "emptyValue",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
entities: [entity],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(emailOnly.contacts?.[0]?.redactedFields).toEqual(["email"]);
|
||||||
|
expect(emailOnly.contacts?.[0]?.redacted).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export function normalizeRdap(
|
|||||||
const registrant = contacts?.find((c) => c.type === "registrant");
|
const registrant = contacts?.find((c) => c.type === "registrant");
|
||||||
const privacyEnabled =
|
const privacyEnabled =
|
||||||
isPrivacyContact(registrant) ||
|
isPrivacyContact(registrant) ||
|
||||||
!!registrant?.redacted ||
|
|
||||||
!!redactions?.some((r) => redactionTargetsRole(r, "registrant"));
|
!!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.
|
||||||
@@ -257,7 +256,7 @@ function extractContacts(entities: unknown, redactions?: Redaction[]): Contact[]
|
|||||||
const matching = (redactions ?? []).filter((r) => redactionTargetsRole(r, type.toLowerCase()));
|
const matching = (redactions ?? []).filter((r) => redactionTargetsRole(r, type.toLowerCase()));
|
||||||
// Fields the redaction names, limited to ones actually absent (a present value wasn't hidden).
|
// Fields the redaction names, limited to ones actually absent (a present value wasn't hidden).
|
||||||
const fields = matching
|
const fields = matching
|
||||||
.flatMap((r) => redactionFields(r.name))
|
.flatMap((r) => redactionFields(r.name, r.prePath))
|
||||||
.filter((f) => !contact[f] || (Array.isArray(contact[f]) && !contact[f]?.length));
|
.filter((f) => !contact[f] || (Array.isArray(contact[f]) && !contact[f]?.length));
|
||||||
out.push(finalizeContact(contact, fields.length ? fields : matching.length > 0));
|
out.push(finalizeContact(contact, fields.length ? fields : matching.length > 0));
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -240,7 +240,12 @@ export interface DomainRecord {
|
|||||||
nameservers?: Nameserver[];
|
nameservers?: Nameserver[];
|
||||||
/** Contacts (registrant, admin, tech, billing, abuse, etc.) */
|
/** Contacts (registrant, admin, tech, billing, abuse, etc.) */
|
||||||
contacts?: Contact[];
|
contacts?: Contact[];
|
||||||
/** Best guess that the registrant is hidden behind a privacy service or redacted, from name/organization phrases or RFC 9537 redactions */
|
/**
|
||||||
|
* Coarse signal that some registrant data was redacted or is behind a privacy service, from
|
||||||
|
* name/organization phrases or RFC 9537 registrant redactions. It does not mean the registrant's
|
||||||
|
* identity is hidden (an email-only redaction sets it); check the registrant contact's
|
||||||
|
* `redactedFields` and `privacyService` for precision.
|
||||||
|
*/
|
||||||
privacyEnabled?: boolean;
|
privacyEnabled?: boolean;
|
||||||
/** RFC 9537 redaction metadata reported by RDAP, if any */
|
/** RFC 9537 redaction metadata reported by RDAP, if any */
|
||||||
redactions?: Redaction[];
|
redactions?: Redaction[];
|
||||||
|
|||||||
Reference in New Issue
Block a user