@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,814 @@
1
+ // Bounded, one-connection TLS and certificate intelligence for deep scans.
2
+ //
3
+ // The collector resolves the submitted hostname once through the same public-
4
+ // address guard used by safe-fetch, validates every returned address again at
5
+ // this boundary, and connects directly to exactly one validated address. The
6
+ // original hostname remains the SNI and certificate-identity target. It does
7
+ // not enumerate protocol versions or cipher suites and retains no certificate
8
+ // bytes, session material, or application data.
9
+ import * as crypto from 'node:crypto';
10
+ import * as net from 'node:net';
11
+ import * as tls from 'node:tls';
12
+ import { domainToASCII } from 'node:url';
13
+ import { createObservation } from "./observation.mjs";
14
+ import { isPrivateAddress, resolvePublicAddresses } from "./safe-fetch.mjs";
15
+ const TLS_PROFILE_VERSION = 2;
16
+ const TLS_PORT = 443;
17
+ const TLS_TIMEOUT_MS = 5000;
18
+ const MAX_RESOLVED_ADDRESSES = 64;
19
+ const MAX_CHAIN_CERTIFICATES = 8;
20
+ const MAX_ALT_NAMES = 50;
21
+ const MAX_SAN_ENTRIES = 100;
22
+ const MAX_EXTENDED_KEY_USAGES = 16;
23
+ const MAX_AIA_LOCATIONS = 32;
24
+ const MAX_NAME_VALUES = 4;
25
+ const MAX_TEXT_LENGTH = 256;
26
+ const MAX_ERROR_LENGTH = 240;
27
+ const MAX_SERIAL_LENGTH = 128;
28
+ const MAX_SAN_SOURCE_LENGTH = 32 * 1024;
29
+ const MAX_AIA_SOURCE_LENGTH = 16 * 1024;
30
+ const MAX_CERTIFICATE_BYTES = 256 * 1024;
31
+ const EXTENDED_KEY_USAGE_NAMES = Object.freeze({
32
+ '2.5.29.37.0': 'Any purpose',
33
+ '1.3.6.1.5.5.7.3.1': 'TLS web server authentication',
34
+ '1.3.6.1.5.5.7.3.2': 'TLS web client authentication',
35
+ '1.3.6.1.5.5.7.3.3': 'Code signing',
36
+ '1.3.6.1.5.5.7.3.4': 'Email protection',
37
+ '1.3.6.1.5.5.7.3.8': 'Timestamping',
38
+ '1.3.6.1.5.5.7.3.9': 'OCSP signing',
39
+ });
40
+ function defaultTlsConnect(options, callback) {
41
+ return tls.connect(options, callback);
42
+ }
43
+ function tracker() {
44
+ return { truncated: false, discarded: 0 };
45
+ }
46
+ function boundedString(value, maxLength, state) {
47
+ if (typeof value !== 'string')
48
+ return null;
49
+ const trimmed = value.trim();
50
+ if (!trimmed)
51
+ return null;
52
+ const normalized = trimmed.replace(/[\u0000-\u001f\u007f]/g, ' ');
53
+ if (normalized !== trimmed)
54
+ state.discarded += 1;
55
+ if (normalized.length > maxLength)
56
+ state.truncated = true;
57
+ return normalized.slice(0, maxLength);
58
+ }
59
+ function normalizeTlsHostname(value) {
60
+ if (typeof value !== 'string' || value.length > 1024 || /[\u0000-\u0020\u007f]/.test(value))
61
+ return null;
62
+ const ascii = domainToASCII(value.trim().replace(/\.+$/, '')).toLowerCase();
63
+ if (!ascii || ascii.length > 253 || net.isIP(ascii))
64
+ return null;
65
+ const labels = ascii.split('.');
66
+ if (labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))
67
+ return null;
68
+ return ascii;
69
+ }
70
+ function normalizeIp(value) {
71
+ if (typeof value !== 'string' || value.length > 64 || /[\u0000-\u0020\u007f%]/.test(value))
72
+ return null;
73
+ return net.isIP(value) ? value.toLowerCase() : null;
74
+ }
75
+ function normalizePublicIp(value) {
76
+ const address = normalizeIp(value);
77
+ return address && !isPrivateAddress(address) ? address : null;
78
+ }
79
+ function normalizePublicAddressRecords(records) {
80
+ if (!Array.isArray(records) || records.length === 0)
81
+ throw new Error('TLS target has no resolved addresses');
82
+ if (records.length > MAX_RESOLVED_ADDRESSES)
83
+ throw new Error('TLS target returned too many resolved addresses');
84
+ return records.map((record) => {
85
+ const value = record && typeof record === 'object' ? record : {};
86
+ const address = normalizeIp(value.address);
87
+ const family = Number(value.family);
88
+ if (!address || ![4, 6].includes(family) || net.isIP(address) !== family || isPrivateAddress(address)) {
89
+ throw new Error('TLS target resolved to an invalid or private/reserved address');
90
+ }
91
+ return { address, family };
92
+ });
93
+ }
94
+ function normalizeDate(value, state) {
95
+ if (typeof value !== 'string')
96
+ return null;
97
+ if (value.length > 64 || /[\u0000-\u001f\u007f]/.test(value)) {
98
+ if (value)
99
+ state.discarded += 1;
100
+ if (value.length > 64)
101
+ state.truncated = true;
102
+ return null;
103
+ }
104
+ const parsed = Date.parse(value);
105
+ if (!Number.isFinite(parsed)) {
106
+ if (value.trim())
107
+ state.discarded += 1;
108
+ return null;
109
+ }
110
+ return new Date(parsed).toISOString();
111
+ }
112
+ function normalizeFingerprint(value, state) {
113
+ if (typeof value !== 'string')
114
+ return null;
115
+ if (value.length > 128) {
116
+ state.truncated = true;
117
+ state.discarded += 1;
118
+ return null;
119
+ }
120
+ const compact = value.replace(/:/g, '').toLowerCase();
121
+ if (/^[0-9a-f]{64}$/.test(compact))
122
+ return compact;
123
+ if (value.trim())
124
+ state.discarded += 1;
125
+ return null;
126
+ }
127
+ function normalizeSha1Fingerprint(value, state) {
128
+ if (typeof value !== 'string')
129
+ return null;
130
+ if (value.length > 96) {
131
+ state.truncated = true;
132
+ state.discarded += 1;
133
+ return null;
134
+ }
135
+ const compact = value.replace(/:/g, '').toLowerCase();
136
+ if (/^[0-9a-f]{40}$/.test(compact))
137
+ return compact;
138
+ if (value.trim())
139
+ state.discarded += 1;
140
+ return null;
141
+ }
142
+ function normalizeSerial(value, state) {
143
+ if (typeof value !== 'string' || /[\u0000-\u001f\u007f]/.test(value))
144
+ return null;
145
+ const compact = value.trim().replace(/:/g, '').toLowerCase();
146
+ if (!compact || compact.length > MAX_SERIAL_LENGTH || !/^[0-9a-f]+$/.test(compact)) {
147
+ if (compact)
148
+ state.discarded += 1;
149
+ if (compact.length > MAX_SERIAL_LENGTH)
150
+ state.truncated = true;
151
+ return null;
152
+ }
153
+ return compact.replace(/^0+(?=[0-9a-f])/, '');
154
+ }
155
+ const NAME_FIELDS = Object.freeze({
156
+ CN: 'commonNames',
157
+ O: 'organizations',
158
+ OU: 'organizationalUnits',
159
+ C: 'countries',
160
+ L: 'localities',
161
+ ST: 'states',
162
+ });
163
+ function normalizeNameValues(value, state) {
164
+ const source = Array.isArray(value) ? value : [value];
165
+ const values = [];
166
+ for (const item of source) {
167
+ const normalized = boundedString(item, MAX_TEXT_LENGTH, state);
168
+ if (!normalized || values.includes(normalized))
169
+ continue;
170
+ if (values.length >= MAX_NAME_VALUES)
171
+ state.truncated = true;
172
+ else
173
+ values.push(normalized);
174
+ }
175
+ return values;
176
+ }
177
+ function normalizeDistinguishedName(value, state) {
178
+ const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
179
+ return Object.fromEntries(Object.entries(NAME_FIELDS).map(([sourceKey, outputKey]) => [
180
+ outputKey,
181
+ normalizeNameValues(source[sourceKey], state),
182
+ ]));
183
+ }
184
+ function parseQuotedAltName(value) {
185
+ if (!value.startsWith('"'))
186
+ return null;
187
+ try {
188
+ const parsed = JSON.parse(value);
189
+ return typeof parsed === 'string' ? parsed : null;
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ }
195
+ function splitAltNames(source, limit = MAX_SAN_ENTRIES + 1) {
196
+ const output = [];
197
+ let start = 0;
198
+ let quoted = false;
199
+ let escaped = false;
200
+ for (let index = 0; index < source.length; index += 1) {
201
+ const char = source[index];
202
+ if (escaped) {
203
+ escaped = false;
204
+ continue;
205
+ }
206
+ if (quoted && char === '\\') {
207
+ escaped = true;
208
+ continue;
209
+ }
210
+ if (char === '"') {
211
+ quoted = !quoted;
212
+ continue;
213
+ }
214
+ if (!quoted && char === ',') {
215
+ output.push(source.slice(start, index).trim());
216
+ if (output.length >= limit)
217
+ return output;
218
+ start = index + 1;
219
+ }
220
+ }
221
+ if (output.length < limit)
222
+ output.push(source.slice(start).trim());
223
+ return output;
224
+ }
225
+ function normalizeSanDns(value) {
226
+ if (typeof value !== 'string' || value.length > 255 || /[\u0000-\u0020\u007f]/.test(value))
227
+ return null;
228
+ if (value.startsWith('*.')) {
229
+ const suffix = normalizeTlsHostname(value.slice(2));
230
+ return suffix ? `*.${suffix}` : null;
231
+ }
232
+ return normalizeTlsHostname(value);
233
+ }
234
+ function normalizeAltNames(value, state) {
235
+ const result = {
236
+ dnsNames: [],
237
+ ipAddresses: [],
238
+ classes: {
239
+ dns: 0,
240
+ ip: 0,
241
+ email: 0,
242
+ uri: 0,
243
+ directoryName: 0,
244
+ registeredId: 0,
245
+ otherName: 0,
246
+ unclassified: 0,
247
+ },
248
+ truncated: false,
249
+ };
250
+ if (typeof value !== 'string' || !value)
251
+ return result;
252
+ let source = value;
253
+ if (source.length > MAX_SAN_SOURCE_LENGTH) {
254
+ source = source.slice(0, MAX_SAN_SOURCE_LENGTH);
255
+ state.truncated = true;
256
+ result.truncated = true;
257
+ }
258
+ const entries = splitAltNames(source);
259
+ if (entries.length > MAX_SAN_ENTRIES) {
260
+ state.truncated = true;
261
+ result.truncated = true;
262
+ }
263
+ for (const entry of entries.slice(0, MAX_SAN_ENTRIES)) {
264
+ const separator = entry.indexOf(':');
265
+ if (separator <= 0) {
266
+ result.classes.unclassified += 1;
267
+ continue;
268
+ }
269
+ const type = entry.slice(0, separator).trim().toLowerCase();
270
+ const raw = entry.slice(separator + 1).trim();
271
+ const decoded = raw.startsWith('"') ? parseQuotedAltName(raw) : raw;
272
+ if (decoded === null) {
273
+ state.discarded += 1;
274
+ continue;
275
+ }
276
+ if (type === 'dns')
277
+ result.classes.dns += 1;
278
+ else if (type === 'ip address')
279
+ result.classes.ip += 1;
280
+ else if (type === 'email')
281
+ result.classes.email += 1;
282
+ else if (type === 'uri')
283
+ result.classes.uri += 1;
284
+ else if (type === 'dirname' || type === 'directory name')
285
+ result.classes.directoryName += 1;
286
+ else if (type === 'registered id' || type === 'registeredid')
287
+ result.classes.registeredId += 1;
288
+ else if (type === 'othername' || type === 'other name')
289
+ result.classes.otherName += 1;
290
+ else
291
+ result.classes.unclassified += 1;
292
+ const list = type === 'dns' ? result.dnsNames : type === 'ip address' ? result.ipAddresses : null;
293
+ const normalized = type === 'dns' ? normalizeSanDns(decoded) : type === 'ip address' ? normalizeIp(decoded) : null;
294
+ if (!list || !normalized) {
295
+ if (list)
296
+ state.discarded += 1;
297
+ continue;
298
+ }
299
+ if (list.includes(normalized))
300
+ continue;
301
+ if (result.dnsNames.length + result.ipAddresses.length >= MAX_ALT_NAMES) {
302
+ state.truncated = true;
303
+ result.truncated = true;
304
+ }
305
+ else
306
+ list.push(normalized);
307
+ }
308
+ result.dnsNames.sort();
309
+ result.ipAddresses.sort();
310
+ return result;
311
+ }
312
+ function normalizeOid(value, state) {
313
+ if (typeof value !== 'string')
314
+ return null;
315
+ const oid = value.trim();
316
+ if (!/^\d{1,10}(?:\.\d{1,10}){1,31}$/.test(oid) || oid.length > 128) {
317
+ if (oid)
318
+ state.discarded += 1;
319
+ if (oid.length > 128)
320
+ state.truncated = true;
321
+ return null;
322
+ }
323
+ return oid;
324
+ }
325
+ function certificatePurposes(value, state) {
326
+ const source = Array.isArray(value) ? value : [];
327
+ const values = [];
328
+ const truncated = source.length > MAX_EXTENDED_KEY_USAGES;
329
+ for (const entry of source.slice(0, MAX_EXTENDED_KEY_USAGES)) {
330
+ const oid = normalizeOid(entry, state);
331
+ if (!oid || values.some((purpose) => purpose.oid === oid))
332
+ continue;
333
+ values.push({ oid, name: EXTENDED_KEY_USAGE_NAMES[oid] || 'Unrecognized purpose' });
334
+ }
335
+ values.sort((left, right) => left.oid.localeCompare(right.oid));
336
+ if (truncated)
337
+ state.truncated = true;
338
+ return { values, truncated };
339
+ }
340
+ function emptyAiaLocationSummary() {
341
+ return { total: 0, http: 0, https: 0, other: 0 };
342
+ }
343
+ function classifyAiaLocation(summary, value) {
344
+ if (typeof value !== 'string')
345
+ return;
346
+ const location = value.trim();
347
+ if (!location || location.length > 4096 || /[\u0000-\u001f\u007f]/.test(location))
348
+ return;
349
+ const scheme = /^([a-z][a-z0-9+.-]{0,31}):/i.exec(location)?.[1]?.toLowerCase();
350
+ summary.total += 1;
351
+ if (scheme === 'https')
352
+ summary.https += 1;
353
+ else if (scheme === 'http')
354
+ summary.http += 1;
355
+ else
356
+ summary.other += 1;
357
+ }
358
+ function authorityInformationAccess(value, state) {
359
+ const result = {
360
+ ocsp: emptyAiaLocationSummary(),
361
+ caIssuers: emptyAiaLocationSummary(),
362
+ unknownMethods: 0,
363
+ truncated: false,
364
+ };
365
+ const entries = [];
366
+ if (typeof value === 'string') {
367
+ let source = value;
368
+ if (source.length > MAX_AIA_SOURCE_LENGTH) {
369
+ source = source.slice(0, MAX_AIA_SOURCE_LENGTH);
370
+ result.truncated = true;
371
+ }
372
+ for (const line of source.split(/\r?\n/, MAX_AIA_LOCATIONS + 1)) {
373
+ const match = /^\s*(.+?)\s+-\s+[A-Z][A-Z0-9 ]{0,31}:(.+)\s*$/i.exec(line);
374
+ if (match?.[1] && match[2])
375
+ entries.push({ method: match[1], location: match[2] });
376
+ else if (line.trim())
377
+ result.unknownMethods += 1;
378
+ }
379
+ }
380
+ else if (value && typeof value === 'object' && !Array.isArray(value)) {
381
+ const sourceRecord = value;
382
+ outer: for (const key in sourceRecord) {
383
+ if (!Object.hasOwn(sourceRecord, key))
384
+ continue;
385
+ const method = key.replace(/\s+-\s+.*$/, '').trim();
386
+ const locations = sourceRecord[key];
387
+ const source = Array.isArray(locations) ? locations : [locations];
388
+ for (const location of source) {
389
+ entries.push({ method, location });
390
+ if (entries.length >= MAX_AIA_LOCATIONS + 1)
391
+ break outer;
392
+ }
393
+ }
394
+ }
395
+ if (entries.length > MAX_AIA_LOCATIONS)
396
+ result.truncated = true;
397
+ for (const entry of entries.slice(0, MAX_AIA_LOCATIONS)) {
398
+ const normalizedMethod = entry.method.trim().toLowerCase();
399
+ if (normalizedMethod === 'ocsp')
400
+ classifyAiaLocation(result.ocsp, entry.location);
401
+ else if (normalizedMethod === 'ca issuers')
402
+ classifyAiaLocation(result.caIssuers, entry.location);
403
+ else
404
+ result.unknownMethods += 1;
405
+ }
406
+ if (result.truncated)
407
+ state.truncated = true;
408
+ return result;
409
+ }
410
+ function x509Certificate(raw, state) {
411
+ if (!Buffer.isBuffer(raw) || raw.length === 0 || raw.length > MAX_CERTIFICATE_BYTES)
412
+ return null;
413
+ try {
414
+ return new crypto.X509Certificate(raw);
415
+ }
416
+ catch {
417
+ state.discarded += 1;
418
+ return null;
419
+ }
420
+ }
421
+ function hashRawCertificate(raw, state) {
422
+ if (!Buffer.isBuffer(raw))
423
+ return null;
424
+ if (raw.length === 0 || raw.length > MAX_CERTIFICATE_BYTES) {
425
+ state.discarded += 1;
426
+ if (raw.length > MAX_CERTIFICATE_BYTES)
427
+ state.truncated = true;
428
+ return null;
429
+ }
430
+ return crypto.createHash('sha256').update(raw).digest('hex');
431
+ }
432
+ function publicKeyMetadata(certificate, fallback, state) {
433
+ if (certificate) {
434
+ try {
435
+ const key = certificate.publicKey;
436
+ const exported = key.export({ format: 'der', type: 'spki' });
437
+ const details = key.asymmetricKeyDetails || {};
438
+ const modulusLength = details.modulusLength;
439
+ return {
440
+ type: boundedString(key.asymmetricKeyType, 32, state),
441
+ bits: typeof modulusLength === 'number' && Number.isSafeInteger(modulusLength) && modulusLength > 0 ? modulusLength : null,
442
+ curve: boundedString(details.namedCurve, 64, state),
443
+ fingerprintSha256: crypto.createHash('sha256').update(exported).digest('hex'),
444
+ };
445
+ }
446
+ catch {
447
+ state.discarded += 1;
448
+ }
449
+ }
450
+ const bits = Number(fallback && fallback.bits);
451
+ return {
452
+ type: null,
453
+ bits: Number.isSafeInteger(bits) && bits > 0 && bits <= 32768 ? bits : null,
454
+ curve: boundedString(fallback && (fallback.nistCurve || fallback.asn1Curve), 64, state),
455
+ fingerprintSha256: null,
456
+ };
457
+ }
458
+ function normalizeCertificate(value, state, options = {}) {
459
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length === 0)
460
+ return null;
461
+ const record = value;
462
+ const parsedCertificate = options.includePublicKey === true ? x509Certificate(record.raw, state) : null;
463
+ const fingerprintSha256 = hashRawCertificate(record.raw, state) || normalizeFingerprint(record.fingerprint256, state);
464
+ // Node's peer-certificate fingerprint is retained only as the provider
465
+ // identifier needed for exact SSLBL comparison. WHOISleuth does not compute
466
+ // or use SHA-1 for trust, signatures, uniqueness, or security decisions.
467
+ const fingerprintSha1 = normalizeSha1Fingerprint(record.fingerprint, state);
468
+ const certificate = {
469
+ subject: normalizeDistinguishedName(record.subject, state),
470
+ issuer: normalizeDistinguishedName(record.issuer, state),
471
+ serialNumber: normalizeSerial(record.serialNumber, state),
472
+ validFrom: normalizeDate(record.valid_from, state),
473
+ validTo: normalizeDate(record.valid_to, state),
474
+ fingerprintSha1,
475
+ fingerprintSha256,
476
+ isCertificateAuthority: typeof record.ca === 'boolean' ? record.ca : null,
477
+ };
478
+ if (options.includeAltNames === true) {
479
+ certificate.subjectAltNames = normalizeAltNames(parsedCertificate?.subjectAltName || record.subjectaltname, state);
480
+ }
481
+ if (options.includePublicKey === true) {
482
+ certificate.publicKey = publicKeyMetadata(parsedCertificate, record, state);
483
+ certificate.signature = {
484
+ algorithm: boundedString(parsedCertificate?.signatureAlgorithm, 128, state),
485
+ oid: normalizeOid(parsedCertificate?.signatureAlgorithmOid, state),
486
+ };
487
+ certificate.extendedKeyUsage = certificatePurposes(parsedCertificate?.keyUsage || record.ext_key_usage, state);
488
+ certificate.authorityInformationAccess = authorityInformationAccess(parsedCertificate?.infoAccess || record.infoAccess, state);
489
+ }
490
+ return certificate;
491
+ }
492
+ function certificateIdentity(value, state) {
493
+ const record = value && typeof value === 'object' ? value : {};
494
+ return hashRawCertificate(record.raw, state)
495
+ || normalizeFingerprint(record.fingerprint256, state)
496
+ || normalizeSerial(record.serialNumber, state);
497
+ }
498
+ function certificateChain(peer, state) {
499
+ const chain = [];
500
+ const seenObjects = new Set();
501
+ const seenIdentities = new Set();
502
+ let current = peer;
503
+ let truncated = false;
504
+ while (current && typeof current === 'object' && Object.keys(current).length > 0) {
505
+ if (seenObjects.has(current))
506
+ break;
507
+ seenObjects.add(current);
508
+ const identity = certificateIdentity(current, state);
509
+ if (identity && seenIdentities.has(identity))
510
+ break;
511
+ if (identity)
512
+ seenIdentities.add(identity);
513
+ if (chain.length >= MAX_CHAIN_CERTIFICATES) {
514
+ state.truncated = true;
515
+ truncated = true;
516
+ break;
517
+ }
518
+ const normalized = normalizeCertificate(current, state);
519
+ if (!normalized)
520
+ break;
521
+ chain.push(normalized);
522
+ const next = current.issuerCertificate;
523
+ if (!next || next === current)
524
+ break;
525
+ current = next;
526
+ }
527
+ return { certificates: chain, truncated };
528
+ }
529
+ function boundedCipher(value, state) {
530
+ if (!value || typeof value !== 'object' || Array.isArray(value))
531
+ return null;
532
+ const record = value;
533
+ const name = boundedString(record.name, 128, state);
534
+ const standardName = boundedString(record.standardName, 128, state);
535
+ const version = boundedString(record.version, 32, state);
536
+ return name || standardName || version ? { name, standardName, version } : null;
537
+ }
538
+ function boundedEphemeralKey(value, state) {
539
+ if (!value || typeof value !== 'object' || Array.isArray(value))
540
+ return null;
541
+ const record = value;
542
+ const type = boundedString(record.type, 32, state);
543
+ const name = boundedString(record.name, 64, state);
544
+ const size = Number(record.size);
545
+ const normalizedSize = Number.isSafeInteger(size) && size > 0 && size <= 32768 ? size : null;
546
+ return type || name || normalizedSize ? { type, name, size: normalizedSize } : null;
547
+ }
548
+ function validityStatus(certificate, now) {
549
+ if (!certificate || !certificate.validFrom || !certificate.validTo)
550
+ return 'unknown';
551
+ const time = now.getTime();
552
+ if (time < Date.parse(certificate.validFrom))
553
+ return 'not_yet_valid';
554
+ if (time > Date.parse(certificate.validTo))
555
+ return 'expired';
556
+ return 'valid';
557
+ }
558
+ function tlsFindings(profile) {
559
+ const findings = [];
560
+ if (profile.validity.status === 'expired')
561
+ findings.push({ id: 'certificate_expired', tone: 'warning', label: 'Certificate expired', detail: 'The leaf certificate was outside its validity period at observation time.' });
562
+ if (profile.validity.status === 'not_yet_valid')
563
+ findings.push({ id: 'certificate_not_yet_valid', tone: 'warning', label: 'Certificate not yet valid', detail: 'The leaf certificate validity period had not started at observation time.' });
564
+ if (profile.authorization.authorized === false)
565
+ findings.push({ id: 'certificate_unauthorized', tone: 'warning', label: 'Certificate not authorized', detail: 'The runtime trust store did not authorize the observed certificate chain. This can reflect an incomplete chain, private CA, self-signed certificate, expiry, or another validation failure.' });
566
+ if (profile.hostname.matches === false)
567
+ findings.push({ id: 'hostname_mismatch', tone: 'warning', label: 'Hostname mismatch', detail: 'The observed leaf certificate did not match the SNI hostname.' });
568
+ if (profile.certificate?.subjectAltNames?.dnsNames.some((name) => name.startsWith('*.')))
569
+ findings.push({ id: 'wildcard_certificate', tone: 'neutral', label: 'Wildcard certificate', detail: 'The leaf certificate includes at least one wildcard DNS name. Wildcard use is common and is not inherently suspicious.' });
570
+ return findings;
571
+ }
572
+ function buildTlsObservation(handshake = {}, options = {}) {
573
+ const state = tracker();
574
+ const certificate = normalizeCertificate(handshake.peerCertificate, state, { includeAltNames: true, includePublicKey: true });
575
+ const chain = certificateChain(handshake.peerCertificate, state);
576
+ const connectedAddress = normalizePublicIp(handshake.connectedAddress);
577
+ const protocol = boundedString(handshake.protocol, 32, state);
578
+ const cipher = boundedCipher(handshake.cipher, state);
579
+ const sniHost = normalizeTlsHostname(handshake.sniHost);
580
+ const authorized = typeof handshake.authorized === 'boolean' ? handshake.authorized : null;
581
+ const hostnameMatches = typeof handshake.hostnameMatches === 'boolean' ? handshake.hostnameMatches : null;
582
+ const authorizationError = authorized === false
583
+ ? boundedString(handshake.authorizationError, MAX_ERROR_LENGTH, state)
584
+ : null;
585
+ const hostnameError = hostnameMatches === false
586
+ ? boundedString(handshake.hostnameError, MAX_ERROR_LENGTH, state)
587
+ : null;
588
+ const now = options.now instanceof Date ? options.now : new Date(options.now || Date.now());
589
+ const profile = {
590
+ profileVersion: TLS_PROFILE_VERSION,
591
+ connectedAddress,
592
+ connectedFamily: connectedAddress ? net.isIP(connectedAddress) : null,
593
+ port: TLS_PORT,
594
+ sniHost,
595
+ protocol,
596
+ alpnProtocol: boundedString(handshake.alpnProtocol, 32, state),
597
+ cipher,
598
+ ephemeralKey: boundedEphemeralKey(handshake.ephemeralKey, state),
599
+ authorization: {
600
+ authorized,
601
+ error: authorizationError,
602
+ },
603
+ hostname: {
604
+ matches: hostnameMatches,
605
+ error: hostnameError,
606
+ },
607
+ validity: { status: validityStatus(certificate, now) },
608
+ certificate,
609
+ chain: chain.certificates,
610
+ chainTruncated: chain.truncated,
611
+ findings: [],
612
+ };
613
+ profile.findings = tlsFindings(profile);
614
+ const complete = Boolean(connectedAddress
615
+ && sniHost
616
+ && protocol
617
+ && cipher
618
+ && certificate?.fingerprintSha256
619
+ && typeof authorized === 'boolean'
620
+ && typeof hostnameMatches === 'boolean')
621
+ && !state.truncated
622
+ && state.discarded === 0;
623
+ return {
624
+ ...createObservation({
625
+ status: complete ? 'success' : 'partial',
626
+ observedAt: options.observedAt,
627
+ scanMode: 'deep',
628
+ source: 'tls',
629
+ durationMs: options.durationMs,
630
+ complete,
631
+ truncated: state.truncated,
632
+ limitations: [
633
+ 'This is a point-in-time TLS handshake to one validated public address; other addresses or edge locations may present different results.',
634
+ 'Authorization reflects the certificate authorities and verification behavior available to this runtime.',
635
+ 'The profile records the negotiated connection only and does not enumerate supported TLS versions or cipher suites.',
636
+ ...(state.truncated ? ['One or more certificate fields or chain entries reached a retention limit.'] : []),
637
+ ],
638
+ diagnostics: {
639
+ connectionAttempts: 1,
640
+ resolvedAddressCount: typeof options.resolvedAddressCount === 'number' && Number.isSafeInteger(options.resolvedAddressCount)
641
+ ? Math.max(0, Math.min(MAX_RESOLVED_ADDRESSES, options.resolvedAddressCount))
642
+ : null,
643
+ discardedFields: state.discarded,
644
+ },
645
+ }),
646
+ ...profile,
647
+ };
648
+ }
649
+ function failedTlsObservation(error, options = {}) {
650
+ const state = tracker();
651
+ const detail = boundedString(error instanceof Error ? error.message : String(error || 'TLS connection failed'), MAX_ERROR_LENGTH, state);
652
+ const connectedAddress = normalizePublicIp(options.connectedAddress);
653
+ return {
654
+ ...createObservation({
655
+ status: 'error',
656
+ observedAt: options.observedAt,
657
+ scanMode: 'deep',
658
+ source: 'tls',
659
+ durationMs: options.durationMs,
660
+ complete: false,
661
+ limitations: ['TLS intelligence was inconclusive; DNS, connection, timeout, policy, or handshake failure is not proof that no TLS service exists.'],
662
+ diagnostics: { connectionAttempts: options.connectionAttempts || 0, error: detail },
663
+ }),
664
+ profileVersion: TLS_PROFILE_VERSION,
665
+ connectedAddress,
666
+ connectedFamily: connectedAddress ? net.isIP(connectedAddress) : null,
667
+ port: TLS_PORT,
668
+ sniHost: normalizeTlsHostname(options.sniHost),
669
+ protocol: null,
670
+ alpnProtocol: null,
671
+ cipher: null,
672
+ ephemeralKey: null,
673
+ authorization: { authorized: null, error: null },
674
+ hostname: { matches: null, error: null },
675
+ validity: { status: 'unknown' },
676
+ certificate: null,
677
+ chain: [],
678
+ chainTruncated: false,
679
+ findings: [],
680
+ };
681
+ }
682
+ function skippedTlsObservation(detail = 'TLS intelligence is disabled by deployment policy.') {
683
+ return {
684
+ ...failedTlsObservation(detail, { connectionAttempts: 0 }),
685
+ ...createObservation({
686
+ status: 'skipped',
687
+ scanMode: 'deep',
688
+ source: 'tls',
689
+ complete: false,
690
+ limitations: [detail],
691
+ diagnostics: { connectionAttempts: 0 },
692
+ }),
693
+ };
694
+ }
695
+ async function collectTlsIntelligence(hostname, options = {}) {
696
+ const normalizedHostname = normalizeTlsHostname(hostname);
697
+ const now = options.now || Date.now;
698
+ const observedAt = options.observedAt || (() => new Date().toISOString());
699
+ const started = now();
700
+ if (!normalizedHostname)
701
+ return failedTlsObservation('TLS target hostname is invalid', { observedAt: observedAt(), durationMs: now() - started });
702
+ const requestedTimeout = Number(options.timeoutMs);
703
+ const timeoutMs = Number.isFinite(requestedTimeout) ? Math.max(100, Math.min(15_000, requestedTimeout)) : TLS_TIMEOUT_MS;
704
+ const setTimer = options.setTimer || ((callback, milliseconds) => setTimeout(callback, milliseconds));
705
+ const clearTimer = options.clearTimer || ((handle) => clearTimeout(handle));
706
+ const resolveAddresses = options.resolveAddresses || resolvePublicAddresses;
707
+ let records;
708
+ let resolutionDeadline;
709
+ try {
710
+ const resolved = await Promise.race([
711
+ Promise.resolve().then(() => resolveAddresses(normalizedHostname)),
712
+ new Promise((_, reject) => {
713
+ resolutionDeadline = setTimer(() => reject(new Error('TLS resolution timed out')), timeoutMs);
714
+ }),
715
+ ]);
716
+ records = normalizePublicAddressRecords(resolved);
717
+ }
718
+ catch (error) {
719
+ return failedTlsObservation(error, { sniHost: normalizedHostname, observedAt: observedAt(), durationMs: now() - started });
720
+ }
721
+ finally {
722
+ if (resolutionDeadline !== undefined)
723
+ clearTimer(resolutionDeadline);
724
+ }
725
+ const selected = records[0];
726
+ if (!selected) {
727
+ return failedTlsObservation('TLS target has no resolved addresses', {
728
+ sniHost: normalizedHostname,
729
+ connectionAttempts: 0,
730
+ observedAt: observedAt(),
731
+ durationMs: now() - started,
732
+ });
733
+ }
734
+ const connect = options.connect || defaultTlsConnect;
735
+ const checkServerIdentity = options.checkServerIdentity || ((host, certificate) => tls.checkServerIdentity(host, certificate));
736
+ const remainingMs = Math.max(0, timeoutMs - Math.max(0, now() - started));
737
+ if (remainingMs === 0) {
738
+ return failedTlsObservation('TLS handshake timed out', {
739
+ sniHost: normalizedHostname,
740
+ connectedAddress: selected.address,
741
+ connectionAttempts: 0,
742
+ observedAt: observedAt(),
743
+ durationMs: now() - started,
744
+ });
745
+ }
746
+ return new Promise((resolve) => {
747
+ let socket;
748
+ let deadline;
749
+ let settled = false;
750
+ const finish = (result) => {
751
+ if (settled)
752
+ return;
753
+ settled = true;
754
+ if (deadline !== undefined)
755
+ clearTimer(deadline);
756
+ if (socket && typeof socket.destroy === 'function')
757
+ socket.destroy();
758
+ resolve(result);
759
+ };
760
+ const fail = (error) => finish(failedTlsObservation(error, {
761
+ sniHost: normalizedHostname,
762
+ connectedAddress: selected.address,
763
+ connectionAttempts: 1,
764
+ observedAt: observedAt(),
765
+ durationMs: now() - started,
766
+ }));
767
+ try {
768
+ socket = connect({
769
+ host: selected.address,
770
+ port: TLS_PORT,
771
+ servername: normalizedHostname,
772
+ rejectUnauthorized: false,
773
+ ALPNProtocols: ['h2', 'http/1.1'],
774
+ }, () => {
775
+ try {
776
+ const peerCertificate = socket.getPeerCertificate(true);
777
+ let hostnameError = null;
778
+ if (peerCertificate && Object.keys(peerCertificate).length > 0) {
779
+ hostnameError = checkServerIdentity(normalizedHostname, peerCertificate) || null;
780
+ }
781
+ finish(buildTlsObservation({
782
+ connectedAddress: socket.remoteAddress || selected.address,
783
+ sniHost: normalizedHostname,
784
+ protocol: socket.getProtocol(),
785
+ alpnProtocol: socket.alpnProtocol || null,
786
+ cipher: socket.getCipher(),
787
+ ephemeralKey: socket.getEphemeralKeyInfo(),
788
+ authorized: socket.authorized,
789
+ authorizationError: socket.authorizationError || null,
790
+ hostnameMatches: peerCertificate && Object.keys(peerCertificate).length > 0 ? hostnameError === null : null,
791
+ hostnameError: hostnameError && (hostnameError.message || hostnameError.code || String(hostnameError)),
792
+ peerCertificate,
793
+ }, {
794
+ observedAt: observedAt(),
795
+ durationMs: now() - started,
796
+ resolvedAddressCount: records.length,
797
+ now: new Date(now()),
798
+ }));
799
+ }
800
+ catch (error) {
801
+ fail(error);
802
+ }
803
+ });
804
+ socket.once('error', fail);
805
+ deadline = setTimer(() => fail(new Error('TLS handshake timed out')), remainingMs);
806
+ if (settled && deadline !== undefined)
807
+ clearTimer(deadline);
808
+ }
809
+ catch (error) {
810
+ fail(error);
811
+ }
812
+ });
813
+ }
814
+ export { TLS_PROFILE_VERSION, TLS_TIMEOUT_MS, MAX_RESOLVED_ADDRESSES, MAX_CHAIN_CERTIFICATES, MAX_ALT_NAMES, MAX_SAN_ENTRIES, MAX_EXTENDED_KEY_USAGES, MAX_AIA_LOCATIONS, normalizeTlsHostname, normalizePublicAddressRecords, normalizeAltNames, buildTlsObservation, failedTlsObservation, skippedTlsObservation, collectTlsIntelligence, };