@slicedearth/whoisleuth-cli 1.34.0 → 1.36.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +2 -0
  2. package/cli/arguments.mjs +92 -0
  3. package/cli/artifact-verify.mjs +4 -0
  4. package/cli/bulk.mjs +1 -2
  5. package/cli/completion.mjs +19 -0
  6. package/cli/export-evidence.mjs +9 -2
  7. package/cli/formatters/terminal.mjs +7 -1
  8. package/cli/investigation-plan.mjs +112 -0
  9. package/cli/mail-review.mjs +47 -2
  10. package/cli/offline-evidence-review.mjs +141 -0
  11. package/cli/registry-support.mjs +1 -4
  12. package/cli/runner.mjs +109 -1
  13. package/docs/cli.md +102 -9
  14. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  15. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  16. package/frontend/src/lib/analysis/http-summary.js +2 -1
  17. package/frontend/src/lib/analysis/utils.js +7 -40
  18. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  19. package/lib/availability.mjs +30 -10
  20. package/lib/bulk-limits.mjs +13 -0
  21. package/lib/ct-response-bounds.mjs +9 -0
  22. package/lib/ct-search.mjs +66 -12
  23. package/lib/dns-delegation-health.mjs +56 -5
  24. package/lib/dnssec-evidence-validation.mjs +235 -0
  25. package/lib/domain-control-manifest.mjs +339 -0
  26. package/lib/domain-posture.mjs +2 -1
  27. package/lib/encrypted-dns-contract.mjs +81 -0
  28. package/lib/evidence-export.mjs +17 -3
  29. package/lib/evidence-report.mjs +3 -0
  30. package/lib/favicon.mjs +3 -8
  31. package/lib/html-signals.mjs +4 -0
  32. package/lib/http-evidence-bounds.mjs +4 -0
  33. package/lib/http-intelligence.mjs +2 -1
  34. package/lib/ip-prefix.mjs +54 -0
  35. package/lib/local-geoip-evidence.mjs +89 -0
  36. package/lib/outbound-identity.mjs +9 -0
  37. package/lib/outbound-request-bounds.mjs +9 -0
  38. package/lib/perceptual-hash-comparison.mjs +34 -0
  39. package/lib/perceptual-hash.mjs +3 -31
  40. package/lib/project-metadata.mjs +8 -0
  41. package/lib/provider-policy-admission.mjs +49 -0
  42. package/lib/rdap-search-workbench.mjs +157 -0
  43. package/lib/rdap.mjs +7 -2
  44. package/lib/registry-capabilities.mjs +31 -1
  45. package/lib/registry-capability-catalogue.mjs +11 -10
  46. package/lib/response-policy.mjs +81 -6
  47. package/lib/rpki-evidence.mjs +105 -0
  48. package/lib/safe-fetch.mjs +2 -1
  49. package/lib/semantic-version.mjs +54 -0
  50. package/lib/static-html-analysis.mjs +27 -0
  51. package/lib/threatfox-intelligence.mjs +10 -5
  52. package/lib/tlsa-evidence.mjs +194 -0
  53. package/lib/urlhaus-intelligence.mjs +10 -5
  54. package/lib/urlscan-intelligence.mjs +10 -5
  55. package/lib/website-security-posture.mjs +11 -4
  56. package/lib/whois-chain.mjs +14 -152
  57. package/lib/whois.mjs +2 -2
  58. package/package.json +2 -2
@@ -1,7 +1,9 @@
1
1
  import { compareRdapPublications, compareRegistrySources } from "./registry-comparison.mjs";
2
2
  import { buildRegistryInsights } from "./registry-insights.mjs";
3
+ import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "./project-metadata.mjs";
4
+ import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
3
5
  export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
4
- export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 23;
6
+ export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 24;
5
7
  const REGISTRAR_RDAP_STATUSES = new Set([
6
8
  'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
7
9
  ]);
@@ -37,6 +39,14 @@ function boundedTimestamp(value) {
37
39
  const time = Date.parse(text);
38
40
  return Number.isFinite(time) ? new Date(time).toISOString() : null;
39
41
  }
