@slicedearth/whoisleuth-cli 2.3.0 → 2.4.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 (264) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +19 -15
  3. package/bin/whoisleuth.mjs +8 -1
  4. package/cli/archive-content-digest.mjs +14 -0
  5. package/cli/archive-inspect.mjs +20 -53
  6. package/cli/arguments.mjs +68 -6
  7. package/cli/artifact-validation/case-response.mjs +47 -41
  8. package/cli/artifact-validation/investigation-capsule.mjs +52 -31
  9. package/cli/artifact-validation/lookup-evidence.mjs +12 -6
  10. package/cli/artifact-validation/signed-review.mjs +83 -61
  11. package/cli/artifact-validation/structure-primitives.mjs +1 -105
  12. package/cli/artifact-verify.mjs +90 -24
  13. package/cli/assurance-command-runner.mjs +39 -4
  14. package/cli/bagit-folder.mjs +57 -0
  15. package/cli/bagit-review.mjs +23 -0
  16. package/cli/bounded-json.mjs +1 -1
  17. package/cli/bulk-command-runner.mjs +2 -0
  18. package/cli/bulk-output.mjs +29 -3
  19. package/cli/case-command.mjs +195 -0
  20. package/cli/case-pack.mjs +39 -31
  21. package/cli/command-argument-grammar.mjs +4 -0
  22. package/cli/command-reference.mjs +145 -75
  23. package/cli/completion.mjs +298 -79
  24. package/cli/config-profile.mjs +7 -5
  25. package/cli/ct-event-intake.mjs +12 -12
  26. package/cli/discover.mjs +4 -3
  27. package/cli/discovery-observation-snapshot.mjs +44 -16
  28. package/cli/discovery-scan-command-runner.mjs +2 -0
  29. package/cli/discovery-scan.mjs +7 -4
  30. package/cli/discovery-workflow.mjs +1 -1
  31. package/cli/domain-control-monitor.mjs +50 -20
  32. package/cli/domain-control-observations.mjs +149 -102
  33. package/cli/errors.mjs +33 -1
  34. package/cli/evidence-command-runner.mjs +16 -5
  35. package/cli/evidence-signing.mjs +2 -2
  36. package/cli/external-observation-mapping.mjs +7 -8
  37. package/cli/formatters/terminal-lookup.mjs +18 -46
  38. package/cli/formatters/terminal-shared.mjs +5 -3
  39. package/cli/formatters/terminal-technology.mjs +114 -0
  40. package/cli/inline-command-families.mjs +1 -0
  41. package/cli/interactive-launcher.mjs +2 -160
  42. package/cli/interchange-report.mjs +4 -8
  43. package/cli/investigation-artifacts.mjs +176 -0
  44. package/cli/investigation-checkpoint.mjs +10 -0
  45. package/cli/investigation-folder.mjs +114 -0
  46. package/cli/investigation-manifest.mjs +1 -112
  47. package/cli/investigation-package-review.mjs +103 -0
  48. package/cli/investigation-plan.mjs +32 -19
  49. package/cli/investigation-run.mjs +188 -33
  50. package/cli/local-document-checkpoint.mjs +170 -0
  51. package/cli/lookup-browser.mjs +5 -12
  52. package/cli/lookup-command-runner.mjs +17 -6
  53. package/cli/lookup-diff.mjs +37 -3
  54. package/cli/lookup-plan.mjs +11 -5
  55. package/cli/lookup-reconcile.mjs +10 -8
  56. package/cli/mail-review.mjs +66 -14
  57. package/cli/manual.mjs +1 -0
  58. package/cli/network-command-runner.mjs +2 -0
  59. package/cli/output-file.mjs +31 -5
  60. package/cli/registry-doctor.mjs +3 -1
  61. package/cli/registry-support.mjs +1 -1
  62. package/cli/retained-artifact-diff.mjs +2 -2
  63. package/cli/review-command-runner.mjs +45 -0
  64. package/cli/runner.mjs +55 -6
  65. package/cli/sharing-review.mjs +21 -34
  66. package/cli/signer-trust.mjs +104 -0
  67. package/cli/source-reliability.mjs +74 -83
  68. package/cli/terminal-input.mjs +168 -0
  69. package/cli/workflow-command-runner.mjs +68 -13
  70. package/docs/cli-reference.md +76 -30
  71. package/docs/cli.md +289 -25
  72. package/lib/abort.mjs +4 -1
  73. package/lib/availability.mjs +161 -112
  74. package/lib/bounded-file.mjs +3 -1
  75. package/lib/bounded-html-document.mjs +125 -0
  76. package/lib/bounded-json.mjs +43 -3
  77. package/lib/browser-library-profile.mjs +42 -51
  78. package/lib/browser-library-worker.mjs +112 -0
  79. package/lib/client-behavior-profile.mjs +3 -3
  80. package/lib/ct-search.mjs +11 -4
  81. package/lib/dns-delegation-health.mjs +24 -14
  82. package/lib/dns-inheritance-review.mjs +217 -0
  83. package/lib/dns-intelligence.mjs +36 -23
  84. package/lib/dns-mx.mjs +1 -1
  85. package/lib/dnssec-evidence-validation.mjs +11 -3
  86. package/lib/domain-change-packet.mjs +7 -2
  87. package/lib/domain-change-review.mjs +14 -8
  88. package/lib/domain-control-flight-recorder.mjs +219 -118
  89. package/lib/domain-control-manifest.mjs +117 -57
  90. package/lib/domain-posture-analysis.mjs +20 -7
  91. package/lib/domain-posture-parsers.mjs +88 -6
  92. package/lib/domain-posture.mjs +113 -26
  93. package/lib/evidence-export-privacy.mjs +561 -0
  94. package/lib/evidence-export.mjs +11 -592
  95. package/lib/evidence-report.mjs +3 -1
  96. package/lib/favicon.mjs +117 -41
  97. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  98. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  99. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  100. package/lib/hostname.mjs +2 -38
  101. package/lib/html-signals.mjs +82 -85
  102. package/lib/http-evidence-bounds.mjs +2 -1
  103. package/lib/http-intelligence.mjs +39 -26
  104. package/lib/lookup-cache.mjs +34 -11
  105. package/lib/lookup-child-profile-contract.mjs +24 -15
  106. package/lib/lookup-diagnostics.mjs +24 -1
  107. package/lib/lookup-progress.mjs +30 -13
  108. package/lib/lookup-response-contract.mjs +1048 -0
  109. package/lib/lookup-response.mjs +7 -10
  110. package/lib/lookup-source-progress.mjs +17 -14
  111. package/lib/lookup.mjs +48 -27
  112. package/lib/observed-network-context.mjs +43 -27
  113. package/lib/outbound-request-bounds.mjs +4 -1
  114. package/lib/page-fingerprints.mjs +69 -133
  115. package/lib/page-role-profile.mjs +12 -8
  116. package/lib/perceptual-hash.mjs +43 -14
  117. package/lib/rdap-bootstrap.mjs +72 -22
  118. package/lib/rdap-client.mjs +15 -7
  119. package/lib/rdap-normalization.mjs +33 -18
  120. package/lib/rdap-search-workbench.mjs +1 -1
  121. package/lib/rdap-transport.mjs +47 -20
  122. package/lib/rdap-types.mjs +2 -1
  123. package/lib/rdap.mjs +18 -3
  124. package/lib/registrable-domain.mjs +21 -1
  125. package/lib/registrar-standing-contract.mjs +1 -0
  126. package/lib/registry-capabilities.mjs +5 -1
  127. package/lib/registry-capability-catalogue.mjs +2 -1
  128. package/lib/registry-dates.mjs +2 -190
  129. package/lib/registry-insights.mjs +56 -18
  130. package/lib/response-policy.mjs +86 -48
  131. package/lib/risk-calibration-summary.mjs +17 -3
  132. package/lib/rpki-evidence.mjs +8 -5
  133. package/lib/safe-fetch.mjs +54 -47
  134. package/lib/security-txt.mjs +10 -7
  135. package/lib/service-binding-dns.mjs +8 -0
  136. package/lib/smtp-transport-review.mjs +27 -15
  137. package/lib/sslbl-certificates.generated.mjs +136 -135
  138. package/lib/static-html-analysis.mjs +117 -146
  139. package/lib/structured-data-identity.mjs +1 -0
  140. package/lib/technology-markup.mjs +100 -0
  141. package/lib/threat-intelligence-manifests.mjs +11 -139
  142. package/lib/threat-intelligence-runtime.mjs +24 -52
  143. package/lib/threat-intelligence-targets.mjs +6 -34
  144. package/lib/threat-intelligence-types.mjs +39 -0
  145. package/lib/typosquat-generator.mjs +19 -15
  146. package/lib/urlhaus-intelligence.mjs +8 -3
  147. package/lib/urlscan-intelligence.mjs +1 -1
  148. package/lib/website-security-posture.mjs +30 -6
  149. package/lib/website-technology.mjs +111 -71
  150. package/lib/whois-authority.mjs +14 -2
  151. package/lib/whois-chain.mjs +9 -2
  152. package/lib/whois-common-formats.mjs +5 -5
  153. package/lib/whois-contracts.mjs +2 -1
  154. package/lib/whois-dialects.mjs +2 -2
  155. package/lib/whois-parser-support.mjs +8 -1
  156. package/lib/whois-transport.mjs +23 -7
  157. package/package.json +2 -1
  158. package/packages/cases/case-attachment-model.mjs +83 -0
  159. package/packages/cases/case-evidence-model.mjs +156 -62
  160. package/packages/cases/case-export-input.mjs +53 -0
  161. package/packages/cases/case-migration-model.mjs +59 -25
  162. package/packages/cases/case-model.mjs +1 -0
  163. package/packages/cases/case-recheck-model.mjs +91 -0
  164. package/packages/cases/case-record-core.mjs +3 -2
  165. package/packages/cases/case-record-operations.mjs +89 -27
  166. package/packages/cases/case-record-projection.mjs +23 -20
  167. package/packages/cases/case-report.mjs +63 -52
  168. package/packages/cases/case-response-actions.mjs +654 -0
  169. package/packages/cases/case-response-model.mjs +41 -1129
  170. package/packages/cases/case-response-outcomes.mjs +317 -0
  171. package/packages/cases/case-response-packet-render.mjs +153 -0
  172. package/packages/cases/case-response-packet-types.mjs +1 -0
  173. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  174. package/packages/cases/case-response-packet.mjs +38 -587
  175. package/packages/cases/case-response-records.mjs +94 -0
  176. package/packages/cases/case-response-review-inputs.mjs +371 -0
  177. package/packages/cases/case-response-values.mjs +85 -0
  178. package/packages/cases/case-review-package.mjs +45 -0
  179. package/packages/cases/case-selection.mjs +46 -0
  180. package/packages/cases/case-storage-model.mjs +25 -0
  181. package/packages/cases/response-route-freshness.mjs +16 -0
  182. package/packages/comparison/capture-context.mjs +61 -0
  183. package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
  184. package/packages/comparison/page-similarity.mjs +2 -1
  185. package/packages/comparison/relationship-evidence.mjs +85 -8
  186. package/packages/comparison/relationship-provenance.mjs +93 -0
  187. package/packages/contracts/analyst-interchange.mjs +216 -0
  188. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  189. package/packages/contracts/analyst-review-state.mjs +5 -4
  190. package/packages/contracts/case-portability.mjs +279 -116
  191. package/packages/contracts/case-views-contract.mjs +12 -0
  192. package/packages/contracts/case-views.mjs +26 -0
  193. package/packages/contracts/cli-command-semantics.mjs +1 -0
  194. package/packages/contracts/cli-lookup.mjs +0 -1
  195. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  196. package/packages/contracts/domain-control-manifest.mjs +127 -73
  197. package/packages/contracts/domain-control-monitor.mjs +48 -45
  198. package/packages/contracts/domain-control-review.mjs +179 -121
  199. package/packages/contracts/domain-name.mjs +21 -0
  200. package/packages/contracts/external-observation-interchange.mjs +2 -1
  201. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  202. package/packages/contracts/http-url.mjs +2 -0
  203. package/packages/contracts/investigation-package-limits.mjs +24 -0
  204. package/packages/contracts/investigation-portability.mjs +56 -27
  205. package/packages/contracts/investigation-run.mjs +16 -0
  206. package/packages/contracts/ip-address.mjs +34 -0
  207. package/packages/contracts/lookup-evidence.mjs +35 -0
  208. package/packages/contracts/offline-comparison.mjs +1 -2
  209. package/packages/contracts/page-fingerprints.mjs +15 -0
  210. package/packages/contracts/risk-calibration.mjs +0 -1
  211. package/packages/contracts/schema-compatibility.mjs +1 -1
  212. package/packages/contracts/schema-lifecycle.mjs +83 -153
  213. package/packages/contracts/selected-file-limits.mjs +6 -0
  214. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  215. package/packages/contracts/web-capture.mjs +10 -0
  216. package/packages/contracts/workspace-portability.mjs +160 -225
  217. package/packages/evidence/artifact-integrity.mjs +8 -2
  218. package/packages/evidence/artifact-structure.mjs +105 -0
  219. package/packages/evidence/decision-fact.mjs +4 -1
  220. package/packages/evidence/domain-control-runtime.mjs +155 -27
  221. package/packages/evidence/domain-posture-context.mjs +79 -0
  222. package/packages/evidence/image-regions.mjs +56 -0
  223. package/packages/evidence/latest-observations.mjs +28 -0
  224. package/packages/evidence/lookup-target.mjs +104 -0
  225. package/packages/evidence/observation.mjs +16 -1
  226. package/packages/evidence/passphrase-encryption.mjs +44 -0
  227. package/packages/evidence/record-identity.mjs +72 -0
  228. package/packages/evidence/registry-dates.mjs +190 -0
  229. package/packages/evidence/retained-file.mjs +69 -0
  230. package/packages/evidence/review-age.mjs +14 -0
  231. package/packages/interchange/bagit.mjs +342 -0
  232. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  233. package/packages/interchange/crc32.mjs +17 -0
  234. package/packages/interchange/external-findings-import.mjs +89 -49
  235. package/packages/interchange/web-capture-import.mjs +382 -0
  236. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  237. package/packages/investigation/investigation-bagit.mjs +14 -0
  238. package/packages/investigation/investigation-capsule.mjs +33 -5
  239. package/packages/investigation/investigation-manifest.mjs +200 -0
  240. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  241. package/packages/investigation/investigation-package.mjs +200 -0
  242. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  243. package/packages/investigation/lookup-asset-graph.mjs +161 -141
  244. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  245. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  246. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  247. package/packages/monitoring/analyst-review-state.mjs +38 -90
  248. package/packages/workspace/brand-profile-model.mjs +158 -20
  249. package/packages/workspace/bulk-columns.mjs +17 -0
  250. package/packages/workspace/bulk-review-model.mjs +11 -5
  251. package/packages/workspace/bulk-session-model.mjs +95 -20
  252. package/packages/workspace/bulk-sort.mjs +1 -0
  253. package/packages/workspace/case-views.mjs +102 -0
  254. package/packages/workspace/hostile-input.mjs +29 -8
  255. package/packages/workspace/investigation-template-model.mjs +19 -4
  256. package/packages/workspace/page-baseline.mjs +18 -15
  257. package/packages/workspace/relationship-observation-model.mjs +41 -26
  258. package/packages/workspace/watchlist-history.mjs +2 -2
  259. package/packages/workspace/watchlist-store.mjs +8 -2
  260. package/packages/workspace/website-snapshot-model.mjs +70 -30
  261. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  262. package/packages/workspace/workspace-archive.mjs +85 -21
  263. package/third-party-notices.txt +29 -2
  264. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -88,6 +88,31 @@ export function enforceStoreBudget(cases) {
88
88
  }
