@slicedearth/whoisleuth-cli 1.32.0

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 (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
@@ -0,0 +1,391 @@
1
+ import { registryAccessProfileLabel } from "./registry-access.mjs";
2
+ const MAX_REPORT_VALUE_LENGTH = 300;
3
+ const MAX_REPORT_LIST_ITEMS = 50;
4
+ const MAX_REPORT_COMPARISON_FIELDS = 20;
5
+ const MAX_REPORT_RISK_FACTORS = 20;
6
+ function objectOrEmpty(value) {
7
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
+ }
9
+ function cleanReportText(value, fallback = 'Not reported') {
10
+ if (value === null || value === undefined || value === '')
11
+ return fallback;
12
+ const text = String(value)
13
+ .replace(/[\x00-\x1f\x7f]+/g, ' ')
14
+ .replace(/[\u061c\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff]/g, '')
15
+ .replace(/\s+/g, ' ')
16
+ .trim()
17
+ .slice(0, MAX_REPORT_VALUE_LENGTH);
18
+ return text || fallback;
19
+ }
20
+ function displayLabel(value) {
21
+ return cleanReportText(value, 'Unknown')
22
+ .replace(/[_-]+/g, ' ')
23
+ .replace(/\b\w/g, (character) => character.toUpperCase());
24
+ }
25
+ function yesNoUnknown(value) {
26
+ if (value === true)
27
+ return 'Yes';
28
+ if (value === false)
29
+ return 'No';
30
+ return 'Unknown';
31
+ }
32
+ function riskReport(value) {
33
+ const source = objectOrEmpty(value);
34
+ const score = Number.isInteger(source.score) && Number(source.score) >= 0 && Number(source.score) <= 100
35
+ ? Number(source.score)
36
+ : null;
37
+ const modelVersion = Number.isInteger(source.modelVersion)
38
+ && Number(source.modelVersion) > 0
39
+ && Number(source.modelVersion) <= 10_000
40
+ ? Number(source.modelVersion)
41
+ : null;
42
+ const factors = (Array.isArray(source.factors) ? source.factors : [])
43
+ .slice(0, MAX_REPORT_RISK_FACTORS)
44
+ .map((value) => {
45
+ const factor = objectOrEmpty(value);
46
+ const label = cleanReportText(factor.label, '');
47
+ const deltaValue = factor.delta ?? factor.points;
48
+ const delta = Number.isInteger(deltaValue) && Math.abs(Number(deltaValue)) <= 100
49
+ ? Number(deltaValue)
50
+ : null;
51
+ if (!label || delta === null)
52
+ return null;
53
+ return `${label} (${delta > 0 ? '+' : ''}${delta})`;
54
+ })
55
+ .filter((value) => value !== null);
56
+ return { score, modelVersion, factors };
57
+ }
58
+ function listText(value) {
59
+ if (!Array.isArray(value) || !value.length)
60
+ return 'Not reported';
61
+ const candidates = value
62
+ .slice(0, MAX_REPORT_LIST_ITEMS)
63
+ .map((item) => cleanReportText(item).slice(0, 120));
64
+ const retained = [];
65
+ for (const candidate of candidates) {
66
+ const next = [...retained, candidate].join(', ');
67
+ // Reserve enough room for the omission suffix so it is never hidden by
68
+ // the per-value display boundary.
69
+ if (next.length > MAX_REPORT_VALUE_LENGTH - 40)
70
+ break;
71
+ retained.push(candidate);
72
+ }
73
+ const omitted = value.length - retained.length;
74
+ return `${retained.join(', ')}${omitted > 0 ? ` (and ${omitted} more)` : ''}`;
75
+ }
76
+ function entityName(value) {
77
+ if (!value || typeof value !== 'object' || Array.isArray(value))
78
+ return value;
79
+ const entity = value;
80
+ return entity.name || entity.org || entity.handle || null;
81
+ }
82
+ function lifecycleValue(parsed, field) {
83
+ const source = objectOrEmpty(parsed);
84
+ const lifecycle = objectOrEmpty(source.lifecycle);
85
+ return lifecycle[`${field}Iso`] || lifecycle[field] || source[`${field}Iso`] || source[field] || null;
86
+ }
87
+ function reportField(label, value, fallback = 'Not reported') {
88
+ return { label, value: cleanReportText(value, fallback) };
89
+ }
90
+ function buildLookupEvidenceReport(document, options = {}) {
91
+ const report = objectOrEmpty(document);
92
+ const query = objectOrEmpty(report.query);
93
+ const diagnostics = objectOrEmpty(report.diagnostics);
94
+ const rdapDiagnostics = objectOrEmpty(diagnostics.rdap);
95
+ const whoisDiagnostics = objectOrEmpty(diagnostics.whois);
96
+ const availabilityDiagnostics = objectOrEmpty(diagnostics.availability);
97
+ const registrarRdapDiagnostics = objectOrEmpty(rdapDiagnostics.registrar);
98
+ const registryAccess = objectOrEmpty(diagnostics.registryAccess);
99
+ const sources = objectOrEmpty(report.sources);
100
+ const rdap = objectOrEmpty(sources.rdap);
101
+ const whois = objectOrEmpty(sources.whois);
102
+ const networkContext = objectOrEmpty(sources.network);
103
+ const networkEndpoint = objectOrEmpty(networkContext.endpoint);
104
+ const networkRdap = objectOrEmpty(networkContext.rdap);
105
+ const networkRegistration = objectOrEmpty(networkContext.network);
106
+ const rdapParsed = objectOrEmpty(rdap.parsed);
107
+ const whoisParsed = objectOrEmpty(whois.parsed);
108
+ const analysis = objectOrEmpty(report.analysis);
109
+ const availability = objectOrEmpty(analysis.availability);
110
+ const comparison = objectOrEmpty(analysis.registryComparison);
111
+ const registryInsights = objectOrEmpty(analysis.registryInsights);
112
+ const registryLifecycle = objectOrEmpty(registryInsights.lifecycle);
113
+ const registryLocks = objectOrEmpty(registryLifecycle.locks);
114
+ const contactDisclosure = objectOrEmpty(registryInsights.contactDisclosure);
115
+ const registryDisclosure = objectOrEmpty(contactDisclosure.registryRdap);
116
+ const whoisDisclosure = objectOrEmpty(contactDisclosure.whois);
117
+ const registryReconciliation = objectOrEmpty(registryInsights.reconciliation);
118
+ const registryPublications = Array.isArray(registryInsights.publications)
119
+ ? registryInsights.publications.map(objectOrEmpty)
120
+ : [];
121
+ const comparisonHealth = objectOrEmpty(comparison.sourceHealth);
122
+ const rdapComparisonHealth = objectOrEmpty(comparisonHealth.rdap);
123
+ const whoisComparisonHealth = objectOrEmpty(comparisonHealth.whois);
124
+ const registrarComparison = objectOrEmpty(analysis.registrarPublicationComparison);
125
+ const registrarComparisonHealth = objectOrEmpty(registrarComparison.sourceHealth);
126
+ const registryPublicationHealth = objectOrEmpty(registrarComparisonHealth.registry);
127
+ const registrarPublicationHealth = objectOrEmpty(registrarComparisonHealth.registrar);
128
+ const registrarRdap = objectOrEmpty(options.registrarRdap);
129
+ const registrarRdapParsed = objectOrEmpty(registrarRdap.parsed);
130
+ const dns = objectOrEmpty(availability.dns);
131
+ const dnsRecords = objectOrEmpty(dns.records);
132
+ const soa = objectOrEmpty(Array.isArray(dnsRecords.soa) ? dnsRecords.soa[0] : null);
133
+ const http = objectOrEmpty(availability.http);
134
+ const httpResponse = objectOrEmpty(http.response);
135
+ const tls = objectOrEmpty(availability.tls);
136
+ const tlsAuthorization = objectOrEmpty(tls.authorization);
137
+ const tlsHostname = objectOrEmpty(tls.hostname);
138
+ const tlsValidity = objectOrEmpty(tls.validity);
139
+ const tlsCertificate = objectOrEmpty(tls.certificate);
140
+ const risk = riskReport(options.risk);
141
+ const titleTarget = query.registrableDomain || query.submitted || 'Unknown domain';
142
+ const registryAccessSuffix = [5, 6, 7, 8].includes(Number(diagnostics.version)) && registryAccess.authority === 'context_only'
143
+ && typeof registryAccess.suffix === 'string'
144
+ ? cleanReportText(registryAccess.suffix, '')
145
+ : '';
146
+ const registryAccessFields = registryAccessSuffix ? [
147
+ reportField('Registry access suffix', `.${registryAccessSuffix}`),
148
+ reportField('WHOIS access', registryAccessProfileLabel(registryAccess.whoisAccessProfile)),
149
+ reportField('RDAP access', registryAccessProfileLabel(registryAccess.rdapAccessProfile)),
150
+ reportField('Registry access note', registryAccess.limitation),
151
+ ] : [];
152
+ const groups = {
153
+ registryRdap: {
154
+ title: 'Registry RDAP',
155
+ fields: [
156
+ reportField('Source status', displayLabel(rdapDiagnostics.status || rdap.status)),
157
+ reportField('Endpoint', rdap.endpoint),
158
+ reportField('HTTP status', rdap.httpStatus),
159
+ reportField('Fetched', rdap.fetchedAt),
160
+ reportField('Domain', rdapParsed.domain),
161
+ reportField('Registry object ID', rdapParsed.handle),
162
+ reportField('Registrar', entityName(rdapParsed.registrar)),
163
+ reportField('Registrar IANA ID', rdapParsed.registrarIanaId),
164
+ reportField('Created', lifecycleValue(rdapParsed, 'createdDate')),
165
+ reportField('Expires', lifecycleValue(rdapParsed, 'expiryDate')),
166
+ reportField('Last updated', lifecycleValue(rdapParsed, 'updatedDate')),
167
+ reportField('DNSSEC', rdapParsed.dnssec),
168
+ reportField('Statuses', listText(rdapParsed.statuses)),
169
+ reportField('Name servers', listText(rdapParsed.nameservers)),
170
+ reportField('Server-declared truncation', yesNoUnknown(rdapParsed.serverTruncated)),
171
+ ],
172
+ },
173
+ registrarRdap: {
174
+ title: 'Registrar RDAP',
175
+ fields: [
176
+ reportField('Source status', displayLabel(registrarRdapDiagnostics.status || registrarRdap.status)),
177
+ reportField('Endpoint', registrarRdap.endpoint),
178
+ reportField('Transport', displayLabel(registrarRdap.transportSecurity)),
179
+ reportField('HTTP status', registrarRdap.upstreamStatus),
180
+ reportField('Fetched', registrarRdap.fetchedAt),
181
+ reportField('Domain', registrarRdapParsed.domain),
182
+ reportField('Registrar', entityName(registrarRdapParsed.registrar)),
183
+ reportField('Registrar IANA ID', registrarRdapParsed.registrarIanaId),
184
+ reportField('Created', lifecycleValue(registrarRdapParsed, 'createdDate')),
185
+ reportField('Expires', lifecycleValue(registrarRdapParsed, 'expiryDate')),
186
+ reportField('Last updated', lifecycleValue(registrarRdapParsed, 'updatedDate')),
187
+ reportField('DNSSEC', registrarRdapParsed.dnssec),
188
+ reportField('Statuses', listText(registrarRdapParsed.statuses)),
189
+ reportField('Name servers', listText(registrarRdapParsed.nameservers)),
190
+ reportField('Server-declared truncation', yesNoUnknown(registrarRdapParsed.serverTruncated)),
191
+ ],
192
+ },
193
+ whois: {
194
+ title: 'WHOIS',
195
+ fields: [
196
+ reportField('Source status', displayLabel(whoisDiagnostics.status || whois.status)),
197
+ reportField('Queried', whois.queriedAt),
198
+ reportField('Authoritative hop', whois.authoritativeHop),
199
+ reportField('Failed hop', whois.failedHop),
200
+ reportField('Domain', whoisParsed.domainName),
201
+ reportField('Registry object ID', whoisParsed.registryDomainId),
202
+ reportField('Registrar', entityName(whoisParsed.registrar)),
203
+ reportField('Registrar IANA ID', whoisParsed.registrarIanaId),
204
+ reportField('Created', lifecycleValue(whoisParsed, 'createdDate')),
205
+ reportField('Expires', lifecycleValue(whoisParsed, 'expiryDate')),
206
+ reportField('Last updated', lifecycleValue(whoisParsed, 'updatedDate')),
207
+ reportField('DNSSEC', whoisParsed.dnssec),
208
+ reportField('Statuses', listText(whoisParsed.statuses)),
209
+ reportField('Name servers', listText(whoisParsed.nameservers)),
210
+ ],
211
+ },
212
+ dns: {
213
+ title: 'DNS and mail',
214
+ fields: [
215
+ reportField('DNS status', displayLabel(dns.status)),
216
+ reportField('Observed', dns.observedAt),
217
+ reportField('Name servers', listText(availability.nameservers)),
218
+ reportField('MX hosts', listText(availability.mxHosts)),
219
+ reportField('SOA primary server', soa.nsname),
220
+ reportField('SOA hostmaster', soa.hostmaster),
221
+ reportField('SOA serial', soa.serial),
222
+ reportField('SOA refresh', soa.refresh),
223
+ reportField('SOA retry', soa.retry),
224
+ reportField('SOA expire', soa.expire),
225
+ reportField('SOA minimum TTL', soa.minttl),
226
+ ],
227
+ },
228
+ website: {
229
+ title: 'Website',
230
+ fields: [
231
+ reportField('Probe status', displayLabel(http.status || availability.websiteProbeStatus)),
232
+ reportField('Observed', http.observedAt),
233
+ reportField('Final URL', http.finalUrl),
234
+ reportField('HTTP response', httpResponse.status),
235
+ reportField('Content type', httpResponse.contentType),
236
+ reportField('Redirects', http.redirectCount),
237
+ reportField('Page title', availability.pageTitle),
238
+ reportField('Password field observed', yesNoUnknown(availability.hasPasswordField)),
239
+ ],
240
+ },
241
+ tls: {
242
+ title: 'TLS',
243
+ fields: [
244
+ reportField('TLS status', displayLabel(tls.status)),
245
+ reportField('Observed', tls.observedAt),
246
+ reportField('Protocol', tls.protocol),
247
+ reportField('Runtime trust', yesNoUnknown(tlsAuthorization.authorized)),
248
+ reportField('Hostname match', yesNoUnknown(tlsHostname.matches)),
249
+ reportField('Certificate validity', displayLabel(tlsValidity.status)),
250
+ reportField('Certificate fingerprint', tlsCertificate.fingerprintSha256),
251
+ ],
252
+ },
253
+ networkRegistration: {
254
+ title: 'Observed network registration',
255
+ fields: [
256
+ reportField('Source status', displayLabel(networkContext.status)),
257
+ reportField('Selected address', networkEndpoint.address),
258
+ reportField('Selected from', displayLabel(networkEndpoint.selectedFrom)),
259
+ reportField('IP RDAP endpoint', networkRdap.endpoint),
260
+ reportField('IP RDAP fetched', networkRdap.fetchedAt),
261
+ reportField('Registered network', networkRegistration.name),
262
+ reportField('Network holder', networkRegistration.holder),
263
+ reportField('Network handle', networkRegistration.handle),
264
+ reportField('CIDR ranges', listText(networkRegistration.cidrs)),
265
+ reportField('Address range', networkRegistration.startAddress && networkRegistration.endAddress
266
+ ? `${networkRegistration.startAddress} to ${networkRegistration.endAddress}` : null),
267
+ reportField('Country', networkRegistration.country),
268
+ reportField('Network type', networkRegistration.networkType),
269
+ reportField('RDAP database updated', networkRegistration.databaseUpdatedAt),
270
+ reportField('Complete response', yesNoUnknown(networkContext.complete)),
271
+ reportField('Truncated response', yesNoUnknown(networkContext.truncated)),
272
+ reportField('Limitations', listText(networkContext.limitations)),
273
+ ],
274
+ },
275
+ };
276
+ const comparisonFields = Array.isArray(comparison.fields)
277
+ ? comparison.fields.slice(0, MAX_REPORT_COMPARISON_FIELDS).map((value) => {
278
+ const item = objectOrEmpty(value);
279
+ return {
280
+ label: cleanReportText(item.label),
281
+ status: displayLabel(item.status),
282
+ rdap: cleanReportText(item.rdapDisplay),
283
+ whois: cleanReportText(item.whoisDisplay),
284
+ };
285
+ })
286
+ : [];
287
+ const registrarComparisonFields = Array.isArray(registrarComparison.fields)
288
+ ? registrarComparison.fields.slice(0, MAX_REPORT_COMPARISON_FIELDS).map((value) => {
289
+ const item = objectOrEmpty(value);
290
+ return {
291
+ label: cleanReportText(item.label),
292
+ status: displayLabel(item.status),
293
+ registry: cleanReportText(item.registryDisplay),
294
+ registrar: cleanReportText(item.registrarDisplay),
295
+ };
296
+ })
297
+ : [];
298
+ return {
299
+ title: cleanReportText(titleTarget),
300
+ 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
+ metadata: [
302
+ reportField('Generated', report.generatedAt),
303
+ reportField('Report contract', `${cleanReportText(report.schema)} v${cleanReportText(report.schemaVersion)}`),
304
+ ],
305
+ query: [
306
+ reportField('Submitted', query.submitted),
307
+ reportField('Type', displayLabel(query.type)),
308
+ reportField('Input hostname', query.inputHostname),
309
+ reportField('Registrable domain', query.registrableDomain),
310
+ reportField('Subdomain input', yesNoUnknown(query.isSubdomain)),
311
+ ],
312
+ assessment: [
313
+ reportField('Availability', displayLabel(availability.state)),
314
+ reportField('Confidence', displayLabel(availability.confidence)),
315
+ reportField('Risk score', risk.score === null ? null : `${risk.score}/100`),
316
+ reportField('Risk model', risk.modelVersion === null ? null : `v${risk.modelVersion}`),
317
+ reportField('Risk factors', risk.factors.length ? listText(risk.factors) : risk.score === null ? null : 'None observed'),
318
+ reportField('Detail', availability.detail),
319
+ reportField('Website activity', displayLabel(availability.activityStatus)),
320
+ reportField('Website observation', availability.websiteProbeDetail),
321
+ reportField('Deep scan complete', yesNoUnknown(availability.deepScanComplete)),
322
+ reportField('MX observed', yesNoUnknown(availability.hasMx)),
323
+ reportField('SPF observed', yesNoUnknown(availability.hasSpf)),
324
+ reportField('DMARC observed', yesNoUnknown(availability.hasDmarc)),
325
+ ],
326
+ registryGroups: [
327
+ groups.registryRdap,
328
+ ...(Object.keys(registrarRdap).length ? [groups.registrarRdap] : []),
329
+ groups.whois,
330
+ ],
331
+ registryInterpretation: registryInsights.version === 1 ? [
332
+ reportField('Lifecycle', displayLabel(registryLifecycle.label)),
333
+ reportField('Raw lifecycle statuses', listText(registryLifecycle.rawStatuses)),
334
+ reportField('Client registration lock observed', yesNoUnknown(registryLocks.client)),
335
+ reportField('Server registration lock observed', yesNoUnknown(registryLocks.server)),
336
+ reportField('Registry RDAP disclosure', displayLabel(registryDisclosure.state)),
337
+ reportField('WHOIS disclosure', displayLabel(whoisDisclosure.state)),
338
+ reportField('Reconciliation', displayLabel(registryReconciliation.state)),
339
+ reportField('Reconciliation summary', registryReconciliation.summary),
340
+ reportField('Complete publications', registryPublications.filter((item) => item.state === 'complete').length),
341
+ reportField('Partial publications', registryPublications.filter((item) => item.state === 'partial').length),
342
+ reportField('Unavailable publications', registryPublications.filter((item) => item.state === 'unavailable').length),
343
+ reportField('Published escalation routes', Array.isArray(registryInsights.abuseRouting) ? registryInsights.abuseRouting.length : 0),
344
+ reportField('Interpretation limit', contactDisclosure.limitation),
345
+ ] : [],
346
+ comparison: {
347
+ health: [
348
+ reportField('RDAP health', displayLabel(rdapComparisonHealth.status)),
349
+ reportField('WHOIS health', displayLabel(whoisComparisonHealth.status)),
350
+ ],
351
+ fields: comparisonFields,
352
+ omitted: Math.max(0, (Array.isArray(comparison.fields) ? comparison.fields.length : 0) - comparisonFields.length),
353
+ },
354
+ registrarComparison: {
355
+ health: [
356
+ reportField('Registry RDAP health', displayLabel(registryPublicationHealth.status)),
357
+ reportField('Registrar RDAP health', displayLabel(registrarPublicationHealth.status)),
358
+ ],
359
+ fields: registrarComparisonFields,
360
+ omitted: Math.max(0, (Array.isArray(registrarComparison.fields) ? registrarComparison.fields.length : 0) - registrarComparisonFields.length),
361
+ },
362
+ networkGroups: [
363
+ groups.dns,
364
+ groups.website,
365
+ groups.tls,
366
+ ...(networkContext.contextVersion === 1 ? [groups.networkRegistration] : []),
367
+ ],
368
+ diagnostics: [
369
+ reportField('Diagnostics version', diagnostics.version),
370
+ reportField('RDAP', displayLabel(rdapDiagnostics.status)),
371
+ reportField('Registrar RDAP', displayLabel(registrarRdapDiagnostics.status)),
372
+ reportField('WHOIS', displayLabel(whoisDiagnostics.status)),
373
+ reportField('Availability', displayLabel(availabilityDiagnostics.status)),
374
+ ...(networkContext.contextVersion === 1 ? [reportField('Observed network context', displayLabel(networkContext.status))] : []),
375
+ ...registryAccessFields,
376
+ ],
377
+ limitations: [
378
+ 'This report summarizes point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
379
+ 'Missing, skipped, partial, unsupported, or failed sources are inconclusive rather than negative evidence.',
380
+ 'The Risk score is a versioned heuristic review priority, not a probability or determination of maliciousness. Review its listed factors and the underlying source states.',
381
+ 'Raw registry payloads and full WHOIS referral responses are available only in the JSON evidence package and may contain public contact data.',
382
+ ...(networkContext.contextVersion === 1 ? [
383
+ 'Observed network registration describes one point-in-time public endpoint. It may identify an edge or shared network rather than the origin host and does not prove control, ownership, intent, or maliciousness.',
384
+ ] : []),
385
+ ...(registryAccessFields.length ? [
386
+ 'Registry access constraints describe collection reachability only. They do not decide registration, availability, ownership, safety, or maliciousness.',
387
+ ] : []),
388
+ ],
389
+ };
390
+ }
391
+ export { MAX_REPORT_COMPARISON_FIELDS, MAX_REPORT_LIST_ITEMS, MAX_REPORT_RISK_FACTORS, MAX_REPORT_VALUE_LENGTH, buildLookupEvidenceReport, cleanReportText, };
@@ -0,0 +1,108 @@
1
+ // Converts already-returned, separately attributed external observations into
2
+ // a conservative Risk contribution. Unknown providers and lone publisher
3
+ // families never contribute. Two adapters operated by the same publisher are
4
+ // deliberately one corroboration source, even when their datasets differ.
5
+ export const EXTERNAL_INTELLIGENCE_CALIBRATION_VERSION = 1;
6
+ export const EXTERNAL_INTELLIGENCE_RECENT_DAYS = 90;
7
+ const DAY_MS = 24 * 60 * 60 * 1000;
8
+ const MAX_CLOCK_SKEW_MS = DAY_MS;
9
+ const MAX_PROVIDERS = 10;
10
+ const MAX_FINDINGS_PER_PROVIDER = 100;
11
+ const POSITIVE_STATES = new Set(['success', 'partial']);
12
+ const QUALIFYING_CATEGORIES = new Set(['phishing', 'malware']);
13
+ // Only built-in provider IDs can affect the built-in score. The two community
14
+ // malware datasets share one publisher family and therefore cannot corroborate
15
+ // one another by themselves.
16
+ const PUBLISHER_FAMILIES = Object.freeze({
17
+ urlscan_search: 'archived-scan-publisher',
18
+ urlhaus_host: 'community-malware-publisher',
19
+ threatfox_domain_ioc: 'community-malware-publisher',
20
+ });
21
+ function record(value) {
22
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
23
+ }
24
+ function timestamp(value) {
25
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u001f\u007f]/u.test(value))
26
+ return null;
27
+ const parsed = Date.parse(value);
28
+ return Number.isFinite(parsed) ? parsed : null;
29
+ }
30
+ function providerEvidence(value) {
31
+ const provider = record(value);
32
+ const identity = record(provider?.provider);
33
+ const observation = record(provider?.observation);
34
+ const providerId = typeof identity?.id === 'string' ? identity.id : '';
35
+ const publisherFamily = Object.hasOwn(PUBLISHER_FAMILIES, providerId)
36
+ ? PUBLISHER_FAMILIES[providerId]
37
+ : null;
38
+ if (!publisherFamily
39
+ || typeof provider?.state !== 'string'
40
+ || !POSITIVE_STATES.has(provider.state)
41
+ || !Array.isArray(provider.findings))
42
+ return null;
43
+ const observedAt = timestamp(observation?.observedAt);
44
+ let latestFindingAt = null;
45
+ let qualifyingFindings = 0;
46
+ for (const item of provider.findings.slice(0, MAX_FINDINGS_PER_PROVIDER)) {
47
+ const finding = record(item);
48
+ if (!finding || typeof finding.category !== 'string' || !QUALIFYING_CATEGORIES.has(finding.category))
49
+ continue;
50
+ qualifyingFindings += 1;
51
+ const candidate = timestamp(finding.lastObservedAt) ?? timestamp(finding.firstObservedAt);
52
+ if (candidate !== null && (latestFindingAt === null || candidate > latestFindingAt))
53
+ latestFindingAt = candidate;
54
+ }
55
+ if (!qualifyingFindings)
56
+ return null;
57
+ let ageDays = null;
58
+ if (observedAt !== null && latestFindingAt !== null && latestFindingAt <= observedAt + MAX_CLOCK_SKEW_MS) {
59
+ ageDays = Math.max(0, Math.floor((observedAt - latestFindingAt) / DAY_MS));
60
+ }
61
+ return {
62
+ providerId,
63
+ publisherFamily,
64
+ qualifyingFindings,
65
+ lastObservedAt: latestFindingAt === null ? null : new Date(latestFindingAt).toISOString(),
66
+ ageDays,
67
+ recent: ageDays !== null && ageDays <= EXTERNAL_INTELLIGENCE_RECENT_DAYS,
68
+ };
69
+ }
70
+ export function calibrateExternalIntelligenceRisk(value) {
71
+ const envelope = record(value);
72
+ const providers = Array.isArray(envelope?.providers) ? envelope.providers.slice(0, MAX_PROVIDERS) : [];
73
+ const byProvider = new Map();
74
+ for (const item of providers) {
75
+ const evidence = providerEvidence(item);
76
+ if (!evidence || byProvider.has(evidence.providerId))
77
+ continue;
78
+ byProvider.set(evidence.providerId, evidence);
79
+ }
80
+ const sources = [...byProvider.values()].sort((a, b) => a.providerId.localeCompare(b.providerId));
81
+ const publisherFamilies = new Set(sources.map((source) => source.publisherFamily));
82
+ const recentPublisherFamilies = new Set(sources.filter((source) => source.recent).map((source) => source.publisherFamily));
83
+ const knownAges = sources.map((source) => source.ageDays).filter((age) => age !== null);
84
+ const independentPublisherCount = publisherFamilies.size;
85
+ const recentPublisherCount = recentPublisherFamilies.size;
86
+ let contribution = 0;
87
+ let factor = null;
88
+ if (independentPublisherCount >= 2) {
89
+ contribution = recentPublisherCount >= 2 ? 18 : 10;
90
+ factor = {
91
+ label: recentPublisherCount >= 2
92
+ ? 'Corroborated recent external phishing/malware records'
93
+ : 'Corroborated external phishing/malware records',
94
+ delta: contribution,
95
+ };
96
+ }
97
+ return {
98
+ version: EXTERNAL_INTELLIGENCE_CALIBRATION_VERSION,
99
+ contribution,
100
+ factor,
101
+ eligibleProviderCount: sources.length,
102
+ independentPublisherCount,
103
+ recentPublisherCount,
104
+ freshestAgeDays: knownAges.length ? Math.min(...knownAges) : null,
105
+ unknownAgeProviderCount: sources.filter((source) => source.ageDays === null).length,
106
+ sources,
107
+ };
108
+ }
@@ -0,0 +1,146 @@
1
+ // Favicon-hash fetch - a lookalike/typosquat domain that serves the same
2
+ // favicon as a brand's real site is a strong sign of a cloned phishing kit
3
+ // (attackers copy the favicon along with the rest of the page); a domain
4
+ // that merely resolves and serves *some* page proves much less.
5
+ //
6
+ // Two hashes are produced from the same bytes:
7
+ // - hash: exact SHA-256 of the raw bytes. Zero false positives, but only
8
+ // catches a byte-for-byte copy.
9
+ // - phash: perceptual (fuzzy) dHash (lib/perceptual-hash.mts). Catches a
10
+ // resized/recompressed/re-saved copy that's visually identical but
11
+ // no longer byte-identical - the common case when the favicon
12
+ // passed through a different tool than the original. Null when the
13
+ // image format can't be decoded (GIF/JPEG/SVG), so it strictly
14
+ // adds coverage without weakening the exact tier.
15
+ import * as crypto from 'node:crypto';
16
+ import { safeFetch, readBytesCapped } from "./safe-fetch.mjs";
17
+ 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;
25
+ // Extracts favicon URLs declared in the page's own <link rel="...icon..."> tags
26
+ // (resolved to absolute URLs against the page origin), in preference order:
27
+ // standard "icon"/"shortcut icon" first, then "apple-touch-icon". Many modern
28
+ // sites (e.g. npm) serve no /favicon.ico at all and only declare a PNG on a
29
+ // CDN this way, so checking only /favicon.ico misses them entirely. data:
30
+ // URIs are passed through verbatim - decodeFaviconCandidate handles them
31
+ // without a network fetch.
32
+ function extractIconUrls(html, baseUrl) {
33
+ const links = [];
34
+ for (const tag of html.match(/<link\b[^>]*>/gi) || []) {
35
+ const rel = (tag.match(/\brel\s*=\s*["']([^"']*)["']/i) || [])[1];
36
+ if (!rel || !/icon/i.test(rel))
37
+ continue;
38
+ const href = (tag.match(/\bhref\s*=\s*["']([^"']+)["']/i) || [])[1];
39
+ if (!href)
40
+ continue;
41
+ links.push({ href: href.trim(), priority: /apple-touch/i.test(rel) ? 1 : 0 });
42
+ }
43
+ links.sort((a, b) => a.priority - b.priority);
44
+ const urls = [];
45
+ for (const { href } of links) {
46
+ if (/^data:/i.test(href)) {
47
+ urls.push(href);
48
+ continue;
49
+ }
50
+ try {
51
+ const resolved = new URL(href, baseUrl);
52
+ if (resolved.protocol === 'http:' || resolved.protocol === 'https:')
53
+ urls.push(resolved.toString());
54
+ }
55
+ catch {
56
+ /* malformed href - skip it */
57
+ }
58
+ }
59
+ return urls;
60
+ }
61
+ // Decodes an inline `data:` favicon (base64 or percent-encoded) into a byte
62
+ // buffer, bounded to MAX_FAVICON_BYTES. Returns null on anything malformed.
63
+ function decodeDataUri(uri) {
64
+ const comma = uri.indexOf(',');
65
+ if (comma === -1)
66
+ return null;
67
+ const meta = uri.slice(5, comma);
68
+ const data = uri.slice(comma + 1);
69
+ try {
70
+ const bytes = /;base64/i.test(meta)
71
+ ? Buffer.from(data, 'base64')
72
+ : Buffer.from(decodeURIComponent(data), 'latin1');
73
+ return bytes.length > 0 && bytes.length <= MAX_FAVICON_BYTES ? bytes : null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ // Returns the favicon bytes for one candidate URL (a real fetch, or an inline
80
+ // data: decode), or null if it can't be retrieved/used.
81
+ async function fetchFaviconBytes(url, headers) {
82
+ if (/^data:/i.test(url))
83
+ return decodeDataUri(url);
84
+ const controller = new AbortController();
85
+ const timeout = setTimeout(() => controller.abort(), FAVICON_FETCH_TIMEOUT_MS);
86
+ try {
87
+ const res = await safeFetch(url, { signal: controller.signal, headers });
88
+ if (!res.ok) {
89
+ // Not reading this body - release it explicitly instead of leaving an
90
+ // unconsumed stream (and the connection it's tied to) open until
91
+ // undici's own idle-timeout eventually notices.
92
+ await res.body?.cancel().catch(() => { });
93
+ return null;
94
+ }
95
+ // The timeout stays armed through this read (cleared in `finally` below,
96
+ // not right after headers arrive) - a malicious site could otherwise send
97
+ // headers immediately and then trickle or stall the body forever, hanging
98
+ // this worker with no deadline once disarmed.
99
+ const { bytes, truncated } = await readBytesCapped(res, MAX_FAVICON_BYTES);
100
+ // A truncated or empty file can't be hashed meaningfully.
101
+ return truncated || bytes.length === 0 ? null : bytes;
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ finally {
107
+ clearTimeout(timeout);
108
+ }
109
+ }
110
+ // Fetches the domain's favicon and returns both an exact SHA-256 and a
111
+ // perceptual dHash of the same bytes (see the module header). `html`, when
112
+ // provided (the deep-check path already has the homepage in hand), is scanned
113
+ // for declared <link rel="icon"> URLs, which are tried before the bare
114
+ // /favicon.ico fallback.
115
+ function buildFaviconCandidates(domain, html = '') {
116
+ const candidates = [];
117
+ const seen = new Set();
118
+ const add = (url) => { if (url && !seen.has(url)) {
119
+ seen.add(url);
120
+ candidates.push(url);
121
+ } };
122
+ if (html)
123
+ for (const url of extractIconUrls(html, `https://${domain}/`))
124
+ add(url);
125
+ add(`https://${domain}/favicon.ico`);
126
+ add(`https://${domain}/favicon.svg`);
127
+ add(`http://${domain}/favicon.ico`);
128
+ add(`http://${domain}/favicon.svg`);
129
+ return candidates.slice(0, MAX_FAVICON_CANDIDATES);
130
+ }
131
+ async function fetchFaviconHash(domain, { html = '' } = {}) {
132
+ const headers = { 'User-Agent': 'Mozilla/5.0 (compatible; DomainStatusChecker/1.0)' };
133
+ const candidates = buildFaviconCandidates(domain, html);
134
+ for (const url of candidates) {
135
+ // eslint-disable-next-line no-await-in-loop
136
+ const bytes = await fetchFaviconBytes(url, headers);
137
+ if (bytes) {
138
+ return {
139
+ hash: crypto.createHash('sha256').update(bytes).digest('hex'),
140
+ phash: faviconPerceptualHash(bytes),
141
+ };
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ export { fetchFaviconHash, extractIconUrls, buildFaviconCandidates };