@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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 (340) hide show
  1. package/DISCLOSURE +1 -1
  2. package/NOTICE +2 -2
  3. package/cli/argument-values.mjs +55 -0
  4. package/cli/arguments.mjs +24 -470
  5. package/cli/artifact-validation/case-response.mjs +67 -9
  6. package/cli/artifact-validation/lookup-evidence.mjs +18 -4
  7. package/cli/artifact-validation/signed-review.mjs +61 -64
  8. package/cli/artifact-verify.mjs +11 -2
  9. package/cli/assurance-arguments.mjs +56 -0
  10. package/cli/assurance-command-definitions.mjs +179 -0
  11. package/cli/assurance-command-runner.mjs +1 -1
  12. package/cli/bounded-json.mjs +1 -1
  13. package/cli/bulk-checkpoint.mjs +1 -1
  14. package/cli/bulk-command-runner.mjs +6 -8
  15. package/cli/bulk.mjs +2 -3
  16. package/cli/case-command.mjs +99 -6
  17. package/cli/case-pack.mjs +19 -4
  18. package/cli/ci-report.mjs +6 -3
  19. package/cli/collection-arguments.mjs +123 -0
  20. package/cli/collection-command-definitions.mjs +330 -0
  21. package/cli/collection-preflight.mjs +8 -1
  22. package/cli/command-argument-grammar.mjs +8 -210
  23. package/cli/command-definition.mjs +330 -0
  24. package/cli/command-reference.mjs +55 -1388
  25. package/cli/command-seeds.mjs +18 -0
  26. package/cli/compare.mjs +1 -1
  27. package/cli/config-profile.mjs +1 -1
  28. package/cli/ct-event-intake.mjs +4 -4
  29. package/cli/discover.mjs +2 -2
  30. package/cli/discovery-observation-snapshot.mjs +2 -2
  31. package/cli/discovery-scan-command-runner.mjs +7 -9
  32. package/cli/discovery-scan.mjs +1 -1
  33. package/cli/discovery-snapshot.mjs +2 -2
  34. package/cli/discovery-workflow.mjs +4 -4
  35. package/cli/domain-control-monitor.mjs +6 -6
  36. package/cli/domain-control-observations.mjs +2 -1
  37. package/cli/domain-feed.mjs +61 -0
  38. package/cli/errors.mjs +5 -6
  39. package/cli/evidence-arguments.mjs +46 -0
  40. package/cli/evidence-command-definitions.mjs +69 -0
  41. package/cli/evidence-command-runner.mjs +2 -3
  42. package/cli/evidence-signing.mjs +3 -2
  43. package/cli/external-observation-mapping.mjs +2 -2
  44. package/cli/formatters/json.mjs +1 -13
  45. package/cli/formatters/terminal-command-formats.mjs +6 -0
  46. package/cli/formatters/terminal-lookup.mjs +6 -0
  47. package/cli/history-arguments.mjs +39 -0
  48. package/cli/history-command-definitions.mjs +88 -0
  49. package/cli/indicator-set-command.mjs +32 -0
  50. package/cli/inline-command-families.mjs +6 -47
  51. package/cli/inline-command-runner.mjs +4 -31
  52. package/cli/input.mjs +2 -2
  53. package/cli/intake-command.mjs +147 -0
  54. package/cli/interchange-report.mjs +4 -2
  55. package/cli/investigation-artifacts.mjs +3 -4
  56. package/cli/investigation-package-review.mjs +2 -0
  57. package/cli/investigation-plan.mjs +32 -154
  58. package/cli/investigation-recipes.mjs +177 -0
  59. package/cli/investigation-run.mjs +78 -12
  60. package/cli/local-mmdb-review.mjs +220 -17
  61. package/cli/lookalike-calibration.mjs +1 -1
  62. package/cli/lookup-brief.mjs +2 -2
  63. package/cli/lookup-command-runner.mjs +19 -35
  64. package/cli/lookup-diff.mjs +10 -9
  65. package/cli/lookup-plan.mjs +10 -14
  66. package/cli/lookup-reconcile.mjs +6 -5
  67. package/cli/lookup-timeline.mjs +7 -6
  68. package/cli/mail-header-review.mjs +24 -247
  69. package/cli/mail-review.mjs +9 -7
  70. package/cli/network-arguments.mjs +51 -0
  71. package/cli/network-command-definitions.mjs +130 -0
  72. package/cli/network-command-runner.mjs +43 -31
  73. package/cli/offline-evidence-review.mjs +85 -6
  74. package/cli/posture.mjs +1 -1
  75. package/cli/registry-fixture-scaffold.mjs +1 -1
  76. package/cli/retained-artifact-diff.mjs +8 -7
  77. package/cli/review-arguments.mjs +132 -0
  78. package/cli/review-command-definitions.mjs +279 -0
  79. package/cli/review-command-runner.mjs +72 -159
  80. package/cli/risk-calibration.mjs +14 -12
  81. package/cli/runner.mjs +20 -32
  82. package/cli/saved-lookup.mjs +2 -2
  83. package/cli/selected-input-worker.mjs +73 -0
  84. package/cli/sharing-review.mjs +5 -64
  85. package/cli/source-reliability.mjs +2 -2
  86. package/cli/support-arguments.mjs +49 -0
  87. package/cli/support-command-definitions.mjs +209 -0
  88. package/cli/support-command-runner.mjs +4 -4
  89. package/cli/watchlist-review.mjs +43 -0
  90. package/cli/workflow-arguments.mjs +100 -0
  91. package/cli/workflow-command-definitions.mjs +103 -0
  92. package/cli/workflow-command-runner.mjs +40 -1
  93. package/docs/cli-reference.md +27 -0
  94. package/docs/cli.md +322 -4
  95. package/docs/contextual-reviews.md +178 -0
  96. package/docs/infrastructure-observations.md +126 -0
  97. package/docs/offline-intake-context.md +213 -0
  98. package/lib/analyst-taxonomy.mjs +2 -40
  99. package/lib/application-version.mjs +1 -1
  100. package/lib/availability.mjs +13 -789
  101. package/lib/bounded-contract-normalizers.mjs +4 -0
  102. package/lib/bounded-json.mjs +2 -330
  103. package/lib/browser-library-profile.mjs +62 -84
  104. package/lib/candidate-provenance-bounds.mjs +2 -0
  105. package/lib/classify.mjs +1 -1
  106. package/lib/credential-form-attribution.mjs +136 -0
  107. package/lib/credential-surface-profile.mjs +6 -2
  108. package/lib/ct-query.mjs +2 -27
  109. package/lib/ct-response-bounds.mjs +2 -9
  110. package/lib/ct-search.mjs +83 -47
  111. package/lib/dmarc-discovery.mjs +62 -0
  112. package/lib/dns-convergence-review.mjs +21 -8
  113. package/lib/dns-delegation-health.mjs +35 -15
  114. package/lib/dns-inheritance-review.mjs +53 -63
  115. package/lib/dns-intelligence.mjs +27 -11
  116. package/lib/dns-query-coverage.mjs +40 -0
  117. package/lib/domain-assurance.mjs +1 -1
  118. package/lib/domain-change-packet.mjs +12 -13
  119. package/lib/domain-change-review.mjs +32 -14
  120. package/lib/domain-control-flight-recorder.mjs +103 -27
  121. package/lib/domain-control-manifest.mjs +25 -17
  122. package/lib/domain-posture-analysis.mjs +75 -7
  123. package/lib/domain-posture-parsers.mjs +9 -0
  124. package/lib/domain-posture.mjs +45 -25
  125. package/lib/domain-registration.mjs +392 -0
  126. package/lib/evidence-export-privacy.mjs +27 -17
  127. package/lib/evidence-export.mjs +3 -3
  128. package/lib/evidence-report.mjs +5 -1
  129. package/lib/external-intelligence-risk.mjs +2 -124
  130. package/lib/favicon.mjs +13 -4
  131. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  132. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  133. package/lib/generated/retire-browser-catalog.mjs +332 -19
  134. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  135. package/lib/html-signals.mjs +13 -2
  136. package/lib/http-evidence-bounds.mjs +2 -8
  137. package/lib/http-intelligence.mjs +13 -1
  138. package/lib/idn-confusable-policy.mjs +6 -4
  139. package/lib/idn-confusables.mjs +1 -1
  140. package/lib/interchange-fidelity-registry.mjs +10 -0
  141. package/lib/lookup-child-profile-contract.mjs +28 -11
  142. package/lib/lookup-contract-primitives.mjs +1 -1
  143. package/lib/lookup-diagnostics.mjs +1 -23
  144. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  145. package/lib/lookup-progress.mjs +50 -1
  146. package/lib/lookup-response-contract.mjs +70 -40
  147. package/lib/lookup-response.mjs +5 -15
  148. package/lib/lookup-source-progress.mjs +8 -22
  149. package/lib/lookup.mjs +1 -1
  150. package/lib/operation-budget.mjs +4 -0
  151. package/lib/opportunity-scoring.mjs +2 -107
  152. package/lib/outbound-identity.mjs +1 -1
  153. package/lib/page-fingerprints.mjs +18 -1
  154. package/lib/perceptual-hash-comparison.mjs +2 -34
  155. package/lib/perceptual-hash.mjs +36 -10
  156. package/lib/portable-generator.mjs +2 -29
  157. package/lib/project-metadata.mjs +2 -8
  158. package/lib/rdap-client.mjs +11 -7
  159. package/lib/registrable-domain.mjs +2 -56
  160. package/lib/registrar-standing-contract.mjs +1 -1
  161. package/lib/registrar-standing.mjs +9 -13
  162. package/lib/registry-capabilities.mjs +2 -2
  163. package/lib/registry-capability-catalogue.mjs +27 -19
  164. package/lib/risk-calibration-summary.mjs +2 -352
  165. package/lib/risk-scoring.mjs +2 -345
  166. package/lib/scoring-evidence-quality.mjs +2 -107
  167. package/lib/semantic-version.mjs +2 -54
  168. package/lib/service-binding-dns.mjs +46 -2
  169. package/lib/smtp-transport-review.mjs +48 -19
  170. package/lib/sslbl-certificates.generated.mjs +142 -139
  171. package/lib/static-html-analysis.mjs +32 -4
  172. package/lib/structured-data-identity.mjs +1 -1
  173. package/lib/technology-evidence-role.mjs +1 -51
  174. package/lib/threat-intelligence-manifests.mjs +1 -1
  175. package/lib/threat-intelligence-runtime.mjs +1 -1
  176. package/lib/threat-intelligence-targets.mjs +1 -1
  177. package/lib/threat-intelligence-types.mjs +2 -75
  178. package/lib/tls-intelligence.mjs +30 -5
  179. package/lib/typosquat-generator.mjs +11 -2
  180. package/lib/website-enrichment.mjs +404 -0
  181. package/lib/whois-authority.mjs +43 -5
  182. package/lib/whois-chain.mjs +12 -3
  183. package/lib/whois-common-formats.mjs +3 -1
  184. package/lib/whois-contacts.mjs +13 -2
  185. package/lib/whois-normalization.mjs +3 -1
  186. package/lib/whois-parser.mjs +5 -3
  187. package/package.json +15 -14
  188. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  189. package/packages/analysis/bounded-json.mjs +330 -0
  190. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  191. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  192. package/packages/analysis/ct-query.mjs +27 -0
  193. package/packages/analysis/ct-response-bounds.mjs +9 -0
  194. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  195. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  196. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  197. package/packages/analysis/opportunity-scoring.mjs +107 -0
  198. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  199. package/packages/analysis/portable-generator.mjs +29 -0
  200. package/packages/analysis/project-metadata.mjs +8 -0
  201. package/packages/analysis/registrable-domain.mjs +56 -0
  202. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  203. package/packages/analysis/risk-scoring.mjs +348 -0
  204. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  205. package/packages/analysis/semantic-version.mjs +54 -0
  206. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  207. package/packages/cases/case-containment-handoff.mjs +195 -0
  208. package/packages/cases/case-evidence-links.mjs +151 -0
  209. package/packages/cases/case-evidence-model.mjs +89 -12
  210. package/packages/cases/case-evidence-quality.mjs +5 -0
  211. package/packages/cases/case-export-input.mjs +7 -5
  212. package/packages/cases/case-incident-context.mjs +52 -0
  213. package/packages/cases/case-markdown.mjs +11 -0
  214. package/packages/cases/case-migration-model.mjs +40 -24
  215. package/packages/cases/case-object-coverage.mjs +33 -0
  216. package/packages/cases/case-packet-correction.mjs +106 -0
  217. package/packages/cases/case-recheck-model.mjs +124 -36
  218. package/packages/cases/case-record-contracts.mjs +1 -1
  219. package/packages/cases/case-record-core.mjs +1 -1
  220. package/packages/cases/case-record-operations.mjs +111 -118
  221. package/packages/cases/case-record-projection.mjs +23 -4
  222. package/packages/cases/case-record-version-input.mjs +41 -0
  223. package/packages/cases/case-report.mjs +93 -27
  224. package/packages/cases/case-requested-evidence.mjs +146 -0
  225. package/packages/cases/case-response-actions.mjs +159 -1
  226. package/packages/cases/case-response-model.mjs +16 -1
  227. package/packages/cases/case-response-object.mjs +119 -0
  228. package/packages/cases/case-response-outcomes.mjs +108 -20
  229. package/packages/cases/case-response-packet-render.mjs +35 -12
  230. package/packages/cases/case-response-packet.mjs +55 -13
  231. package/packages/cases/case-response-records.mjs +0 -1
  232. package/packages/cases/case-response-review-inputs.mjs +60 -8
  233. package/packages/cases/case-storage-model.mjs +1 -1
  234. package/packages/cases/case-workflow-metadata.mjs +217 -0
  235. package/packages/cases/case-workflow-migration.mjs +43 -0
  236. package/packages/cases/http-summary.mjs +6 -1
  237. package/packages/comparison/favicon-similarity.mjs +1 -1
  238. package/packages/comparison/page-similarity.mjs +1 -1
  239. package/packages/comparison/relationship-evidence.mjs +21 -9
  240. package/packages/comparison/relationship-provenance.mjs +7 -1
  241. package/packages/contracts/analyst-interchange.mjs +35 -15
  242. package/packages/contracts/candidate-watch-review.mjs +54 -0
  243. package/packages/contracts/case-portability.mjs +360 -49
  244. package/packages/contracts/cli-command-semantics.mjs +5 -0
  245. package/packages/contracts/cli-grammar.mjs +6 -0
  246. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  247. package/packages/contracts/context-review.mjs +25 -0
  248. package/packages/contracts/document-intake.mjs +11 -0
  249. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  250. package/packages/contracts/domain-control-manifest.mjs +1 -1
  251. package/packages/contracts/domain-control-monitor.mjs +13 -2
  252. package/packages/contracts/domain-control-review.mjs +36 -15
  253. package/packages/contracts/external-observation-interchange.mjs +96 -28
  254. package/packages/contracts/har-review.mjs +2 -0
  255. package/packages/contracts/identity-events.mjs +4 -0
  256. package/packages/contracts/investigation-run.mjs +2 -0
  257. package/packages/contracts/ip-address.mjs +10 -0
  258. package/packages/contracts/lookup-evidence.mjs +3 -2
  259. package/packages/contracts/mail-authentication.mjs +5 -0
  260. package/packages/contracts/message-intake.mjs +37 -0
  261. package/packages/contracts/offline-comparison.mjs +4 -1
  262. package/packages/contracts/passive-mail.mjs +10 -0
  263. package/packages/contracts/risk-calibration.mjs +44 -29
  264. package/packages/contracts/schema-lifecycle.mjs +3 -1
  265. package/packages/contracts/web-capture.mjs +2 -2
  266. package/packages/contracts/workspace-portability.mjs +144 -28
  267. package/packages/evidence/collection-quality.mjs +67 -0
  268. package/packages/evidence/credential-form-attribution.mjs +60 -0
  269. package/packages/evidence/dns-query-outcome.mjs +72 -0
  270. package/packages/evidence/email-recipient.mjs +18 -0
  271. package/packages/evidence/image-regions.mjs +15 -0
  272. package/packages/evidence/lookup-target.mjs +5 -1
  273. package/packages/evidence/observation.mjs +2 -0
  274. package/packages/evidence/png-pixels.mjs +117 -0
  275. package/packages/evidence/public-address-policy.mjs +83 -0
  276. package/packages/evidence/record-identity.mjs +60 -16
  277. package/packages/evidence/technology-evidence-role.mjs +51 -0
  278. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  279. package/packages/interchange/external-findings-import.mjs +36 -29
  280. package/packages/interchange/json-output.mjs +14 -0
  281. package/packages/interchange/managed-indicator-export.mjs +36 -0
  282. package/packages/interchange/managed-indicator-set.mjs +223 -0
  283. package/packages/interchange/misp-indicator-export.mjs +93 -0
  284. package/packages/interchange/sharing-policy.mjs +56 -0
  285. package/packages/interchange/stix-indicator-export.mjs +129 -0
  286. package/packages/interchange/web-capture-import.mjs +22 -15
  287. package/packages/investigation/acquisition-decision-packet.mjs +79 -1
  288. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  289. package/packages/investigation/capture-coverage.mjs +57 -0
  290. package/packages/investigation/connector-provenance-review.mjs +100 -0
  291. package/packages/investigation/context-review.mjs +35 -0
  292. package/packages/investigation/document-intake.mjs +87 -0
  293. package/packages/investigation/docx-intake.mjs +133 -0
  294. package/packages/investigation/domain-history-review.mjs +132 -0
  295. package/packages/investigation/domain-transition-review.mjs +49 -0
  296. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  297. package/packages/investigation/har-intake.mjs +68 -0
  298. package/packages/investigation/identity-event-intake.mjs +131 -0
  299. package/packages/investigation/identity-incident-review.mjs +39 -0
  300. package/packages/investigation/incident-sequence-review.mjs +58 -0
  301. package/packages/investigation/infrastructure-observation.mjs +234 -0
  302. package/packages/investigation/intake-context.mjs +284 -0
  303. package/packages/investigation/intake-indicators.mjs +116 -0
  304. package/packages/investigation/intake-phones.mjs +155 -0
  305. package/packages/investigation/intake-report.mjs +20 -0
  306. package/packages/investigation/investigation-manifest.mjs +35 -9
  307. package/packages/investigation/link-intake.mjs +209 -0
  308. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  309. package/packages/investigation/mail-authentication-review.mjs +222 -0
  310. package/packages/investigation/mail-header-identity.mjs +209 -0
  311. package/packages/investigation/message-intake.mjs +235 -0
  312. package/packages/investigation/page-behaviour.mjs +113 -0
  313. package/packages/investigation/pdf-intake.mjs +155 -0
  314. package/packages/investigation/platform-continuity-review.mjs +60 -0
  315. package/packages/investigation/qr-intake.mjs +56 -0
  316. package/packages/investigation/requested-action-hints.mjs +11 -0
  317. package/packages/investigation/selected-input-review.mjs +17 -0
  318. package/packages/investigation/storefront-review.mjs +70 -0
  319. package/packages/monitoring/domain-feed.mjs +321 -0
  320. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  321. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  322. package/packages/workspace/brand-profile-model.mjs +48 -4
  323. package/packages/workspace/bulk-review-model.mjs +28 -26
  324. package/packages/workspace/bulk-session-model.mjs +27 -13
  325. package/packages/workspace/campaign-model.mjs +1 -1
  326. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  327. package/packages/workspace/detection-rule-model.mjs +11 -3
  328. package/packages/workspace/hostile-input.mjs +1 -1
  329. package/packages/workspace/investigation-guide.mjs +2 -2
  330. package/packages/workspace/investigation-template-model.mjs +26 -2
  331. package/packages/workspace/page-baseline.mjs +2 -2
  332. package/packages/workspace/relationship-observation-model.mjs +17 -13
  333. package/packages/workspace/shortlist-model.mjs +24 -7
  334. package/packages/workspace/watchlist-history.mjs +59 -16
  335. package/packages/workspace/watchlist-store.mjs +152 -11
  336. package/packages/workspace/website-snapshot-model.mjs +28 -3
  337. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  338. package/packages/workspace/workspace-archive.mjs +5 -4
  339. package/third-party-notices.txt +549 -16
  340. package/lib/hostname.mjs +0 -2
