@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,4 +1,9 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { readBoundedRegularFile } from "../lib/bounded-file.mjs";
3
+ import { buildInvestigationPackage } from "../packages/investigation/investigation-package.mjs";
4
+ import { encryptInvestigationPackage } from "../packages/investigation/investigation-package-crypto.mjs";
5
+ import { writeInvestigationFolder, writePreparedEvidenceFolder } from "./investigation-folder.mjs";
6
+ import { buildInvestigationBagIt, prepareInvestigationBagIt } from "../packages/investigation/investigation-bagit.mjs";
2
7
  import { scanBoundedJson } from "../lib/bounded-json.mjs";
3
8
  import { MAX_ASSURANCE_INPUT_BYTES, buildDomainAssurance, formatDomainAssurance, } from "../lib/domain-assurance.mjs";
4
9
  import { MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES, buildDomainChangePacket, formatDomainChangePacket, } from "../lib/domain-change-packet.mjs";
@@ -12,7 +17,7 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
12
17
  import EXIT_CODES from "./exit-codes.mjs";
13
18
  import { MAX_EXTERNAL_OBSERVATION_MAPPING_BYTES, formatExternalObservationMapping, mapExternalObservations, } from "./external-observation-mapping.mjs";
14
19
  import { formatJsonDocument } from "./formatters/json.mjs";
15
- import { MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, buildInvestigationManifest, formatInvestigationManifest, } from "./investigation-manifest.mjs";
20
+ import { MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, buildInvestigationManifest, formatInvestigationManifest, investigationFileMediaType, } from "./investigation-manifest.mjs";
16
21
  import { buildOpenAssetModelBridge, formatOpenAssetModelBridge, } from "./open-asset-model-bridge.mjs";
17
22
  import { ASSURANCE_INLINE_COMMANDS } from "./inline-command-families.mjs";
18
23
  import { MAX_SHARING_REVIEW_BYTES, buildSharingReview, formatSharingReview, } from "./sharing-review.mjs";
