@slicedearth/whoisleuth-cli 2.2.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -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 +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  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 +112 -72
  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-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. 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";
@@ -16,7 +16,6 @@ import { analyzeStaticHtml } from "./static-html-analysis.mjs";
16
16
  import { analyzeStructuredDataIdentity } from "./structured-data-identity.mjs";
17
17
  import { analyzeWebsiteTechnology } from "./website-technology.mjs";
18
18
  import { PAGE_PUBLICATION_LIMITATIONS, PAGE_PUBLICATION_METADATA_VERSION, } from "./homepage-metadata-contract.mjs";
19
- const MAX_TITLE_LENGTH = 200;
20
19
  const MAX_EXTERNAL_ASSET_HOSTS = 20;
21
20
  const MAX_IDENTITY_TAGS = 512;
22
21
  const MAX_IDENTITY_TAG_LENGTH = 4096;
@@ -37,10 +36,6 @@ const MAX_URLS_PER_TAG = 20;
37
36
  const PAGE_IDENTITY_VERSION = 3;
38
37
  const CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/gu;
39
38
  const HAS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
40
- const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
41
- // A password input is the single strongest static-HTML tell that a page is
42
- // asking for credentials, regardless of what it claims to be.
43
- const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
44
39
  // <img>/<script>/<link> tags loading a resource from an absolute, external
45
40
  // URL - a common phishing-kit tell is hotlinking the real brand's own logo/
46
41
  // CSS/JS instead of copying it. Relative URLs (the common case, same
