@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
package/lib/hostname.mjs CHANGED
@@ -1,38 +1,2 @@
1
- // Linear-time validation for already-normalized ASCII DNS hostnames. Keep this
2
- // helper deliberately independent of public-suffix policy: callers decide
3
- // whether a syntactically valid hostname must also be registrable.
4
- const ASCII_LABEL_RE = /^[A-Za-z0-9-]+$/u;
5
- function isValidAsciiHostname(value, options = {}) {
6
- if (typeof value !== 'string'
7
- || value.length < 1
8
- || value.length > 253
9
- || (options.requireLowercase === true && value !== value.toLowerCase()))
10
- return false;
11
- const labels = value.split('.');
12
- if (options.requireDot !== false && labels.length < 2)
13
- return false;
14
- for (const label of labels) {
15
- if (!label
16
- || label.length > 63
17
- || label.startsWith('-')
18
- || label.endsWith('-')
19
- || !ASCII_LABEL_RE.test(label))
20
- return false;
21
- }
22
- return true;
23
- }
24
- function isIpv4Literal(value) {
25
- const parts = value.split('.');
26
- return parts.length === 4 && parts.every((part) => {
27
- if (!/^(?:0|[1-9]\d{0,2})$/u.test(part))
28
- return false;
29
- return Number(part) <= 255;
30
- });
31
- }
32
- // Domain-only records must not accept an IPv4 literal merely because its
33
- // dotted decimal labels also satisfy hostname syntax. IPv6 literals already
34
- // fail the ASCII hostname grammar because they contain colons.
35
- function isValidAsciiDomainName(value, options = {}) {
36
- return isValidAsciiHostname(value, options) && !isIpv4Literal(value);
37
- }
38
- export { isValidAsciiDomainName, isValidAsciiHostname };
1
+ // Preserve the existing public import while sharing one dependency-neutral grammar.
2
+ export { isValidAsciiDomainName, isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
@@ -43,13 +43,8 @@ const HAS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignora
43
43
  // scoped to resource tags, not every <a href> - an outbound link to the
44
44
  // real site is normal on all sorts of pages; a resource pulled live from it
45
45
  // during page load is not.
46
- const ASSET_TAG_RE = /<(?:img|script|link)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"']+)["'][^>]*>/gi;
47
- const ABSOLUTE_URL_HOST_RE = /^(?:https?:)?\/\/([^/]+)/i;
48
- const IDENTITY_TAG_RE = /<(html|link|meta|form)\b[^>]{0,4096}>/gi;
49
- const OVERSIZED_IDENTITY_TAG_RE = /<(?:html|link|meta|form)\b[^>]{4097}/i;
50
- const RELATIONSHIP_TAG_RE = /<(a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{0,4096}>/gi;
51
- const OVERSIZED_RELATIONSHIP_TAG_RE = /<(?:a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{4097}/i;
52
- const ATTRIBUTE_RE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
46
+ const IDENTITY_TAGS = new Set(['html', 'link', 'meta', 'form']);
47
+ const RELATIONSHIP_TAGS = new Set(['a', 'img', 'script', 'link', 'iframe', 'frame', 'source', 'video', 'audio', 'object', 'embed']);
53
48
  const LANGUAGE_TAG_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/i;
54
49
  const HOSTNAME_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
55
50
  const RESOURCE_LINK_RELS = new Set(['stylesheet', 'icon', 'preload', 'prefetch', 'modulepreload', 'manifest']);
@@ -57,9 +52,6 @@ const RISKY_DOWNLOAD_EXTENSIONS = new Set([
57
52
  '7z', 'apk', 'bat', 'cmd', 'dmg', 'docm', 'exe', 'img', 'iso', 'jar', 'js',
58
53
  'msi', 'pkg', 'ps1', 'rar', 'scr', 'vbs', 'xlsm', 'zip',
59
54
  ]);
60
- const HTML_COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;
61
- const RAW_TEXT_BLOCK_RE = /<(script|style|textarea|template)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi;
62
- const NON_EXECUTING_RAW_TEXT_BLOCK_RE = /<(style|textarea|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
63
55
  function stripWwwPrefix(host) {
64
56
  return host.toLowerCase().replace(/^www\./, '');
65
57
  }
@@ -72,24 +64,19 @@ function boundedHtmlText(value, maxLength, ellipsis = false) {
72
64
  return null;
73
65
  if (text.length <= maxLength)
74
66
  return text;
75
- return ellipsis ? `${text.slice(0, maxLength)}…` : text.slice(0, maxLength);
67
+ return ellipsis ? `${text.slice(0, Math.max(0, maxLength - 1))}…` : text.slice(0, maxLength);
76
68
  }
77
- function extractExternalAssetHosts(html, ownDomain) {
69
+ function extractExternalAssetHosts(analysis, ownDomain, documentUrl) {
78
70
  const ownHost = stripWwwPrefix(ownDomain);
79
71
  const hosts = new Set();
80
- let match;
81
- ASSET_TAG_RE.lastIndex = 0;
82
- while ((match = ASSET_TAG_RE.exec(html))) {
83
- const resourceUrl = match[1];
84
- if (!resourceUrl)
72
+ for (const element of analysis.elements) {
73
+ if (!element.html || !['img', 'script', 'link'].includes(element.name))
85
74
  continue;
86
- const hostMatch = resourceUrl.match(ABSOLUTE_URL_HOST_RE);
87
- if (!hostMatch)
75
+ const attribute = element.attributes.find((attribute) => attribute.name === (element.name === 'link' ? 'href' : 'src'));
76
+ const resource = normalizeIdentityUrl(attribute?.value, analysis.effectiveBaseUrl ?? documentUrl);
77
+ if (!resource)
88
78
  continue;
89
- const matchedHost = hostMatch[1];
90
- if (!matchedHost || HAS_CONTROL_CHARACTER_RE.test(matchedHost))
91
- continue;
92
- const host = stripWwwPrefix(matchedHost);
79
+ const host = stripWwwPrefix(new URL(resource.url).hostname);
93
80
  if (host && host !== ownHost)
94
81
  hosts.add(host);
95
82
  if (hosts.size >= MAX_EXTERNAL_ASSET_HOSTS)
@@ -97,32 +84,6 @@ function extractExternalAssetHosts(html, ownDomain) {
97
84
  }
98
85
  return [...hosts];
99
86
  }
100
- function parseAttributes(tag) {
101
- const start = tag.search(/\s/);
102
- if (start === -1)
103
- return new Map();
104
- const attributes = new Map();
105
- ATTRIBUTE_RE.lastIndex = 0;
106
- let match;
107
- while ((match = ATTRIBUTE_RE.exec(tag.slice(start)))) {
108
- const name = (match[1] ?? '').toLowerCase();
109
- if (!name)
110
- continue;
111
- if (!attributes.has(name))
112
- attributes.set(name, match[2] ?? match[3] ?? match[4] ?? '');
113
- }
114
- return attributes;
115
- }
116
- function markupForTagParsing(html) {
117
- return html
118
- .replace(HTML_COMMENT_RE, ' ')
119
- .replace(RAW_TEXT_BLOCK_RE, '<$1$2>');
120
- }
121
- function markupForTrackingIdentifiers(html) {
122
- return html
123
- .replace(HTML_COMMENT_RE, ' ')
124
- .replace(NON_EXECUTING_RAW_TEXT_BLOCK_RE, ' ');
125
- }
126
87
  function normalizeLanguage(value) {
127
88
  if (typeof value !== 'string' || value.length > 35 || HAS_CONTROL_CHARACTER_RE.test(value))
128
89
  return null;
@@ -295,10 +256,9 @@ function trackingIdentifiers(html) {
295
256
  truncated,
296
257
  };
297
258
  }
298
- function extractPageRelationships(html, domain, options = {}) {
259
+ function extractPageRelationships(analysis, domain, options = {}) {
299
260
  const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
300
261
  const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? documentUrl);
301
- const markup = typeof options.tagMarkup === 'string' ? options.tagMarkup : markupForTagParsing(html);
302
262
  const baseOrigin = options.documentOrigin ?? new URL(documentUrl).origin;
303
263
  const resourceKeys = new Set();
304
264
  const resourceOrigins = new Set();
@@ -312,25 +272,26 @@ function extractPageRelationships(html, domain, options = {}) {
312
272
  let riskyDownloadCount = 0;
313
273
  let tagsExamined = 0;
314
274
  let discardedUrls = 0;
315
- let tagLimitReached = OVERSIZED_RELATIONSHIP_TAG_RE.test(markup);
275
+ let tagLimitReached = analysis.inputLimitReached || analysis.tagLimitReached;
316
276
  let resourceOriginLimitReached = false;
317
277
  let embeddedOriginLimitReached = false;
318
278
  let contactDomainLimitReached = false;
319
279
  let downloadOriginLimitReached = false;
320
280
  let downloadFileTypeLimitReached = false;
321
281
  let perTagLimitReached = false;
322
- let match;
323
- RELATIONSHIP_TAG_RE.lastIndex = 0;
324
- while ((match = RELATIONSHIP_TAG_RE.exec(markup))) {
282
+ let resourceCountLimitReached = false;
283
+ for (const element of analysis.elements) {
284
+ if (!element.html || !RELATIONSHIP_TAGS.has(element.name))
285
+ continue;
325
286
  if (tagsExamined >= MAX_RESOURCE_TAGS) {
326
287
  tagLimitReached = true;
327
288
  break;
328
289
  }
329
290
  tagsExamined += 1;
330
- const tagName = (match[1] ?? '').toLowerCase();
331
- if (!tagName)
332
- continue;
333
- const attributes = parseAttributes(match[0]);
291
+ const tagName = element.name;
292
+ const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
293
+ if (element.attributesTruncated)
294
+ tagLimitReached = true;
334
295
  if (tagName === 'a') {
335
296
  const href = attributes.get('href');
336
297
  const contacts = mailtoDomains(href);
@@ -378,6 +339,10 @@ function extractPageRelationships(html, domain, options = {}) {
378
339
  }
379
340
  const key = `${reference.type}:${normalized.url}`;
380
341
  if (!resourceKeys.has(key)) {
342
+ if (resourceKeys.size >= MAX_RESOURCE_TAGS) {
343
+ resourceCountLimitReached = true;
344
+ continue;
345
+ }
381
346
  resourceKeys.add(key);
382
347
  byType[reference.type] += 1;
383
348
  }
@@ -389,10 +354,15 @@ function extractPageRelationships(html, domain, options = {}) {
389
354
  embeddedOriginLimitReached = true;
390
355
  }
391
356
  }
392
- const tracking = trackingIdentifiers(markupForTrackingIdentifiers(html));
357
+ const tracking = trackingIdentifiers([
358
+ ...analysis.elements.filter((element) => element.html).flatMap((element) => element.attributes.map((attribute) => attribute.value)),
359
+ ...analysis.scripts.map((script) => script.inlineContent),
360
+ ].join('\n'));
361
+ if (analysis.scriptLimitReached || analysis.inlineLimitReached)
362
+ tracking.truncated = true;
393
363
  const truncated = tagLimitReached || resourceOriginLimitReached || embeddedOriginLimitReached
394
364
  || contactDomainLimitReached || downloadOriginLimitReached || downloadFileTypeLimitReached
395
- || perTagLimitReached || tracking.truncated;
365
+ || perTagLimitReached || resourceCountLimitReached || tracking.truncated;
396
366
  const limitations = [];
397
367
  if (tagLimitReached)
398
368
  limitations.push(`Resource parsing reached the ${MAX_RESOURCE_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
@@ -408,6 +378,8 @@ function extractPageRelationships(html, domain, options = {}) {
408
378
  limitations.push(`Only the first ${MAX_DOWNLOAD_FILE_TYPES} risky download file types were retained.`);
409
379
  if (perTagLimitReached)
410
380
  limitations.push(`Some srcset URL candidates could not be safely enumerated within the ${MAX_URLS_PER_TAG}-candidate per-tag boundary.`);
381
+ if (resourceCountLimitReached)
382
+ limitations.push(`Only the first ${MAX_RESOURCE_TAGS} distinct resource references were examined.`);
411
383
  if (tracking.truncated)
412
384
  limitations.push(`Only the first ${MAX_TRACKING_IDENTIFIERS} tracking identifiers were retained.`);
413
385
  return {
@@ -415,7 +387,7 @@ function extractPageRelationships(html, domain, options = {}) {
415
387
  count: resourceKeys.size,
416
388
  byType,
417
389
  externalOrigins: [...resourceOrigins].sort(),
418
- truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached,
390
+ truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached || resourceCountLimitReached,
419
391
  },
420
392
  embeddedOrigins: [...embeddedOrigins].sort(),
421
393
  contactDomains: [...contactDomains].sort(),
@@ -445,12 +417,9 @@ function metaRefreshTarget(content) {
445
417
  }
446
418
  function extractPageIdentity(html, domain, options = {}) {
447
419
  const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
448
- const baseAnalysis = options.effectiveBaseUrl === undefined
449
- ? analyzeStaticHtml(html, { baseUrl: documentUrl })
450
- : null;
451
- const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? baseAnalysis?.effectiveBaseUrl ?? documentUrl);
452
- const baseHrefState = options.baseHrefState ?? baseAnalysis?.baseHrefState ?? 'absent';
453
- const tagMarkup = markupForTagParsing(html);
420
+ const analysis = options.htmlAnalysis ?? analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
421
+ const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? analysis.effectiveBaseUrl ?? documentUrl);
422
+ const baseHrefState = options.baseHrefState ?? analysis.baseHrefState;
454
423
  const parsedDocument = new URL(documentUrl);
455
424
  const baseOrigin = options.documentOrigin ?? parsedDocument.origin;
456
425
  const baseUsesHttps = parsedDocument.protocol === 'https:';
@@ -469,19 +438,19 @@ function extractPageIdentity(html, domain, options = {}) {
469
438
  let discardedUrls = 0;
470
439
  let formLimitReached = false;
471
440
  let originLimitReached = false;
472
- let tagLimitReached = OVERSIZED_IDENTITY_TAG_RE.test(tagMarkup);
473
- let match;
474
- IDENTITY_TAG_RE.lastIndex = 0;
475
- while ((match = IDENTITY_TAG_RE.exec(tagMarkup))) {
441
+ let tagLimitReached = analysis.inputLimitReached || analysis.tagLimitReached;
442
+ for (const element of analysis.elements) {
443
+ if (!element.html || !IDENTITY_TAGS.has(element.name))
444
+ continue;
476
445
  if (tagsExamined >= MAX_IDENTITY_TAGS) {
477
446
  tagLimitReached = true;
478
447
  break;
479
448
  }
480
449
  tagsExamined += 1;
481
- const tagName = (match[1] ?? '').toLowerCase();
482
- if (!tagName)
483
- continue;
484
- const attributes = parseAttributes(match[0]);
450
+ const tagName = element.name;
451
+ const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
452
+ if (element.attributesTruncated)
453
+ tagLimitReached = true;
485
454
  if (tagName === 'html' && documentLanguage === null) {
486
455
  documentLanguage = normalizeLanguage(attributes.get('lang'));
487
456
  continue;
@@ -551,12 +520,10 @@ function extractPageIdentity(html, domain, options = {}) {
551
520
  const queryOmitted = [canonical, metaRefresh, openGraphUrl].some((item) => item?.queryOmitted);
552
521
  const pathTruncated = [canonical, metaRefresh, openGraphUrl].some((item) => item?.pathTruncated);
553
522
  const sourceTruncated = options.sourceTruncated === true;
554
- const relationships = extractPageRelationships(html, domain, { baseUrl: documentUrl, effectiveBaseUrl: baseUrl, documentOrigin: baseOrigin, tagMarkup });
523
+ const relationships = extractPageRelationships(analysis, domain, { baseUrl: documentUrl, effectiveBaseUrl: baseUrl, documentOrigin: baseOrigin });
555
524
  const fingerprints = createPageFingerprints(html, {
556
- // Retained fingerprint algorithms pre-date document-base handling. Keep
557
- // their response-URL normalization stable until a versioned fingerprint
558
- // migration is intentionally introduced.
559
525
  baseUrl: documentUrl,
526
+ htmlAnalysis: analysis,
560
527
  exactBodyHash: options.exactBodyHash,
561
528
  sourceTruncated,
562
529
  resources: relationships.resources,
@@ -696,9 +663,34 @@ function buildPagePublicationMetadata(analysis, sourceTruncated) {
696
663
  },
697
664
  };
698
665
  }
699
- function extractHtmlSignals(html, domain, options = {}) {
666
+ function domainSaleLandingPage(analysis, domain) {
667
+ if (analysis.inputLimitReached || analysis.tagLimitReached || analysis.visibleTextLimitReached)
668
+ return false;
669
+ const title = analysis.title?.trim().toLowerCase() ?? '';
670
+ const content = analysis.visibleText.trim().toLowerCase();
671
+ const saleSuffix = /^\s+(?:(?:is|may be)\s+)?(?:for sale|available for purchase|available for lease)(?:[.!?:\s]|$)/u;
672
+ const explicitSale = (value) => {
673
+ const genericSubject = /^(?:(?:this|the) domain(?: name)?|domain(?: name)?)/u.exec(value)?.[0];
674
+ return (genericSubject !== undefined && saleSuffix.test(value.slice(genericSubject.length)))
675
+ || (value.startsWith(domain) && saleSuffix.test(value.slice(domain.length)));
676
+ };
677
+ const explicitPurchase = /^(?:buy|purchase|own|bid on|inquire about) (?:this|the) domain(?: name)?(?:[.!?:\s]|$)/u;
678
+ // A short, directly worded landing page is sufficient. Long articles need
679
+ // corroborating page-title and transaction-control evidence; a generic
680
+ // commerce CTA or quoted example cannot classify the domain for sale.
681
+ if (content.length <= 240 && (explicitSale(content) || explicitPurchase.test(content)))
682
+ return true;
683
+ if (!explicitSale(title) && !explicitPurchase.test(title))
684
+ return false;
685
+ if (!/(?:this domain(?: name)? (?:is )?for sale|buy this domain|purchase this domain|domain name for sale)/u.test(content))
686
+ return false;
687
+ return analysis.elements.some((element) => element.html
688
+ && ['form', 'button', 'a'].includes(element.name));
689
+ }
690
+ async function extractHtmlSignals(html, domain, options = {}) {
691
+ options.signal?.throwIfAborted();
700
692
  const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
701
- const htmlAnalysis = analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
693
+ const htmlAnalysis = options.htmlAnalysis ?? analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
702
694
  const effectiveBaseUrl = htmlAnalysis.effectiveBaseUrl ?? documentUrl;
703
695
  const documentOrigin = new URL(documentUrl).origin;
704
696
  const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, {
@@ -707,6 +699,7 @@ function extractHtmlSignals(html, domain, options = {}) {
707
699
  effectiveBaseUrl,
708
700
  documentOrigin,
709
701
  baseHrefState: htmlAnalysis.baseHrefState,
702
+ htmlAnalysis,
710
703
  });
711
704
  const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
712
705
  const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
@@ -719,14 +712,17 @@ function extractHtmlSignals(html, domain, options = {}) {
719
712
  }
720
713
  : pageIdentity;
721
714
  const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis);
715
+ const domainSaleSignal = options.sourceTruncated !== true && domainSaleLandingPage(htmlAnalysis, domain)
716
+ ? 'explicit domain-sale landing-page content' : null;
722
717
  return {
718
+ domainSaleSignal,
723
719
  pageTitle: htmlAnalysis.title,
724
720
  hasPasswordField: htmlAnalysis.forms.categories.password > 0,
725
721
  phishingLanguageMatch: pageLanguageSignal?.label ?? null,
726
722
  hasExternalFormAction: pageIdentity
727
723
  ? pageIdentity.forms.externalActionOrigins.length > 0
728
724
  : null,
729
- externalAssetHosts: extractExternalAssetHosts(html, domain),
725
+ externalAssetHosts: extractExternalAssetHosts(htmlAnalysis, domain, documentUrl),
730
726
  cspMetaPolicy: analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached),
731
727
  pageIdentity: pageIdentityOutput,
732
728
  credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
@@ -740,9 +736,10 @@ function extractHtmlSignals(html, domain, options = {}) {
740
736
  observedAt: options.observedAt,
741
737
  sourceTruncated: options.sourceTruncated,
742
738
  }) : null,
743
- technologyProfile: includeTechnologyProfile && htmlAnalysis ? analyzeWebsiteTechnology({
739
+ technologyProfile: includeTechnologyProfile && htmlAnalysis ? await analyzeWebsiteTechnology({
744
740
  htmlAnalysis,
745
741
  generator: pageIdentity.generator,
742
+ ...(options.signal ? { signal: options.signal } : {}),
746
743
  httpServer: options.httpServer,
747
744
  responseHeaders: options.responseHeaders,
748
745
  resourceOrigins: pageIdentity.resources.externalOrigins,
@@ -754,7 +751,7 @@ function extractHtmlSignals(html, domain, options = {}) {
754
751
  pageRoleProfile: includeDerivedPageProfiles && htmlAnalysis ? analyzePageRole({
755
752
  htmlAnalysis,
756
753
  pageTitle: htmlAnalysis.title,
757
- activityStatus: options.activityStatus,
754
+ activityStatus: domainSaleSignal ? 'parked' : options.activityStatus,
758
755
  observedAt: options.observedAt,
759
756
  sourceTruncated: options.sourceTruncated,
760
757
  }) : null,
@@ -1,7 +1,8 @@
1
1
  // Browser-safe bounds for the rich HTTP evidence contract and its compact
2
2
  // browser-local projection. Both producers and consumers import these values
3
3
  // so valid retained evidence is not rejected by a stale projection.
4
+ import { MAX_OUTBOUND_HTTP_URL_CHARACTERS } from "../packages/contracts/http-url.mjs";
4
5
  export const MAX_HTTP_EVIDENCE_REDIRECTS = 5;
5
6
  export const MAX_HTTP_ATTEMPTS = 2;
6
- export const MAX_HTTP_PROVENANCE_URL = 2_048;
7
+ export const MAX_HTTP_PROVENANCE_URL = MAX_OUTBOUND_HTTP_URL_CHARACTERS;
7
8
  export const MAX_HTTP_ERROR_LENGTH = 180;
@@ -3,6 +3,7 @@
3
3
  // converts the shared safe-fetch trace and selected response metadata into an
4
4
  // additive evidence object suitable for API responses and exports.
5
5
  import { createObservation } from "../packages/evidence/observation.mjs";
6
+ import { MAX_OUTBOUND_HTTP_URL_CHARACTERS } from "../packages/contracts/http-url.mjs";
6
7
  import { MAX_HTTP_ATTEMPTS, MAX_HTTP_ERROR_LENGTH, MAX_HTTP_EVIDENCE_REDIRECTS, MAX_HTTP_PROVENANCE_URL, } from "./http-evidence-bounds.mjs";
7
8
  import { HTTP_DELIVERY_LIMITATIONS, HTTP_DELIVERY_METADATA_VERSION, MAX_HTTP_CACHE_SECONDS, MAX_HTTP_DELIVERY_HEADER_BYTES, MAX_HTTP_DELIVERY_TOKENS, } from "./homepage-metadata-contract.mjs";
8
9
  const MAX_HTTP_REDIRECTS = MAX_HTTP_EVIDENCE_REDIRECTS;
@@ -25,7 +26,7 @@ function boundedString(value, maxLength) {
25
26
  return normalized ? normalized.slice(0, maxLength) : null;
26
27
  }
27
28
  function normalizeProvenanceUrl(value) {
28
- if (typeof value !== 'string' || value.length === 0 || value.length > 4096)
29
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_OUTBOUND_HTTP_URL_CHARACTERS)
29
30
  return null;
30
31
  try {
31
32
  const parsed = new URL(value);
@@ -165,14 +166,14 @@ function headerValidity(header, validator) {
165
166
  return { present: true, valid: null };
166
167
  return { present: true, valid: header.state === 'observed' && Boolean(header.value && validator(header.value)) };
167
168
  }
168
- function validHttpDate(value) {
169
+ function validHttpDate(value, observedAt) {
169
170
  const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
170
171
  const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
171
172
  const longWeekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
172
- const time = '([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d)';
173
+ const time = '([01]\\d|2[0-3]):([0-5]\\d):([0-5]\\d|60)';
173
174
  const imfFixdate = value.match(new RegExp(`^(${weekdays.join('|')}), (0[1-9]|[12]\\d|3[01]) (${months.join('|')}) (\\d{4}) ${time} GMT$`, 'u'));
174
175
  const rfc850 = value.match(new RegExp(`^(${longWeekdays.join('|')}), (0[1-9]|[12]\\d|3[01])-(${months.join('|')})-(\\d{2}) ${time} GMT$`, 'u'));
175
- const asctime = value.match(new RegExp(`^(${weekdays.join('|')}) (${months.join('|')}) ( {2}[1-9]| [12]\\d| 3[01]) ${time} (\\d{4})$`, 'u'));
176
+ const asctime = value.match(new RegExp(`^(${weekdays.join('|')}) (${months.join('|')}) ( [1-9]|0[1-9]|[12]\\d|3[01]) ${time} (\\d{4})$`, 'u'));
176
177
  let weekday;
177
178
  let day;
178
179
  let month;
@@ -196,10 +197,19 @@ function validHttpDate(value) {
196
197
  day = Number(rfc850[2]);
197
198
  month = months.indexOf(rfc850[3]);
198
199
  const shortYear = Number(rfc850[4]);
199
- year = shortYear >= 70 ? 1900 + shortYear : 2000 + shortYear;
200
+ // RFC 9110's rolling window is relative to receipt, not a later review
201
+ // or the host's timezone. The observation supplies that stable reference.
202
+ const futureBoundary = new Date(observedAt);
203
+ futureBoundary.setUTCFullYear(futureBoundary.getUTCFullYear() + 50);
204
+ year = Math.floor(futureBoundary.getUTCFullYear() / 100) * 100 + shortYear;
200
205
  hour = Number(rfc850[5]);
201
206
  minute = Number(rfc850[6]);
202
207
  second = Number(rfc850[7]);
208
+ const candidate = new Date(0);
209
+ candidate.setUTCFullYear(year, month, day);
210
+ candidate.setUTCHours(hour, minute, Math.min(second, 59), 0);
211
+ if (candidate.getTime() > futureBoundary.getTime())
212
+ year -= 100;
203
213
  expectedWeekdays = longWeekdays;
204
214
  }
205
215
  else if (asctime) {
@@ -218,13 +228,15 @@ function validHttpDate(value) {
218
228
  return false;
219
229
  const parsed = new Date(0);
220
230
  parsed.setUTCFullYear(year, month, day);
221
- parsed.setUTCHours(hour, minute, second, 0);
231
+ // A leap-second spelling must not roll into the following calendar day
232
+ // while validating the declared date and weekday.
233
+ parsed.setUTCHours(hour, minute, Math.min(second, 59), 0);
222
234
  return parsed.getUTCFullYear() === year
223
235
  && parsed.getUTCMonth() === month
224
236
  && parsed.getUTCDate() === day
225
237
  && expectedWeekdays[parsed.getUTCDay()] === weekday;
226
238
  }
227
- function parseCachePolicy(headers) {
239
+ function parseCachePolicy(headers, observedAt) {
228
240
  const cacheControl = deliveryHeader(headers, 'cache-control');
229
241
  const age = deliveryHeader(headers, 'age', 64);
230
242
  const etagHeader = deliveryHeader(headers, 'etag');
@@ -329,8 +341,8 @@ function parseCachePolicy(headers) {
329
341
  if (age.state === 'observed' && ageSeconds === null)
330
342
  malformed = true;
331
343
  const etag = headerValidity(etagHeader, (value) => /^(?:W\/)?"[\x21\x23-\x7e]*"$/u.test(value));
332
- const lastModified = headerValidity(lastModifiedHeader, validHttpDate);
333
- const expires = headerValidity(expiresHeader, validHttpDate);
344
+ const lastModified = headerValidity(lastModifiedHeader, (value) => validHttpDate(value, observedAt));
345
+ const expires = headerValidity(expiresHeader, (value) => validHttpDate(value, observedAt));
334
346
  malformed = malformed || [etag, lastModified, expires].some((item) => item.present && item.valid === false);
335
347
  const headersObserved = [cacheControl, age, etagHeader, lastModifiedHeader, expiresHeader]
336
348
  .some((header) => header.state !== 'not_observed');
@@ -362,9 +374,9 @@ function parseCachePolicy(headers) {
362
374
  truncated: partial,
363
375
  };
364
376
  }
365
- function buildDeliveryMetadata(headers) {
377
+ function buildDeliveryMetadata(headers, observedAt) {
366
378
  const contentEncoding = parseContentEncoding(headers);
367
- const cacheResult = parseCachePolicy(headers);
379
+ const cacheResult = parseCachePolicy(headers, observedAt);
368
380
  const cachePolicy = cacheResult.value;
369
381
  const incomplete = [contentEncoding.status, cachePolicy.status].some((status) => ['partial', 'malformed'].includes(status));
370
382
  const truncated = contentEncoding.status === 'partial' || cacheResult.truncated;
@@ -466,21 +478,22 @@ function buildHttpObservation(detail, options = {}) {
466
478
  ...(Array.isArray(options.previousAttempts) ? options.previousAttempts : []),
467
479
  { url: detail && detail.requestedUrl, httpStatus: status },
468
480
  ]);
481
+ const observation = createObservation({
482
+ status: incomplete ? 'partial' : 'success',
483
+ observedAt: options.observedAt,
484
+ scanMode: 'deep',
485
+ source: 'http',
486
+ durationMs: detail && detail.durationMs,
487
+ complete: !incomplete,
488
+ truncated: incomplete,
489
+ limitations,
490
+ diagnostics: {
491
+ redirectCount: redirects.length,
492
+ httpStatus: status,
493
+ },
494
+ });
469
495
  return {
470
- ...createObservation({
471
- status: incomplete ? 'partial' : 'success',
472
- observedAt: options.observedAt,
473
- scanMode: 'deep',
474
- source: 'http',
475
- durationMs: detail && detail.durationMs,
476
- complete: !incomplete,
477
- truncated: incomplete,
478
- limitations,
479
- diagnostics: {
480
- redirectCount: redirects.length,
481
- httpStatus: status,
482
- },
483
- }),
496
+ ...observation,
484
497
  requestUrl: requested ? requested.url : null,
485
498
  finalUrl: final ? final.url : null,
486
499
  transportSecurity: final ? final.url.split(':', 1)[0] : null,
@@ -506,7 +519,7 @@ function buildHttpObservation(detail, options = {}) {
506
519
  bytes: capturedBodyBytes,
507
520
  } : null,
508
521
  securityHeaders: securityHeaders(response && response.headers),
509
- deliveryMetadata: buildDeliveryMetadata(response && response.headers),
522
+ deliveryMetadata: buildDeliveryMetadata(response && response.headers, observation.observedAt),
510
523
  },
511
524
  };
512
525
  }
@@ -10,6 +10,7 @@
10
10
  // In-memory, so (like the bootstrap cache and the rate limiter) this
11
11
  // applies globally on server.mts's one long-lived process, but only within
12
12
  // a single warm container on Netlify Functions.
13
+ import { abortable } from "./abort.mjs";
13
14
  const TTL_MS = 3 * 60 * 1000; // 3 minutes
14
15
  const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
15
16
  // Bounds worst-case memory regardless of the TTL/sweep - without this, a
@@ -72,7 +73,7 @@ function getCached(key) {
72
73
  deleteEntry(key);
73
74
  return undefined;
74
75
  }
75
- return entry.value;
76
+ return structuredClone(entry.value);
76
77
  }
77
78
  function setCached(key, value) {
78
79
  sweepExpiredEntries(Date.now());
@@ -80,9 +81,16 @@ function setCached(key, value) {
80
81
  const size = approxByteSize(value);
81
82
  // A value whose serialized size cannot be measured cannot participate in a
82
83
  // byte-bounded cache. Return it to the current caller without retaining it.
83
- if (size === null)
84
- return;
85
- store.set(key, { value, expiresAt: Date.now() + TTL_MS, size });
84
+ if (size === null || size > MAX_TOTAL_BYTES)
85
+ return { value, owned: false };
86
+ let snapshot;
87
+ try {
88
+ snapshot = structuredClone(value);
89
+ }
90
+ catch {
91
+ return { value, owned: false };
92
+ }
93
+ store.set(key, { value: snapshot, expiresAt: Date.now() + TTL_MS, size });
86
94
  totalBytes += size;
87
95
  while (store.size > 0 && (store.size > MAX_ENTRIES || totalBytes > MAX_TOTAL_BYTES)) {
88
96
  const oldestKey = store.keys().next().value;
@@ -90,6 +98,12 @@ function setCached(key, value) {
90
98
  break;
91
99
  deleteEntry(oldestKey);
92
100
  }
101
+ return { value: snapshot, owned: true };
102
+ }
103
+ // Stored data and coalesced results belong to the cache, not to a caller. Each
104
+ // consumer receives its own copy, including the first and concurrent callers.
105
+ function callerResult(result) {
106
+ return result.owned ? structuredClone(result.value) : result.value;
93
107
  }
94
108
  // Concurrent callers for the same key (e.g. a bulk scan's concurrent
95
109
  // workers, or a fast scan immediately followed by a deep-check, hitting the
@@ -103,25 +117,34 @@ const inFlight = new Map();
103
117
  // The cache intentionally holds heterogeneous public lookup results. Callers
104
118
  // supply distinct result contracts, so this internal compatibility boundary
105
119
  // stays dynamically typed rather than asserting one shared payload shape.
106
- async function cached(key, factory) {
120
+ async function cached(key, factory, signal) {
121
+ signal?.throwIfAborted();
107
122
  const hit = getCached(key);
108
123
  if (hit !== undefined)
109
124
  return hit;
125
+ // A deadline-scoped caller may reuse completed public data, but must own
126
+ // its cancellable work rather than aborting another caller's shared request.
127
+ if (signal) {
128
+ const value = await abortable(factory, signal);
129
+ signal.throwIfAborted();
130
+ return callerResult(setCached(key, value));
131
+ }
110
132
  const pending = inFlight.get(key);
111
133
  if (pending)
112
- return pending;
113
- const promise = (async () => {
134
+ return callerResult(await pending);
135
+ // Register pending work before invoking the factory, including factories
136
+ // that throw synchronously. A rejected promise must never poison the key.
137
+ const promise = Promise.resolve().then(async () => {
114
138
  try {
115
139
  const value = await factory();
116
- setCached(key, value);
117
- return value;
140
+ return setCached(key, value);
118
141
  }
119
142
  finally {
120
143
  inFlight.delete(key);
121
144
  }
122
- })();
145
+ });
123
146
  inFlight.set(key, promise);
124
- return promise;
147
+ return callerResult(await promise);
125
148
  }
126
149
  // Expired entries are swept lazily during cache activity. This preserves the
127
150
  // long-running Express cleanup behavior without creating an import-time timer,