@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,14 +1,18 @@
1
1
  import { ACQUISITION_DECISIONS, ACQUISITION_MANUAL_CHECKS, ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../../packages/investigation/acquisition-decision-packet.mjs";
2
2
  import { BULK_DOMAIN_COMPARISON_EXPORT_VERSION, BULK_DOMAIN_COMPARISON_SCHEMA, } from "../../packages/investigation/bulk-domain-comparison.mjs";
3
3
  import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../../packages/investigation/bulk-mail-exposure.mjs";
4
- import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../../packages/investigation/bulk-review-export.mjs";
4
+ import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS, } from "../../packages/contracts/investigation-portability.mjs";
5
+ import { MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES } from "../../packages/contracts/workspace-portability.mjs";
6
+ import { BULK_SORT_KEYS } from "../../packages/workspace/bulk-sort.mjs";
7
+ import { normalizeExplicitIsoTimestamp } from "../../packages/evidence/observation.mjs";
5
8
  import { LOOKUP_CLAIM_PASSPORT_SCHEMA, LOOKUP_CLAIM_PASSPORT_TARGET_TYPES, LOOKUP_CLAIM_PASSPORT_VERSION, MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, } from "../../packages/investigation/lookup-claim-passport.mjs";
6
9
  import { LOOKUP_CLAIM_IDS, LOOKUP_CLAIM_READINESS_VERSION, LOOKUP_CLAIM_REQUIREMENT_IDS, } from "../../packages/investigation/lookup-claim-readiness.mjs";
7
10
  import { normalizeDomainControlManifestDocument } from "../../packages/evidence/domain-control-runtime.mjs";
8
- import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION, } from "../../lib/domain-change-packet.mjs";
11
+ import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS, } from "../../lib/domain-change-packet.mjs";
12
+ import { DOMAIN_CHANGE_REVIEW_SCHEMA } from "../../lib/domain-change-review.mjs";
9
13
  import { DOMAIN_CONTROL_MANIFEST_SCHEMA } from "../../packages/contracts/domain-control-manifest.mjs";
10
- import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, } from "../investigation-manifest.mjs";
11
- import { array, boolean, domain, digest, enumeration, exact, fail, integer, iso, optionalText, sameValues, strings, text, validateIntegrity, } from "./structure-primitives.mjs";
14
+ import { INVESTIGATION_MANIFEST_SCHEMA, validateInvestigationManifest, } from "../investigation-manifest.mjs";
15
+ import { array, boolean, domain, enumeration, exact, fail, integer, iso, optionalText, sameValues, strings, text, validateIntegrity, } from "./structure-primitives.mjs";
12
16
  const CLAIM_PASSPORT_SOURCE_STATES = ['complete', 'not_found', 'partial', 'skipped', 'unavailable', 'unknown', 'unsupported'];
13
17
  const COMPARISON_STATES = ['conflicting', 'different', 'equal', 'missing', 'not_recorded', 'unavailable'];
14
18
  const SOURCE_STATES = ['complete', 'error', 'not_found', 'not_recorded', 'partial', 'skipped', 'unavailable', 'unsupported'];
@@ -209,11 +213,17 @@ function validateDomainComparison(value) {
209
213
  strings(comparison.limitations, 'Domain comparison limitations', 12, 600);
210
214
  validateIntegrity(root.integrity, 'Domain comparison integrity', root.version, BULK_DOMAIN_COMPARISON_EXPORT_VERSION);
211
215
  }
