@slicedearth/whoisleuth-cli 1.43.0 → 1.47.4

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 (153) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +67 -10
  4. package/bin/whoisleuth.mjs +22 -9
  5. package/cli/arguments.mjs +256 -9
  6. package/cli/artifact-structure.mjs +1833 -0
  7. package/cli/artifact-verify.mjs +242 -49
  8. package/cli/bounded-json.mjs +1 -0
  9. package/cli/bulk-checkpoint.mjs +67 -31
  10. package/cli/bulk-command-runner.mjs +16 -6
  11. package/cli/bulk.mjs +18 -7
  12. package/cli/case-pack.mjs +819 -34
  13. package/cli/command-reference.mjs +48 -24
  14. package/cli/completion.mjs +50 -11
  15. package/cli/config-profile.mjs +15 -12
  16. package/cli/ct-event-intake.mjs +32 -2
  17. package/cli/discovery-observation-snapshot.mjs +8 -14
  18. package/cli/discovery-scan-command-runner.mjs +18 -6
  19. package/cli/discovery-snapshot.mjs +8 -15
  20. package/cli/discovery-workflow.mjs +2 -3
  21. package/cli/doctor.mjs +2 -2
  22. package/cli/domain-control-monitor.mjs +21 -15
  23. package/cli/domain-control-observations.mjs +25 -2
  24. package/cli/errors.mjs +7 -1
  25. package/cli/evidence-command-runner.mjs +17 -11
  26. package/cli/evidence-signing.mjs +73 -45
  27. package/cli/export-evidence.mjs +11 -4
  28. package/cli/formatters/html.mjs +3 -3
  29. package/cli/formatters/json.mjs +12 -4
  30. package/cli/formatters/terminal.mjs +4 -1
  31. package/cli/http.mjs +2 -1
  32. package/cli/input.mjs +57 -0
  33. package/cli/interchange-report.mjs +226 -0
  34. package/cli/investigation-manifest.mjs +16 -17
  35. package/cli/investigation-run.mjs +71 -23
  36. package/cli/local-mmdb-review.mjs +45 -14
  37. package/cli/lookup-command-runner.mjs +2 -2
  38. package/cli/lookup-diff.mjs +9 -0
  39. package/cli/lookup-reconcile.mjs +1 -1
  40. package/cli/mail-review.mjs +5 -5
  41. package/cli/manual.mjs +1 -1
  42. package/cli/network-command-runner.mjs +78 -0
  43. package/cli/offline-evidence-review.mjs +28 -8
  44. package/cli/page-compare.mjs +8 -5
  45. package/cli/registry-cohort.mjs +385 -61
  46. package/cli/registry-fixture-scaffold.mjs +4 -3
  47. package/cli/registry-support.mjs +2 -1
  48. package/cli/retained-artifact-diff.mjs +376 -0
  49. package/cli/risk-calibration.mjs +7 -5
  50. package/cli/runner.mjs +150 -177
  51. package/cli/saved-lookup.mjs +26 -3
  52. package/cli/sharing-review.mjs +12 -6
  53. package/docs/cli-reference.md +467 -74
  54. package/docs/cli.md +25 -5
  55. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  56. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  57. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  58. package/frontend/src/lib/analysis/brand-profile-model.js +33 -7
  59. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  60. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  61. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  62. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  63. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  64. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  65. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  66. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  67. package/frontend/src/lib/analysis/case-evidence-model.js +9 -1
  68. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  69. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  70. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  71. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  72. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  73. package/frontend/src/lib/analysis/case-record-operations.js +43 -13
  74. package/frontend/src/lib/analysis/case-report.js +39 -6
  75. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  76. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  77. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  78. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  79. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  80. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  81. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  82. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  83. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  84. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  85. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  86. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  87. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  88. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  89. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  90. package/frontend/src/lib/analysis/lookup-decision-support.js +63 -8
  91. package/frontend/src/lib/analysis/lookup-presentation.js +70 -14
  92. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  93. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  94. package/frontend/src/lib/analysis/utils.js +27 -4
  95. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  96. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  97. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  98. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  99. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  100. package/frontend/src/lib/analysis/workspace-archive.js +105 -19
  101. package/lib/application-version.mjs +3 -0
  102. package/lib/availability.mjs +14 -12
  103. package/lib/bounded-file.mjs +82 -0
  104. package/lib/bounded-json-response.mjs +170 -0
  105. package/lib/bounded-json.mjs +249 -0
  106. package/lib/browser-library-profile.mjs +167 -38
  107. package/lib/certificate-extension-profile.mjs +2 -1
  108. package/lib/cryptographic-assurance.mjs +155 -0
  109. package/lib/ct-search.mjs +87 -47
  110. package/lib/dns-intelligence.mjs +3 -2
  111. package/lib/dns-resolver-selection.mjs +6 -4
  112. package/lib/dnssec-chain-validation.mjs +1053 -0
  113. package/lib/dnssec-evidence-validation.mjs +10 -3
  114. package/lib/domain-change-packet.mjs +7 -5
  115. package/lib/domain-control-manifest.mjs +17 -129
  116. package/lib/domain-posture.mjs +7 -4
  117. package/lib/evidence-export.mjs +751 -78
  118. package/lib/evidence-report-markdown.mjs +3 -0
  119. package/lib/evidence-report.mjs +5 -0
  120. package/lib/favicon.mjs +8 -5
  121. package/lib/http-evidence-bounds.mjs +3 -0
  122. package/lib/http-intelligence.mjs +2 -5
  123. package/lib/interchange-fidelity-registry.mjs +122 -0
  124. package/lib/local-geoip-evidence.mjs +2 -1
  125. package/lib/lookup-cache.mjs +5 -1
  126. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  127. package/lib/lookup-response-contract.mjs +691 -3
  128. package/lib/lookup.mjs +2 -2
  129. package/lib/observation.mjs +7 -6
  130. package/lib/outbound-identity.mjs +2 -3
  131. package/lib/perceptual-hash.mjs +12 -4
  132. package/lib/portable-generator.mjs +21 -0
  133. package/lib/provider-policy-admission.mjs +9 -4
  134. package/lib/rdap-normalization.mjs +18 -5
  135. package/lib/rdap-registrar.mjs +5 -1
  136. package/lib/rdap-transport.mjs +6 -6
  137. package/lib/registry-capabilities.mjs +3 -0
  138. package/lib/registry-capability-catalogue.mjs +12 -2
  139. package/lib/registry-comparison.mjs +28 -11
  140. package/lib/risk-calibration-summary.mjs +338 -0
  141. package/lib/rpki-evidence.mjs +9 -8
  142. package/lib/safe-fetch.mjs +96 -42
  143. package/lib/security-txt.mjs +26 -3
  144. package/lib/service-binding-dns.mjs +6 -1
  145. package/lib/smtp-transport-review.mjs +784 -0
  146. package/lib/sslbl-certificates.generated.mjs +136 -134
  147. package/lib/threat-intelligence-runtime.mjs +4 -13
  148. package/lib/threat-intelligence-types.mjs +18 -1
  149. package/lib/tls-intelligence.mjs +11 -5
  150. package/lib/tlsa-evidence.mjs +37 -7
  151. package/lib/website-technology.mjs +2 -6
  152. package/package.json +1 -1
  153. package/third-party-notices.txt +95 -5817
