@slicedearth/whoisleuth-cli 1.47.4 → 2.0.1

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 (236) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/project-metadata.mjs +1 -1
  100. package/lib/provider-policy-admission.mjs +3 -1
  101. package/lib/rdap-bootstrap.mjs +76 -16
  102. package/lib/rdap-client.mjs +20 -12
  103. package/lib/rdap-normalization.mjs +306 -75
  104. package/lib/rdap-transport.mjs +2 -2
  105. package/lib/rdap-validation.mjs +65 -14
  106. package/lib/rdap.mjs +2 -2
  107. package/lib/registrable-domain.mjs +36 -0
  108. package/lib/registry-insights.mjs +7 -0
  109. package/lib/response-policy.mjs +27 -4
  110. package/lib/risk-calibration-summary.mjs +35 -33
  111. package/lib/risk-scoring.mjs +4 -3
  112. package/lib/safe-fetch.mjs +62 -40
  113. package/lib/security-txt.mjs +1 -1
  114. package/lib/smtp-transport-review.mjs +8 -5
  115. package/lib/sslbl-intelligence.mjs +6 -8
  116. package/lib/static-html-analysis.mjs +301 -4
  117. package/lib/structured-data-identity.mjs +51 -19
  118. package/lib/threat-intelligence-fixtures.mjs +2 -4
  119. package/lib/threat-intelligence-manifests.mjs +2 -7
  120. package/lib/threat-intelligence-runtime.mjs +3 -5
  121. package/lib/threat-intelligence-types.mjs +3 -0
  122. package/lib/threatfox-intelligence.mjs +7 -3
  123. package/lib/tls-intelligence.mjs +4 -4
  124. package/lib/urlhaus-intelligence.mjs +4 -2
  125. package/lib/urlscan-intelligence.mjs +6 -5
  126. package/lib/website-security-posture.mjs +1 -1
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-authority.mjs +43 -8
  129. package/lib/whois-chain.mjs +25 -3
  130. package/lib/whois-normalization.mjs +3 -1
  131. package/lib/whois-transport.mjs +12 -3
  132. package/lib/zone-intent-review.mjs +1 -1
  133. package/package.json +13 -5
  134. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  135. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  136. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  137. package/packages/cases/case-migration-model.mjs +476 -0
  138. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  139. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  140. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  141. package/packages/cases/case-record-model.mjs +9 -0
  142. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  143. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  144. package/packages/cases/case-response-model.mjs +1604 -0
  145. package/packages/cases/case-response-packet.mjs +1312 -0
  146. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  147. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  148. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  149. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  150. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  151. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  152. package/packages/comparison/favicon-similarity.mjs +78 -0
  153. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  154. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  155. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  156. package/packages/contracts/analyst-review-state.mjs +50 -0
  157. package/packages/contracts/case-portability.mjs +1310 -0
  158. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  159. package/packages/contracts/cli-lookup.mjs +394 -0
  160. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  161. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  162. package/packages/contracts/domain-control-monitor.mjs +310 -0
  163. package/packages/contracts/domain-control-review.mjs +828 -0
  164. package/packages/contracts/domain-name.mjs +4 -0
  165. package/packages/contracts/external-observation-interchange.mjs +224 -0
  166. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  167. package/packages/contracts/investigation-portability.mjs +331 -0
  168. package/packages/contracts/offline-comparison.mjs +160 -0
  169. package/packages/contracts/risk-calibration.mjs +1288 -0
  170. package/packages/contracts/schema-compatibility.mjs +71 -0
  171. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  172. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  173. package/packages/contracts/workspace-portability.mjs +1347 -0
  174. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  175. package/packages/evidence/decision-fact.mjs +735 -0
  176. package/packages/evidence/domain-control-runtime.mjs +547 -0
  177. package/packages/evidence/domain-name.mjs +41 -0
  178. package/packages/evidence/observation.mjs +384 -0
  179. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  180. package/packages/interchange/retained-text.mjs +8 -0
  181. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  182. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  183. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  184. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  185. package/packages/investigation/bulk-review-policy.mjs +1 -0
  186. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  187. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  188. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  189. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  190. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  191. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  192. package/packages/monitoring/analyst-review-state.mjs +680 -0
  193. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
  194. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  195. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  196. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  197. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  198. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  199. package/packages/workspace/hostile-input.mjs +172 -0
  200. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  201. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  202. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  203. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  204. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  205. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  206. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  207. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  208. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  209. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  210. package/third-party-notices.txt +14 -14
  211. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  212. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  213. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  214. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  215. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  216. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  217. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  218. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  219. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  220. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  221. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  222. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  223. package/frontend/src/lib/analysis/scoring.js +0 -55
  224. package/frontend/src/lib/analysis/utils.js +0 -255
  225. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  226. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  227. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  228. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  229. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  230. package/lib/bounded-json-response.mjs +0 -170
  231. package/lib/lookup-response-contract.mjs +0 -1034
  232. package/lib/observation.mjs +0 -115
  233. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  236. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -2,22 +2,19 @@ import { Buffer } from 'node:buffer';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { parseBoundedJsonObject } from "./bounded-json.mjs";
