@slicedearth/whoisleuth-cli 1.45.0 → 2.0.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 (268) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +74 -124
  4. package/SECURITY.md +6 -3
  5. package/bin/whoisleuth.mjs +7 -4
  6. package/cli/archive-inspect.mjs +28 -10
  7. package/cli/arguments.mjs +450 -173
  8. package/cli/artifact-structure.mjs +2507 -0
  9. package/cli/artifact-verify.mjs +286 -64
  10. package/cli/bounded-json.mjs +1 -0
  11. package/cli/bulk-checkpoint.mjs +35 -23
  12. package/cli/bulk-command-runner.mjs +2 -5
  13. package/cli/bulk.mjs +9 -2
  14. package/cli/case-pack.mjs +435 -46
  15. package/cli/ci-report.mjs +6 -1
  16. package/cli/collection-preflight.mjs +2 -2
  17. package/cli/command-catalogue.mjs +12 -3
  18. package/cli/command-reference.mjs +948 -106
  19. package/cli/completion.mjs +504 -218
  20. package/cli/config-profile.mjs +13 -3
  21. package/cli/ct-event-intake.mjs +32 -2
  22. package/cli/discover.mjs +7 -1
  23. package/cli/discovery-observation-snapshot.mjs +24 -28
  24. package/cli/discovery-scan-command-runner.mjs +13 -14
  25. package/cli/discovery-scan.mjs +17 -3
  26. package/cli/discovery-snapshot.mjs +15 -7
  27. package/cli/discovery-workflow.mjs +2 -3
  28. package/cli/doctor.mjs +2 -2
  29. package/cli/domain-control-monitor.mjs +215 -36
  30. package/cli/domain-control-observations.mjs +34 -17
  31. package/cli/errors.mjs +8 -3
  32. package/cli/evidence-command-runner.mjs +17 -11
  33. package/cli/evidence-signing.mjs +68 -50
  34. package/cli/export-evidence.mjs +11 -4
  35. package/cli/external-observation-mapping.mjs +3 -3
  36. package/cli/formatters/html.mjs +3 -3
  37. package/cli/formatters/json.mjs +20 -32
  38. package/cli/formatters/terminal.mjs +586 -22
  39. package/cli/input.mjs +62 -0
  40. package/cli/interactive-launcher.mjs +226 -0
  41. package/cli/interchange-report.mjs +226 -0
  42. package/cli/investigation-manifest.mjs +16 -17
  43. package/cli/investigation-plan.mjs +167 -16
  44. package/cli/investigation-run.mjs +80 -24
  45. package/cli/local-mmdb-review.mjs +45 -14
  46. package/cli/lookalike-calibration.mjs +3 -1
  47. package/cli/lookup-brief.mjs +13 -8
  48. package/cli/lookup-browser.mjs +844 -0
  49. package/cli/lookup-command-runner.mjs +96 -33
  50. package/cli/lookup-diff.mjs +11 -2
  51. package/cli/lookup-plan.mjs +1 -0
  52. package/cli/mail-review.mjs +206 -94
  53. package/cli/manual.mjs +5 -5
  54. package/cli/network-command-runner.mjs +78 -0
  55. package/cli/offline-evidence-review.mjs +45 -14
  56. package/cli/open-asset-model-bridge.mjs +1 -1
  57. package/cli/output-file.mjs +70 -14
  58. package/cli/page-compare.mjs +18 -10
  59. package/cli/progress-events.mjs +2 -1
  60. package/cli/progress.mjs +3 -3
  61. package/cli/registry-cohort.mjs +343 -61
  62. package/cli/registry-doctor.mjs +67 -28
  63. package/cli/registry-support.mjs +2 -1
  64. package/cli/retained-artifact-diff.mjs +375 -0
  65. package/cli/risk-calibration.mjs +89 -40
  66. package/cli/runner.mjs +322 -246
  67. package/cli/saved-lookup.mjs +439 -49
  68. package/cli/sharing-review.mjs +18 -7
  69. package/cli/source-reliability.mjs +75 -36
  70. package/cli/terminal-presentation.mjs +43 -18
  71. package/docs/cli-reference.md +125 -1334
  72. package/docs/cli.md +87 -236
  73. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -0
  74. package/frontend/src/lib/analysis/domain-control-records.js +3 -0
  75. package/lib/application-version.mjs +3 -0
  76. package/lib/availability.mjs +72 -52
  77. package/lib/bounded-contract-normalizers.mjs +2 -4
  78. package/lib/bounded-file.mjs +91 -4
  79. package/lib/bounded-json.mjs +287 -0
  80. package/lib/browser-library-profile.mjs +1 -1
  81. package/lib/certificate-extension-profile.mjs +2 -1
  82. package/lib/classify.mjs +56 -5
  83. package/lib/client-behavior-profile.mjs +1 -1
  84. package/lib/credential-surface-profile.mjs +1 -1
  85. package/lib/cryptographic-assurance.mjs +173 -0
  86. package/lib/ct-query.mjs +3 -3
  87. package/lib/ct-search.mjs +120 -64
  88. package/lib/distributed-operation-budget.mjs +4 -2
  89. package/lib/dns-delegation-health.mjs +9 -6
  90. package/lib/dns-intelligence.mjs +4 -3
  91. package/lib/dns-resolver-selection.mjs +6 -4
  92. package/lib/dnssec-chain-validation.mjs +1052 -0
  93. package/lib/dnssec-evidence-validation.mjs +23 -10
  94. package/lib/domain-assurance.mjs +12 -12
  95. package/lib/domain-change-packet.mjs +7 -5
  96. package/lib/domain-control-flight-recorder.mjs +442 -78
  97. package/lib/domain-control-manifest.mjs +314 -171
  98. package/lib/domain-posture-analysis.mjs +5 -2
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/encrypted-dns-contract.mjs +2 -3
  101. package/lib/evidence-export.mjs +1243 -88
  102. package/lib/evidence-report-markdown.mjs +3 -0
  103. package/lib/evidence-report.mjs +5 -0
  104. package/lib/external-intelligence-risk.mjs +26 -10
  105. package/lib/favicon.mjs +8 -5
  106. package/lib/feature-policy.mjs +1 -1
  107. package/lib/homepage-metadata-contract.mjs +290 -0
  108. package/lib/hostname.mjs +1 -1
  109. package/lib/html-signals.mjs +89 -5
  110. package/lib/http-evidence-bounds.mjs +3 -0
  111. package/lib/http-intelligence.mjs +332 -6
  112. package/lib/interchange-fidelity-registry.mjs +124 -0
  113. package/lib/local-geoip-evidence.mjs +2 -1
  114. package/lib/lookup-cache.mjs +6 -4
  115. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  116. package/lib/lookup.mjs +27 -4
  117. package/lib/observed-network-context.mjs +1 -1
  118. package/lib/outbound-identity.mjs +2 -3
  119. package/lib/page-fingerprints.mjs +2 -2
  120. package/lib/page-role-profile.mjs +1 -1
  121. package/lib/perceptual-hash.mjs +12 -4
  122. package/lib/portable-generator.mjs +29 -0
  123. package/lib/provider-policy-admission.mjs +11 -4
  124. package/lib/rdap-bootstrap.mjs +76 -16
  125. package/lib/rdap-client.mjs +20 -12
  126. package/lib/rdap-normalization.mjs +324 -80
  127. package/lib/rdap-registrar.mjs +5 -1
  128. package/lib/rdap-transport.mjs +2 -2
  129. package/lib/rdap-validation.mjs +65 -14
  130. package/lib/rdap.mjs +2 -2
  131. package/lib/registrable-domain.mjs +36 -0
  132. package/lib/registry-capabilities.mjs +3 -0
  133. package/lib/registry-capability-catalogue.mjs +12 -2
  134. package/lib/registry-comparison.mjs +28 -11
  135. package/lib/registry-insights.mjs +7 -0
  136. package/lib/response-policy.mjs +27 -4
  137. package/lib/risk-calibration-summary.mjs +340 -0
  138. package/lib/risk-scoring.mjs +4 -3
  139. package/lib/rpki-evidence.mjs +9 -8
  140. package/lib/safe-fetch.mjs +121 -50
  141. package/lib/security-txt.mjs +27 -4
  142. package/lib/service-binding-dns.mjs +6 -1
  143. package/lib/smtp-transport-review.mjs +787 -0
  144. package/lib/sslbl-certificates.generated.mjs +136 -134
  145. package/lib/sslbl-intelligence.mjs +6 -8
  146. package/lib/static-html-analysis.mjs +301 -4
  147. package/lib/structured-data-identity.mjs +51 -19
  148. package/lib/threat-intelligence-fixtures.mjs +2 -4
  149. package/lib/threat-intelligence-manifests.mjs +2 -7
  150. package/lib/threat-intelligence-runtime.mjs +5 -15
  151. package/lib/threat-intelligence-types.mjs +21 -1
  152. package/lib/threatfox-intelligence.mjs +7 -3
  153. package/lib/tls-intelligence.mjs +15 -9
  154. package/lib/tlsa-evidence.mjs +37 -7
  155. package/lib/urlhaus-intelligence.mjs +4 -2
  156. package/lib/urlscan-intelligence.mjs +6 -5
  157. package/lib/website-security-posture.mjs +1 -1
  158. package/lib/website-technology.mjs +1 -1
  159. package/lib/whois-authority.mjs +43 -8
  160. package/lib/whois-chain.mjs +25 -3
  161. package/lib/whois-normalization.mjs +3 -1
  162. package/lib/whois-transport.mjs +12 -3
  163. package/lib/zone-intent-review.mjs +1 -1
  164. package/package.json +12 -4
  165. package/packages/cases/case-brand-profile-references.mjs +85 -0
  166. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +43 -14
  167. package/packages/cases/case-investigation-branch-model.mjs +132 -0
  168. package/packages/cases/case-migration-model.mjs +476 -0
  169. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  170. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -19
  171. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  172. package/packages/cases/case-record-model.mjs +9 -0
  173. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +106 -33
  174. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +143 -16
  175. package/packages/cases/case-response-model.mjs +1604 -0
  176. package/packages/cases/case-response-packet.mjs +1312 -0
  177. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  178. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  179. package/packages/cases/opaque-reference-id.mjs +9 -0
  180. package/packages/comparison/comparison-ledger-bulk.mjs +366 -0
  181. package/packages/comparison/comparison-ledger-contract.mjs +241 -0
  182. package/packages/comparison/comparison-ledger-json.mjs +80 -0
  183. package/packages/comparison/comparison-ledger-serialization.mjs +52 -0
  184. package/packages/comparison/favicon-similarity.mjs +78 -0
  185. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  186. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  187. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  188. package/packages/contracts/analyst-review-state.mjs +50 -0
  189. package/packages/contracts/case-portability.mjs +1310 -0
  190. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  191. package/packages/contracts/cli-lookup.mjs +394 -0
  192. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  193. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  194. package/packages/contracts/domain-control-monitor.mjs +310 -0
  195. package/packages/contracts/domain-control-review.mjs +828 -0
  196. package/packages/contracts/domain-name.mjs +4 -0
  197. package/packages/contracts/external-observation-interchange.mjs +224 -0
  198. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  199. package/packages/contracts/investigation-portability.mjs +331 -0
  200. package/packages/contracts/offline-comparison.mjs +160 -0
  201. package/packages/contracts/risk-calibration.mjs +1288 -0
  202. package/packages/contracts/schema-compatibility.mjs +71 -0
  203. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  204. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  205. package/packages/contracts/workspace-portability.mjs +1347 -0
  206. package/packages/evidence/artifact-integrity.mjs +65 -0
  207. package/packages/evidence/decision-fact.mjs +735 -0
  208. package/packages/evidence/domain-control-runtime.mjs +547 -0
  209. package/packages/evidence/domain-name.mjs +41 -0
  210. package/packages/evidence/observation.mjs +384 -0
  211. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +86 -20
  212. package/packages/interchange/retained-text.mjs +8 -0
  213. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +11 -8
  214. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +28 -9
  215. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +67 -18
  216. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +10 -9
  217. package/packages/investigation/bulk-review-policy.mjs +1 -0
  218. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +50 -18
  219. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  220. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  221. package/packages/investigation/lookup-claim-passport.mjs +232 -0
  222. package/packages/investigation/lookup-claim-readiness.mjs +287 -0
  223. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  224. package/packages/monitoring/analyst-review-state.mjs +680 -0
  225. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +97 -50
  226. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +15 -15
  227. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +179 -39
  228. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +12 -5
  229. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  230. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  231. package/packages/workspace/hostile-input.mjs +172 -0
  232. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +51 -46
  233. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +15 -15
  234. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  235. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  236. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  237. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  238. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  239. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +18 -12
  240. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  241. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +195 -84
  242. package/third-party-notices.txt +112 -5834
  243. package/frontend/src/lib/analysis/artifact-integrity.js +0 -21
  244. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  245. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  246. package/frontend/src/lib/analysis/case-migration-model.js +0 -253
  247. package/frontend/src/lib/analysis/case-record-model.js +0 -7
  248. package/frontend/src/lib/analysis/case-response-model.js +0 -669
  249. package/frontend/src/lib/analysis/case-response-packet.js +0 -573
  250. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -487
  251. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  252. package/frontend/src/lib/analysis/lookup-presentation.js +0 -58
  253. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  254. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  255. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  256. package/frontend/src/lib/analysis/scoring.js +0 -55
  257. package/frontend/src/lib/analysis/utils.js +0 -232
  258. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  259. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  260. package/frontend/src/lib/analysis/visualization-matrix.js +0 -88
  261. package/frontend/src/lib/analysis/visualization-models.js +0 -369
  262. package/frontend/src/lib/analysis/visualization-time-series.js +0 -144
  263. package/lib/bounded-json-response.mjs +0 -157
  264. package/lib/lookup-response-contract.mjs +0 -346
  265. package/lib/observation.mjs +0 -114
  266. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  267. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  268. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