@@ -0,0 +1,136 @@
1
+ import { classifyStaticCredentialInput, staticControlDisabled, MAX_STATIC_FORMS, MAX_STATIC_INPUTS, } from "./static-html-analysis.mjs";
2
+ import { CREDENTIAL_CATEGORY_NAMES, MAX_FORM_DESTINATIONS, } from "../packages/evidence/credential-form-attribution.mjs";
3
+ const attribute = (element, name) => element.attributes.find(item => item.name === name)?.value;
4
+ /** HTML Auto buttons submit only without command attributes or a select parent. */
5
+ export function staticControlIsSubmitter(element, parent) {
6
+ const type = (attribute(element, 'type') ?? '').toLowerCase();
7
+ if (element.name === 'input')
8
+ return type === 'submit' || type === 'image';
9
+ if (element.name !== 'button')
10
+ return false;
11
+ if (type === 'submit')
12
+ return true;
13
+ if (type === 'button' || type === 'reset')
14
+ return false;
15
+ return attribute(element, 'command') === undefined && attribute(element, 'commandfor') === undefined
16
+ && !(parent?.html && parent.name === 'select');
17
+ }
18
+ // Uses the existing bounded, inert-template-free tree. IDs and control values
19
+ // are transient association inputs; only ordinal forms and fixed categories
20
+ // leave this projection.
21
+ export function attributeCredentialForms(analysis, documentUrl, sourceTruncated = false) {
22
+ const elements = analysis.elements;
23
+ const firstId = new Map();
24
+ const firstLegends = new Map();
25
+ let firstUncertainId = Number.POSITIVE_INFINITY;
26
+ const formIndices = [];
27
+ let complete = !sourceTruncated && !analysis.inputLimitReached && !analysis.tagLimitReached && !analysis.forms.truncated;
28
+ for (const [index, element] of elements.entries()) {
29
+ if (element.attributesTruncated && attribute(element, 'id') === undefined)
30
+ firstUncertainId = Math.min(firstUncertainId, index);
31
+ const id = attribute(element, 'id');
32
+ if (id !== undefined && id.length <= 4096 && !firstId.has(id))
33
+ firstId.set(id, index);
34
+ if (element.html && element.name === 'form') {
35
+ if (formIndices.length < MAX_STATIC_FORMS)
36
+ formIndices.push(index);
37
+ else
38
+ complete = false;
39
+ }
40
+ if (element.html && element.name === 'legend' && element.parent !== null && !firstLegends.has(element.parent))
41
+ firstLegends.set(element.parent, index);
42
+ }
43
+ const ancestor = (index, name) => {
44
+ for (let parent = elements[index]?.parent ?? null; parent !== null; parent = elements[parent]?.parent ?? null) {
45
+ const element = elements[parent];
46
+ if (element.html && element.name === name)
47
+ return parent;
48
+ }
49
+ return null;
50
+ };
51
+ const owner = (index) => {
52
+ const element = elements[index];
53
+ if (element.attributesTruncated)
54
+ return null;
55
+ for (let parent = element.parent; parent !== null; parent = elements[parent]?.parent ?? null) {
56
+ if (elements[parent]?.name === 'fieldset' && elements[parent]?.attributesTruncated)
57
+ return null;
58
+ }
59
+ const explicit = attribute(element, 'form');
60
+ if (explicit !== undefined) {
61
+ const selected = firstId.get(explicit);
62
+ // A clipped earlier ID could shadow this form in the browser's first-ID
63
+ // lookup. Do not attribute its controls to the later retained match.
64
+ return selected !== undefined && selected < firstUncertainId && elements[selected]?.html && elements[selected]?.name === 'form' ? selected : null;
65
+ }
66
+ return ancestor(index, 'form');
67
+ };
68
+ const destination = (form, submitter) => {
69
+ const method = (submitter && attribute(submitter, 'formmethod')) ?? attribute(form, 'method') ?? 'get';
70
+ if (method.toLowerCase() === 'dialog')
71
+ return { relationship: 'no_submission', origin: null };
72
+ if (form.attributesTruncated || submitter?.attributesTruncated)
73
+ return { relationship: 'unknown', origin: null };
74
+ const action = (submitter && attribute(submitter, 'formaction')) ?? attribute(form, 'action');
75
+ if (action !== undefined && (action.length > 4096 || /[\u0000-\u001f\u007f]/u.test(action)))
76
+ return { relationship: 'unknown', origin: null };
77
+ try {
78
+ const source = new URL(documentUrl);
79
+ // Missing/empty actions use the document URL, not the <base> URL.
80
+ if (action?.trim() && analysis.baseHrefState === 'invalid')
81
+ return { relationship: 'unknown', origin: null };
82
+ const target = new URL(action?.trim() || documentUrl, analysis.effectiveBaseUrl ?? documentUrl);
83
+ if (!['http:', 'https:'].includes(target.protocol) || target.username || target.password || target.origin.length > 320)
84
+ return { relationship: 'unknown', origin: null };
85
+ return { relationship: target.origin === source.origin ? 'same_origin' : 'external', origin: target.origin };
86
+ }
87
+ catch {
88
+ return { relationship: 'unknown', origin: null };
89
+ }
90
+ };
91
+ const forms = formIndices.map((index, ordinal) => ({
92
+ index: ordinal + 1,
93
+ categories: Object.fromEntries(CREDENTIAL_CATEGORY_NAMES.map(name => [name, 0])),
94
+ destinations: [destination(elements[index])],
95
+ }));
96
+ const formRows = new Map(formIndices.map((index, ordinal) => [index, forms[ordinal]]));
97
+ let unassociatedInputs = 0;
98
+ let controls = 0;
99
+ for (const [index, element] of elements.entries()) {
100
+ if (!element.html || !['input', 'button'].includes(element.name))
101
+ continue;
102
+ if (++controls > MAX_STATIC_INPUTS) {
103
+ complete = false;
104
+ break;
105
+ }
106
+ if (staticControlDisabled(elements, index, firstLegends))
107
+ continue;
108
+ const categories = element.name === 'input' ? classifyStaticCredentialInput(element.attributes) : { values: [], truncated: false };
109
+ if (categories.truncated || element.attributesTruncated)
110
+ complete = false;
111
+ const ownerIndex = owner(index);
112
+ const row = ownerIndex === null ? undefined : formRows.get(ownerIndex);
113
+ if (categories.values.length) {
114
+ if (!row) {
115
+ unassociatedInputs++;
116
+ complete = false;
117
+ }
118
+ else
119
+ for (const category of categories.values)
120
+ row.categories[category]++;
121
+ }
122
+ const submitter = staticControlIsSubmitter(element, element.parent === null ? undefined : elements[element.parent]);
123
+ if (row && ownerIndex !== null && submitter && (attribute(element, 'formaction') !== undefined || attribute(element, 'formmethod') !== undefined)) {
124
+ const next = destination(elements[ownerIndex], element);
125
+ if (!row.destinations.some(value => value.relationship === next.relationship && value.origin === next.origin)) {
126
+ if (row.destinations.length >= MAX_FORM_DESTINATIONS)
127
+ complete = false;
128
+ else
129
+ row.destinations.push(next);
130
+ }
131
+ }
132
+ }
133
+ if (forms.some(form => form.destinations.some(value => value.relationship === 'unknown')))
134
+ complete = false;
135
+ return { forms, unassociatedInputs, complete };
136
+ }
@@ -5,22 +5,25 @@
5
5
  // never enter this profile.