212
- function validateSourceCoverage(value, label) {
213
- const source = exact(value, ['source', 'state'], label);
216
+ function validateNullableObservationTime(value, label) {
217
+ if (value !== null && normalizeExplicitIsoTimestamp(value) !== value)
218
+ fail(label);
219
+ }
220
+ function validateSourceCoverage(value, label, observedTime = false) {
221
+ const source = exact(value, ['source', 'state', ...(observedTime ? ['observedAt'] : [])], label);
214
222
  if (typeof source.source !== 'string' || !/^[a-z][a-z0-9_-]{0,39}$/u.test(source.source))
215
223
  fail(label);
216
224
  enumeration(source.state, SOURCE_STATES.filter((state) => state !== 'not_recorded'), label);
225
+ if (observedTime)
226
+ validateNullableObservationTime(source.observedAt, `${label} observedAt`);
217
227
  }
218
228
  function validateProfileContext(value, label) {
219
229
  const context = exact(value, ['sourceState', 'activeProfileId', 'profileUpdatedAt', 'limitation'], label);
@@ -281,29 +291,40 @@ function validateBulkView(value, label) {
281
291
  text(view.caseDispositionFilter, label, 60, true);
282
292
  enumeration(view.reviewStateFilter, ['', 'unreviewed', 'reviewing', 'reviewed', 'deferred'], label);
283
293
  text(view.groupBy, label, 60, true);
284
- enumeration(view.sortKey, ['domain', 'availability', 'risk', 'opportunity', 'activity', 'registrar', 'mutation'], label);
294
+ enumeration(view.sortKey, BULK_SORT_KEYS, label);
285
295
  if (view.sortDirection !== 1 && view.sortDirection !== -1)
286
296
  fail(label);
287
297
  }
288
298
  function validateBulkReviewManifest(value) {
289
299
  const root = exact(value, ['schema', 'version', 'generatedAt', 'observedAt', 'lookupProfile', 'selection', 'view', 'rows', 'limitations', 'integrity'], 'Bulk review manifest');
300
+ const version = SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS.find((candidate) => candidate === root.version);
301
+ if (version === undefined)
302
+ fail('Bulk review manifest version');
303
+ const sourceTimes = version === BULK_REVIEW_MANIFEST_VERSION;
304
+ const maximumRows = sourceTimes ? MAX_BULK_SESSION_ROWS : 2_000;
305
+ const maximumSources = sourceTimes ? MAX_BULK_SESSION_SOURCES : 12;
290
306
  iso(root.generatedAt, 'Bulk review manifest generatedAt');
291
- iso(root.observedAt, 'Bulk review manifest observedAt');
307
+ if (sourceTimes)
308
+ validateNullableObservationTime(root.observedAt, 'Bulk review manifest observedAt');
309
+ else
310
+ iso(root.observedAt, 'Bulk review manifest observedAt');
292
311
  enumeration(root.lookupProfile, ['deep', 'fast'], 'Bulk review manifest lookup profile');
293
312
  const selection = exact(root.selection, ['count', 'domains'], 'Bulk review manifest selection');
294
- const domains = strings(selection.domains, 'Bulk review manifest selected domains', 2_000, 253);
313
+ const domains = strings(selection.domains, 'Bulk review manifest selected domains', maximumRows, 253);
295
314
  domains.forEach((item) => domain(item, 'Bulk review manifest selected domain'));
296
- if (integer(selection.count, 'Bulk review manifest selection count', 0, 2_000) !== domains.length)
315
+ if (integer(selection.count, 'Bulk review manifest selection count', 0, maximumRows) !== domains.length)
297
316
  fail('Bulk review manifest selection');
298
317
  validateBulkView(root.view, 'Bulk review manifest view');
299
- const rows = array(root.rows, 'Bulk review manifest rows', 2_000);
318
+ const rows = array(root.rows, 'Bulk review manifest rows', maximumRows);
300
319
  for (const [index, candidate] of rows.entries()) {
301
- const row = exact(candidate, ['domain', 'reviewState', 'resultState', 'scanDepth', 'sourceCoverage', 'profileContext'], `Bulk review manifest row ${index + 1}`);
320
+ const row = exact(candidate, ['domain', 'reviewState', 'resultState', 'scanDepth', ...(sourceTimes ? ['observedAt'] : []), 'sourceCoverage', 'profileContext'], `Bulk review manifest row ${index + 1}`);
302
321
  domain(row.domain, 'Bulk review manifest row domain');
303
322
  enumeration(row.reviewState, ['unreviewed', 'reviewing', 'reviewed', 'deferred'], 'Bulk review manifest review state');
304
323
  enumeration(row.resultState, ['complete', 'error'], 'Bulk review manifest result state');
305
324
  enumeration(row.scanDepth, ['deep', 'fast'], 'Bulk review manifest scan depth');
306
- array(row.sourceCoverage, 'Bulk review manifest source coverage', 12).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk review manifest source ${sourceIndex + 1}`));
325
+ if (sourceTimes)
326
+ validateNullableObservationTime(row.observedAt, 'Bulk review manifest row observedAt');
327
+ array(row.sourceCoverage, 'Bulk review manifest source coverage', maximumSources).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk review manifest source ${sourceIndex + 1}`, sourceTimes));
307
328
  validateProfileContext(row.profileContext, 'Bulk review manifest profile context');
308
329
  if (domains[index] !== row.domain)
309
330
  fail('Bulk review manifest selection');
@@ -311,59 +332,49 @@ function validateBulkReviewManifest(value) {
311
332
  if (rows.length !== domains.length)
312
333
  fail('Bulk review manifest selection');
313
334
  strings(root.limitations, 'Bulk review manifest limitations', 8, 600);
314
- validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version, BULK_REVIEW_MANIFEST_VERSION);
335
+ validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version, version);
315
336
  }
