@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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 (340) hide show
  1. package/DISCLOSURE +1 -1
  2. package/NOTICE +2 -2
  3. package/cli/argument-values.mjs +55 -0
  4. package/cli/arguments.mjs +24 -470
  5. package/cli/artifact-validation/case-response.mjs +67 -9
  6. package/cli/artifact-validation/lookup-evidence.mjs +18 -4
  7. package/cli/artifact-validation/signed-review.mjs +61 -64
  8. package/cli/artifact-verify.mjs +11 -2
  9. package/cli/assurance-arguments.mjs +56 -0
  10. package/cli/assurance-command-definitions.mjs +179 -0
  11. package/cli/assurance-command-runner.mjs +1 -1
  12. package/cli/bounded-json.mjs +1 -1
  13. package/cli/bulk-checkpoint.mjs +1 -1
  14. package/cli/bulk-command-runner.mjs +6 -8
  15. package/cli/bulk.mjs +2 -3
  16. package/cli/case-command.mjs +99 -6
  17. package/cli/case-pack.mjs +19 -4
  18. package/cli/ci-report.mjs +6 -3
  19. package/cli/collection-arguments.mjs +123 -0
  20. package/cli/collection-command-definitions.mjs +330 -0
  21. package/cli/collection-preflight.mjs +8 -1
  22. package/cli/command-argument-grammar.mjs +8 -210
  23. package/cli/command-definition.mjs +330 -0
  24. package/cli/command-reference.mjs +55 -1388
  25. package/cli/command-seeds.mjs +18 -0
  26. package/cli/compare.mjs +1 -1
  27. package/cli/config-profile.mjs +1 -1
  28. package/cli/ct-event-intake.mjs +4 -4
  29. package/cli/discover.mjs +2 -2
  30. package/cli/discovery-observation-snapshot.mjs +2 -2
  31. package/cli/discovery-scan-command-runner.mjs +7 -9
  32. package/cli/discovery-scan.mjs +1 -1
  33. package/cli/discovery-snapshot.mjs +2 -2
  34. package/cli/discovery-workflow.mjs +4 -4
  35. package/cli/domain-control-monitor.mjs +6 -6
  36. package/cli/domain-control-observations.mjs +2 -1
  37. package/cli/domain-feed.mjs +61 -0
  38. package/cli/errors.mjs +5 -6
  39. package/cli/evidence-arguments.mjs +46 -0
  40. package/cli/evidence-command-definitions.mjs +69 -0
  41. package/cli/evidence-command-runner.mjs +2 -3
  42. package/cli/evidence-signing.mjs +3 -2
  43. package/cli/external-observation-mapping.mjs +2 -2
  44. package/cli/formatters/json.mjs +1 -13
  45. package/cli/formatters/terminal-command-formats.mjs +6 -0
  46. package/cli/formatters/terminal-lookup.mjs +6 -0
  47. package/cli/history-arguments.mjs +39 -0
  48. package/cli/history-command-definitions.mjs +88 -0
  49. package/cli/indicator-set-command.mjs +32 -0
  50. package/cli/inline-command-families.mjs +6 -47
  51. package/cli/inline-command-runner.mjs +4 -31
  52. package/cli/input.mjs +2 -2
  53. package/cli/intake-command.mjs +147 -0
  54. package/cli/interchange-report.mjs +4 -2
  55. package/cli/investigation-artifacts.mjs +3 -4
  56. package/cli/investigation-package-review.mjs +2 -0
  57. package/cli/investigation-plan.mjs +32 -154
  58. package/cli/investigation-recipes.mjs +177 -0
  59. package/cli/investigation-run.mjs +78 -12
  60. package/cli/local-mmdb-review.mjs +220 -17
  61. package/cli/lookalike-calibration.mjs +1 -1
  62. package/cli/lookup-brief.mjs +2 -2
  63. package/cli/lookup-command-runner.mjs +19 -35
  64. package/cli/lookup-diff.mjs +10 -9
  65. package/cli/lookup-plan.mjs +10 -14
  66. package/cli/lookup-reconcile.mjs +6 -5
  67. package/cli/lookup-timeline.mjs +7 -6
  68. package/cli/mail-header-review.mjs +24 -247
  69. package/cli/mail-review.mjs +9 -7
  70. package/cli/network-arguments.mjs +51 -0
  71. package/cli/network-command-definitions.mjs +130 -0
  72. package/cli/network-command-runner.mjs +43 -31
  73. package/cli/offline-evidence-review.mjs +85 -6
  74. package/cli/posture.mjs +1 -1
  75. package/cli/registry-fixture-scaffold.mjs +1 -1
  76. package/cli/retained-artifact-diff.mjs +8 -7
  77. package/cli/review-arguments.mjs +132 -0
  78. package/cli/review-command-definitions.mjs +279 -0
  79. package/cli/review-command-runner.mjs +72 -159
  80. package/cli/risk-calibration.mjs +14 -12
  81. package/cli/runner.mjs +20 -32
  82. package/cli/saved-lookup.mjs +2 -2
  83. package/cli/selected-input-worker.mjs +73 -0
  84. package/cli/sharing-review.mjs +5 -64
  85. package/cli/source-reliability.mjs +2 -2
  86. package/cli/support-arguments.mjs +49 -0
  87. package/cli/support-command-definitions.mjs +209 -0
  88. package/cli/support-command-runner.mjs +4 -4
  89. package/cli/watchlist-review.mjs +43 -0
  90. package/cli/workflow-arguments.mjs +100 -0
  91. package/cli/workflow-command-definitions.mjs +103 -0
  92. package/cli/workflow-command-runner.mjs +40 -1
  93. package/docs/cli-reference.md +27 -0
  94. package/docs/cli.md +322 -4
  95. package/docs/contextual-reviews.md +178 -0
  96. package/docs/infrastructure-observations.md +126 -0
  97. package/docs/offline-intake-context.md +213 -0
  98. package/lib/analyst-taxonomy.mjs +2 -40
  99. package/lib/application-version.mjs +1 -1
  100. package/lib/availability.mjs +13 -789
  101. package/lib/bounded-contract-normalizers.mjs +4 -0
  102. package/lib/bounded-json.mjs +2 -330
  103. package/lib/browser-library-profile.mjs +62 -84
  104. package/lib/candidate-provenance-bounds.mjs +2 -0
  105. package/lib/classify.mjs +1 -1
  106. package/lib/credential-form-attribution.mjs +136 -0
  107. package/lib/credential-surface-profile.mjs +6 -2
  108. package/lib/ct-query.mjs +2 -27
  109. package/lib/ct-response-bounds.mjs +2 -9
  110. package/lib/ct-search.mjs +83 -47
  111. package/lib/dmarc-discovery.mjs +62 -0
  112. package/lib/dns-convergence-review.mjs +21 -8
  113. package/lib/dns-delegation-health.mjs +35 -15
  114. package/lib/dns-inheritance-review.mjs +53 -63
  115. package/lib/dns-intelligence.mjs +27 -11
  116. package/lib/dns-query-coverage.mjs +40 -0
  117. package/lib/domain-assurance.mjs +1 -1
  118. package/lib/domain-change-packet.mjs +12 -13
  119. package/lib/domain-change-review.mjs +32 -14
  120. package/lib/domain-control-flight-recorder.mjs +103 -27
  121. package/lib/domain-control-manifest.mjs +25 -17
  122. package/lib/domain-posture-analysis.mjs +75 -7
  123. package/lib/domain-posture-parsers.mjs +9 -0
  124. package/lib/domain-posture.mjs +45 -25
  125. package/lib/domain-registration.mjs +392 -0
  126. package/lib/evidence-export-privacy.mjs +27 -17
  127. package/lib/evidence-export.mjs +3 -3
  128. package/lib/evidence-report.mjs +5 -1
  129. package/lib/external-intelligence-risk.mjs +2 -124
  130. package/lib/favicon.mjs +13 -4
  131. package/lib/generated/cisa-kev-catalog.mjs +41 -3
  132. package/lib/generated/registrar-standing-catalogue.mjs +18 -10
  133. package/lib/generated/retire-browser-catalog.mjs +332 -19
  134. package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
  135. package/lib/html-signals.mjs +13 -2
  136. package/lib/http-evidence-bounds.mjs +2 -8
  137. package/lib/http-intelligence.mjs +13 -1
  138. package/lib/idn-confusable-policy.mjs +6 -4
  139. package/lib/idn-confusables.mjs +1 -1
  140. package/lib/interchange-fidelity-registry.mjs +10 -0
  141. package/lib/lookup-child-profile-contract.mjs +28 -11
  142. package/lib/lookup-contract-primitives.mjs +1 -1
  143. package/lib/lookup-diagnostics.mjs +1 -23
  144. package/lib/lookup-network-evidence-bounds.mjs +1 -10
  145. package/lib/lookup-progress.mjs +50 -1
  146. package/lib/lookup-response-contract.mjs +70 -40
  147. package/lib/lookup-response.mjs +5 -15
  148. package/lib/lookup-source-progress.mjs +8 -22
  149. package/lib/lookup.mjs +1 -1
  150. package/lib/operation-budget.mjs +4 -0
  151. package/lib/opportunity-scoring.mjs +2 -107
  152. package/lib/outbound-identity.mjs +1 -1
  153. package/lib/page-fingerprints.mjs +18 -1
  154. package/lib/perceptual-hash-comparison.mjs +2 -34
  155. package/lib/perceptual-hash.mjs +36 -10
  156. package/lib/portable-generator.mjs +2 -29
  157. package/lib/project-metadata.mjs +2 -8
  158. package/lib/rdap-client.mjs +11 -7
  159. package/lib/registrable-domain.mjs +2 -56
  160. package/lib/registrar-standing-contract.mjs +1 -1
  161. package/lib/registrar-standing.mjs +9 -13
  162. package/lib/registry-capabilities.mjs +2 -2
  163. package/lib/registry-capability-catalogue.mjs +27 -19
  164. package/lib/risk-calibration-summary.mjs +2 -352
  165. package/lib/risk-scoring.mjs +2 -345
  166. package/lib/scoring-evidence-quality.mjs +2 -107
  167. package/lib/semantic-version.mjs +2 -54
  168. package/lib/service-binding-dns.mjs +46 -2
  169. package/lib/smtp-transport-review.mjs +48 -19
  170. package/lib/sslbl-certificates.generated.mjs +142 -139
  171. package/lib/static-html-analysis.mjs +32 -4
  172. package/lib/structured-data-identity.mjs +1 -1
  173. package/lib/technology-evidence-role.mjs +1 -51
  174. package/lib/threat-intelligence-manifests.mjs +1 -1
  175. package/lib/threat-intelligence-runtime.mjs +1 -1
  176. package/lib/threat-intelligence-targets.mjs +1 -1
  177. package/lib/threat-intelligence-types.mjs +2 -75
  178. package/lib/tls-intelligence.mjs +30 -5
  179. package/lib/typosquat-generator.mjs +11 -2
  180. package/lib/website-enrichment.mjs +404 -0
  181. package/lib/whois-authority.mjs +43 -5
  182. package/lib/whois-chain.mjs +12 -3
  183. package/lib/whois-common-formats.mjs +3 -1
  184. package/lib/whois-contacts.mjs +13 -2
  185. package/lib/whois-normalization.mjs +3 -1
  186. package/lib/whois-parser.mjs +5 -3
  187. package/package.json +15 -14
  188. package/packages/analysis/analyst-taxonomy.mjs +40 -0
  189. package/packages/analysis/bounded-json.mjs +330 -0
  190. package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
  191. package/packages/analysis/cli-argument-grammar.mjs +216 -0
  192. package/packages/analysis/ct-query.mjs +27 -0
  193. package/packages/analysis/ct-response-bounds.mjs +9 -0
  194. package/packages/analysis/external-intelligence-risk.mjs +124 -0
  195. package/packages/analysis/http-evidence-bounds.mjs +8 -0
  196. package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
  197. package/packages/analysis/opportunity-scoring.mjs +107 -0
  198. package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
  199. package/packages/analysis/portable-generator.mjs +29 -0
  200. package/packages/analysis/project-metadata.mjs +8 -0
  201. package/packages/analysis/registrable-domain.mjs +56 -0
  202. package/packages/analysis/risk-calibration-summary.mjs +352 -0
  203. package/packages/analysis/risk-scoring.mjs +348 -0
  204. package/packages/analysis/scoring-evidence-quality.mjs +107 -0
  205. package/packages/analysis/semantic-version.mjs +54 -0
  206. package/packages/analysis/threat-intelligence-types.mjs +75 -0
  207. package/packages/cases/case-containment-handoff.mjs +195 -0
  208. package/packages/cases/case-evidence-links.mjs +151 -0
  209. package/packages/cases/case-evidence-model.mjs +89 -12
  210. package/packages/cases/case-evidence-quality.mjs +5 -0
  211. package/packages/cases/case-export-input.mjs +7 -5
  212. package/packages/cases/case-incident-context.mjs +52 -0
  213. package/packages/cases/case-markdown.mjs +11 -0
  214. package/packages/cases/case-migration-model.mjs +40 -24
  215. package/packages/cases/case-object-coverage.mjs +33 -0
  216. package/packages/cases/case-packet-correction.mjs +106 -0
  217. package/packages/cases/case-recheck-model.mjs +124 -36
  218. package/packages/cases/case-record-contracts.mjs +1 -1
  219. package/packages/cases/case-record-core.mjs +1 -1
  220. package/packages/cases/case-record-operations.mjs +111 -118
  221. package/packages/cases/case-record-projection.mjs +23 -4
  222. package/packages/cases/case-record-version-input.mjs +41 -0
  223. package/packages/cases/case-report.mjs +93 -27
  224. package/packages/cases/case-requested-evidence.mjs +146 -0
  225. package/packages/cases/case-response-actions.mjs +159 -1
  226. package/packages/cases/case-response-model.mjs +16 -1
  227. package/packages/cases/case-response-object.mjs +119 -0
  228. package/packages/cases/case-response-outcomes.mjs +108 -20
  229. package/packages/cases/case-response-packet-render.mjs +35 -12
  230. package/packages/cases/case-response-packet.mjs +55 -13
  231. package/packages/cases/case-response-records.mjs +0 -1
  232. package/packages/cases/case-response-review-inputs.mjs +60 -8
  233. package/packages/cases/case-storage-model.mjs +1 -1
  234. package/packages/cases/case-workflow-metadata.mjs +217 -0
  235. package/packages/cases/case-workflow-migration.mjs +43 -0
  236. package/packages/cases/http-summary.mjs +6 -1
  237. package/packages/comparison/favicon-similarity.mjs +1 -1
  238. package/packages/comparison/page-similarity.mjs +1 -1
  239. package/packages/comparison/relationship-evidence.mjs +21 -9
  240. package/packages/comparison/relationship-provenance.mjs +7 -1
  241. package/packages/contracts/analyst-interchange.mjs +35 -15
  242. package/packages/contracts/candidate-watch-review.mjs +54 -0
  243. package/packages/contracts/case-portability.mjs +360 -49
  244. package/packages/contracts/cli-command-semantics.mjs +5 -0
  245. package/packages/contracts/cli-grammar.mjs +6 -0
  246. package/packages/contracts/cli-lookup-brief.mjs +2 -0
  247. package/packages/contracts/context-review.mjs +25 -0
  248. package/packages/contracts/document-intake.mjs +11 -0
  249. package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
  250. package/packages/contracts/domain-control-manifest.mjs +1 -1
  251. package/packages/contracts/domain-control-monitor.mjs +13 -2
  252. package/packages/contracts/domain-control-review.mjs +36 -15
  253. package/packages/contracts/external-observation-interchange.mjs +96 -28
  254. package/packages/contracts/har-review.mjs +2 -0
  255. package/packages/contracts/identity-events.mjs +4 -0
  256. package/packages/contracts/investigation-run.mjs +2 -0
  257. package/packages/contracts/ip-address.mjs +10 -0
  258. package/packages/contracts/lookup-evidence.mjs +3 -2
  259. package/packages/contracts/mail-authentication.mjs +5 -0
  260. package/packages/contracts/message-intake.mjs +37 -0
  261. package/packages/contracts/offline-comparison.mjs +4 -1
  262. package/packages/contracts/passive-mail.mjs +10 -0
  263. package/packages/contracts/risk-calibration.mjs +44 -29
  264. package/packages/contracts/schema-lifecycle.mjs +3 -1
  265. package/packages/contracts/web-capture.mjs +2 -2
  266. package/packages/contracts/workspace-portability.mjs +144 -28
  267. package/packages/evidence/collection-quality.mjs +67 -0
  268. package/packages/evidence/credential-form-attribution.mjs +60 -0
  269. package/packages/evidence/dns-query-outcome.mjs +72 -0
  270. package/packages/evidence/email-recipient.mjs +18 -0
  271. package/packages/evidence/image-regions.mjs +15 -0
  272. package/packages/evidence/lookup-target.mjs +5 -1
  273. package/packages/evidence/observation.mjs +2 -0
  274. package/packages/evidence/png-pixels.mjs +117 -0
  275. package/packages/evidence/public-address-policy.mjs +83 -0
  276. package/packages/evidence/record-identity.mjs +60 -16
  277. package/packages/evidence/technology-evidence-role.mjs +51 -0
  278. package/packages/interchange/defensive-indicator-export.mjs +264 -0
  279. package/packages/interchange/external-findings-import.mjs +36 -29
  280. package/packages/interchange/json-output.mjs +14 -0
  281. package/packages/interchange/managed-indicator-export.mjs +36 -0
  282. package/packages/interchange/managed-indicator-set.mjs +223 -0
  283. package/packages/interchange/misp-indicator-export.mjs +93 -0
  284. package/packages/interchange/sharing-policy.mjs +56 -0
  285. package/packages/interchange/stix-indicator-export.mjs +129 -0
  286. package/packages/interchange/web-capture-import.mjs +22 -15
  287. package/packages/investigation/acquisition-decision-packet.mjs +79 -1
  288. package/packages/investigation/bulk-mail-exposure.mjs +7 -13
  289. package/packages/investigation/capture-coverage.mjs +57 -0
  290. package/packages/investigation/connector-provenance-review.mjs +100 -0
  291. package/packages/investigation/context-review.mjs +35 -0
  292. package/packages/investigation/document-intake.mjs +87 -0
  293. package/packages/investigation/docx-intake.mjs +133 -0
  294. package/packages/investigation/domain-history-review.mjs +132 -0
  295. package/packages/investigation/domain-transition-review.mjs +49 -0
  296. package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
  297. package/packages/investigation/har-intake.mjs +68 -0
  298. package/packages/investigation/identity-event-intake.mjs +131 -0
  299. package/packages/investigation/identity-incident-review.mjs +39 -0
  300. package/packages/investigation/incident-sequence-review.mjs +58 -0
  301. package/packages/investigation/infrastructure-observation.mjs +234 -0
  302. package/packages/investigation/intake-context.mjs +284 -0
  303. package/packages/investigation/intake-indicators.mjs +116 -0
  304. package/packages/investigation/intake-phones.mjs +155 -0
  305. package/packages/investigation/intake-report.mjs +20 -0
  306. package/packages/investigation/investigation-manifest.mjs +35 -9
  307. package/packages/investigation/link-intake.mjs +209 -0
  308. package/packages/investigation/lookup-asset-graph.mjs +1 -1
  309. package/packages/investigation/mail-authentication-review.mjs +222 -0
  310. package/packages/investigation/mail-header-identity.mjs +209 -0
  311. package/packages/investigation/message-intake.mjs +235 -0
  312. package/packages/investigation/page-behaviour.mjs +113 -0
  313. package/packages/investigation/pdf-intake.mjs +155 -0
  314. package/packages/investigation/platform-continuity-review.mjs +60 -0
  315. package/packages/investigation/qr-intake.mjs +56 -0
  316. package/packages/investigation/requested-action-hints.mjs +11 -0
  317. package/packages/investigation/selected-input-review.mjs +17 -0
  318. package/packages/investigation/storefront-review.mjs +70 -0
  319. package/packages/monitoring/domain-feed.mjs +321 -0
  320. package/packages/workspace/brand-candidate-workflow.mjs +351 -0
  321. package/packages/workspace/brand-keyword-campaign.mjs +231 -0
  322. package/packages/workspace/brand-profile-model.mjs +48 -4
  323. package/packages/workspace/bulk-review-model.mjs +28 -26
  324. package/packages/workspace/bulk-session-model.mjs +27 -13
  325. package/packages/workspace/campaign-model.mjs +1 -1
  326. package/packages/workspace/candidate-watch-handoff.mjs +195 -0
  327. package/packages/workspace/detection-rule-model.mjs +11 -3
  328. package/packages/workspace/hostile-input.mjs +1 -1
  329. package/packages/workspace/investigation-guide.mjs +2 -2
  330. package/packages/workspace/investigation-template-model.mjs +26 -2
  331. package/packages/workspace/page-baseline.mjs +2 -2
  332. package/packages/workspace/relationship-observation-model.mjs +17 -13
  333. package/packages/workspace/shortlist-model.mjs +24 -7
  334. package/packages/workspace/watchlist-history.mjs +59 -16
  335. package/packages/workspace/watchlist-store.mjs +152 -11
  336. package/packages/workspace/website-snapshot-model.mjs +28 -3
  337. package/packages/workspace/workspace-archive-crypto.mjs +1 -1
  338. package/packages/workspace/workspace-archive.mjs +5 -4
  339. package/third-party-notices.txt +549 -16
  340. package/lib/hostname.mjs +0 -2