6
6
  import { createObservation } from "../packages/evidence/observation.mjs";
7
7
  import { CREDENTIAL_SURFACE_PROFILE_VERSION } from "./lookup-child-profile-contract.mjs";
8
+ import { attributeCredentialForms } from "./credential-form-attribution.mjs";
8
9
  import { MAX_STATIC_FORMS, MAX_STATIC_INPUTS, analyzeStaticHtml, } from "./static-html-analysis.mjs";
9
10
  function analyzeCredentialSurfaceProfile(input = {}) {
10
11
  const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html, { baseUrl: input.baseUrl });
11
12
  const forms = htmlAnalysis.forms;
13
+ const formAttribution = input.formAttribution ?? attributeCredentialForms(htmlAnalysis, typeof input.baseUrl === 'string' ? input.baseUrl : '', input.sourceTruncated === true);
12
14
  const sourceTruncated = input.sourceTruncated === true;
13
15
  const truncated = sourceTruncated
14
16
  || htmlAnalysis.inputLimitReached
15
17
  || htmlAnalysis.tagLimitReached
16
18
  || forms.truncated;
17
19
  const unclassifiedActions = forms.actions.unclassified;
18
- const partial = truncated || unclassifiedActions > 0;
20
+ const partial = truncated || unclassifiedActions > 0 || !formAttribution.complete;
19
21
  const limitations = [
20
22
  'Static input categories use only bounded type and autocomplete declarations; JavaScript-rendered, non-semantic, disabled, and hidden controls are not classified.',
21
23
  'Category counts can overlap when one input declares more than one recognised purpose.',
22
24
  'External submission can be legitimate and does not establish phishing, unsafe handling, ownership, intent, or maliciousness.',
23
- 'No field names, values, labels, placeholders, complete action URLs, paths, queries, fragments, or arbitrary attributes are retained.',
25
+ 'Only form ordinals, input-purpose counts and declared destination origins are retained; no field names, values, labels, placeholders, URL paths, queries or fragments.',
26
+ 'Declared destinations, including submitter overrides, are not observed submissions. Script-created or changed forms are not evaluated.',
24
27
  ];
