@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
@@ -0,0 +1,114 @@
1
+ import { MAX_EVIDENCE_PER_TECHNOLOGY, MAX_LIBRARY_FINDINGS, MAX_TECHNOLOGY_FINDINGS, } from "../../lib/lookup-child-profile-contract.mjs";
2
+ import { TECHNOLOGY_EVIDENCE_ROLE_ORDER, technologyEvidenceRoles, } from "../../lib/technology-evidence-role.mjs";
3
+ import { MAX_OBSERVATION_LIMITATIONS, readObservationTime } from "../../packages/evidence/observation.mjs";
4
+ import { boundedTerminalList, safeTerminalValue, terminalCount, terminalRecord, titleCase } from "./terminal-shared.mjs";
5
+ const ROLE_LABELS = {
6
+ observed_edge: 'Observed edge',
7
+ application_platform: 'App platform',
8
+ framework_runtime: 'Framework/run',
9
+ embedded_dependency: 'Embedded deps',
10
+ };
11
+ const SUMMARY_FINDINGS = 6;
12
+ const SUMMARY_NAMESERVERS = 5;
13
+ function observationLine(label, value, generatedAt) {
14
+ const time = readObservationTime(value, generatedAt);
15
+ if (!time.observedAt)
16
+ return `${label.padEnd(15)}Observed time unavailable`;
17
+ const age = time.ageDays === null ? 'age unavailable'
18
+ : time.ageDays === 0 ? 'less than a day old at output generation'
19
+ : `${time.ageDays} day${time.ageDays === 1 ? '' : 's'} old at output generation`;
20
+ return `${label.padEnd(15)}${time.observedAt} · ${age}`;
21
+ }
22
+ function appendLimitations(lines, value) {
23
+ if (!Array.isArray(value))
24
+ return;
25
+ for (const limitation of value.slice(0, MAX_OBSERVATION_LIMITATIONS)) {
26
+ lines.push(` Limit ${safeTerminalValue(limitation)}`);
27
+ }
28
+ }
29
+ /** Presents admitted source records; it never collects or infers a hidden origin. */
30
+ export function appendTechnologyLines(lines, input) {
31
+ const { technology, libraries, nameservers, generatedAt, detail } = input;
32
+ if (!technology.status && technology.source !== 'derived')
33
+ return;
34
+ const suppliedFindings = Array.isArray(technology.findings) ? technology.findings : [];
35
+ const findings = suppliedFindings.slice(0, MAX_TECHNOLOGY_FINDINGS).map((value) => {
36
+ const finding = terminalRecord(value);
37
+ return { finding, roles: technologyEvidenceRoles(finding) };
38
+ });
39
+ lines.push(`Technology ${titleCase(technology.status)} · ${findings.length} indicator${findings.length === 1 ? '' : 's'}`);
40
+ if (suppliedFindings.length > findings.length)
41
+ lines.push('Technology Additional findings exceed the admitted profile limit');
42
+ if (detail === 'summary')
43
+ return;
44
+ lines.push(observationLine('Tech observed', technology.observedAt, generatedAt));
45
+ if (detail === 'standard' && findings.length) {
46
+ const visible = findings.slice(0, SUMMARY_FINDINGS).map(({ finding }) => {
47
+ const qualifiers = [finding.category, finding.confidence ? `${safeTerminalValue(finding.confidence)} signature strength` : null]
48
+ .filter(Boolean).map((value) => safeTerminalValue(value));
49
+ return `${safeTerminalValue(finding.name, 'Unnamed indicator')}${qualifiers.length ? ` (${qualifiers.join(', ')})` : ''}`;
50
+ });
51
+ lines.push(`Indicators ${boundedTerminalList(visible, findings.length - visible.length)}`);
52
+ }
53
+ const retainedNameservers = Array.isArray(nameservers) ? nameservers : [];
54
+ const visibleNameservers = retainedNameservers.slice(0, SUMMARY_NAMESERVERS).map((value) => safeTerminalValue(value));
55
+ lines.push(`Nameservers ${visibleNameservers.length ? boundedTerminalList(visibleNameservers, retainedNameservers.length - visibleNameservers.length) : 'Unavailable'} · identity does not establish operator or web-host ownership`);
56
+ for (const role of TECHNOLOGY_EVIDENCE_ROLE_ORDER) {
57
+ const matching = findings.filter((item) => item.roles.includes(role));
58
+ const names = matching.slice(0, SUMMARY_FINDINGS).map(({ finding }) => safeTerminalValue(finding.name, 'Unnamed indicator'));
59
+ lines.push(`${ROLE_LABELS[role].padEnd(15)}${names.length ? boundedTerminalList(names, matching.length - names.length) : 'None retained'}`);
60
+ }
61
+ lines.push('Origin host Not established from retained evidence');
62
+ if (detail === 'verbose') {
63
+ for (const { finding, roles } of findings) {
64
+ lines.push(`Indicator ${safeTerminalValue(finding.name, 'Unnamed indicator')}`);
65
+ lines.push(` Role ${roles.map((role) => ROLE_LABELS[role]).join(', ') || 'Not retained'}`);
66
+ lines.push(` Strength ${safeTerminalValue(finding.confidence, 'Unknown')} signature strength`);
67
+ const suppliedEvidence = Array.isArray(finding.evidence) ? finding.evidence : [];
68
+ const evidence = suppliedEvidence.slice(0, MAX_EVIDENCE_PER_TECHNOLOGY);
69
+ if (!evidence.length)
70
+ lines.push(' Signal No detailed signal was retained');
71
+ for (const value of evidence) {
72
+ const item = terminalRecord(value);
73
+ if (typeof item.source !== 'string' || !item.source.trim()
74
+ || typeof item.description !== 'string' || !item.description.trim()) {
75
+ lines.push(' Signal Incomplete retained signal record');
76
+ continue;
77
+ }
78
+ const role = TECHNOLOGY_EVIDENCE_ROLE_ORDER.find((candidate) => candidate === item.role);
79
+ lines.push(` Source ${safeTerminalValue(item.source, 'Not retained')} · ${role ? ROLE_LABELS[role] : 'Role not retained'}`);
80
+ lines.push(` Signal ${safeTerminalValue(item.description, 'Not retained')}`);
81
+ }
82
+ if (suppliedEvidence.length > evidence.length)
83
+ lines.push(' Limit Additional signals exceed the admitted profile limit');
84
+ }
85
+ appendLimitations(lines, technology.limitations);
86
+ }
87
+ if (libraries.profileVersion !== 1 && libraries.profileVersion !== 2 && libraries.source !== 'derived')
88
+ return;
89
+ const suppliedLibraries = Array.isArray(libraries.findings) ? libraries.findings : [];
90
+ const libraryFindings = suppliedLibraries.slice(0, MAX_LIBRARY_FINDINGS).map(terminalRecord);
91
+ const advisoryMatches = libraryFindings.filter((finding) => terminalCount(finding.advisoryCount) > 0).length;
92
+ lines.push(`JS libraries ${titleCase(libraries.status)} · ${libraryFindings.length} apparent · ${advisoryMatches} with catalogue advisory match${advisoryMatches === 1 ? '' : 'es'}`);
93
+ lines.push(observationLine('Lib observed', libraries.observedAt, generatedAt));
94
+ if (suppliedLibraries.length > libraryFindings.length)
95
+ lines.push('JS libraries Additional findings exceed the admitted profile limit');
96
+ if (detail !== 'verbose')
97
+ return;
98
+ const catalogue = terminalRecord(libraries.catalog);
99
+ lines.push(`Catalogue ${safeTerminalValue(catalogue.name, 'Component catalogue')} · ${safeTerminalValue(catalogue.version, 'Version unavailable')}`);
100
+ for (const library of libraryFindings) {
101
+ lines.push(`Library ${safeTerminalValue(library.name, 'Unnamed library')} · ${safeTerminalValue(library.apparentVersion, 'Version unavailable')}`);
102
+ const methods = Array.isArray(library.detectionMethods) ? library.detectionMethods.slice(0, 4).map((value) => safeTerminalValue(value)) : [];
103
+ lines.push(` Detected by ${methods.join(', ') || 'Not retained'}`);
104
+ lines.push(` Advisories ${terminalCount(library.advisoryCount)} catalogue matches`);
105
+ if (library.highestSeverity)
106
+ lines.push(` Severity ${safeTerminalValue(library.highestSeverity)}`);
107
+ for (const [field, label] of [['advisoryIdentifiers', 'Identifier'], ['knownExploitedIdentifiers', 'Known exploited']]) {
108
+ const values = Array.isArray(library[field]) ? library[field] : [];
109
+ for (const value of values.slice(0, 16))
110
+ lines.push(` ${label.padEnd(15)} ${safeTerminalValue(value)}`);
111
+ }
112
+ }
113
+ appendLimitations(lines, libraries.limitations);
114
+ }
@@ -20,6 +20,7 @@ const REVIEW_INLINE_COMMANDS = Object.freeze([
20
20
  'mail-headers',
21
21
  'review-evidence',
22
22
  'brief',
23
+ 'case',
23
24
  'case-pack',
24
25
  ]);