4
4
  import { validateInvestigationCapsuleStructure, validateLookupEvidenceArtifactStructure, validateOfflineArtifactStructure, validateSignedDigestArtifactStructure, } from "./artifact-structure.mjs";
5
- import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, LEGACY_CASE_RESPONSE_PACKET_VERSION, verifyCaseResponsePacketIntegrity, } from "../frontend/src/lib/analysis/case-response-packet.js";
6
- import { ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../frontend/src/lib/analysis/acquisition-decision-packet.js";
7
- import { LOOKUP_CLAIM_PASSPORT_SCHEMA, LOOKUP_CLAIM_PASSPORT_VERSION, } from "../frontend/src/lib/analysis/lookup-claim-passport.js";
8
- import { BULK_DOMAIN_COMPARISON_SCHEMA, BULK_DOMAIN_COMPARISON_EXPORT_VERSION, } from "../frontend/src/lib/analysis/bulk-domain-comparison.js";
9
- import { BULK_MAIL_EXPOSURE_SCHEMA, BULK_MAIL_EXPOSURE_EXPORT_VERSION, } from "../frontend/src/lib/analysis/bulk-mail-exposure.js";
10
- import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../frontend/src/lib/analysis/bulk-review-export.js";
11
- import { decryptWorkspaceArchive, inspectEncryptedWorkspaceArchive, isEncryptedWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive-crypto.js";
12
- import { WORKSPACE_ARCHIVE_SCHEMA, previewWorkspaceArchive, readWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive.js";
13
- import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, sha256ArtifactDigestFor, SORTED_JSON_V1, SORTED_JSON_V2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
14
- import { SAVED_LOOKUP_SCHEMA, SAVED_LOOKUP_SCHEMA_VERSION, parseSavedLookupDocument, } from "./saved-lookup.mjs";
15
- import { DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
16
- import { DOMAIN_CONTROL_PASSPORT_VERSION } from "../frontend/src/lib/analysis/domain-control-manifest-core.js";
5
+ import { verifyCaseResponsePacketIntegrity, } from "../packages/cases/case-response-packet.mjs";
6
+ 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
+ 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";
10
+ import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, sha256ArtifactDigestFor, SORTED_JSON_V2, } from "../packages/evidence/artifact-integrity.mjs";
11
+ import { SAVED_LOOKUP_SCHEMA, SUPPORTED_SAVED_LOOKUP_SCHEMA_VERSIONS, parseCliLookupDocument, } from "./saved-lookup.mjs";
12
+ 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";
17
14
  import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION, } from "../lib/domain-change-packet.mjs";
18
- import { INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, LEGACY_INVESTIGATION_CAPSULE_VERSION, verifyInvestigationCapsule, } from "../frontend/src/lib/analysis/investigation-capsule.js";
15
+ import { INVESTIGATION_CAPSULE_SCHEMA, SUPPORTED_INVESTIGATION_CAPSULE_VERSIONS, verifyInvestigationCapsule, } from "../packages/investigation/investigation-capsule.mjs";
19
16
  import { INVESTIGATION_MANIFEST_SCHEMA, INVESTIGATION_MANIFEST_VERSION, } from "./investigation-manifest.mjs";
20
- import { CLI_CASE_PACK_SCHEMA, verifyCliCasePack, } from "./case-pack.mjs";
17
+ import { verifyCliCasePack } from "./case-pack.mjs";
21
18
  import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, LOOKUP_EVIDENCE_SCHEMA, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../lib/evidence-export.mjs";
22
19
  export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
23
20
  export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 3;
@@ -30,31 +27,62 @@ export class UnsupportedOfflineArtifactError extends TypeError {
30
27
  this.name = 'UnsupportedOfflineArtifactError';
31
28
  }
32
29
  }
33
- const SIGNED_ARTIFACT_VERSIONS = Object.freeze({
34
- [ACQUISITION_DECISION_PACKET_SCHEMA]: new Set([1, ACQUISITION_DECISION_PACKET_VERSION]),
35
- [LOOKUP_CLAIM_PASSPORT_SCHEMA]: new Set([LOOKUP_CLAIM_PASSPORT_VERSION]),
36
- [BULK_DOMAIN_COMPARISON_SCHEMA]: new Set([3, BULK_DOMAIN_COMPARISON_EXPORT_VERSION]),
37
- [BULK_MAIL_EXPOSURE_SCHEMA]: new Set([1, BULK_MAIL_EXPOSURE_EXPORT_VERSION]),
38
- [BULK_REVIEW_MANIFEST_SCHEMA]: new Set([1, BULK_REVIEW_MANIFEST_VERSION]),
39
- [DOMAIN_CONTROL_MANIFEST_SCHEMA]: new Set([DOMAIN_CONTROL_PASSPORT_VERSION, DOMAIN_CONTROL_MANIFEST_VERSION]),
40
- [DOMAIN_CHANGE_PACKET_SCHEMA]: new Set([1, DOMAIN_CHANGE_PACKET_VERSION]),
41
- [INVESTIGATION_MANIFEST_SCHEMA]: new Set([1, INVESTIGATION_MANIFEST_VERSION]),
42
- });
43
- const SIGNED_ARTIFACT_CANONICALIZATION = Object.freeze({
44
- [ACQUISITION_DECISION_PACKET_SCHEMA]: [{ version: 1, canonicalization: SORTED_JSON_V1, explicit: false }, { version: ACQUISITION_DECISION_PACKET_VERSION, canonicalization: SORTED_JSON_V2, explicit: true }],
45
- [LOOKUP_CLAIM_PASSPORT_SCHEMA]: [{ version: LOOKUP_CLAIM_PASSPORT_VERSION, canonicalization: SORTED_JSON_V2, explicit: true }],
46
- [BULK_DOMAIN_COMPARISON_SCHEMA]: [{ version: 3, canonicalization: SORTED_JSON_V1, explicit: false }, { version: BULK_DOMAIN_COMPARISON_EXPORT_VERSION, canonicalization: SORTED_JSON_V2, explicit: true }],
47
- [BULK_MAIL_EXPOSURE_SCHEMA]: [{ version: 1, canonicalization: SORTED_JSON_V1, explicit: false }, { version: BULK_MAIL_EXPOSURE_EXPORT_VERSION, canonicalization: SORTED_JSON_V2, explicit: true }],
48
- [BULK_REVIEW_MANIFEST_SCHEMA]: [{ version: 1, canonicalization: SORTED_JSON_V1, explicit: false }, { version: BULK_REVIEW_MANIFEST_VERSION, canonicalization: SORTED_JSON_V2, explicit: true }],
49
- [DOMAIN_CONTROL_MANIFEST_SCHEMA]: [{ version: DOMAIN_CONTROL_PASSPORT_VERSION, canonicalization: SORTED_JSON_V1, explicit: true }, { version: DOMAIN_CONTROL_MANIFEST_VERSION, canonicalization: SORTED_JSON_V2, explicit: true }],
50
- [DOMAIN_CHANGE_PACKET_SCHEMA]: [{ version: 1, canonicalization: SORTED_JSON_V1, explicit: false }, { version: DOMAIN_CHANGE_PACKET_VERSION, canonicalization: SORTED_JSON_V2, explicit: true }],
51
- [INVESTIGATION_MANIFEST_SCHEMA]: [{ version: 1, canonicalization: SORTED_JSON_V1, explicit: false }, { version: INVESTIGATION_MANIFEST_VERSION, canonicalization: SORTED_JSON_V2, explicit: true }],
30
+ function currentCanonicalizationRoutes(versions) {
31
+ return Object.freeze(versions.map((version) => Object.freeze({
32
+ version,
33
+ canonicalization: SORTED_JSON_V2,
34
+ explicit: true,
35
+ })));
36
+ }
37
+ const SIGNED_ARTIFACT_ROUTES = Object.freeze({
38
+ [ACQUISITION_DECISION_PACKET_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_ACQUISITION_DECISION_PACKET_VERSIONS),
39
+ [LOOKUP_CLAIM_PASSPORT_SCHEMA]: currentCanonicalizationRoutes([LOOKUP_CLAIM_PASSPORT_VERSION]),
40
+ [BULK_DOMAIN_COMPARISON_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_BULK_DOMAIN_COMPARISON_EXPORT_VERSIONS),
41
+ [BULK_MAIL_EXPOSURE_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_BULK_MAIL_EXPOSURE_EXPORT_VERSIONS),
42
+ [BULK_REVIEW_MANIFEST_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS),
43
+ [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]),
52
46
  });
53
47
  function record(value) {
54
48
  return value && typeof value === 'object' && !Array.isArray(value)
55
49
  ? value
56
50
  : null;
57
51
  }
52
+ function selectCasePortabilityVerifier(value) {
53
+ const packet = record(value.packet);
54
+ for (const descriptor of CASE_PORTABILITY_VERIFIER_DISPATCH) {
55
+ if (descriptor.id === 'case-response-packet'
56
+ && descriptor.discriminator === 'root_schema'
57
+ && value.schema === CASE_RESPONSE_PACKET_SCHEMA)
58
+ return descriptor;
59
+ if (descriptor.id === 'cli-case-pack'
60
+ && descriptor.discriminator === 'packet_schema'
61
+ && packet?.schema === CLI_CASE_PACK_SCHEMA)
62
+ return descriptor;
63
+ }
64
+ return null;
65
+ }
66
+ function casePortabilityVersion(descriptor, value) {
67
+ if (descriptor.versionField === 'schemaVersion')
68
+ return artifactVersion(value);
69
+ const packet = record(value.packet);
70
+ if (!packet)
71
+ throw new TypeError('The Case portability verifier discriminator is missing.');
72
+ return artifactVersion(packet);
73
+ }
74
+ function supportsCasePortabilityVersion(descriptor, version) {
75
+ return descriptor.supportedVersions.some((candidate) => candidate === version);
76
+ }
77
+ function rejectUnsupportedCasePortabilityVersion(label, version, currentVersion) {
78
+ if (Number.isSafeInteger(version) && version >= 1 && version < currentVersion) {
79
+ throw new UnsupportedOfflineArtifactError(`${label} version ${String(version)} is retired. Export it again as version ${String(currentVersion)} with the last broad-reader release; no data was changed.`);
80
+ }
81
+ if (Number.isSafeInteger(version) && version > currentVersion) {
82
+ throw new UnsupportedOfflineArtifactError(`${label} version ${String(version)} is newer than the supported version ${String(currentVersion)}; no data was changed.`);
83
+ }
84
+ throw new UnsupportedOfflineArtifactError(`This ${label} version is malformed or unsupported; no data was changed.`);
85
+ }
58
86
  function parseJson(raw) {
59
87
  return parseBoundedJsonObject(raw, { maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES });
60
88
  }
@@ -115,11 +143,11 @@ async function archiveReport(raw, source, archive, encrypted, ciphertextBytes) {
115
143
  schema: WORKSPACE_ARCHIVE_SCHEMA,
116
144
  version: archive.sourceVersion,
117
145
  }),
118
- state: 'verified',
146
+ state: encrypted ? 'verified' : 'integrity_valid',
119
147
  checks: Object.freeze({
120
148
  structure: 'verified',
121
149
  contentIntegrity: 'verified',
122
- contentIntegrityScope: 'whole_artifact',
150
+ contentIntegrityScope: encrypted ? 'whole_artifact' : 'embedded_projections',
123
151
  authenticatedEncryption: encrypted ? 'verified' : 'not_applicable',
124
152
  }),
125
153
  summary: Object.freeze({
@@ -135,7 +163,9 @@ async function archiveReport(raw, source, archive, encrypted, ciphertextBytes) {
135
163
  fullyImportable,
136
164
  }),
137
165
  limitations: Object.freeze([
138
- 'Verification checks the retained file against its declared versioned integrity contract; it does not establish that the original observations were accurate or remain current.',
166
+ ...(encrypted
167
+ ? ['Authenticated decryption and archive validation cover the complete encrypted workspace content; they do not establish that the original observations were accurate or remain current.']
168
+ : ['Section digest verification covers the archive projections, not root metadata such as generation time or archive limitations. It does not establish that the original observations were accurate or remain current.']),
139
169
  ...(!fullyImportable
140
170
  ? ['One or more integrity-valid archive sections cannot be imported completely by this version. Inspect the archive before selecting data to restore.']
141
171
  : []),
@@ -143,8 +173,8 @@ async function archiveReport(raw, source, archive, encrypted, ciphertextBytes) {
143
173
  });
144
174
  }
145
175
  async function verifySignedArtifact(raw, value, schema, version) {
146
- const supported = SIGNED_ARTIFACT_VERSIONS[schema];
147
- if (!supported?.has(version)) {
176
+ const routes = SIGNED_ARTIFACT_ROUTES[schema] ?? [];
177
+ if (!routes.some((route) => route.version === version)) {
148
178
  throw new UnsupportedOfflineArtifactError('This signed review-artifact schema or version is not supported.');
149
179
  }
150
180
  validateSignedDigestArtifactStructure(schema, value);
@@ -155,7 +185,7 @@ async function verifySignedArtifact(raw, value, schema, version) {
155
185
  || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
156
186
  throw new TypeError('The signed review artefact has a missing or malformed integrity envelope.');
157
187
  }
158
- const canonicalization = resolveArtifactCanonicalization(version, integrity.canonicalization, SIGNED_ARTIFACT_CANONICALIZATION[schema] ?? [], 'Signed review artefact');
188
+ const canonicalization = resolveArtifactCanonicalization(version, integrity.canonicalization, routes, 'Signed review artefact');
159
189
  const { integrity: _integrity, ...unsigned } = value;
160
190
  if (await sha256ArtifactDigestFor(unsigned, canonicalization) !== integrity.digestSha256) {
161
191
  throw new TypeError('The signed review artefact failed its SHA-256 integrity check.');
@@ -186,8 +216,12 @@ async function verifySignedArtifact(raw, value, schema, version) {
186
216
  }
187
217
  async function verifyOfflineArtifactCore(raw, options = {}) {
188
218
  const value = parseJson(raw);
189
- const casePackPacket = record(value.packet);
190
- if (casePackPacket?.schema === CLI_CASE_PACK_SCHEMA) {
219
+ const casePortabilityVerifier = selectCasePortabilityVerifier(value);
220
+ if (casePortabilityVerifier?.id === 'cli-case-pack') {
221
+ const casePackVersion = casePortabilityVersion(casePortabilityVerifier, value);
222
+ if (!supportsCasePortabilityVersion(casePortabilityVerifier, casePackVersion)) {
223
+ rejectUnsupportedCasePortabilityVersion('CLI Case-pack', casePackVersion, CLI_CASE_PACK_VERSION);
224
+ }
191
225
  const verified = verifyCliCasePack(value);
192
226
  return Object.freeze({
193
227
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
@@ -195,7 +229,7 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
195
229
  artifact: Object.freeze({
196
230
  kind: 'cli_case_pack',
197
231
  schema: CLI_CASE_PACK_SCHEMA,
198
- version: Number(casePackPacket.version),
232
+ version: casePackVersion,
199
233
  }),
200
234
  state: 'verified',
201
235
  checks: Object.freeze({
@@ -254,11 +288,11 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
254
288
  if (schema === WORKSPACE_ARCHIVE_SCHEMA) {
255
289
  return archiveReport(raw, value, await readWorkspaceArchive(value), false, null);
256
290
  }
257
- if (schema === CASE_RESPONSE_PACKET_SCHEMA) {
258
- if (version !== LEGACY_CASE_RESPONSE_PACKET_VERSION && version !== CASE_RESPONSE_PACKET_VERSION) {
259
- throw new UnsupportedOfflineArtifactError('This case-response packet version is not supported.');
291
+ if (casePortabilityVerifier?.id === 'case-response-packet') {
292
+ if (!supportsCasePortabilityVersion(casePortabilityVerifier, version)) {
293
+ rejectUnsupportedCasePortabilityVersion('case-response packet', version, CASE_RESPONSE_PACKET_VERSION);
260
294
  }
261
- validateOfflineArtifactStructure(schema, value);
295
+ validateOfflineArtifactStructure(CASE_RESPONSE_PACKET_SCHEMA, value);
262
296
  const integrity = record(value.integrity);
263
297
  if (!integrity || !await verifyCaseResponsePacketIntegrity(value)) {
264
298
  throw new TypeError('The case-response packet failed its manifest integrity check.');
@@ -266,7 +300,7 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
266
300
  return Object.freeze({
267
301
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
268
302
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
269
- artifact: Object.freeze({ kind: 'case_response_packet', schema, version }),
303
+ artifact: Object.freeze({ kind: 'case_response_packet', schema: CASE_RESPONSE_PACKET_SCHEMA, version }),
270
304
  state: 'verified',
271
305
  checks: Object.freeze({
272
306
  structure: 'verified',
@@ -286,23 +320,22 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
286
320
  });
287
321
  }
288
322
  if (schema === INVESTIGATION_CAPSULE_SCHEMA) {
289
- if (version !== LEGACY_INVESTIGATION_CAPSULE_VERSION && version !== INVESTIGATION_CAPSULE_VERSION) {
323
+ if (!SUPPORTED_INVESTIGATION_CAPSULE_VERSIONS.some((candidate) => candidate === version)) {
290
324
  throw new UnsupportedOfflineArtifactError('This investigation-capsule version is not supported.');
291
325
  }
292
326
  validateInvestigationCapsuleStructure(value);
293
327
  const verification = await verifyInvestigationCapsule(value);
294
328
  if (!verification.valid)
295
329
  throw new TypeError('The investigation capsule failed its embedded projection integrity checks.');
296
- const current = version === INVESTIGATION_CAPSULE_VERSION;
297
330
  return Object.freeze({
298
331
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
299
332
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
300
333
  artifact: Object.freeze({ kind: 'investigation_capsule', schema, version }),
301
- state: current ? 'verified' : 'integrity_valid',
334
+ state: 'verified',
302
335
  checks: Object.freeze({
303
336
  structure: 'verified',
304
337
  contentIntegrity: 'verified',
305
- contentIntegrityScope: current ? 'whole_artifact' : 'embedded_projections',
338
+ contentIntegrityScope: 'whole_artifact',
306
339
  authenticatedEncryption: 'not_applicable',
307
340
  }),
308
341
  summary: Object.freeze({
@@ -312,9 +345,7 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
312
345
  ciphertextBytes: null,
313
346
  }),
314
347
  limitations: Object.freeze([
315
- ...(current
316
- ? ['The whole capsule matches its declared digest, including metadata and the linked source-contract projection digests. The non-embedded Lookup evidence remains linked by digest and must be retained separately.']
317
- : ['The embedded brief, graph, and optional analyst-record projections match their declared digests. Capsule metadata and the linked Lookup evidence are outside those projection digests and must not be treated as whole-file integrity verified.']),
348
+ 'The whole capsule matches its declared digest, including metadata and the linked source-contract projection digests. The non-embedded Lookup evidence remains linked by digest and must be retained separately.',
318
349
  'Digest verification detects changed content but does not authenticate the analyst, signer, collection source, or truth of retained observations and assertions.',
319
350
  ]),
320
351
  });
@@ -352,10 +383,10 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
352
383
  });
353
384
  }
354
385
  if (schema === SAVED_LOOKUP_SCHEMA) {
355
- if (version !== SAVED_LOOKUP_SCHEMA_VERSION) {
386
+ if (!SUPPORTED_SAVED_LOOKUP_SCHEMA_VERSIONS.some((supported) => supported === version)) {
356
387
  throw new UnsupportedOfflineArtifactError('This saved Lookup document version is not supported.');
357
388
  }
358
- const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artefact' });
389
+ parseCliLookupDocument(raw, { label: 'Saved Lookup artefact' });
359
390
  return Object.freeze({
360
391
  schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
361
392
  version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
@@ -374,7 +405,7 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
374
405
  ciphertextBytes: null,
375
406
  }),
376
407
  limitations: Object.freeze([
377
- `The saved ${document.mode} Lookup matches its versioned structural contract, but this document format has no embedded checksum or signature. Structural validity does not prove that its evidence is accurate, current, or unchanged since collection.`,
408
+ 'The saved Lookup matches its versioned structural contract, but this document format has no embedded checksum or signature. Structural validity does not prove that its evidence is accurate, current, or unchanged since collection.',
378
409
  ]),
379
410
  });
380
411
  }
@@ -409,15 +440,13 @@ async function verifyManifestIdentity(artifactRaw, manifestRaw, entryId) {
409
440
  throw new TypeError('The requested investigation manifest entry was not found.');
410
441
  const artifactValue = parseJson(artifactRaw);
411
442
  const metadata = artifactMetadata(artifactValue);
412
- const manifestVersion = artifactVersion(manifest);
413
- const canonicalization = manifestVersion === 1 ? SORTED_JSON_V1 : SORTED_JSON_V2;
414
443
  const actualByteLength = inputBytes(artifactRaw);
415
444
  const expectedByteLength = Number(item.byteLength);
416
445
  const checks = Object.freeze({
417
446
  manifestIntegrity: 'verified',
418
447
  byteLength: actualByteLength === expectedByteLength ? 'verified' : 'mismatch',
419
448
  rawContentDigest: rawContentDigest(artifactRaw) === item.contentDigestSha256 ? 'verified' : 'mismatch',
420
- canonicalDigest: rawContentDigest(canonicalArtifactJsonFor(artifactValue, canonicalization)) === item.canonicalDigestSha256 ? 'verified' : 'mismatch',
449
+ canonicalDigest: rawContentDigest(canonicalArtifactJsonFor(artifactValue, SORTED_JSON_V2)) === item.canonicalDigestSha256 ? 'verified' : 'mismatch',
421
450
  schema: metadata.schema === item.schema ? 'verified' : 'mismatch',
422
451
  version: metadata.version === item.version ? 'verified' : 'mismatch',
423
452
  });
@@ -437,7 +466,7 @@ async function verifyManifestIdentity(artifactRaw, manifestRaw, entryId) {
437
466
  : 'mismatch';
438
467
  return Object.freeze({
439
468
  state,
440
- manifest: Object.freeze({ schema: INVESTIGATION_MANIFEST_SCHEMA, version: manifestVersion, entryId }),
469
+ manifest: Object.freeze({ schema: INVESTIGATION_MANIFEST_SCHEMA, version: INVESTIGATION_MANIFEST_VERSION, entryId }),
441
470
  checks,
442
471
  expectedByteLength,
443
472
  actualByteLength,
@@ -1 +1 @@
1
- export { MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES, parseBoundedJsonObject, scanBoundedJson, } from "../lib/bounded-json.mjs";
1
+ export { MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES, parseBoundedJson, parseBoundedJsonObject, scanBoundedJson, } from "../lib/bounded-json.mjs";
@@ -2,11 +2,12 @@ import { createHash } from 'node:crypto';
2
2
  import { lstat } from 'node:fs/promises';
3
3
  import { recordOrNull } from "../lib/bounded-contract-normalizers.mjs";
4
4
  import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
5
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
6
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
5
7
  import { boundedCliInputError, CliUsageError } from "./errors.mjs";
6
8
  import { writePrivateFile } from "./output-file.mjs";
7
9
  export const CLI_BULK_CHECKPOINT_SCHEMA = 'whoisleuth.cli.bulk-checkpoint';
8
10
  export const CLI_BULK_CHECKPOINT_VERSION = 2;
9
- const SUPPORTED_BULK_CHECKPOINT_VERSIONS = new Set([1, CLI_BULK_CHECKPOINT_VERSION]);
10
11
  export const MAX_BULK_CHECKPOINT_BYTES = 16 * 1024 * 1024;
11
12
  const MAX_CHECKPOINT_JSON_DEPTH = 12;
12
13
  const MAX_CHECKPOINT_OBJECT_KEYS = 256;
@@ -19,8 +20,8 @@ function checkpointDigest(queries, deep) {
19
20
  .update(queries.join('\n'))
20
21
  .digest('hex');
21
22
  }
22
- function validTimestamp(value) {
23
- return typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value));
23
+ function normalizedTimestamp(value) {
24
+ return normalizeExplicitIsoTimestamp(value);
24
25
  }
25
26
  function isBoundedCheckpointJson(value, depth = 0) {
26
27
  if (depth > MAX_CHECKPOINT_JSON_DEPTH)
@@ -53,7 +54,7 @@ function normalizeCompactResult(value) {
53
54
  return null;
54
55
  return result;
55
56
  }
56
- function normalizeCheckpointResult(value, queries, classifyQuery, version = CLI_BULK_CHECKPOINT_VERSION) {
57
+ function normalizeCheckpointResult(value, queries, classifyQuery) {
57
58
  const item = recordOrNull(value);
58
59
  const index = item?.index;
59
60
  if (!Number.isSafeInteger(index) || Number(index) < 0 || Number(index) >= queries.length)
@@ -61,11 +62,9 @@ function normalizeCheckpointResult(value, queries, classifyQuery, version = CLI_
61
62
  const query = queries[Number(index)];
62
63
  if (typeof item?.query !== 'string' || item.query !== query)
63
64
  return null;
64
- const observedAt = version >= 2
65
- ? item.observedAt === null
66
- ? null
67
- : validTimestamp(item.observedAt) ? new Date(item.observedAt).toISOString() : undefined
68
- : null;
65
+ const observedAt = item.observedAt === null
66
+ ? null
67
+ : normalizedTimestamp(item.observedAt) ?? undefined;
69
68
  if (observedAt === undefined)
70
69
  return null;
71
70
  if (item.ok === false) {
@@ -86,33 +85,37 @@ function parseBulkCheckpoint(text, options) {
86
85
  if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > MAX_BULK_CHECKPOINT_BYTES) {
87
86
  throw new CliUsageError(`Bulk checkpoint input is limited to ${MAX_BULK_CHECKPOINT_BYTES} bytes.`);
88
87
  }
88
+ const normalized = text.replace(/^\uFEFF/u, '');
89
89
  let parsed;
90
90
  try {
91
- parsed = JSON.parse(text.replace(/^\uFEFF/u, ''));
91
+ scanBoundedJson(normalized);
92
+ parsed = JSON.parse(normalized);
92
93
  }
93
94
  catch {
94
- throw new CliUsageError('Bulk checkpoint must be valid JSON.');
95
+ throw new CliUsageError('Bulk checkpoint must be valid bounded JSON without duplicate keys.');
95
96
  }
96
97
  const document = recordOrNull(parsed);
97
98
  const expectedDigest = checkpointDigest(options.queries, options.deep);
98
99
  const version = typeof document?.version === 'number' && Number.isSafeInteger(document.version)
99
100
  ? document.version
100
101
  : 0;
101
- if (document?.schema !== CLI_BULK_CHECKPOINT_SCHEMA || !SUPPORTED_BULK_CHECKPOINT_VERSIONS.has(version)) {
102
- throw new CliUsageError(`Bulk checkpoint must use ${CLI_BULK_CHECKPOINT_SCHEMA} version 1 or ${CLI_BULK_CHECKPOINT_VERSION}.`);
102
+ if (document?.schema !== CLI_BULK_CHECKPOINT_SCHEMA || version !== CLI_BULK_CHECKPOINT_VERSION) {
103
+ throw new CliUsageError(`Bulk checkpoint must use ${CLI_BULK_CHECKPOINT_SCHEMA} version ${CLI_BULK_CHECKPOINT_VERSION}.`);
103
104
  }
104
105
  if (document.mode !== (options.deep ? 'deep' : 'fast')
105
106
  || document.inputDigestSha256 !== expectedDigest
106
107
  || document.queryCount !== options.queries.length) {
107
108
  throw new CliUsageError('Bulk checkpoint does not match the current input or scan mode.');
108
109
  }
109
- if (!validTimestamp(document.startedAt) || !validTimestamp(document.updatedAt) || !Array.isArray(document.results)) {
110
+ const startedAt = normalizedTimestamp(document.startedAt);
111
+ const updatedAt = normalizedTimestamp(document.updatedAt);
112
+ if (!startedAt || !updatedAt || !Array.isArray(document.results)) {
110
113
  throw new CliUsageError('Bulk checkpoint metadata is invalid.');
111
114
  }
112
115
  const results = [];
113
116
  const seen = new Set();
114
117
  for (const candidate of document.results.slice(0, options.queries.length + 1)) {
115
- const result = normalizeCheckpointResult(candidate, options.queries, options.classifyQuery, version);
118
+ const result = normalizeCheckpointResult(candidate, options.queries, options.classifyQuery);
116
119
  if (!result || seen.has(result.index))
117
120
  throw new CliUsageError('Bulk checkpoint contains an invalid or duplicate result.');
118
121
  seen.add(result.index);
@@ -124,8 +127,8 @@ function parseBulkCheckpoint(text, options) {
124
127
  mode: options.deep ? 'deep' : 'fast',
125
128
  inputDigestSha256: expectedDigest,
126
129
  queryCount: options.queries.length,
127
- startedAt: new Date(document.startedAt).toISOString(),
128
- updatedAt: new Date(document.updatedAt).toISOString(),
130
+ startedAt,
131
+ updatedAt,
129
132
  results: results.sort((left, right) => left.index - right.index),
130
133
  };
131
134
  }
@@ -164,14 +167,20 @@ async function createBulkCheckpointWriter(options) {
164
167
  const resumed = options.resume
165
168
  ? parseBulkCheckpoint(await readCheckpointFile(options.path), options)
166
169
  : null;
167
- const startedAt = resumed?.startedAt || now();
170
+ const currentTimestamp = () => {
171
+ const timestamp = normalizedTimestamp(now());
172
+ if (!timestamp)
173
+ throw new CliUsageError('Bulk checkpoint collection time is invalid.');
174
+ return timestamp;
175
+ };
176
+ const startedAt = resumed?.startedAt || currentTimestamp();
168
177
  const results = new Map((resumed?.results || []).map((item) => [item.index, item]));
169
178
  let activeWrite = null;
170
179
  let dirty = false;
171
180
  let writeFailure = null;
172
181
  let created = resumed !== null;
173
182
  function document() {
174
- const updatedAt = now();
183
+ const updatedAt = currentTimestamp();
175
184
  return {
176
185
  schema: CLI_BULK_CHECKPOINT_SCHEMA,
177
186
  version: CLI_BULK_CHECKPOINT_VERSION,
@@ -226,7 +235,11 @@ async function createBulkCheckpointWriter(options) {
226
235
  return Object.freeze({
227
236
  initialResults: Object.freeze([...results.values()].sort((left, right) => left.index - right.index)),
228
237
  record(result) {
229
- const observedAt = validTimestamp(result.observedAt) ? new Date(result.observedAt).toISOString() : now();
238
+ const observedAt = normalizedTimestamp(result.observedAt) ?? normalizedTimestamp(now());
239
+ if (!observedAt) {
240
+ writeFailure ||= new CliUsageError('Bulk checkpoint collection time is invalid.');
241
+ return;
242
+ }
230
243
  const normalized = normalizeCheckpointResult({ ...result, observedAt }, options.queries, options.classifyQuery);
231
244
  if (!normalized) {
232
245
  writeFailure ||= new CliUsageError('Bulk checkpoint result is not a bounded compact lookup result.');
package/cli/bulk.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { abortable } from "../lib/abort.mjs";
3
+ import { decodeBoundedUtf8 } from "../lib/bounded-file.mjs";
3
4
  import { CliUsageError } from "./arguments.mjs";
5
+ import { hasUnsafeCliText } from "./errors.mjs";
4
6
  import { MAX_DEEP_BULK_QUERIES, MAX_FAST_BULK_QUERIES, } from "../lib/bulk-limits.mjs";
5
7
  const MAX_BULK_INPUT_BYTES = 1024 * 1024;
6
8
  async function readTextStreamBounded(stream, limit = MAX_BULK_INPUT_BYTES, signal) {
@@ -26,7 +28,12 @@ async function readTextStreamBounded(stream, limit = MAX_BULK_INPUT_BYTES, signa
26
28
  finally {
27
29
  signal?.removeEventListener('abort', abort);
28
30
  }
29
- return Buffer.concat(chunks).toString('utf8');
31
+ try {
32
+ return decodeBoundedUtf8(Buffer.concat(chunks), 'Bulk input');
33
+ }
34
+ catch (cause) {
35
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'Bulk input must contain valid UTF-8 text.');
36
+ }
30
37
  }
31
38
  function parseBulkQueries(text, { deep = false } = {}) {
32
39
  if (typeof text !== 'string')
@@ -43,7 +50,7 @@ function parseBulkQueries(text, { deep = false } = {}) {
43
50
  const query = line.trim();
44
51
  if (!query)
45
52
  continue;
46
- if (query.length > 1024 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(query)) {
53
+ if (query.length > 1024 || hasUnsafeCliText(query)) {
47
54
  throw new CliUsageError('Bulk input contains an overlong query or unsupported control character.');
48
55
  }
49
56
  const key = query.toLowerCase();