@slicedearth/whoisleuth-cli 1.32.2 → 1.34.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.
- package/README.md +41 -5
- package/SECURITY.md +35 -0
- package/cli/arguments.mjs +88 -8
- package/cli/artifact-verify.mjs +28 -0
- package/cli/bulk-command-runner.mjs +3 -1
- package/cli/bulk-output.mjs +6 -1
- package/cli/command-catalogue.mjs +33 -0
- package/cli/completion.mjs +51 -7
- package/cli/doctor.mjs +78 -43
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/terminal.mjs +6 -6
- package/cli/lookup-command-runner.mjs +8 -0
- package/cli/lookup-diff.mjs +16 -5
- package/cli/lookup-plan.mjs +99 -0
- package/cli/lookup-timeline.mjs +123 -0
- package/cli/manual.mjs +2 -1
- package/cli/runner.mjs +105 -10
- package/docs/cli.md +100 -38
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
- package/lib/csv.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +3 -3
- package/package.json +6 -4
- package/third-party-notices.txt +2 -2
package/README.md
CHANGED
|
@@ -11,19 +11,35 @@ WHOISleuth requires Node.js 24 or later. Run the reviewed public package without
|
|
|
11
11
|
a global installation:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
npm exec --yes --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
|
|
14
|
+
npm exec --yes --ignore-scripts --package=@slicedearth/whoisleuth-cli -- whoisleuth --help
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
Or install the `whoisleuth` command globally:
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
npm install --global @slicedearth/whoisleuth-cli
|
|
20
|
+
npm install --global --ignore-scripts @slicedearth/whoisleuth-cli
|
|
21
21
|
whoisleuth doctor
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
+
Update an existing global installation and confirm the installed release with:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install --global --ignore-scripts @slicedearth/whoisleuth-cli@latest
|
|
28
|
+
whoisleuth --version
|
|
29
|
+
```
|
|
30
|
+
|
|
24
31
|
The package version follows the application version. Each release is assembled
|
|
25
32
|
from the executable's bounded dependency closure and retains the project
|
|
26
33
|
licence, notices, trademark terms, source location, and provenance metadata.
|
|
34
|
+
The generated package manifest pins each direct runtime dependency to the exact
|
|
35
|
+
version exercised by the reviewed repository lockfile.
|
|
36
|
+
|
|
37
|
+
The package has no install, postinstall, or other lifecycle scripts. Network
|
|
38
|
+
access begins only when an analyst runs a networked command. Offline commands
|
|
39
|
+
may read deliberately selected local evidence or configuration files, but do
|
|
40
|
+
not upload them or invoke a hosted WHOISleuth deployment. The installation
|
|
41
|
+
examples disable lifecycle scripts for the package and its dependency tree;
|
|
42
|
+
WHOISleuth does not require them.
|
|
27
43
|
|
|
28
44
|
## Repository checkout
|
|
29
45
|
|
|
@@ -33,17 +49,22 @@ Contributors can run the same source command without installing the package:
|
|
|
33
49
|
node bin/whoisleuth.mts --help
|
|
34
50
|
node bin/whoisleuth.mts lookup example.test
|
|
35
51
|
node bin/whoisleuth.mts lookup example.test --deep --summary
|
|
52
|
+
node bin/whoisleuth.mts lookup example.test --deep --plan --json
|
|
36
53
|
node bin/whoisleuth.mts lookup example.test --deep --markdown --output lookup.md
|
|
37
54
|
node bin/whoisleuth.mts bulk domains.txt --deep --checkpoint bulk-checkpoint.json
|
|
38
55
|
node bin/whoisleuth.mts bulk domains.txt --csv --registered-only
|
|
56
|
+
node bin/whoisleuth.mts bulk domains.txt --queries --errors-only
|
|
39
57
|
node bin/whoisleuth.mts discover example.test --dictionary private-terms.txt --snapshot discovery-state.json --json
|
|
40
58
|
node bin/whoisleuth.mts discover-scan example.test --scan-limit 50 --checkpoint candidate-scan.json --json
|
|
41
59
|
node bin/whoisleuth.mts page-compare official.json candidate.json --json
|
|
42
60
|
node bin/whoisleuth.mts mail-review bulk.json --json
|
|
43
61
|
node bin/whoisleuth.mts diff first-lookup.json second-lookup.json --json
|
|
62
|
+
node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
|
|
44
63
|
node bin/whoisleuth.mts registry-support example.test --json
|
|
45
64
|
node bin/whoisleuth.mts doctor
|
|
65
|
+
node bin/whoisleuth.mts commands --json
|
|
46
66
|
node bin/whoisleuth.mts completion zsh
|
|
67
|
+
node bin/whoisleuth.mts completion powershell
|
|
47
68
|
node bin/whoisleuth.mts manual | man -l -
|
|
48
69
|
```
|
|
49
70
|
|
|
@@ -51,6 +72,13 @@ Fast lookup is the default. Deep collection must be requested explicitly and
|
|
|
51
72
|
can disclose the target to additional authoritative or first-party network
|
|
52
73
|
sources. Missing, partial, rate-limited, and unsupported sources remain
|
|
53
74
|
explicit and are never interpreted as evidence of safety or absence.
|
|
75
|
+
`lookup --plan` classifies a target and explains the selected source families
|
|
76
|
+
and disclosures without making a network request. `commands --json` exposes the
|
|
77
|
+
installed command contracts for local wrappers without running collection.
|
|
78
|
+
Focused command help and the generated manual label every operation as offline
|
|
79
|
+
or networked and state its target, input, and concurrency boundaries. The
|
|
80
|
+
packaged [dual-use disclosure](DISCLOSURE) defines the supported defensive use
|
|
81
|
+
of those capabilities.
|
|
54
82
|
|
|
55
83
|
Interactive output uses restrained semantic colour, width-aware wrapping, and
|
|
56
84
|
stderr-only progress for slower collection. Redirected and machine-readable
|
|
@@ -65,8 +93,15 @@ the partial-result exit code. Ctrl-C suppresses partial final output and
|
|
|
65
93
|
returns exit code 130; a second interrupt performs best-effort temporary-file
|
|
66
94
|
cleanup before exiting immediately.
|
|
67
95
|
|
|
68
|
-
|
|
69
|
-
|
|
96
|
+
The offline `timeline` command orders 2 to 20 saved Lookup documents for one
|
|
97
|
+
domain and compares each adjacent observation. It retains observation times and
|
|
98
|
+
normalized field states but no input filenames or raw registry payloads.
|
|
99
|
+
Observed differences remain separate from missing, unavailable, and changed
|
|
100
|
+
collection conditions.
|
|
101
|
+
|
|
102
|
+
Bulk also supports fixed-column CSV, domain-only or exact-query output,
|
|
103
|
+
registered, inconclusive, and hard-failure output filters, and bounded A, AAAA,
|
|
104
|
+
NS, and MX summaries. Discover
|
|
70
105
|
supports local custom dictionaries and private snapshot comparison suitable
|
|
71
106
|
for an operator-managed scheduler. The separate networked `discover-scan`
|
|
72
107
|
command can collect a deterministic bounded subset in chunks, retain mutation
|
|
@@ -79,6 +114,7 @@ favicon, technology, and TLS evidence, while saved Bulk output can be reviewed
|
|
|
79
114
|
for passive MX, null MX, SPF, DMARC, and shared-provider context.
|
|
80
115
|
|
|
81
116
|
See `docs/cli.md` in the package for the complete command, privacy, output, and
|
|
82
|
-
evidence-contract reference.
|
|
117
|
+
evidence-contract reference. Report suspected vulnerabilities through the
|
|
118
|
+
private channel described in `SECURITY.md`.
|
|
83
119
|
|
|
84
120
|
Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are applied to the latest released WHOISleuth application and
|
|
6
|
+
CLI version. Older releases are not maintained as separate security branches.
|
|
7
|
+
Upgrade to the latest release before reporting a problem that may already have
|
|
8
|
+
been corrected.
|
|
9
|
+
|
|
10
|
+
## Report a vulnerability privately
|
|
11
|
+
|
|
12
|
+
Use [GitHub private vulnerability reporting](https://github.com/slicedearth/whoisleuth/security/advisories/new)
|
|
13
|
+
for suspected vulnerabilities in the application, CLI, release process, or
|
|
14
|
+
deployment defaults. Do not open a public issue for an undisclosed security
|
|
15
|
+
problem.
|
|
16
|
+
|
|
17
|
+
Include the affected version, the smallest reproducible example, expected and
|
|
18
|
+
observed behaviour, and the likely impact. Use reserved or synthetic domains
|
|
19
|
+
and remove credentials, session material, personal information, private case
|
|
20
|
+
data, and raw registration records before submitting evidence.
|
|
21
|
+
|
|
22
|
+
Ordinary defects, feature requests, and questions that do not expose a security
|
|
23
|
+
weakness can use the public issue tracker.
|
|
24
|
+
|
|
25
|
+
## Scope and response
|
|
26
|
+
|
|
27
|
+
Reports are assessed against WHOISleuth's documented trust boundaries,
|
|
28
|
+
including its local-first workspace, bounded network collection, shared
|
|
29
|
+
deployment password, and optional hosted monitoring. A source outage, partial
|
|
30
|
+
registry response, or heuristic disagreement is not by itself a security
|
|
31
|
+
vulnerability, but unsafe handling of those states may be.
|
|
32
|
+
|
|
33
|
+
Confirmed reports will be handled through a private advisory until a fix and
|
|
34
|
+
coordinated disclosure are ready. No bounty or guaranteed response time is
|
|
35
|
+
offered.
|
package/cli/arguments.mjs
CHANGED
|
@@ -5,6 +5,7 @@ const MAX_CLI_ARGUMENT_LENGTH = 1024;
|
|
|
5
5
|
const CLI_COMMANDS = [
|
|
6
6
|
'completion',
|
|
7
7
|
'doctor',
|
|
8
|
+
'commands',
|
|
8
9
|
'manual',
|
|
9
10
|
'lookup',
|
|
10
11
|
'bulk',
|
|
@@ -25,6 +26,7 @@ const CLI_COMMANDS = [
|
|
|
25
26
|
'page-compare',
|
|
26
27
|
'mail-review',
|
|
27
28
|
'diff',
|
|
29
|
+
'timeline',
|
|
28
30
|
'export',
|
|
29
31
|
];
|
|
30
32
|
function boundedArgument(value) {
|
|
@@ -108,6 +110,8 @@ function parseCliArgumentsCore(argv) {
|
|
|
108
110
|
return parseCompletionArguments(argv.slice(1));
|
|
109
111
|
if (command === 'doctor')
|
|
110
112
|
return parseDoctorArguments(argv.slice(1));
|
|
113
|
+
if (command === 'commands')
|
|
114
|
+
return parseCommandsArguments(argv.slice(1));
|
|
111
115
|
if (command === 'manual')
|
|
112
116
|
return parseManualArguments(argv.slice(1));
|
|
113
117
|
if (command === 'ct-search')
|
|
@@ -144,6 +148,8 @@ function parseCliArgumentsCore(argv) {
|
|
|
144
148
|
return parseMailReviewArguments(argv.slice(1));
|
|
145
149
|
if (command === 'diff')
|
|
146
150
|
return parseDiffArguments(argv.slice(1));
|
|
151
|
+
if (command === 'timeline')
|
|
152
|
+
return parseTimelineArguments(argv.slice(1));
|
|
147
153
|
if (command === 'export')
|
|
148
154
|
return parseExportArguments(argv.slice(1));
|
|
149
155
|
let query = null;
|
|
@@ -156,6 +162,7 @@ function parseCliArgumentsCore(argv) {
|
|
|
156
162
|
let detailSet = false;
|
|
157
163
|
let strictExit = false;
|
|
158
164
|
let events = false;
|
|
165
|
+
let plan = false;
|
|
159
166
|
for (const argument of argv.slice(1)) {
|
|
160
167
|
if (argument === '--json') {
|
|
161
168
|
if (output !== 'terminal')
|
|
@@ -195,6 +202,11 @@ function parseCliArgumentsCore(argv) {
|
|
|
195
202
|
throw new CliUsageError('--events may be supplied only once.');
|
|
196
203
|
events = true;
|
|
197
204
|
}
|
|
205
|
+
else if (argument === '--plan') {
|
|
206
|
+
if (plan)
|
|
207
|
+
throw new CliUsageError('--plan may be supplied only once.');
|
|
208
|
+
plan = true;
|
|
209
|
+
}
|
|
198
210
|
else if (argument === '--quiet')
|
|
199
211
|
quiet = true;
|
|
200
212
|
else if (argument === '--no-color')
|
|
@@ -210,11 +222,16 @@ function parseCliArgumentsCore(argv) {
|
|
|
210
222
|
throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
|
|
211
223
|
if (detailSet && output !== 'terminal')
|
|
212
224
|
throw new CliUsageError('--summary and --verbose apply only to terminal output.');
|
|
213
|
-
|
|
225
|
+
if (plan && (output === 'markdown' || output === 'html'))
|
|
226
|
+
throw new CliUsageError('--plan supports terminal or JSON output only.');
|
|
227
|
+
if (plan && (detailSet || strictExit || events || quiet)) {
|
|
228
|
+
throw new CliUsageError('--plan cannot be combined with detail, strict-exit, event, or quiet options.');
|
|
229
|
+
}
|
|
230
|
+
return { action: 'lookup', query, output, deep, detail, strictExit, events, plan, quiet, color };
|
|
214
231
|
}
|
|
215
232
|
function parseCompletionArguments(argv) {
|
|
216
|
-
if (argv.length !== 1 || !['bash', 'zsh', 'fish'].includes(argv[0] || '')) {
|
|
217
|
-
throw new CliUsageError('completion requires exactly one shell: bash, zsh, or
|
|
233
|
+
if (argv.length !== 1 || !['bash', 'zsh', 'fish', 'powershell'].includes(argv[0] || '')) {
|
|
234
|
+
throw new CliUsageError('completion requires exactly one shell: bash, zsh, fish, or powershell.');
|
|
218
235
|
}
|
|
219
236
|
return { action: 'completion', shell: argv[0] };
|
|
220
237
|
}
|
|
@@ -223,6 +240,27 @@ function parseManualArguments(argv) {
|
|
|
223
240
|
throw new CliUsageError('manual does not accept command arguments.');
|
|
224
241
|
return { action: 'manual' };
|
|
225
242
|
}
|
|
243
|
+
function parseCommandsArguments(argv) {
|
|
244
|
+
let output = 'terminal';
|
|
245
|
+
let quiet = false;
|
|
246
|
+
let color = true;
|
|
247
|
+
for (const argument of argv) {
|
|
248
|
+
if (argument === '--json') {
|
|
249
|
+
if (output !== 'terminal')
|
|
250
|
+
throw new CliUsageError('--json may be supplied only once.');
|
|
251
|
+
output = 'json';
|
|
252
|
+
}
|
|
253
|
+
else if (argument === '--quiet')
|
|
254
|
+
quiet = true;
|
|
255
|
+
else if (argument === '--no-color')
|
|
256
|
+
color = false;
|
|
257
|
+
else
|
|
258
|
+
throw new CliUsageError(`Unknown option "${argument}".`);
|
|
259
|
+
}
|
|
260
|
+
if (quiet && output !== 'terminal')
|
|
261
|
+
throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
|
|
262
|
+
return { action: 'commands', output, quiet, color };
|
|
263
|
+
}
|
|
226
264
|
function parseDoctorArguments(argv) {
|
|
227
265
|
let network = false;
|
|
228
266
|
let output = 'terminal';
|
|
@@ -266,10 +304,18 @@ function parseBulkArguments(argv) {
|
|
|
266
304
|
const argument = argv[index];
|
|
267
305
|
if (argument === undefined)
|
|
268
306
|
break;
|
|
269
|
-
if (argument === '--json' || argument === '--jsonl' || argument === '--csv' || argument === '--domains') {
|
|
307
|
+
if (argument === '--json' || argument === '--jsonl' || argument === '--csv' || argument === '--domains' || argument === '--queries') {
|
|
270
308
|
if (output !== 'terminal')
|
|
271
309
|
throw new CliUsageError('Choose only one output format.');
|
|
272
|
-
output = argument === '--json'
|
|
310
|
+
output = argument === '--json'
|
|
311
|
+
? 'json'
|
|
312
|
+
: argument === '--jsonl'
|
|
313
|
+
? 'jsonl'
|
|
314
|
+
: argument === '--csv'
|
|
315
|
+
? 'csv'
|
|
316
|
+
: argument === '--domains'
|
|
317
|
+
? 'domains'
|
|
318
|
+
: 'queries';
|
|
273
319
|
}
|
|
274
320
|
else if (argument === '--deep' || argument === '--fast') {
|
|
275
321
|
if (scanMode)
|
|
@@ -305,10 +351,14 @@ function parseBulkArguments(argv) {
|
|
|
305
351
|
throw new CliUsageError('--events may be supplied only once.');
|
|
306
352
|
events = true;
|
|
307
353
|
}
|
|
308
|
-
else if (argument === '--registered-only' || argument === '--inconclusive-only') {
|
|
354
|
+
else if (argument === '--registered-only' || argument === '--inconclusive-only' || argument === '--errors-only') {
|
|
309
355
|
if (filter !== 'all')
|
|
310
|
-
throw new CliUsageError('
|
|
311
|
-
filter = argument === '--registered-only'
|
|
356
|
+
throw new CliUsageError('Bulk output filters are mutually exclusive and may be supplied only once.');
|
|
357
|
+
filter = argument === '--registered-only'
|
|
358
|
+
? 'registered'
|
|
359
|
+
: argument === '--inconclusive-only'
|
|
360
|
+
? 'inconclusive'
|
|
361
|
+
: 'errors';
|
|
312
362
|
}
|
|
313
363
|
else if (argument === '--quiet')
|
|
314
364
|
quiet = true;
|
|
@@ -876,6 +926,36 @@ function parseDiffArguments(argv) {
|
|
|
876
926
|
throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
|
|
877
927
|
return { action: 'diff', leftSource, rightSource, output, quiet, color };
|
|
878
928
|
}
|
|
929
|
+
function parseTimelineArguments(argv) {
|
|
930
|
+
const sources = [];
|
|
931
|
+
let output = 'terminal';
|
|
932
|
+
let quiet = false;
|
|
933
|
+
let color = true;
|
|
934
|
+
for (const argument of argv) {
|
|
935
|
+
if (argument === '--json') {
|
|
936
|
+
if (output !== 'terminal')
|
|
937
|
+
throw new CliUsageError('--json may be supplied only once.');
|
|
938
|
+
output = 'json';
|
|
939
|
+
}
|
|
940
|
+
else if (argument === '--quiet')
|
|
941
|
+
quiet = true;
|
|
942
|
+
else if (argument === '--no-color')
|
|
943
|
+
color = false;
|
|
944
|
+
else if (argument.startsWith('-'))
|
|
945
|
+
throw new CliUsageError(`Unknown option "${argument}".`);
|
|
946
|
+
else
|
|
947
|
+
sources.push(argument);
|
|
948
|
+
}
|
|
949
|
+
if (sources.length < 2 || sources.length > 20) {
|
|
950
|
+
throw new CliUsageError('timeline requires from 2 to 20 saved lookup JSON files.');
|
|
951
|
+
}
|
|
952
|
+
if (new Set(sources).size !== sources.length) {
|
|
953
|
+
throw new CliUsageError('timeline input files must be different.');
|
|
954
|
+
}
|
|
955
|
+
if (quiet && output !== 'terminal')
|
|
956
|
+
throw new CliUsageError('--quiet cannot be combined with machine-readable output.');
|
|
957
|
+
return { action: 'timeline', sources, output, quiet, color };
|
|
958
|
+
}
|
|
879
959
|
function parseRegistrySupportArguments(argv) {
|
|
880
960
|
let target = null;
|
|
881
961
|
let output = 'terminal';
|
package/cli/artifact-verify.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../f
|
|
|
7
7
|
import { decryptWorkspaceArchive, inspectEncryptedWorkspaceArchive, isEncryptedWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive-crypto.js";
|
|
8
8
|
import { WORKSPACE_ARCHIVE_SCHEMA, readWorkspaceArchive, } from "../frontend/src/lib/analysis/workspace-archive.js";
|
|
9
9
|
import { sha256ArtifactDigest } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
10
|
+
import { SAVED_LOOKUP_SCHEMA, SAVED_LOOKUP_SCHEMA_VERSION, parseSavedLookupDocument, } from "./saved-lookup.mjs";
|
|
10
11
|
export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
|
|
11
12
|
export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 1;
|
|
12
13
|
export const MAX_OFFLINE_ARTIFACT_BYTES = 15 * 1024 * 1024;
|
|
@@ -186,6 +187,33 @@ export async function verifyOfflineArtifact(raw, options = {}) {
|
|
|
186
187
|
]),
|
|
187
188
|
});
|
|
188
189
|
}
|
|
190
|
+
if (schema === SAVED_LOOKUP_SCHEMA) {
|
|
191
|
+
if (version !== SAVED_LOOKUP_SCHEMA_VERSION) {
|
|
192
|
+
throw new TypeError('This saved Lookup document version is not supported.');
|
|
193
|
+
}
|
|
194
|
+
const document = parseSavedLookupDocument(raw, { label: 'Saved Lookup artifact' });
|
|
195
|
+
return Object.freeze({
|
|
196
|
+
schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
|
|
197
|
+
version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
|
|
198
|
+
artifact: Object.freeze({ kind: 'saved_lookup', schema, version }),
|
|
199
|
+
state: 'structure_valid',
|
|
200
|
+
valid: true,
|
|
201
|
+
checks: Object.freeze({
|
|
202
|
+
structure: 'verified',
|
|
203
|
+
contentIntegrity: 'not_checked',
|
|
204
|
+
authenticatedEncryption: 'not_applicable',
|
|
205
|
+
}),
|
|
206
|
+
summary: Object.freeze({
|
|
207
|
+
inputBytes: inputBytes(raw),
|
|
208
|
+
sectionCount: null,
|
|
209
|
+
recordCount: null,
|
|
210
|
+
ciphertextBytes: null,
|
|
211
|
+
}),
|
|
212
|
+
limitations: Object.freeze([
|
|
213
|
+
`The saved ${document.mode} Lookup matches its versioned structural contract, but this document format has no embedded checksum or signature. Structural validity does not prove that its evidence is accurate, current, or unchanged since collection.`,
|
|
214
|
+
]),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
189
217
|
return verifySignedArtifact(raw, value, schema, version);
|
|
190
218
|
}
|
|
191
219
|
export function formatOfflineArtifactVerification(report) {
|
|
@@ -3,7 +3,7 @@ import { classifyQuery } from "../lib/classify.mjs";
|
|
|
3
3
|
import { runUnifiedLookup } from "../lib/lookup.mjs";
|
|
4
4
|
import { createBulkCheckpointWriter } from "./bulk-checkpoint.mjs";
|
|
5
5
|
import { MAX_BULK_INPUT_BYTES, parseBulkQueries, readTextStreamBounded, runBulkLookups, } from "./bulk.mjs";
|
|
6
|
-
import { formatBulkCsv, formatBulkDomainList, selectBulkItems } from "./bulk-output.mjs";
|
|
6
|
+
import { formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems } from "./bulk-output.mjs";
|
|
7
7
|
import { boundedCliErrorMessage, CliUsageError } from "./errors.mjs";
|
|
8
8
|
import EXIT_CODES from "./exit-codes.mjs";
|
|
9
9
|
import { buildCliBulkDocument, formatJsonDocument, formatJsonLines } from "./formatters/json.mjs";
|
|
@@ -91,6 +91,8 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
91
91
|
context.writeStdout(formatBulkCsv(selectedItems));
|
|
92
92
|
else if (args.output === 'domains')
|
|
93
93
|
context.writeStdout(formatBulkDomainList(selectedItems));
|
|
94
|
+
else if (args.output === 'queries')
|
|
95
|
+
context.writeStdout(formatBulkQueryList(selectedItems));
|
|
94
96
|
else
|
|
95
97
|
context.writeStdout(context.terminal(formatTerminalBulk(selectedItems, metadata), args.color));
|
|
96
98
|
}
|
package/cli/bulk-output.mjs
CHANGED
|
@@ -18,6 +18,8 @@ function selectBulkItems(items, filter) {
|
|
|
18
18
|
if (filter === 'all')
|
|
19
19
|
return [...items];
|
|
20
20
|
return items.filter((item) => {
|
|
21
|
+
if (filter === 'errors')
|
|
22
|
+
return !item.ok;
|
|
21
23
|
if (!item.ok)
|
|
22
24
|
return filter === 'inconclusive';
|
|
23
25
|
const state = availabilityState(item);
|
|
@@ -109,4 +111,7 @@ function formatBulkDomainList(items) {
|
|
|
109
111
|
: []);
|
|
110
112
|
return values.length ? `${[...new Set(values)].join('\n')}\n` : '';
|
|
111
113
|
}
|
|
112
|
-
|
|
114
|
+
function formatBulkQueryList(items) {
|
|
115
|
+
return items.length ? `${items.map((item) => item.query).join('\n')}\n` : '';
|
|
116
|
+
}
|
|
117
|
+
export { REGISTERED_STATES, availabilityState, bulkDnsSummary, formatBulkCsv, formatBulkDomainList, formatBulkQueryList, selectBulkItems, };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export const CLI_COMMAND_CATALOGUE_SCHEMA = 'whoisleuth.cli.command-catalogue';
|
|
2
|
+
export const CLI_COMMAND_CATALOGUE_VERSION = 1;
|
|
3
|
+
function buildCliCommandCatalogue(options) {
|
|
4
|
+
return Object.freeze({
|
|
5
|
+
schema: CLI_COMMAND_CATALOGUE_SCHEMA,
|
|
6
|
+
version: CLI_COMMAND_CATALOGUE_VERSION,
|
|
7
|
+
packageVersion: options.packageVersion,
|
|
8
|
+
commands: Object.freeze(options.commands.map((command) => {
|
|
9
|
+
const detail = options.details[command];
|
|
10
|
+
return Object.freeze({
|
|
11
|
+
command,
|
|
12
|
+
description: detail.description,
|
|
13
|
+
usage: options.usage[command],
|
|
14
|
+
example: detail.example,
|
|
15
|
+
collection: options.collections[command],
|
|
16
|
+
boundary: detail.boundary,
|
|
17
|
+
});
|
|
18
|
+
})),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function formatCliCommandCatalogue(catalogue) {
|
|
22
|
+
const lines = [
|
|
23
|
+
`WHOISleuth CLI ${catalogue.packageVersion}`,
|
|
24
|
+
'Command catalogue',
|
|
25
|
+
'',
|
|
26
|
+
];
|
|
27
|
+
for (const entry of catalogue.commands) {
|
|
28
|
+
lines.push(`${entry.command} [${entry.collection.mode}]`, ` ${entry.description}`, ` ${entry.usage}`);
|
|
29
|
+
}
|
|
30
|
+
lines.push('', 'Run "whoisleuth <command> --help" for examples and collection boundaries.');
|
|
31
|
+
return `${lines.join('\n')}\n`;
|
|
32
|
+
}
|
|
33
|
+
export { buildCliCommandCatalogue, formatCliCommandCatalogue };
|
package/cli/completion.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { CLI_COMMANDS } from "./arguments.mjs";
|
|
2
2
|
const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force']);
|
|
3
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'],
|
|
4
|
+
lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--plan', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
|
|
5
|
+
bulk: ['--json', '--jsonl', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
|
|
6
6
|
'ct-search': ['--json', '--quiet', '--no-color'],
|
|
7
7
|
discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
|
|
8
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'],
|
|
@@ -20,8 +20,10 @@ const OPTIONS_BY_COMMAND = Object.freeze({
|
|
|
20
20
|
'page-compare': ['--json', '--quiet', '--no-color'],
|
|
21
21
|
'mail-review': ['--json', '--quiet', '--no-color'],
|
|
22
22
|
diff: ['--json', '--quiet', '--no-color'],
|
|
23
|
+
timeline: ['--json', '--quiet', '--no-color'],
|
|
23
24
|
export: ['--markdown', '--html', '--compact'],
|
|
24
25
|
completion: [],
|
|
26
|
+
commands: ['--json', '--quiet', '--no-color'],
|
|
25
27
|
doctor: ['--network', '--json', '--quiet', '--no-color'],
|
|
26
28
|
manual: [],
|
|
27
29
|
});
|
|
@@ -36,7 +38,7 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
|
|
|
36
38
|
tls: 'Inspect one TLS connection',
|
|
37
39
|
'registry-support': 'Explain local registry coverage',
|
|
38
40
|
'risk-calibrate': 'Replay reviewed Risk labels offline',
|
|
39
|
-
'verify-artifact': 'Validate
|
|
41
|
+
'verify-artifact': 'Validate saved evidence offline',
|
|
40
42
|
'inspect-archive': 'Inspect an archive locally',
|
|
41
43
|
'sign-artifact': 'Sign a reviewed artifact locally',
|
|
42
44
|
'verify-signature': 'Verify a signed evidence package',
|
|
@@ -45,8 +47,10 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
|
|
|
45
47
|
'page-compare': 'Compare saved static page evidence',
|
|
46
48
|
'mail-review': 'Review saved passive mail evidence',
|
|
47
49
|
diff: 'Compare two saved domain lookups',
|
|
50
|
+
timeline: 'Build same-domain history from saved lookups',
|
|
48
51
|
export: 'Convert a lookup to an evidence report',
|
|
49
52
|
completion: 'Print shell completion',
|
|
53
|
+
commands: 'List installed command contracts',
|
|
50
54
|
doctor: 'Check the local CLI runtime',
|
|
51
55
|
manual: 'Print the generated manual page',
|
|
52
56
|
});
|
|
@@ -92,7 +96,7 @@ _whoisleuth_completion() {
|
|
|
92
96
|
return
|
|
93
97
|
fi
|
|
94
98
|
if [[ "\${command}" == "completion" && \${COMP_CWORD} -eq 2 ]]; then
|
|
95
|
-
COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${current}") )
|
|
99
|
+
COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "\${current}") )
|
|
96
100
|
return
|
|
97
101
|
fi
|
|
98
102
|
case "\${previous}" in
|
|
@@ -125,7 +129,7 @@ _whoisleuth() {
|
|
|
125
129
|
command="\${words[2]}"
|
|
126
130
|
previous="\${words[CURRENT-1]}"
|
|
127
131
|
if [[ "\${command}" == "completion" && CURRENT -eq 3 ]]; then
|
|
128
|
-
compadd -- bash zsh fish
|
|
132
|
+
compadd -- bash zsh fish powershell
|
|
129
133
|
return
|
|
130
134
|
fi
|
|
131
135
|
case "\${previous}" in
|
|
@@ -160,16 +164,56 @@ complete -c whoisleuth -f
|
|
|
160
164
|
complete -c whoisleuth -n '__fish_use_subcommand' -l help
|
|
161
165
|
complete -c whoisleuth -n '__fish_use_subcommand' -l version
|
|
162
166
|
${commandLines.join('\n')}
|
|
163
|
-
complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
|
|
167
|
+
complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell'
|
|
164
168
|
${optionLines.join('\n')}
|
|
165
169
|
${valueLines.join('\n')}
|
|
166
170
|
`;
|
|
167
171
|
}
|
|
172
|
+
function powershellCompletion() {
|
|
173
|
+
const commandOptions = Object.fromEntries(CLI_COMMANDS.map((command) => [
|
|
174
|
+
command,
|
|
175
|
+
[...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])],
|
|
176
|
+
]));
|
|
177
|
+
return `# WHOISleuth PowerShell completion
|
|
178
|
+
Register-ArgumentCompleter -Native -CommandName whoisleuth -ScriptBlock {
|
|
179
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
180
|
+
$commands = @(${CLI_COMMANDS.map((command) => `'${command}'`).join(', ')})
|
|
181
|
+
$options = @{
|
|
182
|
+
${Object.entries(commandOptions).map(([command, options]) => ` '${command}' = @(${options.map((option) => `'${option}'`).join(', ')})`).join('\n')}
|
|
183
|
+
}
|
|
184
|
+
$values = @{
|
|
185
|
+
${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @(${values.map((value) => `'${value}'`).join(', ')})`).join('\n')}
|
|
186
|
+
'completion' = @('bash', 'zsh', 'fish', 'powershell')
|
|
187
|
+
}
|
|
188
|
+
$elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
|
|
189
|
+
$command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
|
|
190
|
+
$previous = if ($elements.Count -gt 1) { $elements[$elements.Count - 1] } else { '' }
|
|
191
|
+
$candidates = if ($elements.Count -le 2) {
|
|
192
|
+
@($commands) + @('--help', '--version')
|
|
193
|
+
} elseif ($command -eq 'completion' -and $elements.Count -le 3) {
|
|
194
|
+
$values['completion']
|
|
195
|
+
} elseif ($values.ContainsKey($previous)) {
|
|
196
|
+
$values[$previous]
|
|
197
|
+
} elseif ($options.ContainsKey($command)) {
|
|
198
|
+
$options[$command]
|
|
199
|
+
} else {
|
|
200
|
+
@('--help')
|
|
201
|
+
}
|
|
202
|
+
foreach ($candidate in $candidates) {
|
|
203
|
+
if ($candidate.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
204
|
+
[System.Management.Automation.CompletionResult]::new($candidate, $candidate, 'ParameterValue', $candidate)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
`;
|
|
209
|
+
}
|
|
168
210
|
function buildShellCompletion(shell) {
|
|
169
211
|
if (shell === 'bash')
|
|
170
212
|
return bashCompletion();
|
|
171
213
|
if (shell === 'zsh')
|
|
172
214
|
return zshCompletion();
|
|
173
|
-
|
|
215
|
+
if (shell === 'fish')
|
|
216
|
+
return fishCompletion();
|
|
217
|
+
return powershellCompletion();
|
|
174
218
|
}
|
|
175
219
|
export { buildShellCompletion, };
|