@slicedearth/whoisleuth-cli 2.3.0 → 2.5.1

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 (418) hide show
  1. package/NOTICE +8 -2
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/argument-values.mjs +55 -0
  7. package/cli/arguments.mjs +24 -408
  8. package/cli/artifact-validation/case-response.mjs +65 -44
  9. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  10. package/cli/artifact-validation/lookup-evidence.mjs +15 -9
  11. package/cli/artifact-validation/signed-review.mjs +136 -68
  12. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  13. package/cli/artifact-verify.mjs +92 -24
  14. package/cli/assurance-arguments.mjs +56 -0
  15. package/cli/assurance-command-definitions.mjs +179 -0
  16. package/cli/assurance-command-runner.mjs +40 -5
  17. package/cli/bagit-folder.mjs +57 -0
  18. package/cli/bagit-review.mjs +23 -0
  19. package/cli/bounded-json.mjs +1 -1
  20. package/cli/bulk-checkpoint.mjs +1 -1
  21. package/cli/bulk-command-runner.mjs +4 -2
  22. package/cli/bulk-output.mjs +29 -3
  23. package/cli/bulk.mjs +2 -3
  24. package/cli/case-command.mjs +212 -0
  25. package/cli/case-pack.mjs +50 -32
  26. package/cli/ci-report.mjs +6 -3
  27. package/cli/collection-arguments.mjs +123 -0
  28. package/cli/collection-command-definitions.mjs +330 -0
  29. package/cli/collection-preflight.mjs +8 -1
  30. package/cli/command-argument-grammar.mjs +8 -206
  31. package/cli/command-definition.mjs +329 -0
  32. package/cli/command-reference.mjs +94 -1357
  33. package/cli/command-seeds.mjs +18 -0
  34. package/cli/compare.mjs +1 -1
  35. package/cli/completion.mjs +298 -79
  36. package/cli/config-profile.mjs +8 -6
  37. package/cli/ct-event-intake.mjs +16 -16
  38. package/cli/discover.mjs +5 -4
  39. package/cli/discovery-observation-snapshot.mjs +46 -18
  40. package/cli/discovery-scan-command-runner.mjs +5 -3
  41. package/cli/discovery-scan.mjs +7 -4
  42. package/cli/discovery-snapshot.mjs +2 -2
  43. package/cli/discovery-workflow.mjs +5 -5
  44. package/cli/domain-control-monitor.mjs +50 -20
  45. package/cli/domain-control-observations.mjs +151 -103
  46. package/cli/errors.mjs +37 -6
  47. package/cli/evidence-arguments.mjs +46 -0
  48. package/cli/evidence-command-definitions.mjs +69 -0
  49. package/cli/evidence-command-runner.mjs +18 -8
  50. package/cli/evidence-signing.mjs +5 -4
  51. package/cli/external-observation-mapping.mjs +9 -10
  52. package/cli/formatters/terminal-command-formats.mjs +6 -0
  53. package/cli/formatters/terminal-lookup.mjs +24 -46
  54. package/cli/formatters/terminal-shared.mjs +5 -3
  55. package/cli/formatters/terminal-technology.mjs +114 -0
  56. package/cli/history-arguments.mjs +39 -0
  57. package/cli/history-command-definitions.mjs +88 -0
  58. package/cli/indicator-set-command.mjs +33 -0
  59. package/cli/inline-command-families.mjs +6 -46
  60. package/cli/inline-command-runner.mjs +4 -31
  61. package/cli/input.mjs +2 -2
  62. package/cli/intake-command.mjs +107 -0
  63. package/cli/interactive-launcher.mjs +2 -160
  64. package/cli/interchange-report.mjs +8 -10
  65. package/cli/investigation-artifacts.mjs +176 -0
  66. package/cli/investigation-checkpoint.mjs +10 -0
  67. package/cli/investigation-folder.mjs +114 -0
  68. package/cli/investigation-manifest.mjs +1 -112
  69. package/cli/investigation-package-review.mjs +103 -0
  70. package/cli/investigation-plan.mjs +38 -147
  71. package/cli/investigation-recipes.mjs +177 -0
  72. package/cli/investigation-run.mjs +256 -35
  73. package/cli/local-document-checkpoint.mjs +170 -0
  74. package/cli/local-mmdb-review.mjs +197 -17
  75. package/cli/lookalike-calibration.mjs +1 -1
  76. package/cli/lookup-brief.mjs +2 -2
  77. package/cli/lookup-browser.mjs +5 -12
  78. package/cli/lookup-command-runner.mjs +33 -39
  79. package/cli/lookup-diff.mjs +47 -12
  80. package/cli/lookup-plan.mjs +16 -14
  81. package/cli/lookup-reconcile.mjs +15 -12
  82. package/cli/lookup-timeline.mjs +7 -6
  83. package/cli/mail-header-review.mjs +24 -247
  84. package/cli/mail-review.mjs +75 -21
  85. package/cli/manual.mjs +1 -0
  86. package/cli/network-arguments.mjs +51 -0
  87. package/cli/network-command-definitions.mjs +130 -0
  88. package/cli/network-command-runner.mjs +45 -31
  89. package/cli/offline-evidence-review.mjs +39 -6
  90. package/cli/output-file.mjs +31 -5
  91. package/cli/posture.mjs +1 -1
  92. package/cli/registry-doctor.mjs +3 -1
  93. package/cli/registry-fixture-scaffold.mjs +1 -1
  94. package/cli/registry-support.mjs +1 -1
  95. package/cli/retained-artifact-diff.mjs +10 -9
  96. package/cli/review-arguments.mjs +131 -0
  97. package/cli/review-command-definitions.mjs +273 -0
  98. package/cli/review-command-runner.mjs +104 -149
  99. package/cli/risk-calibration.mjs +8 -8
  100. package/cli/runner.mjs +74 -37
  101. package/cli/saved-lookup.mjs +2 -2
  102. package/cli/selected-input-worker.mjs +63 -0
  103. package/cli/sharing-review.mjs +8 -80
  104. package/cli/signer-trust.mjs +104 -0
  105. package/cli/source-reliability.mjs +75 -84
  106. package/cli/support-arguments.mjs +49 -0
  107. package/cli/support-command-definitions.mjs +209 -0
  108. package/cli/support-command-runner.mjs +2 -2
  109. package/cli/terminal-input.mjs +168 -0
  110. package/cli/workflow-arguments.mjs +86 -0
  111. package/cli/workflow-command-definitions.mjs +78 -0
  112. package/cli/workflow-command-runner.mjs +75 -13
  113. package/docs/cli-reference.md +103 -30
  114. package/docs/cli.md +510 -25
  115. package/docs/contextual-reviews.md +155 -0
  116. package/lib/abort.mjs +4 -1
  117. package/lib/analyst-taxonomy.mjs +2 -40
  118. package/lib/application-version.mjs +1 -1
  119. package/lib/availability.mjs +27 -754
  120. package/lib/bounded-contract-normalizers.mjs +4 -0
  121. package/lib/bounded-file.mjs +3 -1
  122. package/lib/bounded-html-document.mjs +125 -0
  123. package/lib/bounded-json.mjs +2 -290
  124. package/lib/browser-library-profile.mjs +99 -130
  125. package/lib/browser-library-worker.mjs +112 -0
  126. package/lib/candidate-provenance-bounds.mjs +2 -0
  127. package/lib/classify.mjs +1 -1
  128. package/lib/client-behavior-profile.mjs +3 -3
  129. package/lib/ct-query.mjs +2 -27
  130. package/lib/ct-response-bounds.mjs +2 -9
  131. package/lib/ct-search.mjs +89 -46
  132. package/lib/dmarc-discovery.mjs +62 -0
  133. package/lib/dns-convergence-review.mjs +21 -8
  134. package/lib/dns-delegation-health.mjs +36 -22
  135. package/lib/dns-inheritance-review.mjs +207 -0
  136. package/lib/dns-intelligence.mjs +44 -29
  137. package/lib/dns-mx.mjs +1 -1
  138. package/lib/dns-query-coverage.mjs +40 -0
  139. package/lib/dnssec-evidence-validation.mjs +11 -3
  140. package/lib/domain-assurance.mjs +1 -1
  141. package/lib/domain-change-packet.mjs +18 -14
  142. package/lib/domain-change-review.mjs +40 -16
  143. package/lib/domain-control-flight-recorder.mjs +301 -124
  144. package/lib/domain-control-manifest.mjs +126 -58
  145. package/lib/domain-posture-analysis.mjs +41 -11
  146. package/lib/domain-posture-parsers.mjs +88 -6
  147. package/lib/domain-posture.mjs +120 -32
  148. package/lib/domain-registration.mjs +392 -0
  149. package/lib/evidence-export-privacy.mjs +568 -0
  150. package/lib/evidence-export.mjs +12 -593
  151. package/lib/evidence-report.mjs +7 -2
  152. package/lib/external-intelligence-risk.mjs +2 -124
  153. package/lib/favicon.mjs +117 -41
  154. package/lib/generated/cisa-kev-catalog.mjs +50 -4
  155. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  156. package/lib/generated/retire-browser-catalog.mjs +2742 -1034
  157. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  158. package/lib/html-signals.mjs +87 -85
  159. package/lib/http-evidence-bounds.mjs +2 -7
  160. package/lib/http-intelligence.mjs +52 -27
  161. package/lib/idn-confusable-policy.mjs +6 -4
  162. package/lib/idn-confusables.mjs +1 -1
  163. package/lib/interchange-fidelity-registry.mjs +10 -0
  164. package/lib/lookup-cache.mjs +34 -11
  165. package/lib/lookup-child-profile-contract.mjs +24 -15
  166. package/lib/lookup-contract-primitives.mjs +1 -1
  167. package/lib/lookup-diagnostics.mjs +2 -1
  168. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  169. package/lib/lookup-progress.mjs +79 -13
  170. package/lib/lookup-response-contract.mjs +1077 -0
  171. package/lib/lookup-response.mjs +11 -24
  172. package/lib/lookup-source-progress.mjs +24 -35
  173. package/lib/lookup.mjs +49 -28
  174. package/lib/observed-network-context.mjs +43 -27
  175. package/lib/opportunity-scoring.mjs +2 -107
  176. package/lib/outbound-identity.mjs +1 -1
  177. package/lib/outbound-request-bounds.mjs +4 -1
  178. package/lib/page-fingerprints.mjs +87 -134
  179. package/lib/page-role-profile.mjs +12 -8
  180. package/lib/perceptual-hash-comparison.mjs +2 -34
  181. package/lib/perceptual-hash.mjs +79 -24
  182. package/lib/portable-generator.mjs +2 -29
  183. package/lib/project-metadata.mjs +2 -8
  184. package/lib/rdap-bootstrap.mjs +72 -22
  185. package/lib/rdap-client.mjs +26 -14
  186. package/lib/rdap-normalization.mjs +33 -18
  187. package/lib/rdap-search-workbench.mjs +1 -1
  188. package/lib/rdap-transport.mjs +47 -20
  189. package/lib/rdap-types.mjs +2 -1
  190. package/lib/rdap.mjs +18 -3
  191. package/lib/registrable-domain.mjs +2 -36
  192. package/lib/registrar-standing-contract.mjs +2 -1
  193. package/lib/registrar-standing.mjs +9 -13
  194. package/lib/registry-capabilities.mjs +7 -3
  195. package/lib/registry-capability-catalogue.mjs +29 -20
  196. package/lib/registry-dates.mjs +2 -190
  197. package/lib/registry-insights.mjs +56 -18
  198. package/lib/response-policy.mjs +86 -48
  199. package/lib/risk-calibration-summary.mjs +2 -338
  200. package/lib/risk-scoring.mjs +2 -345
  201. package/lib/rpki-evidence.mjs +8 -5
  202. package/lib/safe-fetch.mjs +54 -47
  203. package/lib/scoring-evidence-quality.mjs +2 -107
  204. package/lib/security-txt.mjs +10 -7
  205. package/lib/semantic-version.mjs +2 -54
  206. package/lib/service-binding-dns.mjs +35 -0
  207. package/lib/smtp-transport-review.mjs +75 -34
  208. package/lib/sslbl-certificates.generated.mjs +141 -138
  209. package/lib/static-html-analysis.mjs +117 -146
  210. package/lib/structured-data-identity.mjs +2 -1
  211. package/lib/technology-markup.mjs +100 -0
  212. package/lib/threat-intelligence-manifests.mjs +11 -139
  213. package/lib/threat-intelligence-runtime.mjs +24 -52
  214. package/lib/threat-intelligence-targets.mjs +6 -34
  215. package/lib/threat-intelligence-types.mjs +2 -36
  216. package/lib/typosquat-generator.mjs +29 -16
  217. package/lib/urlhaus-intelligence.mjs +8 -3
  218. package/lib/urlscan-intelligence.mjs +1 -1
  219. package/lib/website-enrichment.mjs +388 -0
  220. package/lib/website-security-posture.mjs +30 -6
  221. package/lib/website-technology.mjs +111 -71
  222. package/lib/whois-authority.mjs +55 -5
  223. package/lib/whois-chain.mjs +9 -2
  224. package/lib/whois-common-formats.mjs +5 -5
  225. package/lib/whois-contracts.mjs +2 -1
  226. package/lib/whois-dialects.mjs +2 -2
  227. package/lib/whois-normalization.mjs +3 -1
  228. package/lib/whois-parser-support.mjs +8 -1
  229. package/lib/whois-parser.mjs +5 -3
  230. package/lib/whois-transport.mjs +23 -7
  231. package/package.json +14 -14
  232. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  233. package/packages/analysis/bounded-json.mjs +330 -0
  234. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  235. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  236. package/packages/analysis/ct-query.mjs +27 -0
  237. package/packages/analysis/ct-response-bounds.mjs +9 -0
  238. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  239. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  240. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  241. package/packages/analysis/opportunity-scoring.mjs +107 -0
  242. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  243. package/packages/analysis/portable-generator.mjs +29 -0
  244. package/packages/analysis/project-metadata.mjs +8 -0
  245. package/packages/analysis/registrable-domain.mjs +56 -0
  246. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  247. package/packages/analysis/risk-scoring.mjs +345 -0
  248. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  249. package/packages/analysis/semantic-version.mjs +54 -0
  250. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  251. package/packages/cases/case-attachment-model.mjs +83 -0
  252. package/packages/cases/case-evidence-links.mjs +135 -0
  253. package/packages/cases/case-evidence-model.mjs +229 -69
  254. package/packages/cases/case-evidence-quality.mjs +5 -0
  255. package/packages/cases/case-export-input.mjs +55 -0
  256. package/packages/cases/case-incident-context.mjs +52 -0
  257. package/packages/cases/case-markdown.mjs +11 -0
  258. package/packages/cases/case-migration-model.mjs +98 -48
  259. package/packages/cases/case-model.mjs +1 -0
  260. package/packages/cases/case-recheck-model.mjs +149 -0
  261. package/packages/cases/case-record-contracts.mjs +1 -1
  262. package/packages/cases/case-record-core.mjs +4 -3
  263. package/packages/cases/case-record-operations.mjs +123 -133
  264. package/packages/cases/case-record-projection.mjs +30 -20
  265. package/packages/cases/case-record-version-input.mjs +19 -0
  266. package/packages/cases/case-report.mjs +114 -67
  267. package/packages/cases/case-requested-evidence.mjs +146 -0
  268. package/packages/cases/case-response-actions.mjs +756 -0
  269. package/packages/cases/case-response-model.mjs +49 -1129
  270. package/packages/cases/case-response-outcomes.mjs +338 -0
  271. package/packages/cases/case-response-packet-render.mjs +157 -0
  272. package/packages/cases/case-response-packet-types.mjs +1 -0
  273. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  274. package/packages/cases/case-response-packet.mjs +58 -593
  275. package/packages/cases/case-response-records.mjs +93 -0
  276. package/packages/cases/case-response-review-inputs.mjs +382 -0
  277. package/packages/cases/case-response-values.mjs +85 -0
  278. package/packages/cases/case-review-package.mjs +45 -0
  279. package/packages/cases/case-selection.mjs +46 -0
  280. package/packages/cases/case-storage-model.mjs +26 -1
  281. package/packages/cases/case-workflow-metadata.mjs +225 -0
  282. package/packages/cases/case-workflow-migration.mjs +43 -0
  283. package/packages/cases/http-summary.mjs +6 -1
  284. package/packages/cases/response-route-freshness.mjs +16 -0
  285. package/packages/comparison/capture-context.mjs +61 -0
  286. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  287. package/packages/comparison/favicon-similarity.mjs +1 -1
  288. package/packages/comparison/page-similarity.mjs +3 -2
  289. package/packages/comparison/relationship-evidence.mjs +103 -14
  290. package/packages/comparison/relationship-provenance.mjs +99 -0
  291. package/packages/contracts/analyst-interchange.mjs +234 -0
  292. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  293. package/packages/contracts/analyst-review-state.mjs +5 -4
  294. package/packages/contracts/case-portability.mjs +370 -114
  295. package/packages/contracts/case-views-contract.mjs +12 -0
  296. package/packages/contracts/case-views.mjs +26 -0
  297. package/packages/contracts/cli-command-semantics.mjs +3 -0
  298. package/packages/contracts/cli-grammar.mjs +6 -0
  299. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  300. package/packages/contracts/cli-lookup.mjs +0 -1
  301. package/packages/contracts/context-review.mjs +24 -0
  302. package/packages/contracts/document-intake.mjs +11 -0
  303. package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
  304. package/packages/contracts/domain-control-manifest.mjs +128 -74
  305. package/packages/contracts/domain-control-monitor.mjs +59 -45
  306. package/packages/contracts/domain-control-review.mjs +200 -121
  307. package/packages/contracts/domain-name.mjs +21 -0
  308. package/packages/contracts/external-observation-interchange.mjs +2 -1
  309. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  310. package/packages/contracts/har-review.mjs +2 -0
  311. package/packages/contracts/http-url.mjs +2 -0
  312. package/packages/contracts/identity-events.mjs +4 -0
  313. package/packages/contracts/investigation-package-limits.mjs +24 -0
  314. package/packages/contracts/investigation-portability.mjs +56 -27
  315. package/packages/contracts/investigation-run.mjs +18 -0
  316. package/packages/contracts/ip-address.mjs +44 -0
  317. package/packages/contracts/lookup-evidence.mjs +35 -0
  318. package/packages/contracts/mail-authentication.mjs +5 -0
  319. package/packages/contracts/message-intake.mjs +31 -0
  320. package/packages/contracts/offline-comparison.mjs +4 -2
  321. package/packages/contracts/page-fingerprints.mjs +15 -0
  322. package/packages/contracts/passive-mail.mjs +10 -0
  323. package/packages/contracts/risk-calibration.mjs +0 -1
  324. package/packages/contracts/schema-compatibility.mjs +1 -1
  325. package/packages/contracts/schema-lifecycle.mjs +83 -153
  326. package/packages/contracts/selected-file-limits.mjs +6 -0
  327. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  328. package/packages/contracts/web-capture.mjs +10 -0
  329. package/packages/contracts/workspace-portability.mjs +219 -238
  330. package/packages/evidence/artifact-integrity.mjs +8 -2
  331. package/packages/evidence/artifact-structure.mjs +105 -0
  332. package/packages/evidence/collection-quality.mjs +67 -0
  333. package/packages/evidence/decision-fact.mjs +4 -1
  334. package/packages/evidence/dns-query-outcome.mjs +72 -0
  335. package/packages/evidence/domain-control-runtime.mjs +155 -27
  336. package/packages/evidence/domain-posture-context.mjs +79 -0
  337. package/packages/evidence/email-recipient.mjs +18 -0
  338. package/packages/evidence/image-regions.mjs +56 -0
  339. package/packages/evidence/latest-observations.mjs +28 -0
  340. package/packages/evidence/lookup-target.mjs +108 -0
  341. package/packages/evidence/observation.mjs +18 -1
  342. package/packages/evidence/passphrase-encryption.mjs +44 -0
  343. package/packages/evidence/png-pixels.mjs +117 -0
  344. package/packages/evidence/public-address-policy.mjs +83 -0
  345. package/packages/evidence/record-identity.mjs +72 -0
  346. package/packages/evidence/registry-dates.mjs +190 -0
  347. package/packages/evidence/retained-file.mjs +69 -0
  348. package/packages/evidence/review-age.mjs +14 -0
  349. package/packages/interchange/bagit.mjs +342 -0
  350. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  351. package/packages/interchange/crc32.mjs +17 -0
  352. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  353. package/packages/interchange/external-findings-import.mjs +93 -61
  354. package/packages/interchange/managed-indicator-export.mjs +35 -0
  355. package/packages/interchange/managed-indicator-set.mjs +210 -0
  356. package/packages/interchange/misp-indicator-export.mjs +93 -0
  357. package/packages/interchange/sharing-policy.mjs +56 -0
  358. package/packages/interchange/stix-indicator-export.mjs +129 -0
  359. package/packages/interchange/web-capture-import.mjs +389 -0
  360. package/packages/investigation/acquisition-decision-packet.mjs +1 -1
  361. package/packages/investigation/bulk-mail-exposure.mjs +9 -14
  362. package/packages/investigation/capture-coverage.mjs +57 -0
  363. package/packages/investigation/connector-provenance-review.mjs +100 -0
  364. package/packages/investigation/context-review.mjs +35 -0
  365. package/packages/investigation/document-intake.mjs +81 -0
  366. package/packages/investigation/docx-intake.mjs +133 -0
  367. package/packages/investigation/domain-history-review.mjs +100 -0
  368. package/packages/investigation/domain-transition-review.mjs +47 -0
  369. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  370. package/packages/investigation/har-intake.mjs +68 -0
  371. package/packages/investigation/identity-event-intake.mjs +131 -0
  372. package/packages/investigation/identity-incident-review.mjs +39 -0
  373. package/packages/investigation/incident-sequence-review.mjs +58 -0
  374. package/packages/investigation/intake-report.mjs +19 -0
  375. package/packages/investigation/investigation-bagit.mjs +14 -0
  376. package/packages/investigation/investigation-capsule.mjs +33 -5
  377. package/packages/investigation/investigation-manifest.mjs +200 -0
  378. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  379. package/packages/investigation/investigation-package.mjs +200 -0
  380. package/packages/investigation/link-intake.mjs +179 -0
  381. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  382. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  383. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  384. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  385. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  386. package/packages/investigation/mail-authentication-review.mjs +222 -0
  387. package/packages/investigation/mail-header-identity.mjs +209 -0
  388. package/packages/investigation/message-intake.mjs +195 -0
  389. package/packages/investigation/page-behaviour.mjs +113 -0
  390. package/packages/investigation/pdf-intake.mjs +155 -0
  391. package/packages/investigation/platform-continuity-review.mjs +60 -0
  392. package/packages/investigation/qr-intake.mjs +56 -0
  393. package/packages/investigation/requested-action-hints.mjs +11 -0
  394. package/packages/investigation/selected-input-review.mjs +17 -0
  395. package/packages/investigation/storefront-review.mjs +70 -0
  396. package/packages/monitoring/analyst-review-state.mjs +38 -90
  397. package/packages/workspace/brand-profile-model.mjs +160 -22
  398. package/packages/workspace/bulk-columns.mjs +17 -0
  399. package/packages/workspace/bulk-review-model.mjs +39 -31
  400. package/packages/workspace/bulk-session-model.mjs +112 -27
  401. package/packages/workspace/bulk-sort.mjs +1 -0
  402. package/packages/workspace/campaign-model.mjs +1 -1
  403. package/packages/workspace/case-views.mjs +102 -0
  404. package/packages/workspace/detection-rule-model.mjs +1 -1
  405. package/packages/workspace/hostile-input.mjs +29 -8
  406. package/packages/workspace/investigation-guide.mjs +2 -2
  407. package/packages/workspace/investigation-template-model.mjs +45 -6
  408. package/packages/workspace/page-baseline.mjs +20 -17
  409. package/packages/workspace/relationship-observation-model.mjs +54 -35
  410. package/packages/workspace/shortlist-model.mjs +14 -5
  411. package/packages/workspace/watchlist-history.mjs +30 -14
  412. package/packages/workspace/watchlist-store.mjs +24 -6
  413. package/packages/workspace/website-snapshot-model.mjs +98 -33
  414. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  415. package/packages/workspace/workspace-archive.mjs +90 -25
  416. package/third-party-notices.txt +577 -17
  417. package/lib/hostname.mjs +0 -38
  418. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -0,0 +1,35 @@