25
28
  if (sourceTruncated || htmlAnalysis.inputLimitReached) {
26
29
  limitations.push('The captured homepage body was truncated, so credential-surface evidence may be incomplete.');
@@ -36,6 +39,7 @@ function analyzeCredentialSurfaceProfile(input = {}) {
36
39
  }
37
40
  return {
38
41
  credentialSurfaceVersion: CREDENTIAL_SURFACE_PROFILE_VERSION,
42
+ formAttribution,
39
43
  ...createObservation({
40
44
  status: partial ? 'partial' : 'success',
41
45
  observedAt: input.observedAt,
package/lib/ct-query.mjs CHANGED
@@ -1,27 +1,2 @@
1
- const MAX_CT_QUERY_LENGTH = 200;
2
- const CT_QUERY_ERROR_CODE = 'INVALID_CT_QUERY';
3
- const CT_QUERY_ERROR_MESSAGE = `Certificate Transparency query must be at most ${MAX_CT_QUERY_LENGTH} characters and contain no control or invisible formatting characters.`;
4
- const UNSAFE_QUERY_CHARACTER_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
5
- class CtQueryError extends Error {
6
- code = CT_QUERY_ERROR_CODE;
7
- constructor() {
8
- super(CT_QUERY_ERROR_MESSAGE);
9
- this.name = 'CtQueryError';
10
- }
11
- }
12
- function normalizeCtQuery(value) {
13
- if (value === undefined || value === null)
14
- return '';
15
- if (typeof value !== 'string'
16
- || value.length > MAX_CT_QUERY_LENGTH
17
- || UNSAFE_QUERY_CHARACTER_RE.test(value)) {
18
- throw new CtQueryError();
19
- }
20
- return value.trim();
21
- }
22
- function isCtQueryError(value) {
23
- return value instanceof Error
24
- && 'code' in value
25
- && value.code === CT_QUERY_ERROR_CODE;
26
- }
27
- export { CT_QUERY_ERROR_CODE, CT_QUERY_ERROR_MESSAGE, CtQueryError, isCtQueryError, MAX_CT_QUERY_LENGTH, normalizeCtQuery, };
1
+ // Retained public import; the runtime-neutral implementation lives in the domain layer.
2
+ export * from "../packages/analysis/ct-query.mjs";
@@ -1,9 +1,2 @@
1
- // Shared response-contract bounds for certificate-log search. The collector
2
- // and browser normalizer import these values so a valid backend response is
3
- // not accidentally clipped by a stale client-side mirror.
4
- export const MAX_CT_RESPONSE_RESULTS = 500;
5
- export const MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH = 50;
6
- export const MAX_CT_RESPONSE_TIMESTAMP_LENGTH = 64;
7
- export const MAX_CT_RESPONSE_CERTIFICATE_GROUPS = 100;
8
- export const MAX_CT_RESPONSE_DOMAINS_PER_GROUP = 20;
9
- export const MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP = 30;
1
+ // Retained public import; the runtime-neutral implementation lives in the domain layer.
2
+ export * from "../packages/analysis/ct-response-bounds.mjs";
package/lib/ct-search.mjs CHANGED
@@ -8,13 +8,13 @@
8
8
  // Express server and the Netlify Functions.
9
9
  import { parse } from 'tldts';
10
10
  import { setTimeout as sleep } from 'node:timers/promises';
11
- import { normalizeCtQuery } from "./ct-query.mjs";
11
+ import { normalizeCtQuery } from "../packages/analysis/ct-query.mjs";
12
12
  import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
13
13
  import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
14
14
  import { createObservation } from "../packages/evidence/observation.mjs";
15
- import { isValidAsciiHostname } from "./hostname.mjs";
15
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
16
16
  import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
17
- import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "./ct-response-bounds.mjs";
17
+ import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "../packages/analysis/ct-response-bounds.mjs";
18
18
  const CRT_SH_TIMEOUT_MS = 20000;
19
19
  const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
20
20
  // crt.sh's response size scales with how many certificates ever matched the
@@ -38,6 +38,27 @@ const RETRY_DELAY_MS = 1500;
38
38
  // delay before a status-code retry above), so this caps the worst case at
39
39
  // two attempts (~40s) instead of stacking on top of MAX_RETRIES.
40
40
  const MAX_TIMEOUT_RETRIES = 1;
41
+ const COLLECTION_ERRORS = {
42
+ CT_TIMEOUT: [504, 'Certificate search timed out. Try a narrower keyword or try again later.'],
43
+ CT_OVERLOADED: [503, 'Certificate search is temporarily overloaded. Try again later.'],
44
+ CT_QUERY_TOO_BROAD: [422, 'Certificate search returned too many results. Try a narrower keyword.'],
45
+ CT_INVALID_RESPONSE: [502, 'Certificate search returned invalid UTF-8 or an unexpected response format (expected a JSON array). Try again later.'],
46
+ CT_UPSTREAM_ERROR: [502, 'Certificate search could not retrieve a response from its source. Try again later.'],
47
+ };
48
+ export class CtCollectionError extends Error {
49
+ code;
50
+ constructor(code) {
51
+ super(COLLECTION_ERRORS[code][1]);
52
+ this.code = code;
53
+ this.name = 'CtCollectionError';
54
+ }
55
+ }
56
+ /** Only this finite collection vocabulary may cross an HTTP error boundary. */
57
+ export function ctCollectionErrorResponse(error) {
58
+ return error instanceof CtCollectionError
59
+ ? { statusCode: COLLECTION_ERRORS[error.code][0], body: { error: COLLECTION_ERRORS[error.code][1], errorCode: error.code } }
60
+ : null;
61
+ }
41
62
  // Row-count defense: a hostile or malformed response could fit a very large
42
63
  // number of tiny rows into the 5 MB byte cap and impose excessive
43
64
  // iteration/set overhead after parsing. Legitimate responses under the byte
@@ -75,61 +96,76 @@ function normalizeHostname(raw) {
75
96
  function delay(ms, signal) {
76
97
  return sleep(ms, undefined, signal ? { signal } : {});
77
98
  }
78
- async function fetchCrtSh(keyword, attempt = 0, dependencies = {}) {
99
+ async function fetchCrtSh(keyword, dependencies = {}) {
79
100
  dependencies.signal?.throwIfAborted();
80
101
  const fetcher = dependencies.fetcher || safeFetch;
81
102
  const wait = dependencies.delay || delay;
82
- const controller = new AbortController();
83
- const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
84
- const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
85
- try {
86
- let res;
103
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
104
+ dependencies.signal?.throwIfAborted();
105
+ const controller = new AbortController();
106
+ const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
107
+ const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
108
+ let retryDelay = 0;
87
109
  try {
88
- res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
110
+ const res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
89
111
  headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
90
112
  signal,
91
113
  }, 0);
92
- }
93
- catch (err) {
94
- dependencies.signal?.throwIfAborted();
95
- if (!(err instanceof Error) || err.name !== 'AbortError')
96
- throw err;
97
- if (attempt < MAX_TIMEOUT_RETRIES)
98
- return fetchCrtSh(keyword, attempt + 1, dependencies);
99
- throw new Error(`crt.sh took too long to respond (>${CRT_SH_TIMEOUT_MS / 1000}s per attempt, ${attempt + 1} attempts) - ` +
100
- "it can be slow for broad search terms or under heavy load; try a narrower keyword or try again shortly.");
101
- }
102
- if (!res.ok) {
103
- // Not reading this body - release it explicitly instead of leaving an
104
- // unconsumed stream (and the connection it's tied to) open until
105
- // undici's own idle-timeout eventually notices.
106
- await res.body?.cancel().catch(() => { });
107
- if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
108
- await wait(RETRY_DELAY_MS * (attempt + 1), dependencies.signal);
109
- dependencies.signal?.throwIfAborted();
110
- return fetchCrtSh(keyword, attempt + 1, dependencies);
114
+ if (!res.ok) {
115
+ await res.body?.cancel().catch(() => { });
116
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
117
+ retryDelay = RETRY_DELAY_MS * (attempt + 1);
118
+ }
119
+ else {
120
+ throw new CtCollectionError(RETRYABLE_STATUSES.has(res.status) || res.status === 429 ? 'CT_OVERLOADED' : 'CT_UPSTREAM_ERROR');
121
+ }
122
+ }
123
+ else {
124
+ let text;
125
+ try {
126
+ const body = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
127
+ signal.throwIfAborted();
128
+ if (body.truncated)
129
+ throw new CtCollectionError('CT_QUERY_TOO_BROAD');
130
+ text = body.text;
131
+ }
132
+ catch (error) {
133
+ // Preserve transport/deadline failures for the attempt retry below.
134
+ if (error instanceof TypeError && !signal.aborted) {
135
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
136
+ }
137
+ throw error;
138
+ }
139
+ let data;
140
+ try {
141
+ data = JSON.parse(text);
142
+ }
143
+ catch {
144
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
145
+ }
146
+ if (!Array.isArray(data))
147
+ throw new CtCollectionError('CT_INVALID_RESPONSE');
148
+ if (data.length > MAX_CT_ROWS)
149
+ throw new CtCollectionError('CT_QUERY_TOO_BROAD');
150
+ return data;
111
151
  }
112
- throw new Error(RETRYABLE_STATUSES.has(res.status)
113
- ? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
114
- : `crt.sh returned ${res.status}`);
115
- }
116
- const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
117
- signal.throwIfAborted();
118
- if (truncated) {
119
- throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
120
152
  }
121
- const data = JSON.parse(text);
122
- if (!Array.isArray(data)) {
123
- throw new Error('crt.sh returned an unexpected response format (expected a JSON array).');
153
+ catch (error) {
154
+ dependencies.signal?.throwIfAborted();
155
+ if (!controller.signal.aborted && (!(error instanceof Error) || error.name !== 'AbortError'))
156
+ throw error;
157
+ if (attempt >= MAX_TIMEOUT_RETRIES)
158
+ throw new CtCollectionError('CT_TIMEOUT');
124
159
  }
125
- if (data.length > MAX_CT_ROWS) {
126
- throw new Error(`crt.sh returned too many rows (${data.length}) for "${keyword}" - try a narrower keyword.`);
160
+ finally {
161
+ // End this attempt before delay/retry: body reads share the same deadline
162
+ // as headers, and earlier timers cannot fire during a later attempt.
163
+ clearTimeout(timeout);
127
164
  }
128
- return data;
129
- }
130
- finally {
131
- clearTimeout(timeout);
165
+ if (retryDelay)
166
+ await wait(retryDelay, dependencies.signal);
132
167
  }
168
+ throw new CtCollectionError('CT_UPSTREAM_ERROR');
133
169
  }
134
170
  // ---------------------------------------------------------------------------
135
171
  // Certificate identity
@@ -485,7 +521,7 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
485
521
  }),
486
522
  };
487
523
  }
488
- const data = await fetchCrtSh(trimmed, 0, dependencies);
524
+ const data = await fetchCrtSh(trimmed, dependencies);
489
525
  const summary = summarizeCtResults(data);
490
526
  return {
491
527
  certCount: data.length,
@@ -0,0 +1,62 @@
1
+ import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
3
+ import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
4
+ export function requireDmarcDomain(domain) {
5
+ if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
6
+ throw new TypeError('The additional DNS review requires a normalised domain.');
7
+ }
8
+ }
9
+ export function dmarcPolicyObservation(owner, observation) {
10
+ const records = observation.records;
11
+ let bytes = 0;
12
+ const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
13
+ const parts = typeof raw === 'string' ? [raw] : raw;
14
+ return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
15
+ if (typeof part !== 'string' || part.length > 4096)
16
+ return false;
17
+ bytes += Buffer.byteLength(part, 'utf8');
18
+ return bytes <= 65_536;
19
+ });
20
+ });
21
+ const parsed = parseDmarcRecords(admitted ? records : []);
22
+ const unavailable = Boolean(observation.error) || !admitted;
23
+ const psd = parsed.tags.psd?.toLowerCase() ?? 'u';
24
+ const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(psd);
25
+ const incomplete = unavailable || (!valid && parsed.records.length === 1);
26
+ const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
27
+ const description = valid
28
+ ? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${psd}; test=${parsed.testMode ? 'yes' : 'no'}`
29
+ : state;
30
+ const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
31
+ ? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
32
+ return { owner, parsed, psd, valid, unavailable, incomplete,
33
+ record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
34
+ }
35
+ /** RFC 9989 sections 4.10–4.10.2: at most eight owners, never PSL inference. */
36
+ export async function walkDmarcTree(domain, exact, resolve, purpose) {
37
+ requireDmarcDomain(domain);
38
+ const rows = [dmarcPolicyObservation(domain, exact)];
39
+ const first = rows[0];
40
+ const labels = domain.split('.');
41
+ let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
42
+ const policyOnly = purpose === 'policy' && first.valid;
43
+ if (!policyOnly && !first.unavailable && !(first.valid && ['n', 'y'].includes(first.psd))) {
44
+ while (next.length) {
45
+ const owner = next.join('.');
46
+ const row = dmarcPolicyObservation(owner, await resolve(`_dmarc.${owner}`));
47
+ rows.push(row);
48
+ if (row.unavailable || (row.valid && ['n', 'y'].includes(row.psd)))
49
+ break;
50
+ next = next.slice(1);
51
+ }
52
+ }
53
+ const available = rows.filter(row => row.valid);
54
+ const boundary = available.find(row => row.psd === 'n' || (row.psd === 'y' && row.owner !== domain));
55
+ const incomplete = rows.some(row => row.incomplete);
56
+ // An exact policy alone does not establish the organisational boundary.
57
+ const organisationalDomain = incomplete || (policyOnly && !['n', 'y'].includes(first.psd)) ? null
58
+ : boundary?.psd === 'y'
59
+ ? labels.slice(-(boundary.owner.split('.').length + 1)).join('.')
60
+ : boundary?.owner ?? available.at(-1)?.owner ?? domain;
61
+ return { rows, available, boundary, incomplete, organisationalDomain };
62
+ }
@@ -1,14 +1,18 @@
1
1
  import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
2
2
  import { normaliseRdata } from "./zone-intent-review.mjs";
3
+ import { dnsQueryState, normaliseDnsQueryCoverage } from "./dns-query-coverage.mjs";
3
4
  export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
4
5
  export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
5
- export const DNS_CONVERGENCE_REVIEW_VERSION = 1;
6
+ export const DNS_CONVERGENCE_INPUT_VERSION = 2;
7
+ export const SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS = Object.freeze([1, DNS_CONVERGENCE_INPUT_VERSION]);
8
+ export const DNS_CONVERGENCE_REVIEW_VERSION = 2;
6
9
  export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
7
10
  export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
8
11
  const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
9
12
  const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
10
13
  const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
11
14
  const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
15
+ const COVERED_SNAPSHOT_KEYS = new Set([...SNAPSHOT_KEYS, 'queries']);
12
16
  const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
13
17
  function type(value, label) {
14
18
  const candidate = requireBoundedString(value, label, 16).toUpperCase();
@@ -83,8 +87,8 @@ function expectedRows(value, apex) {
83
87
  }
84
88
  export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
85
89
  const input = requireRecord(inputRaw, 'DNS convergence input');
86
- if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version !== 1) {
87
- throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
90
+ if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || !SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS.includes(input.version)) {
91
+ throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1 or ${DNS_CONVERGENCE_INPUT_VERSION}.`);
88
92
  }
