@slicedearth/whoisleuth-cli 1.45.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 (133) hide show
  1. package/DISCLOSURE +7 -3
  2. package/NOTICE +5 -4
  3. package/README.md +59 -6
  4. package/bin/whoisleuth.mjs +5 -3
  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 +2 -3
  10. package/cli/bulk-command-runner.mjs +2 -5
  11. package/cli/case-pack.mjs +819 -34
  12. package/cli/command-reference.mjs +48 -24
  13. package/cli/completion.mjs +16 -8
  14. package/cli/ct-event-intake.mjs +32 -2
  15. package/cli/discovery-scan-command-runner.mjs +4 -5
  16. package/cli/discovery-workflow.mjs +2 -3
  17. package/cli/doctor.mjs +2 -2
  18. package/cli/domain-control-monitor.mjs +21 -15
  19. package/cli/domain-control-observations.mjs +25 -2
  20. package/cli/evidence-command-runner.mjs +17 -11
  21. package/cli/evidence-signing.mjs +73 -45
  22. package/cli/export-evidence.mjs +11 -4
  23. package/cli/formatters/html.mjs +3 -3
  24. package/cli/formatters/terminal.mjs +3 -0
  25. package/cli/input.mjs +57 -0
  26. package/cli/interchange-report.mjs +226 -0
  27. package/cli/investigation-manifest.mjs +16 -17
  28. package/cli/investigation-run.mjs +71 -23
  29. package/cli/local-mmdb-review.mjs +45 -14
  30. package/cli/lookup-command-runner.mjs +2 -2
  31. package/cli/lookup-diff.mjs +9 -0
  32. package/cli/network-command-runner.mjs +78 -0
  33. package/cli/offline-evidence-review.mjs +28 -8
  34. package/cli/page-compare.mjs +8 -5
  35. package/cli/registry-cohort.mjs +385 -61
  36. package/cli/registry-support.mjs +2 -1
  37. package/cli/retained-artifact-diff.mjs +376 -0
  38. package/cli/risk-calibration.mjs +7 -5
  39. package/cli/runner.mjs +147 -185
  40. package/cli/saved-lookup.mjs +26 -3
  41. package/cli/sharing-review.mjs +12 -6
  42. package/docs/cli-reference.md +444 -70
  43. package/docs/cli.md +24 -5
  44. package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
  45. package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
  46. package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
  47. package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
  48. package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
  49. package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
  50. package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
  51. package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
  52. package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
  53. package/frontend/src/lib/analysis/bulk-sort.js +4 -0
  54. package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
  55. package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
  56. package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
  57. package/frontend/src/lib/analysis/case-migration-model.js +83 -31
  58. package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
  59. package/frontend/src/lib/analysis/case-record-model.js +2 -0
  60. package/frontend/src/lib/analysis/case-record-operations.js +42 -12
  61. package/frontend/src/lib/analysis/case-report.js +39 -6
  62. package/frontend/src/lib/analysis/case-response-model.js +66 -7
  63. package/frontend/src/lib/analysis/case-response-packet.js +15 -5
  64. package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
  65. package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
  66. package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
  67. package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
  68. package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
  69. package/frontend/src/lib/analysis/domain-control-records.js +88 -0
  70. package/frontend/src/lib/analysis/external-findings-import.js +70 -6
  71. package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
  72. package/frontend/src/lib/analysis/investigation-guide.js +52 -10
  73. package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
  74. package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
  75. package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
  76. package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
  77. package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
  78. package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
  79. package/frontend/src/lib/analysis/utils.js +26 -3
  80. package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
  81. package/frontend/src/lib/analysis/visualization-models.js +63 -94
  82. package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
  83. package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
  84. package/frontend/src/lib/analysis/workspace-archive.js +104 -14
  85. package/lib/application-version.mjs +3 -0
  86. package/lib/availability.mjs +14 -12
  87. package/lib/bounded-file.mjs +13 -0
  88. package/lib/bounded-json-response.mjs +17 -4
  89. package/lib/bounded-json.mjs +249 -0
  90. package/lib/certificate-extension-profile.mjs +2 -1
  91. package/lib/cryptographic-assurance.mjs +155 -0
  92. package/lib/ct-search.mjs +87 -47
  93. package/lib/dns-intelligence.mjs +3 -2
  94. package/lib/dns-resolver-selection.mjs +6 -4
  95. package/lib/dnssec-chain-validation.mjs +1053 -0
  96. package/lib/dnssec-evidence-validation.mjs +10 -3
  97. package/lib/domain-change-packet.mjs +7 -5
  98. package/lib/domain-control-manifest.mjs +17 -129
  99. package/lib/domain-posture.mjs +7 -4
  100. package/lib/evidence-export.mjs +751 -78
  101. package/lib/evidence-report-markdown.mjs +3 -0
  102. package/lib/evidence-report.mjs +5 -0
  103. package/lib/favicon.mjs +8 -5
  104. package/lib/http-evidence-bounds.mjs +3 -0
  105. package/lib/http-intelligence.mjs +2 -5
  106. package/lib/interchange-fidelity-registry.mjs +122 -0
  107. package/lib/local-geoip-evidence.mjs +2 -1
  108. package/lib/lookup-network-evidence-bounds.mjs +9 -0
  109. package/lib/lookup-response-contract.mjs +691 -3
  110. package/lib/lookup.mjs +2 -2
  111. package/lib/observation.mjs +7 -6
  112. package/lib/outbound-identity.mjs +2 -3
  113. package/lib/perceptual-hash.mjs +12 -4
  114. package/lib/portable-generator.mjs +21 -0
  115. package/lib/provider-policy-admission.mjs +9 -4
  116. package/lib/rdap-normalization.mjs +18 -5
  117. package/lib/rdap-registrar.mjs +5 -1
  118. package/lib/registry-capabilities.mjs +3 -0
  119. package/lib/registry-capability-catalogue.mjs +12 -2
  120. package/lib/registry-comparison.mjs +28 -11
  121. package/lib/risk-calibration-summary.mjs +338 -0
  122. package/lib/rpki-evidence.mjs +9 -8
  123. package/lib/safe-fetch.mjs +59 -10
  124. package/lib/security-txt.mjs +26 -3
  125. package/lib/service-binding-dns.mjs +6 -1
  126. package/lib/smtp-transport-review.mjs +784 -0
  127. package/lib/sslbl-certificates.generated.mjs +136 -134
  128. package/lib/threat-intelligence-runtime.mjs +3 -11
  129. package/lib/threat-intelligence-types.mjs +18 -1
  130. package/lib/tls-intelligence.mjs +11 -5
  131. package/lib/tlsa-evidence.mjs +37 -7
  132. package/package.json +1 -1
  133. 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.' },
