@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
@@ -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',
@@ -83,14 +86,31 @@ function iso(value, label, optional = false) {
83
86
  function limitations(value, index) {
84
87
  if (value === undefined)
85
88
  return [];
86
- if (!Array.isArray(value) || value.length > 8) {
87
- 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.`);
88
91
  }
89
92
  const unique = new Set();
90
93
  for (const item of value)
91
- unique.add(requiredText(item, 240, `Finding ${index + 1} limitation`));
94
+ unique.add(requiredText(item, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, `Finding ${index + 1} limitation`));
92
95
  return [...unique];
93
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
+ }
94
114
  function structuredObservation(value, index) {
95
115
  if (value === undefined || value === null)
96
116
  return null;
@@ -267,21 +287,19 @@ function importedSourceLabel(finding, sourceName) {
267
287
  ? `Deployment observation: ${sourceName}`
268
288
  : `Provider report: ${sourceName}`;
269
289
  }
270
- function existingPinKey(recordValue, finding, sourceName) {
271
- const expectedLabel = `External ${finding.category} finding`;
272
- const expectedValue = finding.structuredObservation?.value ?? pinValue(finding);
273
- const expectedSource = importedSourceLabel(finding, sourceName);
274
- const observation = finding.structuredObservation;
275
- return recordValue.evidencePins.some((pin) => (pin.label === expectedLabel
276
- && pin.value === expectedValue
277
- && pin.source === expectedSource
278
- && pin.observedAt === finding.observedAt
279
- && pin.completeness === finding.completeness
280
- && (!observation || (pin.field === observation.field
281
- && pin.sourceSchema?.collection === 'external_observations'
282
- && pin.sourceSchema.schema === observation.sourceSchema
283
- && pin.sourceSchema.version === observation.sourceVersion
284
- && (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
+ });
285
303
  }
286
304
  function structuredPinFields(finding) {
287
305
  const observation = finding.structuredObservation;
@@ -313,37 +331,41 @@ function structuredPinFields(finding) {
313
331
  certificateObservation,
314
332
  };
315
333
  }
316
- function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
317
- const target = current.find((candidate) => candidate.id === caseId) ?? null;
318
- if (!target)
319
- throw new Error('The selected Case is unavailable for this finding.');
320
- if (existingPinKey(target, finding, source.name)) {
321
- return { cases: [...current], record: target, added: false };
322
- }
323
- 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 = [
324
348
  finding.evidenceClass === 'deployment_observation'
325
349
  ? `Imported as an observation made by ${source.name}; this browser session did not collect or independently verify it.`
326
350
  : `Reported by ${source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
327
- ...(source.reference ? [`Source reference: ${source.reference}`] : []),
328
- ...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.`] : []),
329
354
  ];
330
- const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
331
- let updated = updateCase([...current], target.id, {
332
- evidencePin: {
355
+ const sourceLimitations = retainExternalFindingLimitations(finding.limitations, mandatory);
356
+ const omittedLimitations = finding.limitations.filter((item) => !sourceLimitations.includes(item)).length;
357
+ const pin = normalizeCaseEvidencePins([{
333
358
  ...structuredPinFields(finding),
359
+ importContentSha256: identitySha256,
334
360
  label: `External ${finding.category} finding`,
335
- value: finding.structuredObservation?.value ?? pinValue(finding),
336
- source: importedSourceLabel(finding, source.name),
361
+ value,
362
+ source: sourceLabel,
337
363
  observedAt: finding.observedAt,
338
364
  completeness: finding.completeness,
365
+ truncated: shortenedFields.length || omittedLimitations ? true : null,
339
366
  limitations: sourceLimitations,
340
- },
341
- }, now);
342
- const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
343
- if (!addedPin)
344
- throw new Error('The imported finding could not be retained as Case evidence.');
345
- updated = updateCase(updated.cases, target.id, {
346
- sighting: {
367
+ }], finding.observedAt)[0];
368
+ const observed = normalizeCaseSightings([{
347
369
  state: finding.evidenceClass === 'deployment_observation'
348
370
  ? 'observed_by_deployment'
349
371
  : 'reported_by_provider',
@@ -359,9 +381,28 @@ function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
359
381
  source: source.name,
360
382
  observedAt: finding.observedAt,
361
383
  completeness: finding.completeness,
362
- evidencePinId: addedPin.id,
363
384
  limitations: sourceLimitations,
364
- },
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 },
365
406
  }, now);
366
407
  return { cases: updated.cases, record: updated.record, added: true };
367
408
  }
@@ -372,7 +413,6 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
372
413
  let findingsAdded = 0;
373
414
  let duplicatesSkipped = 0;
374
415
  for (const finding of document.findings) {
375
- const existing = cases.find((candidate) => candidate.domain === finding.domain) ?? null;
376
416
  const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
377
417
  cases = opened.cases;
378
418
  const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
@@ -383,10 +423,10 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
383
423
  continue;
384
424
  }
385
425
  findingsAdded += 1;
386
- if (existing)
387
- updatedDomains.add(finding.domain);
388
- else
426
+ if (opened.created)
389
427
  createdDomains.add(finding.domain);
428
+ else
429
+ updatedDomains.add(finding.domain);
390
430
  }
391
431
  return {
392
432
  cases,
@@ -422,7 +462,7 @@ export function externalFindingsCaseTargets(document, caseDomain) {
422
462
  }
423
463
  /**
424
464
  * Merges already-validated external findings into one selected Case. A Case is
425
- * 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
426
466
  * hostname. Only same-domain findings are accepted, the exact hostname remains
427
467
  * explicit in the retained evidence, and this operation can never open another
428
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
+ }