316
- function validateInvestigationManifest(value) {
317
- const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'workflow', 'configuration', 'artifacts', 'steps', 'summary', 'limitations', 'integrity'], 'Investigation manifest');
318
- iso(root.generatedAt, 'Investigation manifest generatedAt');
319
- const application = exact(root.application, ['name', 'version'], 'Investigation manifest application');
320
- if (application.name !== 'WHOISleuth CLI' || typeof application.version !== 'string'
321
- || !/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(application.version))
322
- fail('Investigation manifest application');
323
- text(root.workflow, 'Investigation manifest workflow', 160);
324
- const configuration = exact(root.configuration, ['digestSha256'], 'Investigation manifest configuration');
325
- if (configuration.digestSha256 !== null)
326
- digest(configuration.digestSha256, 'Investigation manifest configuration');
327
- const artifacts = array(root.artifacts, 'Investigation manifest artifacts', MAX_INVESTIGATION_MANIFEST_ARTIFACTS, 1);
328
- const steps = array(root.steps, 'Investigation manifest steps', MAX_INVESTIGATION_MANIFEST_ARTIFACTS, 1);
329
- let totalBytes = 0;
330
- for (const [index, candidate] of artifacts.entries()) {
331
- const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256'], `Investigation manifest artifact ${index + 1}`);
332
- if (integer(item.sequence, 'Investigation manifest artifact sequence', 1, artifacts.length) !== index + 1
333
- || item.id !== `artifact-${index + 1}`)
334
- fail('Investigation manifest artifact order');
335
- if (item.schema !== null)
336
- text(item.schema, 'Investigation manifest artifact schema', 160);
337
- if (item.version !== null)
338
- integer(item.version, 'Investigation manifest artifact version', 1, 1_000);
339
- totalBytes += integer(item.byteLength, 'Investigation manifest artifact bytes', 1, 15 * 1024 * 1024);
340
- digest(item.contentDigestSha256, 'Investigation manifest content digest');
341
- digest(item.canonicalDigestSha256, 'Investigation manifest canonical digest');
342
- const step = exact(steps[index], ['sequence', 'artifactId', 'contentDigestSha256'], `Investigation manifest step ${index + 1}`);
343
- if (step.sequence !== item.sequence || step.artifactId !== item.id || step.contentDigestSha256 !== item.contentDigestSha256)
344
- fail('Investigation manifest step linkage');
345
- }
346
- const summary = exact(root.summary, ['artifactCount', 'totalBytes'], 'Investigation manifest summary');
347
- if (integer(summary.artifactCount, 'Investigation manifest artifact count', 1, MAX_INVESTIGATION_MANIFEST_ARTIFACTS) !== artifacts.length
348
- || integer(summary.totalBytes, 'Investigation manifest total bytes', 1, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) !== totalBytes)
349
- fail('Investigation manifest summary');
350
- strings(root.limitations, 'Investigation manifest limitations', 8, 600);
351
- validateIntegrity(root.integrity, 'Investigation manifest integrity', root.version, INVESTIGATION_MANIFEST_VERSION);
337
+ function validateSourceObservations(value, label) {
338
+ const names = new Set();
339
+ return array(value, label, 16).map((candidate) => {
340
+ const source = exact(candidate, ['label', 'source', 'state', 'observedAt'], label);
341
+ const name = text(source.label, `${label} label`, 120).toLowerCase();
342
+ if (names.has(name))
343
+ fail(`${label} duplicate label`);
344
+ names.add(name);
345
+ text(source.source, `${label} source`, 240);
346
+ enumeration(source.state, ['observed', 'partial', 'unavailable'], `${label} state`);
347
+ iso(source.observedAt, `${label} observedAt`);
348
+ return source;
349
+ });
352
350
  }