@@ -5,7 +5,7 @@ const OPTIONS_BY_COMMAND = Object.freeze({
5
5
  manifest: ['--workflow', '--configuration-digest', '--json', '--quiet', '--no-color'],
6
6
  'map-observations': ['--json', '--quiet', '--no-color'],
7
7
  'oam-export': ['--json', '--quiet', '--no-color'],
8
- lookup: ['--json', '--junit', '--markdown', '--html', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--strict-exit', '--fail-on', '--events', '--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'],
9
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'],
10
10
  'ct-search': ['--json', '--quiet', '--no-color'],
11
11
  'ct-intake': ['--json', '--quiet', '--no-color'],
@@ -14,13 +14,16 @@ const OPTIONS_BY_COMMAND = Object.freeze({
14
14
  posture: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
15
15
  http: ['--json', '--quiet', '--no-color'],
16
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'],
17
19
  'registry-support': ['--json', '--quiet', '--no-color'],
18
20
  'registry-doctor': ['--json', '--quiet', '--no-color'],
19
21
  'registry-cohort': ['--json', '--quiet', '--no-color'],
20
22
  'registry-scaffold': ['--profile', '--suffix', '--scenario'],
21
- 'risk-calibrate': ['--json', '--quiet', '--no-color'],
23
+ 'risk-calibrate': ['--json', '--summary-json', '--quiet', '--no-color'],
22
24
  'lookalike-calibrate': ['--json', '--quiet', '--no-color'],
23
- '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'],
24
27
  'inspect-archive': ['--passphrase-file', '--search', '--require-match', '--reveal', '--expect-content-digest', '--json', '--quiet', '--no-color'],
25
28
  'sign-artifact': ['--private-key-file'],
26
29
  'verify-signature': ['--public-key-file', '--json', '--quiet', '--no-color'],
@@ -38,10 +41,10 @@ const OPTIONS_BY_COMMAND = Object.freeze({
38
41
  'sharing-review': ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
39
42
  'workflow-plan': ['--json', '--quiet', '--no-color'],
40
43
  'workflow-run': ['--approve-network', '--resume', '--json', '--quiet', '--no-color'],
41
- diff: ['--json', '--quiet', '--no-color'],
44
+ diff: ['--left-session', '--right-session', '--json', '--quiet', '--no-color'],
42
45
  reconcile: ['--json', '--quiet', '--no-color'],
43
46
  timeline: ['--json', '--quiet', '--no-color'],
44
- export: ['--markdown', '--html', '--compact'],
47
+ export: ['--markdown', '--html', '--compact', '--no-attribution'],
45
48
  completion: [],
46
49
  commands: ['--json', '--quiet', '--no-color'],
47
50
  doctor: ['--network', '--json', '--quiet', '--no-color'],
@@ -60,13 +63,16 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
60
63
  posture: 'Review DNS and mail posture',
61
64
  http: 'Inspect one homepage request',
62
65
  tls: 'Inspect one TLS connection',
66
+ 'dnssec-validate': 'Validate an authorised DNSSEC chain',
67
+ 'mail-transport': 'Review selected authorised SMTP transports',
63
68
  'registry-support': 'Explain local registry coverage',
64
69
  'registry-doctor': 'Diagnose saved registry collection',
65
- 'registry-cohort': 'Aggregate target-free registry quality cohorts',
70
+ 'registry-cohort': 'Build target-free registry quality timelines',
66
71
  'registry-scaffold': 'Create a sanitised registry fixture scaffold',
67
72
  'risk-calibrate': 'Replay reviewed Risk labels offline',
68
73
  'lookalike-calibrate': 'Summarise reviewed lookalike yield offline',
69
74
  'verify-artifact': 'Validate saved evidence offline',
75
+ 'interchange-report': 'Report portable artefact fidelity offline',
70
76
  'inspect-archive': 'Inspect an archive locally',
71
77
  'sign-artifact': 'Sign a reviewed artefact locally',
72
78
  'verify-signature': 'Verify a signed evidence package',
@@ -84,7 +90,7 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
84
90
  'sharing-review': 'Lint an artefact before deliberate sharing',
85
91
  'workflow-plan': 'Plan a fixed investigation recipe',
86
92
  'workflow-run': 'Execute approved fixed-recipe steps',
87
- diff: 'Compare two saved domain lookups',
93
+ diff: 'Compare two compatible retained artefacts',
88
94
  reconcile: 'Reconcile independently labelled observations',
89
95
  timeline: 'Build same-domain history from saved lookups',
90
96
  export: 'Convert a lookup to an evidence report',
@@ -115,6 +121,7 @@ const FILE_OPTIONS = Object.freeze([
115
121
  '--observation-snapshot',
116
122
  '--previous',
117
123
  '--resume',
124
+ '--trust-anchor',
118
125
  ]);
119
126
  const FILE_POSITIONAL_COMMANDS = new Set([
120
127
  'manifest',
@@ -122,6 +129,7 @@ const FILE_POSITIONAL_COMMANDS = new Set([
122
129
  'oam-export',
123
130
  'ct-intake',
124
131
  'change-packet',
132
+ 'mail-transport',
125
133
  ]);
126
134
  const TEXT_OPTIONS = Object.freeze([
127
135
  '--families',
@@ -231,7 +239,7 @@ function fishCompletion() {
231
239
  const requiresValue = fileValue || [...Object.keys(VALUE_OPTIONS), ...TEXT_OPTIONS].includes(option);
232
240
  return `complete -c whoisleuth -n '${condition}' -l ${name}${requiresValue ? ' -r' : ''}${fileValue ? ' -F' : ''}`;
233
241
  };
234
- const commonCondition = `__fish_seen_subcommand_from ${CLI_COMMANDS.join(' ')}`;
242
+ const commonCondition = 'not __fish_use_subcommand';
235
243
  const commonOptionLines = COMMON_OPTIONS.map((option) => optionLine(commonCondition, option));
236
244
  const optionLines = Object.entries(OPTIONS_BY_COMMAND).flatMap(([command, options]) => (options.map((option) => optionLine(`__fish_seen_subcommand_from ${command}`, option))));
237
245
  const valueLines = Object.entries(VALUE_OPTIONS).flatMap(([option, values]) => (values.map((value) => `complete -c whoisleuth -n '__fish_prev_arg_in ${option}' -a '${value}'`)));
@@ -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,8 @@
1
- import { createReadStream } from 'node:fs';
2
1
  import { classifyQuery } from "../lib/classify.mjs";
3
2
  import { normalizeSelectedDnsResolvers } from "../lib/dns-resolver-selection.mjs";
4
3
  import { runUnifiedLookup } from "../lib/lookup.mjs";
5
4
  import { createBulkCheckpointWriter } from "./bulk-checkpoint.mjs";
6
- import { buildDiscoveryScanDocument, formatDiscoveryScanCsv, formatDiscoveryScanDomains, formatDiscoveryScanJsonLines, formatTerminalDiscoveryScan, parseDiscoveryScanAllowlist, readDiscoveryScanListBounded, runDiscoveryScanChunks, } from "./discovery-scan.mjs";
5
+ import { buildDiscoveryScanDocument, formatDiscoveryScanCsv, formatDiscoveryScanDomains, formatDiscoveryScanJsonLines, formatTerminalDiscoveryScan, parseDiscoveryScanAllowlist, runDiscoveryScanChunks, } from "./discovery-scan.mjs";
7
6
  import { updateDiscoveryObservationSnapshot } from "./discovery-observation-snapshot.mjs";
8
7
  import { generateDiscoveryCandidates } from "./discovery-workflow.mjs";
9
8
  import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
@@ -12,13 +11,13 @@ import { formatJsonDocument } from "./formatters/json.mjs";
12
11
  import { createCliProgressEvents } from "./progress-events.mjs";
13
12
  import { buildCollectionPreflight, formatCollectionPreflight } from "./collection-preflight.mjs";
14
13
  import { evaluateCliFailPolicies, formatFailPolicyNotice } from "./fail-policy.mjs";
15
- async function readAllowlist(source, dependencies, classify) {
14
+ async function readAllowlist(source, dependencies, context, classify) {
16
15
  if (!source)
17
16
  return new Set();
18
17
  try {
19
18
  const text = dependencies.readDiscoveryAllowlist
20
19
  ? await dependencies.readDiscoveryAllowlist(source)
21
- : await readDiscoveryScanListBounded(createReadStream(source, { highWaterMark: 64 * 1024 }));
20
+ : await context.readInput(source, 64 * 1024, 'Discovery scan allowlist');
22
21
  return parseDiscoveryScanAllowlist(text, classify);
23
22
  }
24
23
  catch (error) {
@@ -61,7 +60,7 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
61
60
  throw new CliUsageError(boundedCliErrorMessage(error, 'Invalid DNS resolver selection'));
62
61
  }
63
62
  }
64
- const allowlist = await readAllowlist(args.allowlistSource, dependencies, classify);
63
+ const allowlist = await readAllowlist(args.allowlistSource, dependencies, context, classify);
65
64
  const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
66
65
  const checkpoint = args.checkpoint
67
66
  ? await checkpointWriter({
@@ -1,6 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { createReadStream } from 'node:fs';
3
- import { DEFAULT_DISCOVERY_TLDS, MAX_DISCOVERY_DICTIONARY_BYTES, normalizeDiscoveryTlds, readDiscoveryDictionaryBounded, } from "./discover.mjs";
2
+ import { DEFAULT_DISCOVERY_TLDS, MAX_DISCOVERY_DICTIONARY_BYTES, normalizeDiscoveryTlds, } from "./discover.mjs";
4
3
  import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
5
4
  async function generateDiscoveryCandidates(args, dependencies, context) {
6
5
  const seed = args.seed || await context.readSingleInput();
@@ -29,7 +28,7 @@ async function generateDiscoveryCandidates(args, dependencies, context) {
29
28
  try {
30
29
  dictionaryText = dependencies.readDiscoveryDictionary
31
30
  ? await dependencies.readDiscoveryDictionary(args.dictionarySource)
32
- : await readDiscoveryDictionaryBounded(createReadStream(args.dictionarySource, { highWaterMark: MAX_DISCOVERY_DICTIONARY_BYTES }), MAX_DISCOVERY_DICTIONARY_BYTES);
31
+ : await context.readInput(args.dictionarySource, MAX_DISCOVERY_DICTIONARY_BYTES, 'Discovery dictionary');
33
32
  }
34
33
  catch (error) {
35
34
  if (error instanceof CliUsageError)
package/cli/doctor.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { withTimeout } from "../lib/abort.mjs";
2
- const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
3
- const DOCTOR_VERSION = 1;
2
+ export const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
3
+ export const DOCTOR_VERSION = 1;
4
4
  const MINIMUM_NODE_MAJOR = 24;
5
5
  const NETWORK_HOST = 'whois.iana.org';
6
6
  const NETWORK_QUERY = 'example.com';
@@ -1,4 +1,5 @@
1
1
  import { classifyQuery } from "../lib/classify.mjs";
2
+ import { scanBoundedJson } from "../lib/bounded-json.mjs";
2
3
  import { buildDomainControlFlightRecorder, } from "../lib/domain-control-flight-recorder.mjs";
3
4
  import { verifyDomainControlManifest } from "../lib/domain-control-manifest.mjs";
4
5
  import { buildCliLookupDocument } from "./formatters/json.mjs";
@@ -7,16 +8,27 @@ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
7
8
  export const CLI_DOMAIN_CONTROL_MONITOR_SCHEMA = 'whoisleuth.cli.domain-control-monitor';
8
9
  export const CLI_DOMAIN_CONTROL_MONITOR_VERSION = 1;
9
10
  export const MAX_DOMAIN_CONTROL_MONITOR_DOMAINS = 20;
10
- function previousSnapshot(input) {
11
- if (!input)
12
- return { observations: [] };
13
- let parsed;
11
+ function parseMonitorJson(input, label) {
12
+ const normalized = input.replace(/^\uFEFF/u, '');
14
13
  try {
15
- parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
14
+ scanBoundedJson(normalized);
15
+ return JSON.parse(normalized);
16
16
  }
17
- catch {
18
- throw new CliUsageError('Previous monitor snapshot must be valid JSON.');
17
+ catch (cause) {
18
+ const detail = cause instanceof Error ? cause.message : '';
19
+ if (detail === 'Artefact input is not valid JSON.') {
20
+ throw new CliUsageError(`${label} must be valid JSON.`);
21
+ }
22
+ if (detail.startsWith('Artefact JSON ')) {
23
+ throw new CliUsageError(`${label} ${detail.slice('Artefact JSON '.length)}`);
24
+ }
25
+ throw new CliUsageError(`${label} must satisfy the bounded JSON structure contract.`);
19
26
  }
27
+ }
28
+ function previousSnapshot(input) {
29
+ if (!input)
30
+ return { observations: [] };
31
+ const parsed = parseMonitorJson(input, 'Previous monitor snapshot');
20
32
  const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
21
33
  if (root.schema !== CLI_DOMAIN_CONTROL_MONITOR_SCHEMA || root.version !== CLI_DOMAIN_CONTROL_MONITOR_VERSION || !Array.isArray(root.observations)) {
22
34
  throw new CliUsageError(`Previous monitor snapshot must use ${CLI_DOMAIN_CONTROL_MONITOR_SCHEMA} version ${CLI_DOMAIN_CONTROL_MONITOR_VERSION}.`);
@@ -29,14 +41,9 @@ function previousSnapshot(input) {
29
41
  }
30
42
  export async function runDomainControlMonitor(manifestInput, previousInput, options) {
31
43
  const generatedAt = options.now();
32
- let rawManifest;
33
- try {
34
- rawManifest = JSON.parse(manifestInput.replace(/^\uFEFF/u, ''));
35
- }
36
- catch {
37
- throw new CliUsageError('Domain-control manifest must be valid JSON.');
38
- }
44
+ const rawManifest = parseMonitorJson(manifestInput, 'Domain-control manifest');
39
45
  const manifest = verifyDomainControlManifest(rawManifest);
46
+ const previous = previousSnapshot(previousInput);
40
47
  const entries = manifest.entries.slice(0, Math.min(options.limit, MAX_DOMAIN_CONTROL_MONITOR_DOMAINS));
41
48
  const lookups = new Array(entries.length);
42
49
  const failures = [];
@@ -69,7 +76,6 @@ export async function runDomainControlMonitor(manifestInput, previousInput, opti
69
76
  const review = buildCliDomainControlReview(JSON.stringify({
70
77
  schema: 'whoisleuth.cli.domain-control-review-input', version: 1, manifest, lookups: successful,
71
78
  }), generatedAt);
72
- const previous = previousSnapshot(previousInput);
73
79
  const observations = review.observations;
74
80
  const flightRecorder = buildDomainControlFlightRecorder({
75
81
  schema: 'whoisleuth.domain-control-flight-recorder.input', version: 1,
@@ -1,4 +1,5 @@
1
1
  import { Buffer } from 'node:buffer';
2
+ import { MAX_BOUNDED_JSON_DEPTH, MAX_BOUNDED_JSON_KEYS, MAX_BOUNDED_JSON_VALUES, scanBoundedJson, } from "../lib/bounded-json.mjs";
2
3
  import { DOMAIN_CONTROL_FLIGHT_RECORDER_FIELDS, } from "../lib/domain-control-flight-recorder.mjs";
3
4
  import { DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, reviewDomainControlManifest, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
4
5
  import { CliUsageError } from "./errors.mjs";
@@ -7,6 +8,10 @@ export const CLI_DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.cli.domain-con
7
8
  export const CLI_DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.cli.domain-control-review';
8
9
  export const CLI_DOMAIN_CONTROL_REVIEW_VERSION = 1;
9
10
  export const MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES = 32 * 1024 * 1024;
11
+ export const MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS = 100;
12
+ export const MAX_DOMAIN_CONTROL_REVIEW_JSON_DEPTH = MAX_BOUNDED_JSON_DEPTH + 2;
13
+ export const MAX_DOMAIN_CONTROL_REVIEW_JSON_KEYS = MAX_BOUNDED_JSON_KEYS * (MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS + 1);
14
+ export const MAX_DOMAIN_CONTROL_REVIEW_JSON_VALUES = MAX_BOUNDED_JSON_VALUES * (MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS + 1);
10
15
  function record(value) {
11
16
  return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
12
17
  }
@@ -134,9 +139,27 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
134
139
  if (Buffer.byteLength(inputText, 'utf8') > MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES) {
135
140
  throw new CliUsageError(`Domain-control review input is limited to ${MAX_DOMAIN_CONTROL_REVIEW_INPUT_BYTES} bytes.`);
136
141
  }
142
+ const normalizedInput = inputText.replace(/^\uFEFF/u, '');
143
+ try {
144
+ scanBoundedJson(normalizedInput, {
145
+ maximumDepth: MAX_DOMAIN_CONTROL_REVIEW_JSON_DEPTH,
146
+ maximumKeys: MAX_DOMAIN_CONTROL_REVIEW_JSON_KEYS,
147
+ maximumValues: MAX_DOMAIN_CONTROL_REVIEW_JSON_VALUES,
148
+ });
149
+ }
150
+ catch (cause) {
151
+ const detail = cause instanceof Error ? cause.message : '';
152
+ if (detail === 'Artefact input is not valid JSON.') {
153
+ throw new CliUsageError('Domain-control review input must be valid JSON.');
154
+ }
155
+ const boundedDetail = detail.replace(/^Artefact JSON /u, '');
156
+ throw new CliUsageError(boundedDetail
157
+ ? `Domain-control review input ${boundedDetail}`
158
+ : 'Domain-control review input must be valid JSON.');
159
+ }
137
160
  let parsed;
138
161
  try {
139
- parsed = JSON.parse(inputText.replace(/^\uFEFF/u, ''));
162
+ parsed = JSON.parse(normalizedInput);
140
163
  }
141
164
  catch {
142
165
  throw new CliUsageError('Domain-control review input must be valid JSON.');
@@ -148,7 +171,7 @@ export function buildCliDomainControlReview(inputText, generatedAt = new Date().
148
171
  if (Object.keys(input).some((key) => !new Set(['schema', 'version', 'manifest', 'lookups']).has(key))) {
149
172
  throw new CliUsageError('Domain-control review input contains an unsupported field.');
150
173
  }
151
- if (input.lookups.length < 1 || input.lookups.length > 100) {
174
+ if (input.lookups.length < 1 || input.lookups.length > MAX_DOMAIN_CONTROL_REVIEW_LOOKUPS) {
152
175
  throw new CliUsageError('Domain-control review requires from 1 to 100 saved Lookup documents.');
153
176
  }
154
177
  const manifest = verifyDomainControlManifest(input.manifest);