@slicedearth/whoisleuth-cli 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (340) hide show
  1. package/DISCLOSURE +1 -1
  2. package/NOTICE +2 -2
  3. package/cli/argument-values.mjs +55 -0
  4. package/cli/arguments.mjs +24 -470
  5. package/cli/artifact-validation/case-response.mjs +67 -9
  6. package/cli/artifact-validation/lookup-evidence.mjs +18 -4
  7. package/cli/artifact-validation/signed-review.mjs +61 -64
  8. package/cli/artifact-verify.mjs +11 -2
  9. package/cli/assurance-arguments.mjs +56 -0
  10. package/cli/assurance-command-definitions.mjs +179 -0
  11. package/cli/assurance-command-runner.mjs +1 -1
  12. package/cli/bounded-json.mjs +1 -1
  13. package/cli/bulk-checkpoint.mjs +1 -1
  14. package/cli/bulk-command-runner.mjs +6 -8
  15. package/cli/bulk.mjs +2 -3
  16. package/cli/case-command.mjs +99 -6
  17. package/cli/case-pack.mjs +19 -4
  18. package/cli/ci-report.mjs +6 -3
  19. package/cli/collection-arguments.mjs +123 -0
  20. package/cli/collection-command-definitions.mjs +330 -0
  21. package/cli/collection-preflight.mjs +8 -1
  22. package/cli/command-argument-grammar.mjs +8 -210
  23. package/cli/command-definition.mjs +330 -0
  24. package/cli/command-reference.mjs +55 -1388
  25. package/cli/command-seeds.mjs +18 -0
  26. package/cli/compare.mjs +1 -1
  27. package/cli/config-profile.mjs +1 -1
  28. package/cli/ct-event-intake.mjs +4 -4
  29. package/cli/discover.mjs +2 -2
  30. package/cli/discovery-observation-snapshot.mjs +2 -2
  31. package/cli/discovery-scan-command-runner.mjs +7 -9
  32. package/cli/discovery-scan.mjs +1 -1
  33. package/cli/discovery-snapshot.mjs +2 -2
  34. package/cli/discovery-workflow.mjs +4 -4
  35. package/cli/domain-control-monitor.mjs +6 -6
  36. package/cli/domain-control-observations.mjs +2 -1
  37. package/cli/domain-feed.mjs +61 -0
  38. package/cli/errors.mjs +5 -6
  39. package/cli/evidence-arguments.mjs +46 -0
  40. package/cli/evidence-command-definitions.mjs +69 -0
  41. package/cli/evidence-command-runner.mjs +2 -3
  42. package/cli/evidence-signing.mjs +3 -2
  43. package/cli/external-observation-mapping.mjs +2 -2
  44. package/cli/formatters/json.mjs +1 -13
  45. package/cli/formatters/terminal-command-formats.mjs +6 -0
  46. package/cli/formatters/terminal-lookup.mjs +6 -0
  47. package/cli/history-arguments.mjs +39 -0
  48. package/cli/history-command-definitions.mjs +88 -0
  49. package/cli/indicator-set-command.mjs +32 -0
  50. package/cli/inline-command-families.mjs +6 -47
  51. package/cli/inline-command-runner.mjs +4 -31
  52. package/cli/input.mjs +2 -2
  53. package/cli/intake-command.mjs +147 -0
  54. package/cli/interchange-report.mjs +4 -2
  55. package/cli/investigation-artifacts.mjs +3 -4
  56. package/cli/investigation-package-review.mjs +2 -0
  57. package/cli/investigation-plan.mjs +32 -154
  58. package/cli/investigation-recipes.mjs +177 -0
  59. package/cli/investigation-run.mjs +78 -12
  60. package/cli/local-mmdb-review.mjs +220 -17
  61. package/cli/lookalike-calibration.mjs +1 -1
  62. package/cli/lookup-brief.mjs +2 -2
  63. package/cli/lookup-command-runner.mjs +19 -35
  64. package/cli/lookup-diff.mjs +10 -9
  65. package/cli/lookup-plan.mjs +10 -14
  66. package/cli/lookup-reconcile.mjs +6 -5
  67. package/cli/lookup-timeline.mjs +7 -6
  68. package/cli/mail-header-review.mjs +24 -247
  69. package/cli/mail-review.mjs +9 -7
  70. package/cli/network-arguments.mjs +51 -0
  71. package/cli/network-command-definitions.mjs +130 -0
  72. package/cli/network-command-runner.mjs +43 -31
  73. package/cli/offline-evidence-review.mjs +85 -6
  74. package/cli/posture.mjs +1 -1
  75. package/cli/registry-fixture-scaffold.mjs +1 -1
  76. package/cli/retained-artifact-diff.mjs +8 -7
  77. package/cli/review-arguments.mjs +132 -0
  78. package/cli/review-command-definitions.mjs +279 -0
  79. package/cli/review-command-runner.mjs +72 -159
  80. package/cli/risk-calibration.mjs +14 -12
  81. package/cli/runner.mjs +20 -32
  82. package/cli/saved-lookup.mjs +2 -2
  83. package/cli/selected-input-worker.mjs +73 -0
  84. package/cli/sharing-review.mjs +5 -64
  85. package/cli/source-reliability.mjs +2 -2
  86. package/cli/support-arguments.mjs +49 -0
  87. package/cli/support-command-definitions.mjs +209 -0
  88. package/cli/support-command-runner.mjs +4 -4
  89. package/cli/watchlist-review.mjs +43 -0
  90. package/cli/workflow-arguments.mjs +100 -0
  91. package/cli/workflow-command-definitions.mjs +103 -0
  92. package/cli/workflow-command-runner.mjs +40 -1
  93. package/docs/cli-reference.md +27 -0
  94. package/docs/cli.md +322 -4
  95. package/docs/contextual-reviews.md +178 -0
  96. package/docs/infrastructure-observations.md +126 -0
  97. package/docs/offline-intake-context.md +213 -0
  98. package/lib/analyst-taxonomy.mjs +2 -40
  99. package/lib/application-version.mjs +1 -1
  100. package/lib/availability.mjs +13 -789
  101. package/lib/bounded-contract-normalizers.mjs +4 -0
  102. package/lib/bounded-json.mjs +2 -330
  103. package/lib/browser-library-profile.mjs +62 -84
  104. package/lib/candidate-provenance-bounds.mjs +2 -0
  105. package/lib/classify.mjs +1 -1
  106. package/lib/credential-form-attribution.mjs +136 -0
  107. package/lib/credential-surface-profile.mjs +6 -2
  108. package/lib/ct-query.mjs +2 -27
  109. package/lib/ct-response-bounds.mjs +2 -9
  110. package/lib/ct-search.mjs +83 -47
  111. package/lib/dmarc-discovery.mjs +62 -0
  112. package/lib/dns-convergence-review.mjs +21 -8
  113. package/lib/dns-delegation-health.mjs +35 -15
  114. package/lib/dns-inheritance-review.mjs +53 -63
  115. package/lib/dns-intelligence.mjs +27 -11
  116. package/lib/dns-query-coverage.mjs +40 -0
  117. package/lib/domain-assurance.mjs +1 -1
  118. package/lib/domain-change-packet.mjs +12 -13
  119. package/lib/domain-change-review.mjs +32 -14
  120. package/lib/domain-control-flight-recorder.mjs +103 -27
  121. package/lib/domain-control-manifest.mjs +25 -17
  122. package/lib/domain-posture-analysis.mjs +75 -7
  123. package/lib/domain-posture-parsers.mjs +9 -0
  124. package/lib/domain-posture.mjs +45 -25
  125. package/lib/domain-registration.mjs +392 -0
  126. package/lib/evidence-export-privacy.mjs +27 -17
  127. package/lib/evidence-export.mjs +3 -3
  128. package/lib/evidence-report.mjs +5 -1
  129. package/lib/external-intelligence-risk.mjs +2 -124
  130. package/lib/favicon.mjs +13 -4
  131. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  132. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  133. package/lib/generated/retire-browser-catalog.mjs +332 -19
  134. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  135. package/lib/html-signals.mjs +13 -2
  136. package/lib/http-evidence-bounds.mjs +2 -8
  137. package/lib/http-intelligence.mjs +13 -1
  138. package/lib/idn-confusable-policy.mjs +6 -4
  139. package/lib/idn-confusables.mjs +1 -1
  140. package/lib/interchange-fidelity-registry.mjs +10 -0
  141. package/lib/lookup-child-profile-contract.mjs +28 -11
  142. package/lib/lookup-contract-primitives.mjs +1 -1
  143. package/lib/lookup-diagnostics.mjs +1 -23
  144. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  145. package/lib/lookup-progress.mjs +50 -1
  146. package/lib/lookup-response-contract.mjs +70 -40
  147. package/lib/lookup-response.mjs +5 -15
  148. package/lib/lookup-source-progress.mjs +8 -22
  149. package/lib/lookup.mjs +1 -1
  150. package/lib/operation-budget.mjs +4 -0
  151. package/lib/opportunity-scoring.mjs +2 -107
  152. package/lib/outbound-identity.mjs +1 -1
  153. package/lib/page-fingerprints.mjs +18 -1
  154. package/lib/perceptual-hash-comparison.mjs +2 -34
  155. package/lib/perceptual-hash.mjs +36 -10
  156. package/lib/portable-generator.mjs +2 -29
  157. package/lib/project-metadata.mjs +2 -8
  158. package/lib/rdap-client.mjs +11 -7
  159. package/lib/registrable-domain.mjs +2 -56
  160. package/lib/registrar-standing-contract.mjs +1 -1
  161. package/lib/registrar-standing.mjs +9 -13
  162. package/lib/registry-capabilities.mjs +2 -2
  163. package/lib/registry-capability-catalogue.mjs +27 -19
  164. package/lib/risk-calibration-summary.mjs +2 -352
  165. package/lib/risk-scoring.mjs +2 -345
  166. package/lib/scoring-evidence-quality.mjs +2 -107
  167. package/lib/semantic-version.mjs +2 -54
  168. package/lib/service-binding-dns.mjs +46 -2
  169. package/lib/smtp-transport-review.mjs +48 -19
  170. package/lib/sslbl-certificates.generated.mjs +142 -139
  171. package/lib/static-html-analysis.mjs +32 -4
  172. package/lib/structured-data-identity.mjs +1 -1
  173. package/lib/technology-evidence-role.mjs +1 -51
  174. package/lib/threat-intelligence-manifests.mjs +1 -1
  175. package/lib/threat-intelligence-runtime.mjs +1 -1
  176. package/lib/threat-intelligence-targets.mjs +1 -1
  177. package/lib/threat-intelligence-types.mjs +2 -75
  178. package/lib/tls-intelligence.mjs +30 -5
  179. package/lib/typosquat-generator.mjs +11 -2
  180. package/lib/website-enrichment.mjs +404 -0
  181. package/lib/whois-authority.mjs +43 -5
  182. package/lib/whois-chain.mjs +12 -3
  183. package/lib/whois-common-formats.mjs +3 -1
  184. package/lib/whois-contacts.mjs +13 -2
  185. package/lib/whois-normalization.mjs +3 -1
  186. package/lib/whois-parser.mjs +5 -3
  187. package/package.json +15 -14
  188. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  189. package/packages/analysis/bounded-json.mjs +330 -0
  190. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  191. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  192. package/packages/analysis/ct-query.mjs +27 -0
  193. package/packages/analysis/ct-response-bounds.mjs +9 -0
  194. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  195. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  196. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  197. package/packages/analysis/opportunity-scoring.mjs +107 -0
  198. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  199. package/packages/analysis/portable-generator.mjs +29 -0
  200. package/packages/analysis/project-metadata.mjs +8 -0
  201. package/packages/analysis/registrable-domain.mjs +56 -0
  202. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  203. package/packages/analysis/risk-scoring.mjs +348 -0
  204. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  205. package/packages/analysis/semantic-version.mjs +54 -0
  206. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  207. package/packages/cases/case-containment-handoff.mjs +195 -0
  208. package/packages/cases/case-evidence-links.mjs +151 -0
  209. package/packages/cases/case-evidence-model.mjs +89 -12
  210. package/packages/cases/case-evidence-quality.mjs +5 -0
  211. package/packages/cases/case-export-input.mjs +7 -5
  212. package/packages/cases/case-incident-context.mjs +52 -0
  213. package/packages/cases/case-markdown.mjs +11 -0
  214. package/packages/cases/case-migration-model.mjs +40 -24
  215. package/packages/cases/case-object-coverage.mjs +33 -0
  216. package/packages/cases/case-packet-correction.mjs +106 -0
  217. package/packages/cases/case-recheck-model.mjs +124 -36
  218. package/packages/cases/case-record-contracts.mjs +1 -1
  219. package/packages/cases/case-record-core.mjs +1 -1
  220. package/packages/cases/case-record-operations.mjs +111 -118
  221. package/packages/cases/case-record-projection.mjs +23 -4
  222. package/packages/cases/case-record-version-input.mjs +41 -0
  223. package/packages/cases/case-report.mjs +93 -27
  224. package/packages/cases/case-requested-evidence.mjs +146 -0
  225. package/packages/cases/case-response-actions.mjs +159 -1
  226. package/packages/cases/case-response-model.mjs +16 -1
  227. package/packages/cases/case-response-object.mjs +119 -0
  228. package/packages/cases/case-response-outcomes.mjs +108 -20
  229. package/packages/cases/case-response-packet-render.mjs +35 -12
  230. package/packages/cases/case-response-packet.mjs +55 -13
  231. package/packages/cases/case-response-records.mjs +0 -1
  232. package/packages/cases/case-response-review-inputs.mjs +60 -8
  233. package/packages/cases/case-storage-model.mjs +1 -1
  234. package/packages/cases/case-workflow-metadata.mjs +217 -0
  235. package/packages/cases/case-workflow-migration.mjs +43 -0
  236. package/packages/cases/http-summary.mjs +6 -1
  237. package/packages/comparison/favicon-similarity.mjs +1 -1
  238. package/packages/comparison/page-similarity.mjs +1 -1
  239. package/packages/comparison/relationship-evidence.mjs +21 -9
  240. package/packages/comparison/relationship-provenance.mjs +7 -1
  241. package/packages/contracts/analyst-interchange.mjs +35 -15
  242. package/packages/contracts/candidate-watch-review.mjs +54 -0
  243. package/packages/contracts/case-portability.mjs +360 -49
  244. package/packages/contracts/cli-command-semantics.mjs +5 -0
  245. package/packages/contracts/cli-grammar.mjs +6 -0
  246. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  247. package/packages/contracts/context-review.mjs +25 -0
  248. package/packages/contracts/document-intake.mjs +11 -0
  249. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  250. package/packages/contracts/domain-control-manifest.mjs +1 -1
  251. package/packages/contracts/domain-control-monitor.mjs +13 -2
  252. package/packages/contracts/domain-control-review.mjs +36 -15
  253. package/packages/contracts/external-observation-interchange.mjs +96 -28
  254. package/packages/contracts/har-review.mjs +2 -0
  255. package/packages/contracts/identity-events.mjs +4 -0
  256. package/packages/contracts/investigation-run.mjs +2 -0
  257. package/packages/contracts/ip-address.mjs +10 -0
  258. package/packages/contracts/lookup-evidence.mjs +3 -2
  259. package/packages/contracts/mail-authentication.mjs +5 -0
  260. package/packages/contracts/message-intake.mjs +37 -0
  261. package/packages/contracts/offline-comparison.mjs +4 -1
  262. package/packages/contracts/passive-mail.mjs +10 -0
  263. package/packages/contracts/risk-calibration.mjs +44 -29
  264. package/packages/contracts/schema-lifecycle.mjs +3 -1
  265. package/packages/contracts/web-capture.mjs +2 -2
  266. package/packages/contracts/workspace-portability.mjs +144 -28
  267. package/packages/evidence/collection-quality.mjs +67 -0
  268. package/packages/evidence/credential-form-attribution.mjs +60 -0
  269. package/packages/evidence/dns-query-outcome.mjs +72 -0
  270. package/packages/evidence/email-recipient.mjs +18 -0
  271. package/packages/evidence/image-regions.mjs +15 -0
  272. package/packages/evidence/lookup-target.mjs +5 -1
  273. package/packages/evidence/observation.mjs +2 -0
  274. package/packages/evidence/png-pixels.mjs +117 -0
  275. package/packages/evidence/public-address-policy.mjs +83 -0
  276. package/packages/evidence/record-identity.mjs +60 -16
  277. package/packages/evidence/technology-evidence-role.mjs +51 -0
  278. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  279. package/packages/interchange/external-findings-import.mjs +36 -29
  280. package/packages/interchange/json-output.mjs +14 -0
  281. package/packages/interchange/managed-indicator-export.mjs +36 -0
  282. package/packages/interchange/managed-indicator-set.mjs +223 -0
  283. package/packages/interchange/misp-indicator-export.mjs +93 -0
  284. package/packages/interchange/sharing-policy.mjs +56 -0
  285. package/packages/interchange/stix-indicator-export.mjs +129 -0
  286. package/packages/interchange/web-capture-import.mjs +22 -15
  287. package/packages/investigation/acquisition-decision-packet.mjs +79 -1
  288. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  289. package/packages/investigation/capture-coverage.mjs +57 -0
  290. package/packages/investigation/connector-provenance-review.mjs +100 -0
  291. package/packages/investigation/context-review.mjs +35 -0
  292. package/packages/investigation/document-intake.mjs +87 -0
  293. package/packages/investigation/docx-intake.mjs +133 -0
  294. package/packages/investigation/domain-history-review.mjs +132 -0
  295. package/packages/investigation/domain-transition-review.mjs +49 -0
  296. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  297. package/packages/investigation/har-intake.mjs +68 -0
  298. package/packages/investigation/identity-event-intake.mjs +131 -0
  299. package/packages/investigation/identity-incident-review.mjs +39 -0
  300. package/packages/investigation/incident-sequence-review.mjs +58 -0
  301. package/packages/investigation/infrastructure-observation.mjs +234 -0
  302. package/packages/investigation/intake-context.mjs +284 -0
  303. package/packages/investigation/intake-indicators.mjs +116 -0
  304. package/packages/investigation/intake-phones.mjs +155 -0
  305. package/packages/investigation/intake-report.mjs +20 -0
  306. package/packages/investigation/investigation-manifest.mjs +35 -9
  307. package/packages/investigation/link-intake.mjs +209 -0
  308. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  309. package/packages/investigation/mail-authentication-review.mjs +222 -0
  310. package/packages/investigation/mail-header-identity.mjs +209 -0
  311. package/packages/investigation/message-intake.mjs +235 -0
  312. package/packages/investigation/page-behaviour.mjs +113 -0
  313. package/packages/investigation/pdf-intake.mjs +155 -0
  314. package/packages/investigation/platform-continuity-review.mjs +60 -0
  315. package/packages/investigation/qr-intake.mjs +56 -0
  316. package/packages/investigation/requested-action-hints.mjs +11 -0
  317. package/packages/investigation/selected-input-review.mjs +17 -0
  318. package/packages/investigation/storefront-review.mjs +70 -0
  319. package/packages/monitoring/domain-feed.mjs +321 -0
  320. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  321. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  322. package/packages/workspace/brand-profile-model.mjs +48 -4
  323. package/packages/workspace/bulk-review-model.mjs +28 -26
  324. package/packages/workspace/bulk-session-model.mjs +27 -13
  325. package/packages/workspace/campaign-model.mjs +1 -1
  326. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  327. package/packages/workspace/detection-rule-model.mjs +11 -3
  328. package/packages/workspace/hostile-input.mjs +1 -1
  329. package/packages/workspace/investigation-guide.mjs +2 -2
  330. package/packages/workspace/investigation-template-model.mjs +26 -2
  331. package/packages/workspace/page-baseline.mjs +2 -2
  332. package/packages/workspace/relationship-observation-model.mjs +17 -13
  333. package/packages/workspace/shortlist-model.mjs +24 -7
  334. package/packages/workspace/watchlist-history.mjs +59 -16
  335. package/packages/workspace/watchlist-store.mjs +152 -11
  336. package/packages/workspace/website-snapshot-model.mjs +28 -3
  337. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  338. package/packages/workspace/workspace-archive.mjs +5 -4
  339. package/third-party-notices.txt +549 -16
  340. package/lib/hostname.mjs +0 -2
