@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
@@ -6,11 +6,28 @@
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";
13
+ const TECHNOLOGY_CATEGORIES = Object.freeze([
14
+ 'application runtime',
15
+ 'content management',
16
+ 'commerce',
17
+ 'site builder',
18
+ 'web framework',
19
+ 'static site generator',
20
+ 'web server',
21
+ 'delivery platform',
22
+ ]);
23
+ const TECHNOLOGY_EVIDENCE_SOURCES = Object.freeze([
24
+ 'generator metadata',
25
+ 'static HTML',
26
+ 'resource origin',
27
+ 'HTTP server header',
28
+ 'passive response header',
29
+ ]);
12
30
  const MAX_TECHNOLOGY_HTML_CHARS = MAX_STATIC_HTML_CHARS;
13
- const MAX_TECHNOLOGY_TAG_LENGTH = MAX_TAG_LENGTH;
14
31
  const MAX_RESOURCE_ORIGINS = 30;
15
32
  const MAX_GENERATOR_INPUT = 160;
16
33
  const MAX_SERVER_INPUT = 240;
@@ -98,12 +115,16 @@ function generatorEvidence(pattern, description) {
98
115
  matches: ({ generator }) => pattern.test(generator),
99
116
  };
100
117
  }
101
- function htmlEvidence(markers, description, confidence = 'high') {
118
+ function htmlEvidence(markupRules, description, confidence = 'high') {
102
119
  return {
103
120
  source: 'static HTML',
104
121
  description,
105
122
  confidence,
106
- matches: ({ html }) => markers.some((marker) => html.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))
126
+ ? undefined
127
+ : 'embedded_dependency',
107
128
  };
108
129
  }