@@ -23,6 +28,15 @@ async function runManifestCommand(args, dependencies, context) {
23
28
  let totalBytes = 0;
24
29
  try {
25
30
  for (const source of args.sources) {
31
+ if (args.package || args.folder) {
32
+ const content = dependencies.readBinaryArtifactInput ? await dependencies.readBinaryArtifactInput(source)
33
+ : await readBoundedRegularFile(source, { maximumBytes: MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, minimumBytes: 1, label: 'Package artefact input', ...(dependencies.signal ? { signal: dependencies.signal } : {}) });
34
+ if (content.byteLength > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES - totalBytes)
35
+ throw new CliUsageError('Package artefacts exceed the combined byte limit.');
36
+ totalBytes += content.byteLength;
37
+ artifacts.push({ content, mediaType: investigationFileMediaType(source) });
38
+ continue;
39
+ }
26
40
  const content = dependencies.readDiffInput
27
41
  ? await dependencies.readDiffInput(source)
28
42
  : await context.readInput(source, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, 'Manifest artefact input');
@@ -40,11 +54,32 @@ async function runManifestCommand(args, dependencies, context) {
40
54
  }
41
55
  let document;
42
56
  try {
43
- document = await buildInvestigationManifest({
57
+ if (args.package) {
58
+ const built = await (args.bagit ? buildInvestigationBagIt : buildInvestigationPackage)({ workflow: args.workflow, configurationDigestSha256: args.configurationDigestSha256, artifacts }, context.now(), context.packageVersion);
59
+ dependencies.signal?.throwIfAborted();
60
+ if (!context.writeBinaryOutput)
61
+ throw new CliUsageError('Package output requires an explicit file destination.');
62
+ const output = args.passphraseSource
63
+ ? await encryptInvestigationPackage(built.bytes, await context.readPassphraseSource(args.passphraseSource))
64
+ : built.bytes;
65
+ dependencies.signal?.throwIfAborted();
66
+ context.writeBinaryOutput(output);
67
+ return EXIT_CODES.SUCCESS;
68
+ }
69
+ const input = {
44
70
  workflow: args.workflow,
45
71
  configurationDigestSha256: args.configurationDigestSha256,
46
72
  artifacts,
47
- }, context.now(), context.packageVersion);
73
+ };
74
+ if (args.bagit && args.folder) {
75
+ const prepared = await prepareInvestigationBagIt(input, context.now(), context.packageVersion);
76
+ await writePreparedEvidenceFolder(args.folder, prepared.files, 'data', 'tagmanifest-sha512.txt', dependencies.signal);
77
+ document = prepared.manifest;
78
+ }
79
+ else
80
+ document = args.folder
81
+ ? await writeInvestigationFolder(args.folder, input, context.now(), context.packageVersion, dependencies.signal)
82
+ : await buildInvestigationManifest(input, context.now(), context.packageVersion);
48
83
  }
49
84
  catch (error) {
50
85
  throw new CliUsageError(boundedCliErrorMessage(error, 'Investigation manifest input is invalid'));
@@ -52,7 +87,7 @@ async function runManifestCommand(args, dependencies, context) {
52
87
  if (!args.quiet) {
53
88
  context.writeStdout(args.output === 'json'
54
89
  ? formatJsonDocument(document)
55
- : context.terminal(formatInvestigationManifest(document), args.color));
90
+ : context.terminal(`${args.folder ? 'Created a new private evidence folder.\n' : ''}${formatInvestigationManifest(document)}`, args.color));
56
91
  }
57
92
  return EXIT_CODES.SUCCESS;
58
93
  }
@@ -0,0 +1,57 @@
1
+ import { lstat, opendir, realpath } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readBoundedRegularFileWithin } from "../lib/bounded-file.mjs";
4
+ import { assertBagItSelection, bagItPath, bagItEntryMaximum, MAX_BAGIT_ENTRIES } from "../packages/interchange/bagit.mjs";
5
+ import { selectedEvidenceDirectory, evidenceDirectoryIdentity } from "./investigation-folder.mjs";
6
+ /** Bounded recursive enumeration; no symbolic links, special files or writes. */
7
+ export async function readBagItFolder(value, signal) {
8
+ const selected = selectedEvidenceDirectory(value);
9
+ const identity = await evidenceDirectoryIdentity(selected), root = await realpath(selected);
10
+ const directories = new Map([['', identity]]);
11
+ const entries = [];
12
+ const visit = async (relative) => {
13
+ signal?.throwIfAborted();
14
+ const path = join(root, relative);
15
+ if (await realpath(path) !== path)
16
+ throw new TypeError('BagIt directories must not contain symbolic links.');
17
+ for await (const item of await opendir(path)) {
18
+ signal?.throwIfAborted();
19
+ if (entries.length >= MAX_BAGIT_ENTRIES)
20
+ throw new TypeError('BagIt exceeds its entry limit.');
21
+ const name = `${relative ? `${relative}/` : ''}${item.name}`;
22
+ bagItPath(item.isDirectory() ? `${name}/` : name);
23
+ const stat = await lstat(join(root, name));
24
+ if (item.isDirectory() && stat.isDirectory() && !stat.isSymbolicLink()) {
25
+ directories.set(name, await evidenceDirectoryIdentity(join(root, name)));
26
+ entries.push({ path: `${name}/`, byteLength: 0 });
27
+ await visit(name);
28
+ }
29
+ else if (item.isFile() && stat.isFile() && !stat.isSymbolicLink()) {
30
+ if (stat.size > bagItEntryMaximum(name))
31
+ throw new TypeError('BagIt entry exceeds its byte limit.');
32
+ entries.push({ path: name, byteLength: stat.size });
33
+ }
34
+ else
35
+ throw new TypeError('BagIt entries must be ordinary files and directories, not symbolic links or special files.');
36
+ }
37
+ };
38
+ await visit('');
39
+ assertBagItSelection(entries);
40
+ const files = new Map();
41
+ for (const entry of entries) {
42
+ signal?.throwIfAborted();
43
+ files.set(entry.path, entry.path.endsWith('/') ? new Uint8Array() : await readBoundedRegularFileWithin(root, entry.path, {
44
+ maximumBytes: bagItEntryMaximum(entry.path), minimumBytes: 0, expectedBytes: entry.byteLength,
45
+ label: 'BagIt file', ...(signal ? { signal } : {}),
46
+ }));
47
+ }
48
+ for (const [path, before] of directories) {
49
+ signal?.throwIfAborted();
50
+ const currentPath = path ? join(root, path) : selected;
51
+ const after = await evidenceDirectoryIdentity(currentPath);
52
+ if (await realpath(currentPath) !== join(root, path) || before.device !== after.device || before.inode !== after.inode
53
+ || before.modified !== after.modified || before.changed !== after.changed)
54
+ throw new TypeError('BagIt folder changed while it was read.');
55
+ }
56
+ return files;
57
+ }
@@ -0,0 +1,23 @@
1
+ import { inspectBagItEntries, readBagItZip } from "../packages/interchange/bagit.mjs";
2
+ import { OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, OFFLINE_ARTIFACT_VERIFICATION_VERSION } from "./artifact-verify.mjs";
3
+ export async function verifyOfflineBagIt(input) {
4
+ const files = input instanceof Uint8Array ? readBagItZip(input) : input;
5
+ const zipBytes = input instanceof Uint8Array ? input.byteLength : null;
6
+ const { review, inputBytes } = await inspectBagItEntries(files);
7
+ return {
8
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
9
+ artifact: { kind: 'bagit', schema: null, version: '1.0' },
10
+ state: review.state === 'valid' ? 'integrity_valid' : 'partial',
11
+ checks: { structure: review.complete ? 'verified' : 'failed',
12
+ contentIntegrity: review.state === 'valid' ? 'verified' : review.state === 'invalid' ? 'failed' : 'not_checked',
13
+ contentIntegrityScope: 'manifest_and_files', authenticatedEncryption: 'not_applicable' },
14
+ summary: { inputBytes: zipBytes ?? inputBytes,
15
+ sectionCount: null, recordCount: review.entries.length, ciphertextBytes: null },
16
+ manifestIdentity: null, bagit: review,
17
+ limitations: [
18
+ 'BagIt verifies declared file checksums, not payload schemas, factual accuracy, source identity, signatures or trusted timestamps.',
19
+ 'Tag files without a checksum are not integrity-checked. No file is imported, executed or fetched; fetch.txt locations and original paths are omitted from this report.',
20
+ 'This bounded reader supports BagIt 1.0, UTF-8 text tags, SHA-256 and SHA-512, portable relative paths and the selected-file byte limits. Other encodings, algorithms and unsafe or platform-ambiguous paths are not silently accepted.',
21
+ ],
22
+ };
23
+ }
@@ -1 +1 @@
1
- export { MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES, parseBoundedJson, parseBoundedJsonObject, scanBoundedJson, } from "../lib/bounded-json.mjs";
1
+ export { boundedJsonLimitsForBytes, MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES, parseBoundedJson, parseBoundedJsonObject, scanBoundedJson, } from "../lib/bounded-json.mjs";
@@ -113,6 +113,8 @@ async function runBulkCommand(args, dependencies, context) {
113
113
  context.writeStdout(formatJsonLines(selectedItems, metadata));
114
114
  else if (args.output === 'csv')
115
115
  context.writeStdout(formatBulkCsv(selectedItems));
116
+ else if (args.output === 'csv_metadata')
117
+ context.writeStdout(formatBulkCsv(selectedItems, selectedDocument));
116
118
  else if (args.output === 'domains')
117
119
  context.writeStdout(formatBulkDomainList(selectedItems));
118
120
  else if (args.output === 'queries')
@@ -1,6 +1,25 @@
1
1
  import { cliCsvCell } from "./csv.mjs";
2
+ import { lookupDiagnosticStates } from "../lib/lookup-diagnostics.mjs";
3
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
2
4
  const REGISTERED_STATES = new Set(['expiring', 'for_sale', 'registered']);
3
5
  const MAX_DNS_VALUES_PER_TYPE = 100;
6
+ const BULK_CSV_METADATA_COLUMNS = Object.freeze([
7
+ 'source_schema', 'source_version', 'observed_at', 'report_generated_at',
8
+ 'collection_origin', 'scan_mode', 'diagnostics_version', 'source_health',
9
+ ]);
10
+ function bulkCsvMetadataValues(item, metadata) {
11
+ const diagnostics = record(item.diagnostics);
12
+ return [
13
+ metadata.schema,
14
+ metadata.version,
15
+ normalizeExplicitIsoTimestamp(item.observedAt) ?? 'unknown',
16
+ normalizeExplicitIsoTimestamp(metadata.generatedAt) ?? 'unknown',
17
+ item.collectionOrigin === 'current_run' || item.collectionOrigin === 'resumed_checkpoint' ? item.collectionOrigin : 'unknown',
18
+ metadata.mode,
19
+ Number.isSafeInteger(diagnostics.version) && Number(diagnostics.version) > 0 ? diagnostics.version : 'unknown',
20
+ JSON.stringify(lookupDiagnosticStates(item.diagnostics)),
21
+ ];
22
+ }
4
23
  function record(value) {
5
24
  return value && typeof value === 'object' && !Array.isArray(value)
6
25
  ? value
@@ -78,12 +97,18 @@ function bulkDnsSummary(item) {
78
97
  function booleanCell(value) {
79
98
  return value === null ? 'unknown' : value ? 'observed' : 'not_observed';
80
99
  }
81
- function formatBulkCsv(items) {
100
+ function formatBulkCsv(items, metadata) {
101
+ if (metadata && metadata.results.length !== items.length)
102
+ throw new TypeError('CSV metadata does not match the selected result count.');
82
103
  const header = [
83
104
  'query', 'domain', 'outcome', 'availability', 'confidence', 'dns_status',
84
105
  'a', 'aaaa', 'ns', 'mx', 'null_mx', 'spf', 'dmarc', 'error',
106
+ ...(metadata ? BULK_CSV_METADATA_COLUMNS : []),
85
107
  ];
86
- const rows = items.map((item) => {
108
+ const rows = items.map((item, index) => {
109
+ const retained = metadata?.results[index];
110
+ if (metadata && (!retained || retained.index !== item.index || retained.query !== item.query))
111
+ throw new TypeError('CSV metadata does not match the selected result identity.');
87
112
  const availability = item.ok ? record(record(item.result).availability) : {};
88
113
  const dns = bulkDnsSummary(item);
89
114
  return [
@@ -101,6 +126,7 @@ function formatBulkCsv(items) {
101
126
  booleanCell(dns.hasSpf),
102
127
  booleanCell(dns.hasDmarc),
103
128
  item.ok ? '' : item.error,
129
+ ...(metadata && retained ? bulkCsvMetadataValues(retained, metadata) : []),
104
130
  ].map(cliCsvCell).join(',');
105
131
  });
106
132
  return `${[header.join(','), ...rows].join('\n')}\n`;
@@ -114,4 +140,4 @@ function formatBulkDomainList(items) {
114
140
  function formatBulkQueryList(items) {
115
141
  return items.length ? `${items.map((item) => item.query).join('\n')}\n` : '';
116
142
  }
117
- export { REGISTERED_STATES, availabilityState, bulkDnsSummary, formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems, };
143
+ export { REGISTERED_STATES, BULK_CSV_METADATA_COLUMNS, availabilityState, bulkDnsSummary, bulkCsvMetadataValues, formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems, };
@@ -0,0 +1,195 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "../lib/bounded-json.mjs";
3
+ import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
4
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
5
+ import { MAX_CASE_CHECKPOINT_FACTS, MAX_CASE_OBJECTIVE_LENGTH, MAX_CASE_STORE_BYTES, MAX_EDITABLE_CASE_INPUT_BYTES, MAX_NOTE_LENGTH, MAX_RESPONSE_RATIONALE_LENGTH, MAX_RESPONSE_VALUE_LENGTH } from "../packages/contracts/case-portability.mjs";
6
+ import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
7
+ import { createCaseIncident, openOrCreateCase, recordCaseConclusion, recordCaseRecheckOutcome, updateCase } from "../packages/cases/case-record-operations.mjs";
8
+ import { buildCaseExport, serializeCaseStore } from "../packages/cases/case-storage-model.mjs";
9
+ import { appendCaseEvidencePin } from "../packages/cases/case-response-model.mjs";
10
+ import { readCaseRecheckAnswerContext } from "../packages/cases/case-recheck-model.mjs";
11
+ import { dispositionLabel, statusLabel } from "../packages/cases/case-record-decisions.mjs";
12
+ import { normalizeDomain } from "../packages/cases/case-record-core.mjs";
13
+ import { safeTerminalValue } from "./formatters/terminal.mjs";
14
+ import { CliUsageError } from "./errors.mjs";
15
+ import EXIT_CODES from "./exit-codes.mjs";
16
+ function fields(input, allowed, label) {
17
+ if (Object.keys(input).some(key => !allowed.includes(key)))
18
+ throw new CliUsageError(`${label} contains an unsupported field.`);
19
+ }
20
+ function prose(input, maximum, label) {
21
+ if (typeof input !== 'string' || !input.trim() || input.trim().length > maximum
22
+ || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(input)) {
23
+ throw new CliUsageError(`${label} must contain 1–${maximum} characters without unsafe control characters.`);
24
+ }
25
+ return input.trim();
26
+ }
27
+ function selection(cases, args, required) {
28
+ const domain = args.domain === null ? null : normalizeDomain(args.domain);
29
+ if (args.domain !== null && !domain)
30
+ throw new CliUsageError('Select a valid Case domain.');
31
+ const selected = cases.filter(record => (!args.caseId || record.id === args.caseId) && (!domain || record.domain === domain));
32
+ if ((args.caseId || domain || required) && selected.length === 0)
33
+ throw new CliUsageError('The selected Case is not present in this file.');
34
+ if (required && selected.length !== 1)
35
+ throw new CliUsageError('Select exactly one Case with --case-id; no arbitrary Case was chosen.');
36
+ return selected;
37
+ }
38
+ function pin(input, now) {
39
+ if (!input || typeof input !== 'object' || Array.isArray(input))
40
+ throw new CliUsageError('An evidence pin must be a JSON object.');
41
+ const raw = input;
42
+ if (Object.hasOwn(raw, 'id') || Object.hasOwn(raw, 'createdAt'))
43
+ throw new CliUsageError('New pin identity and creation time are allocated by the Case operation.');
44
+ const normal = appendCaseEvidencePin([], raw, now)[0];
45
+ // Validate the supplied subset against the domain result, without duplicating
46
+ // its field inventory or accepting silent truncation/default substitution.
47
+ for (const [key, value] of Object.entries(raw)) {
48
+ if (!Object.hasOwn(normal, key) || canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(normal[key])) {
49
+ throw new CliUsageError(`Pin field ${safeTerminalValue(key)} is not canonical or exceeds its bound. Use explicit ISO timestamps and supported values; no pin was saved.`);
50
+ }
51
+ }
52
+ return raw;
53
+ }
54
+ function retainExistingEntries(before, after) {
55
+ const pairs = [
56
+ [before.notes, after.notes], [before.evidenceHistory, after.evidenceHistory], [before.evidencePins, after.evidencePins],
57
+ [before.decisions, after.decisions], [before.actions, after.actions], [before.assertions, after.assertions],
58
+ [before.manualTrail, after.manualTrail], [before.sightings, after.sightings],
59
+ [before.observedEffects.reviews, after.observedEffects.reviews], [before.closures.records, after.closures.records],
60
+ [before.branches ?? [], after.branches ?? []], [before.attachments ?? [], after.attachments ?? []],
61
+ ];
62
+ for (const [prior, next] of pairs) {
63
+ const retained = new Set(next.map(value => canonicalArtifactJsonV2(value)));
64
+ if (prior.some(value => !retained.has(canonicalArtifactJsonV2(value)))) {
65
+ throw new CliUsageError('The operation would remove or alter earlier retained evidence. Export a smaller working set or review it in the console; nothing was written.');
66
+ }
67
+ }
68
+ }
69
+ function mutate(cases, args, input, note, now) {
70
+ if (args.operation === 'open') {
71
+ const title = args.title === null ? undefined : prose(args.title, MAX_CASE_OBJECTIVE_LENGTH, 'Incident title');
72
+ const result = args.newIncident
73
+ ? createCaseIncident(cases, { domain: args.domain, title }, now)
74
+ : openOrCreateCase(cases, { domain: args.domain, ...(title ? { title } : {}) }, now, args.caseId ? { caseId: args.caseId } : {});
75
+ if (!result.created && title && title !== result.record.title)
76
+ throw new CliUsageError('The selected Case already exists with another title. Use --new-incident for a separate investigation.');
77
+ return result.cases;
78
+ }
79
+ const current = selection(cases, args, true)[0];
80
+ let result;
81
+ if (args.operation === 'note') {
82
+ result = updateCase(cases, current.id, { note: prose(note, MAX_NOTE_LENGTH, 'Case note') }, now);
83
+ }
84
+ else if (args.operation === 'pin') {
85
+ result = updateCase(cases, current.id, { evidencePin: pin(input, now) }, now);
86
+ }
87
+ else if (args.operation === 'assess') {
88
+ if (!input)
89
+ throw new CliUsageError('An assessment requires JSON input.');
90
+ fields(input, ['disposition', 'reviewReasonCode', 'summary', 'rationale', 'evidence'], 'Assessment');
91
+ if (!Array.isArray(input.evidence) || !input.evidence.length || input.evidence.length > MAX_CASE_CHECKPOINT_FACTS) {
92
+ throw new CliUsageError(`Select 1–${MAX_CASE_CHECKPOINT_FACTS} evidence facts for the assessment.`);
93
+ }
94
+ const evidence = input.evidence.map(value => {
95
+ if (!value || typeof value !== 'object' || Array.isArray(value))
96
+ throw new CliUsageError('Each assessment fact needs a pin and stance.');
97
+ fields(value, ['pin', 'pinId', 'stance'], 'Assessment evidence');
98
+ if (!['supports', 'contradicts', 'unresolved'].includes(value.stance))
99
+ throw new CliUsageError('Use supports, contradicts or unresolved for each evidence stance.');
100
+ if (Object.hasOwn(value, 'pin') === Object.hasOwn(value, 'pinId'))
101
+ throw new CliUsageError('Select one new pin or one retained pinId for each assessment fact.');
102
+ return Object.hasOwn(value, 'pinId') ? { pinId: value.pinId, stance: value.stance } : { pin: pin(value.pin, now), stance: value.stance };
103
+ });
104
+ const summary = prose(input.summary, MAX_RESPONSE_VALUE_LENGTH, 'Assessment summary');
105
+ const rationale = prose(input.rationale, MAX_RESPONSE_RATIONALE_LENGTH, 'Assessment rationale');
106
+ result = recordCaseConclusion(cases, current.id, { disposition: input.disposition, reviewReasonCode: input.reviewReasonCode, summary, rationale, evidence }, now);
107
+ if (result.record.decisions.at(-1)?.summary !== summary || result.record.decisions.at(-1)?.rationale !== rationale) {
108
+ throw new CliUsageError('Assessment text needs canonical single-line values; no assessment was written.');
109
+ }
110
+ }
111
+ else if (args.operation === 'recheck') {
112
+ if (!input)
113
+ throw new CliUsageError('A recheck requires JSON input.');
114
+ fields(input, ['state', 'observedAt', 'completeness', 'comparisonSummary', 'source', 'followUpAt', 'limitations', 'collectionDepth', 'recheck', 'observationHostname'], 'Recheck');
115
+ const recheck = readCaseRecheckAnswerContext(input.recheck);
116
+ if (input.state === 'not_reproduced' && !recheck) {
117
+ throw new CliUsageError('Not reproduced requires a saved recheck question and confirmed comparable conditions. Import the planned Case or record unavailable for a limited observation.');
118
+ }
119
+ const summary = prose(input.comparisonSummary, MAX_RESPONSE_VALUE_LENGTH, 'Comparison summary');
120
+ result = recordCaseRecheckOutcome(cases, current.id, {
121
+ ...input, state: input.state, observedAt: input.observedAt, completeness: input.completeness,
122
+ comparisonSummary: summary, source: input.source, ...(recheck ? { recheck } : {}),
123
+ }, now);
124
+ const review = result.record.observedEffects.reviews.at(-1);
125
+ const comparison = result.record.evidencePins.find(value => value.id === review.evidencePinId);
126
+ const projected = { state: review.state, observedAt: review.observedAt, completeness: review.completeness,
127
+ comparisonSummary: comparison.value, source: review.source, followUpAt: review.followUpAt,
128
+ limitations: review.limitations, collectionDepth: comparison.collectionDepth,
129
+ ...(review.recheck ? { recheck: review.recheck } : {}),
130
+ ...(comparison.observationHostname ? { observationHostname: comparison.observationHostname } : {}),
131
+ };
132
+ for (const [key, value] of Object.entries(input)) {
133
+ if (canonicalArtifactJsonV2(value) !== canonicalArtifactJsonV2(projected[key]))
134
+ throw new CliUsageError(`Recheck field ${safeTerminalValue(key)} would be repaired or truncated; nothing was written.`);
135
+ }
136
+ }
137
+ else
138
+ throw new CliUsageError('Select a supported Case mutation.');
139
+ retainExistingEntries(current, result.record);
140
+ return result.cases;
141
+ }
142
+ function formatCases(cases, digest) {
143
+ const lines = ['Local Case review', `File digest ${digest}`, `Cases ${cases.length}`, ''];
144
+ for (const record of cases) {
145
+ lines.push(`Case ${safeTerminalValue(record.id)} · ${safeTerminalValue(record.title || record.domain)}`, `Domain ${safeTerminalValue(record.domain)}`, `Status ${statusLabel(record.status)}`, `Disposition ${dispositionLabel(record.disposition)} (analyst assessment)`, `Updated ${record.updatedAt}`);
146
+ for (const note of record.notes)
147
+ lines.push(`Note ${note.createdAt}`, safeTerminalValue(note.body, '—', MAX_NOTE_LENGTH));
148
+ for (const pin of record.evidencePins)
149
+ lines.push(`Pin ${safeTerminalValue(pin.id)} · ${safeTerminalValue(pin.label)}`, safeTerminalValue(pin.value, '—', MAX_RESPONSE_VALUE_LENGTH), ` Source ${safeTerminalValue(pin.source)} · ${safeTerminalValue(pin.observedAt, 'time unavailable')} · ${pin.completeness}`);
150
+ for (const decision of record.decisions)
151
+ lines.push(`Assessment ${decision.createdAt}`, safeTerminalValue(decision.summary, '—', MAX_RESPONSE_VALUE_LENGTH), safeTerminalValue(decision.rationale, '—', MAX_RESPONSE_RATIONALE_LENGTH));
152
+ for (const review of record.observedEffects.reviews)
153
+ lines.push(`Recheck ${safeTerminalValue(review.observedAt, 'time unavailable')} · ${review.state} · ${review.completeness} · ${safeTerminalValue(review.source)}`);
154
+ lines.push(`Other retained records: ${record.evidenceHistory.length} snapshots, ${record.actions.length} actions, ${record.assertions.length} assertions, ${record.attachments?.length ?? 0} file references.`, '');
155
+ }
156
+ lines.push('Use --json for the full Case export. Attached file bytes remain separate. This command makes no request.', '');
157
+ return lines.join('\n');
158
+ }
159
+ export async function runCaseCommand(args, dependencies, context) {
160
+ context.setFailureLabel('Local Case operation');
161
+ try {
162
+ const raw = args.source ? dependencies.caseFileInput ?? await context.readInput(args.source, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case file') : null;
163
+ const digest = raw === null ? null : `sha256:${createHash('sha256').update(raw).digest('hex')}`;
164
+ if (args.expectedFileDigest && args.expectedFileDigest !== digest)
165
+ throw new CliUsageError('The Case file differs from the reviewed file digest. Nothing was written.');
166
+ const cases = raw === null ? [] : readEditableCaseExport(raw);
167
+ const now = normalizeExplicitIsoTimestamp(context.now());
168
+ if (!now)
169
+ throw new CliUsageError('A valid current timestamp is required.');
170
+ if (args.operation === 'show') {
171
+ const selected = selection(cases, args, false);
172
+ context.writeStdout(args.output === 'json' ? `${JSON.stringify(buildCaseExport(selected, now))}\n` : context.terminal(formatCases(selected, digest), args.color));
173
+ return EXIT_CODES.SUCCESS;
174
+ }
175
+ const input = args.inputSource ? parseBoundedJsonObject(await context.readInput(args.inputSource, MAX_EDITABLE_CASE_INPUT_BYTES, 'Case operation input'), {
176
+ maximumBytes: MAX_EDITABLE_CASE_INPUT_BYTES, limits: boundedJsonLimitsForBytes(MAX_CASE_STORE_BYTES), label: 'Case operation input',
177
+ }) : null;
178
+ const note = args.noteSource ? await context.readInput(args.noteSource, MAX_NOTE_LENGTH * 4 + 4, 'Case note') : args.text;
179
+ const next = mutate(cases, args, input, note, now);
180
+ if (Buffer.byteLength(serializeCaseStore(next)) > MAX_CASE_STORE_BYTES)
181
+ throw new CliUsageError('The updated Case store exceeds its byte budget. No evidence was pruned and nothing was written.');
182
+ const output = `${JSON.stringify(buildCaseExport(next, now))}\n`;
183
+ // Apply the same strict admission to our output before atomic publication.
184
+ readEditableCaseExport(output);
185
+ dependencies.signal?.throwIfAborted();
186
+ context.writeStdout(output);
187
+ return EXIT_CODES.SUCCESS;
188
+ }
189
+ catch (cause) {
190
+ dependencies.signal?.throwIfAborted();
191
+ if (cause instanceof CliUsageError)
192
+ throw cause;
193
+ throw new CliUsageError(cause instanceof Error ? cause.message : 'The Case operation could not be completed.');
194
+ }
195
+ }
package/cli/case-pack.mjs CHANGED
@@ -1,12 +1,13 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { Buffer } from 'node:buffer';
3
3
  import { canonicalArtifactJson, canonicalArtifactJsonV2, SORTED_JSON_V2, } from "../packages/evidence/artifact-integrity.mjs";
4
- import { buildCaseReport } from "../packages/cases/case-report.mjs";
4
+ import { buildCaseReport, buildCaseReportVerificationProjection } from "../packages/cases/case-report.mjs";
5
5
  import { WHOISLEUTH_APPLICATION_VERSION } from "../lib/application-version.mjs";
6
6
  import { assertBoundedJsonStructure, scanBoundedJson } from "../lib/bounded-json.mjs";
7
- import { assertCaseBrandProfileIds, CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, CASE_RECORD_FIELD_NAMES, caseAudienceExclusions, normalizeDomain, normalizeCaseStore, projectCaseForAudience, safeId, } from "../packages/cases/case-model.mjs";
8
- import { CASE_REPORT_SCHEMA, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS as INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS as PACKET_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS as PACKET_KEYS, CLI_CASE_PACK_REPORT_KEYS as REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS as ROOT_KEYS, MAX_CASE_IMPORT_BYTES, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, PUBLIC_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, } from "../packages/contracts/case-portability.mjs";
7
+ import { assertCaseBrandProfileIds, CASE_AUDIENCE_SENSITIVE_FIELD_NAMES, caseAudienceExclusions, normalizeDomain, normalizeCaseStore, projectCaseForAudience, safeId, } from "../packages/cases/case-model.mjs";
8
+ import { CASE_REPORT_SCHEMA, CASE_IMPORT_VERSIONS, CASE_SCHEMA_VERSION, INCIDENT_CASE_SCHEMA_VERSION, CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, CLI_CASE_PACK_INPUT_CASE_VERSIONS, CLI_CASE_PACK_CURRENT_REDACTION_KEYS, CLI_CASE_PACK_PUBLIC_REPORT_KEYS, CLI_CASE_PACK_INTEGRITY_KEYS as INTEGRITY_KEYS, CLI_CASE_PACK_LIMITATIONS as PACKET_LIMITATIONS, CLI_CASE_PACK_PACKET_KEYS as PACKET_KEYS, CLI_CASE_PACK_REPORT_KEYS as REPORT_KEYS, CLI_CASE_PACK_ROOT_KEYS as ROOT_KEYS, MAX_CASE_IMPORT_BYTES, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, PUBLIC_CASE_SCHEMA_VERSION, PUBLISHED_V2_3_CASE_SCHEMA_VERSION, caseReportVersionMatchesCase, } from "../packages/contracts/case-portability.mjs";
9
9
  import { CliUsageError } from "./errors.mjs";
10
+ import { canonicalCaseExportProjection as canonicalPackProjection } from "../packages/cases/case-export-input.mjs";
10
11
  export { CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, MAX_CASE_PACK_CASES, MAX_CASE_PACK_INPUT_BYTES, };
11
12
  const MAX_CASE_PACK_REFERENCE_SCAN_DEPTH = 64;
12
13
  function record(value) {
@@ -125,7 +126,7 @@ function canonicalValuesMatch(left, right) {
125
126
  return false;
126
127
  }
127
128
  }
128
- function assertCanonicalCaseIdentities(cases, label) {
129
+ function assertCanonicalCaseIdentities(cases, label, caseVersion) {
129
130
  const ids = new Set();
130
131
  const domains = new Set();
131
132
  for (const value of cases) {
@@ -136,23 +137,20 @@ function assertCanonicalCaseIdentities(cases, label) {
136
137
  || typeof item.domain !== 'string'
137
138
  || normalizeDomain(item.domain) !== item.domain
138
139
  || ids.has(item.id)
139
- || domains.has(item.domain)) {
140
+ || (caseVersion < INCIDENT_CASE_SCHEMA_VERSION && domains.has(item.domain))) {
140
141
  throw new TypeError(`${label} contains a missing, unsafe, non-canonical, or duplicate Case identity.`);
141
142
  }
142
143
  ids.add(item.id);
143
144
  domains.add(item.domain);
144
145
  }
145
146
  }
146
- function assertCurrentCaseProjection(rawCases, normalised, label) {
147
- const requiredFields = new Set(CASE_RECORD_FIELD_NAMES);
147
+ function assertCurrentCaseProjection(rawCases, normalised, label, caseVersion = CASE_SCHEMA_VERSION) {
148
148
  if (rawCases.length !== normalised.length || rawCases.some((item, index) => {
149
149
  const raw = record(item);
150
150
  return !raw
151
- || Object.keys(raw).length !== requiredFields.size
152
- || Object.keys(raw).some((key) => !requiredFields.has(key))
153
151
  || !canonicalValuesMatch(raw, normalised[index]);
154
152
  })) {
155
- throw new TypeError(`${label} contains a schema ${CASE_SCHEMA_VERSION} Case that would be repaired, truncated, or otherwise changed during normalisation.`);
153
+ throw new TypeError(`${label} contains a schema ${caseVersion} Case that would be repaired, truncated, or otherwise changed during normalisation.`);
156
154
  }
157
155
  }
158
156
  /** Historical packs keep their immutable wire shape, so only their retained audience guarantees are checked here. */
@@ -196,17 +194,17 @@ function assertLegacyAudienceFields(value, normalised, audience) {
196
194
  }
197
195
  }
198
196
  }
199
- function assertCurrentAudienceProjection(value, normalised, audience) {
200
- const expected = projectCaseForAudience(normalised, audience);
201
- for (const field of CASE_RECORD_FIELD_NAMES) {
197
+ function assertCurrentAudienceProjection(value, normalised, audience, caseVersion) {
198
+ const expected = canonicalPackProjection([projectCaseForAudience(normalised, audience)], caseVersion)[0];
199
+ for (const field of Object.keys(expected)) {
202
200
  if (!canonicalValuesMatch(value[field], expected[field])) {
203
201
  throw new TypeError(`The CLI case pack contains ${field} excluded by its audience or transformed incorrectly.`);
204
202
  }
205
203
  }
206
204
  }
207
- function assertAudienceProjection(value, normalised, audience, currentSchema) {
205
+ function assertAudienceProjection(value, normalised, audience, currentSchema, caseVersion) {
208
206
  if (currentSchema)
209
- assertCurrentAudienceProjection(value, normalised, audience);
207
+ assertCurrentAudienceProjection(value, normalised, audience, caseVersion);
210
208
  else
211
209
  assertLegacyAudienceFields(value, normalised, audience);
212
210
  }
@@ -217,11 +215,11 @@ function assertCurrentReportProjection(report, rawCase) {
217
215
  if (typeof report.generatedAt !== 'string') {
218
216
  throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
219
217
  }
220
- const expected = buildCaseReport(rawCase, {
218
+ const expected = buildCaseReportVerificationProjection(rawCase, {
221
219
  applicationVersion: reportApplicationVersion(report),
222
220
  includeNotes: false,
223
221
  generatedAt: report.generatedAt,
224
- }).json;
222
+ }, report.schemaVersion);
225
223
  if (!canonicalValuesMatch(report, expected)) {
226
224
  throw new TypeError('The CLI case pack contains an invalid or mismatched Case report projection.');
227
225
  }
@@ -241,17 +239,18 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
241
239
  throw new CliUsageError('Case-pack input must be valid bounded JSON without duplicate keys.');
242
240
  }
243
241
  const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
244
- if (Number.isSafeInteger(root.version) && root.version < CASE_SCHEMA_VERSION) {
245
- throw new CliUsageError(`Case schema ${String(root.version)} is retired. Export it as schema ${CASE_SCHEMA_VERSION} with the last broad-reader release before packaging; no data was changed.`);
242
+ const supportedInput = CLI_CASE_PACK_INPUT_CASE_VERSIONS.some((version) => version === root.version);
243
+ if (Number.isSafeInteger(root.version) && root.version < CASE_SCHEMA_VERSION && !supportedInput) {
244
+ throw new CliUsageError(`Case schema ${String(root.version)} cannot be packaged directly. This command accepts schemas ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(', ')}. Use a supported Case import to create a current export first; no data was changed.`);
246
245
  }
247
246
  if (Number.isSafeInteger(root.version) && root.version > CASE_SCHEMA_VERSION) {
248
247
  throw new CliUsageError(`Case schema ${String(root.version)} is newer than the supported schema ${CASE_SCHEMA_VERSION}; no data was changed.`);
249
248
  }
250
- if (root.version !== CASE_SCHEMA_VERSION || !Array.isArray(root.cases)) {
251
- throw new CliUsageError(`Case-pack input must be a well-formed WHOISleuth Case schema ${CASE_SCHEMA_VERSION} export.`);
249
+ if (!supportedInput || !Array.isArray(root.cases)) {
250
+ throw new CliUsageError(`Case-pack input must be a well-formed WHOISleuth Case export with schema ${CLI_CASE_PACK_INPUT_CASE_VERSIONS.join(' or ')}.`);
252
251
  }
253
252
  try {
254
- assertCanonicalCaseIdentities(root.cases, 'Case-pack input');
253
+ assertCanonicalCaseIdentities(root.cases, 'Case-pack input', root.version);
255
254
  for (const item of root.cases) {
256
255
  const rawCase = record(item);
257
256
  if (!rawCase || !Object.hasOwn(rawCase, 'brandProfileIds'))
@@ -275,7 +274,7 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
275
274
  throw new CliUsageError(`Case packs are limited to ${MAX_CASE_PACK_CASES} reviewed cases. Export a smaller selected set so no case is silently omitted.`);
276
275
  }
277
276
  try {
278
- assertCurrentCaseProjection(root.cases, normalised, 'Case-pack input');
277
+ assertCurrentCaseProjection(root.cases, canonicalPackProjection(normalised, root.version), 'Case-pack input', root.version);
279
278
  }
280
279
  catch (cause) {
281
280
  throw new CliUsageError(cause instanceof Error ? cause.message : `Case-pack schema ${CASE_SCHEMA_VERSION} input is not exact.`);
@@ -313,7 +312,7 @@ export function buildCliCasePack(input, options, generatedAt = new Date().toISOS
313
312
  }),
314
313
  });
315
314
  if (Buffer.byteLength(JSON.stringify(document), 'utf8') > MAX_CASE_IMPORT_BYTES) {
316
- throw new CliUsageError('The generated case pack exceeds the browser 2 MiB import limit. Select fewer cases or a more restrictive audience so no evidence is silently omitted.');
315
+ throw new CliUsageError(`The generated case pack exceeds the browser import limit of ${MAX_CASE_IMPORT_BYTES} bytes. Select fewer cases or a more restrictive audience so no evidence is silently omitted.`);
317
316
  }
318
317
  return document;
319
318
  }
@@ -382,15 +381,16 @@ export function verifyCliCasePack(input) {
382
381
  || !stringListsMatch(redactionManifest.excluded, caseAudienceExclusions(audience, root.version))) {
383
382
  throw new TypeError('The CLI case pack has an invalid audience redaction manifest.');
384
383
  }
385
- assertCanonicalCaseIdentities(root.cases, 'The CLI case pack');
384
+ assertCanonicalCaseIdentities(root.cases, 'The CLI case pack', root.version);
386
385
  const normalised = normalizeCaseStore(root).cases;
387
386
  if (normalised.length !== root.cases.length) {
388
387
  throw new TypeError('The CLI case pack contains an invalid case collection.');
389
388
  }
390
- const currentCaseSchema = root.version === CASE_SCHEMA_VERSION;
389
+ const currentCaseSchema = root.version >= PUBLISHED_V2_3_CASE_SCHEMA_VERSION;
390
+ const projected = canonicalPackProjection(normalised, root.version);
391
391
  if (currentCaseSchema)
392
- assertCurrentCaseProjection(root.cases, normalised, 'The CLI case pack');
393
- const normalisedByDomain = new Map(normalised.map((item) => [item.domain, item]));
392
+ assertCurrentCaseProjection(root.cases, projected, 'The CLI case pack', root.version);
393
+ const normalisedById = new Map(projected.map((item) => [item.id, item]));
394
394
  const caseReferenceLists = [];
395
395
  const reportReferenceLists = [];
396
396
  for (let index = 0; index < root.cases.length; index += 1) {
@@ -401,17 +401,18 @@ export function verifyCliCasePack(input) {
401
401
  || !report
402
402
  || !reportCase
403
403
  || typeof rawCase.domain !== 'string'
404
+ || typeof rawCase.id !== 'string'
404
405
  || report.schema !== CASE_REPORT_SCHEMA
405
406
  || !caseReportVersionMatchesCase(root.version, report.schemaVersion)
406
407
  || reportCase.id !== rawCase.id
407
408
  || reportCase.domain !== rawCase.domain
408
- || !normalisedByDomain.has(rawCase.domain)) {
409
+ || !normalisedById.has(rawCase.id)) {
409
410
  throw new TypeError('The CLI case pack contains an invalid or mismatched Case report.');
410
411
  }
411
- const normalisedCase = normalisedByDomain.get(rawCase.domain);
412
+ const normalisedCase = normalisedById.get(rawCase.id);
412
413
  if (!normalisedCase)
413
414
  throw new TypeError('The CLI case pack contains an invalid Case projection.');
414
- assertAudienceProjection(rawCase, normalisedCase, audience, currentCaseSchema);
415
+ assertAudienceProjection(rawCase, normalisedCase, audience, currentCaseSchema, root.version);
415
416
  try {
416
417
  if (!Object.hasOwn(rawCase, 'brandProfileIds') || !Object.hasOwn(reportCase, 'brandProfileIds'))
417
418
  throw new Error('missing');
@@ -423,6 +424,13 @@ export function verifyCliCasePack(input) {
423
424
  }
424
425
  if (currentCaseSchema)
425
426
  assertCurrentReportProjection(report, normalisedCase);
427
+ else {
428
+ assertLegacyAudienceFields(reportCase, normalisedCase, audience);
429
+ const response = record(report.analystResponse);
430
+ if (!response)
431
+ throw new TypeError('The CLI case pack contains an invalid analyst-response report.');
432
+ assertLegacyAudienceFields(response, normalisedCase, audience);
433
+ }
426
434
  }
427
435
  const omitted = redactionManifest.brandProfileReferencesOmitted;
428
436
  if (!Object.hasOwn(redactionManifest, 'brandProfileReferencesOmitted')
@@ -28,6 +28,10 @@ function parseTokens(command, argv) {
28
28
  const positionals = [];
29
29
  for (let index = 0; index < argv.length; index += 1) {
30
30
  const argument = argv[index];
31
+ if (argument === '--') {
32
+ positionals.push(...argv.slice(index + 1));
33
+ break;
34
+ }
31
35
  if (!argument.startsWith('-')) {
32
36
  positionals.push(argument);
33
37
  continue;