@@ -14,6 +14,8 @@ Investigate:
14
14
  bulk Triage newline-delimited targets with bounded concurrency.
15
15
  http Inspect one homepage request and redirect chain.
16
16
  tls Inspect one hostname's current certificate connection.
17
+ dnssec-validate Validate one authorised DNSSEC chain in isolation.
18
+ mail-transport Review selected authorised MX transports and STARTTLS.
17
19
  posture Review DNS mail and domain-control posture.
18
20
 
19
21
  Discover:
@@ -23,7 +25,7 @@ Discover:
23
25
  discover-scan Collect a bounded candidate review queue.
24
26
  registry-support Explain local registry coverage without a request.
25
27
  registry-doctor Diagnose a saved registry collection against local policy.
26
- registry-cohort Aggregate target-free registry quality cohorts.
28
+ registry-cohort Build target-free registry quality timelines.
27
29
  registry-scaffold Create a sanitised synthetic fixture scaffold.
28
30
 
29
31
  Review saved evidence:
@@ -33,7 +35,7 @@ Review saved evidence:
33
35
  compare Compare saved registry publications.
34
36
  page-compare Compare saved static page and TLS evidence.
35
37
  mail-review Review saved passive mail exposure evidence.
36
- review-evidence Review supplied DNS, routing, GeoIP, RDAP, or trust-store evidence offline.
38
+ review-evidence Review supplied DNS, routing, cryptographic, GeoIP, RDAP, or trust-store evidence offline.
37
39
  brief Turn one saved Lookup into a bounded decision brief.
38
40
  case-pack Build a reviewed browser-importable case package.
39
41
  domain-control Build or review an integrity-protected desired-state manifest.
@@ -43,12 +45,13 @@ Review saved evidence:
43
45
  sharing-review Lint a reviewed artifact before deliberate sharing.
44
46
  workflow-plan Plan a fixed investigation recipe without executing it.
45
47
  workflow-run Execute resumable approved steps from a fixed recipe.
46
- diff Compare two saved domain observations.
48
+ diff Compare two compatible retained artefacts.
47
49
  reconcile Reconcile independently labelled observations.
48
50
  timeline Compare a sequence of observations for one domain.
49
51
  export Convert a saved lookup into an evidence report.
50
52
  inspect-archive Inspect a workspace archive, redacted by default.
51
53
  verify-artifact Validate saved evidence or an integrity envelope offline.
54
+ interchange-report Report preserved and excluded field groups offline.
52
55
 
53
56
  Integrity and calibration:
54
57
  manifest Record ordered artefact and configuration digests.
