@slicedearth/whoisleuth-cli 1.32.2 → 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 (73) hide show
  1. package/README.md +43 -5
  2. package/SECURITY.md +35 -0
  3. package/cli/arguments.mjs +180 -8
  4. package/cli/artifact-verify.mjs +32 -0
  5. package/cli/bulk-command-runner.mjs +3 -1
  6. package/cli/bulk-output.mjs +6 -1
  7. package/cli/bulk.mjs +1 -2
  8. package/cli/command-catalogue.mjs +33 -0
  9. package/cli/completion.mjs +70 -7
  10. package/cli/doctor.mjs +78 -43
  11. package/cli/export-evidence.mjs +9 -2
  12. package/cli/formatters/html.mjs +1 -1
  13. package/cli/formatters/terminal.mjs +13 -7
  14. package/cli/investigation-plan.mjs +112 -0
  15. package/cli/lookup-command-runner.mjs +8 -0
  16. package/cli/lookup-diff.mjs +16 -5
  17. package/cli/lookup-plan.mjs +99 -0
  18. package/cli/lookup-timeline.mjs +123 -0
  19. package/cli/mail-review.mjs +47 -2
  20. package/cli/manual.mjs +2 -1
  21. package/cli/offline-evidence-review.mjs +141 -0
  22. package/cli/registry-support.mjs +1 -4
  23. package/cli/runner.mjs +214 -11
  24. package/docs/cli.md +200 -45
  25. package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
  26. package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
  27. package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
  28. package/frontend/src/lib/analysis/http-summary.js +2 -1
  29. package/frontend/src/lib/analysis/utils.js +7 -40
  30. package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
  31. package/lib/availability.mjs +30 -10
  32. package/lib/bulk-limits.mjs +13 -0
  33. package/lib/csv.mjs +1 -1
  34. package/lib/ct-response-bounds.mjs +9 -0
  35. package/lib/ct-search.mjs +66 -12
  36. package/lib/dns-delegation-health.mjs +56 -5
  37. package/lib/dnssec-evidence-validation.mjs +235 -0
  38. package/lib/domain-control-manifest.mjs +339 -0
  39. package/lib/domain-posture.mjs +2 -1
  40. package/lib/encrypted-dns-contract.mjs +81 -0
  41. package/lib/evidence-export.mjs +17 -3
  42. package/lib/evidence-report-markdown.mjs +3 -3
  43. package/lib/evidence-report.mjs +3 -0
  44. package/lib/favicon.mjs +3 -8
  45. package/lib/html-signals.mjs +4 -0
  46. package/lib/http-evidence-bounds.mjs +4 -0
  47. package/lib/http-intelligence.mjs +2 -1
  48. package/lib/ip-prefix.mjs +54 -0
  49. package/lib/local-geoip-evidence.mjs +89 -0
  50. package/lib/outbound-identity.mjs +9 -0
  51. package/lib/outbound-request-bounds.mjs +9 -0
  52. package/lib/perceptual-hash-comparison.mjs +34 -0
  53. package/lib/perceptual-hash.mjs +3 -31
  54. package/lib/project-metadata.mjs +8 -0
  55. package/lib/provider-policy-admission.mjs +49 -0
  56. package/lib/rdap-search-workbench.mjs +157 -0
  57. package/lib/rdap.mjs +7 -2
  58. package/lib/registry-capabilities.mjs +31 -1
  59. package/lib/registry-capability-catalogue.mjs +11 -10
  60. package/lib/response-policy.mjs +81 -6
  61. package/lib/rpki-evidence.mjs +105 -0
  62. package/lib/safe-fetch.mjs +2 -1
  63. package/lib/semantic-version.mjs +54 -0
  64. package/lib/static-html-analysis.mjs +27 -0
  65. package/lib/threatfox-intelligence.mjs +10 -5
  66. package/lib/tlsa-evidence.mjs +194 -0
  67. package/lib/urlhaus-intelligence.mjs +10 -5
  68. package/lib/urlscan-intelligence.mjs +10 -5
  69. package/lib/website-security-posture.mjs +11 -4
  70. package/lib/whois-chain.mjs +14 -152
  71. package/lib/whois.mjs +2 -2
  72. package/package.json +7 -5
  73. package/third-party-notices.txt +2 -2