353
- function validateReviewMatrix(value, label) {
351
+ function validateReviewMatrix(value, label, sources) {
354
352
  for (const [rowIndex, candidate] of array(value, label, 500).entries()) {
355
353
  const row = exact(candidate, ['owner', 'type', 'state', 'observations'], `${label} row ${rowIndex + 1}`);
356
354
  text(row.owner, `${label} owner`, 253);
357
355
  enumeration(row.type, DNS_TYPES, `${label} type`);
358
356
  const observations = array(row.observations, `${label} observations`, 16);
357
+ if (sources && observations.length !== sources.length)
358
+ fail(`${label} source coverage`);
359
359
  let complete = 0;
360
360
  const signatures = new Set();
361
+ const names = new Set();
361
362
  for (const observationCandidate of observations) {
362
- const observation = exact(observationCandidate, ['label', 'source', 'state', 'values', 'ttlRange'], `${label} observation`);
363
+ const observation = exact(observationCandidate, ['label', 'source', 'state', 'values', 'ttlRange', ...(sources ? ['observedAt'] : [])], `${label} observation`);
363
364
  text(observation.label, `${label} observation label`, 120);
364
365
  text(observation.source, `${label} observation source`, 240);
365
366
  const state = enumeration(observation.state, ['observed', 'partial', 'unavailable'], `${label} observation state`);
366
367
  const values = strings(observation.values, `${label} observation values`, 500, 16_384);
368
+ if (sources) {
369
+ const name = String(observation.label).toLowerCase();
370
+ if (names.has(name))
371
+ fail(`${label} duplicate observation`);
372
+ names.add(name);
373
+ const source = sources.find((item) => item.label === observation.label);
374
+ if (!source || ['label', 'source', 'state', 'observedAt'].some((key) => observation[key] !== source[key]))
375
+ fail(`${label} source provenance`);
376
+ iso(observation.observedAt, `${label} observation time`);
377
+ }
367
378
  if (state === 'observed') {
368
379
  complete += 1;
369
380
  signatures.add(JSON.stringify(values));
@@ -381,15 +392,17 @@ function validateReviewMatrix(value, label) {
381
392
  fail(`${label} state`);
382
393
  }
383
394
  }
384
- function validateDomainChangeReview(value, label) {
385
- const review = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'state', 'authoritativeRecordMatrix', 'resolverDivergenceMatrix', 'dnssecAutomation', 'acmeDependencies', 'certificate', 'services', 'hsts', 'gate', 'limitations'], label);
386
- if (review.schema !== 'whoisleuth.domain-change.review' || review.version !== 1)
395
+ function validateDomainChangeReview(value, label, version) {
396
+ const retainedTimes = version >= 2;
397
+ const review = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'state', 'authoritativeRecordMatrix', 'resolverDivergenceMatrix', 'dnssecAutomation', 'acmeDependencies', 'certificate', 'services', 'hsts', 'gate', 'limitations', ...(retainedTimes ? ['sourceObservations'] : [])], label);
398
+ if (review.schema !== DOMAIN_CHANGE_REVIEW_SCHEMA || review.version !== version)
387
399
  fail(label);
388
400
  iso(review.generatedAt, `${label} generatedAt`);
389
401
  domain(review.domain, `${label} domain`);
390
402
  enumeration(review.state, ['ready', 'review'], `${label} state`);
391
- validateReviewMatrix(review.authoritativeRecordMatrix, `${label} authority matrix`);
392
- validateReviewMatrix(review.resolverDivergenceMatrix, `${label} resolver matrix`);
403
+ const sources = retainedTimes ? exact(review.sourceObservations, ['authorities', 'resolvers'], `${label} source observations`) : null;
404
+ validateReviewMatrix(review.authoritativeRecordMatrix, `${label} authority matrix`, sources ? validateSourceObservations(sources.authorities, `${label} authority sources`) : undefined);
405
+ validateReviewMatrix(review.resolverDivergenceMatrix, `${label} resolver matrix`, sources ? validateSourceObservations(sources.resolvers, `${label} resolver sources`) : undefined);
393
406
  const automation = exact(review.dnssecAutomation, ['state', 'cdsObserved', 'cdnskeyObserved', 'csyncObserved', 'conflictingTypes', 'detail'], `${label} DNSSEC automation`);
394
407
  enumeration(automation.state, ['not_observed', 'conflict', 'partial', 'review_ready'], `${label} DNSSEC automation`);
395
408
  boolean(automation.cdsObserved, `${label} CDS observed`);
@@ -405,8 +418,13 @@ function validateDomainChangeReview(value, label) {
405
418
  optionalText(dependency.provider, `${label} ACME provider`, 120);
406
419
  enumeration(dependency.state, ['confirmed', 'partial', 'unknown'], `${label} ACME state`);
407
420
  }
408
- const certificate = exact(review.certificate, ['state', 'continuity', 'findings'], `${label} certificate`);
421
+ const certificate = exact(review.certificate, ['state', 'continuity', 'findings', ...(retainedTimes ? ['observedAt'] : [])], `${label} certificate`);
409
422
  enumeration(certificate.state, ['not_supplied', 'observed', 'partial', 'unavailable'], `${label} certificate state`);
423
+ if (retainedTimes) {
424
+ iso(certificate.observedAt, `${label} certificate observedAt`, true);
425
+ if ((certificate.state === 'not_supplied') !== (certificate.observedAt === null))
426
+ fail(`${label} certificate observation time`);
427
+ }
410
428
  enumeration(certificate.continuity, ['unknown', 'retained', 'changes'], `${label} certificate continuity`);
411
429
  strings(certificate.findings, `${label} certificate findings`, 8, 600);
412
430
  for (const candidate of array(review.services, `${label} services`, 1_000)) {
@@ -534,6 +552,10 @@ function expectedDomainChangeSummary(before, after) {
534
552
  }
535
553
  function validateDomainChangePacket(value) {
536
554
  const root = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'reference', 'state', 'gate', 'summary', 'evidence', 'limitations', 'integrity'], 'Domain change packet');
555
+ const version = integer(root.version, 'Domain change packet version', 1, 1_000);
556
+ const reviewVersion = DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS[version];
557
+ if (reviewVersion === undefined)
558
+ fail('Domain change packet version');
537
559
  iso(root.generatedAt, 'Domain change packet generatedAt');
538
560
  domain(root.domain, 'Domain change packet domain');
539
561
  text(root.reference, 'Domain change packet reference', 200);
@@ -556,8 +578,8 @@ function validateDomainChangePacket(value) {
556
578
  enumeration(summary.postChangeState, ['ready', 'review'], 'Domain change packet post-change state');
557
579
  enumeration(summary.assuranceState, ['incomplete', 'needs_review', 'ready'], 'Domain change packet assurance state');
558
580
  const evidence = exact(root.evidence, ['preChange', 'postChange', 'assurance'], 'Domain change packet evidence');
559
- const pre = validateDomainChangeReview(evidence.preChange, 'Domain change packet pre-change review');
560
- const post = validateDomainChangeReview(evidence.postChange, 'Domain change packet post-change review');
581
+ const pre = validateDomainChangeReview(evidence.preChange, 'Domain change packet pre-change review', reviewVersion);
582
+ const post = validateDomainChangeReview(evidence.postChange, 'Domain change packet post-change review', reviewVersion);
561
583
  const assurance = validatePlannedAssurance(evidence.assurance, 'Domain change packet assurance');
562
584
  const assuranceResult = assurance.result;
563
585
  if (pre.generatedAt !== root.generatedAt || post.generatedAt !== root.generatedAt || assurance.generatedAt !== root.generatedAt
@@ -584,7 +606,7 @@ function validateDomainChangePacket(value) {
584
606
  }))
585
607
  fail('Domain change packet summary');
586
608
  strings(root.limitations, 'Domain change packet limitations', 8, 600);
587
- validateIntegrity(root.integrity, 'Domain change packet integrity', root.version, DOMAIN_CHANGE_PACKET_VERSION);
609
+ validateIntegrity(root.integrity, 'Domain change packet integrity', root.version, version);
588
610
  }
589
611
  export function validateSignedDigestArtifactStructure(schema, value) {
590
612
  if (value.schema !== schema)
@@ -1,105 +1 @@
1
- const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
2
- const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
3
- const DOMAIN_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
4
- export const HEX_DIGEST_RE = /^[a-f0-9]{64}$/u;
5
- export const SEMANTIC_VERSION_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
6
- export function fail(label) {
7
- throw new TypeError(`${label} has an unsupported or malformed structure.`);
8
- }
9
- export function record(value, label) {
10
- if (!value || typeof value !== 'object' || Array.isArray(value))
11
- fail(label);
12
- return value;
13
- }
14
- export function exact(value, keys, label) {
15
- const source = record(value, label);
16
- const actual = Object.keys(source);
17
- if (actual.length !== keys.length || actual.some((key) => !keys.includes(key)))
18
- fail(label);
19
- return source;
20
- }
21
- export function exactOptional(value, required, optional, label) {
22
- const source = record(value, label);
23
- const actual = Object.keys(source);
24
- if (required.some((key) => !actual.includes(key))
25
- || actual.some((key) => !required.includes(key) && !optional.includes(key)))
26
- fail(label);
27
- return source;
28
- }
29
- export function text(value, label, maximum = 2_000, allowEmpty = false) {
30
- if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value) || (!allowEmpty && !value))
31
- fail(label);
32
- return value;
33
- }
34
- export function optionalText(value, label, maximum = 2_000) {
35
- if (value !== null)
36
- text(value, label, maximum);
37
- }
38
- export function iso(value, label, nullable = false) {
39
- if (nullable && value === null)
40
- return;
41
- const candidate = text(value, label, 64);
42
- const parsed = Date.parse(candidate);
43
- if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== candidate)
44
- fail(label);
45
- }
46
- export function integer(value, label, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
47
- if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum)
48
- fail(label);
49
- return Number(value);
50
- }
51
- export function boolean(value, label) {
52
- if (typeof value !== 'boolean')
53
- fail(label);
54
- }
55
- export function enumeration(value, allowed, label) {
56
- if (typeof value !== 'string' || !allowed.includes(value))
57
- fail(label);
58
- return value;
59
- }
60
- export function array(value, label, maximum, minimum = 0) {
61
- if (!Array.isArray(value) || value.length < minimum || value.length > maximum)
62
- fail(label);
63
- return value;
64
- }
65
- export function strings(value, label, maximum, textMaximum = 2_000) {
66
- const values = array(value, label, maximum);
67
- for (const item of values)
68
- text(item, label, textMaximum);
69
- return values;
70
- }
71
- export function digest(value, label) {
72
- if (typeof value !== 'string' || !DIGEST_RE.test(value))
73
- fail(label);
74
- }
75
- export function sameValues(left, right) {
76
- return left.length === right.length && left.every((value, index) => value === right[index]);
77
- }
78
- export function domain(value, label) {
79
- if (typeof value !== 'string' || !DOMAIN_RE.test(value))
80
- fail(label);
81
- }
82
- export function absoluteUrl(value, label, protocols) {
83
- const candidate = text(value, label, 2_048);
84
- try {
85
- const parsed = new URL(candidate);
86
- if (!protocols.includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash)
87
- fail(label);
88
- return parsed;
89
- }
90
- catch {
91
- return fail(label);
92
- }
93
- }
94
- export function nullableRecord(value, label) {
95
- if (value !== null)
96
- record(value, label);
97
- }
98
- export function validateIntegrity(value, label, version, currentVersion) {
99
- if (version !== currentVersion)
100
- fail(label);
101
- const integrity = exact(value, ['algorithm', 'canonicalization', 'digestSha256'], label);
102
- if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2')
103
- fail(label);
104
- digest(integrity.digestSha256, label);
105
- }
1
+ export * from "../../packages/evidence/artifact-structure.mjs";
@@ -1,24 +1,25 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash } from 'node:crypto';
3
- import { parseBoundedJsonObject } from "./bounded-json.mjs";
3
+ import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
4
+ import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "./bounded-json.mjs";
4
5
  import { validateInvestigationCapsuleStructure, validateLookupEvidenceArtifactStructure, validateOfflineArtifactStructure, validateSignedDigestArtifactStructure, } from "./offline-artifact-validation.mjs";
