@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,209 @@
1
+ import { isValidAsciiDomainName } from "../contracts/domain-name.mjs";
2
+ import { MAIL_AUTHENTICATION_METHODS } from "../contracts/mail-authentication.mjs";
3
+ export const MAX_MAIL_REVIEW_DOMAINS = 128;
4
+ export const AUTHENTICATION_METHODS = MAIL_AUTHENTICATION_METHODS;
5
+ export function normalizeHeaderDomain(value) {
6
+ const normalized = value.trim().toLowerCase().replace(/^\[|\]$/gu, '').replace(/\.$/u, '');
7
+ return isValidAsciiDomainName(normalized, { requireLowercase: true }) ? normalized : null;
8
+ }
9
+ export function lexicalSegments(value, delimiter, trackAngles = false) {
10
+ const segments = [];
11
+ let start = 0;
12
+ let quoted = false;
13
+ let escaped = false;
14
+ let commentDepth = 0;
15
+ let angleDepth = 0;
16
+ for (let index = 0; index < value.length; index += 1) {
17
+ const character = value[index] ?? '';
18
+ if (escaped) {
19
+ escaped = false;
20
+ continue;
21
+ }
22
+ if (character === '\\' && (quoted || commentDepth > 0)) {
23
+ escaped = true;
24
+ continue;
25
+ }
26
+ if (commentDepth > 0) {
27
+ if (character === '(')
28
+ commentDepth += 1;
29
+ else if (character === ')')
30
+ commentDepth -= 1;
31
+ continue;
32
+ }
33
+ if (quoted) {
34
+ if (character === '"')
35
+ quoted = false;
36
+ continue;
37
+ }
38
+ if (character === '"')
39
+ quoted = true;
40
+ else if (character === '(')
41
+ commentDepth = 1;
42
+ else if (character === ')')
43
+ return null;
44
+ else if (trackAngles && character === '<') {
45
+ if (angleDepth !== 0)
46
+ return null;
47
+ angleDepth = 1;
48
+ }
49
+ else if (trackAngles && character === '>') {
50
+ if (angleDepth !== 1)
51
+ return null;
52
+ angleDepth = 0;
53
+ }
54
+ else if (character === delimiter && angleDepth === 0) {
55
+ segments.push(value.slice(start, index));
56
+ start = index + 1;
57
+ }
58
+ }
59
+ if (escaped || quoted || commentDepth !== 0 || angleDepth !== 0)
60
+ return null;
61
+ segments.push(value.slice(start));
62
+ return segments;
63
+ }
64
+ function angleAddress(value) {
65
+ let quoted = false;
66
+ let escaped = false;
67
+ let commentDepth = 0;
68
+ let start = -1;
69
+ let end = -1;
70
+ for (let index = 0; index < value.length; index += 1) {
71
+ const character = value[index] ?? '';
72
+ if (escaped) {
73
+ escaped = false;
74
+ continue;
75
+ }
76
+ if (character === '\\' && (quoted || commentDepth > 0)) {
77
+ escaped = true;
78
+ continue;
79
+ }
80
+ if (commentDepth > 0) {
81
+ if (character === '(')
82
+ commentDepth += 1;
83
+ else if (character === ')')
84
+ commentDepth -= 1;
85
+ continue;
86
+ }
87
+ if (quoted) {
88
+ if (character === '"')
89
+ quoted = false;
90
+ continue;
91
+ }
92
+ if (character === '"')
93
+ quoted = true;
94
+ else if (character === '(')
95
+ commentDepth = 1;
96
+ else if (character === ')')
97
+ return null;
98
+ else if (character === '<') {
99
+ if (start >= 0 || end >= 0)
100
+ return null;
101
+ start = index;
102
+ }
103
+ else if (character === '>') {
104
+ if (start < 0 || end >= 0)
105
+ return null;
106
+ end = index;
107
+ }
108
+ }
109
+ if (escaped || quoted || commentDepth !== 0 || (start >= 0) !== (end >= 0))
110
+ return null;
111
+ return start >= 0 ? value.slice(start + 1, end) : value;
112
+ }
113
+ function addressDomain(value) {
114
+ let candidate = '';
115
+ let quoted = false;
116
+ let escaped = false;
117
+ let commentDepth = 0;
118
+ const separators = [];
119
+ for (const character of value) {
120
+ if (escaped) {
121
+ candidate += character;
122
+ escaped = false;
123
+ continue;
124
+ }
125
+ if (character === '\\' && (quoted || commentDepth > 0)) {
126
+ if (quoted)
127
+ candidate += character;
128
+ escaped = true;
129
+ continue;
130
+ }
131
+ if (commentDepth > 0) {
132
+ if (character === '(')
133
+ commentDepth += 1;
134
+ else if (character === ')')
135
+ commentDepth -= 1;
136
+ continue;
137
+ }
138
+ if (quoted) {
139
+ candidate += character;
140
+ if (character === '"')
141
+ quoted = false;
142
+ continue;
143
+ }
144
+ if (character === '"') {
145
+ quoted = true;
146
+ candidate += character;
147
+ }
148
+ else if (character === '(')
149
+ commentDepth = 1;
150
+ else if (character === ')')
151
+ return null;
152
+ else {
153
+ if (character === '@')
154
+ separators.push(candidate.length);
155
+ candidate += character;
156
+ }
157
+ }
158
+ if (escaped || quoted || commentDepth !== 0 || separators.length !== 1)
159
+ return null;
160
+ const separator = separators[0] ?? -1;
161
+ const local = candidate.slice(0, separator).trim();
162
+ const domain = candidate.slice(separator + 1).trim();
163
+ const validLocal = /^(?:[^\s"<>(),:;@]+|"(?:[^"\\]|\\.)+")$/u.test(local);
164
+ if (!validLocal || !domain || /\s/u.test(domain))
165
+ return null;
166
+ return normalizeHeaderDomain(domain);
167
+ }
168
+ export function addressDomains(values) {
169
+ const domains = new Set();
170
+ for (const value of values) {
171
+ const mailboxes = lexicalSegments(value, ',', true);
172
+ if (!mailboxes)
173
+ continue;
174
+ for (const mailbox of mailboxes) {
175
+ const selected = angleAddress(mailbox);
176
+ const domain = selected === null ? null : addressDomain(selected);
177
+ if (domain)
178
+ domains.add(domain);
179
+ if (domains.size >= MAX_MAIL_REVIEW_DOMAINS)
180
+ return [...domains].sort();
181
+ }
182
+ }
183
+ return [...domains].sort();
184
+ }
185
+ export function messageIdDomains(values) {
186
+ return addressDomains(values.map((value) => value.replace(/[<>]/gu, '')));
187
+ }
188
+ export function authenticationServiceDomains(values) {
189
+ const output = new Set();
190
+ for (const value of values) {
191
+ const clauses = lexicalSegments(value, ';');
192
+ const service = normalizeHeaderDomain((clauses?.[0] ?? '').trim().split(/\s/u, 1)[0] ?? '');
193
+ if (service)
194
+ output.add(service);
195
+ }
196
+ return [...output].sort().slice(0, MAX_MAIL_REVIEW_DOMAINS);
197
+ }
198
+ export function dkimSigningDomains(values) {
199
+ const output = new Set();
200
+ for (const value of values) {
201
+ for (const clause of lexicalSegments(value, ';') ?? []) {
202
+ const match = /^\s*d\s*=\s*([^\s;]+)/iu.exec(clause);
203
+ const domain = normalizeHeaderDomain(match?.[1] ?? '');
204
+ if (domain)
205
+ output.add(domain);
206
+ }
207
+ }
208
+ return [...output].sort().slice(0, MAX_MAIL_REVIEW_DOMAINS);
209
+ }
@@ -0,0 +1,235 @@
1
+ import PostalMime, {} from 'postal-mime';
2
+ import { parse, defaultTreeAdapter } from 'parse5';
3
+ import { sha256ArtifactBytes } from "../evidence/artifact-integrity.mjs";
4
+ import { createLinkIntake } from "./link-intake.mjs";
5
+ import { createIndicatorIntake, MAX_INTAKE_INDICATOR_TEXT } from "./intake-indicators.mjs";
6
+ import { reviewPhoneCandidates } from "./intake-phones.mjs";
7
+ import { AUTHENTICATION_METHODS, addressDomains, authenticationServiceDomains, dkimSigningDomains } from "./mail-header-identity.mjs";
8
+ import { createIntakeReport } from "./intake-report.mjs";
9
+ export { assertMessageBytes } from "./intake-report.mjs";
10
+ import { requestedActionHints } from "./requested-action-hints.mjs";
11
+ import { reviewMailAuthentication, aggregateMailAuthentication } from "./mail-authentication-review.mjs";
12
+ import { MAX_AUTHENTICATION_HEADERS } from "../contracts/mail-authentication.mjs";
13
+ import { MAX_MESSAGE_INTAKE_BYTES, MAX_MESSAGE_PARTS, MAX_MESSAGE_DEPTH, MAX_MESSAGE_HTML_NODES } from "../contracts/message-intake.mjs";
14
+ const HTML_TEXT_BLOCK = /^(?:address|article|aside|blockquote|body|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|pre|section|table|tr|ul)$/u;
15
+ /** All parsing is inert. Only HTTP(S) link targets are offered for deliberate collection. */
16
+ export async function reviewMessageInput(bytes, kind, reviewedAt, qrText) {
17
+ const base = await createIntakeReport(bytes, kind, reviewedAt);
18
+ if (!['text', 'email', 'calendar', 'qr'].includes(kind))
19
+ throw new TypeError('Use the selected-file intake entry point for this format.');
20
+ const links = createLinkIntake(), indicators = createIndicatorIntake(), identities = [], authenticationClaims = [];
21
+ const actionHints = new Set(), bounds = new Set();
22
+ const authenticationHeaders = [];
23
+ const messageParts = [];
24
+ let omittedAuthenticationHeaders = 0;
25
+ let partialAuthentication = false;
26
+ let reviewedParts = 0, unreviewedAttachments = 0, decodedBytes = 0;
27
+ const text = (value, source, partId, includeIndicators = true) => {
28
+ for (const hint of requestedActionHints(value))
29
+ actionHints.add(hint);
30
+ links.addText(value, source);
31
+ if (includeIndicators)
32
+ indicators.addText(value, source, { partId, page: null });
33
+ };
34
+ function html(value, partId) {
35
+ // parse5 never renders, loads resources, or runs supplied script.
36
+ let allocated = 0;
37
+ const htmlBound = new Error('HTML work bound');
38
+ const admit = () => { if (++allocated > MAX_MESSAGE_HTML_NODES)
39
+ throw htmlBound; };
40
+ let tree;
41
+ try {
42
+ tree = parse(value, { treeAdapter: { ...defaultTreeAdapter,
43
+ createElement(...args) { admit(); return defaultTreeAdapter.createElement(...args); },
44
+ createTextNode(...args) { admit(); return defaultTreeAdapter.createTextNode(...args); },
45
+ createCommentNode(...args) { admit(); return defaultTreeAdapter.createCommentNode(...args); },
46
+ } });
47
+ }
48
+ catch (cause) {
49
+ if (cause !== htmlBound)
50
+ throw cause;
51
+ bounds.add('HTML nodes');
52
+ return;
53
+ }
54
+ const queue = [tree];
55
+ const indicatorText = [];
56
+ let indicatorCharacters = 0, indicatorOverflow = false;
57
+ const appendIndicatorText = (value) => {
58
+ if (indicatorOverflow)
59
+ return;
60
+ if (value.length > MAX_INTAKE_INDICATOR_TEXT - indicatorCharacters) {
61
+ indicatorOverflow = true;
62
+ indicatorText.length = 0;
63
+ indicators.markPartial();
64
+ return;
65
+ }
66
+ indicatorCharacters += value.length;
67
+ indicatorText.push(value);
68
+ };
69
+ let nodes = 0;
70
+ while (queue.length) {
71
+ const node = queue.pop();
72
+ if (node === null) {
73
+ appendIndicatorText('\n');
74
+ continue;
75
+ }
76
+ if (++nodes > MAX_MESSAGE_HTML_NODES) {
77
+ bounds.add('HTML nodes');
78
+ break;
79
+ }
80
+ if ('tagName' in node) {
81
+ const attrs = new Map(node.attrs.map(attr => [attr.name, attr.value]));
82
+ if (node.tagName === 'a' && attrs.has('href')) {
83
+ const shown = [], children = [...node.childNodes];
84
+ let count = 0;
85
+ while (children.length && ++count <= MAX_MESSAGE_HTML_NODES) {
86
+ const child = children.pop();
87
+ if ('value' in child)
88
+ shown.push(child.value);
89
+ if ('childNodes' in child)
90
+ children.push(...child.childNodes);
91
+ }
92
+ links.add(attrs.get('href'), 'html_link', shown.reverse().join('').trim());
93
+ }
94
+ if (node.tagName === 'form' && attrs.has('action'))
95
+ links.add(attrs.get('action'), 'html_form');
96
+ if ((node.tagName === 'iframe' || node.tagName === 'frame') && attrs.has('src'))
97
+ links.add(attrs.get('src'), 'html_frame');
98
+ if (node.tagName === 'script' || node.tagName === 'style')
99
+ continue;
100
+ if (HTML_TEXT_BLOCK.test(node.tagName)) {
101
+ appendIndicatorText('\n');
102
+ queue.push(null);
103
+ }
104
+ else if (node.tagName === 'br' || node.tagName === 'td' || node.tagName === 'th')
105
+ appendIndicatorText(' ');
106
+ }
107
+ if ('value' in node) {
108
+ text(node.value, 'text', partId, false);
109
+ // Inline markup must not separate a credential label from its value.
110
+ // Block boundaries keep unrelated visible paragraphs independently usable.
111
+ appendIndicatorText(node.value.replace(/\s+/gu, ' '));
112
+ }
113
+ if ('childNodes' in node)
114
+ for (let index = node.childNodes.length - 1; index >= 0; index--)
115
+ queue.push(node.childNodes[index]);
116
+ }
117
+ if (!indicatorOverflow)
118
+ indicators.addText(indicatorText.join(''), 'text', { partId, page: null });
119
+ }
120
+ function calendar(value, partId) {
121
+ const unfolded = value.replace(/\r?\n[ \t]/gu, '');
122
+ // No attendee names, addresses, descriptions or event identifiers enter the report.
123
+ for (const line of unfolded.split(/\r?\n/u)) {
124
+ const colon = line.indexOf(':');
125
+ if (colon < 0)
126
+ continue;
127
+ const key = line.slice(0, colon).split(';', 1)[0]?.toUpperCase();
128
+ if (['URL', 'DESCRIPTION', 'LOCATION', 'X-ALT-DESC'].includes(key ?? ''))
129
+ text(line.slice(colon + 1).replace(/\\n/giu, '\n').replace(/\\([,;\\])/gu, '$1'), 'calendar', partId);
130
+ }
131
+ }
132
+ function identity(part, role, domains) {
133
+ for (const domain of domains)
134
+ identities.push({ part, role, domain });
135
+ }
136
+ function headers(mail, part) {
137
+ const sourceReview = reviewMailAuthentication(mail.headers.map(header => ({ name: header.key, value: header.value })), part, MAX_AUTHENTICATION_HEADERS - authenticationHeaders.length);
138
+ authenticationHeaders.push(...sourceReview.headers);
139
+ omittedAuthenticationHeaders += sourceReview.omittedHeaders;
140
+ partialAuthentication ||= sourceReview.headers.some(header => !['parsed', 'none'].includes(header.state));
141
+ if (sourceReview.omittedHeaders)
142
+ bounds.add('Authentication headers');
143
+ const values = (name) => mail.headers.filter(header => header.key === name).map(header => header.value);
144
+ identity(part, 'from', addressDomains(values('from')));
145
+ identity(part, 'reply_to', addressDomains(values('reply-to')));
146
+ identity(part, 'return_path', addressDomains(values('return-path')));
147
+ identity(part, 'dkim', dkimSigningDomains(values('dkim-signature')));
148
+ const reported = values('authentication-results');
149
+ identity(part, 'authentication_service', authenticationServiceDomains(reported));
150
+ const aggregate = aggregateMailAuthentication(sourceReview);
151
+ for (const method of AUTHENTICATION_METHODS) {
152
+ const claim = aggregate[method];
153
+ if (claim.observations)
154
+ authenticationClaims.push({ part, method, result: claim.state });
155
+ }
156
+ }
157
+ async function message(input, depth, parentPart) {
158
+ if (depth > MAX_MESSAGE_DEPTH || reviewedParts >= MAX_MESSAGE_PARTS) {
159
+ bounds.add('Message nesting or parts');
160
+ unreviewedAttachments++;
161
+ return;
162
+ }
163
+ decodedBytes += input.byteLength;
164
+ if (decodedBytes > MAX_MESSAGE_INTAKE_BYTES * 2) {
165
+ bounds.add('Decoded message bytes');
166
+ unreviewedAttachments++;
167
+ return;
168
+ }
169
+ let mail;
170
+ try {
171
+ mail = await PostalMime.parse(input, { maxNestingDepth: MAX_MESSAGE_DEPTH, maxHeadersSize: 256 * 1024, rfc822Attachments: true, forceRfc822Attachments: true, attachmentEncoding: 'arraybuffer' });
172
+ }
173
+ catch {
174
+ throw new TypeError('The message could not be decoded within the MIME nesting and header limits. No input was retained.');
175
+ }
176
+ const part = ++reviewedParts;
177
+ messageParts.push({ part, parentPart, digestSha256: parentPart === null ? base.source.digestSha256 : await sha256ArtifactBytes(input), byteLength: input.byteLength });
178
+ headers(mail, part);
179
+ if (mail.text)
180
+ text(mail.text, 'text', `message-${part}`);
181
+ if (mail.html)
182
+ html(mail.html, `message-${part}`);
183
+ for (const attachment of mail.attachments) {
184
+ if (reviewedParts >= MAX_MESSAGE_PARTS) {
185
+ bounds.add('Message parts');
186
+ unreviewedAttachments++;
187
+ continue;
188
+ }
189
+ const body = typeof attachment.content === 'string' ? new TextEncoder().encode(attachment.content) : new Uint8Array(attachment.content);
190
+ if (attachment.mimeType === 'message/rfc822')
191
+ await message(body, depth + 1, part);
192
+ else if (attachment.mimeType === 'text/calendar') {
193
+ const calendarPart = ++reviewedParts;
194
+ messageParts.push({ part: calendarPart, parentPart: part, digestSha256: await sha256ArtifactBytes(body), byteLength: body.byteLength });
195
+ calendar(new TextDecoder().decode(body), `message-${calendarPart}`);
196
+ }
197
+ else {
198
+ unreviewedAttachments++;
199
+ }
200
+ }
201
+ }
202
+ if (kind === 'email')
203
+ await message(bytes, 0, null);
204
+ else if (kind === 'qr') {
205
+ if (!Array.isArray(qrText) || qrText.length > MAX_MESSAGE_PARTS || qrText.some(value => typeof value !== 'string' || value.length > 8_192))
206
+ throw new TypeError('QR review requires bounded decoded text.');
207
+ reviewedParts = qrText.length;
208
+ for (const value of qrText) {
209
+ for (const hint of requestedActionHints(value))
210
+ actionHints.add(hint);
211
+ links.addQr(value);
212
+ }
213
+ }
214
+ else {
215
+ reviewedParts = 1;
216
+ const value = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
217
+ if (kind === 'calendar')
218
+ calendar(value, 'input');
219
+ else
220
+ text(value, 'text', 'input');
221
+ }
222
+ const result = links.result();
223
+ for (const limitation of result.limitations)
224
+ bounds.add(limitation);
225
+ if (result.bounded)
226
+ bounds.add('Link extraction');
227
+ const indicatorResult = indicators.result();
228
+ if (indicatorResult.indicatorCoverage.state === 'partial')
229
+ bounds.add('Indicator extraction');
230
+ const phoneReview = kind === 'text' ? reviewPhoneCandidates(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes), base.source.digestSha256) : undefined;
231
+ return { report: { ...base,
232
+ coverage: { state: bounds.size || unreviewedAttachments || partialAuthentication ? 'partial' : 'reviewed', reviewedParts, unreviewedAttachments, rejectedLinks: result.rejected, boundsReached: [...bounds] },
233
+ identities, authenticationClaims, authenticationReview: { headers: authenticationHeaders, omittedHeaders: omittedAuthenticationHeaders }, messageParts,
234
+ ...indicatorResult, links: result.links, actionHints: [...actionHints] }, targets: result.targets, ...(phoneReview ? { phoneReview } : {}) };
235
+ }
@@ -0,0 +1,113 @@
1
+ import { array, boolean, enumeration, exact, integer, text, HEX_DIGEST_RE } from "../evidence/artifact-structure.mjs";
2
+ import { MESSAGE_ACTION_HINTS } from "../contracts/message-intake.mjs";
3
+ import { readCaptureCoverage, captureCoverageIsPartial, captureAttemptDescription } from "./capture-coverage.mjs";
4
+ export const PAGE_BEHAVIOUR_VERSION = 1;
5
+ // Separate from the capture's request, DOM traversal and aggregate byte bounds.
6
+ export const MAX_PAGE_OBSERVATIONS = 500;
7
+ export const PAGE_ACTION_HINTS = MESSAGE_ACTION_HINTS;
8
+ export function pageObservationOrigin(raw, base) {
9
+ if (typeof raw !== 'string' || raw.length > 8_192 || /[\u0000-\u0020\u007f]/u.test(raw))
10
+ return null;
11
+ try {
12
+ const url = new URL(raw, base);
13
+ return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? url.origin : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ function origin(value, nullable = false) {
20
+ if (nullable && value === null)
21
+ return null;
22
+ const candidate = text(value, 'Page observation origin', 500);
23
+ if (pageObservationOrigin(candidate) !== candidate)
24
+ throw new TypeError('Page observations retain origins only.');
25
+ return candidate;
26
+ }
27
+ function hash(value) {
28
+ if (value === null)
29
+ return null;
30
+ if (typeof value !== 'string' || !HEX_DIGEST_RE.test(value))
31
+ throw new TypeError('Page observation digest must be SHA-256.');
32
+ return value;
33
+ }
34
+ function ordered(values) {
35
+ if (values.some((value, index) => index > 0 && value.position <= values[index - 1].position))
36
+ throw new TypeError('Page observation positions must be unique and increasing.');
37
+ return values;
38
+ }
39
+ export function readPageBehaviour(raw) {
40
+ const value = exact(raw, ['version', 'state', 'requests', 'elements', 'actionHints', 'clipboardWriteAttempts', 'coverage'], 'Page behaviour');
41
+ if (value.version !== PAGE_BEHAVIOUR_VERSION)
42
+ throw new TypeError('Unsupported page behaviour version.');
43
+ const requests = ordered(array(value.requests, 'Page requests', MAX_PAGE_OBSERVATIONS).map(raw => {
44
+ const item = exact(raw, ['position', 'kind', 'origin', 'contentSha256', 'status', 'cspEnforced', 'cspReportOnly'], 'Page request');
45
+ boolean(item.cspEnforced, 'Enforced policy presence');
46
+ boolean(item.cspReportOnly, 'Report-only policy presence');
47
+ const kind = enumeration(item.kind, ['navigation', 'script', 'frame', 'document'], 'Page request kind'), contentSha256 = hash(item.contentSha256);
48
+ if (kind !== 'script' && contentSha256 !== null)
49
+ throw new TypeError('Only script response bodies are hashed in page observations.');
50
+ return { position: integer(item.position, 'Request position', 1, 10_000), kind, origin: origin(item.origin), contentSha256,
51
+ status: integer(item.status, 'Response status', 100, 599), cspEnforced: item.cspEnforced, cspReportOnly: item.cspReportOnly };
52
+ }));
53
+ const elements = ordered(array(value.elements, 'Page elements', MAX_PAGE_OBSERVATIONS).map(raw => {
54
+ const item = exact(raw, ['position', 'kind', 'origin', 'inline', 'integrity', 'method', 'passwordFields', 'scriptSha256'], 'Page element');
55
+ const kind = enumeration(item.kind, ['script', 'frame', 'form'], 'Page element kind');
56
+ boolean(item.inline, 'Inline script');
57
+ const method = item.method === null ? null : enumeration(item.method, ['get', 'post', 'other'], 'Form method');
58
+ const passwordFields = integer(item.passwordFields, 'Password field count', 0, 20_000), scriptSha256 = hash(item.scriptSha256);
59
+ const integrity = enumeration(item.integrity, ['present', 'absent', 'not_applicable'], 'Integrity attribute');
60
+ if (kind !== 'form' && (method !== null || passwordFields !== 0) || kind === 'form' && method === null
61
+ || kind !== 'script' && (item.inline || scriptSha256 !== null || integrity !== 'not_applicable')
62
+ || kind === 'script' && (item.inline && item.origin !== null || !item.inline && scriptSha256 !== null))
63
+ throw new TypeError('Page element fields do not match their kind.');
64
+ return { position: integer(item.position, 'Element position', 1, 20_000), kind, origin: origin(item.origin, true), inline: item.inline, integrity, method, passwordFields, scriptSha256 };
65
+ }));
66
+ const actionHints = array(value.actionHints, 'Requested-action hints', PAGE_ACTION_HINTS.length).map(raw => enumeration(raw, PAGE_ACTION_HINTS, 'Requested-action hint'));
67
+ if (new Set(actionHints).size !== actionHints.length)
68
+ throw new TypeError('Page action hints must not repeat.');
69
+ const coverage = readCaptureCoverage(value.coverage);
70
+ if (value.state === 'observed' && captureCoverageIsPartial(coverage))
71
+ throw new TypeError('Partial request coverage cannot declare complete page observations.');
72
+ return { version: PAGE_BEHAVIOUR_VERSION, state: enumeration(value.state, ['observed', 'partial'], 'Page observation state'), requests, elements, actionHints,
73
+ clipboardWriteAttempts: integer(value.clipboardWriteAttempts, 'Blocked clipboard attempts', 0, 1_000_000), coverage };
74
+ }
75
+ export function readManifestPageBehaviour(value, manifestVersion) {
76
+ if (manifestVersion === 2 && value === undefined)
77
+ return null;
78
+ if (manifestVersion !== 3)
79
+ throw new TypeError('Page behaviour requires capture manifest version 3.');
80
+ return readPageBehaviour(value);
81
+ }
82
+ /** A missing/partial observation cannot establish removal or unchanged content. */
83
+ export function pageBehaviourRows(input) {
84
+ return [
85
+ ...input.requests.map(value => ({ identity: JSON.stringify(['request', value.kind, value.origin, value.contentSha256, value.status, value.cspEnforced, value.cspReportOnly]),
86
+ label: `${value.kind} response ${value.position} · HTTP ${value.status} · enforced CSP ${value.cspEnforced ? 'present' : 'absent'} · report-only CSP ${value.cspReportOnly ? 'present' : 'absent'}`, origin: value.origin, digest: value.contentSha256 })),
87
+ ...input.elements.map(value => ({ identity: JSON.stringify(['element', value.kind, value.origin, value.inline, value.integrity, value.method, value.passwordFields, value.scriptSha256]),
88
+ label: `${value.kind} element ${value.position}${value.inline ? ' · inline' : ''}${value.kind === 'form' ? ` · ${value.method} · ${value.passwordFields} password fields` : value.kind === 'script' ? ` · integrity attribute ${value.integrity}` : ''}`,
89
+ origin: value.origin, digest: value.scriptSha256 })),
90
+ ...input.actionHints.map(value => ({ identity: JSON.stringify(['action_wording', value]), label: `Body text wording · ${value.replaceAll('_', ' ')}`, origin: null, digest: null })),
91
+ ...input.coverage.attempts.filter(value => !['navigation', 'script', 'frame', 'document'].includes(value.channel) || value.state !== 'observed').map(value => ({
92
+ identity: JSON.stringify(['request_channel', value.channel, value.method, value.origin, value.state, value.reason, value.collectionStarted]),
93
+ label: `${value.channel.replaceAll('_', ' ')} request ${value.position} · ${captureAttemptDescription(value)}`,
94
+ origin: value.origin, digest: null,
95
+ })),
96
+ ];
97
+ }
98
+ export function comparePageBehaviour(before, after) {
99
+ const counts = (input) => { const map = new Map(); for (const row of input ? pageBehaviourRows(input) : [])
100
+ map.set(row.identity, { ...row, count: (map.get(row.identity)?.count ?? 0) + 1 }); return map; };
101
+ const left = counts(before), right = counts(after), complete = before?.state === 'observed' && after?.state === 'observed';
102
+ const difference = (a, b) => [...a.values()].filter(row => row.count > (b.get(row.identity)?.count ?? 0))
103
+ .map(row => ({ ...row, count: row.count - (b.get(row.identity)?.count ?? 0) }));
104
+ const added = difference(right, left), notReobserved = difference(left, right);
105
+ const navigation = (value) => value.requests.filter(row => row.kind === 'navigation').map(row => [row.origin, row.status]);
106
+ const navigationChanged = before && after ? JSON.stringify(navigation(before)) !== JSON.stringify(navigation(after)) : null;
107
+ const clipboardWriteDelta = before && after ? after.clipboardWriteAttempts - before.clipboardWriteAttempts : null;
108
+ const requestChannelsChanged = before && after ? JSON.stringify(before.coverage) !== JSON.stringify(after.coverage) : null;
109
+ return { state: !before || !after ? 'unavailable' : complete ? added.length || notReobserved.length || navigationChanged || clipboardWriteDelta || requestChannelsChanged ? 'changed' : 'unchanged' : 'inconclusive',
110
+ navigationChanged, clipboardWriteDelta, requestChannelsChanged,
111
+ added: before ? added : [], notReobserved: after ? notReobserved : [], removalEstablished: false,
112
+ boundary: 'Selected capture observations, not proof of authorisation, worldwide removal or compromise. Script hashes identify collected bytes; an integrity attribute is not a verified integrity result.' };
113
+ }