109
130
  function resourceEvidence(hosts, description) {
@@ -133,7 +154,7 @@ function serverEvidence(pattern, description, confidence = 'high') {
133
154
  function responseHeaderEvidence(name, pattern, description, confidence = 'high') {
134
155
  return {
135
156
  source: 'passive response header',
136
- description,
157
+ description: `${name}: ${description}`,
137
158
  confidence,
138
159
  matches: ({ responseHeaders }) => {
139
160
  if (!responseHeaders.has(name))
@@ -142,7 +163,10 @@ function responseHeaderEvidence(name, pattern, description, confidence = 'high')
142
163
  },
143
164
  };
144
165
  }
145
- function evidenceRole(signature, evidence) {
166
+ function evidenceRole(signature, evidence, context) {
167
+ const contextualRole = context ? evidence.roleFor?.(context) : undefined;
168
+ if (contextualRole)
169
+ return contextualRole;
146
170
  if (evidence.role)
147
171
  return evidence.role;
148
172
  if (evidence.source === 'resource origin')
@@ -159,14 +183,20 @@ const TECHNOLOGY_SIGNATURES = [
159
183
  id: 'wordpress', name: 'WordPress', category: 'content management',
160
184
  evidence: [
161
185
  generatorEvidence(/^wordpress(?:\s|$)/i, 'Generator metadata identifies WordPress.'),
162
- htmlEvidence(['/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'),
163
190
  ],
164
191
  },
165
192
  {
166
193
  id: 'drupal', name: 'Drupal', category: 'content management',
167
194
  evidence: [
168
195
  generatorEvidence(/^drupal(?:\s|$)/i, 'Generator metadata identifies Drupal.'),
169
- 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.'),
170
200
  responseHeaderEvidence('x-drupal-cache', null, 'A Drupal-specific cache response header was observed.', 'medium'),
171
201
  ],
172
202
  },
@@ -178,7 +208,10 @@ const TECHNOLOGY_SIGNATURES = [
178
208
  id: 'ghost', name: 'Ghost', category: 'content management',
179
209
  evidence: [
180
210
  generatorEvidence(/^ghost(?:\s|$)/i, 'Generator metadata identifies Ghost.'),
181
- 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.'),
182
215
  ],
183
216
  },
184
217
  {
@@ -195,7 +228,10 @@ const TECHNOLOGY_SIGNATURES = [
195
228
  id: 'shopify', name: 'Shopify', category: 'commerce',
196
229
  requiresNonResourceEvidence: true,
197
230
  evidence: [
198
- 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.'),
199
235
  resourceEvidence(['cdn.shopify.com'], 'A retained resource origin uses the Shopify content network.'),
200
236
  responseHeaderEvidence('x-shopify-stage', null, 'A Shopify-specific platform response header was observed.', 'medium'),
201
237
  responseHeaderEvidence('x-sorting-hat-podid', null, 'A Shopify-specific routing response header was observed.', 'medium'),
@@ -216,33 +252,43 @@ const TECHNOLOGY_SIGNATURES = [
216
252
  {
217
253
  id: 'adobe-commerce-magento', name: 'Adobe Commerce / Magento Open Source', category: 'commerce',
218
254
  evidence: [
219
- 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.'),
220
259
  ],
221
260
  },
222
261
  {
223
262
  id: 'bigcommerce', name: 'BigCommerce', category: 'commerce',
224
263
  minimumEvidenceMatches: 2,
264
+ allowEmbeddedOnly: true,
225
265
  evidence: [
226
- htmlEvidence(['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'),
227
270
  resourcePatternEvidence(/^cdn\d+\.bigcommerce\.com$/i, 'A retained resource origin uses BigCommerce storefront delivery infrastructure.'),
228
271
  ],
229
272
  },
230
273
  {
231
274
  id: 'woocommerce', name: 'WooCommerce', category: 'commerce',
232
275
  evidence: [
233
- 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.'),
234
277
  ],
235
278
  },
236
279
  {
237
280
  id: 'opencart', name: 'OpenCart', category: 'commerce',
238
281
  evidence: [
239
- 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.'),
240
286
  ],
241
287
  },
242
288
  {
243
289
  id: 'prestashop', name: 'PrestaShop', category: 'commerce',
244
290
  evidence: [
245
- 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.'),
246
292
  ],
247
293
  },
248
294
  {
@@ -250,24 +296,29 @@ const TECHNOLOGY_SIGNATURES = [
250
296
  requiresNonResourceEvidence: true,
251
297
  evidence: [
252
298
  generatorEvidence(/^wix(?:\.com)?(?:\s|$)/i, 'Generator metadata identifies Wix.'),
253
- 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'),
254
300
  resourceEvidence(['static.parastorage.com', 'wixstatic.com'], 'A retained resource origin uses Wix delivery infrastructure.'),
255
301
  ],
256
302
  },
257
303
  {
258
304
  id: 'squarespace', name: 'Squarespace', category: 'site builder',
259
305
  requiresNonResourceEvidence: true,
306
+ allowEmbeddedOnly: true,
260
307
  evidence: [
261
308
  generatorEvidence(/^squarespace(?:\s|$)/i, 'Generator metadata identifies Squarespace.'),
262
- htmlEvidence(['squarespace-context'], 'Static markup contains a Squarespace-specific document marker.', 'medium'),
263
- 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'),
264
312
  ],
265
313
  },
266
314
  {
267
315
  id: 'webflow', name: 'Webflow', category: 'site builder',
268
316
  evidence: [
269
317
  generatorEvidence(/^webflow(?:\s|$)/i, 'Generator metadata identifies Webflow.'),
270
- 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.'),
271
322
  ],
272
323
  },
273
324
  {
@@ -275,7 +326,7 @@ const TECHNOLOGY_SIGNATURES = [
275
326
  requiresNonResourceEvidence: true,
276
327
  evidence: [
277
328
  generatorEvidence(/^framer(?:\s|$)/i, 'Generator metadata identifies Framer.'),
278
- 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.'),
279
330
  resourceEvidence(['framerusercontent.com'], 'A retained resource origin uses Framer delivery infrastructure.'),
280
331
  ],
281
332
  },
@@ -283,42 +334,57 @@ const TECHNOLOGY_SIGNATURES = [
283
334
  id: 'weebly', name: 'Weebly', category: 'site builder',
284
335
  requiresNonResourceEvidence: true,
285
336
  evidence: [
286
- 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.'),
287
338
  resourceEvidence(['editmysite.com'], 'A retained resource origin uses Weebly delivery infrastructure.'),
288
339
  ],
289
340
  },
290
341
  {
291
342
  id: 'angular', name: 'Angular', category: 'web framework',
292
- 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.')],
293
344
  },
294
345
  {
295
346
  id: 'aspnet-web-forms', name: 'ASP.NET Web Forms', category: 'web framework',
296
- 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.')],
297
348
  },
298
349
  {
299
350
  id: 'nextjs', name: 'Next.js', category: 'web framework',
300
- 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.')],
301
355
  },
302
356
  {
303
357
  id: 'nuxt', name: 'Nuxt', category: 'web framework',
304
- 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.')],
305
362
  },
306
363
  {
307
364
  id: 'gatsby', name: 'Gatsby', category: 'web framework',
308
- 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.')],
309
369
  },
310
370
  {
311
371
  id: 'sveltekit', name: 'SvelteKit', category: 'web framework',
312
372
  evidence: [
313
- htmlEvidence(['data-sveltekit-preload-data=', 'data-sveltekit-reload='], 'Static markup contains SvelteKit-specific navigation attributes.'),
314
- 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'),
315
378
  ],
316
379
  },
317
380
  {
318
381
  id: 'astro', name: 'Astro', category: 'web framework',
319
382
  evidence: [
320
- htmlEvidence(['<astro-island', '<astro-slot'], 'Static markup contains Astro component-island elements.'),
321
- 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'),
322
388
  ],
323
389
  },
324
390
  {
@@ -406,43 +472,85 @@ const TECHNOLOGY_SIGNATURE_CATALOGUE = Object.freeze(TECHNOLOGY_SIGNATURES.map((
406
472
  confidence: evidence.confidence,
407
473
  }))),
408
474
  })));
409
- 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();
410
506
  // Existing direct callers pass minimised, already-derived page evidence rather
411
507
  // than the page body. The real collector declares false for header-only
412
508
  // responses, while an explicit malformed declaration also fails closed.
413
509
  const htmlAvailable = input.htmlAvailable === undefined
414
510
  ? true
415
511
  : input.htmlAvailable === true;
416
- const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
417
- const browserLibraryProfile = htmlAvailable ? analyzeBrowserLibraries({
512
+ const htmlAnalysis = htmlAvailable ? technologyHtmlAnalysis(input) : analyzeStaticHtml('');
513
+ const browserLibraryProfile = htmlAvailable ? await analyzeBrowserLibraries({
418
514
  htmlAnalysis,
419
515
  observedAt: input.observedAt,
420
516
  sourceTruncated: input.sourceTruncated,
517
+ ...(input.signal ? { signal: input.signal } : {}),
421
518
  }) : null;
422
519
  const context = {
423
- html: htmlAnalysis.markup,
424
- generator: boundedLowercase(input.generator, MAX_GENERATOR_INPUT),
520
+ ...createTechnologyMarkupContext(htmlAnalysis, input.effectiveBaseUrl, input.documentOrigin),
521
+ generator: htmlAvailable ? boundedLowercase(input.generator, MAX_GENERATOR_INPUT) : '',
425
522
  httpServer: boundedLowercase(input.httpServer, MAX_SERVER_INPUT),
426
- resourceHosts: normalizedResourceHosts(input.resourceOrigins),
523
+ resourceHosts: htmlAvailable ? normalizedResourceHosts(input.resourceOrigins) : new Set(),
427
524
  responseHeaders: normalizedResponseHeaders(input.responseHeaders),
428
525
  };
429
526
  const findings = [];
430
527
  for (const signature of TECHNOLOGY_SIGNATURES) {
431
- const matched = signature.evidence.filter((evidence) => evidence.matches(context));
432
- if (matched.length < (signature.minimumEvidenceMatches || 1))
433
- continue;
434
- if (signature.requiresNonResourceEvidence && matched.every((evidence) => evidence.source === 'resource origin'))
528
+ const matched = signature.evidence
529
+ .filter((evidence) => evidence.matches(context))
530
+ .map((evidence) => ({ evidence, role: evidenceRole(signature, evidence, context) }));
531
+ const nonEmbeddedMatches = matched.filter((entry) => entry.role !== 'embedded_dependency');
532
+ const minimumEvidenceMatches = signature.minimumEvidenceMatches || 1;
533
+ const qualifiesForPlatform = matched.length >= minimumEvidenceMatches
534
+ && (minimumEvidenceMatches === 1 || nonEmbeddedMatches.length > 0)
535
+ && (!signature.requiresNonResourceEvidence || nonEmbeddedMatches.length > 0);
536
+ const contextualEmbeddedEvidence = matched.filter((entry) => (entry.role === 'embedded_dependency' && entry.evidence.roleFor !== undefined));
537
+ const selected = qualifiesForPlatform
538
+ ? matched
539
+ : signature.allowEmbeddedOnly
540
+ ? contextualEmbeddedEvidence.slice(0, 1)
541
+ : [];
542
+ if (selected.length === 0)
435
543
  continue;
436
- const matchedEvidence = matched.slice(0, MAX_EVIDENCE_PER_TECHNOLOGY).map((evidence) => ({
544
+ const matchedEvidence = selected.slice(0, MAX_EVIDENCE_PER_TECHNOLOGY).map(({ evidence, role }) => ({
437
545
  source: evidence.source,
438
- role: evidenceRole(signature, evidence),
546
+ role,
439
547
  description: evidence.description,
440
548
  }));
441
549
  findings.push({
442
550
  id: signature.id,
443
551
  name: signature.name,
444
552
  category: signature.category,
445
- confidence: matched.some((evidence) => evidence.confidence === 'high') ? 'high' : 'medium',
553
+ confidence: selected.some(({ evidence }) => evidence.confidence === 'high') ? 'high' : 'medium',
446
554
  roles: TECHNOLOGY_EVIDENCE_ROLE_ORDER.filter((role) => matchedEvidence.some((evidence) => evidence.role === role)),
447
555
  evidence: matchedEvidence,
448
556
  });
@@ -466,7 +574,7 @@ function analyzeWebsiteTechnology(input = {}) {
466
574
  if (htmlAnalysis.inputLimitReached)
467
575
  limitations.push(`Only the first ${MAX_TECHNOLOGY_HTML_CHARS} HTML characters were evaluated.`);
468
576
  if (htmlAnalysis.tagLimitReached)
469
- 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.');
470
578
  if (findingLimitReached)
471
579
  limitations.push(`Only the first ${MAX_TECHNOLOGY_FINDINGS} technology findings were retained.`);
472
580
  return {
@@ -493,4 +601,4 @@ function analyzeWebsiteTechnology(input = {}) {
493
601
  browserLibraryProfile,
494
602
  };
495
603
  }
496
- 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_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) {