@slicedearth/whoisleuth-cli 2.2.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 (282) hide show
  1. package/NOTICE +6 -0
  2. package/README.md +20 -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 +454 -1748
  7. package/cli/artifact-validation/case-response.mjs +66 -35
  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 +112 -72
  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-checkpoint.mjs +34 -3
  18. package/cli/bulk-command-runner.mjs +2 -0
  19. package/cli/bulk-output.mjs +29 -3
  20. package/cli/case-command.mjs +195 -0
  21. package/cli/case-pack.mjs +66 -111
  22. package/cli/command-argument-grammar.mjs +215 -0
  23. package/cli/command-reference.mjs +342 -285
  24. package/cli/completion.mjs +303 -80
  25. package/cli/config-profile.mjs +7 -5
  26. package/cli/ct-event-intake.mjs +12 -12
  27. package/cli/discover.mjs +4 -3
  28. package/cli/discovery-observation-snapshot.mjs +59 -18
  29. package/cli/discovery-scan-command-runner.mjs +21 -0
  30. package/cli/discovery-scan.mjs +13 -4
  31. package/cli/discovery-workflow.mjs +1 -1
  32. package/cli/domain-control-monitor.mjs +50 -20
  33. package/cli/domain-control-observations.mjs +149 -102
  34. package/cli/errors.mjs +33 -1
  35. package/cli/evidence-command-runner.mjs +16 -5
  36. package/cli/evidence-signing.mjs +2 -2
  37. package/cli/external-observation-mapping.mjs +7 -8
  38. package/cli/formatters/terminal-lookup.mjs +18 -46
  39. package/cli/formatters/terminal-shared.mjs +5 -3
  40. package/cli/formatters/terminal-technology.mjs +114 -0
  41. package/cli/inline-command-families.mjs +2 -0
  42. package/cli/input.mjs +116 -0
  43. package/cli/interactive-launcher.mjs +2 -160
  44. package/cli/interchange-report.mjs +18 -8
  45. package/cli/investigation-artifacts.mjs +176 -0
  46. package/cli/investigation-checkpoint.mjs +10 -0
  47. package/cli/investigation-folder.mjs +114 -0
  48. package/cli/investigation-manifest.mjs +1 -112
  49. package/cli/investigation-package-review.mjs +103 -0
  50. package/cli/investigation-plan.mjs +32 -19
  51. package/cli/investigation-run.mjs +188 -33
  52. package/cli/local-document-checkpoint.mjs +170 -0
  53. package/cli/lookalike-calibration.mjs +7 -2
  54. package/cli/lookup-brief.mjs +4 -2
  55. package/cli/lookup-browser-view.mjs +1 -1
  56. package/cli/lookup-browser.mjs +5 -12
  57. package/cli/lookup-command-runner.mjs +17 -6
  58. package/cli/lookup-diff.mjs +37 -3
  59. package/cli/lookup-plan.mjs +11 -5
  60. package/cli/lookup-reconcile.mjs +10 -8
  61. package/cli/mail-header-review.mjs +467 -0
  62. package/cli/mail-review.mjs +66 -14
  63. package/cli/manual.mjs +1 -0
  64. package/cli/network-command-runner.mjs +2 -0
  65. package/cli/output-file.mjs +31 -5
  66. package/cli/registry-doctor.mjs +3 -1
  67. package/cli/registry-support.mjs +1 -1
  68. package/cli/retained-artifact-diff.mjs +2 -2
  69. package/cli/review-command-runner.mjs +70 -0
  70. package/cli/risk-calibration.mjs +19 -17
  71. package/cli/runner.mjs +62 -7
  72. package/cli/sharing-review.mjs +21 -34
  73. package/cli/signer-trust.mjs +104 -0
  74. package/cli/source-reliability.mjs +74 -83
  75. package/cli/terminal-input.mjs +168 -0
  76. package/cli/workflow-command-runner.mjs +68 -13
  77. package/docs/cli-reference.md +77 -24
  78. package/docs/cli.md +300 -25
  79. package/lib/abort.mjs +4 -1
  80. package/lib/availability.mjs +161 -112
  81. package/lib/bounded-file.mjs +3 -1
  82. package/lib/bounded-html-document.mjs +125 -0
  83. package/lib/bounded-json.mjs +51 -8
  84. package/lib/browser-library-profile.mjs +42 -51
  85. package/lib/browser-library-worker.mjs +112 -0
  86. package/lib/client-behavior-profile.mjs +3 -3
  87. package/lib/ct-search.mjs +11 -4
  88. package/lib/dns-convergence-review.mjs +1 -0
  89. package/lib/dns-delegation-health.mjs +24 -14
  90. package/lib/dns-inheritance-review.mjs +217 -0
  91. package/lib/dns-intelligence.mjs +73 -26
  92. package/lib/dns-mx.mjs +1 -1
  93. package/lib/dnssec-chain-validation.mjs +6 -0
  94. package/lib/dnssec-evidence-validation.mjs +11 -3
  95. package/lib/domain-change-packet.mjs +49 -16
  96. package/lib/domain-change-review.mjs +14 -8
  97. package/lib/domain-control-flight-recorder.mjs +221 -117
  98. package/lib/domain-control-manifest.mjs +129 -69
  99. package/lib/domain-posture-analysis.mjs +20 -7
  100. package/lib/domain-posture-parsers.mjs +201 -8
  101. package/lib/domain-posture.mjs +147 -29
  102. package/lib/evidence-export-privacy.mjs +561 -0
  103. package/lib/evidence-export.mjs +11 -543
  104. package/lib/evidence-report.mjs +5 -3
  105. package/lib/favicon.mjs +117 -41
  106. package/lib/generated/cisa-kev-catalog.mjs +12 -4
  107. package/lib/generated/registrar-standing-catalogue.mjs +7 -7
  108. package/lib/generated/retire-browser-catalog.mjs +2335 -940
  109. package/lib/hostname.mjs +2 -38
  110. package/lib/html-signals.mjs +111 -108
  111. package/lib/http-evidence-bounds.mjs +2 -1
  112. package/lib/http-intelligence.mjs +39 -26
  113. package/lib/local-geoip-evidence.mjs +19 -3
  114. package/lib/lookup-cache.mjs +34 -11
  115. package/lib/lookup-child-profile-contract.mjs +24 -15
  116. package/lib/lookup-diagnostics.mjs +24 -1
  117. package/lib/lookup-progress.mjs +30 -13
  118. package/lib/lookup-response-contract.mjs +1048 -0
  119. package/lib/lookup-response.mjs +8 -10
  120. package/lib/lookup-source-progress.mjs +40 -14
  121. package/lib/lookup.mjs +48 -27
  122. package/lib/nameserver-preflight-review.mjs +1 -0
  123. package/lib/observed-network-context.mjs +43 -27
  124. package/lib/outbound-request-bounds.mjs +4 -1
  125. package/lib/page-fingerprints.mjs +69 -133
  126. package/lib/page-role-profile.mjs +12 -8
  127. package/lib/perceptual-hash.mjs +43 -14
  128. package/lib/rdap-bootstrap.mjs +72 -22
  129. package/lib/rdap-capabilities.mjs +1 -1
  130. package/lib/rdap-client.mjs +15 -7
  131. package/lib/rdap-normalization.mjs +33 -18
  132. package/lib/rdap-search-workbench.mjs +1 -1
  133. package/lib/rdap-transport.mjs +47 -20
  134. package/lib/rdap-types.mjs +2 -1
  135. package/lib/rdap.mjs +18 -3
  136. package/lib/registrable-domain.mjs +21 -1
  137. package/lib/registrar-standing-contract.mjs +1 -0
  138. package/lib/registry-capabilities.mjs +5 -1
  139. package/lib/registry-capability-catalogue.mjs +2 -1
  140. package/lib/registry-dates.mjs +2 -190
  141. package/lib/registry-insights.mjs +85 -34
  142. package/lib/response-policy.mjs +86 -48
  143. package/lib/risk-calibration-summary.mjs +18 -6
  144. package/lib/rpki-evidence.mjs +8 -5
  145. package/lib/safe-fetch.mjs +54 -47
  146. package/lib/security-txt.mjs +42 -7
  147. package/lib/service-binding-dns.mjs +8 -0
  148. package/lib/smtp-transport-review.mjs +27 -15
  149. package/lib/sslbl-certificates.generated.mjs +136 -135
  150. package/lib/static-html-analysis.mjs +178 -151
  151. package/lib/structured-data-identity.mjs +1 -0
  152. package/lib/technology-markup.mjs +100 -0
  153. package/lib/threat-intelligence-manifests.mjs +11 -139
  154. package/lib/threat-intelligence-runtime.mjs +24 -52
  155. package/lib/threat-intelligence-targets.mjs +6 -34
  156. package/lib/threat-intelligence-types.mjs +39 -0
  157. package/lib/typosquat-generator.mjs +19 -15
  158. package/lib/urlhaus-intelligence.mjs +8 -3
  159. package/lib/urlscan-intelligence.mjs +1 -1
  160. package/lib/website-security-posture.mjs +30 -6
  161. package/lib/website-technology.mjs +153 -45
  162. package/lib/whois-authority.mjs +14 -2
  163. package/lib/whois-chain.mjs +9 -2
  164. package/lib/whois-common-formats.mjs +5 -5
  165. package/lib/whois-contracts.mjs +2 -1
  166. package/lib/whois-dialects.mjs +2 -2
  167. package/lib/whois-parser-support.mjs +8 -1
  168. package/lib/whois-transport.mjs +23 -7
  169. package/lib/zone-intent-review.mjs +16 -6
  170. package/package.json +2 -1
  171. package/packages/cases/case-attachment-model.mjs +83 -0
  172. package/packages/cases/case-evidence-model.mjs +156 -62
  173. package/packages/cases/case-export-input.mjs +53 -0
  174. package/packages/cases/case-migration-model.mjs +127 -44
  175. package/packages/cases/case-model.mjs +1 -0
  176. package/packages/cases/case-recheck-model.mjs +91 -0
  177. package/packages/cases/case-record-contracts.mjs +5 -20
  178. package/packages/cases/case-record-core.mjs +12 -22
  179. package/packages/cases/case-record-decisions.mjs +72 -0
  180. package/packages/cases/case-record-model.mjs +1 -0
  181. package/packages/cases/case-record-operations.mjs +95 -31
  182. package/packages/cases/case-record-projection.mjs +221 -0
  183. package/packages/cases/case-report.mjs +70 -54
  184. package/packages/cases/case-response-actions.mjs +654 -0
  185. package/packages/cases/case-response-model.mjs +64 -1115
  186. package/packages/cases/case-response-outcomes.mjs +317 -0
  187. package/packages/cases/case-response-packet-render.mjs +153 -0
  188. package/packages/cases/case-response-packet-types.mjs +1 -0
  189. package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
  190. package/packages/cases/case-response-packet.mjs +74 -572
  191. package/packages/cases/case-response-records.mjs +94 -0
  192. package/packages/cases/case-response-review-inputs.mjs +371 -0
  193. package/packages/cases/case-response-values.mjs +85 -0
  194. package/packages/cases/case-review-package.mjs +45 -0
  195. package/packages/cases/case-selection.mjs +46 -0
  196. package/packages/cases/case-storage-model.mjs +31 -2
  197. package/packages/cases/response-route-freshness.mjs +16 -0
  198. package/packages/comparison/capture-context.mjs +61 -0
  199. package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
  200. package/packages/comparison/page-similarity.mjs +5 -2
  201. package/packages/comparison/relationship-evidence.mjs +85 -8
  202. package/packages/comparison/relationship-provenance.mjs +93 -0
  203. package/packages/contracts/analyst-interchange.mjs +216 -0
  204. package/packages/contracts/analyst-review-state-contract.mjs +11 -0
  205. package/packages/contracts/analyst-review-state.mjs +5 -4
  206. package/packages/contracts/case-portability.mjs +366 -83
  207. package/packages/contracts/case-views-contract.mjs +12 -0
  208. package/packages/contracts/case-views.mjs +26 -0
  209. package/packages/contracts/cli-command-semantics.mjs +94 -0
  210. package/packages/contracts/cli-lookup.mjs +0 -1
  211. package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
  212. package/packages/contracts/domain-control-manifest.mjs +127 -73
  213. package/packages/contracts/domain-control-monitor.mjs +48 -45
  214. package/packages/contracts/domain-control-review.mjs +179 -121
  215. package/packages/contracts/domain-name.mjs +21 -0
  216. package/packages/contracts/external-observation-interchange.mjs +2 -1
  217. package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
  218. package/packages/contracts/http-url.mjs +2 -0
  219. package/packages/contracts/investigation-package-limits.mjs +24 -0
  220. package/packages/contracts/investigation-portability.mjs +56 -27
  221. package/packages/contracts/investigation-run.mjs +16 -0
  222. package/packages/contracts/ip-address.mjs +34 -0
  223. package/packages/contracts/lookup-evidence.mjs +35 -0
  224. package/packages/contracts/offline-comparison.mjs +1 -2
  225. package/packages/contracts/page-fingerprints.mjs +15 -0
  226. package/packages/contracts/risk-calibration.mjs +15 -11
  227. package/packages/contracts/schema-compatibility.mjs +1 -1
  228. package/packages/contracts/schema-lifecycle.mjs +83 -153
  229. package/packages/contracts/selected-file-limits.mjs +6 -0
  230. package/packages/contracts/vulnerability-identifiers.mjs +5 -0
  231. package/packages/contracts/web-capture.mjs +10 -0
  232. package/packages/contracts/workspace-portability.mjs +183 -211
  233. package/packages/evidence/artifact-integrity.mjs +8 -2
  234. package/packages/evidence/artifact-structure.mjs +105 -0
  235. package/packages/evidence/decision-fact.mjs +4 -1
  236. package/packages/evidence/domain-control-runtime.mjs +201 -33
  237. package/packages/evidence/domain-posture-context.mjs +79 -0
  238. package/packages/evidence/image-regions.mjs +56 -0
  239. package/packages/evidence/latest-observations.mjs +28 -0
  240. package/packages/evidence/lookup-target.mjs +104 -0
  241. package/packages/evidence/observation.mjs +16 -1
  242. package/packages/evidence/passphrase-encryption.mjs +44 -0
  243. package/packages/evidence/record-identity.mjs +72 -0
  244. package/packages/evidence/registry-dates.mjs +190 -0
  245. package/packages/evidence/retained-file.mjs +69 -0
  246. package/packages/evidence/review-age.mjs +14 -0
  247. package/packages/interchange/bagit.mjs +342 -0
  248. package/packages/interchange/bounded-zip-extraction.mjs +278 -0
  249. package/packages/interchange/crc32.mjs +17 -0
  250. package/packages/interchange/external-findings-import.mjs +92 -51
  251. package/packages/interchange/web-capture-import.mjs +382 -0
  252. package/packages/investigation/bulk-mail-exposure.mjs +2 -1
  253. package/packages/investigation/investigation-bagit.mjs +14 -0
  254. package/packages/investigation/investigation-capsule.mjs +33 -5
  255. package/packages/investigation/investigation-manifest.mjs +200 -0
  256. package/packages/investigation/investigation-package-crypto.mjs +98 -0
  257. package/packages/investigation/investigation-package.mjs +200 -0
  258. package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
  259. package/packages/investigation/lookup-asset-graph.mjs +193 -165
  260. package/packages/investigation/lookup-claim-readiness.mjs +18 -0
  261. package/packages/investigation/lookup-investigation-brief.mjs +3 -3
  262. package/packages/investigation/lookup-task-guidance.mjs +58 -0
  263. package/packages/monitoring/analyst-review-state.mjs +38 -90
  264. package/packages/workspace/brand-profile-model.mjs +276 -22
  265. package/packages/workspace/bulk-columns.mjs +17 -0
  266. package/packages/workspace/bulk-review-model.mjs +23 -11
  267. package/packages/workspace/bulk-session-model.mjs +100 -24
  268. package/packages/workspace/bulk-sort.mjs +1 -0
  269. package/packages/workspace/case-views.mjs +102 -0
  270. package/packages/workspace/detection-rule-model.mjs +70 -22
  271. package/packages/workspace/hostile-input.mjs +29 -8
  272. package/packages/workspace/investigation-template-model.mjs +19 -4
  273. package/packages/workspace/page-baseline.mjs +18 -15
  274. package/packages/workspace/relationship-observation-model.mjs +41 -23
  275. package/packages/workspace/watchlist-history.mjs +2 -2
  276. package/packages/workspace/watchlist-store.mjs +8 -2
  277. package/packages/workspace/website-snapshot-model.mjs +164 -26
  278. package/packages/workspace/workspace-archive-crypto.mjs +41 -61
  279. package/packages/workspace/workspace-archive.mjs +87 -21
  280. package/third-party-notices.txt +29 -2
  281. package/cli/evidence-command-arguments.mjs +0 -156
  282. package/packages/investigation/bulk-review-export.mjs +0 -63