25
26
  const ASSURANCE_INLINE_COMMANDS = Object.freeze([
@@ -1,171 +1,13 @@
1
1
  import { classifyQuery, isDirectLookupTarget } from "../lib/classify.mjs";
2
- import { StringDecoder } from 'node:string_decoder';
3
- import { boundedCliErrorMessage, CliUsageError, hasUnsafeCliText } from "./errors.mjs";
2
+ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
4
3
  import { INVESTIGATION_PLAN_RECIPE_LABELS, INVESTIGATION_PLAN_RECIPES, } from "./investigation-plan.mjs";
5
4
  import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
6
5
  import { canBrowseLookup, } from "./lookup-browser.mjs";
7
- const MAX_INTERACTIVE_ANSWER_SCALARS = 1_024;
8
- const MAX_INTERACTIVE_ANSWER_BYTES = 4_096;
6
+ import { MAX_INTERACTIVE_ANSWER_BYTES, MAX_INTERACTIVE_ANSWER_SCALARS, boundedInteractiveAnswer, readBoundedInteractiveLine } from "./terminal-input.mjs";
9
7
  function canLaunchInteractiveCli(input, output, environment = process.env) {
10
8
  return input?.isTTY === true
11
9
  && canBrowseLookup(input, output, environment);
12
10
  }
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
11
  async function launchInteractiveCli(options) {
170
12
  const environment = options.environment || process.env;
171
13
  if (!canLaunchInteractiveCli(options.input, options.output, environment)) {
@@ -1,12 +1,11 @@
1
1
  import { Buffer } from 'node:buffer';
2
2
  import { INTERCHANGE_ARTIFACT_CONTRACTS, } from "../lib/interchange-fidelity-registry.mjs";
3
- import { offlineArtifactSatisfiesAssurance, verifyOfflineArtifact, } from "./artifact-verify.mjs";
3
+ import { offlineArtifactSatisfiesAssurance, MAX_OFFLINE_ARTIFACT_BYTES, parseOfflineArtifactJson, verifyOfflineArtifact, } from "./artifact-verify.mjs";
4
4
  import { mergeBrandProfiles, } from "../packages/workspace/brand-profile-model.mjs";
5
- import { parseBoundedJsonObject } from "./bounded-json.mjs";
6
5
  import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
7
6
  export const INTERCHANGE_FIDELITY_REPORT_SCHEMA = 'whoisleuth.interchange-fidelity-report';
8
7
  export const INTERCHANGE_FIDELITY_REPORT_VERSION = 2;
9
- export const MAX_INTERCHANGE_REPORT_BYTES = 15 * 1024 * 1024;
8
+ export const MAX_INTERCHANGE_REPORT_BYTES = MAX_OFFLINE_ARTIFACT_BYTES;
10
9
  function record(value) {
11
10
  return value && typeof value === 'object' && !Array.isArray(value)
12
11
  ? value
@@ -19,10 +18,7 @@ function parseInput(raw) {
19
18
  if (bytes < 1 || bytes > MAX_INTERCHANGE_REPORT_BYTES) {
20
19
  throw new TypeError(`Interchange input must be between 1 byte and ${MAX_INTERCHANGE_REPORT_BYTES} bytes.`);
21
20
  }
22
- return parseBoundedJsonObject(raw.replace(/^\uFEFF/u, ''), {
23
- label: 'Interchange input',
24
- maximumBytes: MAX_INTERCHANGE_REPORT_BYTES,
25
- });
21
+ return parseOfflineArtifactJson(raw.replace(/^\uFEFF/u, ''));
26
22
  }
27
23
  function nestedRecord(value, path) {
28
24
  let current = value;
@@ -123,7 +119,7 @@ export async function buildInterchangeFidelityReport(raw, options = {}) {
123
119
  }
124
120
  else {
125
121
  const verification = await verifyOfflineArtifact(raw, { passphrase: options.passphrase ?? null });
126
- verificationState = verification.state;
122
+ verificationState = verification.state === 'partial' ? 'not_verified' : verification.state;
127
123
  assuranceSatisfied = contract.requiredAssurance === 'whole_integrity'
128
124
  ? offlineArtifactSatisfiesAssurance(verification, 'whole_integrity')
129
125
  : contract.requiredAssurance === 'applicable_integrity'
@@ -0,0 +1,176 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { CLI_LOOKUP_SCHEMA, CLI_LOOKUP_VERSION } from "../packages/contracts/cli-lookup.mjs";
4
+ import { DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION } from "../packages/contracts/domain-control-review.mjs";
5
+ import { canonicalArtifactJsonV2 } from "../packages/evidence/artifact-integrity.mjs";
6
+ import { validateDomainControlReviewDocument } from "../lib/domain-control-manifest.mjs";
7
+ import { LOOKUP_EVIDENCE_SCHEMA, LOOKUP_EVIDENCE_SCHEMA_VERSION, serializeLookupEvidence } from "../lib/evidence-export.mjs";
8
+ import { OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, OFFLINE_ARTIFACT_VERIFICATION_VERSION } from "./artifact-verify.mjs";
9
+ import { validateLookupEvidenceArtifactStructure } from "./artifact-validation/lookup-evidence.mjs";
10
+ import { CLI_DISCOVERY_SCAN_SCHEMA, CLI_DISCOVERY_SCAN_VERSION } from "./discovery-scan.mjs";
11
+ import { CLI_DISCOVER_SCHEMA, CLI_DISCOVER_SCHEMA_VERSION, CLI_POSTURE_SCHEMA, CLI_POSTURE_SCHEMA_VERSION, CLI_CT_SEARCH_SCHEMA, CLI_CT_SEARCH_SCHEMA_VERSION, CLI_COMPARE_SCHEMA, CLI_COMPARE_SCHEMA_VERSION, } from "./formatters/json.mjs";
12
+ import { CLI_LOOKUP_BRIEF_SCHEMA, CLI_LOOKUP_BRIEF_VERSION } from "./lookup-brief.mjs";
13
+ import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, parseExternalFindingsDocument } from "../packages/interchange/external-findings-import.mjs";
14
+ import { SOURCE_RELIABILITY_REPORT_SCHEMA, SOURCE_RELIABILITY_REPORT_VERSION } from "./source-reliability.mjs";
15
+ import { CLI_CASE_PACK_SCHEMA, CLI_CASE_PACK_VERSION, verifyCliCasePack } from "./case-pack.mjs";
16
+ import { SHARING_REVIEW_SCHEMA, SHARING_REVIEW_VERSION } from "./sharing-review.mjs";
17
+ import { DOMAIN_ASSURANCE_SCHEMA, DOMAIN_ASSURANCE_VERSION } from "../lib/domain-assurance.mjs";
18
+ import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_VERSION } from "../lib/domain-change-packet.mjs";
19
+ import { CLI_DOMAIN_CONTROL_MONITOR_SCHEMA, CLI_DOMAIN_CONTROL_MONITOR_VERSION } from "../packages/contracts/domain-control-monitor.mjs";
20
+ import { CLI_LOOKUP_DIFF_SCHEMA, CLI_LOOKUP_DIFF_VERSION } from "./lookup-diff.mjs";
21
+ import { CLI_LOOKUP_TIMELINE_SCHEMA, CLI_LOOKUP_TIMELINE_VERSION } from "./lookup-timeline.mjs";
22
+ import { normalizeCliLookupDocument } from "./saved-lookup.mjs";
23
+ import { CliUsageError } from "./errors.mjs";
24
+ import { MAX_INVESTIGATION_RUN_SELECTIONS } from "../packages/contracts/investigation-run.mjs";
25
+ // This is a fixed-recipe boundary, not a general command or format registry.
26
+ // Non-reusable reports have their envelope checked; reusable evidence also
27
+ // passes its existing input validator before it can enter another command.
28
+ export function validateWorkflowResult(step, result, retained) {
29
+ if (!result || typeof result !== 'object' || Array.isArray(result))
30
+ throw new TypeError('Expected a JSON document.');
31
+ const value = result;
32
+ let schema;
33
+ let version;
34
+ switch (step.command) {
35
+ case 'lookup':
36
+ schema = CLI_LOOKUP_SCHEMA;
37
+ version = CLI_LOOKUP_VERSION;
38
+ normalizeCliLookupDocument(value, { label: 'Workflow Lookup result' });
39
+ break;
40
+ case 'export':
41
+ schema = LOOKUP_EVIDENCE_SCHEMA;
42
+ version = LOOKUP_EVIDENCE_SCHEMA_VERSION;
43
+ validateLookupEvidenceArtifactStructure(value);
44
+ serializeLookupEvidence(value);
45
+ break;
46
+ case 'domain-control':
47
+ schema = DOMAIN_CONTROL_REVIEW_SCHEMA;
48
+ version = DOMAIN_CONTROL_REVIEW_VERSION;
49
+ validateDomainControlReviewDocument(value);
50
+ break;
51
+ case 'case-pack':
52
+ schema = CLI_CASE_PACK_SCHEMA;
53
+ version = CLI_CASE_PACK_VERSION;
54
+ verifyCliCasePack(value);
55
+ break;
56
+ case 'ct-search':
57
+ schema = CLI_CT_SEARCH_SCHEMA;
58
+ version = CLI_CT_SEARCH_SCHEMA_VERSION;
59
+ break;
60
+ case 'ct-intake':
61
+ schema = EXTERNAL_FINDINGS_SCHEMA;
62
+ version = EXTERNAL_FINDINGS_VERSION;
63
+ parseExternalFindingsDocument(value);
64
+ break;
65
+ case 'brief':
66
+ schema = CLI_LOOKUP_BRIEF_SCHEMA;
67
+ version = CLI_LOOKUP_BRIEF_VERSION;
68
+ break;
69
+ case 'compare':
70
+ schema = CLI_COMPARE_SCHEMA;
71
+ version = CLI_COMPARE_SCHEMA_VERSION;
72
+ break;
73
+ case 'source-report':
74
+ schema = SOURCE_RELIABILITY_REPORT_SCHEMA;
75
+ version = SOURCE_RELIABILITY_REPORT_VERSION;
76
+ break;
77
+ case 'sharing-review':
78
+ schema = SHARING_REVIEW_SCHEMA;
79
+ version = SHARING_REVIEW_VERSION;
80
+ break;
81
+ case 'assurance':
82
+ schema = DOMAIN_ASSURANCE_SCHEMA;
83
+ version = DOMAIN_ASSURANCE_VERSION;
84
+ break;
85
+ case 'change-packet':
86
+ schema = DOMAIN_CHANGE_PACKET_SCHEMA;
87
+ version = DOMAIN_CHANGE_PACKET_VERSION;
88
+ break;
89
+ case 'monitor-once':
90
+ schema = CLI_DOMAIN_CONTROL_MONITOR_SCHEMA;
91
+ version = CLI_DOMAIN_CONTROL_MONITOR_VERSION;
92
+ break;
93
+ case 'discover':
94
+ schema = CLI_DISCOVER_SCHEMA;
95
+ version = CLI_DISCOVER_SCHEMA_VERSION;
96
+ break;
97
+ case 'discover-scan':
98
+ schema = CLI_DISCOVERY_SCAN_SCHEMA;
99
+ version = CLI_DISCOVERY_SCAN_VERSION;
100
+ break;
101
+ case 'posture':
102
+ schema = CLI_POSTURE_SCHEMA;
103
+ version = CLI_POSTURE_SCHEMA_VERSION;
104
+ break;
105
+ case 'verify-artifact':
106
+ schema = OFFLINE_ARTIFACT_VERIFICATION_SCHEMA;
107
+ version = OFFLINE_ARTIFACT_VERIFICATION_VERSION;
108
+ break;
109
+ case 'diff':
110
+ schema = CLI_LOOKUP_DIFF_SCHEMA;
111
+ version = CLI_LOOKUP_DIFF_VERSION;
112
+ break;
113
+ case 'timeline':
114
+ schema = CLI_LOOKUP_TIMELINE_SCHEMA;
115
+ version = CLI_LOOKUP_TIMELINE_VERSION;
116
+ break;
117
+ default: throw new TypeError('This command has no fixed-workflow output contract.');
118
+ }
119
+ const envelope = step.command === 'case-pack' ? value.packet : value;
120
+ const actualVersion = step.command === 'export' || step.command === 'ct-intake' ? value.schemaVersion : envelope.version;
121
+ // Only existing format validators admit historical versions. Report-only
122
+ // steps use the same output versions as the latest public checkpoint writer.
123
+ const historicalReader = retained && ['lookup', 'export', 'domain-control'].includes(step.command);
124
+ if (envelope.schema !== schema || schema !== step.produces
125
+ || (!historicalReader && actualVersion !== version) || !Number.isSafeInteger(actualVersion)) {
126
+ throw new TypeError('Unsupported workflow output schema or version.');
127
+ }
128
+ const id = `sha256:${createHash('sha256').update(canonicalArtifactJsonV2(value)).digest('hex')}`;
129
+ return Object.freeze({ id, schema, version: Number(actualVersion) });
130
+ }
131
+ export function normalizeWorkflowBindings(plan, value) {
132
+ if (!Array.isArray(value) || value.length > MAX_INVESTIGATION_RUN_SELECTIONS) {
133
+ throw new CliUsageError(`Workflow artefact bindings are limited to ${MAX_INVESTIGATION_RUN_SELECTIONS}.`);
134
+ }
135
+ const slots = new Set();
136
+ const bindings = value.map((raw) => {
137
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)
138
+ || !isDeepStrictEqual(Object.keys(raw).sort(), ['input', 'sourceStepId', 'stepId'])) {
139
+ throw new CliUsageError('Workflow artefact bindings must identify a step, input number and earlier source step.');
140
+ }
141
+ const item = raw;
142
+ const destinationIndex = plan.steps.findIndex((step) => step.id === item.stepId);
143
+ const sourceIndex = plan.steps.findIndex((step) => step.id === item.sourceStepId);
144
+ const step = plan.steps[destinationIndex];
145
+ const source = plan.steps[sourceIndex];
146
+ const input = Number(item.input);
147
+ const expected = step ? workflowInputSchemas(step.command) : [];
148
+ const slot = `${destinationIndex}:${input}`;
149
+ if (!step || !source || sourceIndex >= destinationIndex || !expected.includes(source.produces)
150
+ || typeof item.input !== 'number' || !Number.isSafeInteger(input) || input < 1
151
+ || input > step.arguments.filter((argument) => /^<[^>]+>$/u.test(argument)).length || slots.has(slot)) {
152
+ throw new CliUsageError('Workflow artefact binding does not match a compatible earlier output and file input in the fixed recipe.');
153
+ }
154
+ slots.add(slot);
155
+ return Object.freeze({ stepId: step.id, input, sourceStepId: source.id });
156
+ });
157
+ return Object.freeze(bindings.sort((left, right) => plan.steps.findIndex((step) => step.id === left.stepId) - plan.steps.findIndex((step) => step.id === right.stepId)
158
+ || left.input - right.input));
159
+ }
160
+ /** Compatible file inputs in the installed recipes, not general CLI pipelines. */
161
+ export function workflowInputSchemas(command) {
162
+ switch (command) {
163
+ case 'export':
164
+ case 'diff':
165
+ case 'timeline':
166
+ case 'compare':
167
+ case 'source-report':
168
+ case 'brief': return [CLI_LOOKUP_SCHEMA];
169
+ case 'verify-artifact': return [LOOKUP_EVIDENCE_SCHEMA];
170
+ case 'sharing-review': return [CLI_CASE_PACK_SCHEMA];
171
+ default: return [];
172
+ }
173
+ }
174
+ export function workflowArtifactReference(artifact) {
175
+ return `workflow-artifact:${artifact.id}`;
176
+ }
@@ -0,0 +1,10 @@
1
+ import { MAX_INVESTIGATION_RUN_BYTES } from "../packages/contracts/investigation-run.mjs";
2
+ import { prepareLocalDocumentWrite } from "./local-document-checkpoint.mjs";
3
+ /** Preserve the workflow checkpoint interface and shared on-disk lease convention. */
4
+ export async function prepareInvestigationCheckpoint(options) {
5
+ const checkpoint = await prepareLocalDocumentWrite({
6
+ ...options, source: options.resumeSource, label: 'Workflow state',
7
+ maximumInputBytes: MAX_INVESTIGATION_RUN_BYTES, maximumOutputBytes: MAX_INVESTIGATION_RUN_BYTES,
8
+ });
9
+ return Object.freeze({ resumeInput: checkpoint.sourceInput, publish: checkpoint.publish, release: checkpoint.release });
10
+ }
@@ -0,0 +1,114 @@
1
+ import { lstat, mkdir, opendir, realpath } from 'node:fs/promises';
2
+ import { join, resolve } from 'node:path';
3
+ import { readBoundedRegularFileWithin } from "../lib/bounded-file.mjs";
4
+ import { writePrivateFile } from "./output-file.mjs";
5
+ import { INVESTIGATION_PACKAGE_MANIFEST_PATH, MAX_INVESTIGATION_PACKAGE_ENTRIES, investigationPackagePath, prepareInvestigationPackageEntries, } from "../packages/investigation/investigation-package.mjs";
6
+ import { MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, } from "../packages/investigation/investigation-manifest.mjs";
7
+ export function selectedEvidenceDirectory(value) {
8
+ if (typeof value !== 'string' || !value || value === '-' || value.length > 4096 || /[\x00-\x1f\x7f]/u.test(value))
9
+ throw new TypeError('Select one bounded local folder path.');
10
+ return resolve(value);
11
+ }
12
+ export async function evidenceDirectoryIdentity(path) {
13
+ const value = await lstat(path);
14
+ if (!value.isDirectory() || value.isSymbolicLink())
15
+ throw new TypeError('Evidence folders must be ordinary directories, not symbolic links.');
16
+ return { device: value.dev, inode: value.ino, modified: value.mtimeMs, changed: value.ctimeMs };
17
+ }
18
+ /** Enumerate only the two declared levels; never walk an arbitrary source tree. */
19
+ export async function readInvestigationFolder(value, signal) {
20
+ signal?.throwIfAborted();
21
+ const selected = selectedEvidenceDirectory(value);
22
+ const before = await evidenceDirectoryIdentity(selected), root = await realpath(selected);
23
+ const paths = [];
24
+ let nestedIdentity = null;
25
+ const directory = await opendir(root);
26
+ for await (const entry of directory) {
27
+ signal?.throwIfAborted();
28
+ if (entry.name === 'artifacts' && entry.isDirectory()) {
29
+ const nested = join(root, 'artifacts');
30
+ nestedIdentity = await evidenceDirectoryIdentity(nested);
31
+ for await (const file of await opendir(nested)) {
32
+ signal?.throwIfAborted();
33
+ if (!file.isFile())
34
+ throw new TypeError('Evidence folder entries must be regular files.');
35
+ if (paths.length >= MAX_INVESTIGATION_PACKAGE_ENTRIES)
36
+ throw new TypeError('Evidence folder has too many entries.');
37
+ paths.push(investigationPackagePath(`artifacts/${file.name}`));
38
+ }
39
+ }
40
+ else if (entry.name === INVESTIGATION_PACKAGE_MANIFEST_PATH && entry.isFile()) {
41
+ if (paths.length >= MAX_INVESTIGATION_PACKAGE_ENTRIES)
42
+ throw new TypeError('Evidence folder has too many entries.');
43
+ paths.push(entry.name);
44
+ }
45
+ else
46
+ throw new TypeError('Evidence folders contain only manifest.json and the declared artifacts directory.');
47
+ }
48
+ if (!nestedIdentity || !paths.includes(INVESTIGATION_PACKAGE_MANIFEST_PATH) || paths.length < 2)
49
+ throw new TypeError('Evidence folder is incomplete.');
50
+ const files = new Map();
51
+ let total = 0;
52
+ for (const name of paths.sort()) {
53
+ signal?.throwIfAborted();
54
+ const manifest = name === INVESTIGATION_PACKAGE_MANIFEST_PATH;
55
+ const maximumBytes = manifest ? MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES : Math.min(MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES - total);
56
+ const bytes = await readBoundedRegularFileWithin(root, name, { maximumBytes, minimumBytes: 1, label: 'Evidence folder entry', ...(signal ? { signal } : {}) });
57
+ if (!manifest)
58
+ total += bytes.byteLength;
59
+ files.set(name, bytes);
60
+ }
61
+ const unchanged = (left, right) => left.device === right.device && left.inode === right.inode && left.modified === right.modified && left.changed === right.changed;
62
+ if (await realpath(selected) !== root || !unchanged(before, await evidenceDirectoryIdentity(selected))
63
+ || !unchanged(nestedIdentity, await evidenceDirectoryIdentity(join(root, 'artifacts'))))
64
+ throw new TypeError('Evidence folder changed while it was read.');
65
+ signal?.throwIfAborted();
66
+ return files;
67
+ }
68
+ /** Never replace an existing folder. The manifest is published only after files. */
69
+ export async function writeInvestigationFolder(value, input, generatedAt, version, signal) {
70
+ const prepared = await prepareInvestigationPackageEntries(input, generatedAt, version);
71
+ await writePreparedEvidenceFolder(value, prepared.files, 'artifacts', INVESTIGATION_PACKAGE_MANIFEST_PATH, signal);
72
+ return prepared.manifest;
73
+ }
74
+ /** Only first-party, generated layouts may use this fresh-directory writer. */
75
+ export async function writePreparedEvidenceFolder(value, files, payloadDirectory, completionFile, signal) {
76
+ const target = selectedEvidenceDirectory(value);
77
+ if (!files.has(completionFile) || !/^[a-z0-9.-]+$/u.test(completionFile)
78
+ || [...files.keys()].some(path => path !== `${payloadDirectory}/` && !/^[a-z0-9.-]+$/u.test(path) && !new RegExp(`^${payloadDirectory}/artifact-[1-9][0-9]{0,2}$`, 'u').test(path)))
79
+ throw new TypeError('The generated evidence folder layout is invalid.');
80
+ signal?.throwIfAborted();
81
+ try {
82
+ await mkdir(target, { mode: 0o700 });
83
+ }
84
+ catch {
85
+ throw new TypeError('The output folder must not already exist, and its parent must be writable. Existing files were not replaced.');
86
+ }
87
+ try {
88
+ const root = await realpath(target), identity = await evidenceDirectoryIdentity(target);
89
+ await mkdir(join(root, payloadDirectory), { mode: 0o700 });
90
+ const artifactsIdentity = await evidenceDirectoryIdentity(join(root, payloadDirectory));
91
+ const checkLocation = async () => {
92
+ signal?.throwIfAborted();
93
+ const current = await evidenceDirectoryIdentity(target), nested = await evidenceDirectoryIdentity(join(root, payloadDirectory));
94
+ if (await realpath(target) !== root || current.device !== identity.device || current.inode !== identity.inode
95
+ || nested.device !== artifactsIdentity.device || nested.inode !== artifactsIdentity.inode
96
+ || await realpath(join(root, payloadDirectory)) !== join(root, payloadDirectory))
97
+ throw new TypeError('Evidence output folder changed.');
98
+ };
99
+ for (const [name, content] of files) {
100
+ if (name === completionFile || name.endsWith('/'))
101
+ continue;
102
+ await checkLocation();
103
+ await writePrivateFile(join(root, name), content, { beforePublish: checkLocation });
104
+ }
105
+ await checkLocation();
106
+ await writePrivateFile(join(root, completionFile), files.get(completionFile), { beforePublish: checkLocation });
107
+ await checkLocation();
108
+ }
109
+ catch {
110
+ // Retain only this newly created partial output for deliberate inspection;
111
+ // recursive cleanup could remove unrelated files added by another process.
112
+ throw new TypeError('The new evidence folder could not be completed or confirmed. It may contain private partial output; inspect or remove it before choosing a new destination. Existing folders were not replaced.');
113
+ }
114
+ }