@@ -0,0 +1,39 @@
1
+ import { array, enumeration, exact } from "../evidence/artifact-structure.mjs";
2
+ import { IDENTITY_ACTIONS } from "../contracts/message-intake.mjs";
3
+ /** Reported actions select recovery guidance; they do not establish compromise. */
4
+ export function reviewIdentityIncident(value) {
5
+ const input = exact(value, ['reportedActions'], 'Identity incident review');
6
+ const allowed = IDENTITY_ACTIONS.map(action => action.id);
7
+ const reportedActions = [...new Set(array(input.reportedActions, 'Reported actions', allowed.length)
8
+ .map(item => enumeration(item, allowed, 'Reported action')))];
9
+ const has = (...actions) => actions.some(action => reportedActions.includes(action));
10
+ const nextSteps = [];
11
+ const add = (id, title, detail) => nextSteps.push({ id, title, detail });
12
+ if (reportedActions.length)
13
+ add('preserve', 'Preserve the message and sign-in timeline', 'Record the reported time and action, retain relevant account audit events and keep the original privately. Use an independently known account portal, not the supplied link.');
14
+ if (has('entered_password', 'approved_signin', 'entered_device_code')) {
15
+ add('sessions', 'Review and revoke suspicious account sessions', 'Use the identity provider’s documented response process. Review recent sign-ins, new authentication methods and registered devices; resetting a password alone may not invalidate existing sessions.');
16
+ }
17
+ if (has('entered_password'))
18
+ add('password', 'Change exposed and reused credentials', 'Change the affected password through a trusted route. Review other accounts where the same credential was used, without testing the supplied site.');
19
+ if (has('granted_consent', 'entered_device_code'))
20
+ add('grants', 'Review application grants and tokens', 'Identify the application/client ID in account audit records. Revoke unwanted consent and refresh tokens using the provider’s controls; check accessed mail and files and newly created inbox rules.');
21
+ if (has('executed_command'))
22
+ add('endpoint', 'Escalate for endpoint containment', 'Stop using the affected device for account recovery. Follow the organisation’s endpoint incident process, preserve the command as inert evidence and use a separate trusted device.');
23
+ if (has('entered_password', 'approved_signin', 'entered_device_code', 'granted_consent'))
24
+ add('persistence', 'Check persistence and downstream access', 'Review forwarding rules, delegates, newly authorised applications and affected data. Record the findings independently of public domain evidence.');
25
+ if (reportedActions.length)
26
+ add('followup', 'Record recovery actions and a follow-up', 'Track account recovery separately from provider reports and domain availability. A removed page does not show that an account session or application grant was revoked.');
27
+ return { reportedActions, nextSteps };
28
+ }
29
+ /** A deliberate follow-up request, never a completed recovery or compromise claim. */
30
+ export function identityRecoveryFollowUp(reportedActions, stepId) {
31
+ const review = reviewIdentityIncident({ reportedActions });
32
+ const step = review.nextSteps.find(item => item.id === stepId);
33
+ if (!step)
34
+ throw new TypeError('Select a recommendation supported by the reported actions.');
35
+ return {
36
+ statement: `Requested follow-up: ${step.title}`,
37
+ rationale: `Conditional analyst request based on reported actions, not independently verified account telemetry. ${step.detail} No recovery action or independent result is established by this request.`,
38
+ };
39
+ }
@@ -0,0 +1,58 @@
1
+ import { CASE_PIN_COMPLETENESS } from "../cases/case-response-records.mjs";
2
+ import { MAX_RESPONSE_VALUE_LENGTH, MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_LIMITATIONS, MAX_RESPONSE_LIMITATION_LENGTH } from "../contracts/case-portability.mjs";
3
+ import { CONTEXT_REVIEW_SCHEMA, CONTEXT_REVIEW_VERSION, MAX_CONTEXT_RECORDS } from "../contracts/context-review.mjs";
4
+ import { array, domain, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
5
+ export { INCIDENT_SEQUENCE_INPUT_SCHEMA, INCIDENT_SEQUENCE_INPUT_VERSION } from "../contracts/context-review.mjs";
6
+ export const INCIDENT_STAGE_KINDS = ['message', 'navigation', 'identity_prompt', 'credential_entry', 'consent', 'browser_instruction', 'clipboard', 'local_execution', 'payment', 'recovery', 'other'];
7
+ export const INCIDENT_STAGE_BASES = ['retained_observation', 'imported_record', 'reported_action'];
8
+ export function readIncidentStages(raw) {
9
+ const stages = array(raw, 'Incident stages', MAX_CONTEXT_RECORDS).map(value => {
10
+ const row = exact(value, ['id', 'kind', 'basis', 'description', 'occurredAt', 'hostname', 'source', 'reference', 'referenceSha256', 'completeness', 'limitations'], 'Incident stage');
11
+ const id = text(row.id, 'Stage ID', 64);
12
+ if (!/^[A-Za-z0-9_-]+$/u.test(id))
13
+ throw new TypeError('Stage ID must be a bounded identifier.');
14
+ iso(row.occurredAt, 'Stage occurrence time', true);
15
+ if (row.hostname !== null)
16
+ domain(row.hostname, 'Stage hostname');
17
+ if (row.referenceSha256 !== null && (typeof row.referenceSha256 !== 'string' || !HEX_DIGEST_RE.test(row.referenceSha256)))
18
+ throw new TypeError('Stage reference digest must be hexadecimal SHA-256 or null.');
19
+ return { id, kind: enumeration(row.kind, INCIDENT_STAGE_KINDS, 'Stage kind'), basis: enumeration(row.basis, INCIDENT_STAGE_BASES, 'Stage basis'),
20
+ description: text(row.description, 'Stage description', MAX_RESPONSE_VALUE_LENGTH), occurredAt: row.occurredAt, hostname: row.hostname,
21
+ source: text(row.source, 'Stage source', MAX_RESPONSE_LABEL_LENGTH), reference: text(row.reference, 'Stage reference', 500), referenceSha256: row.referenceSha256,
22
+ completeness: enumeration(row.completeness, CASE_PIN_COMPLETENESS, 'Stage completeness'),
23
+ limitations: array(row.limitations, 'Stage limitations', MAX_RESPONSE_LIMITATIONS).map(item => text(item, 'Stage limitation', MAX_RESPONSE_LIMITATION_LENGTH)), };
24
+ });
25
+ if (new Set(stages.map(row => row.id)).size !== stages.length)
26
+ throw new TypeError('Incident stage IDs must be unique.');
27
+ return stages;
28
+ }
29
+ /** Copy the selected pin's provenance without giving it stronger assurance. */
30
+ export function incidentStageFromPin(pin, kind, id) {
31
+ return readIncidentStages([{ id, kind, basis: 'retained_observation', description: pin.value, occurredAt: pin.observedAt,
32
+ hostname: pin.observationHostname ?? null, source: pin.source, reference: pin.id, referenceSha256: pin.importContentSha256 ?? null,
33
+ completeness: pin.truncated === true && pin.completeness === 'complete' ? 'partial' : pin.completeness, limitations: [...pin.limitations],
34
+ }])[0];
35
+ }
36
+ export function reviewIncidentSequence(raw, reviewedAt) {
37
+ iso(reviewedAt, 'Review time');
38
+ const stages = readIncidentStages(raw);
39
+ const counts = Object.fromEntries(INCIDENT_STAGE_BASES.map(basis => [basis, stages.filter(row => row.basis === basis).length]));
40
+ let priorTime = null, reversedTimes = 0;
41
+ for (const row of stages) {
42
+ if (row.occurredAt === null)
43
+ continue;
44
+ const time = Date.parse(row.occurredAt);
45
+ if (priorTime !== null && time < priorTime)
46
+ reversedTimes++;
47
+ priorTime = time;
48
+ }
49
+ return { schema: CONTEXT_REVIEW_SCHEMA, version: CONTEXT_REVIEW_VERSION, kind: 'incident_sequence', reviewedAt, title: 'Incident sequence',
50
+ state: !stages.length || stages.some(row => row.occurredAt === null || row.completeness !== 'complete') || reversedTimes ? 'partial' : 'reviewed',
51
+ summary: `Analyst-ordered stages: ${stages.length} (retained observations: ${counts.retained_observation}; imported records: ${counts.imported_record}; reported actions: ${counts.reported_action}).${reversedTimes ? ` Timestamp reversals needing review: ${reversedTimes}; the selected order was preserved.` : ''}`,
52
+ observations: stages.map((row, index) => ({ label: `${index + 1}. ${row.kind.replaceAll('_', ' ')} · ${row.basis.replaceAll('_', ' ')}`,
53
+ state: row.completeness === 'complete' ? 'reported' : 'partial', detail: `${row.description} · Source completeness: ${row.completeness}.${row.limitations.length ? ` ${row.limitations.join(' ')}` : ''}`,
54
+ source: `${row.source} · reference ${row.reference}${row.referenceSha256 ? ` · reference SHA-256 ${row.referenceSha256}` : ''}`, observedAt: row.occurredAt, hostname: row.hostname })),
55
+ nextSteps: ['Compare stages with the selected message and capture records; retain gaps and conflicting source times.', 'Use reported credential entry, consent or local execution to select the relevant account or device recovery review, separately from domain reporting.'],
56
+ limitations: ['Order is analyst-selected, not proof of causation or a verified attack chain. An instruction, prompt or clipboard attempt does not establish that a person followed it.', 'Sources, occurrence times and reference digests retain their supplied meaning; importing this input does not authenticate them. Unknown times are not replaced by review or save time.'],
57
+ };
58
+ }
@@ -0,0 +1,234 @@
1
+ // Source-qualified, privacy-minimised retained infrastructure evidence. No collector
2
+ // or store lives here; supplied observations never initiate network work.
3
+ import { exact, text, enumeration, array, strings, iso, boolean } from "../evidence/artifact-structure.mjs";
4
+ import { normalizeDomain } from "../evidence/domain-name.mjs";
5
+ import { canonicalIpAddress } from "../contracts/ip-address.mjs";
6
+ import { parseBoundedJson } from "../analysis/bounded-json.mjs";
7
+ import { technologyEvidenceRoles } from "../evidence/technology-evidence-role.mjs";
8
+ import { INFRASTRUCTURE_OBSERVATION_SCHEMA, INFRASTRUCTURE_OBSERVATION_VERSION, MAX_INFRASTRUCTURE_OBSERVATION_BYTES, MAX_INFRASTRUCTURE_HOSTS, MAX_INFRASTRUCTURE_DNS_ROWS, MAX_INFRASTRUCTURE_CERTIFICATES, MAX_INFRASTRUCTURE_ROLES, MAX_INFRASTRUCTURE_SOURCES, MAX_INFRASTRUCTURE_LIMITATIONS, INFRASTRUCTURE_DNS_TYPES, } from "../contracts/external-observation-interchange.mjs";
9
+ export { INFRASTRUCTURE_OBSERVATION_SCHEMA, INFRASTRUCTURE_OBSERVATION_VERSION, MAX_INFRASTRUCTURE_OBSERVATION_BYTES };
10
+ export { MAX_INFRASTRUCTURE_HOSTS, MAX_INFRASTRUCTURE_DNS_ROWS, MAX_INFRASTRUCTURE_CERTIFICATES, MAX_INFRASTRUCTURE_ROLES, INFRASTRUCTURE_DNS_TYPES };
11
+ export const INFRASTRUCTURE_SOURCE_FAMILIES = ['dns', 'certificate_log', 'tls', 'technology', 'ip_registration', 'routing', 'origin_observation', 'analyst'];
12
+ export const INFRASTRUCTURE_PROVIDER_ROLES = ['dns_operator', 'observed_edge', 'application_platform', 'framework_runtime', 'embedded_dependency', 'address_registration', 'routing_origin', 'observed_origin'];
13
+ export function infrastructureCohortLabel(cohort) {
14
+ switch (cohort.kind) {
15
+ case 'dns': return `Query ${cohort.queriedName} · owner ${cohort.ownerName} · ${cohort.type} ${cohort.aspect}`;
16
+ case 'certificate_names': return `Certificate SHA-256 ${cohort.fingerprintSha256}`;
17
+ case 'provider_role': return `${cohort.subjectType} ${cohort.subject} · ${cohort.role}`;
18
+ }
19
+ }
20
+ function hostname(raw) {
21
+ const value = text(raw, 'Infrastructure hostname', 253);
22
+ if (normalizeDomain(value) !== value)
23
+ throw new TypeError('Infrastructure hostnames must be exact canonical hostnames.');
24
+ return value;
25
+ }
26
+ function name(raw) {
27
+ const value = text(raw, 'Certificate name', 255);
28
+ hostname(value.startsWith('*.') ? value.slice(2) : value);
29
+ return value;
30
+ }
31
+ function identifier(raw, label) {
32
+ const value = text(raw, label, 120);
33
+ if (!/^[a-zA-Z0-9_-]+$/u.test(value))
34
+ throw new TypeError(`${label} must use a bounded portable identity.`);
35
+ return value;
36
+ }
37
+ function unique(values, label, key = value => String(value)) {
38
+ if (new Set(values.map(key)).size !== values.length)
39
+ throw new TypeError(`${label} contains duplicate or ambiguous identities.`);
40
+ return values;
41
+ }
42
+ function at(raw) { iso(raw, 'Infrastructure observation time'); return new Date(raw).toISOString(); }
43
+ function flag(raw) { boolean(raw, 'Infrastructure completeness'); return raw; }
44
+ /** Exact current reader. Old Case pins lacking this field remain unknown, never reconstructed. */
45
+ export function readInfrastructureObservation(raw) {
46
+ // Bound hostile object traversal before serialisation or parsing work.
47
+ const root = exact(raw, ['schema', 'version', 'id', 'target', 'observedAt', 'mode', 'scope', 'coverage', 'sources', 'dns', 'certificates', 'roles', 'limitations'], 'Infrastructure snapshot');
48
+ if (root.schema !== INFRASTRUCTURE_OBSERVATION_SCHEMA || root.version !== INFRASTRUCTURE_OBSERVATION_VERSION)
49
+ throw new TypeError('Unsupported infrastructure snapshot schema or version.');
50
+ const scope = exact(root.scope, ['hostnames', 'dnsTypes', 'selection'], 'Infrastructure scope');
51
+ const selection = enumeration(scope.selection, ['explicit_hosts', 'certificate_names'], 'Selection scope');
52
+ const hostnames = unique(array(scope.hostnames, 'Selected hostnames', MAX_INFRASTRUCTURE_HOSTS, selection === 'explicit_hosts' ? 1 : 0).map(hostname), 'Selected hostnames');
53
+ const dnsTypes = unique(array(scope.dnsTypes, 'Selected DNS types', INFRASTRUCTURE_DNS_TYPES.length).map(value => enumeration(value, INFRASTRUCTURE_DNS_TYPES, 'DNS type')), 'Selected DNS types');
54
+ const coverage = exact(root.coverage, ['state', 'truncated', 'detail'], 'Infrastructure coverage');
55
+ const sources = unique(array(root.sources, 'Infrastructure sources', MAX_INFRASTRUCTURE_SOURCES, 1).map(rawSource => {
56
+ const source = exact(rawSource, ['id', 'name', 'family', 'evidenceClass', 'reference'], 'Infrastructure source');
57
+ const reference = source.reference === null ? null : text(source.reference, 'Source reference', 300);
58
+ if (reference !== null && /[?#]|\b(?:cookie|password|bearer)\s*[:=]/iu.test(reference))
59
+ throw new TypeError('Source references must not retain query strings, fragments or credentials.');
60
+ if (reference?.includes('://')) {
61
+ let url;
62
+ try {
63
+ url = new URL(reference);
64
+ }
65
+ catch {
66
+ throw new TypeError('Source reference URL is invalid.');
67
+ }
68
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
69
+ throw new TypeError('Source references cannot retain credentials or unsafe URL schemes.');
70
+ }
71
+ return { id: identifier(source.id, 'Source ID'), name: text(source.name, 'Source name', 80), family: enumeration(source.family, INFRASTRUCTURE_SOURCE_FAMILIES, 'Source family'), evidenceClass: enumeration(source.evidenceClass, ['local_observation', 'provider_report'], 'Evidence class'), reference };
72
+ }), 'Source IDs', value => value.id);
73
+ const sourceMap = new Map(sources.map(value => [value.id, value]));
74
+ function sourceId(rawId, families) {
75
+ const id = identifier(rawId, 'Observation source ID'), source = sourceMap.get(id);
76
+ if (!source || !families.includes(source.family))
77
+ throw new TypeError('An observation requires its own matching source family.');
78
+ return id;
79
+ }
80
+ const dns = array(root.dns, 'DNS observations', MAX_INFRASTRUCTURE_DNS_ROWS).map(rawRow => {
81
+ const row = exact(rawRow, ['sourceId', 'queriedName', 'ownerName', 'type', 'observedAt', 'outcome', 'values', 'complete', 'truncated'], 'DNS observation');
82
+ const type = enumeration(row.type, INFRASTRUCTURE_DNS_TYPES, 'DNS type');
83
+ const queriedName = hostname(row.queriedName), ownerName = hostname(row.ownerName);
84
+ if (!hostnames.includes(queriedName) || !dnsTypes.includes(type))
85
+ throw new TypeError('DNS observation falls outside its explicit selected scope.');
86
+ const outcome = enumeration(row.outcome, ['answered', 'no_data', 'nxdomain', 'failed', 'not_checked'], 'DNS outcome');
87
+ const values = unique(array(row.values, 'DNS record values', 32).map(rawValue => {
88
+ const value = text(rawValue, 'DNS value', 300);
89
+ if (type === 'A' || type === 'AAAA') {
90
+ const address = canonicalIpAddress(value);
91
+ if (address !== value || (type === 'A') !== !value.includes(':'))
92
+ throw new TypeError('DNS address value does not match its type.');
93
+ }
94
+ else if (type === 'MX') {
95
+ const match = /^(\d{1,5}) ([a-z0-9.-]+)$/u.exec(value);
96
+ if (!match || Number(match[1]) > 65535)
97
+ throw new TypeError('MX records require an explicit preference and target.');
98
+ hostname(match[2]);
99
+ }
100
+ else
101
+ hostname(value);
102
+ return value;
103
+ }), 'DNS values');
104
+ if ((outcome === 'answered') !== (values.length > 0))
105
+ throw new TypeError('Answered DNS observations require values; other outcomes cannot retain answers.');
106
+ if (type === 'CNAME' && values.length > 1)
107
+ throw new TypeError('One CNAME owner cannot have competing alias targets.');
108
+ return { sourceId: sourceId(row.sourceId, ['dns']), queriedName, ownerName, type, observedAt: at(row.observedAt), outcome, values, complete: flag(row.complete), truncated: flag(row.truncated) };
109
+ });
110
+ unique(dns, 'DNS observation identities', row => JSON.stringify([row.sourceId, row.queriedName, row.ownerName, row.type, row.observedAt]));
111
+ const certificates = array(root.certificates, 'Certificate observations', MAX_INFRASTRUCTURE_CERTIFICATES).map(rawRow => {
112
+ const row = exact(rawRow, ['sourceId', 'fingerprintSha256', 'observedAt', 'names', 'namesComplete'], 'Certificate observation');
113
+ const fingerprintSha256 = text(row.fingerprintSha256, 'Certificate digest', 64);
114
+ if (!/^[a-f0-9]{64}$/u.test(fingerprintSha256))
115
+ throw new TypeError('Certificate identity requires an exact SHA-256 digest.');
116
+ return { sourceId: sourceId(row.sourceId, ['tls', 'certificate_log']), fingerprintSha256, observedAt: at(row.observedAt), names: unique(array(row.names, 'Certificate names', MAX_INFRASTRUCTURE_HOSTS, 1).map(name), 'Certificate names'), namesComplete: flag(row.namesComplete) };
117
+ });
118
+ unique(certificates, 'Certificate observation identities', row => JSON.stringify([row.sourceId, row.fingerprintSha256, row.observedAt]));
119
+ const roleFamilies = {
120
+ dns_operator: ['dns'], observed_edge: ['technology'], application_platform: ['technology'], framework_runtime: ['technology'], embedded_dependency: ['technology'], address_registration: ['ip_registration'], routing_origin: ['routing'], observed_origin: ['origin_observation'],
121
+ };
122
+ const roles = array(root.roles, 'Provider role observations', MAX_INFRASTRUCTURE_ROLES).map(rawRow => {
123
+ const row = exact(rawRow, ['sourceId', 'subject', 'subjectType', 'role', 'providerId', 'providerLabel', 'value', 'observedAt', 'complete'], 'Provider role observation');
124
+ const subjectType = enumeration(row.subjectType, ['hostname', 'address'], 'Provider subject type');
125
+ const subject = subjectType === 'hostname' ? hostname(row.subject) : text(row.subject, 'Provider address', 45);
126
+ if (subjectType === 'address' && canonicalIpAddress(subject) !== subject)
127
+ throw new TypeError('Provider role address is invalid.');
128
+ if (subjectType === 'hostname' && !hostnames.includes(subject))
129
+ throw new TypeError('Provider-role hostname falls outside the selected scope.');
130
+ const role = enumeration(row.role, INFRASTRUCTURE_PROVIDER_ROLES, 'Provider role');
131
+ const value = text(row.value, 'Provider role value', 300);
132
+ if (role === 'routing_origin') {
133
+ if (subjectType !== 'address' || !/^AS(?:[1-9]\d{0,9})$/u.test(value) || Number(value.slice(2)) > 4_294_967_295)
134
+ throw new TypeError('Routing evidence requires an address and independently sourced ASN.');
135
+ }
136
+ if (role === 'observed_origin' && normalizeDomain(value) !== value && canonicalIpAddress(value) !== value)
137
+ throw new TypeError('Independently observed origin must retain an exact hostname or address.');
138
+ return { sourceId: sourceId(row.sourceId, roleFamilies[role]), subject, subjectType, role, providerId: identifier(row.providerId, 'Provider ID'), providerLabel: text(row.providerLabel, 'Provider label', 100), value, observedAt: at(row.observedAt), complete: flag(row.complete) };
139
+ });
140
+ unique(roles, 'Provider-role observation identities', row => JSON.stringify([row.sourceId, row.subjectType, row.subject, row.role, row.providerId, row.observedAt]));
141
+ if (roles.some(row => row.subjectType === 'address' && !dns.some(answer => answer.values.includes(row.subject))))
142
+ throw new TypeError('Provider-role addresses must have explicit supporting DNS address observations within the snapshot.');
143
+ const result = { schema: INFRASTRUCTURE_OBSERVATION_SCHEMA, version: INFRASTRUCTURE_OBSERVATION_VERSION, id: identifier(root.id, 'Snapshot ID'), target: hostname(root.target), observedAt: at(root.observedAt), mode: enumeration(root.mode, ['supplied', 'selected_lookup', 'certificate_log'], 'Collection mode'), scope: { hostnames, dnsTypes, selection: enumeration(scope.selection, ['explicit_hosts', 'certificate_names'], 'Selection scope') }, coverage: { state: enumeration(coverage.state, ['complete', 'partial', 'failed'], 'Coverage state'), truncated: flag(coverage.truncated), detail: text(coverage.detail, 'Coverage detail', 500) }, sources, dns, certificates, roles, limitations: unique(strings(root.limitations, 'Infrastructure limitations', MAX_INFRASTRUCTURE_LIMITATIONS, 300), 'Infrastructure limitations') };
144
+ if ([...dns, ...certificates, ...roles].some(row => row.observedAt > result.observedAt))
145
+ throw new TypeError('Snapshot time cannot precede one of its retained observations.');
146
+ if (result.coverage.state === 'complete' && (result.coverage.truncated || dns.some(row => !row.complete || row.truncated || ['failed', 'not_checked'].includes(row.outcome)) || certificates.some(row => !row.namesComplete) || roles.some(row => !row.complete)))
147
+ throw new TypeError('Complete snapshot coverage cannot contain incomplete observations.');
148
+ if (result.coverage.state === 'complete' && hostnames.some(host => dnsTypes.some(type => !dns.some(row => row.queriedName === host && row.type === type))))
149
+ throw new TypeError('Complete DNS coverage requires every explicitly selected host/type pair.');
150
+ if (new TextEncoder().encode(`${JSON.stringify(result)}\n`).byteLength > MAX_INFRASTRUCTURE_OBSERVATION_BYTES)
151
+ throw new TypeError('Infrastructure snapshot exceeds its retained byte bound.');
152
+ return result;
153
+ }
154
+ export function normalizeInfrastructureObservation(raw) {
155
+ if (raw === undefined || raw === null)
156
+ return null;
157
+ return readInfrastructureObservation(raw);
158
+ }
159
+ export function parseInfrastructureObservation(raw) {
160
+ return readInfrastructureObservation(parseBoundedJson(raw, { label: 'Infrastructure snapshot', maximumBytes: MAX_INFRASTRUCTURE_OBSERVATION_BYTES }));
161
+ }
162
+ export function serialiseInfrastructureObservation(raw) { return `${JSON.stringify(readInfrastructureObservation(raw))}\n`; }
163
+ function dnsFactKey(row) { return JSON.stringify([row.sourceId, row.queriedName, row.ownerName, row.type]); }
164
+ function roleFactKey(row) { return JSON.stringify([row.sourceId, row.subjectType, row.subject, row.role]); }
165
+ /** A negative DNS answer has a clock too; a missing row has none. */
166
+ function observationCohorts(snapshot) {
167
+ const cohorts = new Map();
168
+ const add = (key, observedAt, complete) => {
169
+ const cohort = cohorts.get(key) ?? { times: new Set(), complete: true };
170
+ cohort.times.add(observedAt);
171
+ cohort.complete &&= complete;
172
+ cohorts.set(key, cohort);
173
+ };
174
+ for (const row of snapshot.dns) {
175
+ const complete = row.complete && !row.truncated && !['failed', 'not_checked'].includes(row.outcome);
176
+ add(dnsFactKey(row), row.observedAt, complete);
177
+ add(JSON.stringify([row.sourceId, row.queriedName, row.ownerName, row.type, 'outcome']), row.observedAt, complete);
178
+ }
179
+ for (const row of snapshot.certificates)
180
+ add(JSON.stringify([row.sourceId, row.fingerprintSha256, 'certificate_names']), row.observedAt, row.namesComplete);
181
+ for (const row of snapshot.roles)
182
+ add(roleFactKey(row), row.observedAt, row.complete);
183
+ return cohorts;
184
+ }
185
+ export function infrastructureObservationFacts(raw) {
186
+ const snapshot = readInfrastructureObservation(raw);
187
+ const sources = new Map(snapshot.sources.map(source => [source.id, source]));
188
+ const facts = [];
189
+ const dnsCohort = (row, aspect) => ({ kind: 'dns', sourceId: row.sourceId, queriedName: row.queriedName, ownerName: row.ownerName, type: row.type, aspect });
190
+ for (const row of snapshot.dns)
191
+ for (const value of row.values)
192
+ facts.push({ key: dnsFactKey(row), cohort: dnsCohort(row, 'values'), hostname: row.ownerName, family: row.type, value, observedAt: row.observedAt, source: sources.get(row.sourceId), complete: row.complete && !row.truncated });
193
+ for (const row of snapshot.dns)
194
+ facts.push({ key: JSON.stringify([row.sourceId, row.queriedName, row.ownerName, row.type, 'outcome']), cohort: dnsCohort(row, 'outcome'), hostname: row.ownerName, family: `${row.type}_outcome`, value: row.outcome, observedAt: row.observedAt, source: sources.get(row.sourceId), complete: row.complete && !row.truncated && !['failed', 'not_checked'].includes(row.outcome) });
195
+ for (const row of snapshot.certificates)
196
+ for (const value of row.names)
197
+ facts.push({ key: JSON.stringify([row.sourceId, row.fingerprintSha256, 'certificate_names']), cohort: { kind: 'certificate_names', sourceId: row.sourceId, fingerprintSha256: row.fingerprintSha256 }, hostname: value, family: 'certificate_names', value, observedAt: row.observedAt, source: sources.get(row.sourceId), complete: row.namesComplete });
198
+ for (const row of snapshot.roles)
199
+ facts.push({ key: roleFactKey(row), cohort: { kind: 'provider_role', sourceId: row.sourceId, subjectType: row.subjectType, subject: row.subject, role: row.role }, hostname: row.subject, family: row.role, value: `${row.providerId}: ${row.value}`, observedAt: row.observedAt, source: sources.get(row.sourceId), complete: row.complete });
200
+ return { snapshot, facts };
201
+ }
202
+ export function compareInfrastructureObservations(earlierRaw, laterRaw) {
203
+ const earlier = infrastructureObservationFacts(earlierRaw), later = infrastructureObservationFacts(laterRaw);
204
+ const scope = (row) => JSON.stringify({ target: row.target, mode: row.mode, selection: row.scope.selection, hostnames: [...row.scope.hostnames].sort(), dnsTypes: [...row.scope.dnsTypes].sort(), sources: [...row.sources].sort((a, b) => a.id.localeCompare(b.id)) });
205
+ const comparable = scope(earlier.snapshot) === scope(later.snapshot) && earlier.snapshot.observedAt < later.snapshot.observedAt;
206
+ const complete = comparable && [earlier.snapshot, later.snapshot].every(row => row.coverage.state === 'complete' && !row.coverage.truncated);
207
+ const beforeCohorts = observationCohorts(earlier.snapshot), afterCohorts = observationCohorts(later.snapshot);
208
+ const groups = new Map();
209
+ for (const [side, values] of [['before', earlier.facts], ['after', later.facts]])
210
+ for (const fact of values) {
211
+ const group = groups.get(fact.key) ?? { cohort: fact.cohort, hostname: fact.hostname, family: fact.family, source: fact.source, before: [], after: [], complete: true };
212
+ if (fact.hostname < group.hostname)
213
+ group.hostname = fact.hostname;
214
+ group[side].push(fact.value);
215
+ group.complete &&= fact.complete;
216
+ groups.set(fact.key, group);
217
+ }
218
+ const rows = [...groups.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([key, row]) => {
219
+ row.before = [...new Set(row.before)].sort();
220
+ row.after = [...new Set(row.after)].sort();
221
+ const before = beforeCohorts.get(key), after = afterCohorts.get(key);
222
+ const beforeTimes = [...before?.times ?? []].sort(), afterTimes = [...after?.times ?? []].sort();
223
+ // Multiple historical cohorts are retained, not flattened into a fictitious
224
+ // single response. A later envelope never advances a source observation.
225
+ const ordered = beforeTimes.length === 1 && afterTimes.length === 1 && beforeTimes[0] < afterTimes[0];
226
+ row.complete &&= Boolean(before?.complete && after?.complete);
227
+ const same = JSON.stringify(row.before) === JSON.stringify(row.after);
228
+ const state = !comparable ? 'incomparable' : !ordered || !complete || !row.complete ? 'unknown' : same ? 'unchanged' : !row.before.length ? 'newly_observed' : !row.after.length ? 'not_returned' : 'changed';
229
+ return { ...row, beforeTimes, afterTimes, state, detail: state === 'not_returned' ? 'Not returned by this comparable bounded collection; this does not establish disappearance.' : state === 'newly_observed' ? 'Newly present in retained evidence; not necessarily newly created.' : state === 'unknown' ? !ordered ? 'Source observation times are missing, equal, reversed or span multiple cohorts; envelope time cannot establish a temporal change.' : 'Incomplete evidence cannot establish a removal or change.' : state === 'incomparable' ? 'Source, scope, mode or observation ordering differs; no temporal change is inferred.' : 'Source-qualified retained observation comparison.' };
230
+ });
231
+ return { state: comparable ? complete && !rows.some(row => row.state === 'unknown') ? 'compared' : 'partial' : 'incomparable', earlier: earlier.snapshot.id, later: later.snapshot.id, rows, limitations: ['No new collection was made. Retained changes do not establish ownership, safety, control or maliciousness.', 'Provider-reported history remains labelled separately from local observations. Empty, failed, limited and changed-source snapshots cannot replace a stronger baseline.'] };
232
+ }
233
+ /** Technology roles reuse the canonical role owner, not a new signature catalogue. */
234
+ export function infrastructureTechnologyRoles(value) { return technologyEvidenceRoles(value); }