89
89
  return { cases: working, pruned };
90
90
  }
91
+ /** A read-only, ephemeral preview of the exact existing byte-budget policy. */
92
+ export function prepareCaseStoreSave(current, proposed) {
93
+ const result = enforceStoreBudget(proposed);
94
+ const retainedById = new Map(result.cases.map(record => [record.id, record]));
95
+ const removed = proposed.flatMap(record => {
96
+ const retained = retainedById.get(record.id);
97
+ const ids = new Set(retained?.evidenceHistory.map(snapshot => snapshot.id));
98
+ const snapshots = record.evidenceHistory.filter(snapshot => !ids.has(snapshot.id));
99
+ return snapshots.length ? [{
100
+ caseId: record.id, domain: record.domain,
101
+ snapshotIds: snapshots.map(snapshot => snapshot.id),
102
+ remainingSnapshots: retained?.evidenceHistory.length ?? 0,
103
+ }] : [];
104
+ });
105
+ return {
106
+ ...result,
107
+ current: normalizeCaseStore(current).cases,
108
+ removed,
109
+ removedBytes: Math.max(0, byteLength(serializeCaseStore(proposed)) - byteLength(serializeCaseStore(result.cases))),
110
+ };
111
+ }
112
+ /** Same-clock peer edits invalidate confirmation, including changes to other Cases. */
113
+ export function caseStoreSavePreviewIsCurrent(current, preview) {
114
+ return serializeCaseStore(current) === serializeCaseStore(preview.current);
115
+ }
91
116
  export function buildCaseExport(cases, nowIso) {
92
117
  return {
93
118
  version: CASE_SCHEMA_VERSION,
@@ -0,0 +1,16 @@
1
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
2
+ import { MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS, RESPONSE_ROUTE_STALE_AFTER_DAYS } from "../contracts/case-portability.mjs";
3
+ /** Source freshness is independent of action deadlines and analyst follow-up. */
4
+ export function responseRouteFreshness(observedAt, reviewAfter, evaluatedAt) {
5
+ const observation = normalizeExplicitIsoTimestamp(observedAt);
6
+ const evaluation = normalizeExplicitIsoTimestamp(evaluatedAt);
7
+ const deadline = normalizeExplicitIsoTimestamp(reviewAfter);
8
+ if (!observation || !evaluation || (reviewAfter !== null && !deadline))
9
+ return 'unknown';
10
+ const age = Date.parse(evaluation) - Date.parse(observation);
11
+ return age < -MAX_RESPONSE_AUTHORISATION_CLOCK_SKEW_MS
12
+ || (deadline !== null && Date.parse(evaluation) >= Date.parse(deadline))
13
+ || (deadline === null && age > RESPONSE_ROUTE_STALE_AFTER_DAYS * 86_400_000)
14
+ ? 'stale'
15
+ : 'current';
16
+ }
@@ -0,0 +1,61 @@
1
+ import { exact, text } from "../evidence/artifact-structure.mjs";
2
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
3
+ import { readEvidenceImageDimensions } from "../evidence/image-regions.mjs";
4
+ /** Optional declarations remain unknown for existing manifests; no inference from image dimensions. */
5
+ export function readCaptureConditions(raw) {
6
+ if (raw === undefined || raw === null)
7
+ return null;
8
+ const value = exact(raw, ['browser', 'browserVersion', 'viewport', 'deviceScaleFactor', 'locale', 'timezone', 'colourScheme'], 'Capture conditions');
9
+ const viewport = exact(value.viewport, ['width', 'height'], 'Capture viewport');
10
+ const dimensions = readEvidenceImageDimensions(viewport.width, viewport.height);
11
+ if (typeof value.deviceScaleFactor !== 'number' || !Number.isFinite(value.deviceScaleFactor)
12
+ || value.deviceScaleFactor < 0.25 || value.deviceScaleFactor > 8)
13
+ throw new TypeError('Capture scale must be between 0.25 and 8.');
14
+ if (value.colourScheme !== 'light' && value.colourScheme !== 'dark')
15
+ throw new TypeError('Capture colour scheme is unsupported.');
16
+ return Object.freeze({ browser: text(value.browser, 'Capture browser', 40), browserVersion: text(value.browserVersion, 'Capture browser version', 80),
17
+ viewport: dimensions, deviceScaleFactor: value.deviceScaleFactor,
18
+ locale: text(value.locale, 'Capture locale', 80), timezone: text(value.timezone, 'Capture timezone', 80), colourScheme: value.colourScheme });
19
+ }
20
+ export function readObservationLabel(raw) {
21
+ if (raw === undefined || raw === null || raw === '')
22
+ return null;
23
+ return text(raw, 'Observation label', 80);
24
+ }
25
+ export function compareObservationContexts(input) {
26
+ if (!Array.isArray(input) || input.length < 2 || input.length > 20)
27
+ throw new TypeError('Compare between 2 and 20 observation contexts.');
28
+ const observations = input.map(value => {
29
+ const observedAt = value.observedAt === null ? null : normalizeExplicitIsoTimestamp(value.observedAt);
30
+ if (value.observedAt !== null && !observedAt)
31
+ throw new TypeError('Observation time must include an explicit timezone.');
32
+ return { observedAt, observerLabel: readObservationLabel(value.observerLabel), vantageLabel: readObservationLabel(value.vantageLabel), conditions: readCaptureConditions(value.conditions) };
33
+ });
34
+ const timestamps = observations.map(value => value.observedAt === null ? null : Date.parse(value.observedAt));
35
+ const knownTimes = timestamps.filter((value) => value !== null);
36
+ const row = (id, label, values) => ({ id, label, values,
37
+ state: values.some(value => value === null) ? 'unknown' : new Set(values).size === 1 ? 'same' : 'different' });
38
+ const conditions = observations.map(value => value.conditions);
39
+ const rows = [
40
+ row('observer', 'Declared observer', observations.map(value => value.observerLabel)),
41
+ row('vantage', 'Declared vantage', observations.map(value => value.vantageLabel)),
42
+ row('browser', 'Declared browser', conditions.map(value => value ? `${value.browser} ${value.browserVersion}` : null)),
43
+ row('viewport', 'Declared viewport', conditions.map(value => value ? `${value.viewport.width} × ${value.viewport.height}` : null)),
44
+ row('scale', 'Declared scale', conditions.map(value => value ? String(value.deviceScaleFactor) : null)),
45
+ row('locale', 'Declared locale', conditions.map(value => value?.locale ?? null)),
46
+ row('timezone', 'Declared timezone', conditions.map(value => value?.timezone ?? null)),
47
+ row('colour', 'Declared colour scheme', conditions.map(value => value?.colourScheme ?? null)),
48
+ ];
49
+ const labelPairs = observations.map(value => value.observerLabel && value.vantageLabel ? JSON.stringify([value.observerLabel, value.vantageLabel]) : null);
50
+ return {
51
+ observations, rows, time: { state: knownTimes.length !== observations.length ? 'unknown' : new Set(knownTimes).size === 1 ? 'same_instant' : 'different_instants',
52
+ spanMilliseconds: knownTimes.length === observations.length ? Math.max(...knownTimes) - Math.min(...knownTimes) : null },
53
+ labels: labelPairs.some(value => value === null) ? 'incomplete' : new Set(labelPairs).size === labelPairs.length ? 'distinct' : 'repeated',
54
+ independence: 'not_verified',
55
+ limitations: [
56
+ 'Observer, vantage and capture conditions are declarations, not verified network location or independent collection.',
57
+ 'Different capture times, browser versions, viewports, scale, locale and shared upstream caches can explain differences. Equal declarations do not establish identical environments.',
58
+ 'These selected observations do not establish worldwide availability, removal or takedown.',
59
+ ],
60
+ };
61
+ }
@@ -295,6 +295,10 @@ export function comparisonLedgerBulkPairIndexId(earlierSessionId, laterSessionId
295
295
  return stableComparisonLedgerId('ledger-bulk_session_pair', [String(earlierSessionId ?? ''), String(laterSessionId ?? '')]);
296
296
  }
297
297
  export function buildBulkComparisonCandidates(sessionsRaw, pairsRaw, counters) {
298
+ const pairs = comparisonLedgerInputArray(pairsRaw);
299
+ // Saved sessions are comparison inputs only after a pair is requested.
300
+ if (!pairs.length)
301
+ return [];
298
302
  const input = comparisonLedgerInputArray(sessionsRaw);
299
303
  counters.inputRecords += Math.max(0, input.length - MAX_BULK_SESSIONS);
300
304
  const sessions = new Map();
@@ -310,7 +314,6 @@ export function buildBulkComparisonCandidates(sessionsRaw, pairsRaw, counters) {
310
314
  }
311
315
  sessions.set(item.id, item);
312
316
  }
313
- const pairs = comparisonLedgerInputArray(pairsRaw);
314
317
  counters.inputRecords += Math.max(0, pairs.length - MAX_COMPARISON_LEDGER_BULK_PAIRS);
315
318
  const candidates = [];
316
319
  const seen = new Set();
@@ -141,6 +141,7 @@ export function comparePageBaselines(rawReference, rawObserved) {
141
141
  const observed = normalizePageBaseline(rawObserved);
142
142
  if (!reference || !observed)
143
143
  return null;
144
+ const sameFingerprintVersion = reference.fingerprintVersion === observed.fingerprintVersion;
144
145
  const components = [
145
146
  digestComponent('normalized_html', 'Normalised HTML', 'Normalized-HTML', reference.normalizedHtml, observed.normalizedHtml),
146
147
  visibleTextComponent(reference.visibleText, observed.visibleText),
@@ -149,7 +150,7 @@ export function comparePageBaselines(rawReference, rawObserved) {
149
150
  faviconComponent(reference, observed),
150
151
  setComponent('resource_hosts', 'External resource hosts', 'host', reference.resourceHosts, observed.resourceHosts, (value) => value),
151
152
  setComponent('tracking_identifiers', 'Tracking identifiers', 'identifier', reference.trackingIdentifiers, observed.trackingIdentifiers, (value) => `${value.type}:${value.value}`),
152
- ];
153
+ ].map((item) => sameFingerprintVersion || item.id === 'favicon' ? item : component(item.id, item.label, item.method, 'unavailable', 'Different fingerprint versions', 'These captures use different HTML fingerprint algorithms. Retain the older baseline and capture a new baseline to compare current page evidence.', true));
153
154
  const counts = { same: 0, overlap: 0, different: 0, notObserved: 0, unavailable: 0 };
154
155
  for (const item of components) {
155
156
  if (item.status === 'not_observed')
@@ -5,6 +5,7 @@
5
5
  // boundary.
6
6
  import { normalizeDomain } from "../cases/case-model.mjs";
7
7
  import { groupBySimilarFavicon } from "./favicon-similarity.mjs";
8
+ import { qualifyRelationshipSources, relationshipSourceEvidence, normalizeRelationshipSourceProjection, unknownRelationshipSource, MAX_RELATIONSHIP_SOURCES_PER_DOMAIN, } from "./relationship-provenance.mjs";
8
9
  import { RELATIONSHIP_EVIDENCE_SCHEMA, RELATIONSHIP_EVIDENCE_VERSION, SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS, TLS_RELATIONSHIP_PROFILE_VERSION, } from "../contracts/offline-comparison.mjs";
9
10
  export { RELATIONSHIP_EVIDENCE_SCHEMA, RELATIONSHIP_EVIDENCE_VERSION, SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS, TLS_RELATIONSHIP_PROFILE_VERSION, };
10
11
  export const MAX_RELATIONSHIP_ROWS = 2000;
@@ -16,6 +17,12 @@ export const MAX_TRACKING_IDS_PER_ROW = 30;
16
17
  export const MAX_OFFICIAL_ASSET_HOSTS_PER_ROW = 30;
17
18
  export const MAX_OFFICIAL_DOMAINS = 200;
18
19
  export const MAX_FAVICON_ROWS = 250;
20
+ const ARRAY_RELATIONSHIP_FIELDS = {
21
+ nameserver_set: { field: 'nameservers', maximum: MAX_NAMESERVERS_PER_ROW },
22
+ ip_address: { field: 'ipAddresses', maximum: MAX_IPS_PER_ROW },
23
+ tracking_identifier: { field: 'trackingIdentifiers', maximum: MAX_TRACKING_IDS_PER_ROW },
24
+ official_asset: { field: 'officialAssetHosts', maximum: MAX_OFFICIAL_ASSET_HOSTS_PER_ROW },
25
+ };
19
26
  const CONTROL_RE = /[\x00-\x1f\x7f]/;
20
27
  const FAVICON_SHA_RE = /^[a-f0-9]{64}$/i;
21
28
  const FAVICON_PHASH_RE = /^[a-f0-9]{16}$/i;
@@ -92,7 +99,7 @@ function tlsCertificateFingerprint(availability) {
92
99
  const certificate = record(tls?.certificate);
93
100
  const fingerprint = certificate?.fingerprintSha256;
94
101
  const status = tls?.status;
95
- if (tls?.source !== 'tls' || !SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS.some((version) => version === Number(tls?.profileVersion))
102
+ if (tls?.source !== 'tls' || !SUPPORTED_TLS_RELATIONSHIP_PROFILE_VERSIONS.some((version) => version === tls?.profileVersion)
96
103
  || typeof status !== 'string' || !['success', 'partial'].includes(status) || typeof fingerprint !== 'string'
97
104
  || fingerprint.length !== 64 || !CERTIFICATE_SHA_RE.test(fingerprint))
98
105
  return null;
@@ -131,6 +138,36 @@ export function relationshipObservation(rawAvailability, rawOfficialDomains = []
131
138
  ? availability.faviconHash.toLowerCase() : null;
132
139
  const faviconPHash = typeof availability.faviconPHash === 'string' && FAVICON_PHASH_RE.test(availability.faviconPHash)
133
140
  ? availability.faviconPHash.toLowerCase() : null;
141
+ const certificateFingerprint = tlsCertificateFingerprint(availability);
142
+ const dnsSource = relationshipSourceEvidence('dns', availability.dns);
143
+ const httpSource = relationshipSourceEvidence('http', availability.http);
144
+ const projectedSource = (source, truncated) => truncated
145
+ ? { ...source, complete: false, truncated: true } : source;
146
+ const sourceEvidence = {};
147
+ if (nameservers.values.length)
148
+ sourceEvidence.nameserver_set = [
149
+ // The compact availability nameserver list can be registration data or a
150
+ // DNS fallback. Its source is not distinguishable from this projection.
151
+ ...(Array.isArray(availability.nameservers) && availability.nameservers.length
152
+ ? [availability.source === 'dns' ? projectedSource(dnsSource, nameservers.truncated)
153
+ : unknownRelationshipSource('registration_or_dns')] : []),
154
+ ...(Array.isArray(records.ns) && records.ns.length ? [projectedSource(dnsSource, nameservers.truncated)] : []),
155
+ ];
156
+ if (ipAddresses.values.length)
157
+ sourceEvidence.ip_address = [projectedSource(dnsSource, ipAddresses.truncated)];
158
+ if (certificateFingerprint)
159
+ sourceEvidence.certificate = [relationshipSourceEvidence('tls', availability.tls)];
160
+ if (trackingIdentifiers.values.length)
161
+ sourceEvidence.tracking_identifier = [
162
+ projectedSource(httpSource, trackingIdentifiers.truncated),
163
+ relationshipSourceEvidence('page_identity', availability.pageIdentity),
164
+ ];
165
+ if (officialAssetHosts.length)
166
+ sourceEvidence.official_asset = [projectedSource(httpSource, assets.truncated
167
+ || officialDomainSource.length > MAX_OFFICIAL_DOMAINS)];
168
+ // A homepage timestamp does not date the independently fetched favicon.
169
+ if (faviconHash || faviconPHash)
170
+ sourceEvidence.favicon = [unknownRelationshipSource('favicon')];
134
171
  return {
135
172
  version: RELATIONSHIP_EVIDENCE_VERSION,
136
173
  nameservers: nameservers.values,
@@ -139,7 +176,8 @@ export function relationshipObservation(rawAvailability, rawOfficialDomains = []
139
176
  officialAssetHosts,
140
177
  faviconHash,
141
178
  faviconPHash,
142
- certificateFingerprint: tlsCertificateFingerprint(availability),
179
+ certificateFingerprint,
180
+ sourceEvidence,
143
181
  truncated: nameservers.truncated || ipAddresses.truncated || trackingIdentifiers.truncated || assets.truncated
144
182
  || officialDomainSource.length > MAX_OFFICIAL_DOMAINS,
145
183
  };
@@ -152,7 +190,22 @@ function addBucket(buckets, value, domain) {
152
190
  buckets.get(value)?.add(domain);
153
191
  }
154
192
  function group(type, label, method, value, domains, description, normalizedValue = value) {
155
- return { type, label, method, value, normalizedValue, domains, description };
193
+ return { type, label, method, value, normalizedValue, domains, description, ...qualifyRelationshipSources([], domains) };
194
+ }
195
+ function contributesToGroup(observation, item, domain) {
196
+ if (item.type === 'certificate')
197
+ return typeof observation.certificateFingerprint === 'string'
198
+ && observation.certificateFingerprint.toLowerCase() === item.normalizedValue;
199
+ if (item.type === 'nameserver_set')
200
+ return Array.isArray(observation.nameservers)
201
+ && [...new Set(observation.nameservers.slice(0, MAX_NAMESERVERS_PER_ROW).map(hostname).filter(Boolean))].sort().join(' · ') === item.normalizedValue;
202
+ const field = ARRAY_RELATIONSHIP_FIELDS[item.type];
203
+ if (field)
204
+ return Array.isArray(observation[field.field]) && observation[field.field].slice(0, field.maximum).some((value) => (item.type === 'ip_address' ? ipAddress(value) : item.type === 'official_asset' ? hostname(value) : value) === item.normalizedValue);
205
+ return item.type === 'favicon' && item.normalizedValue.split('|').includes(`${domain}=${[
206
+ typeof observation.faviconHash === 'string' && FAVICON_SHA_RE.test(observation.faviconHash) ? `sha256:${observation.faviconHash.toLowerCase()}` : '',
207
+ typeof observation.faviconPHash === 'string' && FAVICON_PHASH_RE.test(observation.faviconPHash) ? `dhash:${observation.faviconPHash.toLowerCase()}` : '',
208
+ ].filter(Boolean).join(',')}`);
156
209
  }
157
210
  /**
158
211
  * @param {Array<{domain?:unknown,trusted?:unknown,relationship?:unknown}>} rawRows
@@ -164,7 +217,7 @@ export function buildScanRelationships(rawRows) {
164
217
  for (const raw of input.slice(0, MAX_RELATIONSHIP_ROWS)) {
165
218
  const domain = normalizeDomain(raw?.domain);
166
219
  const observation = record(raw?.relationship);
167
- if (!domain || raw?.trusted || !observation || observation.version !== RELATIONSHIP_EVIDENCE_VERSION)
220
+ if (!domain || raw?.trusted || !observation || ![2, RELATIONSHIP_EVIDENCE_VERSION].some((version) => version === observation.version))
168
221
  continue;
169
222
  rows.push({ domain, observation });
170
223
  if (observation.truncated === true)
@@ -245,11 +298,35 @@ export function buildScanRelationships(rawRows) {
245
298
  output.sort((left, right) => (Number(order.get(left.type)) - Number(order.get(right.type))) || left.value.localeCompare(right.value) || left.domains.join('|').localeCompare(right.domains.join('|')));
246
299
  if (output.length > MAX_RELATIONSHIP_GROUPS)
247
300
  truncated = true;
301
+ const rowsByDomain = new Map();
302
+ for (const row of rows) {
303
+ const members = rowsByDomain.get(row.domain) ?? [];
304
+ members.push(row);
305
+ rowsByDomain.set(row.domain, members);
306
+ }
248
307
  const groups = output.slice(0, MAX_RELATIONSHIP_GROUPS).map((item) => {
249
- if (item.domains.length <= MAX_RELATIONSHIP_DOMAINS)
250
- return item;
251
- truncated = true;
252
- return { ...item, domains: item.domains.slice(0, MAX_RELATIONSHIP_DOMAINS) };
308
+ const domains = item.domains.slice(0, MAX_RELATIONSHIP_DOMAINS);
309
+ let groupTruncated = item.domains.length > domains.length || input.length > MAX_RELATIONSHIP_ROWS;
310
+ if (groupTruncated)
311
+ truncated = true;
312
+ const contributors = [];
313
+ for (const domain of domains)
314
+ for (const { observation } of rowsByDomain.get(domain) ?? []) {
315
+ if (!contributesToGroup(observation, item, domain))
316
+ continue;
317
+ const field = ARRAY_RELATIONSHIP_FIELDS[item.type];
318
+ if (field && Array.isArray(observation[field.field]) && observation[field.field].length > field.maximum)
319
+ groupTruncated = true;
320
+ const projection = observation.version === RELATIONSHIP_EVIDENCE_VERSION
321
+ ? normalizeRelationshipSourceProjection(observation.sourceEvidence) : {};
322
+ for (const source of projection[item.type] ?? [unknownRelationshipSource()]) {
323
+ if (contributors.length >= MAX_RELATIONSHIP_DOMAINS * MAX_RELATIONSHIP_SOURCES_PER_DOMAIN)
324
+ groupTruncated = true;
325
+ else
326
+ contributors.push({ domain, ...source });
327
+ }
328
+ }
329
+ return { ...item, domains, ...qualifyRelationshipSources(contributors, domains, { truncated: groupTruncated, type: item.type }) };
253
330
  });
254
331
  return {
255
332
  version: RELATIONSHIP_EVIDENCE_VERSION,
@@ -0,0 +1,93 @@
1
+ // Only the source metadata needed to qualify an observed relationship crosses
2
+ // this boundary. Collection and retention times are never source-time fallbacks.
3
+ import { normalizeDomain } from "../cases/case-model.mjs";
4
+ import { normalizeExplicitIsoTimestamp, readObservationEnvelope } from "../evidence/observation.mjs";
5
+ export const RELATIONSHIP_TYPES = Object.freeze([
6
+ 'nameserver_set', 'ip_address', 'certificate', 'tracking_identifier', 'favicon', 'official_asset',
7
+ ]);
8
+ const SOURCES = ['dns', 'tls', 'http', 'page_identity', 'favicon', 'registration_or_dns', 'unknown'];
9
+ const SOURCES_BY_TYPE = {
10
+ nameserver_set: ['dns', 'registration_or_dns'], ip_address: ['dns'], certificate: ['tls'],
11
+ tracking_identifier: ['http', 'page_identity'], favicon: ['favicon'], official_asset: ['http'],
12
+ };
13
+ const STATUSES = ['success', 'partial', 'not_found', 'skipped', 'error', 'unsupported', 'not_applicable', 'unknown'];
14
+ export const MAX_RELATIONSHIP_SOURCES_PER_DOMAIN = 3;
15
+ export const MAX_RELATIONSHIP_SOURCE_DOMAINS = 50;
16
+ function record(value) {
17
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
18
+ }
19
+ export function unknownRelationshipSource(source = 'unknown') {
20
+ return { source, status: 'unknown', observedAt: null, complete: false, truncated: null };
21
+ }
22
+ export function relationshipSourceEvidence(source, raw) {
23
+ const result = readObservationEnvelope(raw);
24
+ if (result.state !== 'supported' || result.observation.source !== source) {
25
+ return { ...unknownRelationshipSource(source), status: result.state === 'unsupported' ? 'unsupported' : 'unknown' };
26
+ }
27
+ return normalizeSource({ ...result.observation, truncated: record(raw).truncated });
28
+ }
29
+ function normalizeSource(raw, type) {
30
+ const value = record(raw);
31
+ const source = SOURCES.find((candidate) => candidate === value.source) ?? 'unknown';
32
+ if (type && source !== 'unknown' && !SOURCES_BY_TYPE[type].includes(source))
33
+ return unknownRelationshipSource();
34
+ const status = STATUSES.find((candidate) => candidate === value.status) ?? 'unknown';
35
+ const observedAt = normalizeExplicitIsoTimestamp(value.observedAt);
36
+ const truncated = typeof value.truncated === 'boolean' ? value.truncated : null;
37
+ return {
38
+ source, status, observedAt, truncated,
39
+ complete: source !== 'unknown' && source !== 'registration_or_dns' && status === 'success'
40
+ && value.complete === true && truncated === false && observedAt !== null,
41
+ };
42
+ }
43
+ export function normalizeRelationshipSourceProjection(raw) {
44
+ const value = record(raw);
45
+ const output = {};
46
+ for (const type of RELATIONSHIP_TYPES) {
47
+ if (!Array.isArray(value[type]))
48
+ continue;
49
+ const candidates = value[type];
50
+ output[type] = candidates.slice(0, MAX_RELATIONSHIP_SOURCES_PER_DOMAIN).map((candidate) => {
51
+ const source = normalizeSource(candidate, type);
52
+ return candidates.length > MAX_RELATIONSHIP_SOURCES_PER_DOMAIN ? { ...source, complete: false, truncated: true } : source;
53
+ });
54
+ }
55
+ return output;
56
+ }
57
+ /** Missing, malformed or omitted contributors cannot make a group complete. */
58
+ export function qualifyRelationshipSources(raw, rawDomains, options = {}) {
59
+ const domains = [...new Set(rawDomains.slice(0, MAX_RELATIONSHIP_SOURCE_DOMAINS * 2).map(normalizeDomain).filter(Boolean))].slice(0, MAX_RELATIONSHIP_SOURCE_DOMAINS);
60
+ const allowed = new Set(domains);
61
+ const candidates = Array.isArray(raw) ? raw : [];
62
+ const maximum = MAX_RELATIONSHIP_SOURCE_DOMAINS * MAX_RELATIONSHIP_SOURCES_PER_DOMAIN;
63
+ const byDomain = new Map();
64
+ let omitted = options.truncated === true || rawDomains.length > MAX_RELATIONSHIP_SOURCE_DOMAINS || candidates.length > maximum;
65
+ for (const candidate of candidates.slice(0, maximum)) {
66
+ const value = record(candidate);
67
+ const domain = normalizeDomain(value.domain);
68
+ if (!domain || !allowed.has(domain)) {
69
+ omitted = true;
70
+ continue;
71
+ }
72
+ const contribution = { domain, ...normalizeSource(value, options.type) };
73
+ const entries = byDomain.get(domain) ?? new Map();
74
+ const key = JSON.stringify(contribution);
75
+ if (!entries.has(key) && entries.size >= MAX_RELATIONSHIP_SOURCES_PER_DOMAIN)
76
+ omitted = true;
77
+ else
78
+ entries.set(key, contribution);
79
+ byDomain.set(domain, entries);
80
+ }
81
+ const sourceEvidence = domains.sort().flatMap((domain) => {
82
+ const entries = byDomain.get(domain);
83
+ return entries?.size ? [...entries.values()].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
84
+ : [{ domain, ...unknownRelationshipSource() }];
85
+ });
86
+ const observedAt = sourceEvidence.length && sourceEvidence.every((entry) => entry.observedAt !== null)
87
+ ? sourceEvidence.map((entry) => entry.observedAt).sort().at(-1) ?? null : null;
88
+ return {
89
+ sourceEvidence, observedAt,
90
+ complete: sourceEvidence.length > 0 && !omitted && sourceEvidence.every((entry) => entry.complete),
91
+ truncated: omitted || sourceEvidence.some((entry) => entry.truncated === true),
92
+ };
93
+ }
@@ -0,0 +1,216 @@
1
+ import { defineSchemaCompatibility } from "./schema-compatibility.mjs";
2
+ import { buildExtractedLifecycleFamily } from "./extracted-domain-lifecycle.mjs";
3
+ import { defineSchemaLifecycleFamily } from "./schema-lifecycle.mjs";
4
+ export const ANALYST_INTERCHANGE_CONTRACT_OWNER = 'packages/contracts/analyst-interchange.mts';
5
+ export const INVESTIGATION_CACAO_SPEC_VERSION = 'cacao-2.0';
6
+ export const INVESTIGATION_CACAO_PROFILE_VERSION = 2;
7
+ export const INVESTIGATION_CACAO_PROFILE_SEMVER = '2.0.0';
8
+ export const INVESTIGATION_CACAO_SUPPORTED_PROFILE_VERSIONS = [INVESTIGATION_CACAO_PROFILE_VERSION];
9
+ export const MAX_INVESTIGATION_CACAO_IMPORT_BYTES = 384 * 1024;
10
+ export const BRAND_PROTECTION_OPERATIONS_REPORT_SCHEMA = 'whoisleuth.brand-protection-operations-report';
11
+ export const BRAND_PROTECTION_OPERATIONS_REPORT_VERSION = 2;
12
+ export const MAX_OPERATIONS_REPORT_BYTES = 64 * 1024;
13
+ export const DEFENSIVE_INDICATOR_LIFECYCLE_SCHEMA = 'whoisleuth.internal.defensive-indicators';
14
+ export const DEFENSIVE_INDICATOR_EXPORT_VERSION = 2;
15
+ export const DEFENSIVE_INDICATOR_MANIFEST_SCHEMA = 'whoisleuth.defensive-indicator-manifest';
16
+ export const DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA = 'whoisleuth.defensive-indicator-rollback';
17
+ export const STIX_INDICATOR_LIFECYCLE_SCHEMA = 'whoisleuth.internal.stix-indicators';
18
+ export const STIX_INDICATOR_EXPORT_VERSION = 2;
19
+ export const MISP_INDICATOR_LIFECYCLE_SCHEMA = 'whoisleuth.internal.misp-indicators';
20
+ export const MISP_INDICATOR_EXPORT_VERSION = 2;
21
+ export const DNS_CHANGE_REHEARSAL_VERSION = 2;
22
+ export const DNS_CHANGE_REHEARSAL_EXPORT_SCHEMA = 'whoisleuth.dns-change-rehearsal';
23
+ export const MAIL_REPORT_SCHEMA = 'whoisleuth.mail-report-review';
24
+ export const MAIL_REPORT_VERSION = 3;
25
+ export const MAIL_REPORT_CANONICALIZATION_ROUTES = Object.freeze([
26
+ Object.freeze({ version: 2, canonicalization: 'sorted-json-v1', explicit: false }),
27
+ Object.freeze({ version: MAIL_REPORT_VERSION, canonicalization: 'sorted-json-v2', explicit: true }),
28
+ ]);
29
+ export const REGISTRATION_DISCLOSURE_PLAN_SCHEMA = 'whoisleuth.registration-disclosure-plan';
30
+ export const REGISTRATION_DISCLOSURE_PLAN_VERSION = 2;
31
+ export const STATIC_PAGE_PATTERN_PACK_SCHEMA = 'whoisleuth.static-page-pattern-pack';
32
+ export const STATIC_PAGE_PATTERN_PACK_VERSION = 2;
33
+ export const MAX_STATIC_PAGE_PATTERN_PACK_BYTES = 256 * 1024;
34
+ export const WEB_CAPTURE_SUMMARY_SCHEMA = 'whoisleuth.web-capture-summary';
35
+ export const WEB_CAPTURE_SUMMARY_VERSION = 1;
36
+ export const WEB_CAPTURE_MANIFEST_SCHEMA = 'whoisleuth.web-capture-manifest';
37
+ export const WEB_CAPTURE_MANIFEST_VERSION = 2;
38
+ export const MAX_WEB_CAPTURE_MANIFEST_BYTES = 1024 * 1024;
39
+ const CACAO_COMPATIBILITY = defineSchemaCompatibility({
40
+ id: 'export.investigation-cacao-profile', kind: 'export', schema: INVESTIGATION_CACAO_SPEC_VERSION,
41
+ currentVersion: INVESTIGATION_CACAO_PROFILE_VERSION, supportedVersions: INVESTIGATION_CACAO_SUPPORTED_PROFILE_VERSIONS,
42
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
43
+ writeSemantics: 'non_destructive_merge', byteBudget: MAX_INVESTIGATION_CACAO_IMPORT_BYTES,
44
+ owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
45
+ note: 'Restricted CACAO 2.0 profile with a connected linear sequence of manual analyst steps; version 2 adds fixed response recipe identifiers while executable commands, branches, targets, credentials, and arbitrary operations remain rejected.',
46
+ });
47
+ const OPERATIONS_REPORT_COMPATIBILITY = defineSchemaCompatibility({
48
+ id: 'export.brand-protection-operations-report', kind: 'export', schema: BRAND_PROTECTION_OPERATIONS_REPORT_SCHEMA,
49
+ currentVersion: BRAND_PROTECTION_OPERATIONS_REPORT_VERSION, supportedVersions: [BRAND_PROTECTION_OPERATIONS_REPORT_VERSION],
50
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'not_applicable', migration: 'read_only',
51
+ writeSemantics: 'read_only', byteBudget: MAX_OPERATIONS_REPORT_BYTES, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
52
+ note: 'Version 2 aggregates typed action and independent-effect events with explicit denominators, omissions, time-window basis, and limitations. It excludes case identities and is not a service level or provider ranking.',
53
+ });
54
+ const DEFENSIVE_INDICATORS_COMPATIBILITY = defineSchemaCompatibility({
55
+ id: 'export.defensive-indicators', kind: 'export', schema: null,
56
+ currentVersion: DEFENSIVE_INDICATOR_EXPORT_VERSION, supportedVersions: [DEFENSIVE_INDICATOR_EXPORT_VERSION],
57
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'not_applicable', migration: 'read_only',
58
+ writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
59
+ note: 'Review-only indicator, provenance-manifest, and rollback formats; never submitted or applied automatically.',
60
+ });
61
+ const STIX_INDICATORS_COMPATIBILITY = defineSchemaCompatibility({
62
+ id: 'export.stix-indicators', kind: 'export', schema: null,
63
+ currentVersion: STIX_INDICATOR_EXPORT_VERSION, supportedVersions: [1, STIX_INDICATOR_EXPORT_VERSION],
64
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'not_applicable', migration: 'read_only',
65
+ writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
66
+ note: 'STIX 2.1 bundle with observations separated from heuristic indicators. Version 2 uses a Note when observation time is unknown; Indicator validity starts at creation, not at a substituted sighting time.',
67
+ });
68
+ const MISP_INDICATORS_COMPATIBILITY = defineSchemaCompatibility({
69
+ id: 'export.misp-indicators', kind: 'export', schema: null,
70
+ currentVersion: MISP_INDICATOR_EXPORT_VERSION, supportedVersions: [1, MISP_INDICATOR_EXPORT_VERSION],
71
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'not_applicable', migration: 'read_only',
72
+ writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
73
+ note: 'Unpublished, non-IDS, non-correlating event for reviewed import. Version 2 omits optional seen times when the source observation time is unknown.',
74
+ });
75
+ const WEB_CAPTURE_SUMMARY_COMPATIBILITY = defineSchemaCompatibility({
76
+ id: 'export.web-capture-summary', kind: 'export', schema: WEB_CAPTURE_SUMMARY_SCHEMA,
77
+ currentVersion: WEB_CAPTURE_SUMMARY_VERSION, supportedVersions: [WEB_CAPTURE_SUMMARY_VERSION],
78
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
79
+ writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
80
+ note: 'Sanitised metadata-only capture summary accepted through the bounded external-finding preview.',
81
+ });
82
+ const WEB_CAPTURE_MANIFEST_COMPATIBILITY = defineSchemaCompatibility({
83
+ id: 'export.web-capture-manifest', kind: 'export', schema: WEB_CAPTURE_MANIFEST_SCHEMA,
84
+ currentVersion: WEB_CAPTURE_MANIFEST_VERSION, supportedVersions: [WEB_CAPTURE_MANIFEST_VERSION],
85
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'read_only',
86
+ writeSemantics: 'read_only', byteBudget: MAX_WEB_CAPTURE_MANIFEST_BYTES,
87
+ owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
88
+ note: 'Metadata-only capture manifest; the browser and offline artefact comparison accept the v1.47.4 current-writer version 2.',
89
+ });
90
+ const DEFENSIVE_MANIFEST_COMPATIBILITY = defineSchemaCompatibility({
91
+ id: 'export.defensive-indicator-manifest', kind: 'export', schema: DEFENSIVE_INDICATOR_MANIFEST_SCHEMA,
92
+ currentVersion: DEFENSIVE_INDICATOR_EXPORT_VERSION, supportedVersions: [DEFENSIVE_INDICATOR_EXPORT_VERSION],
93
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'not_applicable', migration: 'read_only',
94
+ writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
95
+ note: 'Output-only manifest for explicitly selected defensive indicators; historical version 1 is not claimed without a retained reader.',
96
+ });
97
+ const DEFENSIVE_ROLLBACK_COMPATIBILITY = defineSchemaCompatibility({
98
+ id: 'export.defensive-indicator-rollback', kind: 'export', schema: DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA,
99
+ currentVersion: DEFENSIVE_INDICATOR_EXPORT_VERSION, supportedVersions: [DEFENSIVE_INDICATOR_EXPORT_VERSION],
100
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'not_applicable', migration: 'read_only',
101
+ writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
102
+ note: 'Output-only rollback companion for the same bounded defensive-indicator selection.',
103
+ });
104
+ const DNS_REHEARSAL_COMPATIBILITY = defineSchemaCompatibility({
105
+ id: 'export.dns-change-rehearsal', kind: 'export', schema: DNS_CHANGE_REHEARSAL_EXPORT_SCHEMA,
106
+ currentVersion: DNS_CHANGE_REHEARSAL_VERSION, supportedVersions: [DNS_CHANGE_REHEARSAL_VERSION],
107
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'not_applicable', migration: 'read_only',
108
+ writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
109
+ note: 'Output-only DNS change rehearsal document; version 2 is the only producer contract represented by this tree.',
110
+ });
111
+ const MAIL_REPORT_COMPATIBILITY = defineSchemaCompatibility({
112
+ id: 'export.mail-report-review', kind: 'export', schema: MAIL_REPORT_SCHEMA,
113
+ currentVersion: MAIL_REPORT_VERSION, supportedVersions: [1, 2, MAIL_REPORT_VERSION], acceptsUnversionedLegacy: false,
114
+ futureVersionBehavior: 'not_applicable', migration: 'read_only', writeSemantics: 'read_only', byteBudget: null,
115
+ owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
116
+ note: 'Output-only aggregate mail review: v3 declares sorted-json-v2 integrity; v2 retains its undeclared v1 digest. Admission coverage is separate from pagination. Historical v1 has no reader; input and output byte bounds differ.',
117
+ });
118
+ const REGISTRATION_DISCLOSURE_COMPATIBILITY = defineSchemaCompatibility({
119
+ id: 'export.registration-disclosure-plan', kind: 'export', schema: REGISTRATION_DISCLOSURE_PLAN_SCHEMA,
120
+ currentVersion: REGISTRATION_DISCLOSURE_PLAN_VERSION, supportedVersions: [REGISTRATION_DISCLOSURE_PLAN_VERSION],
121
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'not_applicable', migration: 'read_only',
122
+ writeSemantics: 'read_only', byteBudget: null, owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
123
+ note: 'Output-only bounded registration-disclosure planning document; version 2 is the only current producer contract.',
124
+ });
125
+ const STATIC_PATTERN_PACK_COMPATIBILITY = defineSchemaCompatibility({
126
+ id: 'export.static-page-pattern-pack', kind: 'export', schema: STATIC_PAGE_PATTERN_PACK_SCHEMA,
127
+ currentVersion: STATIC_PAGE_PATTERN_PACK_VERSION, supportedVersions: [STATIC_PAGE_PATTERN_PACK_VERSION],
128
+ acceptsUnversionedLegacy: false, futureVersionBehavior: 'reject', migration: 'exact_current_only',
129
+ writeSemantics: 'non_destructive_merge', byteBudget: MAX_STATIC_PAGE_PATTERN_PACK_BYTES,
130
+ owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
131
+ note: 'Exact-current bounded page-pattern pack imported by explicit analyst action and merged without executing code or starting collection.',
132
+ });
133
+ export function serialiseAnalystInterchangeJson(value) {
134
+ return `${JSON.stringify(value, null, 2)}\n`;
135
+ }
136
+ const F = 'test/fixtures/extracted-domain-lifecycle/';
137
+ export const ANALYST_INTERCHANGE_LIFECYCLE_FAMILY = defineSchemaLifecycleFamily(buildExtractedLifecycleFamily({
138
+ id: 'analyst-interchange', owner: ANALYST_INTERCHANGE_CONTRACT_OWNER,
139
+ serializerExportName: 'serialiseAnalystInterchangeJson', plane: 'shared', projection: 'browser_export',
140
+ retention: 'operator_controlled_output',
141
+ includedCategories: ['reviewed-indicators', 'source-identity', 'provenance', 'completeness', 'omitted-counts', 'limitations'],
142
+ excludedCategories: ['credentials', 'cookies', 'message-content', 'raw-upstream-responses', 'expanded-contacts'],
143
+ formats: [
144
+ { descriptor: CACAO_COMPATIBILITY, lifecycleSchema: INVESTIGATION_CACAO_SPEC_VERSION,
145
+ requiredKeys: ['type', 'spec_version', 'id', 'name', 'created', 'modified', 'workflow_start', 'workflow', 'extension_definitions'],
146
+ optionalKeys: ['description', 'playbook_types', 'playbook_activities', 'created_by', 'playbook_extensions', 'agent_definitions', 'x_whoisleuth_profile_version'],
147
+ hook: { module: 'packages/interchange/investigation-playbook-interchange.mts', exportName: 'parseCacaoInvestigationPlaybook', role: 'normaliser', runtime: 'shared' },
148
+ fixtures: [
149
+ { id: 'investigation-cacao-profile-v2', path: `${F}investigation-cacao-profile-v2.json`, bytes: 4_091, sha256: '7e9a3de6e12b83477690c0c58e03e6efbbb8b0c11fd98dc8470bdb6784d42358', version: INVESTIGATION_CACAO_PROFILE_VERSION },
150
+ ] },
151
+ { descriptor: OPERATIONS_REPORT_COMPATIBILITY, lifecycleSchema: BRAND_PROTECTION_OPERATIONS_REPORT_SCHEMA,
152
+ requiredKeys: ['schema', 'version', 'generatedAt', 'window', 'limitations'], optionalKeys: ['sourceState', 'summary', 'counts', 'states', 'actionTypes', 'durations', 'denominators', 'omissions'],
153
+ hook: { module: 'packages/interchange/brand-protection-operations-report.mts', exportName: 'serializeBrandProtectionOperationsReport', role: 'builder', runtime: 'shared' },
154
+ fixtures: [
155
+ { id: 'brand-protection-operations-report-v2', path: `${F}brand-protection-operations-report-v2.json`, bytes: 187, sha256: 'd38bf9b2c5cfc9c83df267ff2303ea2c3cdb398dd12c6607fbedb265b880a324', version: BRAND_PROTECTION_OPERATIONS_REPORT_VERSION },
156
+ ] },
157
+ { descriptor: DEFENSIVE_INDICATORS_COMPATIBILITY, lifecycleSchema: DEFENSIVE_INDICATOR_LIFECYCLE_SCHEMA,
158
+ requiredKeys: ['version', 'format'], optionalKeys: ['generatedAt', 'expiresAt', 'domains', 'entries', 'indicators', 'exclusions', 'explicitSelection', 'includeWildcards', 'truncated', 'filename', 'manifestFilename', 'rollbackFilename', 'mimeType', 'content', 'manifestContent', 'rollbackContent', 'limitations'],
159
+ hook: { module: 'packages/interchange/defensive-indicator-export.mts', exportName: 'buildDefensiveIndicatorExport', role: 'builder', runtime: 'shared' },
160
+ fixtures: [
161
+ { id: 'defensive-indicators-v2', path: `${F}defensive-indicators-v2.json`, bytes: 66, sha256: '597794782fbb2291acbcd854ced48bb2b8142f175fd9dd8ab99d53dd5532701e', version: DEFENSIVE_INDICATOR_EXPORT_VERSION },
162
+ ] },
163
+ { descriptor: STIX_INDICATORS_COMPATIBILITY, lifecycleSchema: STIX_INDICATOR_LIFECYCLE_SCHEMA,
164
+ requiredKeys: ['type', 'id', 'objects'], optionalKeys: ['x_whoisleuth_export_version'],
165
+ hook: { module: 'packages/interchange/stix-indicator-export.mts', exportName: 'buildStixIndicatorExport', role: 'builder', runtime: 'shared' },
166
+ fixtures: [
167
+ { id: 'stix-indicators-v1', path: `${F}stix-indicators-v1.json`, bytes: 115, sha256: '517fe4aec65bbdb5252945d5f0f0a393c5c72a2eb8f48a1c8147ae5c6e681f8e', version: 1 },
168
+ { id: 'stix-indicators-v2', path: `${F}stix-indicators-v2.json`, bytes: 5302, sha256: '09b3e53413944efbad81458b0042cc76e4d64f0895d5c1c2dbace3d52af0a6a4', version: STIX_INDICATOR_EXPORT_VERSION },
169
+ ] },
170
+ { descriptor: MISP_INDICATORS_COMPATIBILITY, lifecycleSchema: MISP_INDICATOR_LIFECYCLE_SCHEMA,
171
+ requiredKeys: ['Event'], optionalKeys: [],
172
+ hook: { module: 'packages/interchange/misp-indicator-export.mts', exportName: 'buildMispIndicatorExport', role: 'builder', runtime: 'shared' },
173
+ fixtures: [
174
+ { id: 'misp-indicators-v1', path: `${F}misp-indicators-v1.json`, bytes: 167, sha256: '043102cfb145ead16f6eba9b4ebe723aed12cc02f5899c918a0d172674e74997', version: 1 },
175
+ { id: 'misp-indicators-v2', path: `${F}misp-indicators-v2.json`, bytes: 1611, sha256: '9bcf624c2bcc6da9e1299ef470b288ca2f2da51c65665f0729f332c513a3d558', version: MISP_INDICATOR_EXPORT_VERSION },
176
+ ] },
177
+ { descriptor: WEB_CAPTURE_SUMMARY_COMPATIBILITY, lifecycleSchema: WEB_CAPTURE_SUMMARY_SCHEMA,
178
+ requiredKeys: ['schema', 'schemaVersion', 'source', 'captures'], optionalKeys: [],
179
+ hook: { module: 'packages/interchange/web-capture-import.mts', exportName: 'parseWebCaptureSummary', role: 'normaliser', runtime: 'shared' },
180
+ fixtures: [{ id: 'web-capture-summary-v1', path: `${F}web-capture-summary-v1.json`, bytes: 341, sha256: '70fba8a784f61f473ba6cde71a7d9dd915cfca46fbb3469e3bb57767adf973d9', version: WEB_CAPTURE_SUMMARY_VERSION }] },
181
+ { descriptor: WEB_CAPTURE_MANIFEST_COMPATIBILITY, lifecycleSchema: WEB_CAPTURE_MANIFEST_SCHEMA,
182
+ requiredKeys: ['schema', 'schemaVersion', 'source', 'captures'], optionalKeys: [],
183
+ hook: { module: 'packages/interchange/web-capture-import.mts', exportName: 'parseWebCaptureManifest', role: 'normaliser', runtime: 'shared' },
184
+ fixtures: [
185
+ { id: 'web-capture-manifest-v2', path: `${F}web-capture-manifest-v2.json`, bytes: 494, sha256: 'a2208dc956a832d70e37cd9a5929db81d29148856c485e4fd6ce5b123de5b8e6', version: WEB_CAPTURE_MANIFEST_VERSION },
186
+ ] },
187
+ { descriptor: DEFENSIVE_MANIFEST_COMPATIBILITY, lifecycleSchema: DEFENSIVE_INDICATOR_MANIFEST_SCHEMA,
188
+ requiredKeys: ['schema', 'version', 'generatedAt'], optionalKeys: ['expiresAt', 'reviewRequired', 'explicitSelection', 'includeWildcards', 'entries', 'indicators', 'exclusions', 'limitations'],
189
+ hook: { module: 'packages/interchange/defensive-indicator-export.mts', exportName: 'buildDefensiveIndicatorExport', role: 'builder', runtime: 'shared' },
190
+ fixtures: [{ id: 'defensive-indicator-manifest-v2', path: `${F}defensive-indicator-manifest-v2.json`, bytes: 139, sha256: '8a2475b1be1810bcc10b6285630e7056986f961320c884fd8cfc72951eb79db7', version: DEFENSIVE_INDICATOR_EXPORT_VERSION }] },
191
+ { descriptor: DEFENSIVE_ROLLBACK_COMPATIBILITY, lifecycleSchema: DEFENSIVE_INDICATOR_ROLLBACK_SCHEMA,
192
+ requiredKeys: ['schema', 'version', 'generatedAt'], optionalKeys: ['removes', 'indicators', 'limitations'],
193
+ hook: { module: 'packages/interchange/defensive-indicator-export.mts', exportName: 'buildDefensiveIndicatorExport', role: 'builder', runtime: 'shared' },
194
+ fixtures: [{ id: 'defensive-indicator-rollback-v2', path: `${F}defensive-indicator-rollback-v2.json`, bytes: 139, sha256: '1af3d32d3e808327fadc39066256d441abb5957d1ae696b834a61a37658103e6', version: DEFENSIVE_INDICATOR_EXPORT_VERSION }] },
195
+ { descriptor: DNS_REHEARSAL_COMPATIBILITY, lifecycleSchema: DNS_CHANGE_REHEARSAL_EXPORT_SCHEMA,
196
+ requiredKeys: ['schema', 'version', 'generatedAt'], optionalKeys: ['domain', 'changes', 'findings', 'limitations'],
197
+ hook: { module: 'packages/interchange/dns-change-rehearsal.mts', exportName: 'buildDnsChangeRehearsalExport', role: 'builder', runtime: 'shared' },
198
+ fixtures: [{ id: 'dns-change-rehearsal-v2', path: `${F}dns-change-rehearsal-v2.json`, bytes: 169, sha256: '31b2e5d8d3774356362c4f5fc1741506ce7060953b49bb3cd3588adbd3bb96d3', version: DNS_CHANGE_REHEARSAL_VERSION }] },
199
+ { descriptor: MAIL_REPORT_COMPATIBILITY, lifecycleSchema: MAIL_REPORT_SCHEMA,
200
+ requiredKeys: ['schema', 'version', 'generatedAt'], optionalKeys: ['source', 'summary', 'findings', 'limitations', 'reports', 'profileScope', 'integrity'],
201
+ hook: { module: 'packages/interchange/mail-report-workbench.mts', exportName: 'buildMailReportReview', role: 'builder', runtime: 'shared' },
202
+ fixtures: [
203
+ { id: 'mail-report-review-v1', path: `${F}mail-report-review-v1.json`, bytes: 152, sha256: '804ffda72784ee2f0b34985f47f78075238841b4fbcc484b971efcd17f2b0c17', version: 1 },
204
+ { id: 'mail-report-review-v2', path: `${F}mail-report-review-v2.json`, bytes: 2188, sha256: '42bb3b3a05605b0ffdda3f369a52e0490dcf1541405f2db9cee900ddf0477d97', version: 2 },
205
+ { id: 'mail-report-review-v3', path: `${F}mail-report-review-v3.json`, bytes: 1627, sha256: '32d71697a08eae70cc070b981953cb18829efee139283f6163edecd126614048', version: MAIL_REPORT_VERSION },
206
+ ] },
207
+ { descriptor: REGISTRATION_DISCLOSURE_COMPATIBILITY, lifecycleSchema: REGISTRATION_DISCLOSURE_PLAN_SCHEMA,
208
+ requiredKeys: ['schema', 'version', 'generatedAt'], optionalKeys: ['domain', 'requests', 'limitations'],
209
+ hook: { module: 'packages/interchange/registration-disclosure-plan.mts', exportName: 'buildRegistrationDisclosurePlan', role: 'builder', runtime: 'shared' },
210
+ fixtures: [{ id: 'registration-disclosure-plan-v2', path: `${F}registration-disclosure-plan-v2.json`, bytes: 164, sha256: '74e516dc369a2c62fc5794134512bff8ec251faa85b30e78172f670b58db9037', version: REGISTRATION_DISCLOSURE_PLAN_VERSION }] },
211
+ { descriptor: STATIC_PATTERN_PACK_COMPATIBILITY, lifecycleSchema: STATIC_PAGE_PATTERN_PACK_SCHEMA,
212
+ requiredKeys: ['schema', 'version', 'id', 'label', 'description', 'evidenceBoundary', 'relationship', 'confidence', 'rules'], optionalKeys: [],
213
+ hook: { module: 'packages/interchange/static-page-pattern-packs.mts', exportName: 'validateStaticPagePatternPack', role: 'structure_validator', runtime: 'shared' },
214
+ fixtures: [{ id: 'static-page-pattern-pack-v2', path: `${F}static-page-pattern-pack-v2.json`, bytes: 1_094, sha256: 'c409e527850bcb924f5be0708a58c5e2c32d98947c42150f6a79765991c0eb6f', version: STATIC_PAGE_PATTERN_PACK_VERSION }] },
215
+ ],
216
+ }));