@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
@@ -1,12 +1,15 @@
1
- import { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION, } from "../contracts/investigation-portability.mjs";
1
+ import { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION, MAX_LOOKUP_ASSET_LIMITATIONS, } from "../contracts/investigation-portability.mjs";
2
+ import { MAX_PROFILE_VALUES } from "../contracts/workspace-portability.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
+ import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
5
+ import { createGraphInputReader, graphInputIsIncomplete } from "./lookup-asset-graph-inputs.mjs";
2
6
  export { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION };
3
7
  const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
4
- const MAX_NODES = 72;
5
- const MAX_EDGES = 120;
6
- const MAX_VALUES = 16;
7
- const MAX_LIMITATIONS = 5;
8
+ const MAX_VALUES = 64;
9
+ const MAX_NESTED_VALUES = 32;
10
+ const MAX_LIMITATIONS = MAX_LOOKUP_ASSET_LIMITATIONS;
8
11
  const MAX_VISUAL_EDGES_PER_HUB = 10;
9
- function projectedNodeGroup(node, edges) {
12
+ function projectedNodeGroup(node, incident) {
10
13
  if (node.kind === 'target')
11
14
  return { id: 'focus', label: 'Lookup target' };
12
15
  if (node.kind === 'address' || node.kind === 'network' || node.kind === 'prefix') {
@@ -23,7 +26,6 @@ function projectedNodeGroup(node, edges) {
23
26
  return { id: 'identity', label: 'Web identity' };
24
27
  }
25
28
  if (node.kind === 'hostname') {
26
- const incident = edges.filter((edge) => edge.source === node.id || edge.target === node.id);
27
29
  if (incident.some((edge) => edge.kind === 'authorizes-name')) {
28
30
  return { id: 'certificate', label: 'Certificates' };
29
31
  }
@@ -36,12 +38,22 @@ function projectedNodeGroup(node, edges) {
36
38
  return { id: 'summary', label: 'Grouped evidence' };
37
39
  return { id: 'evidence', label: 'Other evidence' };
38
40
  }
39
- function interleaveVisualEdgesByFamily(edges, nodesById) {
41
+ function nodeGroups(nodes, edges) {
42
+ const incident = new Map();
43
+ for (const edge of edges) {
44
+ for (const id of [edge.source, edge.target]) {
45
+ const list = incident.get(id) ?? [];
46
+ list.push(edge);
47
+ incident.set(id, list);
48
+ }
49
+ }
50
+ return new Map(nodes.map((node) => [node.id, projectedNodeGroup(node, incident.get(node.id) ?? [])]));
51
+ }
52
+ function interleaveVisualEdgesByFamily(edges, groups) {
40
53
  const buckets = new Map();
41
54
  for (const edge of edges) {
42
- const endpointGroups = [nodesById.get(edge.source), nodesById.get(edge.target)]
43
- .filter((node) => Boolean(node))
44
- .map((node) => projectedNodeGroup(node, edges).id)
55
+ const endpointGroups = [groups.get(edge.source)?.id, groups.get(edge.target)?.id]
56
+ .filter((group) => Boolean(group))
45
57
  .filter((group) => group !== 'focus')
46
58
  .sort();
47
59
  const family = [...new Set(endpointGroups)].join('+') || 'evidence';
@@ -75,37 +87,13 @@ function record(value) {
75
87
  ? value
76
88
  : {};
77
89
  }
78
- function records(value, maximum = MAX_VALUES) {
79
- return Array.isArray(value)
80
- ? value.slice(0, maximum).map(record).filter((item) => Object.keys(item).length > 0)
81
- : [];
82
- }
83
90
  function text(value, maximum = 240) {
84
- return String(value ?? '')
91
+ return (typeof value === 'string' ? value.slice(0, maximum * 4) : typeof value === 'number' && Number.isFinite(value) ? String(value) : '')
85
92
  .replace(CONTROL_CHARACTERS, ' ')
86
93
  .replace(/\s+/gu, ' ')
87
94
  .trim()
88
95
  .slice(0, maximum);
89
96
  }
90
- function textList(value, maximum = MAX_VALUES) {
91
- if (!Array.isArray(value))
92
- return [];
93
- const output = [];
94
- const seen = new Set();
95
- for (const item of value.slice(0, maximum * 2)) {
96
- const normalized = text(item, 320);
97
- if (!normalized || seen.has(normalized))
98
- continue;
99
- seen.add(normalized);
100
- output.push(normalized);
101
- if (output.length >= maximum)
102
- break;
103
- }
104
- return output;
105
- }
106
- function limitations(value) {
107
- return textList(value, MAX_LIMITATIONS).map((item) => text(item, 280));
108
- }
109
97
  function hash(value) {
110
98
  let state = 2_166_136_261;
111
99
  for (let index = 0; index < value.length; index += 1) {
@@ -119,10 +107,7 @@ function nodeId(kind, value) {
119
107
  return `${kind}-${slug || 'value'}-${hash(value)}`;
120
108
  }
121
109
  function isoDate(value) {
122
- if (typeof value !== 'string')
123
- return null;
124
- const timestamp = Date.parse(value);
125
- return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
110
+ return normalizeExplicitIsoTimestamp(value);
126
111
  }
127
112
  function hostname(value) {
128
113
  const candidate = text(value, 320).toLowerCase().replace(/\.$/u, '');
@@ -161,6 +146,10 @@ function sourceCompleteness(value) {
161
146
  }
162
147
  export function buildLookupAssetGraph(input) {
163
148
  const target = hostname(input.target);
149
+ const suppliedRegistrationDomain = hostname(input.registrationDomain);
150
+ const registrationDomain = suppliedRegistrationDomain && target
151
+ && canonicalRegistrableDomain(target) === suppliedRegistrationDomain
152
+ ? suppliedRegistrationDomain : target;
164
153
  if (!target) {
165
154
  return {
166
155
  version: LOOKUP_ASSET_GRAPH_VERSION,
@@ -168,15 +157,28 @@ export function buildLookupAssetGraph(input) {
168
157
  nodes: [],
169
158
  edges: [],
170
159
  sources: [],
160
+ coverage: { inputs: [] },
171
161
  truncated: false,
172
162
  limitations: ['A normalised domain target is required before an asset graph can be projected.'],
173
163
  };
174
164
  }
175
- const observedAt = isoDate(input.observedAt);
165
+ const reader = createGraphInputReader();
166
+ const textList = (value, id, maximum = MAX_VALUES) => reader.values(id, value, maximum, (item) => {
167
+ if (typeof item !== 'string' || item.length > 320 || item.search(CONTROL_CHARACTERS) >= 0) {
168
+ return null;
169
+ }
170
+ return text(item, 320) || null;
171
+ }, (item) => item);
172
+ const records = (value, id, maximum = MAX_NESTED_VALUES) => reader.values(id, value, maximum, (item) => item && typeof item === 'object' && !Array.isArray(item) ? item : null);
173
+ const limitations = (value, id, mandatory = []) => {
174
+ const optional = textList(value, `${id}.limitations`, MAX_LIMITATIONS - mandatory.length - 1);
175
+ const row = reader.coverage.get(`${id}.limitations`);
176
+ const omitted = row.supplied - row.inspected + row.omitted + row.invalid;
177
+ return [...new Set([...mandatory, ...optional]), ...(omitted ? [`${omitted} additional source limitation entries were not retained; review the source evidence.`] : [])];
178
+ };
176
179
  const nodes = new Map();
177
180
  const edges = new Map();
178
181
  const sources = new Map();
179
- let truncated = false;
180
182
  const targetId = nodeId('target', target);
181
183
  nodes.set(targetId, { id: targetId, label: target, kind: 'target', detail: 'Lookup target' });
182
184
  const addNode = (kind, rawLabel, detail = '') => {
@@ -185,11 +187,7 @@ export function buildLookupAssetGraph(input) {
185
187
  return null;
186
188
  const id = nodeId(kind, label);
187
189
  if (!nodes.has(id)) {
188
- if (nodes.size >= MAX_NODES) {
189
- truncated = true;
190
- return null;
191
- }
192
- nodes.set(id, { id, label, kind, detail: text(detail, 180) });
190
+ nodes.set(id, { id, label, kind, detail: text(detail, 500) });
193
191
  }
194
192
  return id;
195
193
  };
@@ -199,10 +197,6 @@ export function buildLookupAssetGraph(input) {
199
197
  const id = `${edge.kind}-${hash(`${edge.source}|${edge.target}|${edge.sourceLabel}`)}`;
200
198
  if (edges.has(id))
201
199
  return;
202
- if (edges.size >= MAX_EDGES) {
203
- truncated = true;
204
- return;
205
- }
206
200
  const sourceId = `source-${hash(`${edge.sourceLabel}|${edge.href}`)}`;
207
201
  const existingSource = sources.get(sourceId);
208
202
  const sourceLimitations = [...new Set([
@@ -218,7 +212,7 @@ export function buildLookupAssetGraph(input) {
218
212
  id: sourceId,
219
213
  label: edge.sourceLabel,
220
214
  href: edge.href,
221
- observedAt: existingSource?.observedAt ?? edge.observedAt,
215
+ observedAt: existingSource && existingSource.observedAt !== edge.observedAt ? null : edge.observedAt,
222
216
  completeness: sourceCompleteness,
223
217
  limitations: sourceLimitations,
224
218
  });
@@ -231,51 +225,56 @@ export function buildLookupAssetGraph(input) {
231
225
  addEdge({ ...edge, source, target: targetNode });
232
226
  return targetNode;
233
227
  };
234
- const reviewedProfileDomains = new Set([
235
- ...textList(input.profileDomains?.official, 32),
236
- ...textList(input.profileDomains?.partner, 32),
237
- ...textList(input.profileDomains?.allowlisted, 32),
238
- ].map((value) => value.toLowerCase().replace(/\.$/u, '')));
228
+ const registrationId = registrationDomain && registrationDomain !== target
229
+ && target.endsWith(`.${registrationDomain}`)
230
+ ? connect('hostname', registrationDomain, {
231
+ kind: 'registration-namespace', label: 'within registration namespace', sourceLabel: 'Hostname classification',
232
+ observedAt: null, completeness: 'complete', limitations: ['Hostname structure does not establish common ownership or control.'],
233
+ lenses: ['all', 'delegation'], href: '#evidence-registry',
234
+ }, 'Registrable-domain authority') ?? targetId
235
+ : targetId;
236
+ const reviewedProfileDomains = new Set(['official', 'partner', 'allowlisted'].flatMap((key) => reader.values(`profile.${key}`, input.profileDomains?.[key], MAX_PROFILE_VALUES, (value) => typeof value === 'string' && value.length <= 253 ? hostname(value) : null, (value) => value)));
239
237
  const trustBoundary = (candidate, sourceHost = target, candidateOrigin = null, sourceOrigin = null) => {
240
238
  if (!candidate)
241
239
  return 'unresolved';
242
240
  if (candidateOrigin && sourceOrigin && candidateOrigin === sourceOrigin)
243
241
  return 'same_origin';
244
- if (candidate === sourceHost || candidate === target || candidate.endsWith(`.${target}`))
242
+ if (candidate === sourceHost || candidate === registrationDomain || (registrationDomain && candidate.endsWith(`.${registrationDomain}`)))
245
243
  return 'same_registrable_domain';
246
244
  if (reviewedProfileDomains.has(candidate))
247
245
  return 'reviewed_profile';
246
+ if ([...reader.coverage.values()].some((row) => row.id.startsWith('profile.') && graphInputIsIncomplete(row)))
247
+ return 'unresolved';
248
248
  return 'external';
249
249
  };
250
250
  const dnsEvidence = record(input.dnsEvidence);
251
251
  const dnsRecords = record(input.dnsRecords);
252
252
  const dnsCompleteness = sourceCompleteness(dnsEvidence);
253
- const dnsLimits = limitations(dnsEvidence.limitations);
253
+ const dnsLimits = limitations(dnsEvidence.limitations, 'dns');
254
254
  const dnsEdge = (kind, label, lenses, href = '#evidence-dns') => ({
255
255
  kind,
256
256
  label,
257
257
  sourceLabel: 'DNS',
258
- observedAt: isoDate(dnsEvidence.observedAt) || observedAt,
258
+ observedAt: isoDate(dnsEvidence.observedAt),
259
259
  completeness: dnsCompleteness,
260
260
  limitations: dnsLimits,
261
261
  lenses,
262
262
  href,
263
263
  });
264
- for (const address of [...textList(dnsRecords.a), ...textList(dnsRecords.aaaa)]) {
264
+ for (const address of [...textList(dnsRecords.a, 'dns.a'), ...textList(dnsRecords.aaaa, 'dns.aaaa')]) {
265
265
  connect('address', address, dnsEdge('resolves-to', 'resolves to', ['all']));
266
266
  }
267
- for (const alias of textList(dnsRecords.cname)) {
267
+ for (const alias of textList(dnsRecords.cname, 'dns.cname')) {
268
268
  connect('hostname', alias, dnsEdge('aliases-to', 'aliases to', ['all']));
269
269
  }
270
- for (const nameserver of textList(dnsRecords.ns)) {
270
+ for (const nameserver of textList(dnsRecords.ns, 'dns.ns')) {
271
271
  connect('hostname', nameserver, dnsEdge('uses-nameserver', 'uses nameserver', ['all', 'delegation']));
272
272
  }
273
- for (const mx of Array.isArray(dnsRecords.mx) ? dnsRecords.mx.slice(0, MAX_VALUES) : []) {
274
- const host = mxHostname(mx);
273
+ for (const host of reader.values('dns.mx', dnsRecords.mx, MAX_VALUES, mxHostname, (item) => item)) {
275
274
  if (host)
276
275
  connect('hostname', host, dnsEdge('routes-mail-to', 'routes mail to', ['all']));
277
276
  }
278
- for (const binding of records(dnsRecords.https)) {
277
+ for (const binding of records(dnsRecords.https, 'dns.https', MAX_VALUES)) {
279
278
  const serviceTarget = hostname(binding.target);
280
279
  if (serviceTarget && serviceTarget !== '.') {
281
280
  connect('hostname', serviceTarget, dnsEdge('service-binding', 'publishes HTTPS service target', ['all']), `Priority ${text(binding.priority, 12) || 'unknown'}`);
@@ -293,65 +292,69 @@ export function buildLookupAssetGraph(input) {
293
292
  kind: 'registered-via',
294
293
  label: 'registered via',
295
294
  sourceLabel: 'Registry RDAP',
296
- observedAt,
295
+ observedAt: isoDate(rdapEvidence.fetchedAt ?? rdapEvidence.observedAt),
297
296
  completeness: sourceCompleteness(rdapEvidence),
298
- limitations: limitations(rdapEvidence.limitations),
297
+ limitations: limitations(rdapEvidence.limitations, 'rdap'),
299
298
  lenses: ['all'],
300
299
  href: '#evidence-registry',
301
- }, 'Published registrar of record');
300
+ }, 'Published registrar of record', registrationId);
302
301
  }
303
302
  const delegation = record(dnsEvidence.delegation);
304
- const delegationLimits = limitations(delegation.limitations);
303
+ const delegationLimits = limitations(delegation.limitations, 'delegation');
305
304
  const registryObservationId = addNode('observation', 'Registry publication', 'Registry nameserver and glue evidence');
306
- const parentObservationId = addNode('observation', 'Recursive parent view', 'Point-in-time parent delegation observation');
307
- for (const nameserver of textList(record(delegation.registry).nameservers)) {
305
+ const parentObservationId = addNode('observation', 'Recursive nameserver observation', 'Point-in-time recursive resolver observation, not a direct parent-server query');
306
+ for (const nameserver of textList(record(delegation.registry).nameservers, 'delegation.registry.nameservers')) {
308
307
  const nameserverId = addNode('hostname', nameserver, 'Authoritative nameserver');
309
308
  if (registryObservationId && nameserverId) {
310
309
  addEdge({
311
310
  source: registryObservationId,
312
311
  target: nameserverId,
313
312
  ...dnsEdge('registry-publishes', 'registry publishes', ['delegation']),
313
+ observedAt: isoDate(rdapEvidence.fetchedAt ?? rdapEvidence.observedAt),
314
314
  sourceLabel: 'Registry delegation',
315
315
  completeness: sourceCompleteness(delegation),
316
316
  limitations: delegationLimits,
317
317
  });
318
318
  }
319
319
  }
320
- for (const nameserver of textList(record(delegation.parent).nameservers)) {
320
+ for (const nameserver of textList(record(delegation.parent).nameservers, 'delegation.parent.nameservers')) {
321
321
  const nameserverId = addNode('hostname', nameserver, 'Authoritative nameserver');
322
322
  if (parentObservationId && nameserverId) {
323
323
  addEdge({
324
324
  source: parentObservationId,
325
325
  target: nameserverId,
326
- ...dnsEdge('parent-observes', 'parent view observes', ['delegation']),
327
- sourceLabel: 'Recursive parent view',
326
+ ...dnsEdge('parent-observes', 'recursive resolver observes', ['delegation']),
327
+ observedAt: isoDate(delegation.observedAt),
328
+ sourceLabel: 'Recursive nameserver observation',
328
329
  completeness: sourceCompleteness(delegation),
329
330
  limitations: delegationLimits,
330
331
  });
331
332
  }
332
333
  }
333
- for (const authority of records(delegation.authorities)) {
334
+ for (const [authorityIndex, authority] of records(delegation.authorities, 'delegation.authorities').entries()) {
334
335
  const nameserver = hostname(authority.nameserver);
335
336
  if (!nameserver)
336
337
  continue;
337
338
  const nameserverId = addNode('hostname', nameserver, `Direct authority state: ${text(authority.state, 40) || 'unknown'}`);
338
339
  if (nameserverId) {
339
340
  addEdge({
340
- source: targetId,
341
+ source: registrationId,
341
342
  target: nameserverId,
342
343
  ...dnsEdge('direct-authority', 'queried directly', ['delegation']),
344
+ observedAt: isoDate(delegation.observedAt),
343
345
  sourceLabel: 'Direct authoritative DNS',
344
346
  completeness: authority.state === 'success' ? 'complete' : authority.state ? 'partial' : 'unknown',
345
347
  limitations: delegationLimits,
346
348
  });
347
349
  }
348
- for (const address of textList(authority.addresses, 4)) {
350
+ for (const address of textList(authority.addresses, `delegation.authorities.${authorityIndex}.addresses`)) {
349
351
  const addressId = addNode('address', address, text(authority.addressSource, 80));
350
352
  if (nameserverId && addressId) {
351
353
  addEdge({
352
354
  source: nameserverId,
353
355
  target: addressId,
354
356
  ...dnsEdge('nameserver-address', 'answered at', ['delegation']),
357
+ observedAt: isoDate(delegation.observedAt),
355
358
  sourceLabel: text(authority.addressSource, 80) || 'Nameserver address',
356
359
  completeness: authority.state === 'success' ? 'complete' : 'partial',
357
360
  limitations: delegationLimits,
@@ -373,16 +376,16 @@ export function buildLookupAssetGraph(input) {
373
376
  kind: 'observed-endpoint',
374
377
  label: 'was observed at',
375
378
  sourceLabel: 'Observed network context',
376
- observedAt: isoDate(networkContext.observedAt) || observedAt,
379
+ observedAt: isoDate(networkContext.observedAt),
377
380
  completeness: sourceCompleteness(networkContext),
378
- limitations: limitations(networkContext.limitations),
381
+ limitations: limitations(networkContext.limitations, 'network'),
379
382
  lenses: ['all'],
380
383
  href: '#evidence-network',
381
384
  });
382
385
  }
383
386
  const networkLabel = text(network.name || network.holder || network.handle, 180);
384
387
  const networkId = networkLabel
385
- ? addNode('network', networkLabel, textList(network.cidrs, 4).join(', '))
388
+ ? addNode('network', networkLabel, textList(network.cidrs, 'network.cidrs').join(', '))
386
389
  : null;
387
390
  if (endpointId && networkId) {
388
391
  addEdge({
@@ -391,14 +394,14 @@ export function buildLookupAssetGraph(input) {
391
394
  kind: 'registered-with',
392
395
  label: 'registered within',
393
396
  sourceLabel: 'IP RDAP',
394
- observedAt: isoDate(networkContext.observedAt) || observedAt,
397
+ observedAt: isoDate(networkContext.observedAt),
395
398
  completeness: sourceCompleteness(networkContext),
396
- limitations: limitations(networkContext.limitations),
399
+ limitations: limitations(networkContext.limitations, 'network'),
397
400
  lenses: ['all'],
398
401
  href: '#evidence-network',
399
402
  });
400
403
  }
401
- for (const cidr of textList(network.cidrs, 8)) {
404
+ for (const cidr of textList(network.cidrs, 'network.cidrs')) {
402
405
  const prefixId = addNode('prefix', cidr, 'Published IP RDAP network prefix');
403
406
  const prefixSource = networkId || endpointId;
404
407
  if (prefixId && prefixSource) {
@@ -408,9 +411,9 @@ export function buildLookupAssetGraph(input) {
408
411
  kind: 'publishes-prefix',
409
412
  label: 'publishes prefix',
410
413
  sourceLabel: 'IP RDAP',
411
- observedAt: isoDate(networkContext.observedAt) || observedAt,
414
+ observedAt: isoDate(networkContext.observedAt),
412
415
  completeness: sourceCompleteness(networkContext),
413
- limitations: limitations(networkContext.limitations),
416
+ limitations: limitations(networkContext.limitations, 'network'),
414
417
  lenses: ['all'],
415
418
  href: '#evidence-network',
416
419
  });
@@ -424,24 +427,25 @@ export function buildLookupAssetGraph(input) {
424
427
  kind: 'redirects-to',
425
428
  label: finalHost === target ? 'served from' : 'redirects to',
426
429
  sourceLabel: 'HTTP',
427
- observedAt: isoDate(httpEvidence.observedAt) || observedAt,
430
+ observedAt: isoDate(httpEvidence.observedAt),
428
431
  completeness: sourceCompleteness(httpEvidence),
429
- limitations: limitations(httpEvidence.limitations),
432
+ limitations: limitations(httpEvidence.limitations, 'http'),
430
433
  lenses: ['all', 'identity'],
431
434
  href: '#evidence-http',
432
435
  boundary: trustBoundary(finalHost),
433
436
  }, 'Observed final website origin')
434
437
  : null;
435
438
  const identitySource = finalHostId || targetId;
439
+ const pageObservedAt = isoDate(record(input.pageIdentity).observedAt);
436
440
  const canonicalOrigin = webOrigin(record(input.pageCanonical).url);
437
441
  if (canonicalOrigin) {
438
442
  connect('origin', canonicalOrigin.origin, {
439
443
  kind: 'declares-canonical',
440
444
  label: 'declares canonical origin',
441
445
  sourceLabel: 'HTML canonical metadata',
442
- observedAt,
446
+ observedAt: pageObservedAt,
443
447
  completeness: sourceCompleteness(input.pageIdentity),
444
- limitations: limitations(record(input.pageIdentity).limitations),
448
+ limitations: limitations(record(input.pageIdentity).limitations, 'page'),
445
449
  lenses: ['identity'],
446
450
  href: '#evidence-page',
447
451
  boundary: trustBoundary(canonicalOrigin.hostname, finalHost || target, canonicalOrigin.origin, finalOrigin?.origin ?? null),
@@ -453,63 +457,68 @@ export function buildLookupAssetGraph(input) {
453
457
  kind: 'declares-open-graph',
454
458
  label: 'declares Open Graph origin',
455
459
  sourceLabel: 'Open Graph metadata',
456
- observedAt,
460
+ observedAt: pageObservedAt,
457
461
  completeness: sourceCompleteness(input.pageIdentity),
458
- limitations: limitations(record(input.pageIdentity).limitations),
462
+ limitations: limitations(record(input.pageIdentity).limitations, 'page'),
459
463
  lenses: ['identity'],
460
464
  href: '#evidence-page',
461
465
  boundary: trustBoundary(openGraphOrigin.hostname, finalHost || target, openGraphOrigin.origin, finalOrigin?.origin ?? null),
462
466
  }, 'Publisher-declared Open Graph origin', identitySource);
463
467
  }
464
- for (const origin of textList(record(input.pageForms).externalActionOrigins)) {
468
+ for (const origin of textList(record(input.pageForms).externalActionOrigins, 'page.formOrigins')) {
465
469
  const destination = webOrigin(origin);
466
470
  if (destination) {
467
471
  connect('origin', destination.origin, {
468
472
  kind: 'form-destination',
469
473
  label: 'form may submit to',
470
474
  sourceLabel: 'Static HTML form',
471
- observedAt,
475
+ observedAt: pageObservedAt,
472
476
  completeness: sourceCompleteness(input.pageIdentity),
473
- limitations: ['A declared form action does not prove that a user submitted data or that the endpoint received it.'],
477
+ limitations: limitations(record(input.pageIdentity).limitations, 'page.forms', ['A declared form action does not prove that a user submitted data or that the endpoint received it.']),
474
478
  lenses: ['identity'],
475
479
  href: '#evidence-page',
476
480
  boundary: trustBoundary(destination.hostname, finalHost || target, destination.origin, finalOrigin?.origin ?? null),
477
481
  }, 'External form-action origin', identitySource);
478
482
  }
479
483
  }
480
- for (const origin of textList(record(input.pageResources).externalOrigins)) {
484
+ for (const origin of textList(record(input.pageResources).externalOrigins, 'page.resourceOrigins')) {
481
485
  const resource = webOrigin(origin);
482
486
  if (resource) {
483
487
  connect('origin', resource.origin, {
484
488
  kind: 'loads-from',
485
489
  label: 'references resources from',
486
490
  sourceLabel: 'Static HTML resources',
487
- observedAt,
491
+ observedAt: pageObservedAt,
488
492
  completeness: sourceCompleteness(input.pageIdentity),
489
- limitations: ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.'],
493
+ limitations: limitations(record(input.pageIdentity).limitations, 'page.resources', ['A static resource reference does not prove that a browser loaded the resource or disclosed data to it.']),
490
494
  lenses: ['identity'],
491
495
  href: '#evidence-page',
492
496
  boundary: trustBoundary(resource.hostname, finalHost || target, resource.origin, finalOrigin?.origin ?? null),
493
497
  }, 'External resource origin', identitySource);
494
498
  }
495
499
  }
496
- for (const identifier of records(record(input.pageIdentity).trackingIdentifiers, 12)) {
500
+ for (const identifier of records(record(input.pageIdentity).trackingIdentifiers, 'page.trackingIdentifiers', MAX_VALUES)) {
497
501
  const value = text(identifier.value, 160);
498
502
  if (value) {
499
503
  connect('tracker', value, {
500
504
  kind: 'declares-tracker',
501
505
  label: 'declares tracker identifier',
502
506
  sourceLabel: 'Static HTML identity',
503
- observedAt,
507
+ observedAt: pageObservedAt,
504
508
  completeness: sourceCompleteness(input.pageIdentity),
505
- limitations: ['A shared identifier is a relationship lead and does not establish common ownership or control.'],
509
+ limitations: limitations(record(input.pageIdentity).limitations, 'page.trackers', ['A shared identifier is a relationship lead and does not establish common ownership or control.']),
506
510
  lenses: ['identity'],
507
511
  href: '#evidence-page',
508
512
  }, text(identifier.type, 80), identitySource);
509
513
  }
510
514
  }
511
515
  const structuredIdentity = record(input.structuredDataIdentity);
512
- for (const entity of records(structuredIdentity.entities, 12)) {
516
+ const structuredObservedAt = isoDate(structuredIdentity.observedAt);
517
+ const structuredLimits = limitations(structuredIdentity.limitations, 'structured', [
518
+ 'Publisher-declared structured metadata is not independent identity verification.',
519
+ 'A sameAs declaration is a site-authored claim and does not establish ownership or control.',
520
+ ]);
521
+ for (const [entityIndex, entity] of records(structuredIdentity.entities, 'structured.entities').entries()) {
513
522
  const entityLabel = text(entity.name, 180);
514
523
  const declaredOrigin = webOrigin(entity.declaredOrigin);
515
524
  const entityId = entityLabel
@@ -517,40 +526,34 @@ export function buildLookupAssetGraph(input) {
517
526
  kind: 'declares-publisher',
518
527
  label: 'declares publisher',
519
528
  sourceLabel: 'Structured identity metadata',
520
- observedAt,
529
+ observedAt: structuredObservedAt,
521
530
  completeness: sourceCompleteness(structuredIdentity),
522
- limitations: [
523
- ...limitations(structuredIdentity.limitations),
524
- 'Publisher-declared structured metadata is not independent identity verification.',
525
- ].slice(0, MAX_LIMITATIONS),
531
+ limitations: structuredLimits,
526
532
  lenses: ['identity'],
527
533
  href: '#evidence-structured-identity',
528
- }, textList(entity.types, 5).join(', ') || 'Publisher-declared entity', identitySource)
534
+ }, textList(entity.types, `structured.entities.${entityIndex}.types`, MAX_NESTED_VALUES).join(', ') || 'Publisher-declared entity', identitySource)
529
535
  : identitySource;
530
536
  if (declaredOrigin) {
531
537
  connect('origin', declaredOrigin.origin, {
532
538
  kind: 'declares-origin',
533
539
  label: 'declares origin',
534
540
  sourceLabel: 'Structured identity metadata',
535
- observedAt,
541
+ observedAt: structuredObservedAt,
536
542
  completeness: sourceCompleteness(structuredIdentity),
537
- limitations: limitations(structuredIdentity.limitations),
543
+ limitations: structuredLimits,
538
544
  lenses: ['identity'],
539
545
  href: '#evidence-structured-identity',
540
546
  boundary: trustBoundary(declaredOrigin.hostname, finalHost || target, declaredOrigin.origin, finalOrigin?.origin ?? null),
541
547
  }, 'Publisher-declared structured-data origin', entityId || identitySource);
542
548
  }
543
- for (const sameAsHost of textList(entity.sameAsHosts, 12)) {
549
+ for (const sameAsHost of textList(entity.sameAsHosts, `structured.entities.${entityIndex}.sameAsHosts`, MAX_NESTED_VALUES)) {
544
550
  connect('origin', sameAsHost, {
545
551
  kind: 'declares-same-as',
546
552
  label: 'declares sameAs',
547
553
  sourceLabel: 'Structured identity metadata',
548
- observedAt,
554
+ observedAt: structuredObservedAt,
549
555
  completeness: sourceCompleteness(structuredIdentity),
550
- limitations: [
551
- ...limitations(structuredIdentity.limitations),
552
- 'A sameAs declaration is a site-authored claim and does not establish ownership or control.',
553
- ].slice(0, MAX_LIMITATIONS),
556
+ limitations: structuredLimits,
554
557
  lenses: ['identity'],
555
558
  href: '#evidence-structured-identity',
556
559
  boundary: trustBoundary(hostname(sameAsHost), finalHost || target),
@@ -572,9 +575,9 @@ export function buildLookupAssetGraph(input) {
572
575
  kind: 'presents-certificate',
573
576
  label: 'presented certificate',
574
577
  sourceLabel: 'TLS',
575
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
578
+ observedAt: isoDate(tlsEvidence.observedAt),
576
579
  completeness: sourceCompleteness(tlsEvidence),
577
- limitations: limitations(tlsEvidence.limitations),
580
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
578
581
  lenses: ['all', 'certificate'],
579
582
  href: '#evidence-tls',
580
583
  }, certificateDetail)
@@ -592,9 +595,9 @@ export function buildLookupAssetGraph(input) {
592
595
  kind: 'reviewed-hostname-match',
593
596
  label: 'reviewed hostname match',
594
597
  sourceLabel: 'TLS hostname verification',
595
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
598
+ observedAt: isoDate(tlsEvidence.observedAt),
596
599
  completeness: sourceCompleteness(tlsEvidence),
597
- limitations: limitations(tlsEvidence.limitations),
600
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
598
601
  lenses: ['certificate', 'identity'],
599
602
  href: '#evidence-tls',
600
603
  });
@@ -604,9 +607,9 @@ export function buildLookupAssetGraph(input) {
604
607
  kind: 'evaluated-certificate',
605
608
  label: 'evaluated certificate',
606
609
  sourceLabel: 'TLS hostname verification',
607
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
610
+ observedAt: isoDate(tlsEvidence.observedAt),
608
611
  completeness: sourceCompleteness(tlsEvidence),
609
- limitations: limitations(tlsEvidence.limitations),
612
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
610
613
  lenses: ['certificate'],
611
614
  href: '#evidence-tls',
612
615
  });
@@ -624,15 +627,15 @@ export function buildLookupAssetGraph(input) {
624
627
  kind: 'reviewed-runtime-trust',
625
628
  label: 'reviewed runtime trust',
626
629
  sourceLabel: 'TLS chain verification',
627
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
630
+ observedAt: isoDate(tlsEvidence.observedAt),
628
631
  completeness: sourceCompleteness(tlsEvidence),
629
- limitations: limitations(tlsEvidence.limitations),
632
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
630
633
  lenses: ['certificate'],
631
634
  href: '#evidence-tls',
632
635
  });
633
636
  }
634
637
  }
635
- for (const name of textList(record(input.tlsAltNames).dnsNames, 16)) {
638
+ for (const name of textList(record(input.tlsAltNames).dnsNames, 'tls.dnsNames')) {
636
639
  const sanId = addNode('hostname', name, 'Certificate DNS subject alternative name');
637
640
  if (sanId) {
638
641
  addEdge({
@@ -641,9 +644,9 @@ export function buildLookupAssetGraph(input) {
641
644
  kind: 'authorizes-name',
642
645
  label: 'contains SAN',
643
646
  sourceLabel: 'TLS certificate',
644
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
647
+ observedAt: isoDate(tlsEvidence.observedAt),
645
648
  completeness: sourceCompleteness(tlsEvidence),
646
- limitations: limitations(tlsEvidence.limitations),
649
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
647
650
  lenses: ['certificate', 'identity'],
648
651
  href: '#evidence-tls',
649
652
  boundary: trustBoundary(hostname(name)),
@@ -661,38 +664,48 @@ export function buildLookupAssetGraph(input) {
661
664
  kind: 'uses-key',
662
665
  label: 'uses public key',
663
666
  sourceLabel: 'TLS certificate',
664
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
667
+ observedAt: isoDate(tlsEvidence.observedAt),
665
668
  completeness: sourceCompleteness(tlsEvidence),
666
- limitations: limitations(tlsEvidence.limitations),
669
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
667
670
  lenses: ['certificate'],
668
671
  href: '#evidence-tls',
669
672
  });
670
673
  }
671
674
  }
672
675
  const issuer = record(input.tlsIssuer);
673
- const issuerOrganizations = textList(issuer.organizations, 8);
674
- const issuerCommonNames = textList(issuer.commonNames, 8);
676
+ const issuerOrganizations = textList(issuer.organizations, 'tls.issuer.organizations');
677
+ const issuerCommonNames = textList(issuer.commonNames, 'tls.issuer.commonNames');
675
678
  const issuerLabel = issuerOrganizations[0]
676
679
  || issuerCommonNames[0]
677
680
  || text(issuer.organization || issuer.commonName || issuer.CN, 180);
678
681
  if (issuerLabel) {
679
682
  const issuerId = addNode('issuer', issuerLabel, 'Certificate issuer');
680
683
  if (issuerId) {
684
+ for (const [attribute, values] of [['organization', issuerOrganizations], ['common-name', issuerCommonNames]]) {
685
+ for (const value of values) {
686
+ connect('identity', value, {
687
+ kind: `issuer-${attribute}`, label: `declares issuer ${attribute === 'organization' ? 'organisation' : 'common name'}`,
688
+ sourceLabel: 'TLS certificate', observedAt: isoDate(tlsEvidence.observedAt),
689
+ completeness: sourceCompleteness(tlsEvidence), limitations: limitations(tlsEvidence.limitations, 'tls'),
690
+ lenses: ['certificate'], href: '#evidence-tls',
691
+ }, 'Certificate issuer distinguished-name attribute', issuerId);
692
+ }
693
+ }
681
694
  addEdge({
682
695
  source: certificateId,
683
696
  target: issuerId,
684
697
  kind: 'issued-by',
685
698
  label: 'issued by',
686
699
  sourceLabel: 'TLS certificate',
687
- observedAt: isoDate(tlsEvidence.observedAt) || observedAt,
700
+ observedAt: isoDate(tlsEvidence.observedAt),
688
701
  completeness: sourceCompleteness(tlsEvidence),
689
- limitations: limitations(tlsEvidence.limitations),
702
+ limitations: limitations(tlsEvidence.limitations, 'tls'),
690
703
  lenses: ['certificate'],
691
704
  href: '#evidence-tls',
692
705
  });
693
706
  }
694
707
  }
695
- for (const finding of records(record(input.certificatePolicyReview).findings, 8)) {
708
+ for (const [findingIndex, finding] of records(record(input.certificatePolicyReview).findings, 'certificatePolicy.findings').entries()) {
696
709
  const findingId = text(finding.id, 80);
697
710
  const state = text(finding.state, 80);
698
711
  if (!findingId || state === 'not_configured')
@@ -705,21 +718,27 @@ export function buildLookupAssetGraph(input) {
705
718
  kind: 'reviewed-against-policy',
706
719
  label: 'reviewed against',
707
720
  sourceLabel: 'DNS / TLS / Brand Profile',
708
- observedAt: isoDate(record(input.certificatePolicyReview).observedAt) || observedAt,
721
+ observedAt: isoDate(record(input.certificatePolicyReview).observedAt),
709
722
  completeness: state === 'indeterminate' || state === 'no_target_policy_observed' ? 'partial' : 'complete',
710
- limitations: limitations(finding.limitations),
723
+ limitations: limitations(finding.limitations, `certificatePolicy.${findingIndex}`),
711
724
  lenses: ['certificate'],
712
725
  href: '#evidence-certificate-policy',
713
726
  });
714
727
  }
715
728
  }
716
729
  }
730
+ const retainedEdges = [...edges.values()];
731
+ const connectedNodeIds = new Set([targetId, ...[...edges.values()].flatMap((edge) => [edge.source, edge.target])]);
732
+ const retainedSourceIds = new Set(retainedEdges.map((edge) => edge.sourceId));
733
+ const inputCoverage = [...reader.coverage.values()];
734
+ const truncated = inputCoverage.some(graphInputIsIncomplete);
717
735
  return {
718
736
  version: LOOKUP_ASSET_GRAPH_VERSION,
719
737
  targetId,
720
- nodes: [...nodes.values()],
721
- edges: [...edges.values()],
722
- sources: [...sources.values()].sort((left, right) => left.label.localeCompare(right.label)),
738
+ nodes: [...nodes.values()].filter((node) => connectedNodeIds.has(node.id)),
739
+ edges: retainedEdges,
740
+ sources: [...sources.values()].filter((source) => retainedSourceIds.has(source.id)).sort((left, right) => left.label.localeCompare(right.label)),
741
+ coverage: { inputs: inputCoverage },
723
742
  truncated,
724
743
  limitations: [
725
744
  'The graph contains only settled evidence from this Lookup and does not start additional requests.',
@@ -740,10 +759,11 @@ export function countLookupAssetGraphEdgesByLens(graph) {
740
759
  export function projectLookupAssetGraph(graph, lens) {
741
760
  const acceptedEdges = graph.edges.filter((edge) => edgeMatchesLens(edge, lens));
742
761
  const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node]));
762
+ const groups = nodeGroups(graph.nodes, acceptedEdges);
743
763
  const visualEdges = [];
744
764
  const visualDegree = new Map();
745
765
  const omittedByHub = new Map();
746
- for (const edge of interleaveVisualEdgesByFamily(acceptedEdges, graphNodeById)) {
766
+ for (const edge of interleaveVisualEdgesByFamily(acceptedEdges, groups)) {
747
767
  const sourceDegree = visualDegree.get(edge.source) ?? 0;
748
768
  const targetDegree = visualDegree.get(edge.target) ?? 0;
749
769
  const saturatedHub = sourceDegree >= MAX_VISUAL_EDGES_PER_HUB
@@ -772,7 +792,7 @@ export function projectLookupAssetGraph(graph, lens) {
772
792
  const nodes = graph.nodes
773
793
  .filter((node) => nodeIds.has(node.id))
774
794
  .map((node) => {
775
- const group = projectedNodeGroup(node, visualEdges);
795
+ const group = groups.get(node.id);
776
796
  return {
777
797
  id: node.id,
778
798
  label: node.label,