mirror of
https://github.com/jakejarvis/rdapper.git
synced 2025-10-18 20:14:27 -04:00
New privacyEnabled
field to detect redaction based on keyword heuristics
Updated the DomainRecord interface to include detailed comments for clarity and added a new privacyEnabled field to indicate if the registrant's information is privacy-redacted. Enhanced normalization functions for both RDAP and WHOIS to derive the privacy flag based on registrant keywords. Updated tests to verify the correct functionality of privacy detection in both normalization processes. Additionally, the README was updated to reflect the inclusion of the privacy flag in the normalized output.
This commit is contained in:
@@ -4,7 +4,7 @@ RDAP‑first domain registration lookups with WHOIS fallback. Produces a single,
|
||||
|
||||
- RDAP discovery via IANA bootstrap (`https://data.iana.org/rdap/dns.json`)
|
||||
- WHOIS TCP 43 client with TLD discovery, registrar referral follow, and curated exceptions
|
||||
- Normalized output: registrar, contacts, nameservers, statuses, dates, DNSSEC, source metadata
|
||||
- Normalized output: registrar, contacts, nameservers, statuses, dates, DNSSEC, privacy flag, source metadata
|
||||
- TypeScript types included; ESM‑only; no external HTTP client (uses global `fetch`)
|
||||
|
||||
### [🦉 See it in action on hoot.sh!](https://hoot.sh)
|
||||
@@ -117,6 +117,7 @@ interface DomainRecord {
|
||||
country?: string;
|
||||
countryCode?: string;
|
||||
}>;
|
||||
privacyEnabled?: boolean; // registrant appears privacy-redacted based on keyword heuristics
|
||||
whoisServer?: string; // authoritative WHOIS queried (if any)
|
||||
rdapServers?: string[]; // RDAP base URLs tried
|
||||
rawRdap?: unknown; // raw RDAP JSON (only when options.includeRaw)
|
||||
|
14
src/lib/privacy.ts
Normal file
14
src/lib/privacy.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export const PRIVACY_NAME_KEYWORDS = [
|
||||
"redacted",
|
||||
"privacy",
|
||||
"private",
|
||||
"withheld",
|
||||
"not disclosed",
|
||||
"protected",
|
||||
"protection",
|
||||
];
|
||||
|
||||
export function isPrivacyName(value: string): boolean {
|
||||
const v = value.toLowerCase();
|
||||
return PRIVACY_NAME_KEYWORDS.some((k) => v.includes(k));
|
||||
}
|
@@ -80,3 +80,30 @@ test("normalizeRdap maps registrar, contacts, nameservers, events, dnssec", () =
|
||||
expect(rec.whoisServer).toBe("whois.example-registrar.test");
|
||||
expect(rec.source).toBe("rdap");
|
||||
});
|
||||
|
||||
test("normalizeRdap derives privacyEnabled from registrant keywords", () => {
|
||||
const rdap = {
|
||||
ldhName: "example.com",
|
||||
unicodeName: "example.com",
|
||||
entities: [
|
||||
{
|
||||
roles: ["registrant"],
|
||||
vcardArray: [
|
||||
"vcard",
|
||||
[
|
||||
["fn", {}, "text", "REDACTED FOR PRIVACY"],
|
||||
["org", {}, "text", "Example Org"],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const rec = normalizeRdap(
|
||||
"example.com",
|
||||
"com",
|
||||
rdap,
|
||||
["https://rdap.example/"],
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
expect(rec.privacyEnabled).toBe(true);
|
||||
});
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import { toISO } from "../lib/dates";
|
||||
import { isPrivacyName } from "../lib/privacy";
|
||||
import { asDateLike, asString, asStringArray, uniq } from "../lib/text";
|
||||
import type {
|
||||
Contact,
|
||||
@@ -58,6 +59,15 @@ export function normalizeRdap(
|
||||
doc.entities as unknown,
|
||||
);
|
||||
|
||||
// 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)
|
||||
);
|
||||
|
||||
// RDAP uses IANA EPP status values. Preserve raw plus a description if any remarks are present.
|
||||
const statuses = Array.isArray(doc.status)
|
||||
? (doc.status as unknown[])
|
||||
@@ -139,6 +149,7 @@ export function normalizeRdap(
|
||||
? uniq(nameservers.map((n) => ({ ...n, host: n.host.toLowerCase() })))
|
||||
: undefined,
|
||||
contacts,
|
||||
privacyEnabled: privacyEnabled ? true : undefined,
|
||||
whoisServer,
|
||||
rdapServers: rdapServersTried,
|
||||
rawRdap: includeRaw ? rdap : undefined,
|
||||
|
88
src/types.ts
88
src/types.ts
@@ -44,21 +44,37 @@ export interface StatusEvent {
|
||||
}
|
||||
|
||||
export interface DomainRecord {
|
||||
/** Normalized domain name */
|
||||
domain: string;
|
||||
/** Terminal TLD */
|
||||
tld: string;
|
||||
isRegistered: boolean; // whether the domain appears registered
|
||||
/** Whether the domain is registered */
|
||||
isRegistered: boolean;
|
||||
/** Whether the domain is internationalized (IDN) */
|
||||
isIDN?: boolean;
|
||||
/** Unicode name */
|
||||
unicodeName?: string;
|
||||
/** Punycode name */
|
||||
punycodeName?: string;
|
||||
registry?: string; // Registry operator if available
|
||||
/** Registry operator */
|
||||
registry?: string;
|
||||
/** Registrar */
|
||||
registrar?: RegistrarInfo;
|
||||
/** Reseller (if applicable) */
|
||||
reseller?: string;
|
||||
statuses?: StatusEvent[]; // EPP statuses
|
||||
creationDate?: string; // ISO 8601
|
||||
updatedDate?: string; // ISO 8601
|
||||
expirationDate?: string; // ISO 8601
|
||||
deletionDate?: string; // ISO 8601
|
||||
/** EPP status codes */
|
||||
statuses?: StatusEvent[];
|
||||
/** Creation date in ISO 8601 */
|
||||
creationDate?: string;
|
||||
/** Updated date in ISO 8601 */
|
||||
updatedDate?: string;
|
||||
/** Expiration date in ISO 8601 */
|
||||
expirationDate?: string;
|
||||
/** Deletion date in ISO 8601 */
|
||||
deletionDate?: string;
|
||||
/** Transfer lock */
|
||||
transferLock?: boolean;
|
||||
/** DNSSEC data (if available) */
|
||||
dnssec?: {
|
||||
enabled: boolean;
|
||||
dsRecords?: Array<{
|
||||
@@ -68,30 +84,52 @@ export interface DomainRecord {
|
||||
digest?: string;
|
||||
}>;
|
||||
};
|
||||
/** Nameservers */
|
||||
nameservers?: Nameserver[];
|
||||
/** Contacts (registrant, admin, tech, billing, abuse, etc.) */
|
||||
contacts?: Contact[];
|
||||
whoisServer?: string; // authoritative WHOIS queried (if any)
|
||||
rdapServers?: string[]; // RDAP base URLs tried
|
||||
rawRdap?: unknown; // raw RDAP JSON
|
||||
rawWhois?: string; // raw WHOIS text (last authoritative)
|
||||
source: LookupSource; // which source produced data
|
||||
fetchedAt: string; // ISO 8601
|
||||
/** Best guess as to whether registrant is redacted based on keywords */
|
||||
privacyEnabled?: boolean;
|
||||
/** Authoritative WHOIS queried (if any) */
|
||||
whoisServer?: string;
|
||||
/** RDAP base URLs tried */
|
||||
rdapServers?: string[];
|
||||
/** Raw RDAP JSON */
|
||||
rawRdap?: unknown;
|
||||
/** Raw WHOIS text (last authoritative) */
|
||||
rawWhois?: string;
|
||||
/** Which source produced data */
|
||||
source: LookupSource;
|
||||
/** ISO 8601 timestamp at time of lookup */
|
||||
fetchedAt: string;
|
||||
/** Warnings generated during lookup */
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface LookupOptions {
|
||||
timeoutMs?: number; // total timeout budget
|
||||
rdapOnly?: boolean; // don't fall back to WHOIS
|
||||
whoisOnly?: boolean; // don't attempt RDAP
|
||||
followWhoisReferral?: boolean; // follow referral server (default true)
|
||||
maxWhoisReferralHops?: number; // maximum registrar WHOIS referral hops (default 2)
|
||||
rdapFollowLinks?: boolean; // follow RDAP related/entity links (default true)
|
||||
maxRdapLinkHops?: number; // maximum RDAP related link fetches (default 2)
|
||||
rdapLinkRels?: string[]; // RDAP link rels to consider (default ["related","entity","registrar","alternate"])
|
||||
customBootstrapUrl?: string; // override IANA bootstrap
|
||||
// WHOIS discovery and query tuning
|
||||
whoisHints?: Record<string, string>; // override/add authoritative WHOIS per TLD
|
||||
includeRaw?: boolean; // include rawRdap/rawWhois in results (default false)
|
||||
/** Total timeout budget */
|
||||
timeoutMs?: number;
|
||||
/** Don't fall back to WHOIS */
|
||||
rdapOnly?: boolean;
|
||||
/** Don't attempt RDAP */
|
||||
whoisOnly?: boolean;
|
||||
/** Follow referral server (default true) */
|
||||
followWhoisReferral?: boolean;
|
||||
/** Maximum registrar WHOIS referral hops (default 2) */
|
||||
maxWhoisReferralHops?: number;
|
||||
/** Follow RDAP related/entity links (default true) */
|
||||
rdapFollowLinks?: boolean;
|
||||
/** Maximum RDAP related link fetches (default 2) */
|
||||
maxRdapLinkHops?: number;
|
||||
/** RDAP link rels to consider (default ["related","entity","registrar","alternate"]) */
|
||||
rdapLinkRels?: string[];
|
||||
/** Override IANA bootstrap */
|
||||
customBootstrapUrl?: string;
|
||||
/** Override/add authoritative WHOIS per TLD */
|
||||
whoisHints?: Record<string, string>;
|
||||
/** Include rawRdap/rawWhois in results (default false) */
|
||||
includeRaw?: boolean;
|
||||
/** Optional cancellation signal */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
|
@@ -169,3 +169,21 @@ Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProh
|
||||
expect(Boolean(rec.expirationDate)).toBe(true);
|
||||
expect(rec.source).toBe("whois");
|
||||
});
|
||||
|
||||
test("WHOIS derives privacyEnabled from registrant keywords", () => {
|
||||
const text = `
|
||||
Domain Name: EXAMPLE.COM
|
||||
Registrar WHOIS Server: whois.registrar.test
|
||||
Registrar URL: http://www.registrar.test
|
||||
Registrant Name: REDACTED FOR PRIVACY
|
||||
Registrant Organization: Example Org
|
||||
`;
|
||||
const rec = normalizeWhois(
|
||||
"example.com",
|
||||
"com",
|
||||
text,
|
||||
"whois.verisign-grs.com",
|
||||
"2025-01-01T00:00:00Z",
|
||||
);
|
||||
expect(rec.privacyEnabled).toBe(true);
|
||||
});
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import { toISO } from "../lib/dates";
|
||||
import { isWhoisAvailable } from "../lib/domain";
|
||||
import { isPrivacyName } from "../lib/privacy";
|
||||
import { parseKeyValueLines, uniq } from "../lib/text";
|
||||
import type {
|
||||
Contact,
|
||||
@@ -132,6 +133,15 @@ export function normalizeWhois(
|
||||
// Contacts: best-effort parse common keys
|
||||
const contacts = collectContacts(map);
|
||||
|
||||
// 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 dnssecRaw = (map.dnssec?.[0] || "").toLowerCase();
|
||||
const dnssec = dnssecRaw
|
||||
? { enabled: /signed|yes|true/.test(dnssecRaw) }
|
||||
@@ -161,6 +171,7 @@ export function normalizeWhois(
|
||||
dnssec,
|
||||
nameservers,
|
||||
contacts,
|
||||
privacyEnabled: privacyEnabled ? true : undefined,
|
||||
whoisServer,
|
||||
rdapServers: undefined,
|
||||
rawRdap: undefined,
|
||||
|
Reference in New Issue
Block a user