@@ -81,7 +84,7 @@ const COMMAND_USAGE = Object.freeze({
81
84
  manifest: 'whoisleuth manifest <artefact.json> [...] --workflow <label> [--configuration-digest <sha256:digest>] [--json] [--quiet] [--no-color]',
82
85
  'map-observations': 'whoisleuth map-observations [mapping.json] [--json] [--quiet] [--no-color]',
83
86
  'oam-export': 'whoisleuth oam-export [external-findings.json] [--json] [--quiet] [--no-color]',
84
- lookup: 'whoisleuth lookup <domain|IP|ASN> [--json|--junit|--markdown|--html] [--fast|--deep] [--observer <label>] [--vantage <label>] [--plan] [--summary|--verbose] [--strict-exit] [--fail-on <policies>] [--events] [--quiet] [--no-color]',
87
+ lookup: 'whoisleuth lookup <domain|IP|ASN> [--json|--junit|--markdown|--html] [--no-attribution] [--fast|--deep] [--observer <label>] [--vantage <label>] [--plan] [--summary|--verbose] [--strict-exit] [--fail-on <policies>] [--events] [--quiet] [--no-color]',
85
88
  bulk: 'whoisleuth bulk [file] [--json|--jsonl|--junit|--csv|--domains|--queries] [--registered-only|--inconclusive-only|--errors-only] [--fast|--deep] [--concurrency <1-8>] [--checkpoint <file> [--resume]] [--events] [--plan] [--fail-on <policies>]',
86
89
  'ct-search': 'whoisleuth ct-search <keyword> [--json] [--quiet] [--no-color]',
87
90
  'ct-intake': 'whoisleuth ct-intake [events.json] [--json] [--quiet] [--no-color]',
@@ -90,13 +93,16 @@ const COMMAND_USAGE = Object.freeze({
90
93
  posture: 'whoisleuth posture <domain> [--selectors <list>] [--retired-selectors <list>] [--mail-profile <profile>] [--json|--sarif --owned-domain] [--quiet] [--no-color]',
91
94
  http: 'whoisleuth http <domain> [--json] [--quiet] [--no-color]',
92
95
  tls: 'whoisleuth tls <hostname> [--json] [--quiet] [--no-color]',
96
+ 'dnssec-validate': 'whoisleuth dnssec-validate <domain> --resolver <public-IP> --trust-anchor <anchor.json> --owned-or-authorized [--json] [--quiet] [--no-color]',
97
+ 'mail-transport': 'whoisleuth mail-transport [input.json] --resolver <public-IP> --trust-anchor <anchor.json> --owned-or-authorized --active-probe [--json] [--quiet] [--no-color]',
93
98
  'registry-support': 'whoisleuth registry-support <domain|suffix> [--json] [--quiet] [--no-color]',
94
99
  'registry-doctor': 'whoisleuth registry-doctor [lookup.json] [--json] [--quiet] [--no-color]',
95
- 'registry-cohort': 'whoisleuth registry-cohort [lookups.json|lookups.jsonl] [--json] [--quiet] [--no-color]',
100
+ 'registry-cohort': 'whoisleuth registry-cohort [lookups-or-reports.json|jsonl] [--json] [--quiet] [--no-color]',
96
101
  'registry-scaffold': 'whoisleuth registry-scaffold --profile <id> --suffix <suffix> --scenario <registered|not_found|inconclusive>',
97
- 'risk-calibrate': 'whoisleuth risk-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
102
+ 'risk-calibrate': 'whoisleuth risk-calibrate [dataset.json] [--json|--summary-json] [--quiet] [--no-color]',
98
103
  'lookalike-calibrate': 'whoisleuth lookalike-calibrate [dataset.json] [--json] [--quiet] [--no-color]',
99
- 'verify-artifact': 'whoisleuth verify-artifact [artifact.json] [--passphrase-file <file>] [--json] [--quiet] [--no-color]',
104
+ 'verify-artifact': 'whoisleuth verify-artifact [artifact.json] [--passphrase-file <file>] [--manifest <manifest.json> --manifest-entry <artifact-N>] [--json] [--strict-exit] [--quiet] [--no-color]',
105
+ 'interchange-report': 'whoisleuth interchange-report [artifact.json] [--passphrase-file <file>] [--json] [--quiet] [--no-color]',
100
106
  'inspect-archive': 'whoisleuth inspect-archive [archive.json] [--passphrase-file <file>] [--search <value>] [--require-match] [--reveal] [--expect-content-digest <sha256:digest>] [--json]',
101
107
  'sign-artifact': 'whoisleuth sign-artifact [artifact.json] --private-key-file <file>',
102
108
  'verify-signature': 'whoisleuth verify-signature [package.json] [--public-key-file <file>] [--json] [--quiet] [--no-color]',
@@ -114,10 +120,10 @@ const COMMAND_USAGE = Object.freeze({
114
120
  'sharing-review': 'whoisleuth sharing-review [artifact.json] --marking <level> --recipient-scope <scope> --purpose <text> [--human-reviewed] [--personal-data-reviewed] [--redactions-confirmed] [--json]',
115
121
  'workflow-plan': 'whoisleuth workflow-plan <recipe> <domain|brand> [--json] [--quiet] [--no-color]',
116
122
  'workflow-run': 'whoisleuth workflow-run <recipe> <domain|brand> [--approve-network] [--resume <state.json>] [--json] [--quiet] [--no-color]',
117
- diff: 'whoisleuth diff <left.json> <right.json> [--json] [--quiet] [--no-color]',
123
+ diff: 'whoisleuth diff <left.json> <right.json> [--left-session <id> --right-session <id>] [--json] [--quiet] [--no-color]',
118
124
  reconcile: 'whoisleuth reconcile <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
119
125
  timeline: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
120
- export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact]',
126
+ export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact] [--no-attribution]',
121
127
  });
122
128
  const COMMAND_DETAILS = Object.freeze({
123
129
  completion: {
@@ -200,6 +206,16 @@ const COMMAND_DETAILS = Object.freeze({
200
206
  example: 'whoisleuth tls example.test --json',
201
207
  boundary: 'One observed connection is point-in-time evidence and does not establish every address, edge, or historical certificate.',
202
208
  },
209
+ 'dnssec-validate': {
210
+ description: 'Cryptographically validate one authorised DNSSEC chain from a supplied trust anchor through one selected public resolver.',
211
+ example: 'whoisleuth dnssec-validate example.test --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --json',
212
+ boundary: 'This isolated action is never invoked by Lookup, Bulk, monitoring, or recipes. It caps DNS queries, aliases, delegations, bytes, and duration; transport and validation failures remain separate, and secure is not a general safety verdict.',
213
+ },
214
+ 'mail-transport': {
215
+ description: 'Review selected authorised MX endpoints, DNSSEC-qualified TLSA evidence, SMTP capabilities, and optional STARTTLS certificates.',
216
+ example: 'whoisleuth mail-transport selected-mx.json --resolver "$PUBLIC_RESOLVER_IP" --trust-anchor anchor.json --owned-or-authorized --active-probe --json',
217
+ boundary: 'This isolated action probes at most three selected MX hosts sequentially, reports selection, public revalidation, connection, and address authentication separately, sends only EHLO and optional STARTTLS, never retries, and performs no authentication, relay, recipient, mailbox, catch-all, or message test. If a DANE-TA TLSA usage 2 association is published, active collection retains only the leaf certificate and leaves that comparison partial without certificate-path construction and trust-anchor path validation. SMTP relay PKIX-TA usage 0 and PKIX-EE usage 1 records remain unsupported and cannot complete SMTP DANE assurance; a separate usage 3 match remains eligible.',
218
+ },
203
219
  'registry-support': {
204
220
  description: 'Explain the local registry capability profile for one domain or suffix.',
205
221
  example: 'whoisleuth registry-support example.test --json',
@@ -211,9 +227,9 @@ const COMMAND_DETAILS = Object.freeze({
211
227
  boundary: 'The command is offline. It distinguishes expected access constraints from collection results and does not contact a live registry.',
212
228
  },
213
229
  'registry-cohort': {
214
- description: 'Aggregate saved registry observations into privacy-safe suffix and capability-profile cohorts.',
230
+ description: 'Build privacy-safe suffix and capability-profile timelines from saved observations or retained cohort reports.',
215
231
  example: 'whoisleuth registry-cohort saved-lookups.jsonl --json',
216
- boundary: 'This command is offline and omits domains, queries, and raw evidence. Cohorts below the fixed minimum sample remain insufficient.',
232
+ boundary: 'This command is offline and omits domains, queries, and raw evidence. Input families cannot be mixed, and retained samples are never assumed independent.',
217
233
  },
218
234
  'registry-scaffold': {
219
235
  description: 'Create a bounded synthetic WHOIS fixture scaffold for one existing capability profile.',
@@ -222,8 +238,8 @@ const COMMAND_DETAILS = Object.freeze({
222
238
  },
223
239
  'risk-calibrate': {
224
240
  description: 'Replay reviewed labels against the current explainable Risk model.',
225
- example: 'whoisleuth risk-calibrate calibration.json --json',
226
- boundary: 'Calibration is offline and diagnostic. It never trains, tunes, or changes the scoring model automatically.',
241
+ example: 'whoisleuth risk-calibrate calibration.json --summary-json',
242
+ boundary: 'Calibration is offline and diagnostic. The summary form omits record identifiers, domains, and evidence; neither form trains, tunes, or changes the scoring model automatically.',
227
243
  },
228
244
  'lookalike-calibrate': {
229
245
  description: 'Summarise reviewed candidate dispositions by mutation family without retaining domains.',
@@ -231,9 +247,14 @@ const COMMAND_DETAILS = Object.freeze({
231
247
  boundary: 'Calibration is offline and diagnostic. It omits candidate identifiers, domains, notes, and evidence and never tunes generation or filtering automatically.',
232
248
  },
233
249
  'verify-artifact': {
234
- description: 'Validate a supported archive, packet, manifest, or saved Lookup without printing evidence contents.',
235
- example: 'whoisleuth verify-artifact workspace.json --json',
236
- boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file.',
250
+ description: 'Validate a supported archive, claim passport, packet, manifest, saved Lookup, or supported Lookup-evidence export without printing evidence contents.',
251
+ example: 'whoisleuth verify-artifact report.json --manifest manifest.json --manifest-entry artifact-2 --json --strict-exit',
252
+ boundary: 'Verification is offline and redacted. Encrypted archives require an explicitly supplied passphrase file; --strict-exit returns 4 when only an envelope or legacy projection integrity was verified.',
253
+ },
254
+ 'interchange-report': {
255
+ description: 'Report what one recognised portable artefact preserves, excludes, and supports across browser and CLI workflows.',
256
+ example: 'whoisleuth interchange-report workspace.json --json',
257
+ boundary: 'The report is offline and metadata-only. It does not echo targets, contacts, notes, passphrases, evidence values, or an unrecognised schema string.',
237
258
  },
238
259
  'inspect-archive': {
239
260
  description: 'Summarise or search one workspace archive with redacted output by default.',
@@ -246,9 +267,9 @@ const COMMAND_DETAILS = Object.freeze({
246
267
  boundary: 'The command never creates, stores, or transmits keys. Key custody and signer identity remain the operator\'s responsibility.',
247
268
  },
248
269
  'verify-signature': {
249
- description: 'Verify the integrity and signature of one signed evidence package.',
270
+ description: 'Verify the cryptographic signature of one signed evidence package and report embedded-artifact assurance separately.',
250
271
  example: 'whoisleuth verify-signature packet.signed.json --json',
251
- boundary: 'A valid signature proves package consistency for the embedded key, not the real-world identity or authority of its holder.',
272
+ boundary: 'A valid signature proves package consistency for the embedded key. It does not upgrade failed or unsupported embedded-artifact assurance or establish the holder\'s real-world identity or authority.',
252
273
  },
253
274
  'source-report': {
254
275
  description: 'Create a target-free reliability summary from a saved lookup.',
@@ -321,9 +342,9 @@ const COMMAND_DETAILS = Object.freeze({
321
342
  boundary: 'Only installed recipe commands can run. Network steps require explicit approval for each invocation, and analyst-selection placeholders always pause without interpretation.',
322
343
  },
323
344
  diff: {
324
- description: 'Compare bounded evidence retained in two saved domain lookups.',
345
+ description: 'Compare two compatible retained Lookup, Bulk-session, or domain-portfolio artefacts.',
325
346
  example: 'whoisleuth diff first.json second.json --json',
326
- boundary: 'Comparison is offline. Missing, unavailable, equal, and different evidence remain separate states.',
347
+ boundary: 'Comparison is offline. Multi-session Bulk exports require explicit session IDs, and missing, unavailable, equal, and different evidence remain separate states.',
327
348
  },
328
349
  reconcile: {
329
350
  description: 'Reconcile bounded values across independently labelled observations of one domain.',
@@ -338,7 +359,7 @@ const COMMAND_DETAILS = Object.freeze({
338
359
  export: {
339
360
  description: 'Convert one saved lookup into a versioned evidence report.',
340
361
  example: 'whoisleuth export lookup.json --markdown',
341
- boundary: 'Exports preserve source attribution and limitations. Compact output intentionally omits raw registry payloads.',
362
+ boundary: 'The saved Lookup is capped at 8 MiB and scanned for duplicate keys, the prototype-sensitive __proto__ key, and bounded nesting, key, value, and per-container counts before parsing. Exports preserve evidence-source attribution and limitations. Markdown and HTML include a presentation-only generator footer unless --no-attribution is selected; JSON retains bounded generator provenance. Compact output intentionally omits raw registry payloads.',
342
363
  },
343
364
  });
344
365
  const COMMAND_COLLECTION = Object.freeze({
@@ -358,13 +379,16 @@ const COMMAND_COLLECTION = Object.freeze({
358
379
  posture: { mode: 'network', scope: 'Accepts one domain and performs bounded DNS queries only.' },
359
380
  http: { mode: 'network', scope: 'Accepts one domain and follows only the bounded SSRF-guarded homepage redirect workflow.' },
360
381
  tls: { mode: 'network', scope: 'Accepts one public hostname and opens one bounded certificate connection.' },
382
+ 'dnssec-validate': { mode: 'network', scope: 'Accepts one authorised domain, one public resolver IP, and one local trust-anchor file; DNS-over-TCP validation is capped at 32 queries and 15 seconds.' },
383
+ 'mail-transport': { mode: 'network', scope: 'Accepts at most three selected authorised MX hosts, uses one public resolver, and performs sequential bounded SMTP connections with no retries.' },
361
384
  'registry-support': { mode: 'offline', scope: 'Reads the embedded registry capability catalogue for one domain or suffix.' },
362
385
  'registry-doctor': { mode: 'offline', scope: 'Reads one saved Lookup and the embedded registry capability catalogue.' },
363
- 'registry-cohort': { mode: 'offline', scope: 'Reads at most 500 saved Lookups and emits target-free suffix/profile cohort counts.' },
386
+ 'registry-cohort': { mode: 'offline', scope: 'Reads at most 500 saved Lookups or retained cohort reports from one unmixed family and emits bounded target-free timelines.' },
364
387
  'registry-scaffold': { mode: 'offline', scope: 'Reads the embedded registry capability catalogue and prints one synthetic fixture template.' },
365
388
  'risk-calibrate': { mode: 'offline', scope: 'Reads one bounded reviewed-label dataset and changes no model or evidence.' },
366
389
  'lookalike-calibrate': { mode: 'offline', scope: 'Reads at most 5,000 reviewed candidate labels from one dataset capped at 2 MiB.' },
367
- 'verify-artifact': { mode: 'offline', scope: 'Reads one selected bounded archive, packet, manifest, or saved Lookup document.' },
390
+ 'verify-artifact': { mode: 'offline', scope: 'Reads one selected bounded artefact and, when explicitly supplied, one manifest whose selected entry is compared by exact bytes and canonical identity.' },
391
+ 'interchange-report': { mode: 'offline', scope: 'Reads one selected bounded portable artefact and emits fixed compatibility metadata only.' },
368
392
  'inspect-archive': { mode: 'offline', scope: 'Reads one selected bounded workspace archive with redacted output by default.' },
369
393
  'sign-artifact': { mode: 'offline', scope: 'Reads one selected artefact and one local private key without transmitting either.' },
370
394
  'verify-signature': { mode: 'offline', scope: 'Reads one selected signed package and optional local public key.' },
@@ -382,7 +406,7 @@ const COMMAND_COLLECTION = Object.freeze({
382
406
  'sharing-review': { mode: 'offline', scope: 'Reads one artefact capped at 15 MiB, emits only bounded schema/version metadata and no content values, and performs no transmission.' },
383
407
  'workflow-plan': { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
384
408
  'workflow-run': { mode: 'network', scope: 'Runs only concrete fixed-recipe steps; network collection requires --approve-network and analyst-selection steps always pause.' },
385
- diff: { mode: 'offline', scope: 'Reads two saved Lookup documents for different domains.' },
409
+ diff: { mode: 'offline', scope: 'Reads two compatible retained artefacts capped at 8 MiB each and retains no source paths.' },
386
410
  reconcile: { mode: 'offline', scope: 'Reads 2 to 5 saved observations for one domain, capped at 32 MiB in total.' },
387
411
  timeline: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
388
412
  export: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
@@ -2,21 +2,28 @@ import { CLI_COMMANDS } from "./arguments.mjs";
2
2
  import { INVESTIGATION_PLAN_RECIPES } from "./investigation-plan.mjs";
3
3
  const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force', '--config', '--profile']);
4
4
  const OPTIONS_BY_COMMAND = Object.freeze({
5
- lookup: ['--json', '--junit', '--markdown', '--html', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
5
+ manifest: ['--workflow', '--configuration-digest', '--json', '--quiet', '--no-color'],
6
+ 'map-observations': ['--json', '--quiet', '--no-color'],
7
+ 'oam-export': ['--json', '--quiet', '--no-color'],
8
+ lookup: ['--json', '--junit', '--markdown', '--html', '--no-attribution', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
6
9
  bulk: ['--json', '--jsonl', '--junit', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--plan', '--fail-on', '--quiet', '--no-color'],
7
10
  'ct-search': ['--json', '--quiet', '--no-color'],
11
+ 'ct-intake': ['--json', '--quiet', '--no-color'],
8
12
  discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
9
13
  'discover-scan': ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--fast', '--deep', '--scan-limit', '--chunk-size', '--concurrency', '--resolver', '--allowlist', '--checkpoint', '--resume', '--observation-snapshot', '--registered-only', '--inconclusive-only', '--acquisition-only', '--suppressed-only', '--events', '--plan', '--fail-on', '--json', '--jsonl', '--csv', '--domains', '--quiet', '--no-color'],
10
14
  posture: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
11
15
  http: ['--json', '--quiet', '--no-color'],
12
16
  tls: ['--json', '--quiet', '--no-color'],
17
+ 'dnssec-validate': ['--resolver', '--trust-anchor', '--owned-or-authorized', '--json', '--quiet', '--no-color'],
18
+ 'mail-transport': ['--resolver', '--trust-anchor', '--owned-or-authorized', '--active-probe', '--json', '--quiet', '--no-color'],
13
19
  'registry-support': ['--json', '--quiet', '--no-color'],
14
20
  'registry-doctor': ['--json', '--quiet', '--no-color'],
15
21
  'registry-cohort': ['--json', '--quiet', '--no-color'],
16
22
  'registry-scaffold': ['--profile', '--suffix', '--scenario'],
17
- 'risk-calibrate': ['--json', '--quiet', '--no-color'],
23
+ 'risk-calibrate': ['--json', '--summary-json', '--quiet', '--no-color'],
18
24
  'lookalike-calibrate': ['--json', '--quiet', '--no-color'],
19
- 'verify-artifact': ['--passphrase-file', '--json', '--quiet', '--no-color'],
25
+ 'verify-artifact': ['--passphrase-file', '--manifest', '--manifest-entry', '--json', '--strict-exit', '--quiet', '--no-color'],
26
+ 'interchange-report': ['--passphrase-file', '--json', '--quiet', '--no-color'],
20
27
  'inspect-archive': ['--passphrase-file', '--search', '--require-match', '--reveal', '--expect-content-digest', '--json', '--quiet', '--no-color'],
21
28
  'sign-artifact': ['--private-key-file'],
22
29
  'verify-signature': ['--public-key-file', '--json', '--quiet', '--no-color'],
@@ -30,34 +37,42 @@ const OPTIONS_BY_COMMAND = Object.freeze({
30
37
  'domain-control': ['--json', '--quiet', '--no-color'],
31
38
  'monitor-once': ['--previous', '--limit', '--concurrency', '--fail-on', '--json', '--junit', '--quiet', '--no-color'],
32
39
  assurance: ['--json', '--quiet', '--no-color'],
40
+ 'change-packet': ['--json', '--quiet', '--no-color'],
33
41
  'sharing-review': ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
34
42
  'workflow-plan': ['--json', '--quiet', '--no-color'],
35
43
  'workflow-run': ['--approve-network', '--resume', '--json', '--quiet', '--no-color'],
36
- diff: ['--json', '--quiet', '--no-color'],
44
+ diff: ['--left-session', '--right-session', '--json', '--quiet', '--no-color'],
37
45
  reconcile: ['--json', '--quiet', '--no-color'],
38
46
  timeline: ['--json', '--quiet', '--no-color'],
39
- export: ['--markdown', '--html', '--compact'],
47
+ export: ['--markdown', '--html', '--compact', '--no-attribution'],
40
48
  completion: [],
41
49
  commands: ['--json', '--quiet', '--no-color'],
42
50
  doctor: ['--network', '--json', '--quiet', '--no-color'],
43
51
  manual: [],
44
52
  });
45
53
  const COMMAND_DESCRIPTIONS = Object.freeze({
54
+ manifest: 'Build an evidence manifest offline',
55
+ 'map-observations': 'Normalise passive DNS observations offline',
56
+ 'oam-export': 'Export an observation-archive map offline',
46
57
  lookup: 'Collect one domain, IP, or ASN',
47
58
  bulk: 'Run bounded multi-target collection',
48
59
  'ct-search': 'Search certificate observations',
60
+ 'ct-intake': 'Normalise certificate observations offline',
49
61
  discover: 'Generate lookalike candidates offline',
50
62
  'discover-scan': 'Collect a supervised candidate review queue',
51
63
  posture: 'Review DNS and mail posture',
52
64
  http: 'Inspect one homepage request',
53
65
  tls: 'Inspect one TLS connection',
66
+ 'dnssec-validate': 'Validate an authorised DNSSEC chain',
67
+ 'mail-transport': 'Review selected authorised SMTP transports',
54
68
  'registry-support': 'Explain local registry coverage',
55
69
  'registry-doctor': 'Diagnose saved registry collection',
56
- 'registry-cohort': 'Aggregate target-free registry quality cohorts',
70
+ 'registry-cohort': 'Build target-free registry quality timelines',
57
71
  'registry-scaffold': 'Create a sanitised registry fixture scaffold',
58
72
  'risk-calibrate': 'Replay reviewed Risk labels offline',
59
73
  'lookalike-calibrate': 'Summarise reviewed lookalike yield offline',
60
74
  'verify-artifact': 'Validate saved evidence offline',
75
+ 'interchange-report': 'Report portable artefact fidelity offline',
61
76
  'inspect-archive': 'Inspect an archive locally',
62
77
  'sign-artifact': 'Sign a reviewed artefact locally',
63
78
  'verify-signature': 'Verify a signed evidence package',
@@ -71,10 +86,11 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
71
86
  'domain-control': 'Build or review a domain control manifest',
72
87
  'monitor-once': 'Run one bounded domain control review',
73
88
  assurance: 'Review domain change, recovery, or retirement plans',
89
+ 'change-packet': 'Build a reviewed change packet offline',
74
90
  'sharing-review': 'Lint an artefact before deliberate sharing',
75
91
  'workflow-plan': 'Plan a fixed investigation recipe',
76
92
  'workflow-run': 'Execute approved fixed-recipe steps',
77
- diff: 'Compare two saved domain lookups',
93
+ diff: 'Compare two compatible retained artefacts',
78
94
  reconcile: 'Reconcile independently labelled observations',
79
95
  timeline: 'Build same-domain history from saved lookups',
80
96
  export: 'Convert a lookup to an evidence report',
@@ -105,6 +121,15 @@ const FILE_OPTIONS = Object.freeze([
105
121
  '--observation-snapshot',
106
122
  '--previous',
107
123
  '--resume',
124
+ '--trust-anchor',
125
+ ]);
126
+ const FILE_POSITIONAL_COMMANDS = new Set([
127
+ 'manifest',
128
+ 'map-observations',
129
+ 'oam-export',
130
+ 'ct-intake',
131
+ 'change-packet',
132
+ 'mail-transport',
108
133
  ]);
109
134
  const TEXT_OPTIONS = Object.freeze([
110
135
  '--families',
@@ -121,7 +146,7 @@ const TEXT_OPTIONS = Object.freeze([
121
146
  '--vantage',
122
147
  ]);
123
148
  function commandOptions(command) {
124
- return [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])].join(' ');
149
+ return [...COMMON_OPTIONS, ...OPTIONS_BY_COMMAND[command]].join(' ');
125
150
  }
126
151
  function bashCompletion() {
127
152
  const cases = CLI_COMMANDS.map((command) => ` ${command}) options="${commandOptions(command)}" ;;`).join('\n');
@@ -153,6 +178,9 @@ ${valueCases}
153
178
  ${cases}
154
179
  *) options="--help" ;;
155
180
  esac
181
+ case " ${[...FILE_POSITIONAL_COMMANDS].join(' ')} " in
182
+ *" \${command} "*) [[ "\${current}" != -* ]] && COMPREPLY=( $(compgen -f -- "\${current}") ) && return ;;
183
+ esac
156
184
  COMPREPLY=( $(compgen -W "\${options}" -- "\${current}") )
157
185
  }
158
186
  complete -F _whoisleuth_completion whoisleuth
@@ -190,6 +218,10 @@ ${valueCases}
190
218
  ${cases}
191
219
  *) options=(--help) ;;
192
220
  esac
221
+ if [[ " ${[...FILE_POSITIONAL_COMMANDS].join(' ')} " == *" \${command} "* && "\${words[CURRENT]}" != -* ]]; then
222
+ _files
223
+ return
224
+ fi
193
225
  compadd -- \${options[@]}
194
226
  }
195
227
  if [[ "\${funcstack[1]}" == "_whoisleuth" ]]; then
@@ -200,14 +232,14 @@ fi
200
232
  `;
201
233
  }
202
234
  function fishCompletion() {
203
- const commandLines = CLI_COMMANDS.map((command) => (`complete -c whoisleuth -n '__fish_use_subcommand' -a '${command}' -d '${COMMAND_DESCRIPTIONS[command] || command}'`));
235
+ const commandLines = CLI_COMMANDS.map((command) => (`complete -c whoisleuth -n '__fish_use_subcommand' -a '${command}' -d '${COMMAND_DESCRIPTIONS[command]}'${FILE_POSITIONAL_COMMANDS.has(command) ? ' -F' : ''}`));
204
236
  const optionLine = (condition, option) => {
205
237
  const name = option.replace(/^--/u, '');
206
238
  const fileValue = FILE_OPTIONS.includes(option);
207
239
  const requiresValue = fileValue || [...Object.keys(VALUE_OPTIONS), ...TEXT_OPTIONS].includes(option);
208
240
  return `complete -c whoisleuth -n '${condition}' -l ${name}${requiresValue ? ' -r' : ''}${fileValue ? ' -F' : ''}`;
209
241
  };
210
- const commonCondition = `__fish_seen_subcommand_from ${CLI_COMMANDS.join(' ')}`;
242
+ const commonCondition = 'not __fish_use_subcommand';
211
243
  const commonOptionLines = COMMON_OPTIONS.map((option) => optionLine(commonCondition, option));
212
244
  const optionLines = Object.entries(OPTIONS_BY_COMMAND).flatMap(([command, options]) => (options.map((option) => optionLine(`__fish_seen_subcommand_from ${command}`, option))));
213
245
  const valueLines = Object.entries(VALUE_OPTIONS).flatMap(([option, values]) => (values.map((value) => `complete -c whoisleuth -n '__fish_prev_arg_in ${option}' -a '${value}'`)));
@@ -226,7 +258,7 @@ ${valueLines.join('\n')}
226
258
  function powershellCompletion() {
227
259
  const commandOptions = Object.fromEntries(CLI_COMMANDS.map((command) => [
228
260
  command,
229
- [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])],
261
+ [...COMMON_OPTIONS, ...OPTIONS_BY_COMMAND[command]],
230
262
  ]));
231
263
  return `# WHOISleuth PowerShell completion
232
264
  Register-ArgumentCompleter -Native -CommandName whoisleuth -ScriptBlock {
@@ -240,9 +272,16 @@ ${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @($
240
272
  'completion' = @('bash', 'zsh', 'fish', 'powershell')
241
273
  'workflow-plan' = @(${INVESTIGATION_PLAN_RECIPES.map((value) => `'${value}'`).join(', ')})
242
274
  }
275
+ $fileCommands = @(${[...FILE_POSITIONAL_COMMANDS].map((command) => `'${command}'`).join(', ')})
243
276
  $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
244
277
  $command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
245
278
  $previous = if ($elements.Count -gt 1) { $elements[$elements.Count - 1] } else { '' }
279
+ if ($fileCommands -contains $command -and -not $wordToComplete.StartsWith('-')) {
280
+ Get-ChildItem -Path "${'$'}wordToComplete*" -File -ErrorAction SilentlyContinue | ForEach-Object {
281
+ [System.Management.Automation.CompletionResult]::new($_.FullName, $_.Name, 'ProviderItem', $_.FullName)
282
+ }
283
+ return
284
+ }
246
285
  $candidates = if ($elements.Count -le 2) {
247
286
  @($commands) + @('--help', '--version')
248
287
  } elseif ($command -eq 'completion' -and $elements.Count -le 3) {
@@ -1,8 +1,8 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { open } from 'node:fs/promises';
3
2
  import { homedir } from 'node:os';
4
3
  import { join, resolve } from 'node:path';
5
4
  import { CliUsageError } from "./errors.mjs";
5
+ import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
6
6
  export const CLI_CONFIG_SCHEMA = 'whoisleuth.cli.config';
7
7
  export const CLI_CONFIG_VERSION = 1;
8
8
  export const MAX_CLI_CONFIG_BYTES = 64 * 1024;
@@ -91,16 +91,11 @@ function parseProfileDocument(input) {
91
91
  return Object.freeze({ schema: CLI_CONFIG_SCHEMA, version: CLI_CONFIG_VERSION, ...(root.defaultProfile ? { defaultProfile: root.defaultProfile } : {}), profiles: Object.freeze(profiles) });
92
92
  }
93
93
  async function readConfigFile(path) {
94
- const handle = await open(path, 'r');
95
- try {
96
- const metadata = await handle.stat();
97
- if (metadata.size > MAX_CLI_CONFIG_BYTES)
98
- throw new CliUsageError('CLI configuration is limited to 64 KiB.');
99
- return await handle.readFile({ encoding: 'utf8' });
100
- }
101
- finally {
102
- await handle.close();
103
- }
94
+ return readBoundedRegularTextFile(path, {
95
+ maximumBytes: MAX_CLI_CONFIG_BYTES,
96
+ label: 'CLI configuration',
97
+ allowSymbolicLink: true,
98
+ });
104
99
  }
105
100
  function suppliedGroups(argumentsList) {
106
101
  const groups = new Set();
@@ -114,6 +109,14 @@ function suppliedGroups(argumentsList) {
114
109
  return groups;
115
110
  }
116
111
  export async function resolveCliProfileArguments(argv, options = {}) {
112
+ // registry-scaffold owns --profile as the capability template to generate.
113
+ // Do not reinterpret it as a global CLI-default profile.
114
+ if (argv[0] === 'registry-scaffold') {
115
+ if (argv.includes('--config')) {
116
+ throw new CliUsageError('registry-scaffold does not accept global CLI configuration options.');
117
+ }
118
+ return [...argv];
119
+ }
117
120
  const retained = [];
118
121
  let configPath = null;
119
122
  let profileName = null;
@@ -153,7 +156,7 @@ export async function resolveCliProfileArguments(argv, options = {}) {
153
156
  catch (error) {
154
157
  if (error instanceof CliUsageError)
155
158
  throw error;
156
- throw new CliUsageError(`Could not read CLI configuration from ${selectedPath}.`);
159
+ throw new CliUsageError('CLI configuration could not be read as a bounded regular file.');
157
160
  }
158
161
  const document = parseProfileDocument(input);
159
162
  const selectedName = profileName || document.defaultProfile;
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { domainToASCII } from 'node:url';
2
3
  import { exactKeys, requireBoundedString, requireIsoTimestamp, requireRecord, } from "../lib/bounded-contract-normalizers.mjs";
3
4
  export const CT_EVENT_BATCH_SCHEMA = 'whoisleuth.ct-event-batch';
@@ -21,6 +22,15 @@ const EVENT_KEYS = new Set([
21
22
  'limitations',
22
23
  ]);
23
24
  const SHA256_RE = /^[a-f0-9]{64}$/u;
25
+ function eventId(logId, certificateSha256, observedAt) {
26
+ return createHash('sha256')
27
+ .update(logId)
28
+ .update('\u0000')
29
+ .update(certificateSha256)
30
+ .update('\u0000')
31
+ .update(observedAt)
32
+ .digest('hex');
33
+ }
24
34
  function optionalText(value, label, maximum) {
25
35
  if (value === undefined || value === null || value === '')
26
36
  return null;
@@ -80,6 +90,7 @@ export function buildCtEventFindings(inputRaw) {
80
90
  throw new TypeError(`events[${eventIndex}].completeness must be complete or partial.`);
81
91
  }
82
92
  const limitations = boundedLimitations(event.limitations, `events[${eventIndex}].limitations`);
93
+ const retainedEventId = eventId(logId, certificateSha256, observedAt);
83
94
  return names.map((name) => ({
84
95
  domain: name,
85
96
  category: 'certificate',
@@ -96,6 +107,11 @@ export function buildCtEventFindings(inputRaw) {
96
107
  value: certificateSha256,
97
108
  issuer,
98
109
  notAfter,
110
+ eventId: retainedEventId,
111
+ logId,
112
+ certificateSha256,
113
+ dnsNameCount: names.length,
114
+ namesComplete: completeness === 'complete',
99
115
  },
100
116
  }));
101
117
  });
@@ -105,7 +121,7 @@ export function buildCtEventFindings(inputRaw) {
105
121
  || left.observedAt.localeCompare(right.observedAt)
106
122
  || left.structuredObservation.value.localeCompare(right.structuredObservation.value))
107
123
  .filter((item) => {
108
- const key = `${item.domain}\u0000${item.observedAt}\u0000${item.structuredObservation.value}`;
124
+ const key = `${item.domain}\u0000${item.observedAt}\u0000${item.structuredObservation.value}\u0000${item.structuredObservation.eventId}`;
109
125
  if (seen.has(key))
110
126
  return false;
111
127
  seen.add(key);
@@ -127,17 +143,31 @@ export function buildCtEventFindings(inputRaw) {
127
143
  break;
128
144
  }
129
145
  const truncated = selected.length < unique.length;
146
+ const selectedNamesByEvent = new Map();
147
+ for (const finding of selected) {
148
+ const retainedEventId = finding.structuredObservation.eventId;
149
+ selectedNamesByEvent.set(retainedEventId, (selectedNamesByEvent.get(retainedEventId) ?? 0) + 1);
150
+ }
130
151
  const findings = selected.map((finding) => Object.freeze({
131
152
  ...finding,
153
+ structuredObservation: Object.freeze({
154
+ ...finding.structuredObservation,
155
+ namesComplete: finding.structuredObservation.namesComplete
156
+ && selectedNamesByEvent.get(finding.structuredObservation.eventId) === finding.structuredObservation.dnsNameCount,
157
+ }),
132
158
  limitations: Object.freeze([
133
159
  ...finding.limitations,
134
160
  'The supplied event is an observation, not proof that the certificate was served by the named domain or that the domain operator requested it.',
161
+ ...(finding.structuredObservation.namesComplete
162
+ && selectedNamesByEvent.get(finding.structuredObservation.eventId) !== finding.structuredObservation.dnsNameCount
163
+ ? ['The bounded import did not retain every DNS name from this certificate event.']
164
+ : []),
135
165
  ...(truncated ? [`The batch contained ${unique.length} unique domain observations; this import preserves the first ${MAX_CT_EXTERNAL_FINDINGS} in deterministic order.`] : []),
136
166
  ].slice(0, 8)),
137
167
  }));
138
168
  return Object.freeze({
139
169
  schema: 'whoisleuth.external-findings',
140
- schemaVersion: 3,
170
+ schemaVersion: 4,
141
171
  source,
142
172
  findings: Object.freeze(findings),
143
173
  });
@@ -1,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { open } from 'node:fs/promises';
3
2
  import { availabilityState, bulkDnsSummary } from "./bulk-output.mjs";
4
- import { CliUsageError } from "./errors.mjs";
3
+ import { boundedCliInputError, CliUsageError } from "./errors.mjs";
5
4
  import { writePrivateFile } from "./output-file.mjs";
6
5
  import { isValidAsciiDomainName } from "../lib/hostname.mjs";
6
+ import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
7
7
  export const CLI_DISCOVERY_OBSERVATION_SCHEMA = 'whoisleuth.cli.discovery-observation-snapshot';
8
8
  export const CLI_DISCOVERY_OBSERVATION_VERSION = 2;
9
9
  export const MAX_DISCOVERY_OBSERVATION_BYTES = 8 * 1024 * 1024;
@@ -158,22 +158,16 @@ function parseObservationSnapshot(text, expectedDigest) {
158
158
  }
159
159
  async function readExisting(path) {
160
160
  try {
161
- const handle = await open(path, 'r');
162
- try {
163
- const metadata = await handle.stat();
164
- if (!metadata.isFile() || metadata.size > MAX_DISCOVERY_OBSERVATION_BYTES) {
165
- throw new CliUsageError(`Discovery observation snapshots are limited to ${MAX_DISCOVERY_OBSERVATION_BYTES} bytes.`);
166
- }
167
- return await handle.readFile({ encoding: 'utf8' });
168
- }
169
- finally {
170
- await handle.close();
171
- }
161
+ return await readBoundedRegularTextFile(path, {
162
+ maximumBytes: MAX_DISCOVERY_OBSERVATION_BYTES,
163
+ label: 'Discovery observation snapshot',
164
+ allowSymbolicLink: true,
165
+ });
172
166
  }
173
167
  catch (error) {
174
168
  if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
175
169
  return null;
176
- throw error;
170
+ throw boundedCliInputError(error, 'Discovery observation snapshot');
177
171
  }
178
172
  }
179
173
  function currentAttempt(domain, item, previous, generatedAt, deep) {