@slicedearth/whoisleuth-cli 2.3.0 → 2.4.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -23,8 +23,10 @@ import * as net from 'node:net';
23
23
  import * as crypto from 'node:crypto';
24
24
  import { Agent, fetch as undiciFetch } from 'undici';
25
25
  import { MAX_OUTBOUND_REDIRECTS } from "./outbound-request-bounds.mjs";
26
+ import { MAX_OUTBOUND_HTTP_URL_CHARACTERS } from "../packages/contracts/http-url.mjs";
27
+ import { ipv6Groups } from "../packages/contracts/ip-address.mjs";
26
28
  const MAX_REDIRECTS = MAX_OUTBOUND_REDIRECTS;
27
- const MAX_SAFE_FETCH_URL_LENGTH = 4096;
29
+ const MAX_SAFE_FETCH_URL_LENGTH = MAX_OUTBOUND_HTTP_URL_CHARACTERS;
28
30
  const MAX_SAFE_FETCH_ADDRESS_CANDIDATES = 64;
29
31
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
30
32
  async function defaultSafeFetchRequest(url, options) {
@@ -70,47 +72,6 @@ function isPrivateIpv4(ip) {
70
72
  return true; // multicast/reserved
71
73
  return false;
72
74
  }
73
- // Expands a valid IPv6 address string to its full 8-group, zero-padded hex
74
- // form - e.g. "::1" -> all-zero groups ending in "0001". Needed because the
75
- // same address can be written multiple ways (a "::" shorthand for a run of
76
- // zero groups, an embedded IPv4 tail as either dotted-decimal or two hex
77
- // groups), and a hostile authoritative DNS server - the exact threat this
78
- // module exists to guard against - is free to choose whichever string form
79
- // it wants. Checking string prefixes on the as-written form (the previous
80
- // approach) only catches whichever form happened to be anticipated; e.g.
81
- // "::ffff:127.0.0.1" and "::ffff:7f00:1" are the same address, but only the
82
- // first was recognized as IPv4-mapped loopback.
83
- function expandIpv6Groups(ip) {
84
- const hasDoubleColon = ip.includes('::');
85
- const [headStr, tailStr] = hasDoubleColon ? ip.split('::') : [ip, undefined];
86
- // A dotted-decimal segment (the tail of "::ffff:127.0.0.1") is two
87
- // 16-bit groups' worth of bits - convert it to hex groups first so the
88
- // rest of this function only ever deals with plain hex.
89
- const toHexGroups = (part) => {
90
- if (!part)
91
- return [];
92
- return part.split(':').flatMap((group) => {
93
- if (!group.includes('.'))
94
- return [group];
95
- const bytes = group.split('.').map(Number);
96
- const [first = -1, second = -1, third = -1, fourth = -1] = bytes;
97
- if (bytes.length !== 4
98
- || [first, second, third, fourth].some((byte) => !Number.isInteger(byte)))
99
- return [];
100
- return [
101
- (((first << 8) | second) >>> 0).toString(16),
102
- (((third << 8) | fourth) >>> 0).toString(16),
103
- ];
104
- });
105
- };
106
- const headGroups = toHexGroups(headStr);
107
- const tailGroups = hasDoubleColon ? toHexGroups(tailStr) : [];
108
- if (!hasDoubleColon)
109
- return headGroups.map((g) => g.padStart(4, '0').toLowerCase());
110
- const missing = 8 - headGroups.length - tailGroups.length;
111
- const middleGroups = new Array(Math.max(missing, 0)).fill('0');
112
- return [...headGroups, ...middleGroups, ...tailGroups].map((g) => g.padStart(4, '0').toLowerCase());
113
- }
114
75
  function groupsToIpv4(hiGroup, loGroup) {
115
76
  const hi = parseInt(hiGroup, 16);
116
77
  const lo = parseInt(loGroup, 16);
@@ -119,7 +80,7 @@ function groupsToIpv4(hiGroup, loGroup) {
119
80
  return [(hi >> 8) & 0xff, hi & 0xff, (lo >> 8) & 0xff, lo & 0xff].join('.');
120
81
  }
121
82
  function isPrivateIpv6(ip) {
122
- const groups = expandIpv6Groups(ip.toLowerCase());
83
+ const groups = ipv6Groups(ip);
123
84
  if (groups.length !== 8)
124
85
  return true; // couldn't parse cleanly - fail closed
125
86
  const [g0 = '', g1 = '', g2 = '', g3 = '', g4 = '', g5 = '', g6 = '', g7 = '',] = groups;
@@ -319,6 +280,47 @@ async function raceWithSignal(promise, signal) {
319
280
  void promise.then((value) => { signal.removeEventListener('abort', abort); resolve(value); }, (error) => { signal.removeEventListener('abort', abort); reject(error); });
320
281
  });
321
282
  }
283
+ // Request bodies that can be replayed must be owned before the first await.
284
+ // Streams and multipart encoders remain valid for one request, never a replay.
285
+ function ownedRequestBody(body) {
286
+ if (body instanceof URLSearchParams)
287
+ return new URLSearchParams(body);
288
+ if (body instanceof ArrayBuffer)
289
+ return body.slice(0);
290
+ if (ArrayBuffer.isView(body))
291
+ return Uint8Array.from(new Uint8Array(body.buffer, body.byteOffset, body.byteLength));
292
+ return body;
293
+ }
294
+ function redirectedRequest(options, status, source, destination) {
295
+ const next = { ...options, headers: new Headers(options.headers) };
296
+ const method = (options.method ?? 'GET').toUpperCase();
297
+ if ((status === 301 || status === 302) && method === 'POST'
298
+ || status === 303 && method !== 'GET' && method !== 'HEAD') {
299
+ next.method = 'GET';
300
+ next.body = null;
301
+ for (const name of ['content-encoding', 'content-language', 'content-location', 'content-type', 'content-length', 'transfer-encoding'])
302
+ next.headers.delete(name);
303
+ }
304
+ else if (next.body != null && !(typeof next.body === 'string' || next.body instanceof Blob
305
+ || next.body instanceof URLSearchParams || next.body instanceof ArrayBuffer || ArrayBuffer.isView(next.body))) {
306
+ throw new Error('Refusing to replay a streaming or multipart request body after a redirect');
307
+ }
308
+ if (source.origin !== destination.origin) {
309
+ // This collector is not a general authenticated browser. Do not forward a
310
+ // caller's body or custom credentials to another authority or a downgrade.
311
+ if (next.body != null || !['GET', 'HEAD'].includes((next.method ?? 'GET').toUpperCase())) {
312
+ throw new Error('Refusing to forward a request body or mutation across origins');
313
+ }
314
+ const publicHeaders = new Set(['accept', 'accept-encoding', 'accept-language', 'user-agent', 'range']);
315
+ for (const name of [...next.headers.keys()])
316
+ if (!publicHeaders.has(name))
317
+ next.headers.delete(name);
318
+ next.credentials = 'omit';
319
+ next.referrer = '';
320
+ next.referrerPolicy = 'no-referrer';
321
+ }
322
+ return next;
323
+ }
322
324
  // Detailed form of the shared safe request engine. It follows redirects
323
325
  // manually under the same DNS validation and connection-pinning policy as
324
326
  // safeFetch(), while retaining a bounded hop trace for consumers that need
@@ -343,10 +345,14 @@ async function safeFetchDetailed(url, options = {}, dependencies = {}) {
343
345
  const hops = [];
344
346
  let currentUrl = requestedUrl;
345
347
  let redirectCount = 0;
348
+ let requestOptions = { ...options, headers: new Headers(options.headers),
349
+ ...(options.body !== undefined ? { body: ownedRequestBody(options.body) } : {}) };
346
350
  while (true) {
347
351
  const parsed = new URL(currentUrl);
348
352
  const hostname = resolutionHostname(parsed);
349
- const signal = options.signal ?? null;
353
+ const signal = requestOptions.signal ?? null;
354
+ if (signal?.aborted)
355
+ throw abortError(signal);
350
356
  const records = normalizePublicAddressCandidates(await raceWithSignal(resolveAddresses(hostname), signal), hostname);
351
357
  if (signal?.aborted)
352
358
  throw abortError(signal);
@@ -354,7 +360,7 @@ async function safeFetchDetailed(url, options = {}, dependencies = {}) {
354
360
  const hopStartedAt = now();
355
361
  // `dispatcher` is a supported undici extension used to pin the connection.
356
362
  // Keep the non-standard property local to this request boundary.
357
- const fetchOptions = { ...options, redirect: 'manual', dispatcher };
363
+ const fetchOptions = { ...requestOptions, redirect: 'manual', dispatcher };
358
364
  let response;
359
365
  try {
360
366
  response = await request(currentUrl, fetchOptions);
@@ -384,6 +390,7 @@ async function safeFetchDetailed(url, options = {}, dependencies = {}) {
384
390
  if (nextUrl && redirectCount < maxRedirects) {
385
391
  await response.body?.cancel().catch(() => { });
386
392
  await closeDispatcher(dispatcher);
393
+ requestOptions = redirectedRequest(requestOptions, response.status, parsed, new URL(nextUrl));
387
394
  redirectCount += 1;
388
395
  currentUrl = nextUrl;
389
396
  continue;
@@ -418,8 +425,8 @@ async function safeFetch(url, options = {}, redirectsLeft = MAX_REDIRECTS) {
418
425
  // them can be made to hold an arbitrarily large body in memory. `truncated`
419
426
  // tells the caller whether the real body was actually cut short - callers
420
427
  // that need well-formed content (e.g. JSON) should treat that as a hard
421
- // failure rather than parsing a partial blob; callers that only need to
422
- // scan for a substring (e.g. plain-text/HTML matching) can safely ignore it.
428
+ // failure rather than parsing a partial blob. Partial-evidence consumers may
429
+ // use the prefix, but must preserve its incomplete scope.
423
430
  async function readTextCapped(res, maxBytes, options = {}) {
424
431
  const hasher = options.includeSha256 === true ? crypto.createHash('sha256') : null;
425
432
  const result = (text, truncated, bytesRead) => ({
@@ -4,7 +4,7 @@
4
4
  // Risk. Published contact and policy values are normalized into a small,
5
5
  // bounded record; the response body itself is discarded after parsing.
6
6
  import { safeFetchDetailed, readTextCapped } from "./safe-fetch.mjs";
7
- import { createObservation } from "../packages/evidence/observation.mjs";
7
+ import { createObservation, normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
8
8
  const SECURITY_TXT_VERSION = 1;
9
9
  const SECURITY_TXT_PATH = '/.well-known/security.txt';
10
10
  const SECURITY_TXT_TIMEOUT_MS = 6_000;
@@ -18,7 +18,6 @@ const CONTROL_CHARACTER_RE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
18
18
  const URI_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
19
19
  const FIELD_RE = /^([A-Za-z][A-Za-z-]*):[ \t]*(.*)$/u;
20
20
  const LANGUAGE_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/iu;
21
- const RFC3339_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/u;
22
21
  const CONTACT_PROTOCOLS = new Set(['https:', 'mailto:', 'tel:']);
23
22
  const HTTPS_PROTOCOLS = new Set(['https:']);
24
23
  const ENCRYPTION_PROTOCOLS = new Set(['https:', 'dns:', 'openpgp4fpr:']);
@@ -222,8 +221,9 @@ function parseSecurityTxt(text, options = {}) {
222
221
  malformedCount += languages.length - valid.length;
223
222
  }
224
223
  else if (field === 'expires') {
225
- if (RFC3339_RE.test(value) && Number.isFinite(Date.parse(value)))
226
- expires.push(new Date(value).toISOString());
224
+ const expiry = normalizeExplicitIsoTimestamp(value);
225
+ if (expiry)
226
+ expires.push(expiry);
227
227
  else
228
228
  malformedCount += 1;
229
229
  }
@@ -235,15 +235,18 @@ function parseSecurityTxt(text, options = {}) {
235
235
  const boundedPolicies = uniqueBounded(policies);
236
236
  const boundedEncryption = uniqueBounded(encryption);
237
237
  const boundedCanonical = uniqueBounded(canonical);
238
+ const boundedCanonicalComparison = uniqueBounded(canonicalComparison);
239
+ const canonicalComparisonTruncated = valuesTruncated || boundedCanonicalComparison.truncated;
238
240
  const boundedLanguages = uniqueBounded(preferredLanguages);
239
- valuesTruncated ||= [boundedContacts, boundedPolicies, boundedEncryption, boundedCanonical, boundedLanguages]
241
+ valuesTruncated ||= [boundedContacts, boundedPolicies, boundedEncryption, boundedCanonical, boundedCanonicalComparison, boundedLanguages]
240
242
  .some((entry) => entry.truncated);
241
243
  const expiresAt = expires.length === 1 ? expires[0] : null;
242
244
  const finalUrl = options.finalUrl ? normalizedHttpsUrl(options.finalUrl) : null;
243
245
  const finalComparisonUrl = options.finalUrl ? normalizedCanonicalComparisonUrl(options.finalUrl) : null;
244
246
  const canonicalMatches = boundedCanonical.values.length && finalUrl && finalComparisonUrl
245
- ? canonicalComparison.slice(0, MAX_SECURITY_TXT_VALUES)
246
- .some((value) => value === finalComparisonUrl)
247
+ ? boundedCanonicalComparison.values.includes(finalComparisonUrl)
248
+ ? true
249
+ : canonicalComparisonTruncated ? null : false
247
250
  : null;
248
251
  const stale = Boolean(expiresAt && Date.parse(expiresAt) <= (options.now ?? Date.now()));
249
252
  const requiredMalformed = boundedContacts.values.length === 0 || expires.length !== 1;
@@ -442,6 +442,7 @@ function parseResolverEndpoint(value) {
442
442
  }
443
443
  function defaultTcpExchange(query, resolver, options) {
444
444
  return new Promise((resolve, reject) => {
445
+ options.signal?.throwIfAborted();
445
446
  const socket = createConnection({
446
447
  host: resolver.address,
447
448
  port: resolver.port,
@@ -457,12 +458,19 @@ function defaultTcpExchange(query, resolver, options) {
457
458
  settled = true;
458
459
  if (hardDeadline)
459
460
  clearTimeout(hardDeadline);
461
+ options.signal?.removeEventListener('abort', abort);
460
462
  socket.destroy();
461
463
  if (error)
462
464
  reject(error);
463
465
  else
464
466
  resolve(response);
465
467
  };
468
+ const abort = () => finish(options.signal?.reason ?? new Error('DNS TCP query cancelled'));
469
+ options.signal?.addEventListener('abort', abort, { once: true });
470
+ if (options.signal?.aborted) {
471
+ abort();
472
+ return;
473
+ }
466
474
  hardDeadline = setTimeout(() => finish(new ServiceBindingDnsError('DNS TCP query timed out', 'ETIMEOUT')), options.timeoutMs);
467
475
  hardDeadline.unref();
468
476
  socket.setTimeout(options.timeoutMs, () => finish(new ServiceBindingDnsError('DNS TCP query timed out', 'ETIMEOUT')));
@@ -25,6 +25,7 @@ const MAX_SMTP_RESPONSE_BYTES = 16 * 1024;
25
25
  const MAX_SMTP_REPLY_LINES = 64;
26
26
  const MAX_SMTP_LINE_BYTES = 1_000;
27
27
  const MAX_SMTP_CAPABILITIES = 32;
28
+ const SMTP_CAPABILITY_OMISSION = 'The retained EHLO capability inventory is incomplete; additional names were omitted. STARTTLS detection used the full bounded reply.';
28
29
  const MAX_SMTP_CERTIFICATE_BYTES = 256 * 1024;
29
30
  const SMTP_ENDPOINT_TIMEOUT_MS = 8_000;
30
31
  const MAIL_TRANSPORT_TOTAL_TIMEOUT_MS = 30_000;
@@ -138,19 +139,27 @@ function normalizeSmtpReply(linesValue) {
138
139
  const sha256 = crypto.createHash('sha256').update(`${lines.join('\r\n')}\r\n`, 'ascii').digest('hex');
139
140
  return Object.freeze({ code, lines: Object.freeze(lines), bytes, sha256 });
140
141
  }
141
- function smtpCapabilities(reply) {
142
- if (!reply || reply.code !== 250)
143
- return Object.freeze([]);
142
+ function smtpCapabilityInventory(reply) {
143
+ // The reply has already passed line and byte admission. The display cap must
144
+ // not decide which protocol extensions the endpoint advertised.
145
+ const seen = new Set();
144
146
  const output = [];
145
- for (const line of reply.lines.slice(1)) {
147
+ for (const line of reply?.code === 250 ? reply.lines.slice(1) : []) {
146
148
  const capability = line.slice(4).trim().split(/\s+/u)[0]?.toUpperCase() || '';
147
- if (!/^[A-Z0-9][A-Z0-9-]{0,31}$/u.test(capability) || output.includes(capability))
149
+ if (!/^[A-Z0-9][A-Z0-9-]{0,31}$/u.test(capability) || seen.has(capability))
148
150
  continue;
149
- if (output.length >= MAX_SMTP_CAPABILITIES)
150
- break;
151
- output.push(capability);
151
+ seen.add(capability);
152
+ if (output.length < MAX_SMTP_CAPABILITIES)
153
+ output.push(capability);
152
154
  }
153
- return Object.freeze(output.sort());
155
+ return Object.freeze({
156
+ capabilities: Object.freeze(output.sort()),
157
+ starttlsAdvertised: seen.has('STARTTLS'),
158
+ truncated: seen.size > output.length,
159
+ });
160
+ }
161
+ function smtpCapabilities(reply) {
162
+ return smtpCapabilityInventory(reply).capabilities;
154
163
  }
155
164
  async function runSmtpConversation(hostname, connection) {
156
165
  try {
@@ -162,8 +171,7 @@ async function runSmtpConversation(hostname, connection) {
162
171
  connection.assertIdle();
163
172
  await connection.write(EHLO_COMMAND);
164
173
  const ehlo = await connection.readReply();
165
- const capabilities = smtpCapabilities(ehlo);
166
- const starttlsAdvertised = capabilities.includes('STARTTLS');
174
+ const { capabilities, starttlsAdvertised } = smtpCapabilityInventory(ehlo);
167
175
  if (!starttlsAdvertised) {
168
176
  const diagnostics = connection.diagnostics();
169
177
  return Object.freeze({ connectedAddress: connection.remoteAddress, greeting, ehlo, capabilities, starttlsAdvertised, starttlsReply: null, starttlsState: 'not_advertised', tls: null, ...diagnostics });
@@ -486,11 +494,12 @@ function addressAuthentication(selected) {
486
494
  })
487
495
  : Object.freeze({ state: 'unavailable', detail: 'No address candidate was retained for cryptographic authentication review.' });
488
496
  }
489
- function endpointLimitations(addressState) {
497
+ function endpointLimitations(addressState, capabilitiesTruncated = false) {
490
498
  const addressLimitation = addressState === 'connected'
491
499
  ? 'The endpoint observation is one point-in-time connection to one revalidated and pinned public address. Other addresses or later observations may differ.'
492
500
  : 'The endpoint did not produce confirmed connection evidence. Its address field retains only the highest completed public selection or revalidation stage, if any.';
493
501
  return Object.freeze([
502
+ ...(capabilitiesTruncated ? [SMTP_CAPABILITY_OMISSION] : []),
494
503
  addressLimitation,
495
504
  'Public-address validation, fresh revalidation, and connection pinning do not cryptographically authenticate the selected A or AAAA RRset or any CNAME chain.',
496
505
  'Only the SMTP greeting, EHLO capability names, and optional STARTTLS negotiation were observed. No authentication, recipient, mailbox, catch-all, relay, or message command was attempted.',
@@ -587,10 +596,12 @@ async function collectEndpoint(options) {
587
596
  const tlsaComplete = !['indeterminate', 'timed_out', 'unavailable'].includes(tlsa.state)
588
597
  && (tlsa.state !== 'validated' || !tlsa.dane || !['partial', 'unavailable', 'invalid'].includes(tlsa.dane.state));
589
598
  const certificateComplete = smtp.starttlsState !== 'negotiated' || certificate.observation.state === 'observed';
599
+ const capabilitiesTruncated = smtpCapabilityInventory(smtp.ehlo).truncated;
590
600
  const complete = smtpState === 'observed'
591
601
  && dnssec.report.completeness === 'complete'
592
602
  && tlsaComplete
593
- && certificateComplete;
603
+ && certificateComplete
604
+ && !capabilitiesTruncated;
594
605
  return Object.freeze({
595
606
  host: options.host,
596
607
  observedAt: options.observedAt,
@@ -604,7 +615,7 @@ async function collectEndpoint(options) {
604
615
  dnssec: dnssec.report,
605
616
  tlsa,
606
617
  failure: null,
607
- limitations: endpointLimitations(addressState),
618
+ limitations: endpointLimitations(addressState, capabilitiesTruncated),
608
619
  });
609
620
  }
610
621
  catch (error) {
@@ -627,7 +638,7 @@ async function collectEndpoint(options) {
627
638
  dnssec: dnssec?.report ?? null,
628
639
  tlsa: unavailableTlsa('unavailable', 'TLSA and DANE comparison was unavailable because endpoint collection did not complete.'),
629
640
  failure: Object.freeze({ stage, detail }),
630
- limitations: endpointLimitations(addressState),
641
+ limitations: endpointLimitations(addressState, smtpCapabilityInventory(connectedSmtp?.ehlo ?? null).truncated),
631
642
  });
632
643
  }
633
644
  }
@@ -748,6 +759,7 @@ async function collectMailTransportReview(inputValue, options, dependencies = {}
748
759
  retries: 0,
749
760
  }),
750
761
  limitations: Object.freeze([
762
+ ...(endpoints.some((endpoint) => endpoint.limitations.includes(SMTP_CAPABILITY_OMISSION)) ? [SMTP_CAPABILITY_OMISSION] : []),
751
763
  'This command performs active network collection only for the selected MX hosts in this explicitly authorised run. It is not invoked by Lookup, Bulk, monitoring, or automatic recipes.',
752
764
  'DNSSEC, TLSA or DANE, PKIX, certificate identity, STARTTLS, SMTP transport, MTA-STS context, and TLS-RPT context retain separate states and provenance. One family never supplies or upgrades another family.',
753
765
  ACTIVE_DANE_TA_LIMITATION,