89
93
  exactKeys(input, ROOT_KEYS, 'DNS convergence input');
90
94
  const apex = requireDomainName(input.domain, 'domain');
@@ -93,9 +97,10 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
93
97
  throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
94
98
  }
95
99
  let recordCount = 0;
100
+ let queryCount = 0;
96
101
  const snapshots = input.snapshots.map((raw, index) => {
97
102
  const item = requireRecord(raw, `snapshots[${index}]`);
98
- exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
103
+ exactKeys(item, input.version === 1 ? SNAPSHOT_KEYS : COVERED_SNAPSHOT_KEYS, `snapshots[${index}]`);
99
104
  if (!Array.isArray(item.records))
100
105
  throw new TypeError(`snapshots[${index}].records must be an array.`);
101
106
  recordCount += item.records.length;
@@ -104,12 +109,20 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
104
109
  const observationState = state(item.state, `snapshots[${index}].state`);
105
110
  if (observationState === 'unavailable' && item.records.length)
106
111
  throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
112
+ const records = Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`)));
113
+ const queries = normaliseDnsQueryCoverage(item.queries, {
114
+ legacy: input.version === 1, label: `snapshots[${index}].queries`,
115
+ maximum: MAX_DNS_CONVERGENCE_RECORDS - queryCount, state: observationState, records,
116
+ owner: (value, label) => owner(value, apex, label), type,
117
+ });
118
+ queryCount += queries.length;
107
119
  return Object.freeze({
108
120
  observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
109
121
  source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
110
122
  observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
111
123
  state: observationState,
112
- records: Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`))),
124
+ records,
125
+ queries,
113
126
  });