1
+ import { parseBoundedJson } from "../analysis/bounded-json.mjs";
2
+ import { exact, text } from "../evidence/artifact-structure.mjs";
3
+ import { readEditableCaseExport } from "../cases/case-export-input.mjs";
4
+ import { MAX_CONTEXT_INPUT_BYTES } from "../contracts/context-review.mjs";
5
+ import { DOMAIN_HISTORY_INPUT_SCHEMA, DOMAIN_HISTORY_INPUT_VERSION, reviewDomainHistory } from "./domain-history-review.mjs";
6
+ import { PLATFORM_CONTINUITY_INPUT_SCHEMA, PLATFORM_CONTINUITY_INPUT_VERSION, reviewPlatformContinuity } from "./platform-continuity-review.mjs";
7
+ import { STOREFRONT_INPUT_SCHEMA, STOREFRONT_INPUT_VERSION, reviewStorefront } from "./storefront-review.mjs";
8
+ import { CONNECTOR_INPUT_SCHEMA, CONNECTOR_INPUT_VERSION, reviewConnectorProvenance } from "./connector-provenance-review.mjs";
9
+ import { INCIDENT_SEQUENCE_INPUT_SCHEMA, INCIDENT_SEQUENCE_INPUT_VERSION, reviewIncidentSequence } from "./incident-sequence-review.mjs";
10
+ export { CONTEXT_INPUT_SCHEMAS } from "../contracts/context-review.mjs";
11
+ /** Adapters share the same explicit dispatch; each domain owns its validation. */
12
+ export function reviewContextInput(raw, reviewedAt) {
13
+ const input = exact(raw, ['schema', 'version', 'evidence'], 'Context review input');
14
+ if (input.schema === DOMAIN_HISTORY_INPUT_SCHEMA && input.version === DOMAIN_HISTORY_INPUT_VERSION) {
15
+ const evidence = exact(input.evidence, ['caseExport', 'caseId', 'declarations'], 'Domain history input');
16
+ const caseId = text(evidence.caseId, 'Selected Case ID', 240);
17
+ const cases = readEditableCaseExport(JSON.stringify(evidence.caseExport));
18
+ const selected = cases.find(record => record.id === caseId);
19
+ if (!selected)
20
+ throw new TypeError('The selected Case is not present in this export.');
21
+ return reviewDomainHistory(selected, evidence.declarations, reviewedAt);
22
+ }
23
+ if (input.schema === PLATFORM_CONTINUITY_INPUT_SCHEMA && input.version === PLATFORM_CONTINUITY_INPUT_VERSION)
24
+ return reviewPlatformContinuity(input.evidence, reviewedAt);
25
+ if (input.schema === STOREFRONT_INPUT_SCHEMA && input.version === STOREFRONT_INPUT_VERSION)
26
+ return reviewStorefront(input.evidence, reviewedAt);
27
+ if (input.schema === CONNECTOR_INPUT_SCHEMA && input.version === CONNECTOR_INPUT_VERSION)
28
+ return reviewConnectorProvenance(input.evidence, reviewedAt);
29
+ if (input.schema === INCIDENT_SEQUENCE_INPUT_SCHEMA && input.version === INCIDENT_SEQUENCE_INPUT_VERSION)
30
+ return reviewIncidentSequence(input.evidence, reviewedAt);
31
+ throw new TypeError('Unsupported context review schema or version.');
32
+ }
33
+ export function parseContextInput(input) {
34
+ return parseBoundedJson(input, { label: 'Context review input', maximumBytes: MAX_CONTEXT_INPUT_BYTES });
35
+ }
@@ -0,0 +1,81 @@
1
+ import { createIntakeReport } from "./intake-report.mjs";
2
+ import { createLinkIntake } from "./link-intake.mjs";
3
+ import { decodeQrPixels } from "./qr-intake.mjs";
4
+ import { sha256ArtifactBytes } from "../evidence/artifact-integrity.mjs";
5
+ import { MAX_DOCUMENT_PARTS, MAX_DOCUMENT_TEXT_BYTES, MAX_DOCUMENT_TOTAL_IMAGE_PIXELS } from "../contracts/document-intake.mjs";
6
+ /** The format adapters provide inert text/pixels; this owner retains provenance and links. */
7
+ export async function createDocumentIntake(bytes, kind, reviewedAt) {
8
+ const base = await createIntakeReport(bytes, kind, reviewedAt), links = createLinkIntake();
9
+ const parts = [], notes = new Set();
10
+ let textBytes = 0, imagePixels = 0;
11
+ let state = 'reviewed', pageCount = null, reviewedPages = 0;
12
+ const encoder = new TextEncoder();
13
+ function partial(note) { if (state === 'reviewed')
14
+ state = 'partial'; notes.add(note); }
15
+ async function part(input, original) {
16
+ if (parts.length >= MAX_DOCUMENT_PARTS) {
17
+ partial('Further document parts exceed the review bound.');
18
+ return null;
19
+ }
20
+ const value = { ...input, id: `part-${parts.length + 1}`, parentDigestSha256: base.source.digestSha256,
21
+ digestSha256: await sha256ArtifactBytes(original), byteLength: original.byteLength };
22
+ parts.push(value);
23
+ return { partId: value.id, page: value.page };
24
+ }
25
+ return {
26
+ partial,
27
+ unavailable(reason, note) { state = reason; notes.add(note); },
28
+ pages(total, reviewed) { pageCount = total; reviewedPages = reviewed; },
29
+ async text(value, page, original) {
30
+ if (value.length > MAX_DOCUMENT_TEXT_BYTES - textBytes) {
31
+ partial('Further extracted text exceeds the review bound.');
32
+ return;
33
+ }
34
+ const encoded = encoder.encode(value);
35
+ textBytes += encoded.byteLength;
36
+ if (textBytes > MAX_DOCUMENT_TEXT_BYTES) {
37
+ partial('Further extracted text exceeds the review bound.');
38
+ return;
39
+ }
40
+ const location = await part({ kind: 'text', page, identity: original ? 'original_part_bytes' : 'extracted_text', state: 'reviewed' }, original ?? encoded);
41
+ if (location)
42
+ links.addText(value, 'document_text', location);
43
+ },
44
+ async destinations(values, page, original) {
45
+ const encoded = encoder.encode(JSON.stringify(values));
46
+ if (encoded.byteLength > MAX_DOCUMENT_TEXT_BYTES - textBytes) {
47
+ partial('Further document links exceed the review bound.');
48
+ return;
49
+ }
50
+ textBytes += encoded.byteLength;
51
+ const location = await part({ kind: 'links', page, identity: original ? 'original_part_bytes' : 'extracted_links', state: 'reviewed' }, original ?? encoded);
52
+ if (location)
53
+ for (const value of values)
54
+ links.add(value, 'document_link', '', null, 0, location);
55
+ },
56
+ async image(image, page, original) {
57
+ imagePixels += image.width * image.height;
58
+ if (imagePixels > MAX_DOCUMENT_TOTAL_IMAGE_PIXELS) {
59
+ partial('Further image pixels exceed the aggregate QR review bound.');
60
+ return;
61
+ }
62
+ const decoded = decodeQrPixels(image);
63
+ const location = await part({ kind: 'image', page, identity: original ? 'original_part_bytes' : 'decoded_rgba_pixels', state: decoded.bounded || decoded.structured ? 'partial' : 'reviewed' }, original ?? new Uint8Array(image.pixels.buffer, image.pixels.byteOffset, image.pixels.byteLength));
64
+ if (location)
65
+ for (const value of decoded.texts)
66
+ links.addQr(value, 'document_qr', location);
67
+ if (decoded.bounded || decoded.structured)
68
+ partial('Some QR candidates were bounded or require multi-symbol reassembly.');
69
+ },
70
+ finish() {
71
+ const result = links.result();
72
+ for (const limitation of result.limitations)
73
+ partial(limitation);
74
+ if (result.bounded)
75
+ partial('Further extracted links exceed the review bound.');
76
+ return { targets: result.targets, report: { ...base, links: result.links,
77
+ coverage: { ...base.coverage, state: state === 'reviewed' ? 'reviewed' : 'partial', reviewedParts: parts.length, rejectedLinks: result.rejected },
78
+ documentReview: { state, pageCount, reviewedPages, parts, notes: [...notes] } } };
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,133 @@
1
+ import { SaxesParser } from 'saxes';
2
+ import { extractBoundedZipEntries } from "../interchange/bounded-zip-extraction.mjs";
3
+ import { decodeEvidencePng } from "../evidence/png-pixels.mjs";
4
+ import { createDocumentIntake } from "./document-intake.mjs";
5
+ import { MAX_DOCUMENT_PARTS, MAX_DOCUMENT_DECODED_BYTES, MAX_DOCUMENT_TEXT_BYTES, MAX_DOCUMENT_XML_DEPTH, MAX_DOCUMENT_XML_NODES } from "../contracts/document-intake.mjs";
6
+ import { MAX_MESSAGE_INTAKE_BYTES, MAX_INTAKE_LINKS, MAX_INTAKE_URL_LENGTH } from "../contracts/message-intake.mjs";
7
+ const WORD_NAMESPACES = new Set(['http://schemas.openxmlformats.org/wordprocessingml/2006/main', 'http://purl.oclc.org/ooxml/wordprocessingml/main']);
8
+ const RELATIONSHIP_NAMESPACES = new Set(['http://schemas.openxmlformats.org/package/2006/relationships', 'http://purl.oclc.org/ooxml/package/relationships']);
9
+ const WORD_PART = /^word\/(?:document|header\d+|footer\d+|footnotes|endnotes|comments)\.xml$/u;
10
+ const RELATIONSHIP_PART = /^word\/(?:[^/]+\/)*_rels\/[^/]+\.rels$/u;
11
+ const MEDIA_PART = /^word\/media\/[^/]+$/u;
12
+ function archiveKey(name) {
13
+ const parts = name.endsWith('/') ? name.slice(0, -1).split('/') : name.split('/');
14
+ if (!name || name.length > 512 || /[\\\x00-\x1f\x7f]/u.test(name) || parts.some(part => !part || part === '.' || part === '..'))
15
+ throw new TypeError('The document contains an unsafe archive path.');
16
+ return name.normalize('NFC').toLowerCase();
17
+ }
18
+ function xmlText(bytes) {
19
+ const encoding = bytes[0] === 0xff && bytes[1] === 0xfe || bytes[0] === 0x3c && bytes[1] === 0 ? 'utf-16le'
20
+ : bytes[0] === 0xfe && bytes[1] === 0xff || bytes[0] === 0 && bytes[1] === 0x3c ? 'utf-16be' : 'utf-8';
21
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes);
22
+ }
23
+ export async function reviewDocxInput(bytes, reviewedAt) {
24
+ const review = await createDocumentIntake(bytes, 'docx', reviewedAt);
25
+ if (bytes[0] === 0xd0 && bytes[1] === 0xcf) {
26
+ review.unavailable('unsupported', 'This is a compound document, which may be encrypted or a legacy format. Export an unencrypted DOCX copy for review.');
27
+ return review.finish();
28
+ }
29
+ let unsupportedParts = 0;
30
+ const { files } = extractBoundedZipEntries(bytes, {
31
+ maximumEntries: MAX_DOCUMENT_PARTS, maximumSelectedBytes: MAX_DOCUMENT_DECODED_BYTES,
32
+ selectedBytesExceededMessage: 'The document exceeds the decoded-byte review bound.', metadataMismatchMessage: 'The DOCX archive is encrypted, malformed or outside supported ZIP bounds.',
33
+ keyForName: archiveKey,
34
+ inspect(entry, metadata) {
35
+ const key = archiveKey(entry.name);
36
+ if (metadata.kind === 'special')
37
+ throw new TypeError('Document archives cannot contain special filesystem entries.');
38
+ const xml = entry.name === '[Content_Types].xml' || WORD_PART.test(entry.name) || RELATIONSHIP_PART.test(entry.name);
39
+ const png = MEDIA_PART.test(entry.name) && /\.png$/iu.test(entry.name);
40
+ if (entry.name.startsWith('word/embeddings/') || /(?:vbaProject\.bin|\.jpe?g|\.webp|\.svg|\.wmf|\.emf|\.gif)$/iu.test(entry.name))
41
+ unsupportedParts++;
42
+ return { key, selected: xml || png, maximumBytes: png ? MAX_MESSAGE_INTAKE_BYTES : MAX_DOCUMENT_TEXT_BYTES, exceededMessage: 'A document part exceeds the local review bound.' };
43
+ },
44
+ });
45
+ if (!files.has('[Content_Types].xml') || !files.has('word/document.xml'))
46
+ throw new TypeError('The selected archive is not a supported DOCX document.');
47
+ let nodes = 0, declaredMain = false, externalResources = 0;
48
+ function parseXml(input, callbacks) {
49
+ const parser = new SaxesParser({ xmlns: true });
50
+ let depth = 0;
51
+ parser.on('doctype', () => { throw new TypeError('Document XML declarations cannot define entities.'); });
52
+ parser.on('error', () => { throw new TypeError('A document XML part is malformed.'); });
53
+ parser.on('opentag', tag => {
54
+ if (++nodes > MAX_DOCUMENT_XML_NODES || ++depth > MAX_DOCUMENT_XML_DEPTH)
55
+ throw new TypeError('Document XML exceeds the structural review bound.');
56
+ callbacks.open?.(tag);
57
+ });
58
+ parser.on('closetag', tag => { callbacks.close?.(tag); depth--; });
59
+ parser.on('text', value => callbacks.text?.(value));
60
+ parser.on('cdata', value => callbacks.text?.(value));
61
+ const inputText = xmlText(input);
62
+ for (let offset = 0; offset < inputText.length; offset += 4096)
63
+ parser.write(inputText.slice(offset, offset + 4096));
64
+ parser.close();
65
+ }
66
+ const attr = (tag, name) => Object.values(tag.attributes).find(value => value.local === name && value.uri === '')?.value;
67
+ parseXml(files.get('[Content_Types].xml'), { open(tag) {
68
+ if (tag.local === 'Override' && tag.uri === 'http://schemas.openxmlformats.org/package/2006/content-types'
69
+ && attr(tag, 'PartName') === '/word/document.xml' && attr(tag, 'ContentType') === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml')
70
+ declaredMain = true;
71
+ } });
72
+ if (!declaredMain)
73
+ throw new TypeError('The selected archive does not declare an ordinary DOCX document. Macro-enabled and other document types are not interpreted as DOCX.');
74
+ for (const [name, body] of files) {
75
+ if (WORD_PART.test(name)) {
76
+ try {
77
+ let text = '', textDepth = 0;
78
+ parseXml(body, { open(tag) { if (WORD_NAMESPACES.has(tag.uri) && ['t', 'instrText'].includes(tag.local))
79
+ textDepth++; },
80
+ close(tag) { if (WORD_NAMESPACES.has(tag.uri) && ['t', 'instrText'].includes(tag.local))
81
+ textDepth--; if (WORD_NAMESPACES.has(tag.uri) && ['p', 'tab', 'br'].includes(tag.local))
82
+ text += '\n'; },
83
+ text(value) { if (textDepth) {
84
+ if (text.length + value.length > MAX_DOCUMENT_TEXT_BYTES)
85
+ throw new TypeError('Document text exceeds its review bound.');
86
+ text += value;
87
+ } } });
88
+ await review.text(text, null, body);
89
+ }
90
+ catch {
91
+ review.partial('A document text part could not be fully decoded within the XML review bounds.');
92
+ }
93
+ }
94
+ else if (RELATIONSHIP_PART.test(name)) {
95
+ try {
96
+ const destinations = [];
97
+ parseXml(body, { open(tag) {
98
+ if (tag.local !== 'Relationship' || !RELATIONSHIP_NAMESPACES.has(tag.uri) || attr(tag, 'TargetMode') !== 'External')
99
+ return;
100
+ const type = attr(tag, 'Type'), target = attr(tag, 'Target');
101
+ if (type !== 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink' && type !== 'http://purl.oclc.org/ooxml/officeDocument/relationships/hyperlink') {
102
+ externalResources++;
103
+ return;
104
+ }
105
+ if (!target || target.length > MAX_INTAKE_URL_LENGTH || destinations.length >= MAX_INTAKE_LINKS) {
106
+ review.partial('Some relationship targets exceed the link review bound.');
107
+ return;
108
+ }
109
+ destinations.push(target);
110
+ } });
111
+ await review.destinations(destinations, null, body);
112
+ }
113
+ catch {
114
+ review.partial('A relationship part could not be fully decoded within the XML review bounds.');
115
+ }
116
+ }
117
+ else if (MEDIA_PART.test(name)) {
118
+ try {
119
+ await review.image(decodeEvidencePng(body), null, body);
120
+ }
121
+ catch {
122
+ review.partial('An embedded image was unsupported, malformed or outside the pixel review bounds.');
123
+ }
124
+ }
125
+ }
126
+ if (externalResources)
127
+ review.partial(`${externalResources} external non-link resources were declared but not requested.`);
128
+ if (unsupportedParts)
129
+ review.partial(`${unsupportedParts} embedded objects or non-PNG images were not decoded.`);
130
+ // Text/relationships are declarations in the selected package, not a rendered page.
131
+ review.partial('DOCX page layout, vector artwork, embedded objects and non-PNG images are not rendered. Link relationships can include unused declarations.');
132
+ return review.finish();
133
+ }
@@ -0,0 +1,100 @@
1
+ import { caseEvidenceIncomparableReasons, compareCaseEvidence } from "../cases/case-evidence-model.mjs";
2
+ import { domainTransitionReview } from "./domain-transition-review.mjs";
3
+ import { MAX_CASE_EVIDENCE_PINS, MAX_EVIDENCE_SNAPSHOTS_PER_CASE } from "../contracts/case-portability.mjs";
4
+ import { array, domain, enumeration, exact, iso, text } from "../evidence/artifact-structure.mjs";
5
+ import { CONTEXT_REVIEW_SCHEMA, CONTEXT_REVIEW_VERSION, MAX_CONTEXT_RECORDS } from "../contracts/context-review.mjs";
6
+ export { DOMAIN_HISTORY_INPUT_SCHEMA, DOMAIN_HISTORY_INPUT_VERSION } from "../contracts/context-review.mjs";
7
+ export const DOMAIN_CHANGE_FAMILIES = ['registration', 'dns', 'mail', 'certificate', 'web'];
8
+ export function readDomainHistoryDeclarations(raw) {
9
+ const input = exact(raw, ['expectedChanges', 'retiredDependencies'], 'Domain history declarations');
10
+ const expectedChanges = array(input.expectedChanges, 'Expected changes', MAX_CONTEXT_RECORDS).map(value => {
11
+ const item = exact(value, ['family', 'start', 'end', 'reason'], 'Expected change');
12
+ iso(item.start, 'Expected window start');
13
+ iso(item.end, 'Expected window end');
14
+ if (Date.parse(item.start) > Date.parse(item.end))
15
+ throw new TypeError('Expected change window ends before it starts.');
16
+ return { family: enumeration(item.family, DOMAIN_CHANGE_FAMILIES, 'Change family'), start: item.start, end: item.end, reason: text(item.reason, 'Change reason', 500) };
17
+ });
18
+ const retiredDependencies = array(input.retiredDependencies, 'Retired dependencies', MAX_CONTEXT_RECORDS).map(value => {
19
+ const item = exact(value, ['asset', 'dependency', 'family', 'retiredAt', 'source'], 'Retired dependency');
20
+ domain(item.asset, 'Owned asset');
21
+ domain(item.dependency, 'Dependency hostname');
22
+ iso(item.retiredAt, 'Retirement time');
23
+ return { asset: item.asset, dependency: item.dependency, family: enumeration(item.family, DOMAIN_CHANGE_FAMILIES, 'Dependency family'), retiredAt: item.retiredAt, source: text(item.source, 'Retirement source', 500) };
24
+ });
25
+ return { expectedChanges, retiredDependencies };
26
+ }
27
+ function family(field) {
28
+ if (['registrar', 'createdDate', 'expiryDate', 'availability'].includes(field))
29
+ return 'registration';
30
+ if (field === 'nameservers')
31
+ return 'dns';
32
+ if (['hasMx', 'hasSpf', 'hasDmarc'].includes(field))
33
+ return 'mail';
34
+ if (field.startsWith('http') || ['activityStatus', 'pageTitle', 'websiteProbeDetail', 'faviconMatch', 'faviconNearMatch',
35
+ 'reusesOfficialAssets', 'hasPasswordField', 'hasExternalFormAction', 'pageBaselineMatch'].includes(field))
36
+ return 'web';
37
+ return null;
38
+ }
39
+ /** Input is a canonical Case from storage or the existing exact Case-export reader. */
40
+ export function reviewDomainHistory(record, declarations, reviewedAt) {
41
+ iso(reviewedAt, 'Review time');
42
+ const declared = readDomainHistoryDeclarations(declarations);
43
+ const related = (hostname) => hostname === record.domain || hostname.endsWith(`.${record.domain}`);
44
+ if (declared.retiredDependencies.some(row => !related(row.asset) && !related(row.dependency)))
45
+ throw new TypeError('A retired dependency must involve this Case domain or one of its hostnames.');
46
+ array(record.evidenceHistory, 'Case evidence history', MAX_EVIDENCE_SNAPSHOTS_PER_CASE);
47
+ array(record.evidencePins, 'Case evidence pins', MAX_CASE_EVIDENCE_PINS);
48
+ const snapshots = [...record.evidenceHistory].sort((a, b) => Date.parse(a.capturedAt) - Date.parse(b.capturedAt) || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
49
+ const observations = [];
50
+ let orderedPairs = 0;
51
+ let limitedPairs = 0;
52
+ const prompts = new Set();
53
+ for (let index = 1; index < snapshots.length; index++) {
54
+ const before = snapshots[index - 1], after = snapshots[index];
55
+ if (before.capturedAt === after.capturedAt) {
56
+ observations.push({ label: 'Concurrent snapshot observations', state: 'unknown', detail: 'These records have the same observation time; their ordering is not a change sequence.', source: `${before.id} / ${after.id}`, observedAt: after.capturedAt, hostname: record.domain });
57
+ continue;
58
+ }
59
+ orderedPairs++;
60
+ const reasons = caseEvidenceIncomparableReasons(before, after);
61
+ if (reasons.length) {
62
+ limitedPairs++;
63
+ observations.push({ label: 'Comparison coverage', state: 'partial',
64
+ detail: `Some fields are not comparable: ${reasons.map(reason => ({
65
+ 'observation-context': 'different hostname or selected-page context',
66
+ 'scan-depth': 'different collection depth',
67
+ 'collection-quality': 'missing registration values or incomplete page/favicon collection',
68
+ 'risk-model': 'different or unknown risk model',
69
+ 'opportunity-model': 'different or unknown opportunity model',
70
+ })[reason]).join('; ')}. These gaps do not establish activation, disappearance or resolution.`,
71
+ source: `${before.source} ${before.id} → ${after.source} ${after.id}`, observedAt: after.capturedAt,
72
+ hostname: after.observationHostname ?? record.domain });
73
+ }
74
+ for (const change of compareCaseEvidence(before, after)) {
75
+ const category = family(change.field);
76
+ if (!category)
77
+ continue;
78
+ const prompt = domainTransitionReview(change.field, change.before, change.after);
79
+ if (prompt)
80
+ prompts.add(prompt);
81
+ const windows = declared.expectedChanges.filter(row => row.family === category && Date.parse(row.start) <= Date.parse(after.capturedAt) && Date.parse(row.end) >= Date.parse(before.capturedAt));
82
+ observations.push({ label: `${category} · ${change.label}`, state: 'changed',
83
+ detail: `${JSON.stringify(change.before)} → ${JSON.stringify(change.after)}${windows.length ? ` · Overlaps ${windows.length} analyst-declared expected-change window(s); not automatically approved.` : ''}`,
84
+ source: `${before.source} ${before.id} → ${after.source} ${after.id}`, observedAt: after.capturedAt, hostname: after.observationHostname ?? record.domain });
85
+ }
86
+ }
87
+ for (const pin of record.evidencePins.filter(pin => pin.certificateObservation)) {
88
+ observations.push({ label: `Retained certificate · ${pin.label}`, state: pin.completeness === 'complete' && pin.truncated !== true ? 'observed' : 'partial', detail: pin.value,
89
+ source: `${pin.source} · pin ${pin.id}`, observedAt: pin.observedAt, hostname: record.domain });
90
+ }
91
+ for (const row of declared.expectedChanges)
92
+ observations.push({ label: `Expected ${row.family} change`, state: 'reported', detail: `${row.start} – ${row.end} · ${row.reason}`, source: 'Analyst declaration', observedAt: null, hostname: record.domain });
93
+ for (const row of declared.retiredDependencies)
94
+ observations.push({ label: `Declared retired ${row.family} dependency`, state: 'reported', detail: `${row.asset} depended on ${row.dependency}. Check whether owned configuration still references it.`, source: row.source, observedAt: row.retiredAt, hostname: row.dependency });
95
+ const certificateCount = observations.filter(row => row.label.startsWith('Retained certificate')).length;
96
+ return { schema: CONTEXT_REVIEW_SCHEMA, version: CONTEXT_REVIEW_VERSION, kind: 'domain_history', reviewedAt, title: 'Domain history and retired dependencies', state: orderedPairs < 1 || limitedPairs > 0 ? 'partial' : 'reviewed',
97
+ summary: `Retained snapshots: ${snapshots.length}; time-ordered pairs: ${orderedPairs}; limited pairs: ${limitedPairs}; certificate pins: ${certificateCount}; declared retired dependencies: ${declared.retiredDependencies.length}.`, observations,
98
+ nextSteps: [...prompts, 'Review changed registration, DNS, mail and web evidence against declared maintenance windows.', 'Recheck owned references to retired dependencies before changing configuration; preserve evidence of legitimate transfers or rebrands.'],
99
+ limitations: ['This review uses retained Case evidence, not fresh collection. Missing or incompatible fields are not compared.', 'Certificate pins retain their own source and completeness; they are not equivalent to live TLS observations.', 'Changes and retired dependencies do not establish ownership transfer, malicious repurposing or takeover availability.'] };
100
+ }
@@ -0,0 +1,47 @@
1
+ /** Review prompts for changes already admitted by a source-aware comparator. */
2
+ export function domainTransitionReview(field, before, after) {
3
+ if (before == null || after == null)
4
+ return null;
5
+ switch (field) {
6
+ case 'createdDate':
7
+ return 'Reassess earlier relevance, exceptions and response baselines against the changed registration date. Keep the prior history; the date alone does not establish a new owner or confirmed re-registration.';
8
+ case 'availability':
9
+ return before === 'available' || after === 'available'
10
+ ? 'Review the dated registration observations before carrying earlier decisions into this context. An availability change does not by itself establish who controls the domain.'
11
+ : 'Review the registration state in its source context; sale and expiry labels do not establish deletion or a change of owner.';
12
+ case 'expiryDate':
13
+ return 'Check the reported renewal timeline. Expiry, deletion and re-registration are different events; this change does not reset earlier decisions.';
14
+ case 'registrar':
15
+ case 'registrarName':
16
+ return 'Review the registration source and any expected transfer. A registrar change is not evidence of new ownership.';
17
+ case 'nameservers':
18
+ return 'Compare the delegation change with expected hosting or DNS maintenance before revising relationships or response routes.';
19
+ case 'hasMx':
20
+ case 'hasNullMx':
21
+ case 'hasSpf':
22
+ case 'hasDmarc':
23
+ return 'Review the changed mail configuration against the watch reason. DNS configuration does not show whether messages were sent or an account was compromised.';
24
+ case 'hasPasswordField':
25
+ return after === true
26
+ ? 'Review the newly observed password form and its destination against the recorded concern. A login form alone is not credential theft.'
27
+ : 'Review the current page evidence before changing the assessment; removal of one form does not establish that the incident is resolved.';
28
+ case 'hasExternalFormAction':
29
+ return 'Check the observed form destination and legitimate service use before updating the assessment. No form was submitted by this review.';
30
+ case 'faviconMatch':
31
+ case 'faviconNearMatch':
32
+ case 'reusesOfficialAssets':
33
+ case 'pageBaselineMatch':
34
+ return 'Review the retained Brand comparison and legitimate shared assets. An identity match or its disappearance is not an infringement or resolution verdict.';
35
+ case 'activityStatus':
36
+ return after === 'unreachable' || after === 'no_site'
37
+ ? 'The website observation is inconclusive; do not treat it as disappearance or resolution. Recheck deliberately within the existing collection scope.'
38
+ : 'Review the changed site activity against the watch reason. A legitimate launch can change the same fields as an incident.';
39
+ case 'pageTitle':
40
+ case 'httpFinalOrigin':
41
+ case 'httpCrossOriginRedirect':
42
+ case 'faviconHash':
43
+ return 'Review the changed page or destination against the retained concern and expected site changes. Preserve the exact observation scope before making a Case or priority decision.';
44
+ default:
45
+ return null;
46
+ }
47
+ }
@@ -1,4 +1,5 @@
1
- const MAX_ENTRIES = 24;
1
+ export const MAX_EVIDENCE_COVERAGE_ENTRIES = 24;
2
+ const MAX_ENTRIES = MAX_EVIDENCE_COVERAGE_ENTRIES;
2
3
  const MAX_ID_LENGTH = 64;
3
4
  const MAX_LABEL_LENGTH = 120;
4
5
  const MAX_LIMITATIONS = 8;
@@ -330,6 +331,13 @@ export function buildLookupEvidenceCoverageLedger(input) {
330
331
  const providers = Array.isArray(input.threatIntelligenceProviders)
331
332
  ? input.threatIntelligenceProviders
332
333
  : [];
334
+ if (Array.isArray(input.threatIntelligenceWithheld) && input.threatIntelligenceWithheld.length) {
335
+ items.push({
336
+ id: 'external-withheld', label: 'Withheld external-intelligence records', category: 'external',
337
+ status: 'unsupported', complete: false,
338
+ limitations: ['Some provider data was unsupported, unbound, malformed or over-limit. Withheld records are not evidence of no findings and cannot contribute to Risk.'],
339
+ });
340
+ }
333
341
  for (const [providerIndex, providerValue] of providers.entries()) {
334
342
  if (items.length >= MAX_ENTRIES)
335
343
  break;
@@ -0,0 +1,68 @@
1
+ import { parseBoundedJsonObject, boundedJsonLimitsForBytes } from "../analysis/bounded-json.mjs";
2
+ import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { MAX_MESSAGE_INTAKE_BYTES, MAX_INTAKE_URL_LENGTH } from "../contracts/message-intake.mjs";
5
+ import { MAX_HAR_ENTRIES, HAR_TIMING_PHASES } from "../contracts/har-review.mjs";
6
+ import { createIntakeReport } from "./intake-report.mjs";
7
+ import { createLinkIntake } from "./link-intake.mjs";
8
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
9
+ const milliseconds = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
10
+ function mimeCategory(raw) {
11
+ if (typeof raw !== 'string' || !raw.trim())
12
+ return 'unknown';
13
+ const mime = raw.split(';', 1)[0].trim().toLowerCase();
14
+ if (['text/html', 'application/xhtml+xml'].includes(mime))
15
+ return 'html';
16
+ if (['text/javascript', 'application/javascript', 'application/ecmascript', 'text/ecmascript'].includes(mime))
17
+ return 'script';
18
+ if (mime === 'application/json' || mime.endsWith('+json'))
19
+ return 'json';
20
+ if (['application/xml', 'text/xml'].includes(mime) || mime.endsWith('+xml'))
21
+ return 'xml';
22
+ if (mime.startsWith('image/'))
23
+ return 'image';
24
+ if (mime.startsWith('font/') || mime.includes('font'))
25
+ return 'font';
26
+ if (mime.startsWith('audio/') || mime.startsWith('video/'))
27
+ return 'media';
28
+ if (mime.startsWith('text/'))
29
+ return 'text';
30
+ return mime === 'application/octet-stream' ? 'binary' : 'other';
31
+ }
32
+ /** Retain declared request sequence, not cookies, headers, bodies or replay authority. */
33
+ export async function reviewHarInput(bytes, reviewedAt) {
34
+ const base = await createIntakeReport(bytes, 'har', reviewedAt);
35
+ const root = parseBoundedJsonObject(new TextDecoder('utf-8', { fatal: true }).decode(bytes), { label: 'HAR input', maximumBytes: MAX_MESSAGE_INTAKE_BYTES,
36
+ limits: { ...boundedJsonLimitsForBytes(MAX_MESSAGE_INTAKE_BYTES), maximumContainerItems: MAX_HAR_ENTRIES } });
37
+ const log = record(root.log);
38
+ if (log.version !== '1.2' || !Array.isArray(log.entries) || log.entries.length > MAX_HAR_ENTRIES)
39
+ throw new TypeError('Select a HAR 1.2 archive within the entry review bound.');
40
+ const links = createLinkIntake(), entries = [];
41
+ let invalidEntries = 0;
42
+ for (const [index, raw] of log.entries.entries()) {
43
+ const entry = record(raw), request = record(entry.request), response = record(entry.response), content = record(response.content), timing = record(entry.timings);
44
+ if (!Object.keys(request).length || !Object.keys(response).length) {
45
+ invalidEntries++;
46
+ continue;
47
+ }
48
+ const url = parseCredentialFreeHttpUrl(request.url, MAX_INTAKE_URL_LENGTH);
49
+ const method = typeof request.method === 'string' ? request.method.toUpperCase() : null;
50
+ const resource = typeof entry._resourceType === 'string' ? entry._resourceType.toLowerCase() : null;
51
+ entries.push({ sequence: index + 1, startedAt: typeof entry.startedDateTime === 'string' ? normalizeExplicitIsoTimestamp(entry.startedDateTime) : null,
52
+ origin: url?.origin ?? null,
53
+ method: method === null ? 'unknown' : ['GET', 'HEAD', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH', 'CONNECT', 'TRACE'].includes(method) ? method : 'other',
54
+ status: typeof response.status === 'number' && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? response.status : null,
55
+ durationMs: milliseconds(entry.time), mimeCategory: mimeCategory(content.mimeType),
56
+ timings: Object.fromEntries(HAR_TIMING_PHASES.map(phase => [phase, milliseconds(timing[phase])])),
57
+ resourceType: resource === null ? 'unknown' : ['document', 'script', 'stylesheet', 'image', 'font', 'media', 'xhr', 'fetch', 'ping', 'websocket'].includes(resource) ? resource : 'other',
58
+ reportedFailure: typeof entry._error === 'string' && Boolean(entry._error) });
59
+ // Queries may carry access tokens or account identifiers. Even transient
60
+ // destination actions from this archive contain only its origin.
61
+ if (url)
62
+ links.add(url.origin, 'har_request');
63
+ }
64
+ const extracted = links.result();
65
+ return { targets: extracted.targets, report: { ...base, links: extracted.links, harReview: { entries, invalidEntries },
66
+ coverage: { ...base.coverage, state: invalidEntries || extracted.bounded || extracted.rejected ? 'partial' : 'reviewed', reviewedParts: entries.length,
67
+ rejectedLinks: extracted.rejected, boundsReached: [...extracted.limitations, ...(extracted.bounded ? ['Destination list; the admitted request timeline remains available'] : [])] } } };
68
+ }
@@ -0,0 +1,131 @@
1
+ import { parseBoundedJson, boundedJsonLimitsForBytes } from "../analysis/bounded-json.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { exact, exactOptional } from "../evidence/artifact-structure.mjs";
4
+ import { MAX_MESSAGE_INTAKE_BYTES } from "../contracts/message-intake.mjs";
5
+ import { IDENTITY_EVENTS_INPUT_SCHEMA, IDENTITY_EVENTS_INPUT_VERSION, MAX_IDENTITY_EVENTS } from "../contracts/identity-events.mjs";
6
+ import { createIntakeReport } from "./intake-report.mjs";
7
+ const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
+ const instant = (value) => typeof value === 'string' ? normalizeExplicitIsoTimestamp(value) : null;
9
+ const guid = (value) => typeof value === 'string' && /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/iu.test(value) ? value.toLowerCase() : null;
10
+ function applicationId(value, provider) {
11
+ return provider === 'entra' ? guid(value) : typeof value === 'string' && /^0oa[a-z\d]{17}$/iu.test(value) ? value : null;
12
+ }
13
+ function protocol(value) {
14
+ switch (value) {
15
+ case 'oAuth2': return 'oauth2';
16
+ case 'deviceCode': return 'device_code';
17
+ case 'saml20': return 'saml';
18
+ case 'wsFederation': return 'ws_federation';
19
+ case 'ropc': return 'password_grant';
20
+ default: return 'unknown';
21
+ }
22
+ }
23
+ function eventKind(value) {
24
+ switch (value) {
25
+ case 'user.authentication.sso': return 'application_access';
26
+ case 'user.session.start': return 'session_start';
27
+ case 'user.authentication.auth_via_mfa':
28
+ case 'user.authentication.verify': return 'credential_check';
29
+ case 'app.oauth2.consent.grant': return 'consent';
30
+ default: return 'other';
31
+ }
32
+ }
33
+ /** IDs are replaced in memory; actor labels are meaningful only within this file. */
34
+ function extractEvents(input, provider) {
35
+ const actors = new Map(), events = [];
36
+ let invalidEvents = 0;
37
+ const actor = (value) => {
38
+ if (typeof value !== 'string' || !value || value.length > 240)
39
+ return null;
40
+ let label = actors.get(value);
41
+ if (!label) {
42
+ label = `Actor ${actors.size + 1}`;
43
+ actors.set(value, label);
44
+ }
45
+ return label;
46
+ };
47
+ for (const [index, value] of input.entries()) {
48
+ const row = record(value);
49
+ if (provider === 'entra') {
50
+ if (!Object.hasOwn(row, 'createdDateTime') || !Object.hasOwn(row, 'status')) {
51
+ invalidEvents++;
52
+ continue;
53
+ }
54
+ const app = applicationId(row.appId, provider), code = record(row.status).errorCode;
55
+ events.push({ sequence: index + 1, actorLabel: actor(row.userId), applicationIds: app ? [app] : [], tenantId: guid(row.resourceTenantId),
56
+ // A client category (for example Browser) is not an authentication protocol.
57
+ protocol: protocol(row.authenticationProtocol), kind: 'sign_in', occurredAt: instant(row.createdDateTime),
58
+ result: typeof code === 'number' && Number.isSafeInteger(code) && code >= 0 ? code === 0 ? 'success' : 'failure' : 'unknown' });
59
+ }
60
+ else {
61
+ if (typeof row.eventType !== 'string' || !Object.hasOwn(row, 'published')) {
62
+ invalidEvents++;
63
+ continue;
64
+ }
65
+ const targets = Array.isArray(row.target) ? row.target : [];
66
+ const apps = targets.filter(target => record(target).type === 'AppInstance').map(target => applicationId(record(target).id, provider)).filter((id) => id !== null);
67
+ const outcome = record(row.outcome).result;
68
+ events.push({ sequence: index + 1, actorLabel: actor(record(row.actor).id), applicationIds: [...new Set(apps)], tenantId: null,
69
+ protocol: 'unknown', kind: eventKind(row.eventType), occurredAt: instant(row.published),
70
+ result: outcome === 'SUCCESS' ? 'success' : outcome === 'FAILURE' ? 'failure' : outcome === 'DENY' ? 'denied' : outcome === 'CHALLENGE' ? 'challenge' : 'unknown' });
71
+ }
72
+ }
73
+ return { events, invalidEvents };
74
+ }
75
+ export function readIdentityMatchScope(raw, provider) {
76
+ const source = exact(raw, ['applicationId', 'tenantId', 'actorLabel', 'startedAt', 'endedAt'], 'Identity comparison scope');
77
+ const app = applicationId(source.applicationId, provider), tenant = source.tenantId === null ? null : guid(source.tenantId);
78
+ const start = instant(source.startedAt), end = instant(source.endedAt);
79
+ if (!app || !start || !end || Date.parse(end) < Date.parse(start) || source.tenantId !== null && !tenant
80
+ || source.actorLabel !== null && (typeof source.actorLabel !== 'string' || !/^Actor [1-9]\d{0,4}$/u.test(source.actorLabel))) {
81
+ throw new TypeError('Select a valid application ID, explicit-timezone interval, and optional retained tenant or file-local actor label.');
82
+ }
83
+ return { applicationId: app, tenantId: tenant, actorLabel: source.actorLabel, startedAt: start, endedAt: end };
84
+ }
85
+ /** Exact supplied-field corroboration, not a causal or account-compromise judgement. */
86
+ export function compareIdentityEvents(review, rawScope) {
87
+ const scope = readIdentityMatchScope(rawScope, review.provider);
88
+ const compare = (selected, supplied) => selected === null ? 'not_selected' : supplied === null ? 'unavailable' : selected === supplied ? 'matched' : 'different';
89
+ return { ...review, comparison: { scope, events: review.events.map(event => {
90
+ const application = !event.applicationIds.length ? 'unavailable' : event.applicationIds.includes(scope.applicationId) ? 'matched' : 'different';
91
+ const tenant = compare(scope.tenantId, event.tenantId), actor = compare(scope.actorLabel, event.actorLabel);
92
+ const time = !event.occurredAt ? 'unavailable' : Date.parse(event.occurredAt) >= Date.parse(scope.startedAt) && Date.parse(event.occurredAt) <= Date.parse(scope.endedAt) ? 'matched' : 'different';
93
+ const fields = [application, tenant, actor, time];
94
+ return { sequence: event.sequence, application, tenant, actor, time,
95
+ state: fields.includes('different') ? 'different' : fields.includes('unavailable') ? 'incomplete' : 'matched' };
96
+ }) } };
97
+ }
98
+ export async function reviewIdentityEventInput(bytes, reviewedAt) {
99
+ const base = await createIntakeReport(bytes, 'identity', reviewedAt);
100
+ const parsed = parseBoundedJson(new TextDecoder('utf-8', { fatal: true }).decode(bytes), { label: 'Identity event input', maximumBytes: MAX_MESSAGE_INTAKE_BYTES,
101
+ limits: { ...boundedJsonLimitsForBytes(MAX_MESSAGE_INTAKE_BYTES), maximumContainerItems: MAX_IDENTITY_EVENTS } });
102
+ const root = record(parsed);
103
+ let provider, input, match = null;
104
+ if (Object.hasOwn(root, 'schema')) {
105
+ exactOptional(root, ['schema', 'version', 'provider', 'events'], ['match'], 'Identity event input');
106
+ if (root.schema !== IDENTITY_EVENTS_INPUT_SCHEMA || root.version !== IDENTITY_EVENTS_INPUT_VERSION || !['entra', 'okta'].includes(String(root.provider)))
107
+ throw new TypeError('Unsupported identity event input schema, version or provider.');
108
+ provider = root.provider;
109
+ input = root.events;
110
+ match = root.match ?? null;
111
+ }
112
+ else if (Array.isArray(parsed)) {
113
+ provider = 'okta';
114
+ input = parsed;
115
+ }
116
+ else {
117
+ provider = 'entra';
118
+ input = root.value;
119
+ }
120
+ if (!Array.isArray(input) || input.length > MAX_IDENTITY_EVENTS)
121
+ throw new TypeError('Select a sign-in value array, a System Log array, or a versioned identity event input.');
122
+ const extracted = extractEvents(input, provider);
123
+ if (input.length && !extracted.events.length)
124
+ throw new TypeError('No supported identity event records were found.');
125
+ let identityEventReview = { provider, ...extracted, sourceHasMore: typeof root['@odata.nextLink'] === 'string' && Boolean(root['@odata.nextLink']), comparison: null };
126
+ if (match !== null)
127
+ identityEventReview = compareIdentityEvents(identityEventReview, match);
128
+ return { targets: [], report: { ...base, identityEventReview, coverage: { ...base.coverage,
129
+ state: identityEventReview.invalidEvents || identityEventReview.sourceHasMore ? 'partial' : 'reviewed', reviewedParts: identityEventReview.events.length,
130
+ boundsReached: identityEventReview.sourceHasMore ? ['The selected export declares another page; it was not requested'] : [] } } };
131
+ }