@@ -0,0 +1,194 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import { domainToASCII } from 'node:url';
4
+ const TLSA_EVIDENCE_SCHEMA = 'whoisleuth.tlsa-evidence';
5
+ const TLSA_EVIDENCE_VERSION = 1;
6
+ const MAX_TLSA_RECORDS = 50;
7
+ const MAX_CERTIFICATE_BYTES = 256 * 1024;
8
+ const MAX_AUTHORITY_MATERIALS = 10;
9
+ function record(value) {
10
+ return value && typeof value === 'object' && !Array.isArray(value)
11
+ ? value
12
+ : null;
13
+ }
14
+ function integer(value, minimum, maximum) {
15
+ return Number.isInteger(value) && Number(value) >= minimum && Number(value) <= maximum
16
+ ? Number(value)
17
+ : null;
18
+ }
19
+ function hex(value) {
20
+ if (typeof value !== 'string')
21
+ return null;
22
+ const normalized = value.replace(/\s+/gu, '').toLowerCase();
23
+ return normalized && normalized.length % 2 === 0 && /^[0-9a-f]+$/u.test(normalized) ? normalized : null;
24
+ }
25
+ function normalizeTlsaRecord(value) {
26
+ const source = record(value);
27
+ if (!source)
28
+ return null;
29
+ const usage = integer(source.usage, 0, 3);
30
+ const selector = integer(source.selector, 0, 1);
31
+ const matchingType = integer(source.matchingType, 0, 2);
32
+ const associationData = hex(source.associationData);
33
+ if (usage === null || selector === null || matchingType === null || !associationData)
34
+ return null;
35
+ if (matchingType === 1 && associationData.length !== 64)
36
+ return null;
37
+ if (matchingType === 2 && associationData.length !== 128)
38
+ return null;
39
+ if (matchingType === 0 && associationData.length > MAX_CERTIFICATE_BYTES * 2)
40
+ return null;
41
+ return Object.freeze({ usage, selector, matchingType, associationData });
42
+ }
43
+ function decodeBoundedBase64(value) {
44
+ if (typeof value !== 'string' || value.length > MAX_CERTIFICATE_BYTES * 2 || !/^[A-Za-z0-9+/]+={0,2}$/u.test(value))
45
+ return null;
46
+ const decoded = Buffer.from(value, 'base64');
47
+ return decoded.length > 0 && decoded.length <= MAX_CERTIFICATE_BYTES && decoded.toString('base64') === value ? decoded : null;
48
+ }
49
+ function normalizeTlsaServiceName(value) {
50
+ if (typeof value !== 'string' || value.length > 270)
51
+ return null;
52
+ const match = /^_(\d{1,5})\._(tcp|udp)\.(.+)$/iu.exec(value.trim().replace(/\.$/u, ''));
53
+ if (!match)
54
+ return null;
55
+ const port = Number(match[1]);
56
+ const transport = match[2]?.toLowerCase();
57
+ const hostname = domainToASCII(String(match[3] ?? '').toLowerCase());
58
+ if (!Number.isInteger(port) || port < 1 || port > 65_535 || (transport !== 'tcp' && transport !== 'udp'))
59
+ return null;
60
+ if (!hostname || hostname.length > 253 || hostname.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
61
+ return null;
62
+ return Object.freeze({ ownerName: `_${port}._${transport}.${hostname}`, port, transport, hostname });
63
+ }
64
+ function normalizeCertificateMaterial(value) {
65
+ const source = record(value);
66
+ if (!source)
67
+ return null;
68
+ const certificateDer = decodeBoundedBase64(source.certificateDerBase64);
69
+ const spkiDer = decodeBoundedBase64(source.spkiDerBase64);
70
+ return certificateDer || spkiDer ? Object.freeze({ certificateDer, spkiDer }) : null;
71
+ }
72
+ function selectedValues(recordValue, materials) {
73
+ return materials.flatMap((material) => {
74
+ const selected = recordValue.selector === 0 ? material.certificateDer : material.spkiDer;
75
+ if (!selected)
76
+ return [];
77
+ if (recordValue.matchingType === 0)
78
+ return [selected.toString('hex')];
79
+ if (recordValue.matchingType === 1)
80
+ return [createHash('sha256').update(selected).digest('hex')];
81
+ return [createHash('sha512').update(selected).digest('hex')];
82
+ });
83
+ }
84
+ function normalizeDnssecState(value) {
85
+ return value === 'validated' || value === 'insecure' || value === 'bogus' ? value : 'unavailable';
86
+ }
87
+ function normalizePkixValidationState(value) {
88
+ return value === 'validated' || value === 'failed' ? value : 'unavailable';
89
+ }
90
+ function analyzeTlsaEvidence(input) {
91
+ const service = normalizeTlsaServiceName(input.serviceName);
92
+ const dnssecState = normalizeDnssecState(input.dnssecState);
93
+ const pkixValidationState = normalizePkixValidationState(input.pkixValidationState);
94
+ const leafMaterial = normalizeCertificateMaterial({
95
+ certificateDerBase64: input.certificateDerBase64,
96
+ spkiDerBase64: input.spkiDerBase64,
97
+ });
98
+ const rawAuthorityMaterials = Array.isArray(input.authorityMaterials) ? input.authorityMaterials : [];
99
+ const normalizedAuthorityMaterials = rawAuthorityMaterials
100
+ .slice(0, MAX_AUTHORITY_MATERIALS)
101
+ .map(normalizeCertificateMaterial);
102
+ const authorityMaterials = normalizedAuthorityMaterials
103
+ .filter((item) => item !== null);
104
+ const authorityMaterialRejectedCount = normalizedAuthorityMaterials.length - authorityMaterials.length;
105
+ const authorityMaterialTruncated = rawAuthorityMaterials.length > MAX_AUTHORITY_MATERIALS;
106
+ const raw = Array.isArray(input.records) ? input.records : null;
107
+ if (!raw || !service) {
108
+ return Object.freeze({
109
+ schema: TLSA_EVIDENCE_SCHEMA,
110
+ version: TLSA_EVIDENCE_VERSION,
111
+ state: 'invalid',
112
+ dnssecState,
113
+ pkixValidationState,
114
+ service,
115
+ authorityMaterialCount: authorityMaterials.length,
116
+ authorityMaterialRejectedCount,
117
+ authorityMaterialTruncated,
118
+ records: Object.freeze([]),
119
+ rejectedCount: 0,
120
+ truncated: false,
121
+ limitations: Object.freeze([
122
+ ...(!raw ? ['TLSA evidence must be supplied as a bounded record array.'] : []),
123
+ ...(!service ? ['TLSA evidence must be bound to a valid _port._transport.hostname service name.'] : []),
124
+ ]),
125
+ });
126
+ }
127
+ const truncated = raw.length > MAX_TLSA_RECORDS;
128
+ const normalized = raw.slice(0, MAX_TLSA_RECORDS).map(normalizeTlsaRecord);
129
+ const rejectedCount = normalized.filter((item) => item === null).length + Math.max(0, raw.length - MAX_TLSA_RECORDS);
130
+ const records = normalized.filter((item) => item !== null);
131
+ const results = records.map((item) => {
132
+ const materials = item.usage === 0 || item.usage === 2
133
+ ? authorityMaterials
134
+ : leafMaterial ? [leafMaterial] : [];
135
+ const selected = selectedValues(item, materials);
136
+ if (!selected.length) {
137
+ const role = item.usage === 0 || item.usage === 2 ? 'authority' : 'leaf';
138
+ return Object.freeze({ ...item, state: 'unavailable', reason: `No bounded ${role} ${item.selector === 0 ? 'certificate' : 'SPKI'} bytes were supplied for comparison.` });
139
+ }
140
+ const matched = selected.includes(item.associationData);
141
+ return Object.freeze({
142
+ ...item,
143
+ state: matched ? 'matched' : 'different',
144
+ reason: matched ? 'The supplied certificate material matched this TLSA association.' : 'The supplied certificate material differed from this TLSA association.',
145
+ });
146
+ });
147
+ const hasMatch = results.some((item) => item.state === 'matched');
148
+ const hasDifferent = results.some((item) => item.state === 'different');
149
+ const hasUnavailable = results.some((item) => item.state === 'unavailable');
150
+ const limitations = [
151
+ 'This offline comparison does not connect to the target, retrieve DNS, negotiate SMTP STARTTLS, or validate a DNSSEC chain.',
152
+ 'A TLSA match is usable as DANE evidence only when the DNSSEC state was independently validated for the same observation.',
153
+ 'PKIX-TA and PKIX-EE usages require an independently validated PKIX path; association matching alone does not establish that prerequisite.',
154
+ ];
155
+ if (dnssecState !== 'validated' && hasMatch) {
156
+ limitations.push('Certificate material matched, but DNSSEC was not validated, so the result remains partial.');
157
+ }
158
+ const matchedPkixDependent = results.some((item) => item.state === 'matched' && (item.usage === 0 || item.usage === 1));
159
+ const matchedDaneOnly = results.some((item) => item.state === 'matched' && (item.usage === 2 || item.usage === 3));
160
+ if (matchedPkixDependent && pkixValidationState !== 'validated') {
161
+ limitations.push('A PKIX-dependent association matched, but a validated PKIX path was not supplied, so the result is not complete.');
162
+ }
163
+ if (authorityMaterialRejectedCount > 0 || authorityMaterialTruncated) {
164
+ limitations.push('Authority certificate material was malformed or exceeded the review bound, so an unmatched trust-anchor association remains partial.');
165
+ }
166
+ const trustedMatch = dnssecState === 'validated'
167
+ && (matchedDaneOnly || matchedPkixDependent && pkixValidationState === 'validated');
168
+ const state = results.length === 0
169
+ ? 'unavailable'
170
+ : hasDifferent && !hasMatch && rejectedCount === 0 && !truncated
171
+ && authorityMaterialRejectedCount === 0 && !authorityMaterialTruncated
172
+ ? 'different'
173
+ : hasMatch && (dnssecState === 'bogus' || matchedPkixDependent && pkixValidationState === 'failed' && !matchedDaneOnly)
174
+ ? 'untrusted'
175
+ : trustedMatch && !hasUnavailable && rejectedCount === 0 && !truncated
176
+ ? 'matched'
177
+ : 'partial';
178
+ return Object.freeze({
179
+ schema: TLSA_EVIDENCE_SCHEMA,
180
+ version: TLSA_EVIDENCE_VERSION,
181
+ state,
182
+ dnssecState,
183
+ pkixValidationState,
184
+ service,
185
+ authorityMaterialCount: authorityMaterials.length,
186
+ authorityMaterialRejectedCount,
187
+ authorityMaterialTruncated,
188
+ records: Object.freeze(results),
189
+ rejectedCount,
190
+ truncated,
191
+ limitations: Object.freeze(limitations),
192
+ });
193
+ }
194
+ export { MAX_TLSA_RECORDS, MAX_AUTHORITY_MATERIALS, TLSA_EVIDENCE_SCHEMA, TLSA_EVIDENCE_VERSION, analyzeTlsaEvidence, normalizeTlsaRecord, normalizeTlsaServiceName, };
@@ -6,6 +6,8 @@
6
6
  import { classifyQuery } from "./classify.mjs";
7
7
  import { abusechAuthKey } from "./abusech-auth.mjs";
8
8
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
9
+ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
10
+ import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
9
11
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
10
12
  const URLHAUS_HOST_ENDPOINT = 'https://urlhaus-api.abuse.ch/v1/host/';
11
13
  const URLHAUS_MAX_RESULTS = 20;
@@ -53,15 +55,18 @@ function urlhausConfiguration(env = process.env) {
53
55
  const source = env && typeof env === 'object' ? env : {};
54
56
  const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLHAUS);
55
57
  const authKey = abusechAuthKey(source);
58
+ const policy = providerPolicyAdmission(URLHAUS_PROVIDER.terms, source);
56
59
  return {
57
60
  enabled,
58
- configured: enabled && authKey !== null,
61
+ configured: enabled && policy.allowed && authKey !== null,
59
62
  authKey: enabled ? authKey : null,
60
63
  reason: !enabled
61
64
  ? 'Malware-host intelligence is not enabled for this deployment.'
62
- : authKey
63
- ? null
64
- : 'Malware-host intelligence is enabled but its API credential is unavailable or malformed.',
65
+ : !policy.allowed
66
+ ? policy.reason
67
+ : authKey
68
+ ? null
69
+ : 'Malware-host intelligence is enabled but its API credential is unavailable or malformed.',
65
70
  };
66
71
  }