@@ -1,12 +1,24 @@
1
1
  import { constants as fsConstants } from 'node:fs';
2
2
  import { open, realpath } from 'node:fs/promises';
3
3
  import { isIP } from 'node:net';
4
+ import { createHash } from 'node:crypto';
5
+ import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
4
6
  import { Reader } from 'maxmind';
5
7
  import { formatIpPrefix, parseIpPrefix } from "../lib/ip-prefix.mjs";
6
8
  import { CliUsageError } from "./errors.mjs";
9
+ import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
10
+ import { canonicalPublicIpAddress } from "../packages/evidence/public-address-policy.mjs";
7
11
  export const LOCAL_MMDB_QUERY_SCHEMA = 'whoisleuth.local-mmdb-query';
8
- export const LOCAL_MMDB_QUERY_VERSION = 1;
12
+ export const LOCAL_MMDB_QUERY_VERSION = 2;
13
+ export const LOCAL_MMDB_REVIEW_SCHEMA = 'whoisleuth.local-mmdb-review';
14
+ export const LOCAL_MMDB_REVIEW_VERSION = 1;
9
15
  export const MAX_LOCAL_MMDB_BYTES = 512 * 1024 * 1024;
16
+ export const LOCAL_MMDB_REVIEW_DEADLINE_MS = 10_000;
17
+ export const MAX_LOCAL_MMDB_REVIEW_BYTES = 8 * 1024;
18
+ const DAY_MS = 86_400_000;
19
+ const LOCAL_MMDB_WORKER_KIND = 'local-mmdb-review';
20
+ const SUPPORTED_DATABASE_TYPES = new Set(['GeoIP2-City', 'GeoLite2-City', 'GeoIP2-Country', 'GeoLite2-Country',
21
+ 'GeoIP2-ASN', 'GeoLite2-ASN', 'GeoIP2-ISP', 'GeoIP2-Enterprise']);
10
22
  function record(value) {
11
23
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
12
24
  }