@@ -48,13 +43,8 @@ const PASSWORD_FIELD_RE = /<input\b[^>]*\btype\s*=\s*["']?password["']?/i;
48
43
  // scoped to resource tags, not every <a href> - an outbound link to the
49
44
  // real site is normal on all sorts of pages; a resource pulled live from it
50
45
  // during page load is not.
51
- const ASSET_TAG_RE = /<(?:img|script|link)\b[^>]*?\b(?:src|href)\s*=\s*["']([^"']+)["'][^>]*>/gi;
52
- const ABSOLUTE_URL_HOST_RE = /^(?:https?:)?\/\/([^/]+)/i;
53
- const IDENTITY_TAG_RE = /<(html|link|meta|form)\b[^>]{0,4096}>/gi;
54
- const OVERSIZED_IDENTITY_TAG_RE = /<(?:html|link|meta|form)\b[^>]{4097}/i;
55
- const RELATIONSHIP_TAG_RE = /<(a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{0,4096}>/gi;
56
- const OVERSIZED_RELATIONSHIP_TAG_RE = /<(?:a|img|script|link|iframe|frame|source|video|audio|object|embed)\b[^>]{4097}/i;
57
- 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']);
58
48
  const LANGUAGE_TAG_RE = /^[a-z]{2,8}(?:-[a-z0-9]{1,8})*$/i;
59
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;
60
50
  const RESOURCE_LINK_RELS = new Set(['stylesheet', 'icon', 'preload', 'prefetch', 'modulepreload', 'manifest']);
@@ -62,9 +52,6 @@ const RISKY_DOWNLOAD_EXTENSIONS = new Set([
62
52
  '7z', 'apk', 'bat', 'cmd', 'dmg', 'docm', 'exe', 'img', 'iso', 'jar', 'js',
63
53
  'msi', 'pkg', 'ps1', 'rar', 'scr', 'vbs', 'xlsm', 'zip',
64
54
  ]);
65
- const HTML_COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;
66
- const RAW_TEXT_BLOCK_RE = /<(script|style|textarea|template)\b([^>]*)>[\s\S]*?<\/\1\s*>/gi;
67
- const NON_EXECUTING_RAW_TEXT_BLOCK_RE = /<(style|textarea|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
68
55
  function stripWwwPrefix(host) {
69
56
  return host.toLowerCase().replace(/^www\./, '');
70
57
  }
@@ -77,24 +64,19 @@ function boundedHtmlText(value, maxLength, ellipsis = false) {
77
64
  return null;
78
65
  if (text.length <= maxLength)
79
66
  return text;
80
- 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);
81
68
  }
82
- function extractExternalAssetHosts(html, ownDomain) {
69
+ function extractExternalAssetHosts(analysis, ownDomain, documentUrl) {
83
70
  const ownHost = stripWwwPrefix(ownDomain);
84
71
  const hosts = new Set();
85
- let match;
86
- ASSET_TAG_RE.lastIndex = 0;
87
- while ((match = ASSET_TAG_RE.exec(html))) {
88
- const resourceUrl = match[1];
89
- if (!resourceUrl)
72
+ for (const element of analysis.elements) {
73
+ if (!element.html || !['img', 'script', 'link'].includes(element.name))
90
74
  continue;
91
- const hostMatch = resourceUrl.match(ABSOLUTE_URL_HOST_RE);
92
- 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)
93
78
  continue;
94
- const matchedHost = hostMatch[1];
95
- if (!matchedHost || HAS_CONTROL_CHARACTER_RE.test(matchedHost))
96
- continue;
97
- const host = stripWwwPrefix(matchedHost);
79
+ const host = stripWwwPrefix(new URL(resource.url).hostname);
98
80
  if (host && host !== ownHost)
99
81
  hosts.add(host);
100
82
  if (hosts.size >= MAX_EXTERNAL_ASSET_HOSTS)
@@ -102,38 +84,6 @@ function extractExternalAssetHosts(html, ownDomain) {
102
84
  }
103
85
  return [...hosts];
104
86
  }
105
- function extractPageTitle(html) {
106
- const match = html.match(TITLE_RE);
107
- if (!match)
108
- return null;
109
- return boundedHtmlText(match[1], MAX_TITLE_LENGTH, true);
110
- }
111
- function parseAttributes(tag) {
112
- const start = tag.search(/\s/);
113
- if (start === -1)
114
- return new Map();
115
- const attributes = new Map();
116
- ATTRIBUTE_RE.lastIndex = 0;
117
- let match;
118
- while ((match = ATTRIBUTE_RE.exec(tag.slice(start)))) {
119
- const name = (match[1] ?? '').toLowerCase();
120
- if (!name)
121
- continue;
122
- if (!attributes.has(name))
123
- attributes.set(name, match[2] ?? match[3] ?? match[4] ?? '');
124
- }
125
- return attributes;
126
- }
127
- function markupForTagParsing(html) {
128
- return html
129
- .replace(HTML_COMMENT_RE, ' ')
130
- .replace(RAW_TEXT_BLOCK_RE, '<$1$2>');
131
- }
132
- function markupForTrackingIdentifiers(html) {
133
- return html
134
- .replace(HTML_COMMENT_RE, ' ')
135
- .replace(NON_EXECUTING_RAW_TEXT_BLOCK_RE, ' ');
136
- }
137
87
  function normalizeLanguage(value) {
138
88
  if (typeof value !== 'string' || value.length > 35 || HAS_CONTROL_CHARACTER_RE.test(value))
139
89
  return null;
@@ -306,10 +256,10 @@ function trackingIdentifiers(html) {
306
256
  truncated,
307
257
  };
308
258
  }
309
- function extractPageRelationships(html, domain, options = {}) {
310
- const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
311
- const markup = typeof options.tagMarkup === 'string' ? options.tagMarkup : markupForTagParsing(html);
312
- const baseOrigin = new URL(baseUrl).origin;
259
+ function extractPageRelationships(analysis, domain, options = {}) {
260
+ const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
261
+ const baseUrl = resolvedBaseUrl(domain, options.effectiveBaseUrl ?? documentUrl);
262
+ const baseOrigin = options.documentOrigin ?? new URL(documentUrl).origin;
313
263
  const resourceKeys = new Set();
314
264
  const resourceOrigins = new Set();
315
265
  const embeddedOrigins = new Set();
@@ -322,25 +272,26 @@ function extractPageRelationships(html, domain, options = {}) {
322
272
  let riskyDownloadCount = 0;
323
273
  let tagsExamined = 0;
324
274
  let discardedUrls = 0;
325
- let tagLimitReached = OVERSIZED_RELATIONSHIP_TAG_RE.test(markup);
275
+ let tagLimitReached = analysis.inputLimitReached || analysis.tagLimitReached;
326
276
  let resourceOriginLimitReached = false;
327
277
  let embeddedOriginLimitReached = false;
328
278
  let contactDomainLimitReached = false;
329
279
  let downloadOriginLimitReached = false;
330
280
  let downloadFileTypeLimitReached = false;
331
281
  let perTagLimitReached = false;
332
- let match;
333
- RELATIONSHIP_TAG_RE.lastIndex = 0;
334
- 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;
335
286
  if (tagsExamined >= MAX_RESOURCE_TAGS) {
336
287
  tagLimitReached = true;
337
288
  break;
338
289
  }
339
290
  tagsExamined += 1;
340
- const tagName = (match[1] ?? '').toLowerCase();
341
- if (!tagName)
342
- continue;
343
- 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;
344
295
  if (tagName === 'a') {
345
296
  const href = attributes.get('href');
346
297
  const contacts = mailtoDomains(href);
@@ -388,6 +339,10 @@ function extractPageRelationships(html, domain, options = {}) {
388
339
  }
389
340
  const key = `${reference.type}:${normalized.url}`;
390
341
  if (!resourceKeys.has(key)) {
342
+ if (resourceKeys.size >= MAX_RESOURCE_TAGS) {
343
+ resourceCountLimitReached = true;
344
+ continue;
345
+ }
391
346
  resourceKeys.add(key);
392
347
  byType[reference.type] += 1;
393
348
  }
@@ -399,10 +354,15 @@ function extractPageRelationships(html, domain, options = {}) {
399
354
  embeddedOriginLimitReached = true;
400
355
  }
401
356
  }
402
- 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;
403
363
  const truncated = tagLimitReached || resourceOriginLimitReached || embeddedOriginLimitReached
404
364
  || contactDomainLimitReached || downloadOriginLimitReached || downloadFileTypeLimitReached
405
- || perTagLimitReached || tracking.truncated;
365
+ || perTagLimitReached || resourceCountLimitReached || tracking.truncated;
406
366
  const limitations = [];
407
367
  if (tagLimitReached)
408
368
  limitations.push(`Resource parsing reached the ${MAX_RESOURCE_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
@@ -418,6 +378,8 @@ function extractPageRelationships(html, domain, options = {}) {
418
378
  limitations.push(`Only the first ${MAX_DOWNLOAD_FILE_TYPES} risky download file types were retained.`);
419
379
  if (perTagLimitReached)
420
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.`);
421
383
  if (tracking.truncated)
422
384
  limitations.push(`Only the first ${MAX_TRACKING_IDENTIFIERS} tracking identifiers were retained.`);
423
385
  return {
@@ -425,7 +387,7 @@ function extractPageRelationships(html, domain, options = {}) {
425
387
  count: resourceKeys.size,
426
388
  byType,
427
389
  externalOrigins: [...resourceOrigins].sort(),
428
- truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached,
390
+ truncated: tagLimitReached || resourceOriginLimitReached || perTagLimitReached || resourceCountLimitReached,
429
391
  },
430
392
  embeddedOrigins: [...embeddedOrigins].sort(),
431
393
  contactDomains: [...contactDomains].sort(),
@@ -454,11 +416,13 @@ function metaRefreshTarget(content) {
454
416
  return match ? (match[1] ?? match[2] ?? match[3] ?? '').trim() : null;
455
417
  }
456
418
  function extractPageIdentity(html, domain, options = {}) {
457
- const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
458
- const tagMarkup = markupForTagParsing(html);
459
- const parsedBase = new URL(baseUrl);
460
- const baseOrigin = parsedBase.origin;
461
- const baseUsesHttps = parsedBase.protocol === 'https:';
419
+ const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
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;
423
+ const parsedDocument = new URL(documentUrl);
424
+ const baseOrigin = options.documentOrigin ?? parsedDocument.origin;
425
+ const baseUsesHttps = parsedDocument.protocol === 'https:';
462
426
  const externalFormOrigins = new Set();
463
427
  let documentLanguage = null;
464
428
  let canonical = null;
@@ -474,19 +438,19 @@ function extractPageIdentity(html, domain, options = {}) {
474
438
  let discardedUrls = 0;
475
439
  let formLimitReached = false;
476
440
  let originLimitReached = false;
477
- let tagLimitReached = OVERSIZED_IDENTITY_TAG_RE.test(tagMarkup);
478
- let match;
479
- IDENTITY_TAG_RE.lastIndex = 0;
480
- 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;
481
445
  if (tagsExamined >= MAX_IDENTITY_TAGS) {
482
446
  tagLimitReached = true;
483
447
  break;
484
448
  }
485
449
  tagsExamined += 1;
486
- const tagName = (match[1] ?? '').toLowerCase();
487
- if (!tagName)
488
- continue;
489
- 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;
490
454
  if (tagName === 'html' && documentLanguage === null) {
491
455
  documentLanguage = normalizeLanguage(attributes.get('lang'));
492
456
  continue;
@@ -556,20 +520,23 @@ function extractPageIdentity(html, domain, options = {}) {
556
520
  const queryOmitted = [canonical, metaRefresh, openGraphUrl].some((item) => item?.queryOmitted);
557
521
  const pathTruncated = [canonical, metaRefresh, openGraphUrl].some((item) => item?.pathTruncated);
558
522
  const sourceTruncated = options.sourceTruncated === true;
559
- const relationships = extractPageRelationships(html, domain, { baseUrl, tagMarkup });
523
+ const relationships = extractPageRelationships(analysis, domain, { baseUrl: documentUrl, effectiveBaseUrl: baseUrl, documentOrigin: baseOrigin });
560
524
  const fingerprints = createPageFingerprints(html, {
561
- baseUrl,
525
+ baseUrl: documentUrl,
526
+ htmlAnalysis: analysis,
562
527
  exactBodyHash: options.exactBodyHash,
563
528
  sourceTruncated,
564
529
  resources: relationships.resources,
565
530
  trackingIdentifiers: relationships.trackingIdentifiers,
566
531
  identifiersTruncated: relationships.diagnostics.trackingIdentifiersTruncated === true,
567
532
  });
568
- const truncated = sourceTruncated || tagLimitReached || formLimitReached || originLimitReached
533
+ const truncated = sourceTruncated || baseHrefState === 'invalid' || tagLimitReached || formLimitReached || originLimitReached
569
534
  || pathTruncated || relationships.truncated || fingerprints.truncated;
570
535
  const limitations = ['Static HTML metadata only; JavaScript-rendered changes are not evaluated.'];
571
536
  if (sourceTruncated)
572
537
  limitations.push('Homepage body capture reached its byte limit; identity fields may be incomplete.');
538
+ if (baseHrefState === 'invalid')
539
+ limitations.push('The first bounded document base URL was invalid; relative URL relationships use the response URL and remain partial.');
573
540
  if (tagLimitReached)
574
541
  limitations.push(`Page identity parsing reached the ${MAX_IDENTITY_TAGS}-tag or ${MAX_IDENTITY_TAG_LENGTH}-character tag limit.`);
575
542
  if (formLimitReached)
@@ -696,34 +663,67 @@ function buildPagePublicationMetadata(analysis, sourceTruncated) {
696
663
  },
697
664
  };
698
665
  }
699
- function extractHtmlSignals(html, domain, options = {}) {
700
- const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(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();
692
+ const documentUrl = resolvedBaseUrl(domain, options.baseUrl);
693
+ const htmlAnalysis = options.htmlAnalysis ?? analyzeStaticHtml(html, { baseUrl: documentUrl, includeVisibleText: true });
694
+ const effectiveBaseUrl = htmlAnalysis.effectiveBaseUrl ?? documentUrl;
695
+ const documentOrigin = new URL(documentUrl).origin;
696
+ const pageIdentity = options.includePageIdentity === false ? null : extractPageIdentity(html, domain, {
697
+ ...options,
698
+ baseUrl: documentUrl,
699
+ effectiveBaseUrl,
700
+ documentOrigin,
701
+ baseHrefState: htmlAnalysis.baseHrefState,
702
+ htmlAnalysis,
703
+ });
701
704
  const includeCredentialSurfaceProfile = pageIdentity && options.includeCredentialSurfaceProfile === true;
702
705
  const includeStructuredDataIdentity = pageIdentity && options.includeStructuredDataIdentity !== false;
703
706
  const includeTechnologyProfile = pageIdentity && options.includeTechnologyProfile !== false;
704
707
  const includeDerivedPageProfiles = Boolean(pageIdentity);
705
- const baseUrl = resolvedBaseUrl(domain, options.baseUrl);
706
- const htmlAnalysis = includeCredentialSurfaceProfile || includeStructuredDataIdentity || includeTechnologyProfile || includeDerivedPageProfiles
707
- ? analyzeStaticHtml(html, { baseUrl, includeVisibleText: true })
708
- : null;
709
708
  const pageIdentityOutput = pageIdentity && htmlAnalysis && options.includePublicationMetadata !== false
710
709
  ? {
711
710
  ...pageIdentity,
712
711
  publicationMetadata: buildPagePublicationMetadata(htmlAnalysis.publicationMetadata, options.sourceTruncated === true),
713
712
  }
714
713
  : pageIdentity;
715
- const pageLanguageSignal = detectPageLanguageSignal(html, pageIdentity?.documentLanguage, htmlAnalysis ?? undefined);
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;
716
717
  return {
717
- pageTitle: extractPageTitle(html),
718
- hasPasswordField: PASSWORD_FIELD_RE.test(html),
718
+ domainSaleSignal,
719
+ pageTitle: htmlAnalysis.title,
720
+ hasPasswordField: htmlAnalysis.forms.categories.password > 0,
719
721
  phishingLanguageMatch: pageLanguageSignal?.label ?? null,
720
722
  hasExternalFormAction: pageIdentity
721
723
  ? pageIdentity.forms.externalActionOrigins.length > 0
722
724
  : null,
723
- externalAssetHosts: extractExternalAssetHosts(html, domain),
724
- cspMetaPolicy: htmlAnalysis
725
- ? analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached)
726
- : null,
725
+ externalAssetHosts: extractExternalAssetHosts(htmlAnalysis, domain, documentUrl),
726
+ cspMetaPolicy: analyzeCspMetaPolicies(htmlAnalysis.cspMetaPolicies, htmlAnalysis.cspMetaLimitReached),
727
727
  pageIdentity: pageIdentityOutput,
728
728
  credentialSurfaceProfile: includeCredentialSurfaceProfile && htmlAnalysis ? analyzeCredentialSurfaceProfile({
729
729
  htmlAnalysis,
@@ -732,23 +732,26 @@ function extractHtmlSignals(html, domain, options = {}) {
732
732
  }) : null,
733
733
  structuredDataIdentity: includeStructuredDataIdentity && htmlAnalysis ? analyzeStructuredDataIdentity({
734
734
  htmlAnalysis,
735
- baseUrl,
735
+ baseUrl: effectiveBaseUrl,
736
736
  observedAt: options.observedAt,
737
737
  sourceTruncated: options.sourceTruncated,
738
738
  }) : null,
739
- technologyProfile: includeTechnologyProfile && htmlAnalysis ? analyzeWebsiteTechnology({
739
+ technologyProfile: includeTechnologyProfile && htmlAnalysis ? await analyzeWebsiteTechnology({
740
740
  htmlAnalysis,
741
741
  generator: pageIdentity.generator,
742
+ ...(options.signal ? { signal: options.signal } : {}),
742
743
  httpServer: options.httpServer,
743
744
  responseHeaders: options.responseHeaders,
744
745
  resourceOrigins: pageIdentity.resources.externalOrigins,
746
+ effectiveBaseUrl,
747
+ documentOrigin,
745
748
  observedAt: options.observedAt,
746
749
  sourceTruncated: options.sourceTruncated,
747
750
  }) : null,
748
751
  pageRoleProfile: includeDerivedPageProfiles && htmlAnalysis ? analyzePageRole({
749
752
  htmlAnalysis,
750
- pageTitle: extractPageTitle(html),
751
- activityStatus: options.activityStatus,
753
+ pageTitle: htmlAnalysis.title,
754
+ activityStatus: domainSaleSignal ? 'parked' : options.activityStatus,
752
755
  observedAt: options.observedAt,
753
756
  sourceTruncated: options.sourceTruncated,
754
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
  }
@@ -45,14 +45,30 @@ function buildLocalGeoIpDatabase(value) {
45
45
  }
46
46
  const truncated = rows.length > MAX_GEOIP_RECORDS;
47
47
  const normalized = rows.slice(0, MAX_GEOIP_RECORDS).map(normalizeGeoIpEntry);
48
- const records = normalized.filter((item) => item !== null)
49
- .sort((left, right) => right.parsedNetwork.length - left.parsedNetwork.length);
48
+ const byNetwork = new Map();
49
+ const sameAttribution = (left, right) => (left.countryCode === right.countryCode
50
+ && left.region === right.region
51
+ && left.city === right.city
52
+ && left.asn === right.asn
53
+ && left.asName === right.asName);
54
+ for (const item of normalized) {
55
+ if (!item)
56
+ continue;
57
+ const existing = byNetwork.get(item.network);
58
+ if (existing && !sameAttribution(existing, item)) {
59
+ throw new TypeError(`A local GeoIP database must not assign conflicting evidence to canonical prefix ${item.network}.`);
60
+ }
61
+ if (!existing)
62
+ byNetwork.set(item.network, item);
63
+ }
64
+ const records = [...byNetwork.values()]
65
+ .sort((left, right) => right.parsedNetwork.length - left.parsedNetwork.length || left.network.localeCompare(right.network));
50
66
  return Object.freeze({
51
67
  sourceLabel,
52
68
  databaseVersion,
53
69
  license,
54
70
  records: Object.freeze(records),
55
- rejectedCount: normalized.length - records.length + Math.max(0, rows.length - MAX_GEOIP_RECORDS),
71
+ rejectedCount: normalized.filter((item) => item === null).length + Math.max(0, rows.length - MAX_GEOIP_RECORDS),
56
72
  truncated,
57
73
  });
58
74
  }