@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
@@ -0,0 +1,325 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { Buffer } from 'node:buffer';
3
+ import { runBulkLookups } from "./bulk.mjs";
4
+ import { REGISTERED_STATES, availabilityState, bulkDnsSummary } from "./bulk-output.mjs";
5
+ import { cliCsvCell } from "./csv.mjs";
6
+ import { CliUsageError } from "./errors.mjs";
7
+ export const CLI_DISCOVERY_SCAN_SCHEMA = 'whoisleuth.cli.discovery-scan';
8
+ export const CLI_DISCOVERY_SCAN_ITEM_SCHEMA = 'whoisleuth.cli.discovery-scan.item';
9
+ export const CLI_DISCOVERY_SCAN_VERSION = 1;
10
+ export const MAX_DISCOVERY_SCAN_ALLOWLIST_BYTES = 64 * 1024;
11
+ export const MAX_DISCOVERY_SCAN_ALLOWLIST_ITEMS = 500;
12
+ const MAX_DISCOVERY_SCAN_RELATIONSHIPS = 200;
13
+ function record(value) {
14
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
15
+ }
16
+ function boundedText(value, maximum = 500) {
17
+ return typeof value === 'string'
18
+ ? value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
19
+ : '';
20
+ }
21
+ async function readDiscoveryScanListBounded(stream, limit = MAX_DISCOVERY_SCAN_ALLOWLIST_BYTES) {
22
+ if (!stream || stream.isTTY)
23
+ return '';
24
+ const chunks = [];
25
+ let total = 0;
26
+ for await (const chunk of stream) {
27
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
28
+ total += buffer.length;
29
+ if (total > limit)
30
+ throw new CliUsageError(`Discovery scan allowlists are limited to ${limit} bytes.`);
31
+ chunks.push(buffer);
32
+ }
33
+ return Buffer.concat(chunks).toString('utf8');
34
+ }
35
+ function parseDiscoveryScanAllowlist(text, classifyQuery) {
36
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > MAX_DISCOVERY_SCAN_ALLOWLIST_BYTES) {
37
+ throw new CliUsageError(`Discovery scan allowlists are limited to ${MAX_DISCOVERY_SCAN_ALLOWLIST_BYTES} bytes.`);
38
+ }
39
+ const values = new Set();
40
+ for (const line of text.replace(/^\uFEFF/u, '').split(/\r?\n/u)) {
41
+ const value = line.trim();
42
+ if (!value || value.startsWith('#'))
43
+ continue;
44
+ if (value.length > 253 || /[\u0000-\u001f\u007f]/u.test(value)) {
45
+ throw new CliUsageError('Discovery scan allowlists contain an invalid or overlong value.');
46
+ }
47
+ let classified;
48
+ try {
49
+ classified = classifyQuery(value);
50
+ }
51
+ catch {
52
+ throw new CliUsageError(`Discovery scan allowlist value "${boundedText(value, 80)}" is not a domain.`);
53
+ }
54
+ if (classified.type !== 'domain')
55
+ throw new CliUsageError('Discovery scan allowlists may contain domains only.');
56
+ values.add(classified.registrableDomain || classified.value);
57
+ if (values.size > MAX_DISCOVERY_SCAN_ALLOWLIST_ITEMS) {
58
+ throw new CliUsageError(`Discovery scan allowlists are limited to ${MAX_DISCOVERY_SCAN_ALLOWLIST_ITEMS} unique domains.`);
59
+ }
60
+ }
61
+ return values;
62
+ }
63
+ function normalizedCandidate(candidate) {
64
+ const domain = boundedText(candidate.domain, 253).toLowerCase();
65
+ const source = boundedText(candidate.source, 253).toLowerCase();
66
+ const tld = boundedText(candidate.tld, 63).toLowerCase();
67
+ const mutationTypes = Array.isArray(candidate.mutationTypes)
68
+ ? [...new Set(candidate.mutationTypes.flatMap((value) => {
69
+ const normalized = boundedText(value, 80);
70
+ return normalized ? [normalized] : [];
71
+ }))].slice(0, 20)
72
+ : [];
73
+ return { domain, source, tld, mutationTypes };
74
+ }
75
+ function reviewFor(item, domain, allowlist) {
76
+ const state = availabilityState(item) || 'unknown';
77
+ const dns = bulkDnsSummary(item);
78
+ const reasons = [];
79
+ let lane;
80
+ if (allowlist.has(domain)) {
81
+ lane = 'suppressed';
82
+ reasons.push('The domain matches the analyst-supplied allowlist. Evidence is retained but deprioritized.');
83
+ }
84
+ else if (!item.ok || state === 'unknown') {
85
+ lane = 'retry';
86
+ reasons.push('Collection failed or authoritative registration evidence remained inconclusive.');
87
+ }
88
+ else if (state === 'available') {
89
+ lane = 'acquisition_review';
90
+ reasons.push('Authoritative lookup evidence reported the candidate as available at collection time.');
91
+ }
92
+ else {
93
+ lane = 'investigate';
94
+ reasons.push(REGISTERED_STATES.has(state)
95
+ ? 'Registration evidence indicates that the candidate is registered or in a registered lifecycle state.'
96
+ : 'The observed state warrants analyst review before any conclusion.');
97
+ }
98
+ if (dns.hasNullMx === true)
99
+ reasons.push('A null MX record was observed, indicating that the domain does not accept mail under the published policy.');
100
+ else if (dns.mx.length)
101
+ reasons.push('One or more MX records were observed; mail capability requires separate passive review.');
102
+ return { lane, reasons, state };
103
+ }
104
+ function buildScanItem(candidate, item, allowlist, relationshipIds = []) {
105
+ const normalized = normalizedCandidate(candidate);
106
+ const review = reviewFor(item, normalized.domain, allowlist);
107
+ const result = item.ok ? record(item.result) : {};
108
+ return {
109
+ schema: CLI_DISCOVERY_SCAN_ITEM_SCHEMA,
110
+ version: CLI_DISCOVERY_SCAN_VERSION,
111
+ domain: normalized.domain,
112
+ source: normalized.source,
113
+ tld: normalized.tld,
114
+ mutationTypes: normalized.mutationTypes,
115
+ ok: item.ok,
116
+ availabilityState: review.state,
117
+ confidence: item.ok ? boundedText(record(result.availability).confidence, 40) || 'unknown' : 'unknown',
118
+ dnsSummary: bulkDnsSummary(item),
119
+ review: { lane: review.lane, reasons: review.reasons },
120
+ relationshipIds: [...relationshipIds],
121
+ ...(item.ok ? {
122
+ availability: result.availability,
123
+ diagnostics: result.diagnostics,
124
+ } : { error: item.error }),
125
+ };
126
+ }
127
+ function relationshipId(type, value) {
128
+ return `rel-${createHash('sha256').update(`${type}\n${value}`).digest('hex').slice(0, 16)}`;
129
+ }
130
+ function buildRelationships(candidates, items) {
131
+ const groups = new Map();
132
+ for (let index = 0; index < items.length; index++) {
133
+ const item = items[index];
134
+ const candidate = candidates[index];
135
+ if (!item || !candidate || !item.ok)
136
+ continue;
137
+ const domain = normalizedCandidate(candidate).domain;
138
+ const dns = bulkDnsSummary(item);
139
+ const observations = [
140
+ ...dns.a.map((value) => ['address', value]),
141
+ ...dns.aaaa.map((value) => ['address', value]),
142
+ ...dns.ns.map((value) => ['nameserver', value.toLowerCase().replace(/\.$/u, '')]),
143
+ ...dns.mx.map((value) => ['mail_server', value.replace(/^\d+\s+/u, '').toLowerCase().replace(/\.$/u, '')]),
144
+ ];
145
+ for (const [type, value] of observations) {
146
+ if (!value)
147
+ continue;
148
+ const key = `${type}\n${value}`;
149
+ const group = groups.get(key) || { type, value, domains: new Set() };
150
+ group.domains.add(domain);
151
+ groups.set(key, group);
152
+ }
153
+ }
154
+ return [...groups.values()]
155
+ .filter((group) => group.domains.size >= 2)
156
+ .sort((left, right) => right.domains.size - left.domains.size
157
+ || left.type.localeCompare(right.type) || left.value.localeCompare(right.value))
158
+ .slice(0, MAX_DISCOVERY_SCAN_RELATIONSHIPS)
159
+ .map((group) => ({
160
+ id: relationshipId(group.type, group.value),
161
+ type: group.type,
162
+ value: group.value,
163
+ domains: [...group.domains].sort(),
164
+ domainCount: group.domains.size,
165
+ }));
166
+ }
167
+ function selectedByFilter(item, filter) {
168
+ if (filter === 'all')
169
+ return true;
170
+ if (filter === 'registered')
171
+ return REGISTERED_STATES.has(item.availabilityState);
172
+ if (filter === 'inconclusive')
173
+ return !item.ok || item.availabilityState === 'unknown';
174
+ if (filter === 'acquisition')
175
+ return item.review.lane === 'acquisition_review';
176
+ return item.review.lane === 'suppressed';
177
+ }
178
+ function coverageSummary(results) {
179
+ const states = {};
180
+ const lanes = {};
181
+ const tlds = {};
182
+ for (const item of results) {
183
+ states[item.availabilityState] = (states[item.availabilityState] ?? 0) + 1;
184
+ lanes[item.review.lane] = (lanes[item.review.lane] ?? 0) + 1;
185
+ const tld = tlds[item.tld] || { total: 0, available: 0, registered: 0, inconclusive: 0 };
186
+ tld.total += 1;
187
+ if (item.availabilityState === 'available')
188
+ tld.available += 1;
189
+ else if (REGISTERED_STATES.has(item.availabilityState))
190
+ tld.registered += 1;
191
+ else
192
+ tld.inconclusive += 1;
193
+ tlds[item.tld] = tld;
194
+ }
195
+ return { states, reviewLanes: lanes, tlds };
196
+ }
197
+ function buildDiscoveryScanDocument(candidates, items, metadata, allowlist, snapshot = null) {
198
+ if (candidates.length !== items.length)
199
+ throw new TypeError('Discovery candidates and lookup results must align.');
200
+ const relationships = buildRelationships(candidates, items);
201
+ const relationshipIdsByDomain = new Map();
202
+ for (const relationship of relationships) {
203
+ for (const domain of relationship.domains) {
204
+ const ids = relationshipIdsByDomain.get(domain) || [];
205
+ ids.push(relationship.id);
206
+ relationshipIdsByDomain.set(domain, ids);
207
+ }
208
+ }
209
+ const allResults = candidates.map((candidate, index) => {
210
+ const domain = normalizedCandidate(candidate).domain;
211
+ const item = items[index];
212
+ if (!item)
213
+ throw new TypeError('Discovery scan result alignment failed.');
214
+ return buildScanItem(candidate, item, allowlist, relationshipIdsByDomain.get(domain) || []);
215
+ });
216
+ const results = allResults.filter((item) => selectedByFilter(item, metadata.filter));
217
+ return {
218
+ schema: CLI_DISCOVERY_SCAN_SCHEMA,
219
+ version: CLI_DISCOVERY_SCAN_VERSION,
220
+ generatedAt: metadata.generatedAt,
221
+ seed: metadata.seed,
222
+ mode: metadata.deep ? 'deep' : 'fast',
223
+ generation: {
224
+ preset: metadata.preset,
225
+ keyboardLayout: metadata.keyboardLayout,
226
+ tlds: [...metadata.tlds],
227
+ mutationFamilies: [...metadata.mutationFamilies],
228
+ generatedCandidateCount: metadata.generatedCandidateCount,
229
+ selectedCandidateCount: metadata.selectedCandidateCount,
230
+ scanLimit: metadata.scanLimit,
231
+ },
232
+ collection: {
233
+ chunkSize: metadata.chunkSize,
234
+ concurrency: metadata.concurrency,
235
+ resolver: metadata.resolverServers.length ? 'analyst_selected' : 'system_default',
236
+ resolverServers: [...metadata.resolverServers],
237
+ },
238
+ filter: metadata.filter,
239
+ summary: {
240
+ collected: allResults.length,
241
+ matched: results.length,
242
+ succeeded: allResults.filter((item) => item.ok).length,
243
+ failed: allResults.filter((item) => !item.ok).length,
244
+ suppressed: allResults.filter((item) => item.review.lane === 'suppressed').length,
245
+ },
246
+ coverage: coverageSummary(allResults),
247
+ relationships,
248
+ results,
249
+ ...(snapshot ? { snapshot } : {}),
250
+ nextActions: [
251
+ 'Deep-collect only the candidates selected for review before comparing page, mail, certificate, or technology evidence.',
252
+ 'Confirm an available result again at the authoritative registration source before considering defensive acquisition.',
253
+ 'Treat shared infrastructure as a pivot lead only; it does not establish ownership, control, intent, safety, or maliciousness.',
254
+ 'Keep analyst assertions and external enforcement decisions separate from these observed and derived results.',
255
+ ],
256
+ limitations: [
257
+ 'Candidate mutations are leads, not findings. Selection order is deterministic and bounded by the requested scan limit.',
258
+ 'Failed, partial, rate-limited, and unavailable sources remain inconclusive and are not interpreted as absence or safety.',
259
+ 'The allowlist changes review priority only. It does not remove collected evidence or assert that a domain is benign.',
260
+ 'Relationship groups use exact bounded DNS observations from this run and can include common shared infrastructure.',
261
+ ],
262
+ };
263
+ }
264
+ async function runDiscoveryScanChunks(queries, options) {
265
+ if (!Number.isSafeInteger(options.chunkSize) || options.chunkSize < 1 || options.chunkSize > 100) {
266
+ throw new TypeError('Discovery scan chunk size is invalid.');
267
+ }
268
+ const output = new Array(queries.length);
269
+ for (const initial of options.initialResults || [])
270
+ output[initial.index] = initial;
271
+ for (let start = 0; start < queries.length; start += options.chunkSize) {
272
+ options.signal?.throwIfAborted();
273
+ const chunkQueries = queries.slice(start, start + options.chunkSize);
274
+ const initialResults = (options.initialResults || [])
275
+ .filter((item) => item.index >= start && item.index < start + chunkQueries.length)
276
+ .map((item) => ({ ...item, index: item.index - start }));
277
+ const chunk = await runBulkLookups([...chunkQueries], {
278
+ ...options,
279
+ initialResults,
280
+ onItemSettled: (item) => options.onItemSettled?.({ ...item, index: item.index + start }),
281
+ });
282
+ for (const item of chunk)
283
+ output[item.index + start] = { ...item, index: item.index + start };
284
+ }
285
+ return output;
286
+ }
287
+ function formatDiscoveryScanJsonLines(document) {
288
+ return document.results.length
289
+ ? `${document.results.map((item) => JSON.stringify({ ...item, generatedAt: document.generatedAt })).join('\n')}\n`
290
+ : '';
291
+ }
292
+ function formatDiscoveryScanCsv(document) {
293
+ const header = ['domain', 'availability', 'confidence', 'review_lane', 'mutation_types', 'a', 'aaaa', 'ns', 'mx', 'relationship_ids', 'error'];
294
+ const rows = document.results.map((item) => [
295
+ item.domain, item.availabilityState, item.confidence, item.review.lane, item.mutationTypes,
296
+ item.dnsSummary.a, item.dnsSummary.aaaa, item.dnsSummary.ns, item.dnsSummary.mx,
297
+ item.relationshipIds, 'error' in item ? item.error : '',
298
+ ].map(cliCsvCell).join(','));
299
+ return `${[header.join(','), ...rows].join('\n')}\n`;
300
+ }
301
+ function formatDiscoveryScanDomains(document) {
302
+ return document.results.length ? `${document.results.map((item) => item.domain).join('\n')}\n` : '';
303
+ }
304
+ function formatTerminalDiscoveryScan(document) {
305
+ const lines = [
306
+ 'Supervised candidate scan',
307
+ `Seed ${document.seed}`,
308
+ `Mode ${document.mode}`,
309
+ `Generated ${document.generation.generatedCandidateCount}`,
310
+ `Collected ${document.summary.collected}`,
311
+ `Matched ${document.summary.matched}`,
312
+ `Source failures ${document.summary.failed}`,
313
+ `Relationships ${document.relationships.length}`,
314
+ '',
315
+ 'Review queue',
316
+ ];
317
+ for (const item of document.results.slice(0, 200)) {
318
+ lines.push(`${item.domain.padEnd(42)} ${item.availabilityState.padEnd(13)} ${item.review.lane}`);
319
+ }
320
+ if (document.results.length > 200)
321
+ lines.push(`... ${document.results.length - 200} additional results omitted from terminal display; use --json, --jsonl, or --csv.`);
322
+ lines.push('', 'Next actions:', ...document.nextActions.map((item) => ` - ${item}`));
323
+ return `${lines.join('\n')}\n`;
324
+ }
325
+ export { buildDiscoveryScanDocument, formatDiscoveryScanCsv, formatDiscoveryScanDomains, formatDiscoveryScanJsonLines, formatTerminalDiscoveryScan, parseDiscoveryScanAllowlist, readDiscoveryScanListBounded, runDiscoveryScanChunks, };
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { open } from 'node:fs/promises';
3
+ import { CliUsageError } from "./errors.mjs";
4
+ import { writePrivateFile } from "./output-file.mjs";
5
+ import { isValidAsciiDomainName } from "../lib/hostname.mjs";
6
+ export const CLI_DISCOVERY_SNAPSHOT_SCHEMA = 'whoisleuth.cli.discovery-snapshot';
7
+ export const CLI_DISCOVERY_SNAPSHOT_VERSION = 1;
8
+ export const MAX_DISCOVERY_SNAPSHOT_BYTES = 4 * 1024 * 1024;
9
+ export const MAX_DISCOVERY_SNAPSHOT_DOMAINS = 10_000;
10
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
11
+ function configurationDigest(configuration) {
12
+ return createHash('sha256').update(JSON.stringify({
13
+ seed: configuration.seed,
14
+ preset: configuration.preset,
15
+ keyboardLayout: configuration.keyboardLayout,
16
+ tlds: [...configuration.tlds],
17
+ mutationFamilies: [...configuration.mutationFamilies],
18
+ dictionaryDigestSha256: configuration.dictionaryDigestSha256,
19
+ })).digest('hex');
20
+ }
21
+ function normalizedDomains(value) {
22
+ if (!Array.isArray(value) || value.length > MAX_DISCOVERY_SNAPSHOT_DOMAINS) {
23
+ throw new CliUsageError(`Discovery snapshots support at most ${MAX_DISCOVERY_SNAPSHOT_DOMAINS} candidates.`);
24
+ }
25
+ const output = new Set();
26
+ for (const candidate of value) {
27
+ if (!isValidAsciiDomainName(candidate, { requireDot: true, requireLowercase: true })) {
28
+ throw new CliUsageError('Discovery snapshot contains an invalid candidate domain.');
29
+ }
30
+ output.add(candidate);
31
+ }
32
+ return [...output].sort();
33
+ }
34
+ function parseDiscoverySnapshot(text, expectedConfigurationDigest) {
35
+ if (Buffer.byteLength(text, 'utf8') > MAX_DISCOVERY_SNAPSHOT_BYTES) {
36
+ throw new CliUsageError(`Discovery snapshot input is limited to ${MAX_DISCOVERY_SNAPSHOT_BYTES} bytes.`);
37
+ }
38
+ let parsed;
39
+ try {
40
+ parsed = JSON.parse(text.replace(/^\uFEFF/u, ''));
41
+ }
42
+ catch {
43
+ throw new CliUsageError('Discovery snapshot must be valid JSON.');
44
+ }
45
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
46
+ throw new CliUsageError('Discovery snapshot must be one JSON object.');
47
+ }
48
+ const value = parsed;
49
+ if (value.schema !== CLI_DISCOVERY_SNAPSHOT_SCHEMA || value.version !== CLI_DISCOVERY_SNAPSHOT_VERSION) {
50
+ throw new CliUsageError(`Discovery snapshot must use ${CLI_DISCOVERY_SNAPSHOT_SCHEMA} version ${CLI_DISCOVERY_SNAPSHOT_VERSION}.`);
51
+ }
52
+ if (value.configurationDigestSha256 !== expectedConfigurationDigest) {
53
+ throw new CliUsageError('Discovery snapshot configuration does not match this run. Choose another state file or restore the original discovery controls.');
54
+ }
55
+ if (typeof value.generatedAt !== 'string' || !Number.isFinite(Date.parse(value.generatedAt))) {
56
+ throw new CliUsageError('Discovery snapshot collection time is invalid.');
57
+ }
58
+ const candidates = normalizedDomains(value.candidates);
59
+ if (value.candidateCount !== candidates.length)
60
+ throw new CliUsageError('Discovery snapshot candidate count is inconsistent.');
61
+ return {
62
+ schema: CLI_DISCOVERY_SNAPSHOT_SCHEMA,
63
+ version: CLI_DISCOVERY_SNAPSHOT_VERSION,
64
+ generatedAt: new Date(value.generatedAt).toISOString(),
65
+ configurationDigestSha256: expectedConfigurationDigest,
66
+ candidateCount: candidates.length,
67
+ candidates,
68
+ };
69
+ }
70
+ async function readExistingSnapshot(path) {
71
+ try {
72
+ const handle = await open(path, 'r');
73
+ try {
74
+ const metadata = await handle.stat();
75
+ if (!metadata.isFile() || metadata.size > MAX_DISCOVERY_SNAPSHOT_BYTES) {
76
+ throw new CliUsageError(`Discovery snapshot input is limited to ${MAX_DISCOVERY_SNAPSHOT_BYTES} bytes.`);
77
+ }
78
+ return await handle.readFile({ encoding: 'utf8' });
79
+ }
80
+ finally {
81
+ await handle.close();
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
86
+ return null;
87
+ throw error;
88
+ }
89
+ }
90
+ async function updateDiscoverySnapshot(path, candidatesValue, configuration, generatedAt) {
91
+ if (!path || path.length > 1024 || /[\u0000-\u001f\u007f]/u.test(path)) {
92
+ throw new CliUsageError('Discovery snapshot path must be bounded text without control characters.');
93
+ }
94
+ const candidates = normalizedDomains(candidatesValue);
95
+ const digest = configurationDigest(configuration);
96
+ const existingText = await readExistingSnapshot(path);
97
+ const previous = existingText === null ? null : parseDiscoverySnapshot(existingText, digest);
98
+ const previousSet = new Set(previous?.candidates ?? []);
99
+ const currentSet = new Set(candidates);
100
+ const added = candidates.filter((domain) => !previousSet.has(domain));
101
+ const removed = [...previousSet].filter((domain) => !currentSet.has(domain));
102
+ const document = {
103
+ schema: CLI_DISCOVERY_SNAPSHOT_SCHEMA,
104
+ version: CLI_DISCOVERY_SNAPSHOT_VERSION,
105
+ generatedAt: new Date(generatedAt).toISOString(),
106
+ configurationDigestSha256: digest,
107
+ candidateCount: candidates.length,
108
+ candidates,
109
+ };
110
+ const content = `${JSON.stringify(document, null, 2)}\n`;
111
+ if (Buffer.byteLength(content, 'utf8') > MAX_DISCOVERY_SNAPSHOT_BYTES) {
112
+ throw new CliUsageError(`Discovery snapshot output is limited to ${MAX_DISCOVERY_SNAPSHOT_BYTES} bytes.`);
113
+ }
114
+ await writePrivateFile(path, content, {
115
+ force: previous !== null,
116
+ existingFileMessage: 'Discovery snapshot already exists and could not be replaced.',
117
+ });
118
+ return {
119
+ baselineCreated: previous === null,
120
+ previousGeneratedAt: previous?.generatedAt ?? null,
121
+ previousCandidateCount: previous?.candidateCount ?? 0,
122
+ currentCandidateCount: candidates.length,
123
+ added,
124
+ removed,
125
+ unchanged: candidates.filter((domain) => previousSet.has(domain)).length,
126
+ limitations: [
127
+ 'This local snapshot compares generated candidate names only and makes no network request.',
128
+ 'Added or removed candidates can reflect changed generation controls, dictionaries, or software versions rather than registration activity.',
129
+ 'The state file contains candidate domains and should be handled as analyst workspace data.',
130
+ ],
131
+ };
132
+ }
133
+ export { configurationDigest, parseDiscoverySnapshot, updateDiscoverySnapshot };
@@ -0,0 +1,73 @@
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";
4
+ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
5
+ async function generateDiscoveryCandidates(args, dependencies, context) {
6
+ const seed = args.seed || await context.readSingleInput();
7
+ if (!seed)
8
+ throw new CliUsageError('discover requires one brand label or domain as an argument or on stdin.');
9
+ const loadGenerator = dependencies.loadTyposquatGenerator || (() => import("../lib/typosquat-generator.mjs"));
10
+ const generator = await loadGenerator();
11
+ const tlds = normalizeDiscoveryTlds(args.tldText || DEFAULT_DISCOVERY_TLDS.join(','), generator.MAX_GENERATION_TLDS);
12
+ const requestedFamilies = args.familyText
13
+ ? [...new Set(args.familyText.split(',').map((value) => value.trim()).filter(Boolean))]
14
+ : [];
15
+ const mutationFamilies = args.preset === 'custom'
16
+ ? generator.normalizeMutationFamilyIds(requestedFamilies)
17
+ : [];
18
+ if (args.preset === 'custom'
19
+ && (!mutationFamilies.length || mutationFamilies.length !== requestedFamilies.length)) {
20
+ throw new CliUsageError(`--families requires one or more supported IDs: ${generator.MUTATION_FAMILY_IDS.join(', ')}.`);
21
+ }
22
+ let dictionaryText = '';
23
+ if (args.dictionarySource) {
24
+ if (args.preset === 'custom'
25
+ && !mutationFamilies.includes('dictionary')
26
+ && !mutationFamilies.includes('dictionary_token_replacement')) {
27
+ throw new CliUsageError('--dictionary requires a dictionary mutation family.');
28
+ }
29
+ try {
30
+ dictionaryText = dependencies.readDiscoveryDictionary
31
+ ? await dependencies.readDiscoveryDictionary(args.dictionarySource)
32
+ : await readDiscoveryDictionaryBounded(createReadStream(args.dictionarySource, { highWaterMark: MAX_DISCOVERY_DICTIONARY_BYTES }), MAX_DISCOVERY_DICTIONARY_BYTES);
33
+ }
34
+ catch (error) {
35
+ if (error instanceof CliUsageError)
36
+ throw error;
37
+ throw new CliUsageError(`Could not read discovery dictionary: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
38
+ }
39
+ const normalizedDictionary = generator.normalizeCustomDictionaryTerms(dictionaryText);
40
+ if (!normalizedDictionary.values.length) {
41
+ throw new CliUsageError('The discovery dictionary did not contain any valid terms.');
42
+ }
43
+ }
44
+ const result = generator.generateTyposquatCandidateSet(seed, tlds, {
45
+ preset: args.preset,
46
+ keyboardLayout: args.keyboardLayout,
47
+ dictionaryTerms: dictionaryText,
48
+ ...(args.preset === 'custom' ? { mutationTypes: mutationFamilies } : {}),
49
+ });
50
+ if (!result.inputValid) {
51
+ throw new CliUsageError('discover requires a valid brand label or domain with one suffix label.');
52
+ }
53
+ const normalizedDictionary = generator.normalizeCustomDictionaryTerms(dictionaryText);
54
+ const metadata = {
55
+ generatedAt: context.now(),
56
+ seed,
57
+ preset: args.preset,
58
+ keyboardLayout: args.keyboardLayout,
59
+ tlds,
60
+ mutationFamilies,
61
+ dictionaryTermCount: normalizedDictionary.values.length,
62
+ rejectedDictionaryTermCount: normalizedDictionary.rejectedCount,
63
+ };
64
+ return {
65
+ generator,
66
+ result,
67
+ metadata,
68
+ dictionaryDigestSha256: dictionaryText
69
+ ? createHash('sha256').update(dictionaryText).digest('hex')
70
+ : null,
71
+ };
72
+ }
73
+ export { generateDiscoveryCandidates };
package/cli/doctor.mjs ADDED
@@ -0,0 +1,137 @@
1
+ import { resolvePublicAddresses } from "../lib/safe-fetch.mjs";
2
+ import { withTimeout } from "../lib/abort.mjs";
3
+ import { whoisQuery } from "../lib/whois-transport.mjs";
4
+ const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
5
+ const DOCTOR_VERSION = 1;
6
+ const MINIMUM_NODE_MAJOR = 24;
7
+ const NETWORK_HOST = 'whois.iana.org';
8
+ const NETWORK_QUERY = 'example.com';
9
+ const NETWORK_TIMEOUT_MS = 6_000;
10
+ const MAX_DOCTOR_DETAIL_LENGTH = 180;
11
+ function boundedDetail(value, fallback) {
12
+ return String(value || fallback)
13
+ .replace(/[\u0000-\u001f\u007f]+/gu, ' ')
14
+ .replace(/\s+/gu, ' ')
15
+ .trim()
16
+ .slice(0, MAX_DOCTOR_DETAIL_LENGTH) || fallback;
17
+ }
18
+ function parseNodeMajor(version) {
19
+ const major = Number(version.split('.')[0]);
20
+ return Number.isSafeInteger(major) && major >= 0 ? major : 0;
21
+ }
22
+ async function buildDoctorReport(options) {
23
+ const nodeVersion = boundedDetail(options.nodeVersion || process.versions.node, 'unknown');
24
+ const nodeSupported = parseNodeMajor(nodeVersion) >= MINIMUM_NODE_MAJOR;
25
+ const checks = [
26
+ Object.freeze({
27
+ id: 'runtime',
28
+ label: 'Node runtime',
29
+ state: nodeSupported ? 'pass' : 'partial',
30
+ detail: nodeSupported
31
+ ? `Node ${nodeVersion} satisfies the supported Node ${MINIMUM_NODE_MAJOR}+ runtime.`
32
+ : `Node ${nodeVersion} is below the supported Node ${MINIMUM_NODE_MAJOR}+ runtime.`,
33
+ }),
34
+ Object.freeze({
35
+ id: 'platform',
36
+ label: 'Platform',
37
+ state: 'pass',
38
+ detail: `${boundedDetail(options.platform || process.platform, 'unknown')} · ${boundedDetail(options.architecture || process.arch, 'unknown')}`,
39
+ }),
40
+ Object.freeze({
41
+ id: 'terminal',
42
+ label: 'Terminal presentation',
43
+ state: 'pass',
44
+ detail: options.presentation.interactive
45
+ ? `Interactive terminal detected at ${options.presentation.width || 'unknown'} columns; semantic colour ${options.presentation.color ? 'enabled' : 'disabled'}.`
46
+ : 'Non-interactive output detected; colour, wrapping, and transient progress are disabled.',
47
+ }),
48
+ Object.freeze({
49
+ id: 'offline_features',
50
+ label: 'Offline features',
51
+ state: 'pass',
52
+ detail: 'Help, completion, registry coverage, lookalike generation, archive inspection, comparison, export, and signature verification are available without network access.',
53
+ }),
54
+ ];
55
+ if (!options.network) {
56
+ checks.push(Object.freeze({
57
+ id: 'network',
58
+ label: 'Network checks',
59
+ state: 'skipped',
60
+ detail: 'Not requested. Run doctor --network to test bounded public DNS and WHOIS connectivity.',
61
+ }));
62
+ }
63
+ else {
64
+ const resolve = options.resolveAddresses || resolvePublicAddresses;
65
+ const query = options.queryWhois || whoisQuery;
66
+ const networkTimeoutMs = Number.isSafeInteger(options.networkTimeoutMs)
67
+ && Number(options.networkTimeoutMs) >= 1
68
+ && Number(options.networkTimeoutMs) <= NETWORK_TIMEOUT_MS
69
+ ? Number(options.networkTimeoutMs)
70
+ : NETWORK_TIMEOUT_MS;
71
+ try {
72
+ const addresses = await withTimeout(() => resolve(NETWORK_HOST), networkTimeoutMs, `Public DNS check timed out after ${networkTimeoutMs} ms.`);
73
+ checks.push(Object.freeze({
74
+ id: 'public_dns',
75
+ label: 'Public DNS',
76
+ state: addresses.length > 0 ? 'pass' : 'partial',
77
+ detail: addresses.length > 0
78
+ ? `${addresses.length} public address${addresses.length === 1 ? '' : 'es'} validated; addresses were not retained.`
79
+ : 'The fixed diagnostic host returned no validated public addresses.',
80
+ }));
81
+ }
82
+ catch (error) {
83
+ checks.push(Object.freeze({
84
+ id: 'public_dns',
85
+ label: 'Public DNS',
86
+ state: 'partial',
87
+ detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'Public DNS check failed.'),
88
+ }));
89
+ }
90
+ try {
91
+ const response = await query(NETWORK_HOST, NETWORK_QUERY, {
92
+ timeoutMs: Math.max(1, networkTimeoutMs - 1_000),
93
+ totalDeadlineMs: networkTimeoutMs,
94
+ });
95
+ checks.push(Object.freeze({
96
+ id: 'whois_transport',
97
+ label: 'WHOIS transport',
98
+ state: response.trim() ? 'pass' : 'partial',
99
+ detail: response.trim()
100
+ ? 'A bounded port 43 response was received; response content was not retained.'
101
+ : 'The fixed diagnostic query returned an empty response.',
102
+ }));
103
+ }
104
+ catch (error) {
105
+ checks.push(Object.freeze({
106
+ id: 'whois_transport',
107
+ label: 'WHOIS transport',
108
+ state: 'partial',
109
+ detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'WHOIS transport check failed.'),
110
+ }));
111
+ }
112
+ }
113
+ const state = checks.some((check) => check.state === 'partial') ? 'partial' : 'pass';
114
+ return Object.freeze({
115
+ schema: DOCTOR_SCHEMA,
116
+ version: DOCTOR_VERSION,
117
+ generatedAt: options.generatedAt,
118
+ cliVersion: options.version,
119
+ state,
120
+ networkRequested: options.network,
121
+ checks: Object.freeze(checks),
122
+ });
123
+ }
124
+ function formatDoctorReport(report) {
125
+ const lines = [
126
+ 'Runtime:',
127
+ `CLI version ${report.cliVersion}`,
128
+ `Overall ${report.state === 'pass' ? 'Pass' : 'Partial'}`,
129
+ '',
130
+ ];
131
+ for (const check of report.checks) {
132
+ lines.push(`[${check.state.toUpperCase()}] ${check.label}`);
133
+ lines.push(` Detail ${check.detail}`);
134
+ }
135
+ return `${lines.join('\n')}\n`;
136
+ }
137
+ export { buildDoctorReport, formatDoctorReport, };