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
This commit is contained in:
2026-09-19 23:04:01 -04:00
parent 386006dc61
commit 1d04be5c74
3 changed files with 75 additions and 24 deletions
+31
View File
@@ -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);
}
});
+43 -23
View File
@@ -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;
}