@slicedearth/whoisleuth-cli 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +661 -0
  2. package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
  3. package/NOTICE +76 -0
  4. package/README.md +84 -0
  5. package/TRADEMARKS.md +41 -0
  6. package/bin/whoisleuth.mjs +35 -0
  7. package/cli/archive-inspect.mjs +198 -0
  8. package/cli/arguments.mjs +1022 -0
  9. package/cli/artifact-verify.mjs +210 -0
  10. package/cli/bulk-checkpoint.mjs +218 -0
  11. package/cli/bulk-command-runner.mjs +109 -0
  12. package/cli/bulk-output.mjs +112 -0
  13. package/cli/bulk.mjs +133 -0
  14. package/cli/compare.mjs +238 -0
  15. package/cli/completion.mjs +175 -0
  16. package/cli/csv.mjs +1 -0
  17. package/cli/discover.mjs +50 -0
  18. package/cli/discovery-command-runner.mjs +36 -0
  19. package/cli/discovery-observation-snapshot.mjs +315 -0
  20. package/cli/discovery-scan-command-runner.mjs +141 -0
  21. package/cli/discovery-scan.mjs +325 -0
  22. package/cli/discovery-snapshot.mjs +133 -0
  23. package/cli/discovery-workflow.mjs +73 -0
  24. package/cli/doctor.mjs +137 -0
  25. package/cli/errors.mjs +21 -0
  26. package/cli/evidence-command-arguments.mjs +145 -0
  27. package/cli/evidence-command-runner.mjs +106 -0
  28. package/cli/evidence-signing.mjs +215 -0
  29. package/cli/exit-codes.mjs +9 -0
  30. package/cli/export-evidence.mjs +28 -0
  31. package/cli/formatters/evidence-report.mjs +3 -0
  32. package/cli/formatters/html.mjs +69 -0
  33. package/cli/formatters/json.mjs +135 -0
  34. package/cli/formatters/markdown.mjs +3 -0
  35. package/cli/formatters/terminal.mjs +658 -0
  36. package/cli/http.mjs +27 -0
  37. package/cli/lookup-command-runner.mjs +87 -0
  38. package/cli/lookup-diff.mjs +171 -0
  39. package/cli/mail-review.mjs +204 -0
  40. package/cli/manual.mjs +34 -0
  41. package/cli/network-command-runner.mjs +88 -0
  42. package/cli/output-file.mjs +97 -0
  43. package/cli/page-compare.mjs +123 -0
  44. package/cli/posture.mjs +20 -0
  45. package/cli/progress-events.mjs +53 -0
  46. package/cli/progress.mjs +56 -0
  47. package/cli/registry-access.mjs +3 -0
  48. package/cli/registry-support.mjs +120 -0
  49. package/cli/risk-calibration.mjs +294 -0
  50. package/cli/runner-types.mjs +1 -0
  51. package/cli/runner.mjs +726 -0
  52. package/cli/saved-lookup.mjs +87 -0
  53. package/cli/source-reliability.mjs +727 -0
  54. package/cli/strict-exit.mjs +55 -0
  55. package/cli/terminal-presentation.mjs +147 -0
  56. package/docs/cli.md +827 -0
  57. package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
  58. package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
  59. package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
  60. package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
  61. package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
  62. package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
  63. package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
  64. package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
  65. package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
  66. package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
  67. package/frontend/src/lib/analysis/bulk-sort.js +49 -0
  68. package/frontend/src/lib/analysis/campaign-model.js +265 -0
  69. package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
  70. package/frontend/src/lib/analysis/case-migration-model.js +248 -0
  71. package/frontend/src/lib/analysis/case-model.js +5 -0
  72. package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
  73. package/frontend/src/lib/analysis/case-record-core.js +232 -0
  74. package/frontend/src/lib/analysis/case-record-model.js +7 -0
  75. package/frontend/src/lib/analysis/case-record-operations.js +224 -0
  76. package/frontend/src/lib/analysis/case-response-model.js +640 -0
  77. package/frontend/src/lib/analysis/case-response-packet.js +573 -0
  78. package/frontend/src/lib/analysis/case-storage-model.js +93 -0
  79. package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
  80. package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
  81. package/frontend/src/lib/analysis/http-summary.js +165 -0
  82. package/frontend/src/lib/analysis/investigation-guide.js +500 -0
  83. package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
  84. package/frontend/src/lib/analysis/page-baseline.js +272 -0
  85. package/frontend/src/lib/analysis/page-similarity.js +179 -0
  86. package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
  87. package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
  88. package/frontend/src/lib/analysis/scoring.js +135 -0
  89. package/frontend/src/lib/analysis/shortlist-model.js +209 -0
  90. package/frontend/src/lib/analysis/utils.js +265 -0
  91. package/frontend/src/lib/analysis/watchlist-history.js +643 -0
  92. package/frontend/src/lib/analysis/watchlist-store.js +127 -0
  93. package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
  94. package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
  95. package/frontend/src/lib/analysis/workspace-archive.js +472 -0
  96. package/lib/abort.mjs +26 -0
  97. package/lib/abusech-auth.mjs +19 -0
  98. package/lib/availability.mjs +716 -0
  99. package/lib/bounded-contract-normalizers.mjs +61 -0
  100. package/lib/browser-library-profile.mjs +310 -0
  101. package/lib/bulk-comparison-evidence.mjs +83 -0
  102. package/lib/classify.mjs +93 -0
  103. package/lib/client-behavior-profile.mjs +83 -0
  104. package/lib/credential-surface-profile.mjs +66 -0
  105. package/lib/csv.mjs +23 -0
  106. package/lib/ct-query.mjs +27 -0
  107. package/lib/ct-search.mjs +393 -0
  108. package/lib/distributed-operation-budget.mjs +461 -0
  109. package/lib/dns-delegation-health.mjs +344 -0
  110. package/lib/dns-intelligence.mjs +739 -0
  111. package/lib/dns-mx.mjs +24 -0
  112. package/lib/dns-resolver-selection.mjs +33 -0
  113. package/lib/domain-posture-analysis.mjs +254 -0
  114. package/lib/domain-posture-parsers.mjs +358 -0
  115. package/lib/domain-posture.mjs +745 -0
  116. package/lib/error-detail.mjs +10 -0
  117. package/lib/evidence-export.mjs +431 -0
  118. package/lib/evidence-report-markdown.mjs +71 -0
  119. package/lib/evidence-report.mjs +391 -0
  120. package/lib/external-intelligence-risk.mjs +108 -0
  121. package/lib/favicon.mjs +146 -0
  122. package/lib/feature-policy.mjs +70 -0
  123. package/lib/generated/retire-browser-catalog.mjs +8724 -0
  124. package/lib/generated/unicode-confusables-17.mjs +93 -0
  125. package/lib/hostname.mjs +38 -0
  126. package/lib/html-signals.mjs +680 -0
  127. package/lib/http-intelligence.mjs +232 -0
  128. package/lib/idn-confusable-policy.mjs +86 -0
  129. package/lib/idn-confusables.mjs +465 -0
  130. package/lib/lookup-cache.mjs +130 -0
  131. package/lib/lookup-progress.mjs +291 -0
  132. package/lib/lookup-source-progress.mjs +109 -0
  133. package/lib/lookup-threat-provider-inventory.mjs +13 -0
  134. package/lib/lookup.mjs +592 -0
  135. package/lib/observation.mjs +114 -0
  136. package/lib/observed-network-context.mjs +355 -0
  137. package/lib/operation-budget.mjs +489 -0
  138. package/lib/page-fingerprints.mjs +481 -0
  139. package/lib/page-role-profile.mjs +117 -0
  140. package/lib/perceptual-hash.mjs +451 -0
  141. package/lib/rdap-attempts.mjs +34 -0
  142. package/lib/rdap-bootstrap.mjs +173 -0
  143. package/lib/rdap-capabilities.mjs +292 -0
  144. package/lib/rdap-client.mjs +125 -0
  145. package/lib/rdap-normalization.mjs +691 -0
  146. package/lib/rdap-registrar.mjs +196 -0
  147. package/lib/rdap-transport.mjs +39 -0
  148. package/lib/rdap-types.mjs +1 -0
  149. package/lib/rdap-validation.mjs +94 -0
  150. package/lib/rdap.mjs +16 -0
  151. package/lib/registry-access.mjs +32 -0
  152. package/lib/registry-capabilities.mjs +117 -0
  153. package/lib/registry-capability-catalogue.mjs +1206 -0
  154. package/lib/registry-comparison.mjs +318 -0
  155. package/lib/registry-dates.mjs +190 -0
  156. package/lib/registry-insights.mjs +325 -0
  157. package/lib/response-policy.mjs +256 -0
  158. package/lib/risk-scoring.mjs +164 -0
  159. package/lib/safe-fetch.mjs +446 -0
  160. package/lib/security-txt.mjs +359 -0
  161. package/lib/service-binding-dns.mjs +608 -0
  162. package/lib/sslbl-certificates.generated.mjs +143 -0
  163. package/lib/sslbl-intelligence.mjs +241 -0
  164. package/lib/static-html-analysis.mjs +340 -0
  165. package/lib/structured-data-identity.mjs +323 -0
  166. package/lib/threat-intelligence-contract.mjs +5 -0
  167. package/lib/threat-intelligence-definition-registry.mjs +34 -0
  168. package/lib/threat-intelligence-fixtures.mjs +82 -0
  169. package/lib/threat-intelligence-manifests.mjs +512 -0
  170. package/lib/threat-intelligence-runtime.mjs +662 -0
  171. package/lib/threat-intelligence-targets.mjs +168 -0
  172. package/lib/threat-intelligence-types.mjs +3 -0
  173. package/lib/threatfox-intelligence.mjs +307 -0
  174. package/lib/tls-intelligence.mjs +814 -0
  175. package/lib/typosquat-generator.mjs +884 -0
  176. package/lib/urlhaus-intelligence.mjs +361 -0
  177. package/lib/urlscan-intelligence.mjs +349 -0
  178. package/lib/website-security-posture.mjs +325 -0
  179. package/lib/website-technology.mjs +437 -0
  180. package/lib/whois-authority.mjs +98 -0
  181. package/lib/whois-chain.mjs +261 -0
  182. package/lib/whois-contacts.mjs +163 -0
  183. package/lib/whois-contracts.mjs +3 -0
  184. package/lib/whois-normalization.mjs +27 -0
  185. package/lib/whois-transport.mjs +100 -0
  186. package/lib/whois-values.mjs +92 -0
  187. package/lib/whois.mjs +1187 -0
  188. package/package.json +55 -0
  189. package/third-party-notices.txt +5571 -0