67
72
  function boundedText(value, maxLength) {
@@ -231,7 +236,7 @@ function createUrlhausIntelligenceAdapter(dependencies = {}) {
231
236
  accept: 'application/json',
232
237
  'auth-key': configuration.authKey,
233
238
  'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
234
- 'user-agent': 'WHOISleuth/1.0',
239
+ ...whoisleuthRequestHeaders(),
235
240
  },
236
241
  body,
237
242
  signal: AbortSignal.timeout(URLHAUS_PROVIDER.limits.timeoutMs),
@@ -4,6 +4,8 @@
4
4
  // key; credentials are sent only in a header to the fixed HTTPS endpoint.
5
5
  import { classifyQuery } from "./classify.mjs";
6
6
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
7
+ import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
8
+ import { providerPolicyAdmission } from "./provider-policy-admission.mjs";
7
9
  import { createThreatIntelligenceResult, defineThreatIntelligenceProvider, normalizeThreatIntelligenceTarget, } from "./threat-intelligence-contract.mjs";
8
10
  const URLSCAN_SEARCH_ENDPOINT = 'https://urlscan.io/api/v1/search/';
9
11
  const URLSCAN_SEARCH_DAYS = 90;
@@ -57,15 +59,18 @@ function urlscanConfiguration(env = process.env) {
57
59
  const source = env && typeof env === 'object' ? env : {};
58
60
  const enabled = enabledValue(source.WHOISLEUTH_ENABLE_URLSCAN);
59
61
  const apiKey = normalizedApiKey(source.URLSCAN_API_KEY);
62
+ const policy = providerPolicyAdmission(URLSCAN_PROVIDER.terms, source);
60
63
  return {
61
64
  enabled,
62
- configured: enabled && apiKey !== null,
65
+ configured: enabled && policy.allowed && apiKey !== null,
63
66
  apiKey: enabled ? apiKey : null,
64
67
  reason: !enabled
65
68
  ? 'Archived URLscan verdict search is not enabled for this deployment.'
66
- : apiKey
67
- ? null
68
- : 'Archived URLscan verdict search is enabled but its API credential is unavailable or malformed.',
69
+ : !policy.allowed
70
+ ? policy.reason
71
+ : apiKey
72
+ ? null
73
+ : 'Archived URLscan verdict search is enabled but its API credential is unavailable or malformed.',
69
74
  };
70
75
  }
71
76
  function boundedText(value, maxLength) {
@@ -230,7 +235,7 @@ function createUrlscanIntelligenceAdapter(dependencies = {}) {
230
235
  headers: {
231
236
  accept: 'application/json',
232
237
  'api-key': configuration.apiKey,
233
- 'user-agent': 'WHOISleuth/1.0',
238
+ ...whoisleuthRequestHeaders(),
234
239
  },
235
240
  signal: AbortSignal.timeout(URLSCAN_PROVIDER.limits.timeoutMs),
236
241
  }, { maxRedirects: 0 });
@@ -91,8 +91,15 @@ const RESPONSE_POLICY_FINDINGS = Object.freeze({
91
91
  detail: () => "The effective selected script source included 'unsafe-eval'.",
92
92
  },
93
93
  csp_unsafe_inline: {
94
- label: 'CSP permits unqualified inline script',
95
- detail: () => "The effective selected script source included 'unsafe-inline' without an observed nonce or hash source in that directive.",
94
+ label: 'Response CSP header permits unqualified inline script',
95
+ detail: () => "The selected response header included 'unsafe-inline' without a nonce or hash source in that directive. A separate page policy was not observed early enough to qualify this response-scoped finding.",
96
+ },
97
+ csp_inline_constrained_by_meta: {
98
+ label: 'Page CSP further constrains inline script',
99
+ detail: () => 'A bounded CSP meta policy appeared before page scripts and further constrained the inline-script allowance in the selected response header. Browsers enforce both policies.',
100
+ state: 'observed',
101
+ tone: 'configured',
102
+ evidence: ['Selected HTTP response headers', 'Static page CSP meta policy'],
96
103
  },
97
104
  hsts_disabled: {
98
105
  label: 'HSTS disabled by selected response',
@@ -125,7 +132,7 @@ const RESPONSE_POLICY_FINDINGS = Object.freeze({
125
132
  });
126
133
  function responsePolicyFindings(value) {
127
134
  const policy = record(value);
128
- if (policy.responsePolicyVersion !== 1)
135
+ if (![1, 2].includes(Number(policy.responsePolicyVersion)))
129
136
  return [];
130
137
  const findings = [];
131
138
  const components = record(policy.components);
@@ -147,7 +154,7 @@ function responsePolicyFindings(value) {
147
154
  if (!definition || findings.some((item) => item.id === id))
148
155
  continue;
149
156
  const count = boundedCount(signal.count, 32);
150
- findings.push(finding(id, 'response headers', 'potential_exposure', 'review', definition.label, definition.detail(count), ['Transient selected response-policy analysis']));
157
+ findings.push(finding(id, 'response headers', definition.state ?? 'potential_exposure', definition.tone ?? 'review', definition.label, definition.detail(count), definition.evidence ?? ['Transient selected response-policy analysis']));
151
158
  }
152
159
  return findings;
153
160
  }
@@ -1,15 +1,13 @@
1
1
  // WHOIS referral-chain orchestration. Transport remains isolated in
2
- // whois-transport.mts; this module applies registry query profiles, follows
3
- // bounded referrals, and adapts the registry-web fallback into WHOIS text.
2
+ // whois-transport.mts; this module applies registry query profiles and follows
3
+ // bounded referrals.
4
4
  import { domainToUnicode } from 'node:url';
5
5
  import { cached } from "./lookup-cache.mjs";
6
- import { registryCapabilityFor } from "./registry-capabilities.mjs";
7
- import { readTextCapped, safeFetch } from "./safe-fetch.mjs";
6
+ import { registryCapabilityFor, registryServiceAdmissionFor, } from "./registry-capabilities.mjs";
8
7
  import { whoisQuery } from "./whois-transport.mjs";
9
8
  const IANA_WHOIS = 'whois.iana.org';
10
9
  const WHOIS_HOP_DEADLINE_MS = 12_000;
11
10
  const WHOIS_CHAIN_DEADLINE_MS = 25_000;
12
- const MAX_GT_REGISTRY_HTML_BYTES = 500_000;
13
11
  const WHOIS_QUERY_FORMATTERS = {
14
12
  'plain-domain': (domain) => domain,
15
13
  'denic-domain-ace': (domain) => `-T dn,ace ${domain}`,
@@ -48,134 +46,19 @@ function extractReferral(whoisText) {
48
46
  }
49
47
  return null;
50
48
  }
51
- function stripTags(html) {
52
- return html
53
- .replace(/<[^>]+>/g, ' ')
54
- .replace(/&nbsp;/g, ' ')
55
- .replace(/\s+/g, ' ')
56
- .trim();
57
- }
58
- function sectionBetween(html, startRe, endRes) {
59
- const startMatch = html.match(startRe);
60
- if (!startMatch)
61
- return '';
62
- const rest = html.slice((startMatch.index ?? 0) + startMatch[0].length);
63
- let endIndex = rest.length;
64
- for (const endRe of endRes) {
65
- const match = rest.match(endRe);
66
- if (!match)
67
- continue;
68
- const matchIndex = match.index ?? 0;
69
- const index = match[0].startsWith('>') ? matchIndex + 1 : matchIndex;
70
- if (index < endIndex)
71
- endIndex = index;
72
- }
73
- return rest.slice(0, endIndex);
74
- }
75
- function extractIconFields(html, iconMap) {
76
- let marked = html;
77
- for (const [icon, key] of Object.entries(iconMap)) {
78
- marked = marked.replace(new RegExp(`<i[^>]*\\b${icon}\\b[^>]*></i>`, 'gi'), `\n@@${key}@@\n`);
79
- }
80
- const parts = stripTags(marked).split(/@@(\w+)@@/);
81
- const fields = {};
82
- for (let index = 1; index < parts.length; index += 2) {
83
- const value = (parts[index + 1] || '').trim();
84
- const key = parts[index];
85
- if (key && value && !fields[key])
86
- fields[key] = value;
87
- }
88
- return fields;
89
- }
90
- export async function fetchGtRegistryWhois(domain, { fetcher = safeFetch, } = {}) {
91
- const url = `https://www.gt/sitio/whois.php?dn=${encodeURIComponent(domain)}.&lang=en`;
92
- const controller = new AbortController();
93
- const timeout = setTimeout(() => controller.abort(), 8_000);
94
- try {
95
- const response = await fetcher(url, {
96
- headers: { 'User-Agent': 'Mozilla/5.0 (compatible; DomainStatusChecker/1.0)' },
97
- signal: controller.signal,
98
- });
99
- if (!response.ok) {
100
- await response.body?.cancel().catch(() => { });
101
- return null;
102
- }
103
- const body = await readTextCapped(response, MAX_GT_REGISTRY_HTML_BYTES);
104
- if (body.truncated)
105
- return null;
106
- const html = body.text;
107
- if (/is not registered/i.test(html))
108
- return { registered: false };
109
- const statusMatch = html.match(/<i class="fas fa-bell fa-fw"><\/i>\s*([A-Za-z]+)/i);
110
- const expiryMatch = html.match(/Expiration:\s*([0-9]{4}-[A-Za-z]{3}-[0-9]{2}[^<]*)/i);
111
- const orgSection = sectionBetween(html, /Entitled Organization/i, [/Servers\s*<\/h4>/i]);
112
- const org = extractIconFields(orgSection, {
113
- 'fa-building': 'org',
114
- 'fa-address-card': 'address',
115
- 'fa-phone': 'phone',
116
- });
117
- const adminSection = sectionBetween(html, />\s*ADMINISTRATIVE\s*</i, [/>\s*TECHNICAL\s*</i, />\s*BILLING\s*</i]);
118
- const admin = extractIconFields(adminSection, {
119
- 'fa-user': 'name',
120
- 'fa-envelope': 'email',
121
- 'fa-address-card': 'address',
122
- 'fa-building': 'org',
123
- });
124
- const serversSection = sectionBetween(html, /Servers\s*<\/h4>/i, [
125
- /<div class="span6">/i,
126
- ]);
127
- const nameservers = [
128
- ...serversSection.matchAll(/<strong>\s*([a-zA-Z0-9.\-]+)\.?\s*<\/strong>/gi),
129
- ]
130
- .map((match) => match[1]?.trim())
131
- .filter((value) => Boolean(value));
132
- return {
133
- registered: true,
134
- status: statusMatch?.[1]?.trim() || null,
135
- expiryDate: expiryMatch?.[1]?.trim() || null,
136
- registrantOrg: org.org || null,
137
- registrantAddress: org.address || null,
138
- registrantPhone: org.phone || null,
139
- adminName: admin.name || null,
140
- adminOrg: admin.org || null,
141
- adminEmail: admin.email || null,
142
- nameservers,
143
- };
144
- }
145
- catch {
146
- return null;
147
- }
148
- finally {
149
- clearTimeout(timeout);
150
- }
151
- }
152
- function formatGtResultAsText(domain, result) {
153
- if (!result.registered)
154
- return `No match for domain ${domain.toUpperCase()}.`;
155
- const lines = [`Domain Name: ${domain.toUpperCase()}`];
156
- if (result.status)
157
- lines.push(`Domain Status: ${result.status}`);
158
- if (result.expiryDate)
159
- lines.push(`Registry Expiry Date: ${result.expiryDate}`);
160
- if (result.registrantOrg)
161
- lines.push(`Registrant Organization: ${result.registrantOrg}`);
162
- if (result.registrantAddress)
163
- lines.push(`Registrant Address: ${result.registrantAddress}`);
164
- if (result.registrantPhone)
165
- lines.push(`Registrant Phone: ${result.registrantPhone}`);
166
- if (result.adminName)
167
- lines.push(`Admin Name: ${result.adminName}`);
168
- if (result.adminOrg)
169
- lines.push(`Admin Organization: ${result.adminOrg}`);
170
- if (result.adminEmail)
171
- lines.push(`Admin Email: ${result.adminEmail}`);
172
- for (const nameserver of result.nameservers)
173
- lines.push(`Name Server: ${nameserver}`);
174
- return lines.join('\n');
175
- }
176
49
  export async function buildWhoisChainUncached(query, options = {}) {
50
+ const admission = registryServiceAdmissionFor(query, 'whois');
51
+ if (admission && !admission.allowed) {
52
+ return [{
53
+ server: 'registry capability policy',
54
+ queryProfile: 'not-issued',
55
+ responseEncoding: 'utf-8',
56
+ error: admission.state === 'permission_required'
57
+ ? 'WHOIS collection requires registry permission or source authorization; no socket was opened.'
58
+ : 'IANA publishes no domain WHOIS service for this suffix; no socket was opened.',
59
+ }];
60
+ }
177
61
  const queryWhois = options.whoisQuery || whoisQuery;
178
- const fetchGtWhois = options.fetchGtRegistryWhois || fetchGtRegistryWhois;
179
62
  const now = options.now || Date.now;
180
63
  const chainDeadlineMs = options.chainDeadlineMs || WHOIS_CHAIN_DEADLINE_MS;
181
64
  const chain = [];
@@ -233,27 +116,6 @@ export async function buildWhoisChainUncached(query, options = {}) {
233
116
  break;
234
117
  currentServer = referral;
235
118
  }
236
- const firstHop = chain[0];
237
- if (query.toLowerCase().endsWith('.gt') &&
238
- chain.length === 1 &&
239
- firstHop &&
240
- !('error' in firstHop)) {
241
- try {
242
- const result = await fetchGtWhois(query);
243
- if (result) {
244
- chain.push({
245
- server: 'www.gt (registry website - .gt has no WHOIS:43 server)',
246
- queriedAt: new Date().toISOString(),
247
- queryProfile: 'gt-registry-web',
248
- responseEncoding: 'utf-8',
249
- response: formatGtResultAsText(query, result),
250
- });
251
- }
252
- }
253
- catch {
254
- // Best-effort registry fallback. The IANA hop remains explicit on failure.
255
- }
256
- }
257
119
  return chain;
258
120
  }
259
121
  export async function buildWhoisChain(query) {
package/lib/whois.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import { domainToASCII } from 'node:url';
5
5
  import { registryDateIso } from "./registry-dates.mjs";
6
6
  import { queryWhoisAddress, whoisQuery, } from "./whois-transport.mjs";
7
- import { buildWhoisChain, buildWhoisChainUncached, fetchGtRegistryWhois, } from "./whois-chain.mjs";
7
+ import { buildWhoisChain, buildWhoisChainUncached, } from "./whois-chain.mjs";
8
8
  import { analyzeWhoisChainAuthority, hasNicKgRegistrationEvidence, } from "./whois-authority.mjs";
9
9
  import { parseIndentedContactBlock, resolveFredContact, resolveIrnicContact, resolveIsnicRole, } from "./whois-contacts.mjs";
10
10
  import { normalizeWhoisChain } from "./whois-normalization.mjs";
@@ -1184,4 +1184,4 @@ function parseWhoisChain(chain) {
1184
1184
  ...authority,
1185
1185
  };
1186
1186
  }
1187
- export { buildWhoisChain, parseWhoisChain, analyzeWhoisChainAuthority, whoisQuery, queryWhoisAddress, buildWhoisChainUncached, fetchGtRegistryWhois, };
1187
+ export { buildWhoisChain, parseWhoisChain, analyzeWhoisChainAuthority, whoisQuery, queryWhoisAddress, buildWhoisChainUncached, };
package/package.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "name": "@slicedearth/whoisleuth-cli",
3
3
  "type": "module",
4
4
  "license": "AGPL-3.0-only",
5
+ "author": "slicedearth",
5
6
  "contentPolicy": {
6
7
  "class": "dual-use"
7
8
  },
@@ -26,17 +27,17 @@
26
27
  },
27
28
  "repository": {
28
29
  "type": "git",
29
- "url": "https://github.com/slicedearth/whoisleuth.git"
30
+ "url": "git+https://github.com/slicedearth/whoisleuth.git"
30
31
  },
31
32
  "homepage": "https://whoisleuth.com/",
32
33
  "bugs": {
33
34
  "url": "https://github.com/slicedearth/whoisleuth/issues"
34
35
  },
35
- "version": "1.32.2",
36
+ "version": "1.36.1",
36
37
  "dependencies": {
37
- "parse5": "^8.0.1",
38
- "tldts": "^7.4.9",
39
- "undici": "^8.7.0"
38
+ "parse5": "8.0.1",
39
+ "tldts": "7.4.10",
40
+ "undici": "8.9.0"
40
41
  },
41
42
  "files": [
42
43
  "bin/**/*.mjs",
@@ -49,6 +50,7 @@
49
50
  "LICENSES/*.txt",
50
51
  "NOTICE",
51
52
  "README.md",
53
+ "SECURITY.md",
52
54
  "TRADEMARKS.md",
53
55
  "third-party-notices.txt"
54
56
  ],
@@ -5130,7 +5130,7 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
5130
5130
  FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
5131
5131
  DEALINGS IN THE SOFTWARE.
5132
5132
  ================================================================================
5133
- tldts@7.4.9
5133
+ tldts@7.4.10
5134
5134
  Relationship: direct production dependency
5135
5135
  Declared licence: MIT
5136
5136
  Licence source: LICENSE
@@ -5149,7 +5149,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
5149
5149
  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
5150
5150
  WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
5151
5151
  ================================================================================
5152
- tldts-core@7.4.9
5152
+ tldts-core@7.4.10
5153
5153
  Relationship: transitive production dependency
5154
5154
  Declared licence: MIT
5155
5155
  Licence source: LICENSE