@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,210 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { CASE_RESPONSE_PACKET_SCHEMA, CASE_RESPONSE_PACKET_VERSION, verifyCaseResponsePacketIntegrity, } from "../frontend/src/lib/analysis/case-response-packet.js";
3
+ import { ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../frontend/src/lib/analysis/acquisition-decision-packet.js";
4
+ import { BULK_DOMAIN_COMPARISON_SCHEMA, BULK_DOMAIN_COMPARISON_VERSION, } from "../frontend/src/lib/analysis/bulk-domain-comparison.js";
5
+ import { BULK_MAIL_EXPOSURE_SCHEMA, BULK_MAIL_EXPOSURE_VERSION, } from "../frontend/src/lib/analysis/bulk-mail-exposure.js";
6
+ import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../frontend/src/lib/analysis/bulk-review-export.js";
7
+ import { decryptWorkspaceArchive, inspectEncryptedWorkspaceArchive, isEncryptedWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive-crypto.js";
8
+ import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive.js";
9
+ import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
10
+ export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
11
+ export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
12
+ export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
13
+ export const MAX_OFFLINE_PASSPHRASE_FILE_BYTES = 1024;
14
+ const SIGNED_ARTIFACT_VERSIONS = Object.freeze({
15
+ [ACQUISITION_DECISION_PACKET_SCHEMA]: new Set([ACQUISITION_DECISION_PACKET_VERSION]),
16
+ [BULK_DOMAIN_COMPARISON_SCHEMA]: new Set([BULK_DOMAIN_COMPARISON_VERSION]),
17
+ [BULK_MAIL_EXPOSURE_SCHEMA]: new Set([BULK_MAIL_EXPOSURE_VERSION]),
18
+ [BULK_REVIEW_MANIFEST_SCHEMA]: new Set([BULK_REVIEW_MANIFEST_VERSION]),
19
+ });
20
+ function record(value) {
21
+ return value && typeof value === 'object' && !Array.isArray(value)
22
+ ? value
23
+ : null;
24
+ }
25
+ function parseJson(raw) {
26
+ if (typeof raw !== 'string')
27
+ throw new TypeError('Artifact input must be UTF-8 JSON text.');
28
+ const bytes = Buffer.byteLength(raw, 'utf8');
29
+ if (bytes === 0 || bytes > MAX_OFFLINE_ARTIFACT_BYTES) {
30
+ throw new TypeError(`Artifact input must be between 1 byte and ${MAX_OFFLINE_ARTIFACT_BYTES} bytes.`);
31
+ }
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(raw);
35
+ }
36
+ catch {
37
+ throw new TypeError('Artifact input is not valid JSON.');
38
+ }
39
+ const value = record(parsed);
40
+ if (!value)
41
+ throw new TypeError('Artifact input must contain one JSON object.');
42
+ return value;
43
+ }
44
+ function artifactVersion(value) {
45
+ if (!Number.isSafeInteger(value.version) || Number(value.version) < 1 || Number(value.version) > 1000) {
46
+ throw new TypeError('Artifact version is missing or invalid.');
47
+ }
48
+ return Number(value.version);
49
+ }
50
+ function inputBytes(raw) {
51
+ return Buffer.byteLength(raw, 'utf8');
52
+ }
53
+ function archiveReport(raw, archive, encrypted, ciphertextBytes) {
54
+ return Object.freeze({
55
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
56
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
57
+ artifact: Object.freeze({
58
+ kind: encrypted ? 'encrypted_workspace_archive' : 'workspace_archive',
59
+ schema: WORKSPACE_ARCHIVE_SCHEMA,
60
+ version: archive.version,
61
+ }),
62
+ state: 'verified',
63
+ valid: true,
64
+ checks: Object.freeze({
65
+ structure: 'verified',
66
+ contentIntegrity: 'verified',
67
+ authenticatedEncryption: encrypted ? 'verified' : 'not_applicable',
68
+ }),
69
+ summary: Object.freeze({
70
+ inputBytes: inputBytes(raw),
71
+ sectionCount: archive.sections.length,
72
+ recordCount: archive.sections.reduce((sum, section) => sum + section.recordCount, 0),
73
+ ciphertextBytes,
74
+ }),
75
+ limitations: Object.freeze([
76
+ 'Verification checks the retained file against its declared versioned integrity contract; it does not establish that the original observations were accurate or remain current.',
77
+ ]),
78
+ });
79
+ }
80
+ async function verifySignedArtifact(raw, value, schema, version) {
81
+ const supported = SIGNED_ARTIFACT_VERSIONS[schema];
82
+ if (!supported?.has(version)) {
83
+ throw new TypeError('This signed review-artifact schema or version is not supported.');
84
+ }
85
+ const integrity = record(value.integrity);
86
+ if (!integrity
87
+ || integrity.algorithm !== 'SHA-256'
88
+ || typeof integrity.digestSha256 !== 'string'
89
+ || !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
90
+ throw new TypeError('The signed review artifact has a missing or malformed integrity envelope.');
91
+ }
92
+ const { integrity: _integrity, ...unsigned } = value;
93
+ if (await sha256ArtifactDigest(unsigned) !== integrity.digestSha256) {
94
+ throw new TypeError('The signed review artifact failed its SHA-256 integrity check.');
95
+ }
96
+ return Object.freeze({
97
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
98
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
99
+ artifact: Object.freeze({ kind: 'signed_review_artifact', schema, version }),
100
+ state: 'verified',
101
+ valid: true,
102
+ checks: Object.freeze({
103
+ structure: 'verified',
104
+ contentIntegrity: 'verified',
105
+ authenticatedEncryption: 'not_applicable',
106
+ }),
107
+ summary: Object.freeze({
108
+ inputBytes: inputBytes(raw),
109
+ sectionCount: null,
110
+ recordCount: null,
111
+ ciphertextBytes: null,
112
+ }),
113
+ limitations: Object.freeze([
114
+ 'Digest verification detects changes to this exported artifact; it does not authenticate the analyst, collection source, or truth of the retained statements.',
115
+ ]),
116
+ });
117
+ }
118
+ export async function verifyOfflineArtifact(raw, options = {}) {
119
+ const value = parseJson(raw);
120
+ if (isEncryptedWorkspaceArchive(value)) {
121
+ const inspected = inspectEncryptedWorkspaceArchive(value);
122
+ if (!options.passphrase) {
123
+ return Object.freeze({
124
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
125
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
126
+ artifact: Object.freeze({
127
+ kind: 'encrypted_workspace_archive',
128
+ schema: String(value.schema),
129
+ version: artifactVersion(value),
130
+ }),
131
+ state: 'envelope_valid',
132
+ valid: true,
133
+ checks: Object.freeze({
134
+ structure: 'verified',
135
+ contentIntegrity: 'not_checked',
136
+ authenticatedEncryption: 'not_checked',
137
+ }),
138
+ summary: Object.freeze({
139
+ inputBytes: inputBytes(raw),
140
+ sectionCount: null,
141
+ recordCount: null,
142
+ ciphertextBytes: inspected.ciphertextBytes,
143
+ }),
144
+ limitations: Object.freeze([
145
+ 'The encrypted envelope is structurally valid, but its authenticated ciphertext and inner workspace checksums were not verified because no passphrase was supplied.',
146
+ 'Pass a separate bounded passphrase file to perform authenticated decryption without placing the passphrase in command history.',
147
+ ]),
148
+ });
149
+ }
150
+ const decrypted = await decryptWorkspaceArchive(value, options.passphrase);
151
+ const archive = await readWorkspaceArchive(decrypted);
152
+ return archiveReport(raw, archive, true, inspected.ciphertextBytes);
153
+ }
154
+ const schema = typeof value.schema === 'string' ? value.schema : '';
155
+ const version = artifactVersion(value);
156
+ if (schema === WORKSPACE_ARCHIVE_SCHEMA) {
157
+ return archiveReport(raw, await readWorkspaceArchive(value), false, null);
158
+ }
159
+ if (schema === CASE_RESPONSE_PACKET_SCHEMA) {
160
+ if (version !== CASE_RESPONSE_PACKET_VERSION) {
161
+ throw new TypeError('This case-response packet version is not supported.');
162
+ }
163
+ const integrity = record(value.integrity);
164
+ if (!integrity || !await verifyCaseResponsePacketIntegrity(value)) {
165
+ throw new TypeError('The case-response packet failed its manifest integrity check.');
166
+ }
167
+ return Object.freeze({
168
+ schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
169
+ version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
170
+ artifact: Object.freeze({ kind: 'case_response_packet', schema, version }),
171
+ state: 'verified',
172
+ valid: true,
173
+ checks: Object.freeze({
174
+ structure: 'verified',
175
+ contentIntegrity: 'verified',
176
+ authenticatedEncryption: 'not_applicable',
177
+ }),
178
+ summary: Object.freeze({
179
+ inputBytes: inputBytes(raw),
180
+ sectionCount: null,
181
+ recordCount: null,
182
+ ciphertextBytes: null,
183
+ }),
184
+ limitations: Object.freeze([
185
+ 'Packet digest verification detects changes after export; it does not authenticate the analyst, recipient, or truth of the retained observations.',
186
+ ]),
187
+ });
188
+ }
189
+ return verifySignedArtifact(raw, value, schema, version);
190
+ }
191
+ export function formatOfflineArtifactVerification(report) {
192
+ const lines = [
193
+ 'WHOISleuth offline artifact verification',
194
+ `Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
195
+ `State: ${report.state}`,
196
+ `Structure: ${report.checks.structure}`,
197
+ `Content integrity: ${report.checks.contentIntegrity}`,
198
+ `Authenticated encryption: ${report.checks.authenticatedEncryption}`,
199
+ `Input bytes: ${report.summary.inputBytes}`,
200
+ ];
201
+ if (report.summary.sectionCount !== null)
202
+ lines.push(`Sections: ${report.summary.sectionCount}`);
203
+ if (report.summary.recordCount !== null)
204
+ lines.push(`Records: ${report.summary.recordCount}`);
205
+ if (report.summary.ciphertextBytes !== null)
206
+ lines.push(`Ciphertext bytes: ${report.summary.ciphertextBytes}`);
207
+ for (const limitation of report.limitations)
208
+ lines.push(`Limitation: ${limitation}`);
209
+ return `${lines.join('\n')}\n`;
210
+ }
@@ -0,0 +1,218 @@
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
+ export const CLI_BULK_CHECKPOINT_SCHEMA = 'whoisleuth.cli.bulk-checkpoint';
6
+ export const CLI_BULK_CHECKPOINT_VERSION = 1;
7
+ export const MAX_BULK_CHECKPOINT_BYTES = 16 * 1024 * 1024;
8
+ const MAX_CHECKPOINT_JSON_DEPTH = 12;
9
+ const MAX_CHECKPOINT_OBJECT_KEYS = 256;
10
+ const MAX_CHECKPOINT_ARRAY_ITEMS = 1_024;
11
+ const MAX_CHECKPOINT_STRING_LENGTH = 32_768;
12
+ const COMPACT_RESULT_KEYS = new Set(['availability', 'diagnostics']);
13
+ function record(value) {
14
+ return value && typeof value === 'object' && !Array.isArray(value)
15
+ ? value
16
+ : null;
17
+ }
18
+ function checkpointDigest(queries, deep) {
19
+ return createHash('sha256')
20
+ .update(deep ? 'deep\n' : 'fast\n')
21
+ .update(queries.join('\n'))
22
+ .digest('hex');
23
+ }
24
+ function validTimestamp(value) {
25
+ return typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value));
26
+ }
27
+ function isBoundedCheckpointJson(value, depth = 0) {
28
+ if (depth > MAX_CHECKPOINT_JSON_DEPTH)
29
+ return false;
30
+ if (value === null || typeof value === 'boolean')
31
+ return true;
32
+ if (typeof value === 'number')
33
+ return Number.isFinite(value);
34
+ if (typeof value === 'string')
35
+ return value.length <= MAX_CHECKPOINT_STRING_LENGTH;
36
+ if (Array.isArray(value)) {
37
+ return value.length <= MAX_CHECKPOINT_ARRAY_ITEMS
38
+ && value.every((item) => isBoundedCheckpointJson(item, depth + 1));
39
+ }
40
+ const object = record(value);
41
+ if (!object)
42
+ return false;
43
+ const entries = Object.entries(object);
44
+ return entries.length <= MAX_CHECKPOINT_OBJECT_KEYS
45
+ && entries.every(([key, item]) => key.length <= 128 && isBoundedCheckpointJson(item, depth + 1));
46
+ }
47
+ function normalizeCompactResult(value) {
48
+ const result = record(value);
49
+ if (!result || !isBoundedCheckpointJson(result))
50
+ return null;
51
+ const keys = Object.keys(result);
52
+ if (keys.length !== COMPACT_RESULT_KEYS.size || keys.some((key) => !COMPACT_RESULT_KEYS.has(key)))
53
+ return null;
54
+ if (!record(result.availability) || !record(result.diagnostics))
55
+ return null;
56
+ return result;
57
+ }
58
+ function normalizeCheckpointResult(value, queries, classifyQuery) {
59
+ const item = record(value);
60
+ const index = item?.index;
61
+ if (!Number.isSafeInteger(index) || Number(index) < 0 || Number(index) >= queries.length)
62
+ return null;
63
+ const query = queries[Number(index)];
64
+ if (typeof item?.query !== 'string' || item.query !== query)
65
+ return null;
66
+ if (item.ok === false) {
67
+ if (typeof item.error !== 'string' || !item.error || item.error.length > 300)
68
+ return null;
69
+ return { index: Number(index), query, ok: false, error: item.error };
70
+ }
71
+ const result = normalizeCompactResult(item.result);
72
+ if (item.ok !== true || !result)
73
+ return null;
74
+ const classified = classifyQuery(query);
75
+ const storedClassified = record(item.classified);
76
+ if (storedClassified?.type !== classified.type || storedClassified.value !== classified.value)
77
+ return null;
78
+ return { index: Number(index), query, ok: true, classified, result };
79
+ }
80
+ function parseBulkCheckpoint(text, options) {
81
+ if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > MAX_BULK_CHECKPOINT_BYTES) {
82
+ throw new CliUsageError(`Bulk checkpoint input is limited to ${MAX_BULK_CHECKPOINT_BYTES} bytes.`);
83
+ }
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(text.replace(/^\uFEFF/u, ''));
87
+ }
88
+ catch {
89
+ throw new CliUsageError('Bulk checkpoint must be valid JSON.');
90
+ }
91
+ const document = record(parsed);
92
+ const expectedDigest = checkpointDigest(options.queries, options.deep);
93
+ if (document?.schema !== CLI_BULK_CHECKPOINT_SCHEMA || document.version !== CLI_BULK_CHECKPOINT_VERSION) {
94
+ throw new CliUsageError(`Bulk checkpoint must use ${CLI_BULK_CHECKPOINT_SCHEMA} version ${CLI_BULK_CHECKPOINT_VERSION}.`);
95
+ }
96
+ if (document.mode !== (options.deep ? 'deep' : 'fast')
97
+ || document.inputDigestSha256 !== expectedDigest
98
+ || document.queryCount !== options.queries.length) {
99
+ throw new CliUsageError('Bulk checkpoint does not match the current input or scan mode.');
100
+ }
101
+ if (!validTimestamp(document.startedAt) || !validTimestamp(document.updatedAt) || !Array.isArray(document.results)) {
102
+ throw new CliUsageError('Bulk checkpoint metadata is invalid.');
103
+ }
104
+ const results = [];
105
+ const seen = new Set();
106
+ for (const candidate of document.results.slice(0, options.queries.length + 1)) {
107
+ const result = normalizeCheckpointResult(candidate, options.queries, options.classifyQuery);
108
+ if (!result || seen.has(result.index))
109
+ throw new CliUsageError('Bulk checkpoint contains an invalid or duplicate result.');
110
+ seen.add(result.index);
111
+ results.push(result);
112
+ }
113
+ return {
114
+ schema: CLI_BULK_CHECKPOINT_SCHEMA,
115
+ version: CLI_BULK_CHECKPOINT_VERSION,
116
+ mode: options.deep ? 'deep' : 'fast',
117
+ inputDigestSha256: expectedDigest,
118
+ queryCount: options.queries.length,
119
+ startedAt: new Date(document.startedAt).toISOString(),
120
+ updatedAt: new Date(document.updatedAt).toISOString(),
121
+ results: results.sort((left, right) => left.index - right.index),
122
+ };
123
+ }
124
+ async function readCheckpointFile(path) {
125
+ const handle = await open(path, 'r');
126
+ try {
127
+ const stats = await handle.stat();
128
+ if (!stats.isFile() || stats.size > MAX_BULK_CHECKPOINT_BYTES) {
129
+ throw new CliUsageError(`Bulk checkpoint input is limited to ${MAX_BULK_CHECKPOINT_BYTES} bytes.`);
130
+ }
131
+ return await handle.readFile({ encoding: 'utf8' });
132
+ }
133
+ finally {
134
+ await handle.close();
135
+ }
136
+ }
137
+ async function checkpointFileExists(path) {
138
+ try {
139
+ const handle = await open(path, 'r');
140
+ await handle.close();
141
+ return true;
142
+ }
143
+ catch (error) {
144
+ const code = error && typeof error === 'object' && 'code' in error ? error.code : null;
145
+ if (code === 'ENOENT')
146
+ return false;
147
+ throw error;
148
+ }
149
+ }
150
+ async function createBulkCheckpointWriter(options) {
151
+ const now = options.now || (() => new Date().toISOString());
152
+ const exists = await checkpointFileExists(options.path);
153
+ if (options.resume && !exists)
154
+ throw new CliUsageError('Bulk checkpoint does not exist; remove --resume to start a new checkpoint.');
155
+ if (!options.resume && exists)
156
+ throw new CliUsageError('Bulk checkpoint already exists; use --resume or choose another path.');
157
+ const resumed = options.resume
158
+ ? parseBulkCheckpoint(await readCheckpointFile(options.path), options)
159
+ : null;
160
+ const startedAt = resumed?.startedAt || now();
161
+ const results = new Map((resumed?.results || []).map((item) => [item.index, item]));
162
+ let chain = Promise.resolve();
163
+ let writeFailure = null;
164
+ let created = resumed !== null;
165
+ function document() {
166
+ const updatedAt = now();
167
+ return {
168
+ schema: CLI_BULK_CHECKPOINT_SCHEMA,
169
+ version: CLI_BULK_CHECKPOINT_VERSION,
170
+ mode: options.deep ? 'deep' : 'fast',
171
+ inputDigestSha256: checkpointDigest(options.queries, options.deep),
172
+ queryCount: options.queries.length,
173
+ startedAt,
174
+ updatedAt,
175
+ results: [...results.values()].sort((left, right) => left.index - right.index),
176
+ };
177
+ }
178
+ function scheduleWrite() {
179
+ chain = chain.then(async () => {
180
+ if (writeFailure)
181
+ return;
182
+ try {
183
+ const content = `${JSON.stringify(document(), null, 2)}\n`;
184
+ if (Buffer.byteLength(content, 'utf8') > MAX_BULK_CHECKPOINT_BYTES) {
185
+ throw new CliUsageError(`Bulk checkpoints are limited to ${MAX_BULK_CHECKPOINT_BYTES} bytes.`);
186
+ }
187
+ await writePrivateFile(options.path, content, {
188
+ force: created,
189
+ existingFileMessage: 'Bulk checkpoint already exists; use --resume or choose another path.',
190
+ });
191
+ created = true;
192
+ }
193
+ catch (error) {
194
+ writeFailure = error;
195
+ }
196
+ });
197
+ }
198
+ if (!resumed)
199
+ scheduleWrite();
200
+ return Object.freeze({
201
+ initialResults: Object.freeze([...results.values()].sort((left, right) => left.index - right.index)),
202
+ record(result) {
203
+ const normalized = normalizeCheckpointResult(result, options.queries, options.classifyQuery);
204
+ if (!normalized) {
205
+ writeFailure ||= new CliUsageError('Bulk checkpoint result is not a bounded compact lookup result.');
206
+ return;
207
+ }
208
+ results.set(normalized.index, normalized);
209
+ scheduleWrite();
210
+ },
211
+ async flush() {
212
+ await chain;
213
+ if (writeFailure)
214
+ throw writeFailure;
215
+ },
216
+ });
217
+ }
218
+ export { createBulkCheckpointWriter, parseBulkCheckpoint, };
@@ -0,0 +1,109 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { classifyQuery } from "../lib/classify.mjs";
3
+ import { runUnifiedLookup } from "../lib/lookup.mjs";
4
+ import { createBulkCheckpointWriter } from "./bulk-checkpoint.mjs";
5
+ import { MAX_BULK_INPUT_BYTES, parseBulkQueries, readTextStreamBounded, runBulkLookups, } from "./bulk.mjs";
6
+ import { formatBulkCsv, formatBulkDomainList, selectBulkItems } from "./bulk-output.mjs";
7
+ import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
8
+ import EXIT_CODES from "./exit-codes.mjs";
9
+ import { buildCliBulkDocument, formatJsonDocument, formatJsonLines } from "./formatters/json.mjs";
10
+ import { formatTerminalBulk } from "./formatters/terminal.mjs";
11
+ import { createCliProgressEvents } from "./progress-events.mjs";
12
+ async function runBulkCommand(args, dependencies, context) {
13
+ const eventProgress = createCliProgressEvents(context.stderr, {
14
+ command: 'bulk',
15
+ enabled: args.events,
16
+ ...(dependencies.now ? { now: dependencies.now } : {}),
17
+ });
18
+ context.setEventProgress(eventProgress);
19
+ eventProgress.emit({ event: 'started' });
20
+ let input;
21
+ try {
22
+ input = dependencies.readBulkInput
23
+ ? await dependencies.readBulkInput(args.source)
24
+ : await readTextStreamBounded(args.source
25
+ ? createReadStream(args.source, { highWaterMark: 64 * 1024 })
26
+ : dependencies.stdin || process.stdin, MAX_BULK_INPUT_BYTES);
27
+ }
28
+ catch (error) {
29
+ if (error instanceof CliUsageError)
30
+ throw error;
31
+ throw new CliUsageError(`Could not read bulk input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
32
+ }
33
+ const parsed = parseBulkQueries(input, { deep: args.deep });
34
+ const classify = dependencies.classifyQuery || classifyQuery;
35
+ const checkpointWriter = dependencies.createBulkCheckpointWriter || createBulkCheckpointWriter;
36
+ const checkpoint = args.checkpoint
37
+ ? await checkpointWriter({
38
+ path: args.checkpoint,
39
+ queries: parsed.queries,
40
+ deep: args.deep,
41
+ resume: args.resume,
42
+ classifyQuery: classify,
43
+ ...(dependencies.now ? { now: dependencies.now } : {}),
44
+ })
45
+ : null;
46
+ const indicator = context.beginProgress(`Collecting 0 of ${parsed.queries.length} targets`);
47
+ let completed = checkpoint?.initialResults.length || 0;
48
+ if (completed)
49
+ indicator.update(`Resumed ${completed} of ${parsed.queries.length} targets`);
50
+ let items;
51
+ let checkpointFailure = null;
52
+ try {
53
+ items = await runBulkLookups(parsed.queries, {
54
+ deep: args.deep,
55
+ concurrency: args.concurrency,
56
+ classifyQuery: classify,
57
+ runUnifiedLookup: dependencies.runUnifiedLookup || runUnifiedLookup,
58
+ ...(checkpoint ? { initialResults: checkpoint.initialResults } : {}),
59
+ ...(dependencies.signal ? { signal: dependencies.signal } : {}),
60
+ onItemSettled: (item) => {
61
+ completed += 1;
62
+ indicator.update(`Collected ${completed} of ${parsed.queries.length} targets`);
63
+ eventProgress.emit({ event: 'item_settled', index: item.index, ok: item.ok });
64
+ checkpoint?.record(item);
65
+ },
66
+ });
67
+ }
68
+ finally {
69
+ context.endProgress();
70
+ try {
71
+ await checkpoint?.flush();
72
+ }
73
+ catch (error) {
74
+ checkpointFailure = error;
75
+ }
76
+ }
77
+ const metadata = {
78
+ deep: args.deep,
79
+ duplicates: parsed.duplicates,
80
+ generatedAt: context.now(),
81
+ collectedTotal: items.length,
82
+ filter: args.filter,
83
+ };
84
+ const selectedItems = selectBulkItems(items, args.filter);
85
+ if (!args.quiet) {
86
+ if (args.output === 'json')
87
+ context.writeStdout(formatJsonDocument(buildCliBulkDocument(selectedItems, metadata)));
88
+ else if (args.output === 'jsonl')
89
+ context.writeStdout(formatJsonLines(selectedItems, metadata));
90
+ else if (args.output === 'csv')
91
+ context.writeStdout(formatBulkCsv(selectedItems));
92
+ else if (args.output === 'domains')
93
+ context.writeStdout(formatBulkDomainList(selectedItems));
94
+ else
95
+ context.writeStdout(context.terminal(formatTerminalBulk(selectedItems, metadata), args.color));
96
+ }
97
+ if (checkpointFailure) {
98
+ eventProgress.emit({ event: 'warning', state: 'checkpoint_unavailable' });
99
+ if (!eventProgress.enabled) {
100
+ context.writeStderr(`Checkpoint warning: ${boundedCliErrorMessage(checkpointFailure, 'Checkpoint could not be written')}. Completed output is still available.\n`);
101
+ }
102
+ }
103
+ const exitCode = checkpointFailure || items.some((item) => !item.ok)
104
+ ? EXIT_CODES.PARTIAL_FAILURE
105
+ : EXIT_CODES.SUCCESS;
106
+ eventProgress.emit({ event: 'completed', exitCode });
107
+ return exitCode;
108
+ }
109
+ export { runBulkCommand };
@@ -0,0 +1,112 @@
1
+ import { cliCsvCell } from "./csv.mjs";
2
+ const REGISTERED_STATES = new Set(['expiring', 'for_sale', 'registered']);
3
+ const MAX_DNS_VALUES_PER_TYPE = 100;
4
+ function record(value) {
5
+ return value && typeof value === 'object' && !Array.isArray(value)
6
+ ? value
7
+ : {};
8
+ }
9
+ function boundedText(value, maximum = 500) {
10
+ return typeof value === 'string'
11
+ ? value.replace(/[\u0000-\u001f\u007f]+/gu, ' ').replace(/\s+/gu, ' ').trim().slice(0, maximum)
12
+ : '';
13
+ }
14
+ function availabilityState(item) {
15
+ return item.ok ? boundedText(record(record(item.result).availability).state, 40).toLowerCase() : '';
16
+ }
17
+ function selectBulkItems(items, filter) {
18
+ if (filter === 'all')
19
+ return [...items];
20
+ return items.filter((item) => {
21
+ if (!item.ok)
22
+ return filter === 'inconclusive';
23
+ const state = availabilityState(item);
24
+ return filter === 'registered' ? REGISTERED_STATES.has(state) : state === 'unknown';
25
+ });
26
+ }
27
+ function stringValues(value) {
28
+ if (!Array.isArray(value))
29
+ return [];
30
+ const output = new Set();
31
+ for (const candidate of value.slice(0, MAX_DNS_VALUES_PER_TYPE * 2)) {
32
+ const normalized = boundedText(candidate, 500);
33
+ if (normalized)
34
+ output.add(normalized);
35
+ if (output.size >= MAX_DNS_VALUES_PER_TYPE)
36
+ break;
37
+ }
38
+ return [...output];
39
+ }
40
+ function mxValues(value) {
41
+ if (!Array.isArray(value))
42
+ return [];
43
+ const output = new Set();
44
+ for (const candidate of value.slice(0, MAX_DNS_VALUES_PER_TYPE * 2)) {
45
+ const item = record(candidate);
46
+ const exchange = boundedText(item.exchange ?? item.host ?? candidate, 253).replace(/\.$/u, '').toLowerCase();
47
+ const priority = Number.isInteger(item.priority) ? Number(item.priority) : null;
48
+ if (exchange)
49
+ output.add(priority === null ? exchange : `${priority} ${exchange}`);
50
+ if (output.size >= MAX_DNS_VALUES_PER_TYPE)
51
+ break;
52
+ }
53
+ return [...output];
54
+ }
55
+ function bulkDnsSummary(item) {
56
+ if (!item.ok) {
57
+ return {
58
+ status: 'unavailable', a: [], aaaa: [], ns: [], mx: [],
59
+ hasNullMx: null, hasSpf: null, hasDmarc: null,
60
+ };
61
+ }
62
+ const availability = record(record(item.result).availability);
63
+ const dns = record(availability.dns);
64
+ const records = record(dns.records);
65
+ return {
66
+ status: boundedText(dns.status, 40) || 'unavailable',
67
+ a: stringValues(records.a),
68
+ aaaa: stringValues(records.aaaa),
69
+ ns: stringValues(records.ns),
70
+ mx: mxValues(records.mx),
71
+ hasNullMx: typeof availability.hasNullMx === 'boolean' ? availability.hasNullMx : null,
72
+ hasSpf: typeof availability.hasSpf === 'boolean' ? availability.hasSpf : null,
73
+ hasDmarc: typeof availability.hasDmarc === 'boolean' ? availability.hasDmarc : null,
74
+ };
75
+ }
76
+ function booleanCell(value) {
77
+ return value === null ? 'unknown' : value ? 'observed' : 'not_observed';
78
+ }
79
+ function formatBulkCsv(items) {
80
+ const header = [
81
+ 'query', 'domain', 'outcome', 'availability', 'confidence', 'dns_status',
82
+ 'a', 'aaaa', 'ns', 'mx', 'null_mx', 'spf', 'dmarc', 'error',
83
+ ];
84
+ const rows = items.map((item) => {
85
+ const availability = item.ok ? record(record(item.result).availability) : {};
86
+ const dns = bulkDnsSummary(item);
87
+ return [
88
+ item.query,
89
+ item.ok && item.classified.type === 'domain' ? item.classified.registrableDomain || item.classified.value : '',
90
+ item.ok ? 'complete' : 'error',
91
+ item.ok ? boundedText(availability.state, 40) || 'unknown' : 'unknown',
92
+ item.ok ? boundedText(availability.confidence, 40) || 'unknown' : 'unknown',
93
+ dns.status,
94
+ dns.a,
95
+ dns.aaaa,
96
+ dns.ns,
97
+ dns.mx,
98
+ booleanCell(dns.hasNullMx),
99
+ booleanCell(dns.hasSpf),
100
+ booleanCell(dns.hasDmarc),
101
+ item.ok ? '' : item.error,
102
+ ].map(cliCsvCell).join(',');
103
+ });
104
+ return `${[header.join(','), ...rows].join('\n')}\n`;
105
+ }
106
+ function formatBulkDomainList(items) {
107
+ const values = items.flatMap((item) => item.ok && item.classified.type === 'domain'
108
+ ? [item.classified.registrableDomain || item.classified.value]
109
+ : []);
110
+ return values.length ? `${[...new Set(values)].join('\n')}\n` : '';
111
+ }
112
+ export { REGISTERED_STATES, availabilityState, bulkDnsSummary, formatBulkCsv, formatBulkDomainList, selectBulkItems, };