114
127
  }).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
115
128
  const observers = [...new Set(snapshots.map((item) => item.observer))];
@@ -118,7 +131,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
118
131
  const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
119
132
  const keys = [...new Set([
120
133
  ...expected.map((item) => `${item.owner}\u0000${item.type}`),
121
- ...snapshots.flatMap((item) => item.records.map((entry) => `${entry.owner}\u0000${entry.type}`)),
134
+ ...snapshots.flatMap((item) => item.queries.map((entry) => `${entry.owner}\u0000${entry.type}`)),
122
135
  ])].sort();
123
136
  const rows = keys.map((key) => {
124
137
  const [recordOwner = '', recordType = ''] = key.split('\u0000');
@@ -134,7 +147,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
134
147
  observer: snapshot.observer,
135
148
  source: snapshot.source,
136
149
  observedAt: snapshot.observedAt,
137
- state: snapshot.state,
150
+ state: dnsQueryState(snapshot.queries, recordOwner, recordType),
138
151
  values: Object.freeze(values),
139
152
  cacheUntil,
140
153
  });
@@ -178,7 +191,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
178
191
  'This local workbench compares only supplied observations and makes no DNS request.',
179
192
  'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
180
193
  'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
181
- 'Partial and unavailable snapshots remain incomplete and are never interpreted as record absence.',
194
+ 'Only an explicitly observed query can establish an empty answer. Unqueried, partial and unavailable record types are never interpreted as absence; legacy inputs establish scope only for their supplied records.',
182
195
  ]),
183
196
  });
184
197
  }