package/cli/bulk.mjs ADDED
@@ -0,0 +1,133 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { abortable } from "../lib/abort.mjs";
3
+ import { CliUsageError } from "./arguments.mjs";
4
+ const MAX_BULK_INPUT_BYTES = 1024 * 1024;
5
+ const MAX_FAST_BULK_QUERIES = 500;
6
+ const MAX_DEEP_BULK_QUERIES = 50;
7
+ async function readTextStreamBounded(stream, limit = MAX_BULK_INPUT_BYTES) {
8
+ if (!stream || stream.isTTY)
9
+ return '';
10
+ const chunks = [];
11
+ let total = 0;
12
+ for await (const chunk of stream) {
13
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
14
+ total += buffer.length;
15
+ if (total > limit)
16
+ throw new CliUsageError(`Bulk input is limited to ${limit} bytes.`);
17
+ chunks.push(buffer);
18
+ }
19
+ return Buffer.concat(chunks).toString('utf8');
20
+ }
21
+ function parseBulkQueries(text, { deep = false } = {}) {
22
+ if (typeof text !== 'string')
23
+ throw new CliUsageError('Bulk input must be newline-delimited text.');
24
+ if (Buffer.byteLength(text, 'utf8') > MAX_BULK_INPUT_BYTES) {
25
+ throw new CliUsageError(`Bulk input is limited to ${MAX_BULK_INPUT_BYTES} bytes.`);
26
+ }
27
+ const limit = deep ? MAX_DEEP_BULK_QUERIES : MAX_FAST_BULK_QUERIES;
28
+ const seen = new Set();
29
+ const queries = [];
30
+ let duplicates = 0;
31
+ const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/);
32
+ for (const line of lines) {
33
+ const query = line.trim();
34
+ if (!query)
35
+ continue;
36
+ if (query.length > 1024 || /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(query)) {
37
+ throw new CliUsageError('Bulk input contains an overlong query or unsupported control character.');
38
+ }
39
+ const key = query.toLowerCase();
40
+ if (seen.has(key)) {
41
+ duplicates++;
42
+ continue;
43
+ }
44
+ seen.add(key);
45
+ queries.push(query);
46
+ if (queries.length > limit) {
47
+ throw new CliUsageError(`${deep ? 'Deep' : 'Fast'} bulk mode is limited to ${limit} unique queries.`);
48
+ }
49
+ }
50
+ if (!queries.length)
51
+ throw new CliUsageError('Bulk input did not contain any queries.');
52
+ return { queries, duplicates, limit };
53
+ }
54
+ function boundedLookupError(error) {
55
+ const message = error && typeof error === 'object' && 'message' in error ? error.message : undefined;
56
+ return String(message || 'Lookup failed')
57
+ .replace(/[\x00-\x1f\x7f]+/g, ' ')
58
+ .replace(/\s+/g, ' ')
59
+ .trim()
60
+ .slice(0, 300) || 'Lookup failed';
61
+ }
62
+ async function runBulkLookups(queries, options = {}) {
63
+ const classify = options.classifyQuery;
64
+ const executeLookup = options.runUnifiedLookup;
65
+ if (typeof classify !== 'function' || typeof executeLookup !== 'function') {
66
+ throw new TypeError('Bulk lookup dependencies are required.');
67
+ }
68
+ const classifyQuery = classify;
69
+ const runUnifiedLookup = executeLookup;
70
+ const concurrency = options.concurrency;
71
+ if (!Number.isSafeInteger(concurrency) || concurrency < 1 || concurrency > 8) {
72
+ throw new TypeError('Bulk concurrency is invalid.');
73
+ }
74
+ const results = new Array(queries.length);
75
+ for (const result of options.initialResults || []) {
76
+ if (!Number.isSafeInteger(result.index) || result.index < 0 || result.index >= queries.length
77
+ || queries[result.index] !== result.query || results[result.index]) {
78
+ throw new TypeError('Initial Bulk results are invalid.');
79
+ }
80
+ results[result.index] = result;
81
+ }
82
+ const lookupPromises = new Map();
83
+ let cursor = 0;
84
+ function notify(result) {
85
+ try {
86
+ options.onItemSettled?.(result);
87
+ }
88
+ catch {
89
+ // Presentation callbacks must never change collection results.
90
+ }
91
+ }
92
+ async function worker() {
93
+ while (true) {
94
+ options.signal?.throwIfAborted();
95
+ const index = cursor++;
96
+ if (index >= queries.length)
97
+ return;
98
+ if (results[index])
99
+ continue;
100
+ const query = queries[index];
101
+ if (query === undefined)
102
+ return;
103
+ try {
104
+ const classified = classifyQuery(query);
105
+ const lookupKey = `${classified.type}:${classified.value}`;
106
+ let lookupPromise = lookupPromises.get(lookupKey);
107
+ if (!lookupPromise) {
108
+ lookupPromise = Promise.resolve().then(() => runUnifiedLookup(classified, {
109
+ fast: options.deep !== true,
110
+ compact: true,
111
+ ...(options.signal ? { signal: options.signal } : {}),
112
+ ...(options.dnsResolverServers?.length ? { dnsResolverServers: options.dnsResolverServers } : {}),
113
+ }));
114
+ lookupPromises.set(lookupKey, lookupPromise);
115
+ }
116
+ const result = await abortable(() => lookupPromise, options.signal);
117
+ const item = { index, query, ok: true, classified, result };
118
+ results[index] = item;
119
+ notify(item);
120
+ }
121
+ catch (error) {
122
+ if (options.signal?.aborted)
123
+ throw options.signal.reason || new DOMException('Aborted', 'AbortError');
124
+ const item = { index, query, ok: false, error: boundedLookupError(error) };
125
+ results[index] = item;
126
+ notify(item);
127
+ }
128
+ }
129
+ }
130
+ await Promise.all(Array.from({ length: Math.min(concurrency, Math.max(1, queries.length - (options.initialResults?.length || 0))) }, () => worker()));
131
+ return results;
132
+ }
133
+ export { MAX_BULK_INPUT_BYTES, MAX_DEEP_BULK_QUERIES, MAX_FAST_BULK_QUERIES, parseBulkQueries, readTextStreamBounded, runBulkLookups, };
@@ -0,0 +1,238 @@
1
+ import { CliUsageError } from "./arguments.mjs";
2
+ import { isRdapRegistryAccessProfile, isWhoisRegistryAccessProfile, } from "./registry-access.mjs";
3
+ import { MAX_SAVED_LOOKUP_INPUT_BYTES, MAX_SAVED_LOOKUP_STRING_LENGTH, SAVED_LOOKUP_SCHEMA, SAVED_LOOKUP_SCHEMA_VERSION, parseSavedLookupDocument, readSavedLookupInputBounded, } from "./saved-lookup.mjs";
4
+ const MAX_COMPARE_INPUT_BYTES = MAX_SAVED_LOOKUP_INPUT_BYTES;
5
+ // Covers the largest scalar accepted by the normalized WHOIS parser while
6
+ // preventing a saved document from inflating the derived comparison output.
7
+ const MAX_COMPARE_STRING_LENGTH = MAX_SAVED_LOOKUP_STRING_LENGTH;
8
+ const MAX_COMPARE_LIST_ITEMS = 200;
9
+ const MAX_COMPARE_EVENTS = 100;
10
+ const MAX_COMPARE_REGISTRY_ACCESS_LIMITATION_LENGTH = MAX_COMPARE_STRING_LENGTH;
11
+ const MAX_COMPARE_REGISTRY_ACCESS_SUFFIX_LENGTH = 63;
12
+ const REGISTRAR_RDAP_STATUSES = new Set([
13
+ 'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
14
+ ]);
15
+ const LOOKUP_SCHEMA = SAVED_LOOKUP_SCHEMA;
16
+ const LOOKUP_SCHEMA_VERSION = SAVED_LOOKUP_SCHEMA_VERSION;
17
+ async function readCompareInputBounded(stream, limit = MAX_COMPARE_INPUT_BYTES) {
18
+ return readSavedLookupInputBounded(stream, { limit, label: 'Comparison input' });
19
+ }
20
+ function objectOrNull(value) {
21
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
22
+ }
23
+ function boundedSourceString(value, field) {
24
+ if (value === null || value === undefined)
25
+ return null;
26
+ if (typeof value !== 'string')
27
+ throw new CliUsageError(`${field} must be text when present.`);
28
+ if (value.length > MAX_COMPARE_STRING_LENGTH) {
29
+ throw new CliUsageError(`${field} exceeds the comparison value limit.`);
30
+ }
31
+ return value;
32
+ }
33
+ function boundedSourceList(value, field) {
34
+ if (value === null || value === undefined)
35
+ return [];
36
+ if (!Array.isArray(value))
37
+ throw new CliUsageError(`${field} must be an array when present.`);
38
+ if (value.length > MAX_COMPARE_LIST_ITEMS) {
39
+ throw new CliUsageError(`${field} exceeds the comparison item limit.`);
40
+ }
41
+ return value
42
+ .map((item, index) => boundedSourceString(item, `${field}[${index}]`))
43
+ .filter((item) => item !== null);
44
+ }
45
+ function projectRegistryAccessContext(diagnostics) {
46
+ const value = diagnostics.registryAccess;
47
+ if (value === null || value === undefined)
48
+ return null;
49
+ if (![5, 6, 7, 8].includes(Number(diagnostics.version)))
50
+ return null;
51
+ const context = objectOrNull(value);
52
+ if (!context)
53
+ throw new CliUsageError('diagnostics.registryAccess must be an object when present.');
54
+ if (context.authority !== 'context_only') {
55
+ throw new CliUsageError('diagnostics.registryAccess.authority is unsupported.');
56
+ }
57
+ if (context.coverageState !== 'access_documented') {
58
+ throw new CliUsageError('diagnostics.registryAccess.coverageState is unsupported.');
59
+ }
60
+ if (typeof context.suffix !== 'string'
61
+ || context.suffix.length > MAX_COMPARE_REGISTRY_ACCESS_SUFFIX_LENGTH
62
+ || !/^(?=.*[a-z])[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(context.suffix)) {
63
+ throw new CliUsageError('diagnostics.registryAccess.suffix is invalid.');
64
+ }
65
+ if (!isWhoisRegistryAccessProfile(context.whoisAccessProfile)) {
66
+ throw new CliUsageError('diagnostics.registryAccess.whoisAccessProfile is unsupported.');
67
+ }
68
+ if (!isRdapRegistryAccessProfile(context.rdapAccessProfile)) {
69
+ throw new CliUsageError('diagnostics.registryAccess.rdapAccessProfile is unsupported.');
70
+ }
71
+ if (typeof context.limitation !== 'string' || !context.limitation.trim()
72
+ || context.limitation.length > MAX_COMPARE_REGISTRY_ACCESS_LIMITATION_LENGTH) {
73
+ throw new CliUsageError('diagnostics.registryAccess.limitation is invalid.');
74
+ }
75
+ return {
76
+ suffix: context.suffix,
77
+ coverageState: context.coverageState,
78
+ whoisAccessProfile: context.whoisAccessProfile,
79
+ rdapAccessProfile: context.rdapAccessProfile,
80
+ limitation: context.limitation,
81
+ authority: context.authority,
82
+ };
83
+ }
84
+ function projectLifecycle(value, prefix) {
85
+ if (value !== null && value !== undefined && !objectOrNull(value)) {
86
+ throw new CliUsageError(`${prefix} must be an object when present.`);
87
+ }
88
+ const lifecycle = objectOrNull(value) || {};
89
+ return {
90
+ createdDate: boundedSourceString(lifecycle.createdDate, `${prefix}.createdDate`),
91
+ createdDateIso: boundedSourceString(lifecycle.createdDateIso, `${prefix}.createdDateIso`),
92
+ expiryDate: boundedSourceString(lifecycle.expiryDate, `${prefix}.expiryDate`),
93
+ expiryDateIso: boundedSourceString(lifecycle.expiryDateIso, `${prefix}.expiryDateIso`),
94
+ updatedDate: boundedSourceString(lifecycle.updatedDate, `${prefix}.updatedDate`),
95
+ updatedDateIso: boundedSourceString(lifecycle.updatedDateIso, `${prefix}.updatedDateIso`),
96
+ };
97
+ }
98
+ function projectRdapSource(value, prefix = 'rdap.parsed') {
99
+ const source = objectOrNull(value) || {};
100
+ const registrar = objectOrNull(source.registrar);
101
+ const events = source.events === null || source.events === undefined ? [] : source.events;
102
+ if (!Array.isArray(events))
103
+ throw new CliUsageError(`${prefix}.events must be an array when present.`);
104
+ if (events.length > MAX_COMPARE_EVENTS) {
105
+ throw new CliUsageError(`${prefix}.events exceeds the comparison item limit.`);
106
+ }
107
+ return {
108
+ domain: boundedSourceString(source.domain, `${prefix}.domain`),
109
+ handle: boundedSourceString(source.handle, `${prefix}.handle`),
110
+ registrar: registrar ? {
111
+ name: boundedSourceString(registrar.name, `${prefix}.registrar.name`),
112
+ org: boundedSourceString(registrar.org, `${prefix}.registrar.org`),
113
+ handle: boundedSourceString(registrar.handle, `${prefix}.registrar.handle`),
114
+ } : boundedSourceString(source.registrar, `${prefix}.registrar`),
115
+ registrarIanaId: boundedSourceString(source.registrarIanaId, `${prefix}.registrarIanaId`),
116
+ lifecycle: projectLifecycle(source.lifecycle, `${prefix}.lifecycle`),
117
+ events: events.map((event, index) => {
118
+ const item = objectOrNull(event);
119
+ if (!item)
120
+ throw new CliUsageError(`${prefix}.events[${index}] must be an object.`);
121
+ return {
122
+ action: boundedSourceString(item.action, `${prefix}.events[${index}].action`),
123
+ date: boundedSourceString(item.date, `${prefix}.events[${index}].date`),
124
+ };
125
+ }),
126
+ dnssec: boundedSourceString(source.dnssec, `${prefix}.dnssec`),
127
+ statuses: boundedSourceList(source.statuses, `${prefix}.statuses`),
128
+ nameservers: boundedSourceList(source.nameservers, `${prefix}.nameservers`),
129
+ };
130
+ }
131
+ function projectCliRegistrarPublicationInput(document) {
132
+ const rdap = objectOrNull(document.rdap);
133
+ const registrar = objectOrNull(rdap?.registrarRdap);
134
+ const rdapDiagnostics = objectOrNull(document.diagnostics?.rdap);
135
+ const registrarDiagnostics = objectOrNull(rdapDiagnostics?.registrar);
136
+ if (!registrar && !registrarDiagnostics) {
137
+ return { represented: false, status: null, parsed: {} };
138
+ }
139
+ const sourceStatus = registrar?.status;
140
+ const diagnosticStatus = registrarDiagnostics?.status;
141
+ if (sourceStatus !== undefined && diagnosticStatus !== undefined && sourceStatus !== diagnosticStatus) {
142
+ throw new CliUsageError('Registrar RDAP source and diagnostic statuses do not match.');
143
+ }
144
+ const status = sourceStatus ?? diagnosticStatus;
145
+ if (typeof status !== 'string' || !REGISTRAR_RDAP_STATUSES.has(status)) {
146
+ throw new CliUsageError('rdap.registrarRdap.status is unsupported.');
147
+ }
148
+ if (registrar?.parsed !== null && registrar?.parsed !== undefined && !objectOrNull(registrar.parsed)) {
149
+ throw new CliUsageError('rdap.registrarRdap.parsed must be an object when present.');
150
+ }
151
+ const parsed = objectOrNull(registrar?.parsed);
152
+ if (status === 'success' && !parsed) {
153
+ throw new CliUsageError('Successful registrar RDAP input is missing normalized parsed data.');
154
+ }
155
+ const projected = projectRdapSource(parsed, 'rdap.registrarRdap.parsed');
156
+ delete projected.handle;
157
+ const projectedRegistrar = objectOrNull(projected.registrar);
158
+ if (projectedRegistrar)
159
+ delete projectedRegistrar.handle;
160
+ return {
161
+ represented: true,
162
+ status,
163
+ parsed: projected,
164
+ };
165
+ }
166
+ function projectWhoisSource(value) {
167
+ const source = objectOrNull(value) || {};
168
+ return {
169
+ domainName: boundedSourceString(source.domainName, 'whois.parsed.domainName'),
170
+ registryDomainId: boundedSourceString(source.registryDomainId, 'whois.parsed.registryDomainId'),
171
+ registrar: boundedSourceString(source.registrar, 'whois.parsed.registrar'),
172
+ registrarIanaId: boundedSourceString(source.registrarIanaId, 'whois.parsed.registrarIanaId'),
173
+ createdDate: boundedSourceString(source.createdDate, 'whois.parsed.createdDate'),
174
+ createdDateIso: boundedSourceString(source.createdDateIso, 'whois.parsed.createdDateIso'),
175
+ expiryDate: boundedSourceString(source.expiryDate, 'whois.parsed.expiryDate'),
176
+ expiryDateIso: boundedSourceString(source.expiryDateIso, 'whois.parsed.expiryDateIso'),
177
+ updatedDate: boundedSourceString(source.updatedDate, 'whois.parsed.updatedDate'),
178
+ updatedDateIso: boundedSourceString(source.updatedDateIso, 'whois.parsed.updatedDateIso'),
179
+ lifecycle: projectLifecycle(source.lifecycle, 'whois.parsed.lifecycle'),
180
+ dnssec: boundedSourceString(source.dnssec, 'whois.parsed.dnssec'),
181
+ statuses: boundedSourceList(source.statuses, 'whois.parsed.statuses'),
182
+ nameservers: boundedSourceList(source.nameservers, 'whois.parsed.nameservers'),
183
+ };
184
+ }
185
+ function parseCliLookupDocument(text) {
186
+ const document = parseSavedLookupDocument(text, { label: 'Comparison input' });
187
+ return projectCliLookupComparisonInput(document);
188
+ }
189
+ function projectCliLookupComparisonInput(document) {
190
+ const diagnostics = document.diagnostics;
191
+ const rdapStatus = document.diagnostics.rdap.status;
192
+ const whoisStatus = document.diagnostics.whois.status;
193
+ const rdapParsed = objectOrNull(document.rdap?.parsed);
194
+ const whoisParsed = objectOrNull(document.whois?.parsed);
195
+ const registrarPublication = projectCliRegistrarPublicationInput(document);
196
+ return {
197
+ query: document.query,
198
+ registrableDomain: document.registrableDomain,
199
+ lookupGeneratedAt: document.generatedAt,
200
+ lookupMode: document.mode,
201
+ rdapStatus,
202
+ whoisStatus,
203
+ rdapParsed: projectRdapSource(rdapParsed),
204
+ whoisParsed: projectWhoisSource(whoisParsed),
205
+ registrarRdapRepresented: registrarPublication.represented,
206
+ registrarRdapStatus: registrarPublication.status,
207
+ registrarRdapParsed: registrarPublication.parsed,
208
+ registryAccess: projectRegistryAccessContext(diagnostics),
209
+ };
210
+ }
211
+ function compareLookupDocument(input, compareRegistrySources, compareRdapPublications) {
212
+ if (typeof compareRegistrySources !== 'function') {
213
+ throw new TypeError('Registry comparison dependency is required.');
214
+ }
215
+ const comparison = compareRegistrySources(input.rdapParsed, input.whoisParsed, {
216
+ rdapStatus: input.rdapStatus,
217
+ whoisStatus: input.whoisStatus,
218
+ });
219
+ if (input.registrarRdapRepresented && typeof compareRdapPublications !== 'function') {
220
+ throw new TypeError('Registrar RDAP comparison dependency is required.');
221
+ }
222
+ const registrarPublicationComparison = input.registrarRdapRepresented
223
+ ? compareRdapPublications(input.rdapParsed, input.registrarRdapParsed, {
224
+ registryStatus: input.rdapStatus,
225
+ registrarStatus: input.registrarRdapStatus,
226
+ })
227
+ : null;
228
+ return {
229
+ query: input.query,
230
+ registrableDomain: input.registrableDomain,
231
+ lookupGeneratedAt: input.lookupGeneratedAt,
232
+ lookupMode: input.lookupMode,
233
+ ...comparison,
234
+ registrarPublicationComparison,
235
+ ...(input.registryAccess ? { registryAccess: input.registryAccess } : {}),
236
+ };
237
+ }
238
+ export { LOOKUP_SCHEMA, LOOKUP_SCHEMA_VERSION, MAX_COMPARE_EVENTS, MAX_COMPARE_INPUT_BYTES, MAX_COMPARE_LIST_ITEMS, MAX_COMPARE_REGISTRY_ACCESS_LIMITATION_LENGTH, MAX_COMPARE_REGISTRY_ACCESS_SUFFIX_LENGTH, MAX_COMPARE_STRING_LENGTH, compareLookupDocument, parseCliLookupDocument, projectCliLookupComparisonInput, readCompareInputBounded, };
@@ -0,0 +1,175 @@
1
+ import { CLI_COMMANDS } from "./arguments.mjs";
2
+ const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force']);
3
+ const OPTIONS_BY_COMMAND = Object.freeze({
4
+ lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
5
+ bulk: ['--json', '--jsonl', '--csv', '--domains', '--registered-only', '--inconclusive-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
6
+ 'ct-search': ['--json', '--quiet', '--no-color'],
7
+ discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
8
+ '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', '--json', '--jsonl', '--csv', '--domains', '--quiet', '--no-color'],
9
+ posture: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--quiet', '--no-color'],
10
+ http: ['--json', '--quiet', '--no-color'],
11
+ tls: ['--json', '--quiet', '--no-color'],
12
+ 'registry-support': ['--json', '--quiet', '--no-color'],
13
+ 'risk-calibrate': ['--json', '--quiet', '--no-color'],
14
+ 'verify-artifact': ['--passphrase-file', '--json', '--quiet', '--no-color'],
15
+ 'inspect-archive': ['--passphrase-file', '--search', '--require-match', '--reveal', '--json', '--quiet', '--no-color'],
16
+ 'sign-artifact': ['--private-key-file'],
17
+ 'verify-signature': ['--public-key-file', '--json', '--quiet', '--no-color'],
18
+ 'source-report': ['--json', '--quiet', '--no-color'],
19
+ compare: ['--json', '--quiet', '--no-color'],
20
+ 'page-compare': ['--json', '--quiet', '--no-color'],
21
+ 'mail-review': ['--json', '--quiet', '--no-color'],
22
+ diff: ['--json', '--quiet', '--no-color'],
23
+ export: ['--markdown', '--html', '--compact'],
24
+ completion: [],
25
+ doctor: ['--network', '--json', '--quiet', '--no-color'],
26
+ manual: [],
27
+ });
28
+ const COMMAND_DESCRIPTIONS = Object.freeze({
29
+ lookup: 'Collect one domain, IP, or ASN',
30
+ bulk: 'Run bounded multi-target collection',
31
+ 'ct-search': 'Search certificate observations',
32
+ discover: 'Generate lookalike candidates offline',
33
+ 'discover-scan': 'Collect a supervised candidate review queue',
34
+ posture: 'Review DNS and mail posture',
35
+ http: 'Inspect one homepage request',
36
+ tls: 'Inspect one TLS connection',
37
+ 'registry-support': 'Explain local registry coverage',
38
+ 'risk-calibrate': 'Replay reviewed Risk labels offline',
39
+ 'verify-artifact': 'Validate an evidence artifact offline',
40
+ 'inspect-archive': 'Inspect an archive locally',
41
+ 'sign-artifact': 'Sign a reviewed artifact locally',
42
+ 'verify-signature': 'Verify a signed evidence package',
43
+ 'source-report': 'Build a target-free source report',
44
+ compare: 'Compare registry publications in one lookup',
45
+ 'page-compare': 'Compare saved static page evidence',
46
+ 'mail-review': 'Review saved passive mail evidence',
47
+ diff: 'Compare two saved domain lookups',
48
+ export: 'Convert a lookup to an evidence report',
49
+ completion: 'Print shell completion',
50
+ doctor: 'Check the local CLI runtime',
51
+ manual: 'Print the generated manual page',
52
+ });
53
+ const VALUE_OPTIONS = Object.freeze({
54
+ '--preset': ['common', 'impersonation', 'all'],
55
+ '--keyboard': ['qwerty', 'azerty', 'qwertz', 'all'],
56
+ '--mail-profile': ['standard', 'defensive-no-mail', 'parked'],
57
+ '--concurrency': ['1', '2', '3', '4', '5', '6', '7', '8'],
58
+ });
59
+ const FILE_OPTIONS = Object.freeze([
60
+ '--checkpoint',
61
+ '--allowlist',
62
+ '--dictionary',
63
+ '--output',
64
+ '--passphrase-file',
65
+ '--private-key-file',
66
+ '--public-key-file',
67
+ '--snapshot',
68
+ '--observation-snapshot',
69
+ ]);
70
+ const TEXT_OPTIONS = Object.freeze([
71
+ '--families',
72
+ '--resolver',
73
+ '--retired-selectors',
74
+ '--search',
75
+ '--selectors',
76
+ '--tlds',
77
+ ]);
78
+ function commandOptions(command) {
79
+ return [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])].join(' ');
80
+ }
81
+ function bashCompletion() {
82
+ const cases = CLI_COMMANDS.map((command) => ` ${command}) options="${commandOptions(command)}" ;;`).join('\n');
83
+ const valueCases = Object.entries(VALUE_OPTIONS).map(([option, values]) => ` ${option}) COMPREPLY=( $(compgen -W "${values.join(' ')}" -- "\${current}") ); return ;;`).join('\n');
84
+ return `# WHOISleuth bash completion
85
+ _whoisleuth_completion() {
86
+ local current previous command options
87
+ current="\${COMP_WORDS[COMP_CWORD]}"
88
+ previous="\${COMP_WORDS[COMP_CWORD-1]}"
89
+ command="\${COMP_WORDS[1]}"
90
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
91
+ COMPREPLY=( $(compgen -W "${CLI_COMMANDS.join(' ')} --help --version" -- "\${current}") )
92
+ return
93
+ fi
94
+ if [[ "\${command}" == "completion" && \${COMP_CWORD} -eq 2 ]]; then
95
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${current}") )
96
+ return
97
+ fi
98
+ case "\${previous}" in
99
+ ${valueCases}
100
+ ${FILE_OPTIONS.join('|')}) COMPREPLY=( $(compgen -f -- "\${current}") ); return ;;
101
+ ${TEXT_OPTIONS.join('|')}) COMPREPLY=(); return ;;
102
+ esac
103
+ case "\${command}" in
104
+ ${cases}
105
+ *) options="--help" ;;
106
+ esac
107
+ COMPREPLY=( $(compgen -W "\${options}" -- "\${current}") )
108
+ }
109
+ complete -F _whoisleuth_completion whoisleuth
110
+ `;
111
+ }
112
+ function zshCompletion() {
113
+ const commandEntries = CLI_COMMANDS.map((command) => `'${command}:${COMMAND_DESCRIPTIONS[command] || command}'`).join(' ');
114
+ const cases = CLI_COMMANDS.map((command) => ` ${command}) options=(${commandOptions(command)}) ;;`).join('\n');
115
+ const valueCases = Object.entries(VALUE_OPTIONS).map(([option, values]) => ` ${option}) compadd -- ${values.join(' ')}; return ;;`).join('\n');
116
+ return `#compdef whoisleuth
117
+ _whoisleuth() {
118
+ local command previous
119
+ local -a options commands
120
+ commands=(${commandEntries})
121
+ if (( CURRENT == 2 )); then
122
+ _describe 'command' commands
123
+ return
124
+ fi
125
+ command="\${words[2]}"
126
+ previous="\${words[CURRENT-1]}"
127
+ if [[ "\${command}" == "completion" && CURRENT -eq 3 ]]; then
128
+ compadd -- bash zsh fish
129
+ return
130
+ fi
131
+ case "\${previous}" in
132
+ ${valueCases}
133
+ ${FILE_OPTIONS.join('|')}) _files; return ;;
134
+ ${TEXT_OPTIONS.join('|')}) _message 'value'; return ;;
135
+ esac
136
+ case "\${command}" in
137
+ ${cases}
138
+ *) options=(--help) ;;
139
+ esac
140
+ compadd -- \${options[@]}
141
+ }
142
+ if [[ "\${funcstack[1]}" == "_whoisleuth" ]]; then
143
+ _whoisleuth "\$@"
144
+ else
145
+ compdef _whoisleuth whoisleuth
146
+ fi
147
+ `;
148
+ }
149
+ function fishCompletion() {
150
+ const commandLines = CLI_COMMANDS.map((command) => (`complete -c whoisleuth -n '__fish_use_subcommand' -a '${command}' -d '${COMMAND_DESCRIPTIONS[command] || command}'`));
151
+ const optionLines = Object.entries(OPTIONS_BY_COMMAND).flatMap(([command, options]) => ([...COMMON_OPTIONS, ...options].map((option) => {
152
+ const name = option.replace(/^--/u, '');
153
+ const fileValue = FILE_OPTIONS.includes(option);
154
+ const requiresValue = fileValue || [...Object.keys(VALUE_OPTIONS), ...TEXT_OPTIONS].includes(option);
155
+ return `complete -c whoisleuth -n '__fish_seen_subcommand_from ${command}' -l ${name}${requiresValue ? ' -r' : ''}${fileValue ? ' -F' : ''}`;
156
+ })));
157
+ const valueLines = Object.entries(VALUE_OPTIONS).flatMap(([option, values]) => (values.map((value) => `complete -c whoisleuth -n '__fish_prev_arg_in ${option}' -a '${value}'`)));
158
+ return `# WHOISleuth fish completion
159
+ complete -c whoisleuth -f
160
+ complete -c whoisleuth -n '__fish_use_subcommand' -l help
161
+ complete -c whoisleuth -n '__fish_use_subcommand' -l version
162
+ ${commandLines.join('\n')}
163
+ complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
164
+ ${optionLines.join('\n')}
165
+ ${valueLines.join('\n')}
166
+ `;
167
+ }
168
+ function buildShellCompletion(shell) {
169
+ if (shell === 'bash')
170
+ return bashCompletion();
171
+ if (shell === 'zsh')
172
+ return zshCompletion();
173
+ return fishCompletion();
174
+ }
175
+ export { buildShellCompletion, };
package/cli/csv.mjs ADDED
@@ -0,0 +1 @@
1
+ export { serializeCsvCell as cliCsvCell } from "../lib/csv.mjs";
@@ -0,0 +1,50 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { CliUsageError } from "./arguments.mjs";
3
+ const DEFAULT_DISCOVERY_TLDS = Object.freeze(['com', 'net', 'org']);
4
+ const MAX_DISCOVERY_TLD_TEXT_LENGTH = 1024;
5
+ const MAX_DISCOVERY_TLD_TOKENS_INSPECTED = 80;
6
+ const MAX_DISCOVERY_DICTIONARY_BYTES = 4_096;
7
+ async function readDiscoveryDictionaryBounded(stream, limit = MAX_DISCOVERY_DICTIONARY_BYTES) {
8
+ if (!stream || stream.isTTY)
9
+ return '';
10
+ const chunks = [];
11
+ let total = 0;
12
+ for await (const chunk of stream) {
13
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
14
+ total += buffer.length;
15
+ if (total > limit)
16
+ throw new CliUsageError(`Discovery dictionary input is limited to ${limit} bytes.`);
17
+ chunks.push(buffer);
18
+ }
19
+ return Buffer.concat(chunks).toString('utf8');
20
+ }
21
+ function normalizeDiscoveryTlds(raw, maximum) {
22
+ if (!Number.isSafeInteger(maximum) || maximum < 1) {
23
+ throw new TypeError('A valid discovery TLD limit is required.');
24
+ }
25
+ if (typeof raw !== 'string' || raw.length === 0 || raw.length > MAX_DISCOVERY_TLD_TEXT_LENGTH) {
26
+ throw new CliUsageError(`--tlds must contain at most ${MAX_DISCOVERY_TLD_TEXT_LENGTH} characters.`);
27
+ }
28
+ const tokens = raw.split(/[;,\s]+/).filter(Boolean);
29
+ if (!tokens.length)
30
+ throw new CliUsageError('--tlds did not contain any values.');
31
+ if (tokens.length > MAX_DISCOVERY_TLD_TOKENS_INSPECTED) {
32
+ throw new CliUsageError(`--tlds may inspect at most ${MAX_DISCOVERY_TLD_TOKENS_INSPECTED} values.`);
33
+ }
34
+ const values = [];
35
+ const seen = new Set();
36
+ for (const token of tokens) {
37
+ const value = token.toLowerCase().replace(/^\./, '');
38
+ if (!/^[a-z]{2,63}$/.test(value))
39
+ throw new CliUsageError(`Invalid TLD "${token}".`);
40
+ if (seen.has(value))
41
+ continue;
42
+ seen.add(value);
43
+ values.push(value);
44
+ if (values.length > maximum) {
45
+ throw new CliUsageError(`At most ${maximum} unique TLDs are supported.`);
46
+ }
47
+ }
48
+ return values;
49
+ }
50
+ export { DEFAULT_DISCOVERY_TLDS, MAX_DISCOVERY_DICTIONARY_BYTES, MAX_DISCOVERY_TLD_TEXT_LENGTH, MAX_DISCOVERY_TLD_TOKENS_INSPECTED, normalizeDiscoveryTlds, readDiscoveryDictionaryBounded, };
@@ -0,0 +1,36 @@
1
+ import { generateDiscoveryCandidates } from "./discovery-workflow.mjs";
2
+ import EXIT_CODES from "./exit-codes.mjs";
3
+ import { buildCliDiscoverDocument, formatDiscoverDomainList, formatDiscoverJsonLines, formatJsonDocument, } from "./formatters/json.mjs";
4
+ import { updateDiscoverySnapshot } from "./discovery-snapshot.mjs";
5
+ import { formatTerminalDiscover } from "./formatters/terminal.mjs";
6
+ async function runDiscoveryCommand(args, dependencies, context) {
7
+ const { dictionaryDigestSha256, generator, metadata, result } = await generateDiscoveryCandidates(args, dependencies, context);
8
+ const { seed } = metadata;
9
+ const snapshot = args.snapshotSource
10
+ ? await updateDiscoverySnapshot(args.snapshotSource, result.candidates.map((candidate) => candidate.domain), {
11
+ seed,
12
+ preset: args.preset,
13
+ keyboardLayout: args.keyboardLayout,
14
+ tlds: metadata.tlds,
15
+ mutationFamilies: metadata.mutationFamilies,
16
+ dictionaryDigestSha256,
17
+ }, metadata.generatedAt)
18
+ : null;
19
+ const baseDocument = buildCliDiscoverDocument(seed, result, metadata);
20
+ const document = snapshot ? { ...baseDocument, snapshot } : baseDocument;
21
+ if (!args.quiet) {
22
+ if (args.output === 'json')
23
+ context.writeStdout(formatJsonDocument(document));
24
+ else if (args.output === 'jsonl')
25
+ context.writeStdout(formatDiscoverJsonLines(result.candidates, metadata));
26
+ else if (args.output === 'domains')
27
+ context.writeStdout(formatDiscoverDomainList(result.candidates));
28
+ else
29
+ context.writeStdout(context.terminal(formatTerminalDiscover(document, generator.MUTATION_LABELS), args.color));
30
+ }
31
+ if (snapshot && ['domains', 'jsonl'].includes(args.output)) {
32
+ context.writeStderr(`Discovery snapshot: ${snapshot.baselineCreated ? 'baseline created' : `${snapshot.added.length} added, ${snapshot.removed.length} removed`} (${snapshot.currentCandidateCount} current).\n`);
33
+ }
34
+ return EXIT_CODES.SUCCESS;
35
+ }
36
+ export { runDiscoveryCommand };