package/cli/input.mjs ADDED
@@ -0,0 +1,62 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { decodeBoundedUtf8, readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
3
+ import { CliUsageError } from "./errors.mjs";
4
+ function abortError(signal) {
5
+ return signal.reason instanceof Error
6
+ ? signal.reason
7
+ : new DOMException('The operation was aborted.', 'AbortError');
8
+ }
9
+ export async function readCliTextInput(source, stdin, options) {
10
+ if (options.signal?.aborted)
11
+ throw abortError(options.signal);
12
+ if (source) {
13
+ try {
14
+ return await readBoundedRegularTextFile(source, {
15
+ maximumBytes: options.maximumBytes,
16
+ label: options.label,
17
+ allowSymbolicLink: true,
18
+ ...(options.signal ? { signal: options.signal } : {}),
19
+ });
20
+ }
21
+ catch (error) {
22
+ if (options.signal?.aborted)
23
+ throw abortError(options.signal);
24
+ if (error instanceof CliUsageError)
25
+ throw error;
26
+ const message = error instanceof Error ? error.message : `${options.label} could not be read.`;
27
+ throw new CliUsageError(message);
28
+ }
29
+ }
30
+ if (!stdin || stdin.isTTY)
31
+ return '';
32
+ let bytes = 0;
33
+ const chunks = [];
34
+ const onAbort = () => {
35
+ if ('destroy' in stdin && typeof stdin.destroy === 'function')
36
+ stdin.destroy(abortError(options.signal));
37
+ };
38
+ options.signal?.addEventListener('abort', onAbort, { once: true });
39
+ try {
40
+ for await (const chunk of stdin) {
41
+ if (options.signal?.aborted)
42
+ throw abortError(options.signal);
43
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
44
+ bytes += buffer.length;
45
+ if (bytes > options.maximumBytes) {
46
+ throw new CliUsageError(`${options.label} is limited to ${options.maximumBytes} bytes.`);
47
+ }
48
+ chunks.push(buffer);
49
+ }
50
+ if (options.signal?.aborted)
51
+ throw abortError(options.signal);
52
+ try {
53
+ return decodeBoundedUtf8(Buffer.concat(chunks, bytes), options.label);
54
+ }
55
+ catch (cause) {
56
+ throw new CliUsageError(cause instanceof Error ? cause.message : `${options.label} must contain valid UTF-8 text.`);
57
+ }
58
+ }
59
+ finally {
60
+ options.signal?.removeEventListener('abort', onAbort);
61
+ }
62
+ }
@@ -0,0 +1,226 @@
1
+ import { classifyQuery, isDirectLookupTarget } from "../lib/classify.mjs";
2
+ import { StringDecoder } from 'node:string_decoder';
3
+ import { boundedCliErrorMessage, CliUsageError, hasUnsafeCliText } from "./errors.mjs";
4
+ import { INVESTIGATION_PLAN_RECIPE_LABELS, INVESTIGATION_PLAN_RECIPES, } from "./investigation-plan.mjs";
5
+ import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
6
+ import { canBrowseLookup, } from "./lookup-browser.mjs";
7
+ const MAX_INTERACTIVE_ANSWER_SCALARS = 1_024;
8
+ const MAX_INTERACTIVE_ANSWER_BYTES = 4_096;
9
+ function canLaunchInteractiveCli(input, output, environment = process.env) {
10
+ return input?.isTTY === true
11
+ && canBrowseLookup(input, output, environment);
12
+ }
13
+ function boundedInteractiveAnswer(value) {
14
+ const supplied = typeof value === 'string' ? value : '';
15
+ if (Buffer.byteLength(supplied, 'utf8') > MAX_INTERACTIVE_ANSWER_BYTES
16
+ || Array.from(supplied).length > MAX_INTERACTIVE_ANSWER_SCALARS
17
+ || hasUnsafeCliText(supplied)) {
18
+ throw new CliUsageError('Interactive input must be bounded text without control characters.');
19
+ }
20
+ return supplied.trim();
21
+ }
22
+ function abortError() {
23
+ return new DOMException('Aborted', 'AbortError');
24
+ }
25
+ function readBoundedInteractiveLine(prompt, options) {
26
+ const { input, output, signal } = options;
27
+ if (signal?.aborted)
28
+ return Promise.reject(signal.reason || abortError());
29
+ const previousRaw = input.isRaw === true;
30
+ const wasPaused = input.isPaused?.() === true;
31
+ let receivedBytes = 0;
32
+ let receivedScalars = 0;
33
+ let settled = false;
34
+ let characters = [];
35
+ const decoder = new StringDecoder('utf8');
36
+ return new Promise((resolve, reject) => {
37
+ const cleanup = () => {
38
+ let failure = null;
39
+ const attempt = (operation) => {
40
+ try {
41
+ operation();
42
+ }
43
+ catch (error) {
44
+ failure ||= error instanceof Error ? error : new Error('Interactive terminal cleanup failed.');
45
+ }
46
+ };
47
+ attempt(() => input.off?.('data', onData));
48
+ attempt(() => input.off?.('end', onEnd));
49
+ attempt(() => signal?.removeEventListener('abort', onAbort));
50
+ attempt(() => input.setRawMode?.(previousRaw));
51
+ if (wasPaused)
52
+ attempt(() => input.pause?.());
53
+ return failure;
54
+ };
55
+ const finish = (value, error) => {
56
+ if (settled)
57
+ return;
58
+ settled = true;
59
+ const cleanupFailure = cleanup();
60
+ characters = [];
61
+ if (error && cleanupFailure) {
62
+ reject(new AggregateError([error, cleanupFailure], `${error.message}; terminal cleanup also failed: ${cleanupFailure.message}`));
63
+ }
64
+ else if (error || cleanupFailure)
65
+ reject(error || cleanupFailure);
66
+ else
67
+ resolve(value || '');
68
+ };
69
+ const rejectUsage = () => finish(undefined, new CliUsageError('Interactive input must be bounded text without control characters.'));
70
+ const onData = (chunk) => {
71
+ if (settled)
72
+ return;
73
+ let rawChunk;
74
+ if (Buffer.isBuffer(chunk)) {
75
+ if (chunk.length > MAX_INTERACTIVE_ANSWER_BYTES - receivedBytes) {
76
+ rejectUsage();
77
+ return;
78
+ }
79
+ rawChunk = chunk;
80
+ }
81
+ else {
82
+ const supplied = String(chunk ?? '');
83
+ if (supplied.length > MAX_INTERACTIVE_ANSWER_BYTES - receivedBytes
84
+ || Buffer.byteLength(supplied, 'utf8') > MAX_INTERACTIVE_ANSWER_BYTES - receivedBytes) {
85
+ rejectUsage();
86
+ return;
87
+ }
88
+ rawChunk = Buffer.from(supplied, 'utf8');
89
+ }
90
+ receivedBytes += rawChunk.length;
91
+ const value = decoder.write(rawChunk);
92
+ receivedScalars += Array.from(value).length;
93
+ if (receivedBytes > MAX_INTERACTIVE_ANSWER_BYTES
94
+ || receivedScalars > MAX_INTERACTIVE_ANSWER_SCALARS
95
+ || value.includes('\ufffd')) {
96
+ rejectUsage();
97
+ return;
98
+ }
99
+ for (const character of value) {
100
+ if (character === '\r' || character === '\n') {
101
+ try {
102
+ output.write('\n');
103
+ }
104
+ catch (error) {
105
+ finish(undefined, error instanceof Error ? error : new Error('Interactive terminal output failed.'));
106
+ return;
107
+ }
108
+ finish(characters.join(''));
109
+ return;
110
+ }
111
+ if (character === '\u0003' || character === '\u0004' || character === '\u001b') {
112
+ finish(undefined, abortError());
113
+ return;
114
+ }
115
+ if (character === '\u0008' || character === '\u007f') {
116
+ if (characters.length) {
117
+ characters.pop();
118
+ try {
119
+ output.write('\b \b');
120
+ }
121
+ catch (error) {
122
+ finish(undefined, error instanceof Error ? error : new Error('Interactive terminal output failed.'));
123
+ return;
124
+ }
125
+ }
126
+ continue;
127
+ }
128
+ if (hasUnsafeCliText(character)) {
129
+ rejectUsage();
130
+ return;
131
+ }
132
+ characters.push(character);
133
+ try {
134
+ output.write(character);
135
+ }
136
+ catch (error) {
137
+ finish(undefined, error instanceof Error ? error : new Error('Interactive terminal output failed.'));
138
+ return;
139
+ }
140
+ }
141
+ };
142
+ const onEnd = () => {
143
+ const remainder = decoder.end();
144
+ if (remainder)
145
+ rejectUsage();
146
+ else
147
+ finish(undefined, abortError());
148
+ };
149
+ const onAbort = () => finish(undefined, signal?.reason instanceof Error ? signal.reason : abortError());
150
+ try {
151
+ output.write(prompt);
152
+ input.on?.('data', onData);
153
+ input.on?.('end', onEnd);
154
+ signal?.addEventListener('abort', onAbort, { once: true });
155
+ if (signal?.aborted) {
156
+ onAbort();
157
+ return;
158
+ }
159
+ input.setRawMode?.(true);
160
+ if (settled)
161
+ return;
162
+ input.resume?.();
163
+ }
164
+ catch (error) {
165
+ finish(undefined, error instanceof Error ? error : new Error('Interactive terminal input failed.'));
166
+ }
167
+ });
168
+ }
169
+ async function launchInteractiveCli(options) {
170
+ const environment = options.environment || process.env;
171
+ if (!canLaunchInteractiveCli(options.input, options.output, environment)) {
172
+ throw new CliUsageError('Interactive launch requires terminal input and output.');
173
+ }
174
+ if (options.signal?.aborted)
175
+ throw options.signal.reason || new DOMException('Aborted', 'AbortError');
176
+ const question = options.question || ((prompt) => readBoundedInteractiveLine(prompt, options));
177
+ const ask = async (prompt) => boundedInteractiveAnswer(await question(prompt));
178
+ const write = (value) => options.output.write(value);
179
+ write([
180
+ 'WHOISleuth interactive launch',
181
+ '',
182
+ ' 1 Fast Lookup',
183
+ ' 2 Deep Lookup',
184
+ ' 3 Build an investigation workflow plan (offline)',
185
+ ' 4 List commands',
186
+ ' q Exit',
187
+ '',
188
+ ].join('\n'));
189
+ const selection = (await ask('Select: ')).toLowerCase();
190
+ if (selection === 'q' || selection === 'quit' || selection === 'exit' || selection === '')
191
+ return null;
192
+ if (selection === '4')
193
+ return ['commands'];
194
+ if (selection === '3') {
195
+ write(`\n${INVESTIGATION_PLAN_RECIPES.map((recipe, index) => ` ${index + 1} ${INVESTIGATION_PLAN_RECIPE_LABELS[recipe]}`).join('\n')}\n\n`);
196
+ const recipeSelection = await ask('Workflow: ');
197
+ const recipe = INVESTIGATION_PLAN_RECIPES[Number(recipeSelection) - 1];
198
+ if (!recipe)
199
+ throw new CliUsageError('Choose one listed workflow number.');
200
+ const subject = await ask('Domain or brand subject: ');
201
+ if (!subject)
202
+ throw new CliUsageError('A workflow plan requires one domain or brand subject.');
203
+ return ['workflow-plan', recipe, subject];
204
+ }
205
+ if (selection !== '1' && selection !== '2')
206
+ throw new CliUsageError('Choose 1, 2, 3, 4, or q.');
207
+ const query = await ask('Domain, IP address, or ASN: ');
208
+ if (!isDirectLookupTarget(query)) {
209
+ throw new CliUsageError('Interactive Lookup requires an unambiguous domain with a recognised public suffix, public IP, or ASN.');
210
+ }
211
+ const deep = selection === '2';
212
+ let classified;
213
+ try {
214
+ classified = classifyQuery(query);
215
+ }
216
+ catch (error) {
217
+ throw new CliUsageError(boundedCliErrorMessage(error, 'Invalid Lookup target'));
218
+ }
219
+ const plan = buildCliLookupPlan(query, classified, deep);
220
+ write(`\n${formatCliLookupPlan(plan)}\n`);
221
+ const confirmed = (await ask('Start this collection and open the evidence browser? [y/N] ')).toLowerCase();
222
+ if (confirmed !== 'y' && confirmed !== 'yes')
223
+ return null;
224
+ return ['lookup', query, deep ? '--deep' : '--fast', '--browse'];
225
+ }
226
+ export { MAX_INTERACTIVE_ANSWER_BYTES, MAX_INTERACTIVE_ANSWER_SCALARS, boundedInteractiveAnswer, canLaunchInteractiveCli, launchInteractiveCli, readBoundedInteractiveLine, };
@@ -0,0 +1,226 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { INTERCHANGE_ARTIFACT_CONTRACTS, } from "../lib/interchange-fidelity-registry.mjs";
3
+ import { offlineArtifactSatisfiesAssurance, verifyOfflineArtifact, } from "./artifact-verify.mjs";
4
+ import { mergeBrandProfiles, } from "../packages/workspace/brand-profile-model.mjs";
5
+ import { parseBoundedJsonObject } from "./bounded-json.mjs";
6
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
7
+ export const INTERCHANGE_FIDELITY_REPORT_SCHEMA = 'whoisleuth.interchange-fidelity-report';
8
+ export const INTERCHANGE_FIDELITY_REPORT_VERSION = 2;
9
+ export const MAX_INTERCHANGE_REPORT_BYTES = 15 * 1024 * 1024;
10
+ function record(value) {
11
+ return value && typeof value === 'object' && !Array.isArray(value)
12
+ ? value
13
+ : null;
14
+ }
15
+ function parseInput(raw) {
16
+ if (typeof raw !== 'string')
17
+ throw new TypeError('Interchange input must be UTF-8 JSON text.');
18
+ const bytes = Buffer.byteLength(raw, 'utf8');
19
+ if (bytes < 1 || bytes > MAX_INTERCHANGE_REPORT_BYTES) {
20
+ throw new TypeError(`Interchange input must be between 1 byte and ${MAX_INTERCHANGE_REPORT_BYTES} bytes.`);
21
+ }
22
+ return parseBoundedJsonObject(raw.replace(/^\uFEFF/u, ''), {
23
+ label: 'Interchange input',
24
+ maximumBytes: MAX_INTERCHANGE_REPORT_BYTES,
25
+ });
26
+ }
27
+ function nestedRecord(value, path) {
28
+ let current = value;
29
+ for (const part of path)
30
+ current = record(current?.[part]);
31
+ return current;
32
+ }
33
+ function identify(value) {
34
+ for (const contract of INTERCHANGE_ARTIFACT_CONTRACTS) {
35
+ const container = nestedRecord(value, contract.nestedSchemaPath);
36
+ if (container?.schema !== contract.schema)
37
+ continue;
38
+ const declared = container[contract.versionField];
39
+ return {
40
+ contract,
41
+ version: Number.isSafeInteger(declared) && Number(declared) > 0 && Number(declared) <= 1000
42
+ ? Number(declared)
43
+ : null,
44
+ };
45
+ }
46
+ return null;
47
+ }
48
+ function timestamp(value) {
49
+ const normalized = normalizeExplicitIsoTimestamp(value);
50
+ if (!normalized)
51
+ throw new TypeError('Interchange report time is invalid.');
52
+ return normalized;
53
+ }
54
+ function validateBrandProfileExport(value, version) {
55
+ const profiles = value.profiles;
56
+ if (!Array.isArray(profiles) || profiles.length > 100) {
57
+ throw new TypeError('Brand Profile export does not contain a bounded profiles collection.');
58
+ }
59
+ const exportedAt = timestamp(value.exportedAt);
60
+ let nextId = 0;
61
+ const result = mergeBrandProfiles([], value, {
62
+ nowIso: exportedAt,
63
+ makeId: () => `interchange-profile-${++nextId}`,
64
+ });
65
+ return Object.freeze({ accepted: result.profiles.length, skipped: result.skipped });
66
+ }
67
+ export async function buildInterchangeFidelityReport(raw, options = {}) {
68
+ const inputBytes = Buffer.byteLength(raw, 'utf8');
69
+ const value = parseInput(raw);
70
+ const identified = identify(value);
71
+ const generatedAt = timestamp(options.generatedAt ?? new Date().toISOString());
72
+ if (!identified)
73
+ return Object.freeze({
74
+ schema: INTERCHANGE_FIDELITY_REPORT_SCHEMA,
75
+ version: INTERCHANGE_FIDELITY_REPORT_VERSION,
76
+ generatedAt,
77
+ recognised: false,
78
+ artifact: Object.freeze({ id: null, schema: null, version: null, versionSupported: false }),
79
+ verification: Object.freeze({ state: 'not_verified', requiredAssurance: null, assuranceSatisfied: false }),
80
+ compatibility: Object.freeze({
81
+ browser: null,
82
+ cli: null,
83
+ fidelity: 'not_verified',
84
+ preservedFieldGroups: Object.freeze([]),
85
+ excludedFieldGroups: Object.freeze([]),
86
+ futureVersionBehaviour: null,
87
+ fullyImportable: null,
88
+ }),
89
+ summary: Object.freeze({
90
+ inputBytes, recordCount: null, sectionCount: null, encryptedContentVerified: null,
91
+ acceptedRecordCount: null, skippedRecordCount: null, unsupportedSectionCount: null,
92
+ blockedSectionCount: null, prunedRecordCount: null,
93
+ }),
94
+ limitations: Object.freeze([
95
+ 'The file did not match a registered interchange contract. Its supplied schema text and contents are not echoed.',
96
+ 'This report makes no request and does not establish that evidence is accurate, current, complete, or safe to share.',
97
+ ]),
98
+ });
99
+ const { contract, version } = identified;
100
+ const supported = version !== null && contract.versions.includes(version);
101
+ let verificationState = supported ? 'not_verified' : 'unsupported_version';
102
+ let assuranceSatisfied = false;
103
+ let recordCount = null;
104
+ let sectionCount = null;
105
+ let encryptedContentVerified = contract.id === 'encrypted_workspace' ? false : null;
106
+ let acceptedRecordCount = null;
107
+ let skippedRecordCount = null;
108
+ let unsupportedSectionCount = null;
109
+ let blockedSectionCount = null;
110
+ let prunedRecordCount = null;
111
+ let fullyImportable = null;
112
+ if (supported && contract.fidelity !== 'unsupported') {
113
+ try {
114
+ if (contract.id === 'brand_profiles') {
115
+ if (version === null)
116
+ throw new TypeError('Brand Profile export version is invalid.');
117
+ const validation = validateBrandProfileExport(value, version);
118
+ recordCount = validation.accepted;
119
+ acceptedRecordCount = validation.accepted;
120
+ skippedRecordCount = validation.skipped;
121
+ verificationState = validation.skipped === 0 ? 'structure_valid' : 'not_verified';
122
+ assuranceSatisfied = validation.skipped === 0 && contract.requiredAssurance === 'structure';
123
+ }
124
+ else {
125
+ const verification = await verifyOfflineArtifact(raw, { passphrase: options.passphrase ?? null });
126
+ verificationState = verification.state;
127
+ assuranceSatisfied = contract.requiredAssurance === 'whole_integrity'
128
+ ? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
129
+ : contract.requiredAssurance === 'applicable_integrity'
130
+ ? offlineArtifactSatisfiesAssurance(verification, 'applicable_integrity')
131
+ : contract.requiredAssurance === 'structure'
132
+ ? offlineArtifactSatisfiesAssurance(verification, 'structure')
133
+ : contract.requiredAssurance === 'authenticated_whole_integrity'
134
+ ? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
135
+ && verification.checks.authenticatedEncryption === 'verified'
136
+ : false;
137
+ recordCount = verification.summary.recordCount;
138
+ sectionCount = verification.summary.sectionCount;
139
+ const unsupportedSections = verification.summary.unsupportedSectionCount ?? 0;
140
+ unsupportedSectionCount = unsupportedSections;
141
+ blockedSectionCount = verification.summary.blockedSectionCount ?? null;
142
+ skippedRecordCount = verification.summary.skippedRecordCount ?? skippedRecordCount;
143
+ prunedRecordCount = verification.summary.prunedRecordCount ?? null;
144
+ if (verification.summary.fullyImportable !== undefined
145
+ && verification.summary.fullyImportable !== null) {
146
+ fullyImportable = assuranceSatisfied && verification.summary.fullyImportable;
147
+ }
148
+ if (contract.id === 'encrypted_workspace')
149
+ encryptedContentVerified = verification.state === 'verified'
150
+ && verification.checks.authenticatedEncryption === 'verified';
151
+ }
152
+ }
153
+ catch {
154
+ verificationState = 'not_verified';
155
+ assuranceSatisfied = false;
156
+ }
157
+ }
158
+ const fidelityEligible = assuranceSatisfied && fullyImportable !== false;
159
+ const fidelity = !supported
160
+ ? 'unsupported'
161
+ : fidelityEligible
162
+ ? contract.fidelity
163
+ : contract.fidelity === 'unsupported'
164
+ ? 'unsupported'
165
+ : 'not_verified';
166
+ return Object.freeze({
167
+ schema: INTERCHANGE_FIDELITY_REPORT_SCHEMA,
168
+ version: INTERCHANGE_FIDELITY_REPORT_VERSION,
169
+ generatedAt,
170
+ recognised: true,
171
+ artifact: Object.freeze({ id: contract.id, schema: contract.schema, version, versionSupported: supported }),
172
+ verification: Object.freeze({
173
+ state: verificationState,
174
+ requiredAssurance: contract.requiredAssurance,
175
+ assuranceSatisfied,
176
+ }),
177
+ compatibility: Object.freeze({
178
+ browser: contract.browser,
179
+ cli: contract.cli,
180
+ fidelity,
181
+ preservedFieldGroups: contract.preservedFieldGroups,
182
+ excludedFieldGroups: contract.excludedFieldGroups,
183
+ futureVersionBehaviour: contract.futureVersionBehaviour,
184
+ fullyImportable,
185
+ }),
186
+ summary: Object.freeze({
187
+ inputBytes, recordCount, sectionCount, encryptedContentVerified,
188
+ acceptedRecordCount, skippedRecordCount, unsupportedSectionCount,
189
+ blockedSectionCount, prunedRecordCount,
190
+ }),
191
+ limitations: Object.freeze([
192
+ 'Compatibility describes declared field groups and supported operations, not byte-for-byte equality or evidence truth.',
193
+ 'The report emits only registered metadata, counts, and fixed field-group identifiers. Targets, notes, contacts, passphrases, and evidence values are not included.',
194
+ 'A verified integrity envelope detects file changes but does not establish author identity, source accuracy, currency, completeness, or sharing authority.',
195
+ ]),
196
+ });
197
+ }
198
+ export function formatInterchangeFidelityReport(report) {
199
+ const browser = report.compatibility.browser;
200
+ const cli = report.compatibility.cli;
201
+ return [
202
+ 'Interchange fidelity report',
203
+ `Recognised ${report.recognised ? 'yes' : 'no'}`,
204
+ `Artifact ${report.artifact.id ?? 'unrecognised'}`,
205
+ `Version ${report.artifact.version ?? 'unavailable'}${report.artifact.versionSupported ? '' : ' (unsupported)'}`,
206
+ `Verification ${report.verification.state}`,
207
+ `Assurance ${report.verification.requiredAssurance ?? 'unregistered'} · ${report.verification.assuranceSatisfied ? 'satisfied' : 'withheld'}`,
208
+ `Fidelity ${report.compatibility.fidelity}`,
209
+ ...(report.compatibility.fullyImportable === null ? [] : [`Importability ${report.compatibility.fullyImportable ? 'complete' : 'partial'}`]),
210
+ `Browser import ${browser?.import ?? 'unknown'} · export ${browser?.export ?? 'unknown'}`,
211
+ `CLI read ${cli?.read ?? 'unknown'} · write ${cli?.write ?? 'unknown'} · verify ${cli?.verify ?? 'unknown'}`,
212
+ `Records ${report.summary.recordCount ?? 'not disclosed'}`,
213
+ `Sections ${report.summary.sectionCount ?? 'not disclosed'}`,
214
+ ...(report.summary.acceptedRecordCount === null ? [] : [`Accepted ${report.summary.acceptedRecordCount}`]),
215
+ ...(report.summary.skippedRecordCount === null ? [] : [`Skipped ${report.summary.skippedRecordCount}`]),
216
+ ...(report.summary.unsupportedSectionCount === null ? [] : [`Unsupported ${report.summary.unsupportedSectionCount}`]),
217
+ ...(report.summary.blockedSectionCount === null ? [] : [`Blocked ${report.summary.blockedSectionCount}`]),
218
+ ...(report.summary.prunedRecordCount === null ? [] : [`Pruned ${report.summary.prunedRecordCount}`]),
219
+ '',
220
+ `Preserved ${report.compatibility.preservedFieldGroups.join(', ') || 'none declared'}`,
221
+ `Excluded ${report.compatibility.excludedFieldGroups.join(', ') || 'none declared'}`,
222
+ '',
223
+ ...report.limitations.map((item) => `Limitation: ${item}`),
224
+ '',
225
+ ].join('\n');
226
+ }
@@ -1,10 +1,11 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { createHash } from 'node:crypto';
3
- import { canonicalArtifactJson, sha256ArtifactDigest, } from "../frontend/src/lib/analysis/artifact-integrity.js";
4
- import { recordOrNull, requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
3
+ import { canonicalArtifactJsonV2, SORTED_JSON_V2, sha256ArtifactDigestV2, } from "../packages/evidence/artifact-integrity.mjs";
4
+ import { requireBoundedString, requireIsoTimestamp, } from "../lib/bounded-contract-normalizers.mjs";
5
5
  import { normalizeBoundedSemanticVersion } from "../lib/semantic-version.mjs";
6
+ import { parseBoundedJsonObject } from "./bounded-json.mjs";
6
7
  export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
7
- export const INVESTIGATION_MANIFEST_VERSION = 1;
8
+ export const INVESTIGATION_MANIFEST_VERSION = 2;
8
9
  export const MAX_INVESTIGATION_MANIFEST_ARTIFACTS = 16;
9
10
  export const MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES = 15 * 1024 * 1024;
10
11
  export const MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES = 32 * 1024 * 1024;
@@ -40,20 +41,17 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
40
41
  if (byteLength < 1 || byteLength > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES) {
41
42
  throw new TypeError(`Artifact ${index + 1} must be between 1 byte and ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES} bytes.`);
42
43
  }
43
- let parsed;
44
- try {
45
- parsed = JSON.parse(artifact.content);
44
+ if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
45
+ throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
46
46
  }
47
- catch {
48
- throw new TypeError(`Artifact ${index + 1} must be valid JSON.`);
49
- }
50
- const value = recordOrNull(parsed);
51
- if (!value)
52
- throw new TypeError(`Artifact ${index + 1} must contain one JSON object.`);
47
+ const value = parseBoundedJsonObject(artifact.content, {
48
+ label: `Artifact ${index + 1}`,
49
+ maximumBytes: MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES,
50
+ });
53
51
  const schema = value.schema === undefined
54
52
  ? null
55
53
  : requireBoundedString(value.schema, `Artifact ${index + 1} schema`, 160);
56
- const canonical = canonicalArtifactJson(value);
54
+ const canonical = canonicalArtifactJsonV2(value);
57
55
  const contentDigestSha256 = digest(artifact.content);
58
56
  return Object.freeze({
59
57
  sequence: index + 1,
@@ -65,9 +63,6 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
65
63
  canonicalDigestSha256: digest(canonical),
66
64
  });
67
65
  });
68
- if (totalBytes > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) {
69
- throw new TypeError(`Manifest artefacts exceed the ${MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES}-byte combined limit.`);
70
- }
71
66
  const unsigned = Object.freeze({
72
67
  schema: INVESTIGATION_MANIFEST_SCHEMA,
73
68
  version: INVESTIGATION_MANIFEST_VERSION,
@@ -90,7 +85,11 @@ export async function buildInvestigationManifest(input, generatedAtValue, applic
90
85
  });
91
86
  return Object.freeze({
92
87
  ...unsigned,
93
- integrity: Object.freeze({ algorithm: 'SHA-256', digestSha256: await sha256ArtifactDigest(unsigned) }),
88
+ integrity: Object.freeze({
89
+ algorithm: 'SHA-256',
90
+ canonicalization: SORTED_JSON_V2,
91
+ digestSha256: await sha256ArtifactDigestV2(unsigned),
92
+ }),
94
93
  });
95
94
  }
96
95
  export function formatInvestigationManifest(manifest) {