@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/manual.mjs CHANGED
@@ -12,20 +12,20 @@ function buildCliManual(options) {
12
12
  }).join('\n');
13
13
  return `.TH WHOISLEUTH 1 "" "WHOISleuth ${roffText(options.version)}" "User Commands"
14
14
  .SH NAME
15
- whoisleuth \- source-aware domain investigation from the terminal
15
+ whoisleuth \- domain investigation from the terminal
16
16
  .SH SYNOPSIS
17
17
  .B whoisleuth
18
- command [options]
18
+ [command|target] [options]
19
19
  .SH DESCRIPTION
20
- WHOISleuth performs bounded WHOIS, RDAP, DNS, HTTP, TLS, certificate-transparency, posture, lookalike, and offline evidence operations. Evidence sources remain separately attributed, and missing or failed collection is not converted into a claim of absence or safety.
20
+ WHOISleuth performs WHOIS, RDAP, DNS, HTTP, TLS, certificate-transparency, posture, lookalike and offline evidence operations with explicit source and collection states. With no arguments, an eligible interactive terminal opens the Lookup and command launcher; unsupported or redirected terminals print help.
21
21
  .SH COMMANDS
22
22
  ${commands}
23
23
  .SH OUTPUT
24
- Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events are written to standard error. Use --output with an optional --force flag for atomic private file output.
24
+ Human-readable output is the default. Versioned JSON and JSONL are available where documented. Diagnostics and optional progress events use standard error. Use --output and optional --force for atomic private file output. Use --palette auto, light, or dark for a fixed terminal palette. Lookup --browse provides an interactive terminal view.
25
25
  .SH EXIT STATUS
26
26
  0 indicates command completion, 2 invalid usage, 3 a collection or comparison failure, 4 an explicitly detected partial result, 70 an internal bootstrap failure, 130 analyst cancellation, and 143 service termination.
27
27
  .SH PRIVACY
28
- Network commands disclose the target to the directly queried upstream services. Offline commands do not make network requests. Output files are created with private permissions and are never uploaded by the CLI.
28
+ Network commands disclose the target to the sources named in focused help. Offline commands read local input only. Output files use private permissions and remain on the operator's machine.
29
29
  .SH LICENSE
30
30
  AGPL-3.0-only. Copyright 2026 slicedearth.
31
31
  .SH SEE ALSO
@@ -2,6 +2,8 @@ import { fetchHomepage } from "../lib/availability.mjs";
2
2
  import { searchCertificateTransparency } from "../lib/ct-search.mjs";
3
3
  import { checkDomainPosture, normalizeAuditDomain, normalizeDkimSelectors } from "../lib/domain-posture.mjs";
4
4
  import { collectTlsIntelligence, normalizeTlsHostname } from "../lib/tls-intelligence.mjs";
5
+ import { MAX_DNSSEC_TRUST_ANCHOR_BYTES, formatDnssecChainReport, validateDnssecChain, } from "../lib/dnssec-chain-validation.mjs";
6
+ import { MAX_MAIL_TRANSPORT_INPUT_BYTES, collectMailTransportReview, formatMailTransportReview, } from "../lib/smtp-transport-review.mjs";
5
7
  import { CliUsageError } from "./errors.mjs";
6
8
  import { buildCliCtSearchDocument, buildCliHttpDocument, buildCliPostureDocument, buildCliTlsDocument, formatJsonDocument, } from "./formatters/json.mjs";
7
9
  import { formatTerminalCtSearch, formatTerminalHttp, formatTerminalPosture, formatTerminalTls, } from "./formatters/terminal.mjs";
@@ -71,6 +73,82 @@ async function runNetworkCommand(args, dependencies, context) {
71
73
  }
72
74
  return EXIT_CODES.SUCCESS;
73
75
  }
76
+ if (args.action === 'dnssec-validate') {
77
+ let anchorInput;
78
+ try {
79
+ anchorInput = dependencies.readTrustAnchorInput
80
+ ? await dependencies.readTrustAnchorInput(args.trustAnchorSource)
81
+ : await context.readInput(args.trustAnchorSource, MAX_DNSSEC_TRUST_ANCHOR_BYTES, 'DNSSEC trust anchor');
82
+ }
83
+ catch (error) {
84
+ if (error instanceof CliUsageError)
85
+ throw error;
86
+ throw new CliUsageError(`Could not read DNSSEC trust anchor: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
87
+ }
88
+ const validate = dependencies.validateDnssecChain ?? validateDnssecChain;
89
+ const report = await context.withProgress('Validating the isolated DNSSEC chain', () => validate({
90
+ target: args.target,
91
+ resolver: args.resolver,
92
+ trustAnchor: anchorInput,
93
+ observedAt: context.now(),
94
+ ownedOrAuthorized: args.ownedOrAuthorized,
95
+ }));
96
+ if (!args.quiet) {
97
+ context.writeStdout(args.output === 'json'
98
+ ? formatJsonDocument(report)
99
+ : context.terminal(formatDnssecChainReport(report), args.color));
100
+ }
101
+ return report.state === 'secure' || report.state === 'insecure'
102
+ ? EXIT_CODES.SUCCESS
103
+ : EXIT_CODES.PARTIAL_FAILURE;
104
+ }
105
+ if (args.action === 'mail-transport') {
106
+ let input;
107
+ let anchorInput;
108
+ try {
109
+ input = await (dependencies.readMailTransportInput
110
+ ? dependencies.readMailTransportInput(args.source)
111
+ : context.readInput(args.source, MAX_MAIL_TRANSPORT_INPUT_BYTES, 'Mail transport input'));
112
+ }
113
+ catch (error) {
114
+ if (error instanceof CliUsageError)
115
+ throw error;
116
+ throw new CliUsageError(`Could not read mail transport input: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
117
+ }
118
+ try {
119
+ anchorInput = await (dependencies.readTrustAnchorInput
120
+ ? dependencies.readTrustAnchorInput(args.trustAnchorSource)
121
+ : context.readInput(args.trustAnchorSource, MAX_DNSSEC_TRUST_ANCHOR_BYTES, 'DNSSEC trust anchor'));
122
+ }
123
+ catch (error) {
124
+ if (error instanceof CliUsageError)
125
+ throw error;
126
+ throw new CliUsageError(`Could not read DNSSEC trust anchor: ${String(error instanceof Error ? error.message : error).slice(0, 240)}`);
127
+ }
128
+ if (!input.trim())
129
+ throw new CliUsageError('mail-transport requires one versioned JSON file or a document on stdin.');
130
+ const collect = dependencies.collectMailTransportReview ?? collectMailTransportReview;
131
+ let review;
132
+ try {
133
+ review = await context.withProgress('Reviewing selected authorised mail transports', () => collect(input, {
134
+ resolver: args.resolver,
135
+ trustAnchor: anchorInput,
136
+ ownedOrAuthorized: args.ownedOrAuthorized,
137
+ activeProbeAcknowledged: args.activeProbeAcknowledged,
138
+ }));
139
+ }
140
+ catch (error) {
141
+ if (error instanceof TypeError)
142
+ throw new CliUsageError(error.message);
143
+ throw error;
144
+ }
145
+ if (!args.quiet) {
146
+ context.writeStdout(args.output === 'json'
147
+ ? formatJsonDocument(review)
148
+ : context.terminal(formatMailTransportReview(review), args.color));
149
+ }
150
+ return review.runState === 'complete' ? EXIT_CODES.SUCCESS : EXIT_CODES.PARTIAL_FAILURE;
151
+ }
74
152
  const requestedHostname = args.hostname || await context.readSingleInput();
75
153
  if (!requestedHostname)
76
154
  throw new CliUsageError('tls requires one hostname as an argument or on stdin.');
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
2
3
  import { normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, } from "../lib/encrypted-dns-contract.mjs";
3
4
  import { validateDnssecEvidence } from "../lib/dnssec-evidence-validation.mjs";
4
5
  import { reviewDomainChange } from "../lib/domain-change-review.mjs";
@@ -8,14 +9,23 @@ import { buildLocalGeoIpDatabase, lookupLocalGeoIp, } from "../lib/local-geoip-e
8
9
  import { inspectRdapReverseSearchResponse, normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
9
10
  import { reviewRpkiRoute } from "../lib/rpki-evidence.mjs";
10
11
  import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
12
+ import { CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA, buildCryptographicAssuranceReview, } from "../lib/cryptographic-assurance.mjs";
11
13
  import { reviewZoneIntent } from "../lib/zone-intent-review.mjs";
12
14
  import { reviewDnsConvergence } from "../lib/dns-convergence-review.mjs";
13
15
  import { compareTrustStoreEvidence } from "../lib/trust-store-comparison.mjs";
14
16
  import { CliUsageError } from "./errors.mjs";
17
+ import { safeTerminalValue } from "./formatters/terminal.mjs";
15
18
  import { LOCAL_MMDB_QUERY_SCHEMA, reviewLocalMmdb } from "./local-mmdb-review.mjs";
16
19
  const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
17
20
  const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
18
21
  const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
22
+ const RDAP_SEARCH_INPUT_SCHEMA = 'whoisleuth.rdap-search-input';
23
+ const DNSSEC_EVIDENCE_INPUT_SCHEMA = 'whoisleuth.dnssec-evidence-input';
24
+ const TLSA_EVIDENCE_INPUT_SCHEMA = 'whoisleuth.tlsa-evidence-input';
25
+ const RPKI_ROUTE_INPUT_SCHEMA = 'whoisleuth.rpki-route-input';
26
+ const LOCAL_GEOIP_QUERY_SCHEMA = 'whoisleuth.local-geoip-query';
27
+ const ENCRYPTED_DNS_PLAN_INPUT_SCHEMA = 'whoisleuth.encrypted-dns-plan-input';
28
+ const OFFLINE_EVIDENCE_INPUT_VERSION = 1;
19
29
  function record(value) {
20
30
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
21
31
  }
@@ -23,12 +33,14 @@ function parseInput(value) {
23
33
  if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_OFFLINE_EVIDENCE_INPUT_BYTES) {
24
34
  throw new CliUsageError(`Offline evidence input is limited to ${MAX_OFFLINE_EVIDENCE_INPUT_BYTES} bytes.`);
25
35
  }
36
+ const normalized = value.replace(/^\uFEFF/u, '');
26
37
  let parsed;
27
38
  try {
28
- parsed = JSON.parse(value.replace(/^\uFEFF/u, ''));
39
+ scanBoundedJson(normalized);
40
+ parsed = JSON.parse(normalized);
29
41
  }
30
42
  catch {
31
- throw new CliUsageError('Offline evidence review requires one valid JSON document.');
43
+ throw new CliUsageError('Offline evidence review requires one valid bounded JSON document without duplicate keys.');
32
44
  }
33
45
  const document = record(parsed);
34
46
  if (document.version !== 1 || typeof document.schema !== 'string') {
@@ -40,7 +52,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
40
52
  const input = parseInput(value);
41
53
  let kind;
42
54
  let result;
43
- if (input.schema === 'whoisleuth.rdap-search-input') {
55
+ if (input.schema === RDAP_SEARCH_INPUT_SCHEMA) {
44
56
  kind = 'rdap_search';
45
57
  const request = record(input.request);
46
58
  const help = normalizeRdapSearchHelp(input.help);
@@ -58,7 +70,7 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
58
70
  : inspectRdapReverseSearchResponse(input.response, [request.property]),
59
71
  });
60
72
  }
61
- else if (input.schema === 'whoisleuth.dnssec-evidence-input') {
73
+ else if (input.schema === DNSSEC_EVIDENCE_INPUT_SCHEMA) {
62
74
  kind = 'dnssec';
63
75
  result = validateDnssecEvidence({
64
76
  ownerName: input.ownerName,
@@ -69,19 +81,19 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
69
81
  observedAt: input.observedAt,
70
82
  });
71
83
  }
72
- else if (input.schema === 'whoisleuth.tlsa-evidence-input') {
84
+ else if (input.schema === TLSA_EVIDENCE_INPUT_SCHEMA) {
73
85
  kind = 'tlsa';
74
86
  result = analyzeTlsaEvidence({
75
87
  serviceName: input.serviceName,
76
- dnssecState: input.dnssecState,
77
- pkixValidationState: input.pkixValidationState,
88
+ dnssecState: 'unavailable',
89
+ pkixValidationState: 'unavailable',
78
90
  records: input.records,
79
91
  certificateDerBase64: input.certificateDerBase64,
80
92
  spkiDerBase64: input.spkiDerBase64,
81
93
  authorityMaterials: input.authorityMaterials,
82
94
  });
83
95
  }
84
- else if (input.schema === 'whoisleuth.rpki-route-input') {
96
+ else if (input.schema === RPKI_ROUTE_INPUT_SCHEMA) {
85
97
  kind = 'rpki';
86
98
  result = reviewRpkiRoute({
87
99
  routePrefix: input.routePrefix,
@@ -89,11 +101,15 @@ function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString(
89
101
  authorizations: input.authorizations,
90
102
  });
91
103
  }
92
- else if (input.schema === 'whoisleuth.local-geoip-query') {
104
+ else if (input.schema === CRYPTOGRAPHIC_ASSURANCE_INPUT_SCHEMA) {
105
+ kind = 'cryptographic_assurance';
106
+ result = buildCryptographicAssuranceReview(input, generatedAt);
107
+ }
108
+ else if (input.schema === LOCAL_GEOIP_QUERY_SCHEMA) {
93
109
  kind = 'geoip';
94
110
  result = lookupLocalGeoIp(buildLocalGeoIpDatabase(input.database), input.address);
95
111
  }
96
- else if (input.schema === 'whoisleuth.encrypted-dns-plan-input') {
112
+ else if (input.schema === ENCRYPTED_DNS_PLAN_INPUT_SCHEMA) {
97
113
  kind = 'encrypted_dns';
98
114
  const query = record(input.query);
99
115
  result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
@@ -168,7 +184,8 @@ function formatOfflineEvidenceReview(document) {
168
184
  : 'review'
169
185
  : typeof gate.pass === 'boolean'
170
186
  ? gate.pass ? 'ready' : 'review'
171
- : document.kind === 'domain_portfolio' ? 'inventory' : 'reviewed';
187
+ : document.kind === 'domain_portfolio' ? 'inventory'
188
+ : document.kind === 'cryptographic_assurance' ? 'separate' : 'reviewed';
172
189
  const lines = [
173
190
  'Offline evidence review',
174
191
  `Kind ${document.kind.replaceAll('_', ' ')}`,
@@ -220,6 +237,20 @@ function formatOfflineEvidenceReview(document) {
220
237
  lines.push(`Recovery ${listLength(result.recoveryCycles)}`);
221
238
  lines.push(`Unknown ${Object.values(unknownCounts).reduce((total, value) => total + count(value), 0)}`);
222
239
  }
240
+ else if (document.kind === 'cryptographic_assurance') {
241
+ const cards = Array.isArray(result.cards) ? result.cards : [];
242
+ for (const value of cards) {
243
+ const card = record(value);
244
+ lines.push(`${safeTerminalValue(card.label ?? card.family, 'Evidence')} ${safeTerminalValue(card.state, 'unavailable').replaceAll('_', ' ')} (${safeTerminalValue(card.completeness, 'unavailable')})`);
245
+ lines.push(` Source ${safeTerminalValue(card.source, 'not supplied')}`);
246
+ lines.push(` Authority ${safeTerminalValue(card.authority, 'not supplied')}`);
247
+ const cardLimitations = Array.isArray(card.limitations) ? card.limitations : [];
248
+ for (const limitation of cardLimitations) {
249
+ if (typeof limitation === 'string')
250
+ lines.push(` Limitation: ${safeTerminalValue(limitation)}`);
251
+ }
252
+ }
253
+ }
223
254
  else {
224
255
  for (const [label, key] of [
225
256
  ['Records', 'records'],
@@ -238,7 +269,7 @@ function formatOfflineEvidenceReview(document) {
238
269
  lines.push('', 'Review reasons:');
239
270
  for (const reason of reasons.slice(0, 8)) {
240
271
  if (typeof reason === 'string')
241
- lines.push(` - ${reason.replace(/[\u0000-\u001f\u007f]+/gu, ' ').trim().slice(0, 240)}`);
272
+ lines.push(` - ${safeTerminalValue(reason)}`);
242
273
  }
243
274
  if (reasons.length > 8)
244
275
  lines.push(` - ${reasons.length - 8} more reason(s) omitted.`);
@@ -249,8 +280,8 @@ function formatOfflineEvidenceReview(document) {
249
280
  const nestedLimitations = Array.isArray(result.limitations) ? result.limitations : [];
250
281
  for (const limitation of nestedLimitations) {
251
282
  if (typeof limitation === 'string')
252
- lines.push(` - ${limitation}`);
283
+ lines.push(` - ${safeTerminalValue(limitation)}`);
253
284
  }
254
285
  return `${lines.join('\n')}\n`;
255
286
  }
256
- export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, };
287
+ export { DNSSEC_EVIDENCE_INPUT_SCHEMA, ENCRYPTED_DNS_PLAN_INPUT_SCHEMA, LOCAL_GEOIP_QUERY_SCHEMA, MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_INPUT_VERSION, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, RDAP_SEARCH_INPUT_SCHEMA, RPKI_ROUTE_INPUT_SCHEMA, TLSA_EVIDENCE_INPUT_SCHEMA, buildOfflineEvidenceReview, buildOfflineEvidenceReviewWithLocalResources, formatOfflineEvidenceReview, };
@@ -1,5 +1,5 @@
1
1
  import { isIP } from 'node:net';
2
- import { parseExternalFindingsDocument, } from "../frontend/src/lib/analysis/external-findings-import.js";
2
+ import { parseExternalFindingsDocument, } from "../packages/interchange/external-findings-import.mjs";
3
3
  import { requireIsoTimestamp } from "../lib/bounded-contract-normalizers.mjs";
4
4
  export const OPEN_ASSET_MODEL_BRIDGE_SCHEMA = 'whoisleuth.open-asset-model-bridge';
5
5
  export const OPEN_ASSET_MODEL_BRIDGE_VERSION = 1;
@@ -5,7 +5,14 @@ import { basename, dirname, join, resolve } from 'node:path';
5
5
  import { CliUsageError } from "./errors.mjs";
6
6
  export const MAX_CLI_OUTPUT_BYTES = 32 * 1024 * 1024;
7
7
  const MAX_CLI_OUTPUT_PATH_LENGTH = 4096;
8
- const pendingOutputFiles = new Set();
8
+ const pendingOutputFiles = new Map();
9
+ const OUTPUT_FILE_OPERATIONS = Object.freeze({
10
+ randomUUID,
11
+ open,
12
+ link,
13
+ rename,
14
+ unlink,
15
+ });
9
16
  function safeOutputPath(value) {
10
17
  if (typeof value !== 'string' || !value || value.length > MAX_CLI_OUTPUT_PATH_LENGTH) {
11
18
  throw new CliUsageError(`Output paths are limited to ${MAX_CLI_OUTPUT_PATH_LENGTH} characters.`);
@@ -35,18 +42,22 @@ function createBufferedOutput() {
35
42
  value: () => chunks.join(''),
36
43
  });
37
44
  }
38
- async function writePrivateFile(pathValue, content, options = {}) {
45
+ async function writePrivateFile(pathValue, content, options = {}, operations = OUTPUT_FILE_OPERATIONS) {
39
46
  const target = safeOutputPath(pathValue);
40
47
  if (Buffer.byteLength(content, 'utf8') > MAX_CLI_OUTPUT_BYTES) {
41
48
  throw new CliUsageError(`Generated output is limited to ${MAX_CLI_OUTPUT_BYTES} bytes.`);
42
49
  }
43
50
  const directory = dirname(target);
44
- const temporary = join(directory, `.${basename(target)}.${process.pid}.${randomUUID()}.tmp`);
51
+ const temporary = join(directory, `.${basename(target)}.${process.pid}.${operations.randomUUID()}.tmp`);
45
52
  let temporaryCreated = false;
46
53
  try {
47
- const handle = await open(temporary, 'wx', 0o600);
54
+ const handle = await operations.open(temporary, 'wx', 0o600);
48
55
  temporaryCreated = true;
49
- pendingOutputFiles.add(temporary);
56
+ pendingOutputFiles.set(temporary, {
57
+ remove: () => operations.unlink(temporary),
58
+ published: false,
59
+ settled: false,
60
+ });
50
61
  try {
51
62
  await handle.writeFile(content, { encoding: 'utf8' });
52
63
  await handle.sync();
@@ -55,12 +66,12 @@ async function writePrivateFile(pathValue, content, options = {}) {
55
66
  await handle.close();
56
67
  }
57
68
  if (options.force) {
58
- await rename(temporary, target);
69
+ await operations.rename(temporary, target);
59
70
  temporaryCreated = false;
60
71
  }
61
72
  else {
62
73
  try {
63
- await link(temporary, target);
74
+ await operations.link(temporary, target);
64
75
  }
65
76
  catch (error) {
66
77
  const code = error && typeof error === 'object' && 'code' in error ? error.code : null;
@@ -69,19 +80,64 @@ async function writePrivateFile(pathValue, content, options = {}) {
69
80
  }
70
81
  throw error;
71
82
  }
72
- await unlink(temporary);
73
- temporaryCreated = false;
83
+ const pending = pendingOutputFiles.get(temporary);
84
+ if (pending)
85
+ pending.published = true;
86
+ try {
87
+ await operations.unlink(temporary);
88
+ temporaryCreated = false;
89
+ }
90
+ catch {
91
+ // The target is already published. A second best-effort cleanup runs
92
+ // below, and signal cleanup retains ownership if it still cannot run.
93
+ }
74
94
  }
75
95
  return target;
76
96
  }
77
97
  finally {
78
- if (temporaryCreated)
79
- await unlink(temporary).catch(() => { });
80
- pendingOutputFiles.delete(temporary);
98
+ if (temporaryCreated) {
99
+ try {
100
+ await operations.unlink(temporary);
101
+ temporaryCreated = false;
102
+ }
103
+ catch {
104
+ // Keep the path registered for best-effort signal cleanup.
105
+ }
106
+ }
107
+ if (temporaryCreated) {
108
+ const pending = pendingOutputFiles.get(temporary);
109
+ if (pending)
110
+ pending.settled = true;
111
+ }
112
+ if (!temporaryCreated)
113
+ pendingOutputFiles.delete(temporary);
81
114
  }
82
115
  }
116
+ async function cleanupPendingOutputFiles() {
117
+ const entries = [...pendingOutputFiles].filter(([, pending]) => pending.settled);
118
+ let removed = 0;
119
+ for (const [path, pending] of entries) {
120
+ try {
121
+ await pending.remove();
122
+ pendingOutputFiles.delete(path);
123
+ removed += 1;
124
+ }
125
+ catch {
126
+ // The caller receives an explicit retained count and can disclose that
127
+ // the published output is intact while temporary cleanup remains due.
128
+ }
129
+ }
130
+ const retainedFiles = [...pendingOutputFiles.values()].filter((item) => item.settled);
131
+ return Object.freeze({
132
+ attempted: entries.length,
133
+ removed,
134
+ retained: retainedFiles.length,
135
+ retainedPublished: retainedFiles.filter((item) => item.published).length,
136
+ retainedUnpublished: retainedFiles.filter((item) => !item.published).length,
137
+ });
138
+ }
83
139
  function cleanupPendingOutputFilesSync() {
84
- for (const path of pendingOutputFiles) {
140
+ for (const path of pendingOutputFiles.keys()) {
85
141
  try {
86
142
  unlinkSync(path);
87
143
  }
@@ -94,4 +150,4 @@ function cleanupPendingOutputFilesSync() {
94
150
  }
95
151
  }
96
152
  }
97
- export { cleanupPendingOutputFilesSync, createBufferedOutput, writePrivateFile };
153
+ export { cleanupPendingOutputFiles, cleanupPendingOutputFilesSync, createBufferedOutput, writePrivateFile };
@@ -1,10 +1,10 @@
1
1
  import { buildBulkComparisonEvidence } from "../lib/bulk-comparison-evidence.mjs";
2
- import { createPageBaseline } from "../frontend/src/lib/analysis/page-baseline.js";
3
- import { comparePageBaselines } from "../frontend/src/lib/analysis/page-similarity.js";
2
+ import { createPageBaseline } from "../packages/workspace/page-baseline.mjs";
3
+ import { comparePageBaselines } from "../packages/comparison/page-similarity.mjs";
4
4
  import { CliUsageError } from "./errors.mjs";
5
5
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
6
- export const CLI_PAGE_COMPARE_SCHEMA = 'whoisleuth.cli.page-compare';
7
- export const CLI_PAGE_COMPARE_VERSION = 2;
6
+ import { CLI_PAGE_COMPARE_SCHEMA, CLI_PAGE_COMPARE_VERSION, } from "../packages/contracts/offline-comparison.mjs";
7
+ export { CLI_PAGE_COMPARE_SCHEMA, CLI_PAGE_COMPARE_VERSION };
8
8
  function record(value) {
9
9
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
10
  }
@@ -56,6 +56,9 @@ function buildCliPageComparison(leftText, rightText, generatedAt = new Date().to
56
56
  && ['success', 'partial'].includes(right.compact.technology.state);
57
57
  const tlsUsable = ['success', 'partial'].includes(left.compact.tls.state)
58
58
  && ['success', 'partial'].includes(right.compact.tls.state);
59
+ const tlsPartial = left.compact.tls.state === 'partial' || right.compact.tls.state === 'partial';
60
+ const technologyPartial = left.compact.technology.truncated || right.compact.technology.truncated
61
+ || left.compact.technology.state === 'partial' || right.compact.technology.state === 'partial';
59
62
  return {
60
63
  schema: CLI_PAGE_COMPARE_SCHEMA,
61
64
  version: CLI_PAGE_COMPARE_VERSION,
@@ -72,25 +75,30 @@ function buildCliPageComparison(leftText, rightText, generatedAt = new Date().to
72
75
  },
73
76
  page,
74
77
  technology: {
75
- state: technologyUsable ? setComparison(leftTechnology, rightTechnology) : 'unavailable',
78
+ state: technologyUsable
79
+ ? technologyPartial ? 'partial' : setComparison(leftTechnology, rightTechnology)
80
+ : 'unavailable',
76
81
  leftSourceState: left.compact.technology.state,
77
82
  rightSourceState: right.compact.technology.state,
78
83
  leftIds: leftTechnology,
79
84
  rightIds: rightTechnology,
80
85
  sharedIds: sharedTechnology,
81
- partial: left.compact.technology.truncated || right.compact.technology.truncated
82
- || left.compact.technology.state === 'partial' || right.compact.technology.state === 'partial',
86
+ partial: technologyPartial,
83
87
  },
84
88
  tls: {
85
89
  leftSourceState: left.compact.tls.state,
86
90
  rightSourceState: right.compact.tls.state,
87
91
  issuer: {
88
- state: tlsUsable ? scalarComparison(left.compact.tls.issuerLabel, right.compact.tls.issuerLabel) : 'unavailable',
92
+ state: tlsUsable
93
+ ? tlsPartial ? 'partial' : scalarComparison(left.compact.tls.issuerLabel, right.compact.tls.issuerLabel)
94
+ : 'unavailable',
89
95
  left: left.compact.tls.issuerLabel,
90
96
  right: right.compact.tls.issuerLabel,
91
97
  },
92
98
  publicKey: {
93
- state: tlsUsable ? scalarComparison(left.compact.tls.spkiSha256, right.compact.tls.spkiSha256) : 'unavailable',
99
+ state: tlsUsable
100
+ ? tlsPartial ? 'partial' : scalarComparison(left.compact.tls.spkiSha256, right.compact.tls.spkiSha256)
101
+ : 'unavailable',
94
102
  leftSha256: left.compact.tls.spkiSha256,
95
103
  rightSha256: right.compact.tls.spkiSha256,
96
104
  },
@@ -115,7 +123,7 @@ function formatCliPageComparison(document) {
115
123
  for (const component of document.page.components) {
116
124
  lines.push(`${component.label.padEnd(18)} ${component.outcome}`);
117
125
  }
118
- lines.push('', 'Technology', `Relationship ${document.technology.state}`, `Shared ${document.technology.sharedIds.join(', ') || 'None observed'}`, `Left ${document.technology.leftIds.join(', ') || 'Unavailable'}`, `Right ${document.technology.rightIds.join(', ') || 'Unavailable'}`, '', 'TLS', `Issuer ${document.tls.issuer.state}`, `Public key ${document.tls.publicKey.state}`, '', 'Limitations:');
126
+ lines.push('', 'Technology', `Relationship ${document.technology.state}`, `Evidence ${document.technology.partial ? 'Partial; equality and disjointness withheld' : 'Complete retained sets'}`, `Shared ${document.technology.sharedIds.join(', ') || 'None observed'}`, `Left ${document.technology.leftIds.join(', ') || 'Unavailable'}`, `Right ${document.technology.rightIds.join(', ') || 'Unavailable'}`, '', 'TLS', `Issuer ${document.tls.issuer.state}`, `Public key ${document.tls.publicKey.state}`, '', 'Limitations:');
119
127
  for (const limitation of document.limitations)
120
128
  lines.push(` - ${limitation}`);
121
129
  return `${lines.join('\n')}\n`;
@@ -1,3 +1,4 @@
1
+ import { terminalSafeJson } from "./formatters/json.mjs";
1
2
  export const CLI_PROGRESS_EVENT_SCHEMA = 'whoisleuth.cli.progress';
2
3
  export const CLI_PROGRESS_EVENT_VERSION = 1;
3
4
  const MAX_CLI_PROGRESS_EVENTS = 2_100;
@@ -45,7 +46,7 @@ function createCliProgressEvents(stream, options) {
45
46
  ...(typeof fields.ok === 'boolean' ? { ok: fields.ok } : {}),
46
47
  ...(exitCode !== null ? { exitCode } : {}),
47
48
  };
48
- stream.write(`${JSON.stringify(document)}\n`);
49
+ stream.write(`${terminalSafeJson(document)}\n`);
49
50
  sequence += 1;
50
51
  },
51
52
  });
package/cli/progress.mjs CHANGED
@@ -10,7 +10,7 @@ function safeProgressMessage(value) {
10
10
  }
11
11
  function createTerminalProgress(stream, options) {
12
12
  const environment = options.environment || process.env;
13
- const presentation = terminalPresentation(stream, options.color, environment);
13
+ const presentation = terminalPresentation(stream, options.color, environment, options.palette);
14
14
  const enabled = options.enabled
15
15
  && presentation.interactive
16
16
  && environment.WHOISLEUTH_NO_PROGRESS !== '1';
@@ -23,8 +23,8 @@ function createTerminalProgress(stream, options) {
23
23
  if (!enabled || !stream)
24
24
  return;
25
25
  const elapsedSeconds = Math.max(0, Math.floor((now() - startedAt) / 1000));
26
- const marker = tone(PROGRESS_FRAMES[frame % PROGRESS_FRAMES.length] || '·', 'accent', presentation.color);
27
- const elapsed = tone(`${elapsedSeconds}s`, 'dim', presentation.color);
26
+ const marker = tone(PROGRESS_FRAMES[frame % PROGRESS_FRAMES.length] || '·', 'accent', presentation.color ? presentation.palette : false);
27
+ const elapsed = tone(`${elapsedSeconds}s`, 'dim', presentation.color ? presentation.palette : false);
28
28
  stream.write(`\r\u001b[2K${marker} ${message} · ${elapsed}`);
29
29
  frame += 1;
30
30
  }