5
6
  import { verifyCaseResponsePacketIntegrity, } from "../packages/cases/case-response-packet.mjs";
6
7
  import { CASE_PORTABILITY_VERIFIER_DISPATCH, CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_PACKET_SCHEMA, CLI_CASE_PACK_VERSION, CLI_CASE_PACK_SCHEMA, } from "../packages/contracts/case-portability.mjs";
7
8
  import { ACQUISITION_DECISION_PACKET_SCHEMA, BULK_DOMAIN_COMPARISON_SCHEMA, BULK_MAIL_EXPOSURE_SCHEMA, BULK_REVIEW_MANIFEST_SCHEMA, LOOKUP_CLAIM_PASSPORT_SCHEMA, LOOKUP_CLAIM_PASSPORT_VERSION, SUPPORTED_ACQUISITION_DECISION_PACKET_VERSIONS, SUPPORTED_BULK_DOMAIN_COMPARISON_EXPORT_VERSIONS, SUPPORTED_BULK_MAIL_EXPOSURE_EXPORT_VERSIONS, SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS, } from "../packages/contracts/investigation-portability.mjs";
8
- import { decryptWorkspaceArchive, inspectEncryptedWorkspaceArchive, isEncryptedWorkspaceArchive, } from "../packages/workspace/workspace-archive-crypto.mjs";
9
- import { WORKSPACE_ARCHIVE_SCHEMA, previewWorkspaceArchive, readWorkspaceArchive, } from "../packages/workspace/workspace-archive.mjs";
9
+ import { decryptWorkspaceArchiveWithMetadata, inspectEncryptedWorkspaceArchive, isEncryptedWorkspaceArchive, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES, } from "../packages/workspace/workspace-archive-crypto.mjs";
10
+ import { WORKSPACE_ARCHIVE_SCHEMA, prepareWorkspaceArchive, readWorkspaceArchive, } from "../packages/workspace/workspace-archive.mjs";
10
11
  import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, sha256ArtifactDigestFor, SORTED_JSON_V2, } from "../packages/evidence/artifact-integrity.mjs";
