From 1d04be5c74d23ef084911d14339b336a5c2874fa Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sat, 19 Sep 2026 23:04:01 -0400 Subject: [PATCH] fix: tighten `isPrivacyName` to avoid false positives on ambiguous privacy words - Split keyword list into `PRIVACY_STRONG_KEYWORDS` (matched as plain substrings) and two weaker sets (`PRIVACY_WEAK_WORDS` / `PRIVACY_CONTEXT_WORDS`) that only trigger when at least two distinct terms appear together, preventing names like "Private Equity Partners LLC" or "Protection One" from being misclassified - Remove empty-placeholder values (`-`, `n/a`, `none`, etc.) from the privacy check; they are not privacy notices and should be handled separately by callers - Add `privacy.test.ts` covering redaction notices, known privacy-service names, ordinary names with ambiguous words, and empty placeholders - Update README comment to clarify that `privacyEnabled` is set by name heuristics (privacy-service and redaction phrases) or RFC 9537 redactions --- README.md | 2 +- src/lib/privacy.test.ts | 31 +++++++++++++++++++ src/lib/privacy.ts | 66 +++++++++++++++++++++++++++-------------- 3 files changed, 75 insertions(+), 24 deletions(-) create mode 100644 src/lib/privacy.test.ts diff --git a/README.md b/README.md index 1919a29..0103544 100644 --- a/README.md +++ b/README.md @@ -555,7 +555,7 @@ interface DomainRecord { country?: string; countryCode?: string; }>; - privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics 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<{ name: string; // e.g. "Registrant Email" prePath?: string; diff --git a/src/lib/privacy.test.ts b/src/lib/privacy.test.ts new file mode 100644 index 0000000..5cbb857 --- /dev/null +++ b/src/lib/privacy.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vitest"; +import { isPrivacyName } from "./privacy"; + +test("isPrivacyName flags redaction notices and privacy services", () => { + for (const v of [ + "REDACTED FOR PRIVACY", + "Data Redacted", + "Privacy Protect, LLC", + "WhoisGuard Protected", + "Domains By Proxy, LLC", + "Private Registration", + "Domain Protection Services", + "Identity Protection Service", + "Withheld for Privacy ehf", + "Datos Privados", + ]) { + expect(isPrivacyName(v), v).toBe(true); + } +}); + +test("isPrivacyName does not flag ordinary names with ambiguous words", () => { + for (const v of ["Private Equity Partners LLC", "Protection One", "Jane Private", "Acme Inc"]) { + expect(isPrivacyName(v), v).toBe(false); + } +}); + +test("isPrivacyName does not treat empty placeholders as privacy", () => { + for (const v of ["-", "n/a", "N/A", "none", "not available"]) { + expect(isPrivacyName(v), v).toBe(false); + } +}); diff --git a/src/lib/privacy.ts b/src/lib/privacy.ts index 1cd3879..27d608d 100644 --- a/src/lib/privacy.ts +++ b/src/lib/privacy.ts @@ -1,41 +1,61 @@ -export const PRIVACY_NAME_KEYWORDS = [ - "redacted", - "privacy", - "private", +/** + * Phrases that indicate privacy/redaction on their own. Matched as case-insensitive substrings. + */ +export const PRIVACY_STRONG_KEYWORDS = [ + "redacted", // also covers "redacted for privacy", "redacted.forprivacy" "withheld", "not disclosed", - "protected", - "protection", "privado", // Spanish "datos privados", // Spanish "data protected", - "data redacted", - "gdpr redacted", "gdpr masked", "non-public data", "statutory masking", - "redacted.forprivacy", "registration private", + "private registration", "hidden upon user request", "not available from registry", + "whois privacy", + "whoisguard", + "privacy protect", + "privacy service", + "domain privacy", + "contact privacy", + "domains by proxy", + "proxy service", + "for privacy", ]; -// Completely unusable/empty values that should be filtered -export const NO_DATA_VALUES = [ - "-", - ".", - "n/a", - "na", - "no data", - "not available", - "not applicable", - "none", +/** + * 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. + */ +const PRIVACY_WEAK_WORDS = ["privacy", "private", "protect", "protected", "protection"]; +const PRIVACY_CONTEXT_WORDS = [ + "whois", + "proxy", + "domain", + "domains", + "registration", + "guard", + "service", + "services", + "masked", + "anonymous", + "identity", + "contact", ]; +const WEAK_WORD_RE = new RegExp(`\\b(?:${PRIVACY_WEAK_WORDS.join("|")})\\b`, "g"); +const CONTEXT_WORD_RE = new RegExp( + `\\b(?:${[...PRIVACY_WEAK_WORDS, ...PRIVACY_CONTEXT_WORDS].join("|")})\\b`, + "g", +); +/** True when a registrant name/organization looks like a privacy service or redaction notice. */ export function isPrivacyName(value: string): boolean { const v = value.toLowerCase().trim(); - // Check for complete no-data values - if (NO_DATA_VALUES.includes(v)) return true; - // Check for privacy keywords - return PRIVACY_NAME_KEYWORDS.some((k) => v.includes(k)); + if (PRIVACY_STRONG_KEYWORDS.some((k) => v.includes(k))) return true; + if (!WEAK_WORD_RE.test(v)) return false; + WEAK_WORD_RE.lastIndex = 0; + return new Set(v.match(CONTEXT_WORD_RE)).size >= 2; }