@@ -19,24 +31,33 @@ function names(value) {
19
31
  const source = record(value);
20
32
  return boundedText(source.en, 120);
21
33
  }
22
- async function readStableMmdb(databasePath) {
34
+ async function readStableMmdb(databasePath, openFile = open, signal) {
23
35
  let handle = null;
36
+ let database = null;
24
37
  try {
38
+ signal?.throwIfAborted();
25
39
  const resolvedPath = await realpath(databasePath);
26
- handle = await open(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
40
+ signal?.throwIfAborted();
41
+ handle = await openFile(resolvedPath, fsConstants.O_RDONLY | fsConstants.O_NONBLOCK | fsConstants.O_NOFOLLOW);
42
+ signal?.throwIfAborted();
27
43
  const before = await handle.stat();
44
+ signal?.throwIfAborted();
28
45
  if (!before.isFile() || before.size <= 0 || before.size > MAX_LOCAL_MMDB_BYTES) {
29
46
  throw new CliUsageError(`The supplied MMDB must be a file no larger than ${MAX_LOCAL_MMDB_BYTES} bytes.`);
30
47
  }
31
- const database = Buffer.allocUnsafe(before.size);
48
+ // Dedicated backing storage can be transferred without copying a pooled
49
+ // buffer or exposing bytes outside the stable file's exact length.
50
+ database = Buffer.allocUnsafeSlow(before.size);
32
51
  let offset = 0;
33
52
  while (offset < database.length) {
34
53
  const { bytesRead } = await handle.read(database, offset, database.length - offset, offset);
54
+ signal?.throwIfAborted();
35
55
  if (bytesRead === 0)
36
56
  break;
37
57
  offset += bytesRead;
38
58
  }
39
59
  const after = await handle.stat();
60
+ signal?.throwIfAborted();
40
61
  if (offset !== database.length
41
62
  || before.dev !== after.dev
42
63
  || before.ino !== after.ino
@@ -48,6 +69,8 @@ async function readStableMmdb(databasePath) {
48
69
  return database;
49
70
  }
50
71
  catch (cause) {
72
+ database?.fill(0);
73
+ signal?.throwIfAborted();
51
74
  if (cause instanceof CliUsageError)
52
75
  throw cause;
53
76
  throw new CliUsageError('The supplied MMDB file could not be read.');
@@ -56,7 +79,7 @@ async function readStableMmdb(databasePath) {
56
79
  await handle?.close().catch(() => { });
57
80
  }
58
81
  }
59
- export async function reviewLocalMmdb(input, databasePath) {
82
+ function prepareQuery(input, checkedAt) {
60
83
  const address = boundedText(input.address, 64)?.toLowerCase() ?? '';
61
84
  const sourceLabel = boundedText(input.sourceLabel, 120);
62
85
  const databaseVersion = boundedText(input.databaseVersion, 80);
@@ -66,15 +89,79 @@ export async function reviewLocalMmdb(input, databasePath) {
66
89
  if (!sourceLabel || !databaseVersion || !license) {
67
90
  throw new CliUsageError('Local MMDB review requires bounded sourceLabel, databaseVersion, and licence metadata.');
68
91
  }
92
+ if (input.version !== undefined && input.version !== 1 && input.version !== LOCAL_MMDB_QUERY_VERSION) {
93
+ throw new CliUsageError('Local MMDB review requires a supported query version.');
94
+ }
95
+ if (input.version !== LOCAL_MMDB_QUERY_VERSION)
96
+ return { version: 1, address, sourceLabel, databaseVersion, license, checkedAt: null, freshnessPolicy: null };
97
+ const policy = record(input.freshnessPolicy);
98
+ const rationale = boundedText(policy.rationale, 240);
99
+ const reviewedAt = normalizeExplicitIsoTimestamp(checkedAt);
100
+ if (!reviewedAt || !rationale || /[\u0080-\u009f]/u.test(String(policy.rationale)) || !Number.isSafeInteger(policy.maxAgeDays) || Number(policy.maxAgeDays) <= 0
101
+ || Number(policy.maxAgeDays) > Math.floor(Number.MAX_SAFE_INTEGER / DAY_MS)) {
102
+ throw new CliUsageError('Current MMDB review requires an explicit review time and freshnessPolicy with positive whole maxAgeDays and a bounded plain-text rationale.');
103
+ }
104
+ return { version: 2, address, sourceLabel, databaseVersion, license, checkedAt: reviewedAt,
105
+ freshnessPolicy: { maxAgeDays: Number(policy.maxAgeDays), rationale } };
106
+ }
107
+ /** Intrinsic metadata is separate from the analyst's declared version/licence.
108
+ * The policy admits age for this review, not accuracy or an update guarantee. */
109
+ export function inspectLocalMmdbMetadata(value, byteLength, checkedAt, policy) {
110
+ const metadata = record(value);
111
+ const databaseType = boundedText(metadata.databaseType, 120);
112
+ const buildMs = metadata.buildEpoch instanceof Date ? metadata.buildEpoch.getTime() : NaN;
113
+ const reviewMs = Date.parse(normalizeExplicitIsoTimestamp(checkedAt) ?? '');
114
+ const integer = (candidate) => typeof candidate === 'number' && Number.isSafeInteger(candidate) && candidate >= 0;
115
+ if (!databaseType || metadata.databaseType !== databaseType || !Number.isSafeInteger(buildMs) || buildMs < 0 || buildMs % 1000 !== 0 || !Number.isFinite(reviewMs)
116
+ || !integer(metadata.binaryFormatMajorVersion) || !integer(metadata.binaryFormatMinorVersion)
117
+ || ![4, 6].includes(Number(metadata.ipVersion)) || typeof metadata.ipVersion !== 'number'
118
+ || !integer(metadata.nodeCount) || metadata.nodeCount < 1 || ![24, 28, 32].includes(Number(metadata.recordSize))
119
+ || typeof metadata.recordSize !== 'number' || !integer(metadata.searchTreeSize)
120
+ || metadata.searchTreeSize !== metadata.nodeCount * metadata.recordSize / 4 || metadata.searchTreeSize + 16 >= byteLength) {
121
+ return { metadata: null, ageDays: null, reason: 'invalid_metadata' };
122
+ }
123
+ const intrinsic = { databaseType, builtAt: new Date(buildMs).toISOString(),
124
+ binaryFormat: { major: metadata.binaryFormatMajorVersion, minor: metadata.binaryFormatMinorVersion }, ipVersion: metadata.ipVersion };
125
+ const ageDays = (reviewMs - buildMs) / DAY_MS;
126
+ const reason = metadata.binaryFormatMajorVersion !== 2 || metadata.binaryFormatMinorVersion !== 0 ? 'unsupported_format'
127
+ : !SUPPORTED_DATABASE_TYPES.has(databaseType) ? 'unsupported_database'
128
+ : ageDays < 0 ? 'future_database' : ageDays > policy.maxAgeDays ? 'stale_database' : 'admitted';
129
+ return { metadata: intrinsic, ageDays, reason };
130
+ }
131
+ const LIMITATIONS = Object.freeze([
132
+ 'This CLI-only result comes from an analyst-supplied local MMDB file. WHOISleuth does not bundle, download, update, license, or transmit the database.',
133
+ 'Geolocation and network attribution are provider estimates and may be stale, coarse, shared, or incorrect.',
134
+ 'A location or ASN association does not establish the operator, owner, user, intent, safety, or maliciousness of an address.',
135
+ ]);
136
+ function currentResult(query, identity, admission, reason, match = null) {
137
+ const state = match ? reason === 'matched' ? 'matched' : 'partial' : 'unavailable';
138
+ return { schema: LOCAL_MMDB_REVIEW_SCHEMA, version: LOCAL_MMDB_REVIEW_VERSION, state, reason,
139
+ completeness: state === 'matched' ? 'complete' : state === 'partial' ? 'partial' : 'unavailable',
140
+ address: query.address, match, source: { label: query.sourceLabel, version: query.databaseVersion, license: query.license },
141
+ database: { ...identity, metadata: admission.metadata },
142
+ freshness: { state: admission.reason === 'stale_database' ? 'stale' : admission.reason === 'admitted' ? 'current' : 'unknown',
143
+ checkedAt: query.checkedAt, ageDays: admission.ageDays, policy: query.freshnessPolicy },
144
+ limitations: [...LIMITATIONS, 'Declared source, version and licence labels are analyst claims; intrinsic metadata and digest do not authenticate the database or verify licensing.',
145
+ 'Freshness is checked against the explicitly justified age policy. A miss, rejected or stale database is unavailable context, not evidence of location or absence.'] };
146
+ }
147
+ function reviewBytes(query, database, identity) {
69
148
  let reader;
70
149
  try {
71
- reader = new Reader(await readStableMmdb(databasePath));
150
+ reader = new Reader(database);
72
151
  }
73
- catch (cause) {
74
- if (cause instanceof CliUsageError)
75
- throw cause;
152
+ catch {
153
+ if (query.version === 2)
154
+ return currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, 'invalid_database');
76
155
  throw new CliUsageError('The supplied file is not a readable MaxMind DB database.');
77
156
  }
157
+ const admission = query.version === 2 ? inspectLocalMmdbMetadata(reader.metadata, database.length, query.checkedAt, query.freshnessPolicy) : null;
158
+ if (admission && admission.reason !== 'admitted')
159
+ return currentResult(query, identity, admission, admission.reason);
160
+ if (admission && !canonicalPublicIpAddress(query.address))
161
+ return currentResult(query, identity, admission, 'unsupported_address');
162
+ if (admission && isIP(query.address) === 6 && admission.metadata?.ipVersion === 4)
163
+ return currentResult(query, identity, admission, 'unsupported_address_family');
164
+ const address = query.address;
78
165
  const [rawMatch, prefixLength] = reader.getWithPrefixLength(address);
79
166
  const match = record(rawMatch);
80
167
  const traits = record(match.traits);
@@ -84,9 +171,10 @@ export async function reviewLocalMmdb(input, databasePath) {
84
171
  const subdivision = record(subdivisions[0]);
85
172
  const city = record(match.city);
86
173
  const asnValue = match.autonomous_system_number ?? traits.autonomous_system_number;
87
- const asn = typeof asnValue === 'number' && Number.isSafeInteger(asnValue) && asnValue >= 0 ? asnValue : null;
174
+ const asn = typeof asnValue === 'number' && Number.isSafeInteger(asnValue) && asnValue >= 0
175
+ && (query.version === 1 || asnValue <= 4_294_967_295) ? asnValue : null;
88
176
  const prefix = parseIpPrefix(`${address}/${prefixLength}`);
89
- return Object.freeze({
177
+ const result = Object.freeze({
90
178
  state: rawMatch ? 'matched' : 'not_found',
91
179
  address,
92
180
  match: rawMatch ? Object.freeze({
@@ -97,11 +185,126 @@ export async function reviewLocalMmdb(input, databasePath) {
97
185
  asn,
98
186
  asName: boundedText(match.autonomous_system_organization ?? traits.autonomous_system_organization, 240),
99
187
  }) : null,
100
- source: Object.freeze({ label: sourceLabel, version: databaseVersion, license }),
101
- limitations: Object.freeze([
102
- 'This CLI-only result comes from an analyst-supplied local MMDB file. WHOISleuth does not bundle, download, update, license, or transmit the database.',
103
- 'Geolocation and network attribution are provider estimates and may be stale, coarse, shared, or incorrect.',
104
- 'A location or ASN association does not establish the operator, owner, user, intent, safety, or maliciousness of an address.',
105
- ]),
188
+ source: Object.freeze({ label: query.sourceLabel, version: query.databaseVersion, license: query.license }),
189
+ limitations: LIMITATIONS,
106
190
  });
191
+ if (!admission)
192
+ return result;
193
+ if (!rawMatch)
194
+ return currentResult(query, identity, admission, 'no_match');
195
+ if (!result.match || !prefix || !rawMatch || typeof rawMatch !== 'object' || Array.isArray(rawMatch))
196
+ return currentResult(query, identity, admission, 'invalid_record');
197
+ const rawCountry = country.iso_code;
198
+ const countryCode = boundedText(rawCountry, 2)?.toUpperCase() ?? null;
199
+ const projected = { ...result.match, countryCode: countryCode && /^[A-Z]{2}$/u.test(countryCode) ? countryCode : null };
200
+ if (![projected.countryCode, projected.region, projected.city, projected.asn, projected.asName].some(value => value !== null)) {
201
+ return currentResult(query, identity, admission, 'no_useful_record');
202
+ }
203
+ const invalid = rawCountry !== undefined && projected.countryCode === null
204
+ || asnValue !== undefined && asn === null
205
+ || match.country !== undefined && (!match.country || typeof match.country !== 'object' || Array.isArray(match.country))
206
+ || match.city !== undefined && (!match.city || typeof match.city !== 'object' || Array.isArray(match.city))
207
+ || match.subdivisions !== undefined && !Array.isArray(match.subdivisions)
208
+ || subdivision.names !== undefined && projected.region === null
209
+ || subdivision.iso_code !== undefined && projected.region === null
210
+ || city.names !== undefined && projected.city === null
211
+ || (match.autonomous_system_organization ?? traits.autonomous_system_organization) !== undefined && projected.asName === null;
212
+ return currentResult(query, identity, admission, invalid ? 'incomplete_record' : 'matched', projected);
213
+ }
214
+ function freezeResult(result) {
215
+ if (result.match)
216
+ Object.freeze(result.match);
217
+ Object.freeze(result.source);
218
+ Object.freeze(result.limitations);
219
+ if ('database' in result) {
220
+ if (result.database.metadata) {
221
+ Object.freeze(result.database.metadata.binaryFormat);
222
+ Object.freeze(result.database.metadata);
223
+ }
224
+ Object.freeze(result.database);
225
+ Object.freeze(result.freshness.policy);
226
+ Object.freeze(result.freshness);
227
+ }
228
+ return Object.freeze(result);
229
+ }
230
+ if (!isMainThread && parentPort && record(workerData).kind === LOCAL_MMDB_WORKER_KIND) {
231
+ const port = parentPort, request = workerData;
232
+ try {
233
+ const bytes = Buffer.from(request.bytes.buffer, request.bytes.byteOffset, request.bytes.byteLength);
234
+ const result = reviewBytes(request.query, bytes, request.identity);
235
+ if (Buffer.byteLength(JSON.stringify(result), 'utf8') > MAX_LOCAL_MMDB_REVIEW_BYTES)
236
+ throw new TypeError('Local result exceeds its output bound.');
237
+ port.postMessage({ result });
238
+ }
239
+ catch {
240
+ port.postMessage({ error: true });
241
+ }
242
+ finally {
243
+ request.bytes.fill(0);
244
+ port.close();
245
+ }
246
+ }
247
+ /** One disposable worker pre-empts the existing synchronous reader. It is not
248
+ * a sandbox and never receives a database path or inherited environment. */
249
+ export async function reviewLocalMmdb(input, databasePath, checkedAt = new Date().toISOString(), dependencies = {}) {
250
+ const { signal } = dependencies;
251
+ signal?.throwIfAborted();
252
+ const query = prepareQuery(input, checkedAt);
253
+ const deadlineMs = dependencies.deadlineMs ?? LOCAL_MMDB_REVIEW_DEADLINE_MS;
254
+ if (!Number.isSafeInteger(deadlineMs) || deadlineMs <= 0 || deadlineMs > LOCAL_MMDB_REVIEW_DEADLINE_MS)
255
+ throw new CliUsageError('Invalid local database processing deadline.');
256
+ const database = await readStableMmdb(databasePath, dependencies.openFile, signal);
257
+ if (signal?.aborted) {
258
+ database.fill(0);
259
+ signal.throwIfAborted();
260
+ }
261
+ const identity = { sha256: createHash('sha256').update(database).digest('hex'), byteLength: database.length };
262
+ const options = { workerData: { kind: LOCAL_MMDB_WORKER_KIND, query, bytes: database, identity },
263
+ transferList: [database.buffer], env: {}, execArgv: [], stdout: true, stderr: true,
264
+ resourceLimits: { maxOldGenerationSizeMb: 64, maxYoungGenerationSizeMb: 16, stackSizeMb: 4 } };
265
+ let worker;
266
+ try {
267
+ worker = dependencies.createWorker ? dependencies.createWorker(options) : new Worker(new URL(import.meta.url), options);
268
+ }
269
+ catch {
270
+ if (database.byteLength)
271
+ database.fill(0);
272
+ signal?.throwIfAborted();
273
+ if (query.version === 2)
274
+ return freezeResult(currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, 'processing_unavailable'));
275
+ throw new CliUsageError('The supplied database could not be reviewed within its format and processing bounds.');
276
+ }
277
+ worker.stdout?.resume();
278
+ worker.stderr?.resume();
279
+ try {
280
+ return await new Promise((resolve, reject) => {
281
+ let settled = false;
282
+ const finish = (result, reason = 'processing_unavailable') => {
283
+ if (settled)
284
+ return;
285
+ settled = true;
286
+ clearTimeout(timer);
287
+ signal?.removeEventListener('abort', onAbort);
288
+ if (signal?.aborted)
289
+ reject(signal.reason);
290
+ else if (result)
291
+ resolve(freezeResult(result));
292
+ else if (query.version === 2)
293
+ resolve(freezeResult(currentResult(query, identity, { metadata: null, ageDays: null, reason: 'invalid_metadata' }, reason)));
294
+ else
295
+ reject(new CliUsageError('The supplied database could not be reviewed within its format and processing bounds.'));
296
+ };
297
+ const onAbort = () => finish();
298
+ const timer = setTimeout(() => finish(undefined, 'processing_deadline'), deadlineMs);
299
+ worker.once('message', (reply) => reply && 'result' in reply ? finish(reply.result) : finish());
300
+ worker.once('error', () => finish());
301
+ worker.once('exit', () => finish());
302
+ signal?.addEventListener('abort', onAbort, { once: true });
303
+ if (signal?.aborted)
304
+ onAbort();
305
+ });
306
+ }
307
+ finally {
308
+ await worker.terminate();
309
+ }
107
310
  }
@@ -1,5 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { scanBoundedJson } from "../lib/bounded-json.mjs";
2
+ import { scanBoundedJson } from "../packages/analysis/bounded-json.mjs";
3
3
  const LOOKALIKE_CALIBRATION_INPUT_SCHEMA = 'whoisleuth.lookalike-calibration-input';
4
4
  const LOOKALIKE_CALIBRATION_SCHEMA = 'whoisleuth.lookalike-calibration';
5
5
  const LOOKALIKE_CALIBRATION_VERSION = 1;
@@ -1,8 +1,8 @@
1
1
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
2
2
  import { safeTerminalValue } from "./formatters/terminal.mjs";
3
3
  import { compareRdapPublications, compareRegistrySources } from "../lib/registry-comparison.mjs";
4
- export const CLI_LOOKUP_BRIEF_SCHEMA = 'whoisleuth.cli.lookup-brief';
5
- export const CLI_LOOKUP_BRIEF_VERSION = 3;
4
+ import { CLI_LOOKUP_BRIEF_SCHEMA, CLI_LOOKUP_BRIEF_VERSION } from "../packages/contracts/cli-lookup-brief.mjs";
5
+ export { CLI_LOOKUP_BRIEF_SCHEMA, CLI_LOOKUP_BRIEF_VERSION };
6
6
  function record(value) {
7
7
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
8
8
  }
@@ -38,7 +38,7 @@ async function runLookupCommand(args, dependencies, context) {
38
38
  eventProgress.emit({ event: 'started' });
39
39
  const input = args.query || await context.readSingleInput();
40
40
  if (!input)
41
- throw new CliUsageError('lookup requires one domain, IP address, or ASN as an argument or on stdin.');
41
+ throw new CliUsageError('lookup requires one domain, IP address, or ASN as an argument or on stdin.', 'missing_input');
42
42
  const classify = dependencies.classifyQuery || classifyQuery;
43
43
  const executeLookup = dependencies.runUnifiedLookup || runUnifiedLookup;
44
44
  let classified;
@@ -74,6 +74,18 @@ async function runLookupCommand(args, dependencies, context) {
74
74
  ...(args.vantageLabel ? { vantageLabel: args.vantageLabel } : {}),
75
75
  });
76
76
  };
77
+ const collect = (signal, onSourceSettled) => abortable(() => executeLookup(classified, {
78
+ fast: !args.deep,
79
+ compact: false,
80
+ ...(signal ? { signal } : {}),
81
+ ...(args.deep ? {
82
+ ...(selectedUrl ? { selectedUrl } : {}),
83
+ onSourceSettled: settlement => {
84
+ onSourceSettled(settlement);
85
+ eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
86
+ },
87
+ } : {}),
88
+ }), signal);
77
89
  let document;
78
90
  if (args.browse === true) {
79
91
  const browse = dependencies.browseLookupOperation || browseLookupOperation;
@@ -88,23 +100,7 @@ async function runLookupCommand(args, dependencies, context) {
88
100
  plannedSources: args.deep ? plannedLookupProgressSources(classified) : [],
89
101
  ...(dependencies.signal ? { signal: dependencies.signal } : {}),
90
102
  collect: async ({ signal, onSourceSettled }) => {
91
- const result = await abortable(() => executeLookup(classified, args.deep
92
- ? {
93
- fast: false,
94
- compact: false,
95
- ...(selectedUrl ? { selectedUrl } : {}),
96
- signal,
97
- onSourceSettled: (settlement) => {
98
- onSourceSettled(settlement);
99
- eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
100
- },
101
- }
102
- : {
103
- fast: true,
104
- compact: false,
105
- signal,
106
- }), signal);
107
- return buildDocument(result);
103
+ return buildDocument(await collect(signal, onSourceSettled));
108
104
  },
109
105
  });
110
106
  if (args.saveLookup) {
@@ -114,6 +110,7 @@ async function runLookupCommand(args, dependencies, context) {
114
110
  const save = dependencies.writePrivateFile || writePrivateFile;
115
111
  await save(args.saveLookup, serializeCliLookupDocument(document), {
116
112
  existingFileMessage: 'Saved Lookup file already exists. Choose a new --save-lookup path or remove the existing file explicitly.',
113
+ beforePublish: async () => { dependencies.signal?.throwIfAborted(); },
117
114
  });
118
115
  context.writeStderr('Saved the completed private Lookup JSON. It can contain raw public registry and WHOIS responses plus target and collection context; review it before sharing.\n');
119
116
  }
@@ -125,23 +122,10 @@ async function runLookupCommand(args, dependencies, context) {
125
122
  let settledSources = 0;
126
123
  let result;
127
124
  try {
128
- result = await abortable(() => executeLookup(classified, args.deep
129
- ? {
130
- fast: false,
131
- compact: false,
132
- ...(selectedUrl ? { selectedUrl } : {}),
133
- ...(dependencies.signal ? { signal: dependencies.signal } : {}),
134
- onSourceSettled: (settlement) => {
135
- settledSources += 1;
136
- indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
137
- eventProgress.emit({ event: 'source_settled', source: settlement.source, state: settlement.state });
138
- },
139
- }
140
- : {
141
- fast: true,
142
- compact: false,
143
- ...(dependencies.signal ? { signal: dependencies.signal } : {}),
144
- }), dependencies.signal);
125
+ result = await collect(dependencies.signal, settlement => {
126
+ settledSources += 1;
127
+ indicator.update(`Collected ${settledSources} source${settledSources === 1 ? '' : 's'} · ${settlement.source.replaceAll('_', ' ')} ${settlement.state}`);
128
+ });
145
129
  }
146
130
  finally {
147
131
  context.endProgress();
@@ -5,6 +5,7 @@ import { relationshipObservation } from "../packages/comparison/relationship-evi
5
5
  import { CliUsageError } from "./errors.mjs";
6
6
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
7
7
  import { lookupObservationHostname } from "../packages/evidence/lookup-target.mjs";
8
+ import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
8
9
  export const CLI_LOOKUP_DIFF_SCHEMA = 'whoisleuth.cli.lookup-diff';
9
10
  export const CLI_LOOKUP_DIFF_VERSION = 1;
10
11
  function text(value, maximum = 300) {
@@ -198,10 +199,10 @@ function formatCliLookupDiff(document) {
198
199
  const rows = document.comparison.rows.filter((row) => row.state !== 'equal');
199
200
  const output = [
200
201
  'Domain evidence diff',
201
- `Left ${document.left.domain}`,
202
- `Left observed ${document.left.generatedAt}`,
203
- `Right ${document.right.domain}`,
204
- `Right observed ${document.right.generatedAt}`,
202
+ `Left ${safeTerminalValue(document.left.domain)}`,
203
+ `Left observed ${safeTerminalValue(document.left.generatedAt)}`,
204
+ `Right ${safeTerminalValue(document.right.domain)}`,
205
+ `Right observed ${safeTerminalValue(document.right.generatedAt)}`,
205
206
  `Changed ${document.comparison.counts.different + document.comparison.counts.conflicting}`,
206
207
  `One-sided ${document.comparison.counts.missing}`,
207
208
  `Unavailable ${document.comparison.counts.unavailable + document.comparison.counts.not_recorded}`,
@@ -210,14 +211,14 @@ function formatCliLookupDiff(document) {
210
211
  if (!rows.length)
211
212
  output.push('No bounded differences were observed in the comparable fields.');
212
213
  for (const row of rows) {
213
- output.push(`${row.label} [${row.state.replaceAll('_', ' ')}]`);
214
- output.push(` Left: ${row.left}`);
215
- output.push(` Right: ${row.right}`);
216
- output.push(` Source: ${row.source}`);
214
+ output.push(`${safeTerminalValue(row.label)} [${safeTerminalValue(row.state).replaceAll('_', ' ')}]`);
215
+ output.push(` Left: ${safeTerminalValue(row.left)}`);
216
+ output.push(` Right: ${safeTerminalValue(row.right)}`);
217
+ output.push(` Source: ${safeTerminalValue(row.source)}`);
217
218
  }
218
219
  output.push('', 'Limitations:');
219
220
  for (const limitation of document.limitations)
220
- output.push(` - ${limitation}`);
221
+ output.push(` - ${safeTerminalValue(limitation)}`);
221
222
  return `${output.join('\n')}\n`;
222
223
  }
223
224
  export { buildCliLookupDiff, formatCliLookupDiff };
@@ -1,3 +1,4 @@
1
+ import { plannedLookupSources } from "../lib/lookup-progress.mjs";
1
2
  export const CLI_LOOKUP_PLAN_SCHEMA = 'whoisleuth.cli.lookup-plan';
2
3
  export const CLI_LOOKUP_PLAN_VERSION = 1;
3
4
  const RDAP = Object.freeze({
@@ -43,20 +44,15 @@ const REVERSE_DNS = Object.freeze({
43
44
  conditional: false,
44
45
  });
45
46
  function plannedSources(classified, deep, selectedUrl) {
46
- if (!deep) {
47
- return Object.freeze(classified.type === 'domain' ? [RDAP, FAST_DOMAIN_EVIDENCE] : [RDAP]);
48
- }
49
- if (classified.type === 'domain') {
50
- const domainEvidence = selectedUrl ? Object.freeze({
51
- ...DEEP_DOMAIN_EVIDENCE,
52
- disclosure: 'The selected URL path and query are sent to the website and followed redirects. The fragment is not sent. DNS and TLS use its hostname; registration uses the registrable domain. Saved HTTP provenance omits queries, but page-derived text may contain sensitive information.',
53
- }) : DEEP_DOMAIN_EVIDENCE;
54
- return Object.freeze([RDAP, WHOIS, domainEvidence, REGISTRAR_RDAP, NETWORK_CONTEXT]);
55
- }
56
- if (classified.type === 'ipv4' || classified.type === 'ipv6') {
57
- return Object.freeze([RDAP, WHOIS, REVERSE_DNS]);
58
- }
59
- return Object.freeze([RDAP, WHOIS]);
47
+ const domainEvidence = selectedUrl && deep ? Object.freeze({
48
+ ...DEEP_DOMAIN_EVIDENCE,
49
+ disclosure: 'The selected URL path and query are sent to the website and followed redirects. The fragment is not sent. DNS and TLS use its hostname; registration uses the registrable domain. Saved HTTP provenance omits queries, but page-derived text may contain sensitive information.',
50
+ }) : deep ? DEEP_DOMAIN_EVIDENCE : FAST_DOMAIN_EVIDENCE;
51
+ const sources = {
52
+ rdap: RDAP, whois: WHOIS, domain_evidence: domainEvidence,
53
+ registrar_rdap: REGISTRAR_RDAP, network_context: NETWORK_CONTEXT, reverse_dns: REVERSE_DNS,
54
+ };
55
+ return Object.freeze(plannedLookupSources(classified.type, deep ? 'deep' : 'fast').map(id => sources[id]));
60
56
  }
61
57
  function buildCliLookupPlan(query, classified, deep, selectedUrl = false) {
62
58
  return Object.freeze({
@@ -2,6 +2,7 @@ import { CliUsageError } from "./errors.mjs";
2
2
  import { buildCliLookupDiff } from "./lookup-diff.mjs";
3
3
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
4
4
  import { compareObservationContexts } from "../packages/comparison/capture-context.mjs";
5
+ import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
5
6
  const CLI_LOOKUP_RECONCILIATION_SCHEMA = 'whoisleuth.cli.lookup-reconciliation';
6
7
  const CLI_LOOKUP_RECONCILIATION_VERSION = 1;
7
8
  const MAX_LOOKUP_RECONCILIATION_INPUT_BYTES = 32 * 1024 * 1024;
@@ -117,7 +118,7 @@ function formatCliLookupReconciliation(document) {
117
118
  const context = compareObservationContexts(document.observations.map(observation => ({ ...observation, observedAt: observation.generatedAt })));
118
119
  const output = [
119
120
  'Lookup observation reconciliation',
120
- `Domain ${document.domain}`,
121
+ `Domain ${safeTerminalValue(document.domain)}`,
121
122
  `Observations ${document.summary.observationCount}`,
122
123
  `Declared labels ${context.labels}; collection independence not verified`,
123
124
  `Capture time span ${context.time.spanMilliseconds === null ? 'unknown' : `${context.time.spanMilliseconds / 1000} seconds`}`,
@@ -125,18 +126,18 @@ function formatCliLookupReconciliation(document) {
125
126
  `Disagreement ${document.summary.disagreement}`,
126
127
  `Non-comparable ${document.summary.nonComparable}`,
127
128
  '',
128
- ...document.observations.map((observation, index) => `Observation ${index + 1}: ${observation.generatedAt} · ${observation.mode} · observer ${observation.observerLabel ?? 'not declared'} · vantage ${observation.vantageLabel ?? 'not declared'}`),
129
+ ...document.observations.map((observation, index) => `Observation ${index + 1}: ${safeTerminalValue(observation.generatedAt)} · ${safeTerminalValue(observation.mode)} · observer ${safeTerminalValue(observation.observerLabel, 'not declared')} · vantage ${safeTerminalValue(observation.vantageLabel, 'not declared')}`),
129
130
  '',
130
131
  ];
131
132
  for (const field of document.fields.filter((candidate) => candidate.state !== 'agreement')) {
132
- output.push(`${field.label} [${field.state.replaceAll('_', ' ')}]`);
133
+ output.push(`${safeTerminalValue(field.label)} [${safeTerminalValue(field.state).replaceAll('_', ' ')}]`);
133
134
  for (const value of field.values) {
134
- output.push(` Observation ${value.observation + 1}: ${value.value} (${value.sourceState.replaceAll('_', ' ')})`);
135
+ output.push(` Observation ${value.observation + 1}: ${safeTerminalValue(value.value)} (${safeTerminalValue(value.sourceState).replaceAll('_', ' ')})`);
135
136
  }
136
137
  }
137
138
  output.push('', 'Limitations:');
138
139
  for (const limitation of document.limitations)
139
- output.push(` - ${limitation}`);
140
+ output.push(` - ${safeTerminalValue(limitation)}`);
140
141
  output.push(' - These selected observations do not establish worldwide availability, removal or takedown.');
141
142
  return `${output.join('\n')}\n`;
142
143
  }
@@ -2,6 +2,7 @@ import { Buffer } from 'node:buffer';
2
2
  import { CliUsageError } from "./errors.mjs";
3
3
  import { buildCliLookupDiff } from "./lookup-diff.mjs";
4
4
  import { parseSavedLookupDocument } from "./saved-lookup.mjs";
5
+ import { safeTerminalValue } from "./formatters/terminal-shared.mjs";
5
6
  export const CLI_LOOKUP_TIMELINE_SCHEMA = 'whoisleuth.cli.lookup-timeline';
6
7
  export const CLI_LOOKUP_TIMELINE_VERSION = 1;
7
8
  export const MAX_LOOKUP_TIMELINE_OBSERVATIONS = 20;
@@ -95,10 +96,10 @@ function buildCliLookupTimeline(inputs, generatedAt = new Date().toISOString())
95
96
  function formatCliLookupTimeline(document) {
96
97
  const output = [
97
98
  'Same-domain observation timeline',
98
- `Domain ${document.domain}`,
99
+ `Domain ${safeTerminalValue(document.domain)}`,
99
100
  `Observations ${document.summary.observationCount}`,
100
- `First observed ${document.observations[0]?.generatedAt || 'Unavailable'}`,
101
- `Last observed ${document.observations.at(-1)?.generatedAt || 'Unavailable'}`,
101
+ `First observed ${safeTerminalValue(document.observations[0]?.generatedAt, 'Unavailable')}`,
102
+ `Last observed ${safeTerminalValue(document.observations.at(-1)?.generatedAt, 'Unavailable')}`,
102
103
  `Changed transitions ${document.summary.transitionsWithObservedChanges}`,
103
104
  ];
104
105
  for (const transition of document.transitions) {
@@ -106,18 +107,18 @@ function formatCliLookupTimeline(document) {
106
107
  const to = document.observations[transition.toObservation];
107
108
  const counts = transition.comparison.counts;
108
109
  const changedRows = transition.comparison.rows.filter((row) => (row.state === 'conflicting' || row.state === 'different' || row.state === 'missing'));
109
- output.push('', `${from?.generatedAt || 'Unavailable'} to ${to?.generatedAt || 'Unavailable'}`, ` Observed changes ${counts.conflicting + counts.different}`, ` One-sided fields ${counts.missing}`, ` Unavailable ${counts.unavailable + counts.not_recorded}`);
110
+ output.push('', `${safeTerminalValue(from?.generatedAt, 'Unavailable')} to ${safeTerminalValue(to?.generatedAt, 'Unavailable')}`, ` Observed changes ${counts.conflicting + counts.different}`, ` One-sided fields ${counts.missing}`, ` Unavailable ${counts.unavailable + counts.not_recorded}`);
110
111
  if (!changedRows.length)
111
112
  output.push(' No observed field changes in comparable evidence.');
112
113
  for (const row of changedRows.slice(0, 10)) {
113
- output.push(` ${row.label} [${row.state.replaceAll('_', ' ')}]: ${row.left} -> ${row.right}`);
114
+ output.push(` ${safeTerminalValue(row.label)} [${safeTerminalValue(row.state).replaceAll('_', ' ')}]: ${safeTerminalValue(row.left)} -> ${safeTerminalValue(row.right)}`);
114
115
  }
115
116
  if (changedRows.length > 10)
116
117
  output.push(` ${changedRows.length - 10} additional changed fields omitted; use --json for the complete bounded comparison.`);
117
118
  }
118
119
  output.push('', 'Limitations:');
119
120
  for (const limitation of document.limitations)
120
- output.push(` - ${limitation}`);
121
+ output.push(` - ${safeTerminalValue(limitation)}`);
121
122
  return `${output.join('\n')}\n`;
122
123
  }
123
124
  export { buildCliLookupTimeline, formatCliLookupTimeline };