42
+ function boundedSemanticVersion(value) {
43
+ try {
44
+ return normalizeBoundedSemanticVersion(value, 'Evidence application');
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
40
50
  function boundedEndpoint(value) {
41
51
  const text = boundedString(value, 2048);
42
52
  if (!text)
@@ -359,7 +369,7 @@ function registrarPublicationComparison(body, registryParsed) {
359
369
  });
360
370
  }
361
371
  export function buildLookupEvidence(response, options = {}) {
362
- const { generatedAt = new Date().toISOString(), idnAnalysis = null } = options;
372
+ const { generatedAt = new Date().toISOString(), idnAnalysis = null, applicationVersion = null } = options;
363
373
  const body = recordOrNull(response) || {};
364
374
  const rdap = recordOrNull(body.rdap);
365
375
  const whois = recordOrNull(body.whois);
@@ -389,7 +399,11 @@ export function buildLookupEvidence(response, options = {}) {
389
399
  schema: LOOKUP_EVIDENCE_SCHEMA,
390
400
  schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
391
401
  generatedAt,
392
- application: { name: 'WHOISleuth' },
402
+ application: {
403
+ name: 'WHOISleuth',
404
+ version: boundedSemanticVersion(applicationVersion),
405
+ projectUrl: WHOISLEUTH_SOURCE_REPOSITORY_URL,
406
+ },
393
407
  query: {
394
408
  submitted: body.query || null,
395
409
  type: body.type || null,
@@ -89,6 +89,7 @@ function reportField(label, value, fallback = 'Not reported') {
89
89
  }
90
90
  function buildLookupEvidenceReport(document, options = {}) {
91
91
  const report = objectOrEmpty(document);
92
+ const application = objectOrEmpty(report.application);
92
93
  const query = objectOrEmpty(report.query);
93
94
  const diagnostics = objectOrEmpty(report.diagnostics);
94
95
  const rdapDiagnostics = objectOrEmpty(diagnostics.rdap);
@@ -300,6 +301,8 @@ function buildLookupEvidenceReport(document, options = {}) {
300
301
  notice: 'Human-readable summary. The versioned JSON export remains the full-fidelity evidence package; raw RDAP JSON and WHOIS response bodies are deliberately omitted here.',
301
302
  metadata: [
302
303
  reportField('Generated', report.generatedAt),
304
+ reportField('Generator', application.version ? `${application.name || 'WHOISleuth'} ${application.version}` : application.name || 'WHOISleuth'),
305
+ reportField('Project', application.projectUrl),
303
306
  reportField('Report contract', `${cleanReportText(report.schema)} v${cleanReportText(report.schemaVersion)}`),
304
307
  ],
305
308
  query: [
package/lib/favicon.mjs CHANGED
@@ -14,14 +14,9 @@
14
14
  // adds coverage without weakening the exact tier.
15
15
  import * as crypto from 'node:crypto';
16
16
  import { safeFetch, readBytesCapped } from "./safe-fetch.mjs";
17
+ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
17
18
  import { faviconPerceptualHash } from "./perceptual-hash.mjs";
18
- const MAX_FAVICON_BYTES = 200000; // generous for a favicon, small enough to bound memory
19
- const FAVICON_FETCH_TIMEOUT_MS = 5000;
20
- // Bounds worst-case work when a (possibly hostile) page declares many icon
21
- // links - we try candidates in priority order and stop at the first that
22
- // yields hashable bytes, so this only bites on pages where every earlier
23
- // candidate fails.
24
- const MAX_FAVICON_CANDIDATES = 4;
19
+ import { FAVICON_FETCH_TIMEOUT_MS, MAX_FAVICON_BYTES, MAX_FAVICON_CANDIDATES, } from "./outbound-request-bounds.mjs";
25
20
  // Extracts favicon URLs declared in the page's own <link rel="...icon..."> tags
26
21
  // (resolved to absolute URLs against the page origin), in preference order:
27
22
  // standard "icon"/"shortcut icon" first, then "apple-touch-icon". Many modern
@@ -129,7 +124,7 @@ function buildFaviconCandidates(domain, html = '') {
129
124
  return candidates.slice(0, MAX_FAVICON_CANDIDATES);
130
125
  }
131
126
  async function fetchFaviconHash(domain, { html = '' } = {}) {
132
- const headers = { 'User-Agent': 'Mozilla/5.0 (compatible; DomainStatusChecker/1.0)' };
127
+ const headers = whoisleuthRequestHeaders();
133
128
  const candidates = buildFaviconCandidates(domain, html);
134
129
  for (const url of candidates) {
135
130
  // eslint-disable-next-line no-await-in-loop
@@ -10,6 +10,7 @@ import { analyzeClientBehavior } from "./client-behavior-profile.mjs";
10
10
  import { createObservation } from "./observation.mjs";
11
11
  import { createPageFingerprints } from "./page-fingerprints.mjs";
12
12
  import { analyzePageRole } from "./page-role-profile.mjs";
13
+ import { analyzeCspMetaPolicies } from "./response-policy.mjs";
13
14
  import { analyzeStaticHtml } from "./static-html-analysis.mjs";
14
15
  import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
15
16
  import { analyzeWebsiteTechnology } from "./website-technology.mjs";
@@ -642,6 +643,9 @@ function extractHtmlSignals(html, domain, options = {}) {
642
643
  ? pageIdentity.forms.externalActionOrigins.length > 0
643
644
  : null,
644
645
  externalAssetHosts: extractExternalAssetHosts(html, domain),
646
+ cspMetaPolicy: htmlAnalysis
647
+ ? analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached)
648
+ : null,
645
649
  pageIdentity,
646
650
  credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
647
651
  htmlAnalysis,
@@ -0,0 +1,4 @@
1
+ // Browser-safe bounds for the rich HTTP evidence contract and its compact
2
+ // browser-local projection. Both producers and consumers import these values
3
+ // so valid retained evidence is not rejected by a stale projection.
4
+ export const MAX_HTTP_EVIDENCE_REDIRECTS = 5;
@@ -3,8 +3,9 @@
3
3
  // converts the shared safe-fetch trace and selected response metadata into an
4
4
  // additive evidence object suitable for API responses and exports.
5
5
  import { createObservation } from "./observation.mjs";
6
+ import { MAX_HTTP_EVIDENCE_REDIRECTS } from "./http-evidence-bounds.mjs";
6
7
  const MAX_HTTP_PROVENANCE_URL = 2048;
7
- const MAX_HTTP_REDIRECTS = 5;
8
+ const MAX_HTTP_REDIRECTS = MAX_HTTP_EVIDENCE_REDIRECTS;
8
9
  const MAX_HTTP_ATTEMPTS = 2;
9
10
  const MAX_HTTP_ERROR_LENGTH = 180;
10
11
  const MAX_HTTP_HEADER_LENGTH = 1024;
@@ -0,0 +1,54 @@
1
+ import { isIP } from 'node:net';
2
+ function ipv4Value(value) {
3
+ return value.split('.').reduce((total, part) => (total << 8n) | BigInt(Number(part)), 0n);
4
+ }
5
+ function ipv6Parts(value) {
6
+ if (value.includes('.'))
7
+ return null;
8
+ const halves = value.toLowerCase().split('::');
9
+ if (halves.length > 2)
10
+ return null;
11
+ const left = halves[0] ? halves[0].split(':') : [];
12
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
13
+ if (left.some((part) => !/^[0-9a-f]{1,4}$/u.test(part))
14
+ || right.some((part) => !/^[0-9a-f]{1,4}$/u.test(part)))
15
+ return null;
16
+ const missing = 8 - left.length - right.length;
17
+ if (halves.length === 1 && missing !== 0 || halves.length === 2 && missing < 1)
18
+ return null;
19
+ return [...left, ...Array(missing).fill('0'), ...right].map((part) => Number.parseInt(part, 16));
20
+ }
21
+ function ipValue(value, family) {
22
+ if (family === 4)
23
+ return ipv4Value(value);
24
+ const parts = ipv6Parts(value);
25
+ return parts?.reduce((total, part) => (total << 16n) | BigInt(part), 0n) ?? null;
26
+ }
27
+ function parseIpPrefix(value) {
28
+ if (typeof value !== 'string' || value.length > 96 || /[\s%\u0000-\u001f\u007f]/u.test(value))
29
+ return null;
30
+ const [addressValue, lengthValue, ...rest] = value.toLowerCase().split('/');
31
+ if (!addressValue || rest.length > 0)
32
+ return null;
33
+ const family = isIP(addressValue);
34
+ if (family !== 4 && family !== 6)
35
+ return null;
36
+ const maximum = family === 4 ? 32 : 128;
37
+ if (lengthValue !== undefined && !/^\d{1,3}$/u.test(lengthValue))
38
+ return null;
39
+ const length = lengthValue === undefined ? maximum : Number(lengthValue);
40
+ if (!Number.isInteger(length) || length < 0 || length > maximum)
41
+ return null;
42
+ const numeric = ipValue(addressValue, family);
43
+ if (numeric === null)
44
+ return null;
45
+ const full = (1n << BigInt(maximum)) - 1n;
46
+ const mask = length === 0 ? 0n : (full << BigInt(maximum - length)) & full;
47
+ return Object.freeze({ family, address: addressValue, length, value: numeric & mask, mask });
48
+ }
49
+ function prefixContains(container, candidate) {
50
+ return container.family === candidate.family
51
+ && container.length <= candidate.length
52
+ && (candidate.value & container.mask) === container.value;
53
+ }
54
+ export { parseIpPrefix, prefixContains };
@@ -0,0 +1,89 @@
1
+ import { isIP } from 'node:net';
2
+ import { parseIpPrefix, prefixContains } from "./ip-prefix.mjs";
3
+ const LOCAL_GEOIP_SCHEMA = 'whoisleuth.local-geoip-evidence';
4
+ const LOCAL_GEOIP_VERSION = 1;
5
+ const MAX_GEOIP_RECORDS = 100_000;
6
+ function record(value) {
7
+ return value && typeof value === 'object' && !Array.isArray(value)
8
+ ? value
9
+ : null;
10
+ }
11
+ function boundedText(value, maximum) {
12
+ if (typeof value !== 'string' || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value))
13
+ return null;
14
+ return value.replace(/\s+/gu, ' ').trim() || null;
15
+ }
16
+ function normalizeGeoIpEntry(value) {
17
+ const source = record(value);
18
+ const parsedNetwork = parseIpPrefix(source?.network ?? source?.cidr);
19
+ if (!source || !parsedNetwork)
20
+ return null;
21
+ const country = boundedText(source.countryCode ?? source.country, 2)?.toUpperCase() ?? null;
22
+ if (country !== null && !/^[A-Z]{2}$/u.test(country))
23
+ return null;
24
+ const asnValue = source.asn === null || source.asn === undefined ? null : Number(String(source.asn).replace(/^AS/iu, ''));
25
+ if (asnValue !== null && (!Number.isInteger(asnValue) || asnValue < 0 || asnValue > 4_294_967_295))
26
+ return null;
27
+ return Object.freeze({
28
+ network: `${parsedNetwork.address}/${parsedNetwork.length}`,
29
+ parsedNetwork,
30
+ countryCode: country,
31
+ region: boundedText(source.region, 120),
32
+ city: boundedText(source.city, 120),
33
+ asn: asnValue,
34
+ asName: boundedText(source.asName ?? source.organization, 240),
35
+ });
36
+ }
37
+ function buildLocalGeoIpDatabase(value) {
38
+ const source = record(value);
39
+ const sourceLabel = boundedText(source?.sourceLabel, 120);
40
+ const databaseVersion = boundedText(source?.databaseVersion, 80);
41
+ const license = boundedText(source?.license, 240);
42
+ const rows = Array.isArray(source?.records) ? source.records : null;
43
+ if (!sourceLabel || !databaseVersion || !license || !rows) {
44
+ throw new TypeError('A local GeoIP database requires bounded source, version, licence, and record fields.');
45
+ }
46
+ const truncated = rows.length > MAX_GEOIP_RECORDS;
47
+ const normalized = rows.slice(0, MAX_GEOIP_RECORDS).map(normalizeGeoIpEntry);
48
+ const records = normalized.filter((item) => item !== null)
49
+ .sort((left, right) => right.parsedNetwork.length - left.parsedNetwork.length);
50
+ return Object.freeze({
51
+ sourceLabel,
52
+ databaseVersion,
53
+ license,
54
+ records: Object.freeze(records),
55
+ rejectedCount: normalized.length - records.length + Math.max(0, rows.length - MAX_GEOIP_RECORDS),
56
+ truncated,
57
+ });
58
+ }
59
+ function lookupLocalGeoIp(database, address) {
60
+ const normalized = typeof address === 'string' ? address.trim().toLowerCase() : '';
61
+ const family = isIP(normalized);
62
+ const target = family === 4 || family === 6 ? parseIpPrefix(normalized) : null;
63
+ if (!target) {
64
+ return Object.freeze({ state: 'invalid', address: null, match: null, source: null, limitations: Object.freeze([]) });
65
+ }
66
+ const match = database.records.find((item) => prefixContains(item.parsedNetwork, target)) ?? null;
67
+ return Object.freeze({
68
+ state: match ? database.truncated || database.rejectedCount > 0 ? 'partial' : 'matched' : 'not_found',
69
+ address: normalized,
70
+ match: match ? Object.freeze({
71
+ network: match.network,
72
+ countryCode: match.countryCode,
73
+ region: match.region,
74
+ city: match.city,
75
+ asn: match.asn,
76
+ asName: match.asName,
77
+ }) : null,
78
+ source: Object.freeze({
79
+ label: database.sourceLabel,
80
+ version: database.databaseVersion,
81
+ license: database.license,
82
+ }),
83
+ limitations: Object.freeze([
84
+ 'Geolocation and network attribution are provider estimates and may be stale, coarse, shared, or incorrect.',
85
+ 'A location or ASN association does not establish the operator, owner, user, intent, safety, or maliciousness of an address.',
86
+ ]),
87
+ });
88
+ }
89
+ export { LOCAL_GEOIP_SCHEMA, LOCAL_GEOIP_VERSION, MAX_GEOIP_RECORDS, buildLocalGeoIpDatabase, lookupLocalGeoIp, };
@@ -0,0 +1,9 @@
1
+ import packageDocument from '../package.json' with { type: 'json' };
2
+ import { WHOISLEUTH_PROJECT_URL, WHOISLEUTH_REQUEST_POLICY_URL, } from "./project-metadata.mjs";
3
+ import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
4
+ export { WHOISLEUTH_PROJECT_URL, WHOISLEUTH_REQUEST_POLICY_URL };
5
+ export const WHOISLEUTH_USER_AGENT_VERSION = normalizeBoundedSemanticVersion(packageDocument.version, 'Application manifest');
6
+ export const WHOISLEUTH_USER_AGENT = `WHOISleuth/${WHOISLEUTH_USER_AGENT_VERSION} (+${WHOISLEUTH_REQUEST_POLICY_URL})`;
7
+ export function whoisleuthRequestHeaders(headers = {}) {
8
+ return { ...headers, 'User-Agent': WHOISLEUTH_USER_AGENT };
9
+ }
@@ -0,0 +1,9 @@
1
+ // Shared declarative limits for direct website observation. Runtime collectors
2
+ // and the public request-policy page import these values so operator-facing
3
+ // disclosures cannot drift from the enforced request boundaries.
4
+ export const MAX_OUTBOUND_REDIRECTS = 5;
5
+ export const HOMEPAGE_FETCH_TIMEOUT_MS = 6_000;
6
+ export const MAX_HOMEPAGE_BYTES = 300_000;
7
+ export const FAVICON_FETCH_TIMEOUT_MS = 5_000;
8
+ export const MAX_FAVICON_BYTES = 200_000;
9
+ export const MAX_FAVICON_CANDIDATES = 4;
@@ -0,0 +1,34 @@
1
+ // Browser-safe comparison contract for the 64-bit perceptual hashes produced
2
+ // by the bounded image decoder. Keeping comparison and informativeness here
3
+ // prevents Node and browser consumers from drifting into different matches.
4
+ export const PERCEPTUAL_HASH_HEX_RE = /^[0-9a-f]{16}$/u;
5
+ export const MIN_INFORMATIVE_HASH_BITS = 10;
6
+ const POPCOUNT = new Uint8Array(16);
7
+ for (let index = 0; index < POPCOUNT.length; index += 1) {
8
+ POPCOUNT[index] = (index & 1) + ((index >> 1) & 1) + ((index >> 2) & 1) + ((index >> 3) & 1);
9
+ }
10
+ export function isPerceptualHash(value) {
11
+ return typeof value === 'string' && PERCEPTUAL_HASH_HEX_RE.test(value);
12
+ }
13
+ function popcountHex(hex) {
14
+ let bits = 0;
15
+ for (let index = 0; index < hex.length; index += 1) {
16
+ bits += POPCOUNT[parseInt(hex.charAt(index), 16)] ?? 0;
17
+ }
18
+ return bits;
19
+ }
20
+ export function isInformativePerceptualHash(value) {
21
+ if (!isPerceptualHash(value))
22
+ return false;
23
+ const bits = popcountHex(value);
24
+ return bits >= MIN_INFORMATIVE_HASH_BITS && bits <= 64 - MIN_INFORMATIVE_HASH_BITS;
25
+ }
26
+ export function hammingDistanceHex(left, right) {
27
+ if (!isPerceptualHash(left) || !isPerceptualHash(right))
28
+ return null;
29
+ let distance = 0;
30
+ for (let index = 0; index < 16; index += 1) {
31
+ distance += POPCOUNT[parseInt(left.charAt(index), 16) ^ parseInt(right.charAt(index), 16)] ?? 0;
32
+ }
33
+ return distance;
34
+ }
@@ -21,6 +21,7 @@
21
21
  // case, while staying cheap (no DCT). Output is 16 lowercase hex chars;
22
22
  // similarity is the Hamming distance between two such strings.
23
23
  import * as zlib from 'node:zlib';
24
+ import { hammingDistanceHex, isInformativePerceptualHash, } from "./perceptual-hash-comparison.mjs";
24
25
  // Bound the decode work on attacker-controlled image bytes. Real favicons top
25
26
  // out around 256x256; anything claiming to be larger is refused rather than
26
27
  // decoded (it would also never be a legitimate favicon to compare against).
@@ -371,21 +372,11 @@ function grayscaleGrid(image, cols, rows) {
371
372
  // (callers fall back to the exact hash) rather than a collision-prone one.
372
373
  // isInformativeHash re-checks this at comparison time too, since a Brand
373
374
  // Profile saved before this guard existed may already hold a degenerate hash.
374
- const MIN_INFORMATIVE_BITS = 10;
375
- function popcountHex(hex) {
376
- let bits = 0;
377
- for (let i = 0; i < hex.length; i += 1)
378
- bits += POPCOUNT[parseInt(hex.charAt(i), 16)] ?? 0;
379
- return bits;
380
- }
381
375
  function isInformativeHash(hex) {
382
- if (typeof hex !== 'string' || !HEX_HASH_RE.test(hex))
383
- return false;
384
- const bits = popcountHex(hex);
385
- return bits >= MIN_INFORMATIVE_BITS && bits <= 64 - MIN_INFORMATIVE_BITS;
376
+ return isInformativePerceptualHash(hex);
386
377
  }
387
378
  // 64-bit dHash over a 9x8 grid -> 16 hex chars, or null when the resulting
388
- // hash is degenerate (see MIN_INFORMATIVE_BITS). Each row contributes 8 bits:
379
+ // hash is degenerate (see the shared comparison contract). Each row contributes 8 bits:
389
380
  // bit set when a cell is brighter than its right-hand neighbour.
390
381
  function dHash(image) {
391
382
  const cols = 9;
@@ -429,23 +420,4 @@ function faviconPerceptualHash(buf) {
429
420
  // produced by the optional local capture package. Keep the generic name as an
430
421
  // alias so the evidence contract does not imply that a screenshot is a favicon.
431
422
  const imagePerceptualHash = faviconPerceptualHash;
432
- const HEX_HASH_RE = /^[0-9a-f]{16}$/;
433
- const POPCOUNT = new Uint8Array(16);
434
- for (let i = 0; i < 16; i += 1)
435
- POPCOUNT[i] = (i & 1) + ((i >> 1) & 1) + ((i >> 2) & 1) + ((i >> 3) & 1);
436
- /**
437
- * Hamming distance (0-64) between two 16-hex dHash strings, or null if either
438
- * isn't a well-formed hash. Smaller = more visually similar.
439
- */
440
- function hammingDistanceHex(a, b) {
441
- if (typeof a !== 'string' || typeof b !== 'string')
442
- return null;
443
- if (!HEX_HASH_RE.test(a) || !HEX_HASH_RE.test(b))
444
- return null;
445
- let distance = 0;
446
- for (let i = 0; i < 16; i += 1) {
447
- distance += POPCOUNT[parseInt(a.charAt(i), 16) ^ parseInt(b.charAt(i), 16)] ?? 0;
448
- }
449
- return distance;
450
- }
451
423
  export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash };
@@ -0,0 +1,8 @@
1
+ // Browser-safe public project metadata shared by request identity, package
2
+ // verification, canonical URLs, structured data, and public policy pages.
3
+ export const WHOISLEUTH_SITE_ORIGIN = 'https://whoisleuth.com';
4
+ export const WHOISLEUTH_PROJECT_URL = `${WHOISLEUTH_SITE_ORIGIN}/`;
5
+ export const WHOISLEUTH_REQUEST_POLICY_URL = `${WHOISLEUTH_SITE_ORIGIN}/request-policy`;
6
+ export const WHOISLEUTH_SOURCE_REPOSITORY_URL = 'https://github.com/slicedearth/whoisleuth';
7
+ export const WHOISLEUTH_SOURCE_REPOSITORY_GIT_URL = `git+${WHOISLEUTH_SOURCE_REPOSITORY_URL}.git`;
8
+ export const WHOISLEUTH_SOURCE_ISSUES_URL = `${WHOISLEUTH_SOURCE_REPOSITORY_URL}/issues`;
@@ -0,0 +1,49 @@
1
+ export const PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS = 180;
2
+ export const DEPLOYMENT_PURPOSES = ['personal', 'internal', 'commercial'];
3
+ function deploymentPurpose(value) {
4
+ const normalized = String(value ?? '').trim().toLowerCase();
5
+ return DEPLOYMENT_PURPOSES.includes(normalized)
6
+ ? normalized
7
+ : null;
8
+ }
9
+ export function providerPolicyAdmission(terms, env = process.env, now = Date.now()) {
10
+ const source = env && typeof env === 'object' ? env : {};
11
+ const purpose = deploymentPurpose(source.WHOISLEUTH_DEPLOYMENT_PURPOSE);
12
+ const reviewedAt = Date.parse(terms.reviewedAt);
13
+ const reviewAgeDays = Number.isFinite(reviewedAt)
14
+ ? Math.max(0, Math.floor((now - reviewedAt) / 86_400_000))
15
+ : null;
16
+ if (!purpose) {
17
+ return {
18
+ allowed: false,
19
+ purpose: null,
20
+ reviewAgeDays,
21
+ reason: 'Optional intelligence requires WHOISLEUTH_DEPLOYMENT_PURPOSE=personal, internal, or commercial.',
22
+ };
23
+ }
24
+ if (reviewAgeDays === null || reviewAgeDays > PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS) {
25
+ return {
26
+ allowed: false,
27
+ purpose,
28
+ reviewAgeDays,
29
+ reason: `Optional intelligence provider policy review is older than ${PROVIDER_POLICY_MAX_REVIEW_AGE_DAYS} days or invalid.`,
30
+ };
31
+ }
32
+ if (terms.commercialUse === 'unknown') {
33
+ return {
34
+ allowed: false,
35
+ purpose,
36
+ reviewAgeDays,
37
+ reason: 'Optional intelligence commercial-use permission is unknown.',
38
+ };
39
+ }
40
+ if (terms.commercialUse === 'restricted' && purpose !== 'personal') {
41
+ return {
42
+ allowed: false,
43
+ purpose,
44
+ reviewAgeDays,
45
+ reason: `Optional intelligence is classified as restricted for the declared ${purpose} deployment purpose.`,
46
+ };
47
+ }
48
+ return { allowed: true, purpose, reviewAgeDays, reason: null };
49
+ }
@@ -0,0 +1,157 @@
1
+ const RDAP_SEARCH_WORKBENCH_SCHEMA = 'whoisleuth.rdap-search-workbench';
2
+ const RDAP_SEARCH_WORKBENCH_VERSION = 1;
3
+ const MAX_HELP_ENTRIES = 64;
4
+ const MAX_QUERY_VALUE_LENGTH = 254;
5
+ const SEARCHABLE_RESOURCE_TYPES = new Set(['domains', 'nameservers', 'entities', 'ips', 'autnums']);
6
+ const RELATED_RESOURCE_TYPES = new Set(['domains', 'nameservers', 'entities', 'ips', 'autnums']);
7
+ const RECOGNIZED_PROPERTIES = new Set(['handle', 'fn', 'email', 'role']);
8
+ const SENSITIVE_PROPERTIES = new Set(['fn', 'email']);
9
+ function record(value) {
10
+ return value && typeof value === 'object' && !Array.isArray(value)
11
+ ? value
12
+ : null;
13
+ }
14
+ function boundedToken(value) {
15
+ if (typeof value !== 'string')
16
+ return null;
17
+ const normalized = value.trim().toLowerCase();
18
+ if (!normalized || normalized.length > 48 || !/^[a-z][a-z0-9_-]*$/u.test(normalized))
19
+ return null;
20
+ return normalized;
21
+ }
22
+ function declarationKey(value) {
23
+ return `${value.searchableResourceType}:${value.relatedResourceType}:${value.property}`;
24
+ }
25
+ function emptySummary(state, limitation, rejectedCount = 0) {
26
+ return Object.freeze({
27
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
28
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
29
+ state,
30
+ capabilities: Object.freeze([]),
31
+ rejectedCount,
32
+ truncated: false,
33
+ limitations: Object.freeze([limitation]),
34
+ });
35
+ }
36
+ function normalizeRdapSearchHelp(value) {
37
+ const document = record(value);
38
+ if (!document)
39
+ return emptySummary('invalid', 'The RDAP help response was not an object.');
40
+ const raw = document.reverse_search_properties;
41
+ if (raw === undefined) {
42
+ return emptySummary('unsupported', 'The help response did not advertise reverse-search properties.');
43
+ }
44
+ if (!Array.isArray(raw)) {
45
+ return emptySummary('invalid', 'The advertised reverse-search properties were malformed.', 1);
46
+ }
47
+ const capabilities = [];
48
+ const seen = new Set();
49
+ let rejectedCount = 0;
50
+ const truncated = raw.length > MAX_HELP_ENTRIES;
51
+ for (const entry of raw.slice(0, MAX_HELP_ENTRIES)) {
52
+ const candidate = record(entry);
53
+ const searchableResourceType = boundedToken(candidate?.searchableResourceType);
54
+ const relatedResourceType = boundedToken(candidate?.relatedResourceType);
55
+ const property = boundedToken(candidate?.property);
56
+ if (!searchableResourceType || !relatedResourceType || !property) {
57
+ rejectedCount += 1;
58
+ continue;
59
+ }
60
+ const declaration = { searchableResourceType, relatedResourceType, property };
61
+ const key = declarationKey(declaration);
62
+ if (seen.has(key))
63
+ continue;
64
+ seen.add(key);
65
+ const supported = SEARCHABLE_RESOURCE_TYPES.has(searchableResourceType)
66
+ && RELATED_RESOURCE_TYPES.has(relatedResourceType)
67
+ && RECOGNIZED_PROPERTIES.has(property);
68
+ capabilities.push(Object.freeze({
69
+ ...declaration,
70
+ state: supported ? 'supported' : 'unsupported',
71
+ disclosure: SENSITIVE_PROPERTIES.has(property) ? 'contact' : 'identifier',
72
+ limitation: supported ? null : 'The server advertised a tuple that this release does not construct.',
73
+ }));
74
+ }
75
+ if (truncated)
76
+ rejectedCount += raw.length - MAX_HELP_ENTRIES;
77
+ const supportedCount = capabilities.filter((entry) => entry.state === 'supported').length;
78
+ const limitations = [];
79
+ if (truncated)
80
+ limitations.push(`Only the first ${MAX_HELP_ENTRIES} advertised properties were reviewed.`);
81
+ if (rejectedCount > 0)
82
+ limitations.push(`${rejectedCount} malformed or excess properties were not used.`);
83
+ if (capabilities.some((entry) => entry.state === 'unsupported')) {
84
+ limitations.push('Unrecognized advertised tuples remain visible but cannot be planned by this release.');
85
+ }
86
+ if (supportedCount === 0)
87
+ limitations.push('No supported reverse-search tuple was advertised.');
88
+ return Object.freeze({
89
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
90
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
91
+ state: supportedCount === 0 ? 'unsupported' : limitations.length > 0 ? 'partial' : 'supported',
92
+ capabilities: Object.freeze(capabilities),
93
+ rejectedCount,
94
+ truncated,
95
+ limitations: Object.freeze(limitations),
96
+ });
97
+ }
98
+ function normalizeQueryValue(value) {
99
+ if (typeof value !== 'string')
100
+ return null;
101
+ const normalized = value.trim();
102
+ if (!normalized || normalized.length > MAX_QUERY_VALUE_LENGTH)
103
+ return null;
104
+ if (/[\x00-\x1f\x7f]/u.test(normalized))
105
+ return null;
106
+ return normalized;
107
+ }
108
+ function planRdapReverseSearch(help, requested) {
109
+ const searchableResourceType = boundedToken(requested.searchableResourceType);
110
+ const relatedResourceType = boundedToken(requested.relatedResourceType);
111
+ const property = boundedToken(requested.property);
112
+ const value = normalizeQueryValue(requested.value);
113
+ if (!searchableResourceType || !relatedResourceType || !property || !value) {
114
+ return Object.freeze({
115
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
116
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
117
+ state: 'invalid',
118
+ requestPath: null,
119
+ query: null,
120
+ disclosure: null,
121
+ limitation: 'The requested resource types, property, or value were invalid or outside the supported bounds.',
122
+ });
123
+ }
124
+ const capability = help.capabilities.find((entry) => (entry.state === 'supported'
125
+ && entry.searchableResourceType === searchableResourceType
126
+ && entry.relatedResourceType === relatedResourceType
127
+ && entry.property === property));
128
+ if (!capability) {
129
+ return Object.freeze({
130
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
131
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
132
+ state: 'unsupported',
133
+ requestPath: null,
134
+ query: null,
135
+ disclosure: null,
136
+ limitation: 'The supplied help response did not advertise this exact reverse-search tuple.',
137
+ });
138
+ }
139
+ const parameter = `${relatedResourceType}_${property}`;
140
+ const contactDisclosure = capability.disclosure === 'contact';
141
+ return Object.freeze({
142
+ schema: RDAP_SEARCH_WORKBENCH_SCHEMA,
143
+ version: RDAP_SEARCH_WORKBENCH_VERSION,
144
+ state: 'ready',
145
+ requestPath: `/${searchableResourceType}`,
146
+ query: Object.freeze({ [parameter]: value }),
147
+ disclosure: Object.freeze({
148
+ class: capability.disclosure,
149
+ summary: contactDisclosure
150
+ ? `Would disclose the supplied ${property} contact value to the selected RDAP server.`
151
+ : `Would disclose the supplied ${property} identifier to the selected RDAP server.`,
152
+ requiresApproval: true,
153
+ }),
154
+ limitation: 'This is an offline request plan. It neither sends the query nor establishes result authority, completeness, or currentness.',
155
+ });
156
+ }
157
+ export { MAX_HELP_ENTRIES, MAX_QUERY_VALUE_LENGTH, RDAP_SEARCH_WORKBENCH_SCHEMA, RDAP_SEARCH_WORKBENCH_VERSION, normalizeRdapSearchHelp, planRdapReverseSearch, };
package/lib/rdap.mjs CHANGED
@@ -4,11 +4,16 @@ import { fetchRdapFromBasesWithParser, fetchRdapRecordWithParser, } from "./rdap
4
4
  import { normalizeRdapEvents, parseRdap, summarizeLifecycle, } from "./rdap-normalization.mjs";
5
5
  import { fetchRegistrarRdapRecordWithParser, selectRegistrarRdapLink, } from "./rdap-registrar.mjs";
6
6
  import { fetchRdapWithTimeout, } from "./rdap-transport.mjs";
7
+ import { registryServiceAdmissionFor } from "./registry-capabilities.mjs";
7
8
  async function fetchRdapFromBases(type, value, bases, fetchUpstream = fetchRdapWithTimeout) {
8
9
  return fetchRdapFromBasesWithParser(type, value, bases, parseRdap, fetchUpstream);
9
10
  }
10
- async function fetchRdapRecord(type, value) {
11
- return fetchRdapRecordWithParser(type, value, parseRdap);
11
+ async function fetchRdapRecord(type, value, options = {}) {
12
+ if (type === 'domain' && registryServiceAdmissionFor(value, 'rdap')?.allowed === false) {
13
+ return null;
14
+ }
15
+ const fetchRecord = options.fetchRecord ?? fetchRdapRecordWithParser;
16
+ return fetchRecord(type, value, parseRdap);
12
17
  }
13
18
  async function fetchRegistrarRdapRecord(domain, registryRecord, options = {}) {
14
19
  return fetchRegistrarRdapRecordWithParser(domain, registryRecord, parseRdap, options);