@@ -1,9 +1,12 @@
1
1
  import { normalizeDomain, openOrCreateCase, updateCase, } from "../cases/case-model.mjs";
2
2
  import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
3
3
  import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
4
- import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
4
+ import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
5
+ import { sha256IdentityHex } from "../evidence/record-identity.mjs";
6
+ import { MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, normalizeCaseEvidencePins, normalizeCaseSightings, } from "../cases/case-response-model.mjs";
7
+ import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
5
8
  import { hasUnsafeRetainedText } from "./retained-text.mjs";
6
- export { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, };
9
+ export { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, };
7
10
  export const EXTERNAL_FINDING_CATEGORIES = [
8
11
  'certificate',
9
12
  'dns',
@@ -14,6 +17,7 @@ export const EXTERNAL_FINDING_CATEGORIES = [
14
17
  'registration',
15
18
  'reputation',
16
19
  ];
20
+ export const MAX_EXTERNAL_FINDING_SUMMARY_LENGTH = 900;
17
21
  const CATEGORIES = new Set(EXTERNAL_FINDING_CATEGORIES);
18
22
  const COMPLETENESS = new Set(['complete', 'inconclusive', 'partial', 'unknown']);
19
23
  const ROOT_KEYS = new Set(['schema', 'schemaVersion', 'source', 'findings']);
@@ -82,14 +86,31 @@ function iso(value, label, optional = false) {
82
86
  function limitations(value, index) {
83
87
  if (value === undefined)
84
88
  return [];
85
- if (!Array.isArray(value) || value.length > 8) {
86
- throw new Error(`Finding ${index + 1} limitations must be an array with no more than 8 entries.`);
89
+ if (!Array.isArray(value) || value.length > MAX_EXTERNAL_FINDING_LIMITATIONS) {
90
+ throw new Error(`Finding ${index + 1} limitations must be an array with no more than ${MAX_EXTERNAL_FINDING_LIMITATIONS} entries.`);
87
91
  }
88
92
  const unique = new Set();
89
93
  for (const item of value)
90
- unique.add(requiredText(item, 240, `Finding ${index + 1} limitation`));
94
+ unique.add(requiredText(item, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, `Finding ${index + 1} limitation`));
91
95
  return [...unique];
92
96
  }
97
+ /** Keep provenance and omissions visible within the existing import bound. */
98
+ export function retainExternalFindingLimitations(supplied, mandatory) {
99
+ const required = limitations(mandatory, 0);
100
+ const optional = limitations(supplied, 0).filter((entry) => !required.includes(entry));
101
+ const available = MAX_EXTERNAL_FINDING_LIMITATIONS - required.length;
102
+ if (optional.length <= available)
103
+ return Object.freeze([...required, ...optional]);
104
+ if (available < 1)
105
+ throw new TypeError('Mandatory finding limitations leave no room to disclose omitted supplied limitations.');
106
+ const retained = optional.slice(0, available - 1);
107
+ const omitted = optional.length - retained.length;
108
+ return Object.freeze([
109
+ ...required,
110
+ ...retained,
111
+ `${omitted} supplied limitation${omitted === 1 ? ' was' : 's were'} omitted to retain mandatory provenance and omission notices; review the supplied input for the remaining qualifications.`,
112
+ ]);
113
+ }
93
114
  function structuredObservation(value, index) {
94
115
  if (value === undefined || value === null)
95
116
  return null;
@@ -219,7 +240,7 @@ export function parseExternalFindingsDocument(value) {
219
240
  domain,
220
241
  category: item.category,
221
242
  evidenceClass,
222
- summary: requiredText(item.summary, 900, `Finding ${index + 1} summary`),
243
+ summary: requiredText(item.summary, MAX_EXTERNAL_FINDING_SUMMARY_LENGTH, `Finding ${index + 1} summary`),
223
244
  observedAt: iso(item.observedAt, `Finding ${index + 1} observation time`),
224
245
  completeness: item.completeness,
225
246
  limitations: limitations(item.limitations, index),
@@ -266,21 +287,19 @@ function importedSourceLabel(finding, sourceName) {
266
287
  ? `Deployment observation: ${sourceName}`
267
288
  : `Provider report: ${sourceName}`;
268
289
  }
269
- function existingPinKey(recordValue, finding, sourceName) {
270
- const expectedLabel = `External ${finding.category} finding`;
271
- const expectedValue = finding.structuredObservation?.value ?? pinValue(finding);
272
- const expectedSource = importedSourceLabel(finding, sourceName);
273
- const observation = finding.structuredObservation;
274
- return recordValue.evidencePins.some((pin) => (pin.label === expectedLabel
275
- && pin.value === expectedValue
276
- && pin.source === expectedSource
277
- && pin.observedAt === finding.observedAt
278
- && pin.completeness === finding.completeness
279
- && (!observation || (pin.field === observation.field
280
- && pin.sourceSchema?.collection === 'external_observations'
281
- && pin.sourceSchema.schema === observation.sourceSchema
282
- && pin.sourceSchema.version === observation.sourceVersion
283
- && (observation.eventId === null || pin.certificateObservation?.eventId === observation.eventId))))) ? findingKey(finding, sourceName) : '';
290
+ function alreadyRetained(recordValue, projection) {
291
+ const { importContentSha256: expectedIdentity, ...expected } = projection.evidencePin;
292
+ return recordValue.evidencePins.some((pin) => {
293
+ const { id: _id, createdAt: _createdAt, importContentSha256, ...material } = pin;
294
+ if (importContentSha256 && importContentSha256 !== expectedIdentity)
295
+ return false;
296
+ // Older pins have no content digest. Only a complete, exact retained
297
+ // projection can establish duplication; a shortened prefix cannot.
298
+ if (!importContentSha256 && (projection.shortenedFields.length || projection.omittedLimitations))
299
+ return false;
300
+ // An imported digest alone cannot substitute for matching retained fields.
301
+ return canonicalArtifactJsonV2(material) === canonicalArtifactJsonV2(expected);
302
+ });
284
303
  }
285
304
  function structuredPinFields(finding) {
286
305
  const observation = finding.structuredObservation;
@@ -312,37 +331,41 @@ function structuredPinFields(finding) {
312
331
  certificateObservation,
313
332
  };
314
333
  }
315
- function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
316
- const target = current.find((candidate) => candidate.id === caseId) ?? null;
317
- if (!target)
318
- throw new Error('The selected Case is unavailable for this finding.');
319
- if (existingPinKey(target, finding, source.name)) {
320
- return { cases: [...current], record: target, added: false };
321
- }
322
- const sourceLimitations = [
334
+ /** Exact material retained by both the import preview and Case mutation. */
335
+ export function externalFindingCaseProjection(finding, source) {
336
+ const identitySha256 = sha256IdentityHex(new TextEncoder().encode(canonicalArtifactJsonV2({
337
+ sourceName: source.name, sourceReference: source.reference, finding,
338
+ })));
339
+ const value = finding.structuredObservation?.value ?? pinValue(finding);
340
+ const sourceLabel = importedSourceLabel(finding, source.name);
341
+ const sourceReference = source.reference ? `Source reference: ${source.reference}` : null;
342
+ const shortenedFields = [
343
+ ...(value.length > MAX_RESPONSE_VALUE_LENGTH ? ['finding value'] : []),
344
+ ...(sourceLabel.length > MAX_RESPONSE_LABEL_LENGTH ? ['source label'] : []),
345
+ ...(sourceReference && sourceReference.length > MAX_RESPONSE_LIMITATION_LENGTH ? ['source reference'] : []),
346
+ ];
347
+ const mandatory = [
323
348
  finding.evidenceClass === 'deployment_observation'
324
349
  ? `Imported as an observation made by ${source.name}; this browser session did not collect or independently verify it.`
325
350
  : `Reported by ${source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
326
- ...(source.reference ? [`Source reference: ${source.reference}`] : []),
327
- ...finding.limitations,
351
+ ...(sourceReference ? [sourceReference.length > MAX_RESPONSE_LIMITATION_LENGTH
352
+ ? `${sourceReference.slice(0, MAX_RESPONSE_LIMITATION_LENGTH - 13)}… [shortened]` : sourceReference] : []),
353
+ ...(shortenedFields.length ? [`Case storage shortened ${shortenedFields.join(', ')}. The content digest covers the complete accepted finding.`] : []),
328
354
  ];
329
- const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
330
- let updated = updateCase([...current], target.id, {
331
- evidencePin: {
355
+ const sourceLimitations = retainExternalFindingLimitations(finding.limitations, mandatory);
356
+ const omittedLimitations = finding.limitations.filter((item) => !sourceLimitations.includes(item)).length;
357
+ const pin = normalizeCaseEvidencePins([{
332
358
  ...structuredPinFields(finding),
359
+ importContentSha256: identitySha256,
333
360
  label: `External ${finding.category} finding`,
334
- value: finding.structuredObservation?.value ?? pinValue(finding),
335
- source: importedSourceLabel(finding, source.name),
361
+ value,
362
+ source: sourceLabel,
336
363
  observedAt: finding.observedAt,
337
364
  completeness: finding.completeness,
365
+ truncated: shortenedFields.length || omittedLimitations ? true : null,
338
366
  limitations: sourceLimitations,
339
- },
340
- }, now);
341
- const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
342
- if (!addedPin)
343
- throw new Error('The imported finding could not be retained as Case evidence.');
344
- updated = updateCase(updated.cases, target.id, {
345
- sighting: {
367
+ }], finding.observedAt)[0];
368
+ const observed = normalizeCaseSightings([{
346
369
  state: finding.evidenceClass === 'deployment_observation'
347
370
  ? 'observed_by_deployment'
348
371
  : 'reported_by_provider',
@@ -358,9 +381,28 @@ function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
358
381
  source: source.name,
359
382
  observedAt: finding.observedAt,
360
383
  completeness: finding.completeness,
361
- evidencePinId: addedPin.id,
362
384
  limitations: sourceLimitations,
363
- },
385
+ }], finding.observedAt)[0];
386
+ if (!pin || !observed)
387
+ throw new TypeError('The finding has no valid retained Case projection.');
388
+ const { id: _pinId, createdAt: _pinCreatedAt, ...evidencePin } = pin;
389
+ const { id: _sightingId, createdAt: _sightingCreatedAt, evidencePinId: _pinReference, ...sighting } = observed;
390
+ return { evidencePin, sighting, identitySha256, shortenedFields, omittedLimitations };
391
+ }
392
+ function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
393
+ const target = current.find((candidate) => candidate.id === caseId) ?? null;
394
+ if (!target)
395
+ throw new Error('The selected Case is unavailable for this finding.');
396
+ const projection = externalFindingCaseProjection(finding, source);
397
+ if (alreadyRetained(target, projection))
398
+ return { cases: [...current], record: target, added: false };
399
+ const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
400
+ let updated = updateCase([...current], target.id, { evidencePin: projection.evidencePin }, now);
401
+ const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
402
+ if (!addedPin)
403
+ throw new Error('The imported finding could not be retained as Case evidence.');
404
+ updated = updateCase(updated.cases, target.id, {
405
+ sighting: { ...projection.sighting, evidencePinId: addedPin.id },
364
406
  }, now);
365
407
  return { cases: updated.cases, record: updated.record, added: true };
366
408
  }
@@ -371,7 +413,6 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
371
413
  let findingsAdded = 0;
372
414
  let duplicatesSkipped = 0;
373
415
  for (const finding of document.findings) {
374
- const existing = cases.find((candidate) => candidate.domain === finding.domain) ?? null;
375
416
  const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
376
417
  cases = opened.cases;
377
418
  const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
@@ -382,10 +423,10 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
382
423
  continue;
383
424
  }
384
425
  findingsAdded += 1;
385
- if (existing)
386
- updatedDomains.add(finding.domain);
387
- else
426
+ if (opened.created)
388
427
  createdDomains.add(finding.domain);
428
+ else
429
+ updatedDomains.add(finding.domain);
389
430
  }
390
431
  return {
391
432
  cases,
@@ -399,7 +440,7 @@ function targetedFinding(finding, caseDomain) {
399
440
  if (finding.domain === caseDomain)
400
441
  return finding;
401
442
  const prefix = `Captured hostname ${finding.domain}. `;
402
- const maximumSummaryLength = 900;
443
+ const maximumSummaryLength = MAX_EXTERNAL_FINDING_SUMMARY_LENGTH;
403
444
  const remaining = maximumSummaryLength - prefix.length;
404
445
  const summary = remaining > 1 && finding.summary.length > remaining
405
446
  ? `${prefix}${finding.summary.slice(0, remaining - 1).trimEnd()}…`
@@ -421,7 +462,7 @@ export function externalFindingsCaseTargets(document, caseDomain) {
421
462
  }
422
463
  /**
423
464
  * Merges already-validated external findings into one selected Case. A Case is
424
- * keyed to its registrable domain, while rendered captures may retain an exact
465
+ * identified by its immutable ID with a registrable-domain pivot, while rendered captures may retain an exact
425
466
  * hostname. Only same-domain findings are accepted, the exact hostname remains
426
467
  * explicit in the retained evidence, and this operation can never open another
427
468
  * Case as a side effect.
@@ -0,0 +1,382 @@
1
+ import { normalizeDomain } from "../cases/case-model.mjs";
2
+ import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_SUMMARY_LENGTH, parseExternalFindingsDocument, } from "./external-findings-import.mjs";
3
+ import { MAX_WEB_CAPTURE_DOM_DIGEST_BYTES, MAX_WEB_CAPTURE_SCREENSHOT_BYTES, WEB_CAPTURE_MANIFEST_SCHEMA, WEB_CAPTURE_MANIFEST_VERSION, WEB_CAPTURE_SUMMARY_SCHEMA, WEB_CAPTURE_SUMMARY_VERSION, } from "../contracts/web-capture.mjs";
4
+ import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
5
+ import { readCaptureConditions, readObservationLabel } from "../comparison/capture-context.mjs";
6
+ export { WEB_CAPTURE_MANIFEST_SCHEMA, WEB_CAPTURE_MANIFEST_VERSION, WEB_CAPTURE_SUMMARY_SCHEMA, WEB_CAPTURE_SUMMARY_VERSION, } from "../contracts/web-capture.mjs";
7
+ export const MAX_WEB_CAPTURE_SUMMARIES = 50;
8
+ export { MAX_WEB_CAPTURE_DOM_DIGEST_BYTES, MAX_WEB_CAPTURE_SCREENSHOT_BYTES } from "../contracts/web-capture.mjs";
9
+ const MAX_CAPTURE_TECHNOLOGIES = 20;
10
+ const MAX_CAPTURE_ORIGINS = 30;
11
+ const SHA256_RE = /^[a-f0-9]{64}$/i;
12
+ const PERCEPTUAL_HASH_RE = /^[a-f0-9]{16}$/i;
13
+ const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
14
+ const ROOT_KEYS = new Set(['schema', 'schemaVersion', 'source', 'captures']);
15
+ const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
16
+ const CAPTURE_KEYS = new Set([
17
+ 'domain',
18
+ 'capturedAt',
19
+ 'completeness',
20
+ 'limitations',
21
+ 'pageTitle',
22
+ 'finalOrigin',
23
+ 'screenshotSha256',
24
+ 'technologies',
25
+ 'networkOrigins',
26
+ ]);
27
+ const MANIFEST_CAPTURE_KEYS = new Set([
28
+ 'conditions', 'observerLabel', 'vantageLabel',
29
+ 'domain',
30
+ 'capturedAt',
31
+ 'completeness',
32
+ 'limitations',
33
+ 'page',
34
+ 'requestDomains',
35
+ 'technologies',
36
+ 'artifacts',
37
+ ]);
38
+ const PAGE_KEYS = new Set(['title', 'finalOrigin']);
39
+ const ARTIFACT_KEYS = new Set(['kind', 'fileName', 'mimeType', 'sha256', 'perceptualHash', 'bytes', 'width', 'height']);
40
+ function record(value) {
41
+ return value && typeof value === 'object' && !Array.isArray(value)
42
+ ? value
43
+ : null;
44
+ }
45
+ function onlyKeys(value, allowed) {
46
+ return Object.keys(value).every((key) => allowed.has(key));
47
+ }
48
+ function text(value, maximum, label, optional = false) {
49
+ if (optional && (value === undefined || value === null || value === ''))
50
+ return null;
51
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum || CONTROL_RE.test(value)) {
52
+ throw new Error(`${label} must be bounded text without control characters.`);
53
+ }
54
+ return value.replace(/\s+/g, ' ').trim();
55
+ }
56
+ function timestamp(value, label, optional = false) {
57
+ const candidate = text(value, 64, label, optional);
58
+ if (candidate === null)
59
+ return null;
60
+ const normalized = normalizeExplicitIsoTimestamp(candidate);
61
+ if (!normalized)
62
+ throw new Error(`${label} must be a valid date and time with an explicit timezone.`);
63
+ return normalized;
64
+ }
65
+ function origin(value, label) {
66
+ if (value === undefined || value === null || value === '')
67
+ return null;
68
+ const candidate = text(value, 500, label);
69
+ try {
70
+ const parsed = new URL(candidate ?? '');
71
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
72
+ throw new Error();
73
+ if (parsed.pathname !== '/' || parsed.search || parsed.hash)
74
+ throw new Error();
75
+ return parsed.origin.toLowerCase();
76
+ }
77
+ catch {
78
+ throw new Error(`${label} must be an HTTP(S) origin without credentials, path, query, or fragment.`);
79
+ }
80
+ }
81
+ function stringList(value, maximumItems, maximumLength, label) {
82
+ if (value === undefined)
83
+ return [];
84
+ if (!Array.isArray(value) || value.length > maximumItems)
85
+ throw new Error(`${label} exceeds its item bound.`);
86
+ const output = new Set();
87
+ for (const item of value) {
88
+ const normalized = text(item, maximumLength, label);
89
+ if (normalized)
90
+ output.add(normalized);
91
+ }
92
+ return [...output];
93
+ }
94
+ export function parseWebCaptureSummary(value) {
95
+ const root = record(value);
96
+ if (!root
97
+ || !onlyKeys(root, ROOT_KEYS)
98
+ || root.schema !== WEB_CAPTURE_SUMMARY_SCHEMA
99
+ || root.schemaVersion !== WEB_CAPTURE_SUMMARY_VERSION) {
100
+ throw new Error(`Web captures must use ${WEB_CAPTURE_SUMMARY_SCHEMA} schema version ${WEB_CAPTURE_SUMMARY_VERSION}.`);
101
+ }
102
+ const source = record(root.source);
103
+ if (!source || !onlyKeys(source, SOURCE_KEYS))
104
+ throw new Error('Web captures require a bounded source object.');
105
+ const sourceName = text(source.name, 80, 'Capture source name');
106
+ const sourceReference = text(source.reference, 500, 'Capture source reference', true);
107
+ const sourceCollectedAt = timestamp(source.collectedAt, 'Capture source collection time', true);
108
+ if (!Array.isArray(root.captures) || !root.captures.length || root.captures.length > MAX_WEB_CAPTURE_SUMMARIES) {
109
+ throw new Error(`Web captures must contain between 1 and ${MAX_WEB_CAPTURE_SUMMARIES} summaries.`);
110
+ }
111
+ const findings = [];
112
+ const domainCounts = new Map();
113
+ for (const [index, raw] of root.captures.entries()) {
114
+ const capture = record(raw);
115
+ if (!capture || !onlyKeys(capture, CAPTURE_KEYS))
116
+ throw new Error(`Web capture ${index + 1} contains unsupported fields.`);
117
+ const domain = normalizeDomain(text(capture.domain, 253, `Web capture ${index + 1} domain`));
118
+ if (!domain)
119
+ throw new Error(`Web capture ${index + 1} domain is invalid.`);
120
+ const domainCount = (domainCounts.get(domain) ?? 0) + 1;
121
+ if (domainCount > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
122
+ throw new Error(`Web captures exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-summary per-domain limit.`);
123
+ }
124
+ domainCounts.set(domain, domainCount);
125
+ if (domainCounts.size > MAX_EXTERNAL_FINDING_DOMAINS) {
126
+ throw new Error(`Web captures exceed the ${MAX_EXTERNAL_FINDING_DOMAINS}-domain limit.`);
127
+ }
128
+ const observedAt = timestamp(capture.capturedAt, `Web capture ${index + 1} time`);
129
+ const completeness = ['complete', 'inconclusive', 'partial', 'unknown'].includes(String(capture.completeness))
130
+ ? capture.completeness
131
+ : 'unknown';
132
+ const limitations = stringList(capture.limitations, 8, 240, `Web capture ${index + 1} limitations`);
133
+ const pageTitle = text(capture.pageTitle, 300, `Web capture ${index + 1} title`, true);
134
+ const finalOrigin = origin(capture.finalOrigin, `Web capture ${index + 1} final origin`);
135
+ const technologies = stringList(capture.technologies, MAX_CAPTURE_TECHNOLOGIES, 80, `Web capture ${index + 1} technologies`);
136
+ const networkOrigins = stringList(capture.networkOrigins, MAX_CAPTURE_ORIGINS, 500, `Web capture ${index + 1} network origins`)
137
+ .map((item, originIndex) => origin(item, `Web capture ${index + 1} network origin ${originIndex + 1}`))
138
+ .filter((item) => item !== null);
139
+ const screenshotSha256 = capture.screenshotSha256 === undefined || capture.screenshotSha256 === null || capture.screenshotSha256 === ''
140
+ ? null
141
+ : typeof capture.screenshotSha256 === 'string' && SHA256_RE.test(capture.screenshotSha256)
142
+ ? capture.screenshotSha256.toLowerCase()
143
+ : (() => { throw new Error(`Web capture ${index + 1} screenshot digest must be SHA-256.`); })();
144
+ const summaries = [
145
+ pageTitle || finalOrigin
146
+ ? `Sanitised page capture${pageTitle ? ` titled "${pageTitle}"` : ''}${finalOrigin ? ` ended at origin ${finalOrigin}` : ''}.`
147
+ : '',
148
+ technologies.length ? `Observed technology labels: ${technologies.join(', ')}.` : '',
149
+ networkOrigins.length ? `Observed network origins: ${networkOrigins.join(', ')}.` : '',
150
+ screenshotSha256 ? `Screenshot SHA-256: ${screenshotSha256}.` : '',
151
+ ].filter(Boolean);
152
+ if (!summaries.length)
153
+ throw new Error(`Web capture ${index + 1} contains no supported summary evidence.`);
154
+ findings.push({
155
+ domain,
156
+ category: technologies.length && summaries.length === 1 ? 'http' : 'page',
157
+ evidenceClass: 'deployment_observation',
158
+ summary: summaries.join(' '),
159
+ observedAt,
160
+ completeness,
161
+ limitations: [
162
+ 'Imported sanitised capture summary; WHOISleuth did not collect or independently verify this observation.',
163
+ ...limitations,
164
+ ].slice(0, 8),
165
+ reference: sourceReference,
166
+ });
167
+ }
168
+ return parseExternalFindingsDocument({
169
+ schema: EXTERNAL_FINDINGS_SCHEMA,
170
+ schemaVersion: EXTERNAL_FINDINGS_VERSION,
171
+ source: { name: sourceName, reference: sourceReference, collectedAt: sourceCollectedAt },
172
+ findings,
173
+ });
174
+ }
175
+ function artifactName(value, label) {
176
+ const candidate = text(value, 120, label) ?? '';
177
+ if (candidate === '.'
178
+ || candidate === '..'
179
+ || candidate.includes('/')
180
+ || candidate.includes('\\')
181
+ || candidate.includes('%2f')
182
+ || candidate.includes('%5c')) {
183
+ throw new Error(`${label} must be a plain file name without a path.`);
184
+ }
185
+ return candidate;
186
+ }
187
+ function positiveInteger(value, maximum, label) {
188
+ if (!Number.isInteger(value) || Number(value) <= 0 || Number(value) > maximum) {
189
+ throw new Error(`${label} is outside the supported bound.`);
190
+ }
191
+ return Number(value);
192
+ }
193
+ function listSummaryFragments(label, values) {
194
+ const fragments = [];
195
+ let current = `${label}:`;
196
+ for (const value of values) {
197
+ const separator = current === `${label}:` ? ' ' : ', ';
198
+ if (`${current}${separator}${value}.`.length > MAX_EXTERNAL_FINDING_SUMMARY_LENGTH) {
199
+ fragments.push(`${current}.`);
200
+ current = `${label}: ${value}`;
201
+ }
202
+ else
203
+ current += `${separator}${value}`;
204
+ }
205
+ if (current !== `${label}:`)
206
+ fragments.push(`${current}.`);
207
+ return fragments;
208
+ }
209
+ function partitionSummary(fragments) {
210
+ const summaries = [];
211
+ let current = '';
212
+ for (const fragment of fragments) {
213
+ if (!fragment || fragment.length > MAX_EXTERNAL_FINDING_SUMMARY_LENGTH) {
214
+ throw new Error('Web capture metadata cannot be represented within the external-finding summary bound.');
215
+ }
216
+ const combined = current ? `${current} ${fragment}` : fragment;
217
+ if (combined.length > MAX_EXTERNAL_FINDING_SUMMARY_LENGTH) {
218
+ summaries.push(current);
219
+ current = fragment;
220
+ }
221
+ else
222
+ current = combined;
223
+ }
224
+ if (current)
225
+ summaries.push(current);
226
+ return summaries;
227
+ }
228
+ export function readWebCaptureManifest(value) {
229
+ const root = record(value);
230
+ if (!root
231
+ || !onlyKeys(root, ROOT_KEYS)
232
+ || root.schema !== WEB_CAPTURE_MANIFEST_SCHEMA
233
+ || root.schemaVersion !== WEB_CAPTURE_MANIFEST_VERSION) {
234
+ throw new Error(`Web capture manifests must use ${WEB_CAPTURE_MANIFEST_SCHEMA} schema version ${WEB_CAPTURE_MANIFEST_VERSION}.`);
235
+ }
236
+ const source = record(root.source);
237
+ if (!source || !onlyKeys(source, SOURCE_KEYS))
238
+ throw new Error('Web capture manifests require a bounded source object.');
239
+ const sourceName = text(source.name, 80, 'Capture source name');
240
+ const sourceReference = text(source.reference, 500, 'Capture source reference', true);
241
+ const sourceCollectedAt = timestamp(source.collectedAt, 'Capture source collection time', true);
242
+ if (!Array.isArray(root.captures) || !root.captures.length || root.captures.length > MAX_WEB_CAPTURE_SUMMARIES) {
243
+ throw new Error(`Web capture manifests must contain between 1 and ${MAX_WEB_CAPTURE_SUMMARIES} captures.`);
244
+ }
245
+ const findings = [];
246
+ const artifacts = [];
247
+ const contexts = [];
248
+ const domainCounts = new Map();
249
+ const findingCounts = new Map();
250
+ for (const [index, raw] of root.captures.entries()) {
251
+ const capture = record(raw);
252
+ if (!capture || !onlyKeys(capture, MANIFEST_CAPTURE_KEYS)) {
253
+ throw new Error(`Web capture manifest ${index + 1} contains unsupported fields or archive content.`);
254
+ }
255
+ const domain = normalizeDomain(text(capture.domain, 253, `Web capture manifest ${index + 1} domain`));
256
+ if (!domain)
257
+ throw new Error(`Web capture manifest ${index + 1} domain is invalid.`);
258
+ const domainCount = (domainCounts.get(domain) ?? 0) + 1;
259
+ if (domainCount > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
260
+ throw new Error(`Web capture manifests exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-capture per-domain limit.`);
261
+ }
262
+ domainCounts.set(domain, domainCount);
263
+ if (domainCounts.size > MAX_EXTERNAL_FINDING_DOMAINS) {
264
+ throw new Error(`Web capture manifests exceed the ${MAX_EXTERNAL_FINDING_DOMAINS}-domain limit.`);
265
+ }
266
+ const observedAt = timestamp(capture.capturedAt, `Web capture manifest ${index + 1} time`);
267
+ const conditions = readCaptureConditions(capture.conditions);
268
+ const observerLabel = readObservationLabel(capture.observerLabel), vantageLabel = readObservationLabel(capture.vantageLabel);
269
+ const completeness = ['complete', 'inconclusive', 'partial', 'unknown'].includes(String(capture.completeness))
270
+ ? capture.completeness
271
+ : 'unknown';
272
+ const limitations = stringList(capture.limitations, 8, 240, `Web capture manifest ${index + 1} limitations`);
273
+ contexts.push({ domain, observedAt, completeness: String(completeness), conditions, observerLabel, vantageLabel });
274
+ const page = record(capture.page);
275
+ if (page && !onlyKeys(page, PAGE_KEYS))
276
+ throw new Error(`Web capture manifest ${index + 1} page metadata contains unsupported fields.`);
277
+ const pageTitle = text(page?.title, 300, `Web capture manifest ${index + 1} title`, true);
278
+ const finalOrigin = origin(page?.finalOrigin, `Web capture manifest ${index + 1} final origin`);
279
+ const technologies = stringList(capture.technologies, MAX_CAPTURE_TECHNOLOGIES, 80, `Web capture manifest ${index + 1} technologies`);
280
+ const requestDomains = stringList(capture.requestDomains, MAX_CAPTURE_ORIGINS, 253, `Web capture manifest ${index + 1} request domains`)
281
+ .map((item) => normalizeDomain(item))
282
+ .filter((item) => Boolean(item));
283
+ if (!Array.isArray(capture.artifacts) || !capture.artifacts.length || capture.artifacts.length > 2) {
284
+ throw new Error(`Web capture manifest ${index + 1} must contain one or two artifact metadata records.`);
285
+ }
286
+ const artifactSummaries = [];
287
+ const kinds = new Set();
288
+ for (const [artifactIndex, rawArtifact] of capture.artifacts.entries()) {
289
+ const artifact = record(rawArtifact);
290
+ if (!artifact || !onlyKeys(artifact, ARTIFACT_KEYS)) {
291
+ throw new Error(`Web capture manifest ${index + 1} artifact ${artifactIndex + 1} contains unsupported fields.`);
292
+ }
293
+ const kind = artifact.kind === 'screenshot' || artifact.kind === 'dom_digest' ? artifact.kind : null;
294
+ if (!kind || kinds.has(kind))
295
+ throw new Error(`Web capture manifest ${index + 1} has an invalid or duplicate artifact kind.`);
296
+ kinds.add(kind);
297
+ const fileName = artifactName(artifact.fileName, `Web capture manifest ${index + 1} artifact name`);
298
+ const sha256 = typeof artifact.sha256 === 'string' && SHA256_RE.test(artifact.sha256)
299
+ ? artifact.sha256.toLowerCase()
300
+ : null;
301
+ if (!sha256)
302
+ throw new Error(`Web capture manifest ${index + 1} artifact digest must be SHA-256.`);
303
+ const mimeType = text(artifact.mimeType, 80, `Web capture manifest ${index + 1} artifact MIME type`)?.toLowerCase();
304
+ const maximumBytes = kind === 'screenshot' ? MAX_WEB_CAPTURE_SCREENSHOT_BYTES : MAX_WEB_CAPTURE_DOM_DIGEST_BYTES;
305
+ const bytes = positiveInteger(artifact.bytes, maximumBytes, `Web capture manifest ${index + 1} artifact size`);
306
+ if (kind === 'screenshot') {
307
+ if (!['image/png', 'image/jpeg', 'image/webp'].includes(mimeType || '')) {
308
+ throw new Error(`Web capture manifest ${index + 1} screenshot MIME type is unsupported.`);
309
+ }
310
+ const width = positiveInteger(artifact.width, 10_000, `Web capture manifest ${index + 1} screenshot width`);
311
+ const height = positiveInteger(artifact.height, 10_000, `Web capture manifest ${index + 1} screenshot height`);
312
+ const perceptualHash = artifact.perceptualHash === undefined || artifact.perceptualHash === null || artifact.perceptualHash === ''
313
+ ? null
314
+ : typeof artifact.perceptualHash === 'string' && PERCEPTUAL_HASH_RE.test(artifact.perceptualHash)
315
+ ? artifact.perceptualHash.toLowerCase()
316
+ : (() => { throw new Error(`Web capture manifest ${index + 1} screenshot perceptual hash is unsupported.`); })();
317
+ artifactSummaries.push(`Screenshot ${fileName}: ${mimeType}, ${width}x${height}, ${bytes} bytes, SHA-256 ${sha256}${perceptualHash ? `, dHash ${perceptualHash}` : ''}.`);
318
+ }
319
+ else {
320
+ if (mimeType !== 'application/json')
321
+ throw new Error(`Web capture manifest ${index + 1} DOM digest MIME type is unsupported.`);
322
+ if (artifact.width !== undefined || artifact.height !== undefined || artifact.perceptualHash !== undefined) {
323
+ throw new Error(`Web capture manifest ${index + 1} DOM digest cannot declare image-only fields.`);
324
+ }
325
+ artifactSummaries.push(`DOM digest ${fileName}: application/json, ${bytes} bytes, SHA-256 ${sha256}.`);
326
+ }
327
+ artifacts.push({ capture: index + 1, observedAt, kind, fileName, mimeType: mimeType, sha256, bytes });
328
+ }
329
+ const summaryFragments = [
330
+ ...(conditions ? [`Declared capture conditions: ${conditions.browser} ${conditions.browserVersion}; viewport ${conditions.viewport.width}x${conditions.viewport.height}; scale ${conditions.deviceScaleFactor}; locale ${conditions.locale}; timezone ${conditions.timezone}; ${conditions.colourScheme}.`] : []),
331
+ ...(observerLabel || vantageLabel ? [`Declared observer: ${observerLabel ?? 'unknown'}; vantage: ${vantageLabel ?? 'unknown'}. These labels do not verify collection independence.`] : []),
332
+ pageTitle || finalOrigin
333
+ ? `Sanitised page capture${pageTitle ? ` titled "${pageTitle}"` : ''}${finalOrigin ? ` ended at origin ${finalOrigin}` : ''}.`
334
+ : '',
335
+ ...listSummaryFragments('Observed technology labels', technologies),
336
+ ...listSummaryFragments('Observed request domains', requestDomains),
337
+ ...artifactSummaries,
338
+ ].filter(Boolean);
339
+ const summaries = partitionSummary(summaryFragments);
340
+ const priorFindingCount = findingCounts.get(domain) ?? 0;
341
+ if (priorFindingCount + summaries.length > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
342
+ throw new Error(`Web capture manifests exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-finding per-domain import limit after preserving bounded metadata.`);
343
+ }
344
+ findingCounts.set(domain, priorFindingCount + summaries.length);
345
+ for (const summary of summaries) {
346
+ findings.push({
347
+ domain,
348
+ category: 'page',
349
+ evidenceClass: 'deployment_observation',
350
+ summary,
351
+ observedAt,
352
+ completeness,
353
+ limitations: [
354
+ 'Imported capture metadata, not independently collected website evidence; artefact bytes and separate byte checks are not retained in these findings.',
355
+ ...limitations,
356
+ ].slice(0, 8),
357
+ reference: sourceReference,
358
+ });
359
+ }
360
+ }
361
+ const document = parseExternalFindingsDocument({
362
+ schema: EXTERNAL_FINDINGS_SCHEMA,
363
+ schemaVersion: EXTERNAL_FINDINGS_VERSION,
364
+ source: { name: sourceName, reference: sourceReference, collectedAt: sourceCollectedAt },
365
+ findings,
366
+ });
367
+ return { document, artifacts, captures: contexts };
368
+ }
369
+ export function parseWebCaptureManifest(value) {
370
+ return readWebCaptureManifest(value).document;
371
+ }
372
+ // Candidates must describe bytes already read and hashed by the caller. File
373
+ // names and MIME declarations never establish identity; equal copies are all
374
+ // reported rather than choosing an arbitrary file.
375
+ export function matchCaptureArtifacts(artifacts, candidates) {
376
+ return artifacts.map(artifact => {
377
+ const matchingIds = candidates.filter(candidate => candidate.bytes === artifact.bytes
378
+ && candidate.sha256.replace(/^sha256:/u, '') === artifact.sha256)
379
+ .map(candidate => candidate.id);
380
+ return { capture: artifact.capture, kind: artifact.kind, state: matchingIds.length ? 'matched' : 'not_found', matchingIds };
381
+ });
382
+ }
@@ -178,7 +178,8 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
178
178
  ...relation(state, profile, profileSourceState, profileContextComparable, profileContextLimitation),
179
179
  mutationTypes: item.mutationTypes.slice(0, 40),
180
180
  registration: item.availability,
181
- sourceCoverage: item.sourceCoverage.slice(0, 12),
181
+ // The published review retains source states; full source clocks stay in the workspace.
182
+ sourceCoverage: item.sourceCoverage.map(({ source, state }) => ({ source, state })),
182
183
  profileContextState: item.profileContext.sourceState,
183
184
  profileContextLimitation,
184
185
  limitations: rowLimitations(item, state, profileSourceState, profileContextComparable, profileContextLimitation),
@@ -0,0 +1,14 @@
1
+ import { prepareBagItEntries, encodeBagItEntries } from "../interchange/bagit.mjs";
2
+ import { prepareInvestigationPackageEntries, INVESTIGATION_PACKAGE_MANIFEST_PATH } from "./investigation-package.mjs";
3
+ /** The existing manifest keeps source declarations; BagIt preserves the bytes. */
4
+ export async function prepareInvestigationBagIt(input, generatedAt, version) {
5
+ const prepared = await prepareInvestigationPackageEntries(input, generatedAt, version);
6
+ const payload = new Map([...prepared.files].filter(([path]) => path !== INVESTIGATION_PACKAGE_MANIFEST_PATH)
7
+ .map(([path, bytes]) => [`data/${path.slice('artifacts/'.length)}`, bytes]));
8
+ const tags = new Map([['whoisleuth-manifest.json', prepared.files.get(INVESTIGATION_PACKAGE_MANIFEST_PATH)]]);
9
+ return { manifest: prepared.manifest, files: await prepareBagItEntries(payload, tags) };
10
+ }
11
+ export async function buildInvestigationBagIt(input, generatedAt, version) {
12
+ const prepared = await prepareInvestigationBagIt(input, generatedAt, version);
13
+ return { manifest: prepared.manifest, bytes: encodeBagItEntries(prepared.files) };
14
+ }