11
12
  import { SAVED_LOOKUP_SCHEMA, SUPPORTED_SAVED_LOOKUP_SCHEMA_VERSIONS, parseCliLookupDocument, } from "./saved-lookup.mjs";
12
13
  import { DOMAIN_CONTROL_MANIFEST_SCHEMA, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
13
- import { DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, } from "../packages/contracts/domain-control-manifest.mjs";
14
- import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION, } from "../lib/domain-change-packet.mjs";
14
+ import { DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, MAX_DOMAIN_CONTROL_MANIFEST_BYTES, } from "../packages/contracts/domain-control-manifest.mjs";
15
+ import { DOMAIN_CHANGE_PACKET_SCHEMA, SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS, } from "../lib/domain-change-packet.mjs";
15
16
  import { INVESTIGATION_CAPSULE_SCHEMA, SUPPORTED_INVESTIGATION_CAPSULE_VERSIONS, verifyInvestigationCapsule, } from "../packages/investigation/investigation-capsule.mjs";
16
- import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, } from "./investigation-manifest.mjs";
17
+ import { INVESTIGATION_MANIFEST_SCHEMA, SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS, } from "./investigation-manifest.mjs";
17
18
  import { verifyCliCasePack } from "./case-pack.mjs";
18
19
  import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, LOOKUP_EVIDENCE_SCHEMA, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../lib/evidence-export.mjs";
19
20
  export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
20
- export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 3;
21
- export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
21
+ export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 4;
22
+ export const MAX_OFFLINE_ARTIFACT_BYTES = Math.max(MAX_DOMAIN_CONTROL_MANIFEST_BYTES, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES);
22
23
  export const MAX_OFFLINE_PASSPHRASE_FILE_BYTES = 1024;
23
24
  export class UnsupportedOfflineArtifactError extends TypeError {
24
25
  code = 'unsupported_artifact';
@@ -41,8 +42,8 @@ const SIGNED_ARTIFACT_ROUTES = Object.freeze({
41
42
  [BULK_MAIL_EXPOSURE_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_BULK_MAIL_EXPOSURE_EXPORT_VERSIONS),
42
43
  [BULK_REVIEW_MANIFEST_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS),
43
44
  [DOMAIN_CONTROL_MANIFEST_SCHEMA]: DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES,
44
- [DOMAIN_CHANGE_PACKET_SCHEMA]: currentCanonicalizationRoutes([DOMAIN_CHANGE_PACKET_VERSION]),
45
- [INVESTIGATION_MANIFEST_SCHEMA]: currentCanonicalizationRoutes([INVESTIGATION_MANIFEST_VERSION]),
45
+ [DOMAIN_CHANGE_PACKET_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS),
46
+ [INVESTIGATION_MANIFEST_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS),
46
47
  });
47
48
  function record(value) {
48
49
  return value && typeof value === 'object' && !Array.isArray(value)
@@ -83,8 +84,16 @@ function rejectUnsupportedCasePortabilityVersion(label, version, currentVersion)
83
84
  }
84
85
  throw new UnsupportedOfflineArtifactError(`This ${label} version is malformed or unsupported; no data was changed.`);
85
86
  }
