@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
@@ -6,7 +6,8 @@
6
6
  // retained.
7
7
  import { analyzeBrowserLibraries } from "./browser-library-profile.mjs";
8
8
  import { createObservation } from "../packages/evidence/observation.mjs";
9
- import { MAX_STATIC_HTML_CHARS, MAX_TAG_LENGTH, MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
9
+ import { attributeValue, createTechnologyMarkupContext, elementMarker, hasAttributes, navigationMarker, resourceMarker, } from "./technology-markup.mjs";
10
+ import { MAX_STATIC_HTML_CHARS, MAX_STATIC_HTML_TAGS as MAX_TECHNOLOGY_TAGS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
10
11
  import { TECHNOLOGY_EVIDENCE_ROLE_ORDER, } from "./technology-evidence-role.mjs";
11
12
  import { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, TECHNOLOGY_PROFILE_VERSION, } from "./lookup-child-profile-contract.mjs";
12
13
  const TECHNOLOGY_CATEGORIES = Object.freeze([
@@ -27,7 +28,6 @@ const TECHNOLOGY_EVIDENCE_SOURCES = Object.freeze([
27
28
  'passive response header',
28
29
  ]);
29
30
  const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
30
- const MAX_TECHNOLOGY_TAG_LENGTH = MAX_TAG_LENGTH;
31
31
  const MAX_RESOURCE_ORIGINS = 30;
32
32
  const MAX_GENERATOR_INPUT = 160;
33
33
  const MAX_SERVER_INPUT = 240;
@@ -55,29 +55,6 @@ function normalizedResourceHosts(value) {
55
55
  }
56
56
  return hosts;
57
57
  }
58
- function applicationMarkup(markup, resourceHosts, effectiveBaseUrl, documentOrigin) {
59
- let resolutionBase;
60
- try {
61
- resolutionBase = new URL(typeof effectiveBaseUrl === 'string' ? effectiveBaseUrl : 'https://document.invalid/');
62
- }
63
- catch {
64
- resolutionBase = new URL('https://document.invalid/');
65
- }
66
- const comparedOrigin = typeof documentOrigin === 'string' && /^https?:\/\//iu.test(documentOrigin)
67
- ? documentOrigin
68
- : null;
69
- return markup.replace(/\s(src|href|poster|data)="([^"]*)"/giu, (attribute, name, value) => {
70
- try {
71
- const parsed = new URL(value, resolutionBase);
72
- const knownExternalHost = resourceHosts.has(parsed.hostname.toLowerCase());
73
- const offOrigin = comparedOrigin ? parsed.origin !== comparedOrigin : knownExternalHost;
74
- return knownExternalHost && offOrigin ? ` ${name.toLowerCase()}=""` : attribute;
75
- }
76
- catch {
77
- return attribute;
78
- }
79
- });
80
- }
81
58
  const PASSIVE_TECHNOLOGY_HEADER_NAMES = Object.freeze([
82
59
  'cf-ray',
83
60
  'x-drupal-cache',
@@ -138,21 +115,14 @@ function generatorEvidence(pattern, description) {
138
115
  matches: ({ generator }) => pattern.test(generator),
139
116
  };
140
117
  }
141
- function htmlEvidence(markers, description, confidence = 'high') {
142
- return {
143
- source: 'static HTML',
144
- description,
145
- confidence,
146
- matches: ({ html }) => markers.some((marker) => html.includes(marker)),
147
- };
148
- }
149
- function resourcePathEvidence(markers, description, confidence = 'high') {
118
+ function htmlEvidence(markupRules, description, confidence = 'high') {
150
119
  return {
151
120
  source: 'static HTML',
152
121
  description,
153
122
  confidence,
154
- matches: ({ html }) => markers.some((marker) => html.includes(marker)),
155
- roleFor: ({ applicationHtml }) => markers.some((marker) => applicationHtml.includes(marker))
123
+ markupRules,
124
+ matches: (context) => markupRules.some((rule) => rule.matches(context)),
125
+ roleFor: (context) => markupRules.some((rule) => rule.matches(context) && !rule.embedded?.(context))
156
126
  ? undefined
157
127
  : 'embedded_dependency',
158
128
  };
@@ -184,7 +154,7 @@ function serverEvidence(pattern, description, confidence = 'high') {
184
154
  function responseHeaderEvidence(name, pattern, description, confidence = 'high') {
185
155
  return {
186
156
  source: 'passive response header',
187
- description,
157
+ description: `${name}: ${description}`,
188
158
  confidence,
189
159
  matches: ({ responseHeaders }) => {
190
160
  if (!responseHeaders.has(name))
@@ -213,14 +183,20 @@ const TECHNOLOGY_SIGNATURES = [
213
183
  id: 'wordpress', name: 'WordPress', category: 'content management',
214
184
  evidence: [
215
185
  generatorEvidence(/^wordpress(?:\s|$)/i, 'Generator metadata identifies WordPress.'),
216
- resourcePathEvidence(['/wp-content/', '/wp-includes/'], 'Static resource paths use WordPress conventions.', 'medium'),
186
+ htmlEvidence([
187
+ resourceMarker((url) => /\/wp-content\//iu.test(url.pathname), '/wp-content/fixture.css'),
188
+ resourceMarker((url) => /\/wp-includes\//iu.test(url.pathname), '/wp-includes/fixture.js'),
189
+ ], 'Static resource paths use WordPress conventions.', 'medium'),
217
190
  ],
218
191
  },
219
192
  {
220
193
  id: 'drupal', name: 'Drupal', category: 'content management',
221
194
  evidence: [
222
195
  generatorEvidence(/^drupal(?:\s|$)/i, 'Generator metadata identifies Drupal.'),
223
- htmlEvidence(['data-drupal-selector=', 'data-drupal-link-system-path=', 'drupal-settings-json'], 'Static markup contains Drupal-specific attributes.'),
196
+ htmlEvidence([
197
+ elementMarker((element) => hasAttributes(element, 'data-drupal-selector'), '<main data-drupal-selector="fixture"></main>'),
198
+ elementMarker((element) => hasAttributes(element, 'data-drupal-link-system-path'), '<main data-drupal-link-system-path="fixture"></main>'),
199
+ ], 'Static markup contains Drupal-specific attributes.'),
224
200
  responseHeaderEvidence('x-drupal-cache', null, 'A Drupal-specific cache response header was observed.', 'medium'),
225
201
  ],
226
202
  },
@@ -232,7 +208,10 @@ const TECHNOLOGY_SIGNATURES = [
232
208
  id: 'ghost', name: 'Ghost', category: 'content management',
233
209
  evidence: [
234
210
  generatorEvidence(/^ghost(?:\s|$)/i, 'Generator metadata identifies Ghost.'),
235
- htmlEvidence(['ghost/api/content/', 'data-ghost-search'], 'Static markup contains Ghost-specific integration markers.'),
211
+ htmlEvidence([
212
+ elementMarker((element) => hasAttributes(element, 'data-ghost-search'), '<main data-ghost-search></main>'),
213
+ resourceMarker((url) => /\/ghost\/api\/content\//iu.test(url.pathname), '/ghost/api/content/'),
214
+ ], 'Static markup contains Ghost-specific integration markers.'),
236
215
  ],
237
216
  },
238
217
  {
@@ -249,7 +228,10 @@ const TECHNOLOGY_SIGNATURES = [
249
228
  id: 'shopify', name: 'Shopify', category: 'commerce',
250
229
  requiresNonResourceEvidence: true,
251
230
  evidence: [
252
- htmlEvidence(['shopify-section', 'shopify.theme'], 'Static markup contains Shopify-specific storefront markers.'),
231
+ htmlEvidence([
232
+ elementMarker((element) => attributeValue(element, 'class').split(/\s/u).includes('shopify-section')
233
+ || /^shopify-section(?:-|$)/u.test(attributeValue(element, 'id')), '<section class="shopify-section"></section>'),
234
+ ], 'Static markup contains Shopify-specific storefront markers.'),
253
235
  resourceEvidence(['cdn.shopify.com'], 'A retained resource origin uses the Shopify content network.'),
254
236
  responseHeaderEvidence('x-shopify-stage', null, 'A Shopify-specific platform response header was observed.', 'medium'),
255
237
  responseHeaderEvidence('x-sorting-hat-podid', null, 'A Shopify-specific routing response header was observed.', 'medium'),
@@ -270,7 +252,10 @@ const TECHNOLOGY_SIGNATURES = [
270
252
  {
271
253
  id: 'adobe-commerce-magento', name: 'Adobe Commerce / Magento Open Source', category: 'commerce',
272
254
  evidence: [
273
- htmlEvidence(['data-mage-init=', 'type="text/x-magento-init"', "type='text/x-magento-init'"], 'Static markup contains Commerce frontend initialisation markers.'),
255
+ htmlEvidence([
256
+ elementMarker((element) => hasAttributes(element, 'data-mage-init'), '<main data-mage-init="{}"></main>'),
257
+ elementMarker((element) => element.name === 'script' && attributeValue(element, 'type') === 'text/x-magento-init', '<script type="text/x-magento-init">{}</script>'),
258
+ ], 'Static markup contains Commerce frontend initialisation markers.'),
274
259
  ],
275
260
  },
276
261
  {
@@ -278,26 +263,32 @@ const TECHNOLOGY_SIGNATURES = [
278
263
  minimumEvidenceMatches: 2,
279
264
  allowEmbeddedOnly: true,
280
265
  evidence: [
281
- resourcePathEvidence(['cdn11.bigcommerce.com/s-', 'stencil-utils'], 'Static markup contains BigCommerce storefront asset markers.', 'medium'),
266
+ htmlEvidence([
267
+ resourceMarker((url) => /(?:^|\/)stencil-utils(?:[./-]|$)/iu.test(url.pathname), '/assets/stencil-utils.js'),
268
+ resourceMarker((url) => /^cdn\d+\.bigcommerce\.com$/iu.test(url.hostname) && /^\/s-[^/]+\//iu.test(url.pathname), 'https://cdn11.bigcommerce.com/s-fixture/theme.css'),
269
+ ], 'Static markup contains BigCommerce storefront asset markers.', 'medium'),
282
270
  resourcePatternEvidence(/^cdn\d+\.bigcommerce\.com$/i, 'A retained resource origin uses BigCommerce storefront delivery infrastructure.'),
283
271
  ],
284
272
  },
285
273
  {
286
274
  id: 'woocommerce', name: 'WooCommerce', category: 'commerce',
287
275
  evidence: [
288
- htmlEvidence(['/wp-content/plugins/woocommerce/'], 'Static resource paths identify the WooCommerce plugin.'),
276
+ htmlEvidence([resourceMarker((url) => /\/wp-content\/plugins\/woocommerce\//iu.test(url.pathname), '/wp-content/plugins/woocommerce/fixture.css')], 'Static resource paths identify the WooCommerce plugin.'),
289
277
  ],
290
278
  },
291
279
  {
292
280
  id: 'opencart', name: 'OpenCart', category: 'commerce',
293
281
  evidence: [
294
- htmlEvidence(['index.php?route=common/home', 'image/catalog/opencart-logo.png'], 'Static markup contains OpenCart routing or default asset conventions.'),
282
+ htmlEvidence([
283
+ navigationMarker((url) => /\/index\.php$/iu.test(url.pathname) && url.searchParams.getAll('route').length === 1 && url.searchParams.get('route')?.toLowerCase() === 'common/home', 'index.php?route=common/home'),
284
+ resourceMarker((url) => /\/image\/catalog\/opencart-logo\.png$/iu.test(url.pathname), '/image/catalog/opencart-logo.png'),
285
+ ], 'Static markup contains OpenCart routing or default asset conventions.'),
295
286
  ],
296
287
  },
297
288
  {
298
289
  id: 'prestashop', name: 'PrestaShop', category: 'commerce',
299
290
  evidence: [
300
- htmlEvidence(['/modules/ps_'], 'Static resource paths use PrestaShop module conventions.'),
291
+ htmlEvidence([resourceMarker((url) => /\/modules\/ps_[^/]+\//iu.test(url.pathname), '/modules/ps_fixture/fixture.css')], 'Static resource paths use PrestaShop module conventions.'),
301
292
  ],
302
293
  },
303
294
  {
@@ -305,24 +296,29 @@ const TECHNOLOGY_SIGNATURES = [
305
296
  requiresNonResourceEvidence: true,
306
297
  evidence: [
307
298
  generatorEvidence(/^wix(?:\.com)?(?:\s|$)/i, 'Generator metadata identifies Wix.'),
308
- htmlEvidence(['data-mesh-id='], 'Static markup contains a Wix-specific document attribute.', 'medium'),
299
+ htmlEvidence([elementMarker((element) => hasAttributes(element, 'data-mesh-id'), '<main data-mesh-id="fixture"></main>')], 'Static markup contains a Wix-specific document attribute.', 'medium'),
309
300
  resourceEvidence(['static.parastorage.com', 'wixstatic.com'], 'A retained resource origin uses Wix delivery infrastructure.'),
310
301
  ],
311
302
  },
312
303
  {
313
304
  id: 'squarespace', name: 'Squarespace', category: 'site builder',
314
305
  requiresNonResourceEvidence: true,
306
+ allowEmbeddedOnly: true,
315
307
  evidence: [
316
308
  generatorEvidence(/^squarespace(?:\s|$)/i, 'Generator metadata identifies Squarespace.'),
317
- htmlEvidence(['squarespace-context'], 'Static markup contains a Squarespace-specific document marker.', 'medium'),
318
- resourceEvidence(['static.squarespace.com', 'static1.squarespace.com'], 'A retained resource origin uses Squarespace delivery infrastructure.'),
309
+ htmlEvidence([
310
+ resourceMarker((url) => url.hostname === 'assets.squarespace.com' && /^\/universal\/scripts-compressed\/[a-z0-9][a-z0-9.-]*\.js$/iu.test(url.pathname), 'https://assets.squarespace.com/universal/scripts-compressed/fixture.js'),
311
+ ], 'Static resource paths use Squarespace platform bundle conventions.', 'medium'),
319
312
  ],
320
313
  },
321
314
  {
322
315
  id: 'webflow', name: 'Webflow', category: 'site builder',
323
316
  evidence: [
324
317
  generatorEvidence(/^webflow(?:\s|$)/i, 'Generator metadata identifies Webflow.'),
325
- htmlEvidence(['data-wf-page=', 'data-wf-site='], 'Static markup contains Webflow-specific document attributes.'),
318
+ htmlEvidence([
319
+ elementMarker((element) => hasAttributes(element, 'data-wf-page'), '<main data-wf-page="fixture"></main>'),
320
+ elementMarker((element) => hasAttributes(element, 'data-wf-site'), '<main data-wf-site="fixture"></main>'),
321
+ ], 'Static markup contains Webflow-specific document attributes.'),
326
322
  ],
327
323
  },
328
324
  {
@@ -330,7 +326,7 @@ const TECHNOLOGY_SIGNATURES = [
330
326
  requiresNonResourceEvidence: true,
331
327
  evidence: [
332
328
  generatorEvidence(/^framer(?:\s|$)/i, 'Generator metadata identifies Framer.'),
333
- htmlEvidence(['data-framer-name='], 'Static markup contains Framer-specific component attributes.'),
329
+ htmlEvidence([elementMarker((element) => hasAttributes(element, 'data-framer-name'), '<main data-framer-name="fixture"></main>')], 'Static markup contains Framer-specific component attributes.'),
334
330
  resourceEvidence(['framerusercontent.com'], 'A retained resource origin uses Framer delivery infrastructure.'),
335
331
  ],
336
332
  },
@@ -338,42 +334,57 @@ const TECHNOLOGY_SIGNATURES = [
338
334
  id: 'weebly', name: 'Weebly', category: 'site builder',
339
335
  requiresNonResourceEvidence: true,
340
336
  evidence: [
341
- htmlEvidence(['id="wsite-base-style"', "id='wsite-base-style'", 'title="wsite-theme-css"', "title='wsite-theme-css'"], 'Static markup contains Weebly-specific theme attributes.'),
337
+ htmlEvidence([elementMarker((element) => element.name === 'link' && (attributeValue(element, 'id') === 'wsite-base-style' || attributeValue(element, 'title') === 'wsite-theme-css'), '<link id="wsite-base-style" href="/fixture.css">')], 'Static markup contains Weebly-specific theme attributes.'),
342
338
  resourceEvidence(['editmysite.com'], 'A retained resource origin uses Weebly delivery infrastructure.'),
343
339
  ],
344
340
  },
345
341
  {
346
342
  id: 'angular', name: 'Angular', category: 'web framework',
347
- evidence: [htmlEvidence([' ng-version='], 'Static markup contains Angular version metadata.')],
343
+ evidence: [htmlEvidence([elementMarker((element) => hasAttributes(element, 'ng-version'), '<main ng-version="fixture"></main>')], 'Static markup contains Angular version metadata.')],
348
344
  },
349
345
  {
350
346
  id: 'aspnet-web-forms', name: 'ASP.NET Web Forms', category: 'web framework',
351
- evidence: [htmlEvidence([' name="__viewstate"', ' id="__viewstate"'], 'Static form markup contains the ASP.NET Web Forms view-state field.')],
347
+ evidence: [htmlEvidence([elementMarker((element) => element.name === 'input' && (attributeValue(element, 'name') === '__viewstate' || attributeValue(element, 'id') === '__viewstate'), '<input name="__VIEWSTATE">')], 'Static form markup contains the ASP.NET Web Forms view-state field.')],
352
348
  },
353
349
  {
354
350
  id: 'nextjs', name: 'Next.js', category: 'web framework',
355
- evidence: [htmlEvidence(['id="__next_data__"', "id='__next_data__'", '/_next/static/'], 'Static markup contains Next.js bootstrap or asset markers.')],
351
+ evidence: [htmlEvidence([
352
+ elementMarker((element) => element.name === 'script' && attributeValue(element, 'id') === '__next_data__', '<script id="__NEXT_DATA__"></script>'),
353
+ resourceMarker((url) => /\/_next\/static\//iu.test(url.pathname), '/_next/static/fixture.js'),
354
+ ], 'Static markup contains Next.js bootstrap or asset markers.')],
356
355
  },
357
356
  {
358
357
  id: 'nuxt', name: 'Nuxt', category: 'web framework',
359
- evidence: [htmlEvidence(['id="__nuxt"', "id='__nuxt'", '/_nuxt/'], 'Static markup contains Nuxt bootstrap or asset markers.')],
358
+ evidence: [htmlEvidence([
359
+ elementMarker((element) => attributeValue(element, 'id') === '__nuxt', '<main id="__nuxt"></main>'),
360
+ resourceMarker((url) => /\/_nuxt\//iu.test(url.pathname), '/_nuxt/fixture.js'),
361
+ ], 'Static markup contains Nuxt bootstrap or asset markers.')],
360
362
  },
361
363
  {
362
364
  id: 'gatsby', name: 'Gatsby', category: 'web framework',
363
- evidence: [htmlEvidence(['id="___gatsby"', "id='___gatsby'", '/page-data/app-data.json'], 'Static markup contains Gatsby bootstrap or page-data markers.')],
365
+ evidence: [htmlEvidence([
366
+ elementMarker((element) => attributeValue(element, 'id') === '___gatsby', '<main id="___gatsby"></main>'),
367
+ resourceMarker((url) => /\/page-data\/app-data\.json$/iu.test(url.pathname), '/page-data/app-data.json'),
368
+ ], 'Static markup contains Gatsby bootstrap or page-data markers.')],
364
369
  },
365
370
  {
366
371
  id: 'sveltekit', name: 'SvelteKit', category: 'web framework',
367
372
  evidence: [
368
- htmlEvidence(['data-sveltekit-preload-data=', 'data-sveltekit-reload='], 'Static markup contains SvelteKit-specific navigation attributes.'),
369
- htmlEvidence(['href="/_app/immutable/', 'src="/_app/immutable/'], 'Static asset paths use SvelteKit build conventions.', 'medium'),
373
+ htmlEvidence([
374
+ elementMarker((element) => hasAttributes(element, 'data-sveltekit-preload-data'), '<a data-sveltekit-preload-data="hover"></a>'),
375
+ elementMarker((element) => hasAttributes(element, 'data-sveltekit-reload'), '<a data-sveltekit-reload></a>'),
376
+ ], 'Static markup contains SvelteKit-specific navigation attributes.'),
377
+ htmlEvidence([resourceMarker((url) => /\/_app\/immutable\//iu.test(url.pathname), '/_app/immutable/fixture.css')], 'Static asset paths use SvelteKit build conventions.', 'medium'),
370
378
  ],
371
379
  },
372
380
  {
373
381
  id: 'astro', name: 'Astro', category: 'web framework',
374
382
  evidence: [
375
- htmlEvidence(['<astro-island', '<astro-slot'], 'Static markup contains Astro component-island elements.'),
376
- htmlEvidence(['href="/_astro/', 'src="/_astro/'], 'Static asset paths use Astro build conventions.', 'medium'),
383
+ htmlEvidence([
384
+ elementMarker((element) => element.name === 'astro-island', '<astro-island></astro-island>'),
385
+ elementMarker((element) => element.name === 'astro-slot', '<astro-slot></astro-slot>'),
386
+ ], 'Static markup contains Astro component-island elements.'),
387
+ htmlEvidence([resourceMarker((url) => /\/_astro\//iu.test(url.pathname), '/_astro/fixture.css')], 'Static asset paths use Astro build conventions.', 'medium'),
377
388
  ],
378
389
  },
379
390
  {
@@ -461,28 +472,57 @@ const TECHNOLOGY_SIGNATURE_CATALOGUE = Object.freeze(TECHNOLOGY_SIGNATURES.map((
461
472
  confidence: evidence.confidence,
462
473
  }))),
463
474
  })));
464
- function analyzeWebsiteTechnology(input = {}) {
475
+ function technologyHtmlAnalysis(input) {
476
+ return input.htmlAnalysis ?? analyzeStaticHtml(input.html, {
477
+ ...(typeof input.documentOrigin === 'string' ? { baseUrl: input.documentOrigin } : {}),
478
+ });
479
+ }
480
+ // These are fixed semantic reconstructions, not copies of observed markup.
481
+ // Keeping them beside the predicates removes a second keyword catalogue from
482
+ // the contribution tools. Independent structural fixtures test the predicates.
483
+ function minimiseTechnologyMarkup(input) {
484
+ const analysis = technologyHtmlAnalysis(input);
485
+ if (analysis.inputLimitReached || analysis.tagLimitReached) {
486
+ throw new TypeError('Technology review markup exceeded the complete structural-analysis boundary.');
487
+ }
488
+ const context = createTechnologyMarkupContext(analysis, input.effectiveBaseUrl, input.documentOrigin);
489
+ const fragments = TECHNOLOGY_SIGNATURES.flatMap((signature) => signature.evidence.flatMap((evidence) => ((evidence.markupRules ?? []).flatMap((rule) => rule.matches(context)
490
+ ? [rule.embedded?.(context) ? rule.embeddedReviewMarkup : rule.reviewMarkup]
491
+ : []))));
492
+ return [...new Set(fragments)].join('');
493
+ }
494
+ function reconstructTechnologyHtmlEvidence(id, description, role) {
495
+ const rules = TECHNOLOGY_SIGNATURES.find((signature) => signature.id === id)?.evidence
496
+ .find((evidence) => evidence.source === 'static HTML' && evidence.description === description)?.markupRules;
497
+ const markup = role === 'embedded_dependency'
498
+ ? rules?.find((rule) => rule.embeddedReviewMarkup)?.embeddedReviewMarkup
499
+ : rules?.[0]?.reviewMarkup;
500
+ if (!markup)
501
+ throw new TypeError('The retained static clue has no current structural reconstruction.');
502
+ return markup;
503
+ }
504
+ async function analyzeWebsiteTechnology(input = {}) {
505
+ input.signal?.throwIfAborted();
465
506
  // Existing direct callers pass minimised, already-derived page evidence rather
466
507
  // than the page body. The real collector declares false for header-only
467
508
  // responses, while an explicit malformed declaration also fails closed.
468
509
  const htmlAvailable = input.htmlAvailable === undefined
469
510
  ? true
470
511
  : input.htmlAvailable === true;
471
- const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
472
- const browserLibraryProfile = htmlAvailable ? analyzeBrowserLibraries({
512
+ const htmlAnalysis = htmlAvailable ? technologyHtmlAnalysis(input) : analyzeStaticHtml('');
513
+ const browserLibraryProfile = htmlAvailable ? await analyzeBrowserLibraries({
473
514
  htmlAnalysis,
474
515
  observedAt: input.observedAt,
475
516
  sourceTruncated: input.sourceTruncated,
517
+ ...(input.signal ? { signal: input.signal } : {}),
476
518
  }) : null;
477
519
  const context = {
478
- html: htmlAnalysis.markup,
479
- applicationHtml: '',
480
- generator: boundedLowercase(input.generator, MAX_GENERATOR_INPUT),
520
+ ...createTechnologyMarkupContext(htmlAnalysis, input.effectiveBaseUrl, input.documentOrigin),
521
+ generator: htmlAvailable ? boundedLowercase(input.generator, MAX_GENERATOR_INPUT) : '',
481
522
  httpServer: boundedLowercase(input.httpServer, MAX_SERVER_INPUT),
482
- resourceHosts: normalizedResourceHosts(input.resourceOrigins),
523
+ resourceHosts: htmlAvailable ? normalizedResourceHosts(input.resourceOrigins) : new Set(),
483
524
  responseHeaders: normalizedResponseHeaders(input.responseHeaders),
484
525
  };
485
- context.applicationHtml = applicationMarkup(context.html, context.resourceHosts, input.effectiveBaseUrl, input.documentOrigin);
486
526
  const findings = [];
487
527
  for (const signature of TECHNOLOGY_SIGNATURES) {
488
528
  const matched = signature.evidence
@@ -534,7 +574,7 @@ function analyzeWebsiteTechnology(input = {}) {
534
574
  if (htmlAnalysis.inputLimitReached)
535
575
  limitations.push(`Only the first ${MAX_TECHNOLOGY_HTML_CHARS} HTML characters were evaluated.`);
536
576
  if (htmlAnalysis.tagLimitReached)
537
- limitations.push(`Technology matching reached the ${MAX_TECHNOLOGY_TAGS}-tag or ${MAX_TECHNOLOGY_TAG_LENGTH}-character tag boundary.`);
577
+ limitations.push('Static HTML construction or attribute projection reached a bound; later technology indicators may be missing.');
538
578
  if (findingLimitReached)
539
579
  limitations.push(`Only the first ${MAX_TECHNOLOGY_FINDINGS} technology findings were retained.`);
540
580
  return {
@@ -561,4 +601,4 @@ function analyzeWebsiteTechnology(input = {}) {
561
601
  browserLibraryProfile,
562
602
  };
563
603
  }
564
- export { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, MAX_TECHNOLOGY_HTML_CHARS, MAX_TECHNOLOGY_TAGS, PASSIVE_TECHNOLOGY_HEADER_NAMES, TECHNOLOGY_CATEGORIES, TECHNOLOGY_EVIDENCE_SOURCES, TECHNOLOGY_PROFILE_VERSION, TECHNOLOGY_SIGNATURE_CATALOGUE, analyzeWebsiteTechnology, captureTechnologyResponseHeaders, };
604
+ export { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_TECHNOLOGY_EVIDENCE_DESCRIPTION_LENGTH, MAX_TECHNOLOGY_FINDINGS, MAX_TECHNOLOGY_HTML_CHARS, MAX_TECHNOLOGY_TAGS, PASSIVE_TECHNOLOGY_HEADER_NAMES, TECHNOLOGY_CATEGORIES, TECHNOLOGY_EVIDENCE_SOURCES, TECHNOLOGY_PROFILE_VERSION, TECHNOLOGY_SIGNATURE_CATALOGUE, analyzeWebsiteTechnology, captureTechnologyResponseHeaders, minimiseTechnologyMarkup, reconstructTechnologyHtmlEvidence, };
@@ -29,7 +29,10 @@ const LINE_NOT_FOUND_PATTERNS = Object.freeze([
29
29
  const NZ_NOT_FOUND_RE = /^[ \t]*query_status[ \t]*:[ \t]*220(?:\s|$)/im;
30
30
  const NZ_POSITIVE_RE = /^[ \t]*query_status[ \t]*:[ \t]*(?:200|210)(?:\s|$)/im;
31
31
  const NZ_TEMPORARY_FAILURE_RE = /^[ \t]*query_status[ \t]*:[ \t]*4\d{2}(?:\s|$)/im;
32
- const RATE_LIMIT_LINE_RE = /^[ \t]*(?:[%#*;>-]+[ \t]*)?(?:(?:error|status)[ \t:.-]+)?(?:whois[ \t]+limit[ \t]+exceeded|query[ \t]+(?:rate[ \t-]*)?limit[ \t]+exceeded|(?:request|query)[ \t]+limit[ \t]+(?:exceeded|reached)|rate[ \t-]*limit(?:[ \t]+exceeded)?|too[ \t]+many[ \t]+(?:requests|queries)|quota[ \t]+exceeded|number[ \t]+of[^\r\n]{0,120}[ \t]+exceeded|(?:requests?|queries?)[ \t]+(?:are[ \t]+)?throttled|throttled|(?:service[ \t]+)?temporarily[ \t]+unavailable|(?:please[ \t]+)?try[ \t]+again[ \t]+later|please[ \t]+wait)\b[^\r\n]{0,240}$/im;
32
+ // A refusal describes this response; a footer describing permitted query
33
+ // frequency does not. Keep explicit refusals ahead of echoed domain fields.
34
+ const RATE_LIMIT_LINE_RE = /^[ \t]*(?:[%#*;>-]+[ \t]*)?(?:(?:error|status)[ \t:.-]+)?(?:whois[ \t]+limit[ \t]+exceeded|query[ \t]+(?:rate[ \t-]*)?limit[ \t]+exceeded|(?:request|query)[ \t]+limit[ \t]+(?:exceeded|reached)|rate[ \t-]*limit(?:ed|[ \t]+(?:exceeded|reached))|too[ \t]+many[ \t]+(?:requests|queries)|quota[ \t]+exceeded|number[ \t]+of[^\r\n]{0,120}[ \t]+exceeded|(?:requests?|queries?)[ \t]+(?:are[ \t]+)?throttled|throttled|(?:service[ \t]+)?temporarily[ \t]+unavailable)\b[^\r\n]{0,240}$/im;
35
+ const RETRY_LINE_RE = /^[ \t]*(?:[%#*;>-]+[ \t]*)?(?:(?:error|status)[ \t:.-]+)?(?:rate[ \t-]*limit|(?:please[ \t]+)?try[ \t]+again[ \t]+later|please[ \t]+wait(?:[ \t]+\d{1,6}[ \t]+(?:seconds?|minutes?|hours?))?(?:[ \t]+(?:and[ \t]+)?(?:retry|try[ \t]+again)(?:[ \t]+later)?)?)[.!]?[ \t]*$/im;
33
36
  const POSITIVE_REGISTRATION_RE = /^[ \t*]*(?:Domain(?:[ \t]+Name)?|domainname|Registrar|Registrar WHOIS Server|Creation Date|Created(?: On)?|Registry Expiry Date|Registered(?: On)?|Name Server|nserver|Sponsoring Registrar)[ \t.]*:[ \t]*\S/im;
34
37
  const POSITIVE_BRACKET_RE = /\[(?:Domain Name|Registrant|Name Server)\][ \t]*\S/i;
35
38
  function hasSectionedRegistrationEvidence(text) {
@@ -53,7 +56,7 @@ function classifyHopEvidence(hop, index) {
53
56
  const text = hop.response || '';
54
57
  if (!text.trim())
55
58
  return 'inconclusive';
56
- if (RATE_LIMIT_LINE_RE.test(text))
59
+ if (RATE_LIMIT_LINE_RE.test(text) || RETRY_LINE_RE.test(text))
57
60
  return 'rate_limited';
58
61
  if (NZ_TEMPORARY_FAILURE_RE.test(text))
59
62
  return 'rate_limited';
@@ -131,3 +134,12 @@ export function analyzeWhoisChainAuthority(chain) {
131
134
  : 'partial',
132
135
  };
133
136
  }
137
+ /** Collection status shared by final and incremental Lookup presentation. */
138
+ export function whoisCollectionStatus(chain) {
139
+ const source = normalizeWhoisChain(chain);
140
+ if (!source.length || source[0]?.error)
141
+ return 'error';
142
+ if (source.length === 1)
143
+ return 'unsupported';
144
+ return analyzeWhoisChainAuthority(source).chainStatus;
145
+ }
@@ -3,12 +3,12 @@
3
3
  // bounded referrals.
4
4
  import { domainToUnicode } from 'node:url';
5
5
  import { cached } from "./lookup-cache.mjs";
6
+ import { MAX_WHOIS_QUERY_HOPS } from "./whois-contracts.mjs";
6
7
  import { registryCapabilityFor, registryServiceAdmissionFor, } from "./registry-capabilities.mjs";
7
8
  import { whoisQuery } from "./whois-transport.mjs";
8
9
  const IANA_WHOIS = 'whois.iana.org';
9
10
  const WHOIS_HOP_DEADLINE_MS = 12_000;
10
11
  const WHOIS_CHAIN_DEADLINE_MS = 25_000;
11
- const MAX_WHOIS_QUERY_HOPS = 6;
12
12
  function incompleteReferralHop(server, reason) {
13
13
  return {
14
14
  server,
@@ -58,6 +58,7 @@ function extractReferral(whoisText) {
58
58
  return null;
59
59
  }
60
60
  export async function buildWhoisChainUncached(query, options = {}) {
61
+ options.signal?.throwIfAborted();
61
62
  const admission = registryServiceAdmissionFor(query, 'whois');
62
63
  if (admission && !admission.allowed) {
63
64
  return [{
@@ -77,6 +78,7 @@ export async function buildWhoisChainUncached(query, options = {}) {
77
78
  let currentServer = IANA_WHOIS;
78
79
  const startedAt = now();
79
80
  for (let hop = 0; hop < MAX_WHOIS_QUERY_HOPS; hop += 1) {
81
+ options.signal?.throwIfAborted();
80
82
  if (visited.has(currentServer.toLowerCase())) {
81
83
  chain.push(incompleteReferralHop(currentServer, 'referral_loop'));
82
84
  break;
@@ -101,12 +103,15 @@ export async function buildWhoisChainUncached(query, options = {}) {
101
103
  responseText = await queryWhois(currentServer, transport.query, {
102
104
  timeoutMs: Math.min(10_000, remainingMs),
103
105
  totalDeadlineMs: Math.min(WHOIS_HOP_DEADLINE_MS, remainingMs),
106
+ ...(options.signal ? { signal: options.signal } : {}),
104
107
  onAddressSelected: (selected) => {
105
108
  address = selected;
106
109
  },
107
110
  });
111
+ options.signal?.throwIfAborted();
108
112
  }
109
113
  catch (cause) {
114
+ options.signal?.throwIfAborted();
110
115
  chain.push({
111
116
  server: currentServer,
112
117
  queriedAt,
@@ -140,6 +145,8 @@ export async function buildWhoisChainUncached(query, options = {}) {
140
145
  }
141
146
  return chain;
142
147
  }
143
- export async function buildWhoisChain(query) {
148
+ export async function buildWhoisChain(query, options = {}) {
149
+ if (options.signal)
150
+ return buildWhoisChainUncached(query, options);
144
151
  return cached(`whois:${query.toLowerCase()}`, () => buildWhoisChainUncached(query));
145
152
  }
@@ -176,7 +176,7 @@ function applyWhoisCommonFormats(text, isRootHop, dialects, context) {
176
176
  }) === 'capped')
177
177
  break;
178
178
  }
179
- for (const m of text.matchAll(/\[Name Server\][ \t]*([a-zA-Z0-9.\-]+)/gi)) {
179
+ for (const m of text.matchAll(/\[Name Server\][ \t]*([^\s]+)/gi)) {
180
180
  if (addBoundedWhoisSetValue(nameservers, m[1], {
181
181
  maxEntries: MAX_WHOIS_NAMESERVERS, maxLength: 253,
182
182
  field: 'nameservers', truncatedFields,
@@ -188,16 +188,16 @@ function applyWhoisCommonFormats(text, isRootHop, dialects, context) {
188
188
  // on every hop; "nserver:"/"Host Name:"/"DNS:" are real per-domain
189
189
  // labels on some registries (e.g. .ru, .kr, .mx) but only once we're
190
190
  // past the root hop, for the same reason as the field patterns above.
191
- const nsLinePatterns = [/^[ \t*]*Name Server[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim];
191
+ const nsLinePatterns = [/^[ \t*]*Name Server[ \t.]*:[ \t]*([^\s]+)/gim];
192
192
  if (!isRootHop) {
193
- nsLinePatterns.push(/^[ \t*]*nserver[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim, /^[ \t*]*Nameserver[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim, /^[ \t*]*Host Name[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim, /^[ \t]*ns_name_\d{2}[ \t]*:[ \t]*([a-zA-Z0-9.\-]+)/gim);
193
+ nsLinePatterns.push(/^[ \t*]*nserver[ \t.]*:[ \t]*([^\s]+)/gim, /^[ \t*]*Nameserver[ \t.]*:[ \t]*([^\s]+)/gim, /^[ \t*]*Host Name[ \t.]*:[ \t]*([^\s]+)/gim, /^[ \t]*ns_name_\d{2}[ \t]*:[ \t]*([^\s]+)/gim);
194
194
  // Punktum dk uses `DNS: example.dk` for the queried domain, then
195
195
  // `Hostname:` inside its nameserver section. Other supported
196
196
  // registries use `DNS:` for an actual nameserver, so switch aliases
197
197
  // only when the full .dk marker set is present.
198
198
  nsLinePatterns.push(isPunktum
199
- ? /^[ \t*]*Hostname[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim
200
- : /^[ \t*]*DNS[ \t.]*:[ \t]*([a-zA-Z0-9.\-]+)/gim);
199
+ ? /^[ \t*]*Hostname[ \t.]*:[ \t]*([^\s]+)/gim
200
+ : /^[ \t*]*DNS[ \t.]*:[ \t]*([^\s]+)/gim);
201
201
  }
202
202
  for (const re of nsLinePatterns) {
203
203
  for (const m of text.matchAll(re)) {
@@ -1,3 +1,4 @@
1
1
  // Stable normalized WHOIS contracts. Parsing and transport remain separate so
2
2
  // consumers can depend on the record shape without importing network code.
3
- export {};
3
+ export const MAX_WHOIS_STATUSES = 100;
4
+ export const MAX_WHOIS_QUERY_HOPS = 6;
@@ -263,8 +263,8 @@ function applyWhoisDialects(text, isRootHop, context) {
263
263
  assignBoundedWhoisMatch(text, fields, 'updatedDate', /^[ \t]*Last modified[ \t.]*:[ \t]*(.+)$/im, truncatedFields);
264
264
  assignBoundedWhoisMatch(text, fields, 'registrar', /^[ \t]*Current Registar[ \t]*:[ \t]*(.+)$/im, truncatedFields);
265
265
  for (const pattern of [
266
- /^[ \t]*Primary server[ \t.]*:[ \t]*([a-zA-Z0-9.-]+)/gim,
267
- /^[ \t]*Secondary server[ \t.]*:[ \t]*([a-zA-Z0-9.-]+)/gim,
266
+ /^[ \t]*Primary server[ \t.]*:[ \t]*([^\s]+)/gim,
267
+ /^[ \t]*Secondary server[ \t.]*:[ \t]*([^\s]+)/gim,
268
268
  ]) {
269
269
  for (const match of text.matchAll(pattern)) {
270
270
  const result = addBoundedWhoisSetValue(nameservers, match[1], {
@@ -1,7 +1,7 @@
1
1
  // Bounded primitives and generic field patterns shared by the WHOIS parser.
2
2
  import { boundedWhoisValue, whoisFieldLimit, } from "./whois-values.mjs";
3
+ import { MAX_WHOIS_STATUSES } from "./whois-contracts.mjs";
3
4
  const MAX_WHOIS_NAMESERVERS = 200;
4
- const MAX_WHOIS_STATUSES = 100;
5
5
  const WHOIS_DOMAIN_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;
6
6
  // ---------------------------------------------------------------------------
7
7
  // WHOIS response parsing (merges the referral chain into readable fields)
@@ -21,6 +21,13 @@ function assignBoundedWhoisMatch(text, fields, key, pattern, truncatedFields) {
21
21
  }
22
22
  function addBoundedWhoisSetValue(set, rawValue, { maxEntries, maxLength, field, truncatedFields, }) {
23
23
  const bounded = boundedWhoisValue(rawValue, maxLength);
24
+ if (bounded.truncated)
25
+ truncatedFields.add(field);
26
+ if (field === 'nameservers' && (bounded.truncated || !bounded.value
27
+ || !WHOIS_DOMAIN_RE.test(bounded.value.replace(/\.$/u, '')))) {
28
+ truncatedFields.add(field);
29
+ return 'ignored';
30
+ }
24
31
  if (!bounded.value || set.has(bounded.value))
25
32
  return 'ignored';
26
33
  if (set.size >= maxEntries) {
@@ -16,38 +16,45 @@ function errorMessage(value, fallback) {
16
16
  // A referral hostname is upstream-controlled, so resolve and validate its
17
17
  // addresses before connecting. Connect to the selected address directly to
18
18
  // prevent a second DNS lookup from bypassing the public-address decision.
19
- function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, } = {}) {
19
+ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, createConnection = net.createConnection, signal, } = {}) {
20
20
  return new Promise((resolve, reject) => {
21
+ signal?.throwIfAborted();
21
22
  const socket = createConnection({ host: address, port }, () => {
22
- socket.write(query + '\r\n');
23
+ if (!signal?.aborted)
24
+ socket.write(query + '\r\n');
23
25
  });
24
26
  const chunks = [];
25
27
  let totalBytes = 0;
26
28
  let settled = false;
27
29
  const deadline = setTimeout(() => {
28
- settled = true;
30
+ settle(reject, new Error(`WHOIS request to ${server} exceeded the total time limit`));
29
31
  socket.destroy();
30
- reject(new Error(`WHOIS request to ${server} exceeded the total time limit`));
31
32
  }, totalDeadlineMs);
32
33
  function settle(fn, value) {
33
34
  if (settled)
34
35
  return;
35
36
  settled = true;
36
37
  clearTimeout(deadline);
38
+ signal?.removeEventListener('abort', abort);
37
39
  fn(value);
38
40
  }
41
+ const abort = () => { settle(reject, signal.reason); socket.destroy(); };
39
42
  socket.setTimeout(Math.min(timeoutMs, totalDeadlineMs));
40
43
  socket.on('data', (chunk) => {
44
+ if (settled)
45
+ return;
41
46
  const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
42
47
  totalBytes += buffer.length;
43
48
  if (totalBytes > MAX_WHOIS_BYTES) {
44
- socket.destroy();
45
49
  settle(reject, new Error(`WHOIS response from ${server} exceeded ${MAX_WHOIS_BYTES} bytes`));
50
+ socket.destroy();
46
51
  return;
47
52
  }
48
53
  chunks.push(buffer);
49
54
  });
50
55
  const settleResponse = () => {
56
+ if (settled)
57
+ return;
51
58
  try {
52
59
  const responseText = new TextDecoder('utf-8', { fatal: true })
53
60
  .decode(Buffer.concat(chunks, totalBytes));
@@ -60,13 +67,17 @@ function queryWhoisAddress(address, server, query, { port = 43, timeoutMs = 1000
60
67
  socket.on('end', settleResponse);
61
68
  socket.on('close', settleResponse);
62
69
  socket.on('timeout', () => {
63
- socket.destroy();
64
70
  settle(reject, new Error(`WHOIS request to ${server} timed out`));
71
+ socket.destroy();
65
72
  });
66
73
  socket.on('error', (error) => settle(reject, error));
74
+ signal?.addEventListener('abort', abort, { once: true });
75
+ if (signal?.aborted)
76
+ abort();
67
77
  });
68
78
  }
69
- async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, } = {}) {
79
+ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDeadlineMs = WHOIS_HOP_DEADLINE_MS, resolveAddresses = resolvePublicAddresses, queryAddress = queryWhoisAddress, now = Date.now, onAddressSelected = (_address) => { }, signal, } = {}) {
80
+ signal?.throwIfAborted();
70
81
  const startedAt = now();
71
82
  let resolutionTimer;
72
83
  const records = await Promise.race([
@@ -75,10 +86,12 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
75
86
  resolutionTimer = setTimeout(() => reject(new Error(`WHOIS request to ${server} timed out during DNS resolution`)), totalDeadlineMs);
76
87
  }),
77
88
  ]).finally(() => clearTimeout(resolutionTimer));
89
+ signal?.throwIfAborted();
78
90
  const candidates = records.slice(0, MAX_WHOIS_ADDRESSES);
79
91
  const failures = [];
80
92
  let attempts = 0;
81
93
  for (const { address } of candidates) {
94
+ signal?.throwIfAborted();
82
95
  const remainingMs = totalDeadlineMs - (now() - startedAt);
83
96
  if (remainingMs <= 0)
84
97
  break;
@@ -88,7 +101,9 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
88
101
  port,
89
102
  timeoutMs: Math.min(timeoutMs, remainingMs),
90
103
  totalDeadlineMs: remainingMs,
104
+ ...(signal ? { signal } : {}),
91
105
  });
106
+ signal?.throwIfAborted();
92
107
  try {
93
108
  onAddressSelected(address);
94
109
  }
@@ -98,6 +113,7 @@ async function whoisQuery(server, query, { port = 43, timeoutMs = 10000, totalDe
98
113
  return response;
99
114
  }
100
115
  catch (error) {
116
+ signal?.throwIfAborted();
101
117
  failures.push(errorMessage(error, 'connection failed').slice(0, 200));
102
118
  }
103
119
  }