86
- function parseJson(raw) {
87
- return parseBoundedJsonObject(raw, { maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES });
87
+ export function parseOfflineArtifactJson(raw) {
88
+ const value = parseBoundedJsonObject(raw, {
89
+ maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES,
90
+ limits: boundedJsonLimitsForBytes(MAX_OFFLINE_ARTIFACT_BYTES),
91
+ });
92
+ if (value.schema !== WORKSPACE_ARCHIVE_SCHEMA && !isEncryptedWorkspaceArchive(value)
93
+ && Buffer.byteLength(raw, 'utf8') > MAX_DOMAIN_CONTROL_MANIFEST_BYTES) {
94
+ throw new TypeError(`Non-workspace artefacts are limited to ${MAX_DOMAIN_CONTROL_MANIFEST_BYTES} bytes.`);
95
+ }
96
+ return value;
88
97
  }
89
98
  export function hasVerifiedArtifactStructure(report) {
90
99
  return report.checks.structure === 'verified';
@@ -92,6 +101,7 @@ export function hasVerifiedArtifactStructure(report) {
92
101
  export function hasVerifiedApplicableIntegrity(report) {
93
102
  return report.checks.contentIntegrity === 'verified'
94
103
  && (report.checks.contentIntegrityScope === 'whole_artifact'
104
+ || report.checks.contentIntegrityScope === 'manifest_and_files'
95
105
  || report.checks.contentIntegrityScope === 'embedded_projections');
96
106
  }
97
107
  export function hasVerifiedWholeArtifactIntegrity(report) {
@@ -108,6 +118,9 @@ export function offlineArtifactSatisfiesAssurance(report, requirement) {
108
118
  return hasVerifiedWholeArtifactIntegrity(report);
109
119
  }
110
120
  export function isCompleteOfflineArtifactVerification(report) {
121
+ if (report.artifact.kind === 'bagit')
122
+ return report.state === 'integrity_valid' && report.bagit?.state === 'valid'
123
+ && report.checks.structure === 'verified' && report.checks.contentIntegrity === 'verified';
111
124
  return (report.state === 'verified' || report.state === 'structure_valid')
112
125
  && (report.manifestIdentity === null || report.manifestIdentity.state === 'identity_verified');
113
126
  }
@@ -124,8 +137,7 @@ function artifactVersion(value) {
124
137
  function inputBytes(raw) {
125
138
  return Buffer.byteLength(raw, 'utf8');
126
139
  }
127
- async function archiveReport(raw, source, archive, encrypted, ciphertextBytes) {
128
- const preview = await previewWorkspaceArchive(source, {});
140
+ function archiveReport(raw, archive, preview, encrypted, ciphertextBytes) {
129
141
  const readySectionCount = preview.sections.filter((section) => section.status === 'ready').length;
130
142
  const unsupportedSectionCount = preview.sections.filter((section) => section.status === 'unsupported').length;
131
143
  const blockedSectionCount = preview.sections.filter((section) => section.status === 'blocked').length;
@@ -172,6 +184,24 @@ async function archiveReport(raw, source, archive, encrypted, ciphertextBytes) {
172
184
  ]),
173
185
  });
174
186
  }
187
+ async function verifyWorkspaceArchiveValue(raw, value, passphrase) {
188
+ const encrypted = isEncryptedWorkspaceArchive(value);
189
+ if (encrypted && !passphrase) {
190
+ throw new TypeError('Encrypted archive inspection requires a separate passphrase file.');
191
+ }
192
+ if (!encrypted)
193
+ artifactVersion(value);
194
+ const decrypted = encrypted ? await decryptWorkspaceArchiveWithMetadata(value, passphrase) : null;
195
+ const source = decrypted ? decrypted.archive : value;
196
+ const prepared = await prepareWorkspaceArchive(source);
197
+ const archive = prepared.read();
198
+ const report = archiveReport(raw, archive, prepared.preview({}), encrypted, decrypted?.ciphertextBytes ?? null);
199
+ return { archive, report, encrypted };
200
+ }
201
+ /** Verify an archive once and retain its sections for bounded offline inspection. */
202
+ export async function verifyOfflineWorkspaceArchive(raw, options = {}) {
203
+ return verifyWorkspaceArchiveValue(raw, parseOfflineArtifactJson(raw), options.passphrase);
204
+ }
175
205
  async function verifySignedArtifact(raw, value, schema, version) {
176
206
  const routes = SIGNED_ARTIFACT_ROUTES[schema] ?? [];
177
207
  if (!routes.some((route) => route.version === version)) {
@@ -215,7 +245,7 @@ async function verifySignedArtifact(raw, value, schema, version) {
215
245
  });
216
246
  }
217
247
  async function verifyOfflineArtifactCore(raw, options = {}) {
218
- const value = parseJson(raw);
248
+ const value = parseOfflineArtifactJson(raw);
219
249
  const casePortabilityVerifier = selectCasePortabilityVerifier(value);
220
250
  if (casePortabilityVerifier?.id === 'cli-case-pack') {
221
251
  const casePackVersion = casePortabilityVersion(casePortabilityVerifier, value);
@@ -249,9 +279,24 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
249
279
  ]),
250
280
  });
251
281
  }
282
+ if (value.schema === undefined && Array.isArray(value.cases)) {
283
+ const cases = readEditableCaseExport(raw);
284
+ return Object.freeze({
285
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
286
+ artifact: Object.freeze({ kind: 'case_export', schema: null, version: artifactVersion(value) }),
287
+ state: 'structure_valid',
288
+ checks: Object.freeze({ structure: 'verified', contentIntegrity: 'not_checked', contentIntegrityScope: 'not_applicable', authenticatedEncryption: 'not_applicable' }),
289
+ summary: Object.freeze({ inputBytes: inputBytes(raw), sectionCount: 1, recordCount: cases.length, ciphertextBytes: null }),
290
+ limitations: Object.freeze([
291
+ 'The ordinary Case export matches its exact supported structure without repairing or removing retained records. It has no embedded checksum or signature.',
292
+ 'Original-file references do not include original bytes. Verify a selected evidence package to check which references have matching bytes.',
293
+ 'The Case includes private analyst content. Structural validity does not authenticate its author or establish factual accuracy, currentness or response authority.',
294
+ ]),
295
+ });
296
+ }
252
297
  if (isEncryptedWorkspaceArchive(value)) {
253
- const inspected = inspectEncryptedWorkspaceArchive(value);
254
298
  if (!options.passphrase) {
299
+ const inspected = inspectEncryptedWorkspaceArchive(value);
255
300
  return Object.freeze({
256
301
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
257
302
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
@@ -279,14 +324,12 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
279
324
  ]),
280
325
  });
281
326
  }
282
- const decrypted = await decryptWorkspaceArchive(value, options.passphrase);
283
- const archive = await readWorkspaceArchive(decrypted);
284
- return archiveReport(raw, decrypted, archive, true, inspected.ciphertextBytes);
327
+ return (await verifyWorkspaceArchiveValue(raw, value, options.passphrase)).report;
285
328
  }
286
329
  const schema = typeof value.schema === 'string' ? value.schema : '';
287
330
  const version = artifactVersion(value);
288
331
  if (schema === WORKSPACE_ARCHIVE_SCHEMA) {
289
- return archiveReport(raw, value, await readWorkspaceArchive(value), false, null);
332
+ return (await verifyWorkspaceArchiveValue(raw, value)).report;
290
333
  }
291
334
  if (casePortabilityVerifier?.id === 'case-response-packet') {
292
335
  if (!supportsCasePortabilityVersion(casePortabilityVerifier, version)) {
@@ -432,13 +475,13 @@ async function verifyManifestIdentity(artifactRaw, manifestRaw, entryId) {
432
475
  || manifestReport.checks.contentIntegrityScope !== 'whole_artifact') {
433
476
  throw new TypeError('The selected manifest is not a fully integrity-verified investigation manifest.');
434
477
  }
435
- const manifest = parseJson(manifestRaw);
478
+ const manifest = parseOfflineArtifactJson(manifestRaw);
436
479
  const entries = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
437
480
  const entry = entries.find((candidate) => record(candidate)?.id === entryId);
438
481
  const item = record(entry);
439
482
  if (!item)
440
483
  throw new TypeError('The requested investigation manifest entry was not found.');
441
- const artifactValue = parseJson(artifactRaw);
484
+ const artifactValue = parseOfflineArtifactJson(artifactRaw);
442
485
  const metadata = artifactMetadata(artifactValue);
443
486
  const actualByteLength = inputBytes(artifactRaw);
444
487
  const expectedByteLength = Number(item.byteLength);
@@ -466,7 +509,7 @@ async function verifyManifestIdentity(artifactRaw, manifestRaw, entryId) {
466
509
  : 'mismatch';
467
510
  return Object.freeze({
468
511
  state,
469
- manifest: Object.freeze({ schema: INVESTIGATION_MANIFEST_SCHEMA, version: INVESTIGATION_MANIFEST_VERSION, entryId }),
512
+ manifest: Object.freeze({ schema: INVESTIGATION_MANIFEST_SCHEMA, version: manifestReport.artifact.version, entryId }),
470
513
  checks,
471
514
  expectedByteLength,
472
515
  actualByteLength,
@@ -490,8 +533,9 @@ export async function verifyOfflineArtifact(raw, options = {}) {
490
533
  export function formatOfflineArtifactVerification(report) {
491
534
  const lines = [
492
535
  'WHOISleuth offline artefact verification',
493
- `Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
536
+ `Artifact: ${report.artifact.kind} · ${report.artifact.schema ?? 'no schema identifier'} v${report.artifact.version}`,
494
537
  `State: ${report.state}`,
538
+ 'Automation: use --strict-exit for exit 4 on incomplete verification; default exit 0 means the report was produced.',
495
539
  `Structure: ${report.checks.structure}`,
496
540
  `Content integrity: ${report.checks.contentIntegrity}`,
497
541
  `Content integrity scope: ${report.checks.contentIntegrityScope}`,
@@ -534,6 +578,28 @@ export function formatOfflineArtifactVerification(report) {
534
578
  for (const limitation of report.manifestIdentity.limitations)
535
579
  lines.push(`Manifest limitation: ${limitation}`);
536
580
  }
581
+ if (report.bagit) {
582
+ const bag = report.bagit;
583
+ lines.push(`BagIt: ${bag.state}`, `Payload: ${bag.entries.length} files; ${bag.payloadBytes} present bytes`, `Tag checksums: ${bag.verifiedTagFiles} of ${bag.tagFiles} tag files checked`, `Fetch declarations: ${bag.fetchEntries}; ${bag.fetchMissing} files missing; no requests made`, `Unsupported manifests: ${bag.unsupportedManifests}`);
584
+ for (const entry of bag.entries)
585
+ lines.push(`${entry.id}: ${entry.state} · ${entry.byteLength ?? 'unknown'} bytes`);
586
+ for (const issue of bag.issues)
587
+ lines.push(`BagIt issue: ${issue}`);
588
+ }
589
+ if (report.package) {
590
+ lines.push(`Package bytes: ${report.package.digestSha256}`, 'Storage: unchanged; inspection only', 'Audience: private; review selected file contents before sharing', 'Signature trust: not checked', 'Trusted timestamp: not checked', 'Factual accuracy: not established');
591
+ for (const entry of report.package.entries)
592
+ lines.push(`${entry.id}: ${entry.state} · ${entry.byteLength} bytes · ${entry.mediaType}${entry.issue ? ` · ${entry.issue}` : ''}`);
593
+ for (const link of report.package.links)
594
+ lines.push(`Source link ${link.capsuleEntryId}: ${link.state}${link.sourceEntryId ? ` (${link.sourceEntryId})` : ''}`);
595
+ for (const capture of report.package.captureManifests) {
596
+ lines.push(`Capture manifest ${capture.entryId}: ${capture.state}`);
597
+ for (const artifact of capture.artifacts)
598
+ lines.push(`Capture ${artifact.capture} ${artifact.kind}: ${artifact.state}${artifact.matchingIds.length ? ` (${artifact.matchingIds.join(', ')})` : ''}`);
599
+ }
600
+ for (const item of report.package.caseFiles)
601
+ lines.push(`Case files ${item.entryId} (${item.caseCount} Case${item.caseCount === 1 ? '' : 's'}): ${item.matched} of ${item.references} references matched; ${item.missing} missing`);
602
+ }
537
603
  for (const limitation of report.limitations)
538
604
  lines.push(`Limitation: ${limitation}`);
539
605
  return `${lines.join('\n')}\n`;