@slicedearth/whoisleuth-cli 1.43.0 → 1.45.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 +8 -4
- package/bin/whoisleuth.mjs +20 -9
- package/cli/bulk-checkpoint.mjs +65 -28
- package/cli/bulk-command-runner.mjs +15 -2
- package/cli/bulk.mjs +18 -7
- package/cli/completion.mjs +34 -3
- package/cli/config-profile.mjs +15 -12
- package/cli/discovery-observation-snapshot.mjs +8 -14
- package/cli/discovery-scan-command-runner.mjs +14 -1
- package/cli/discovery-snapshot.mjs +8 -15
- package/cli/errors.mjs +7 -1
- package/cli/formatters/json.mjs +12 -4
- package/cli/formatters/terminal.mjs +1 -1
- package/cli/http.mjs +2 -1
- package/cli/lookup-reconcile.mjs +1 -1
- package/cli/mail-review.mjs +5 -5
- package/cli/manual.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +4 -3
- package/cli/runner.mjs +20 -9
- package/docs/cli-reference.md +26 -7
- package/docs/cli.md +1 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
- package/frontend/src/lib/analysis/brand-profile-model.js +4 -2
- package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
- package/frontend/src/lib/analysis/case-record-core.js +1 -1
- package/frontend/src/lib/analysis/case-record-operations.js +1 -1
- package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
- package/frontend/src/lib/analysis/lookup-decision-support.js +2 -2
- package/frontend/src/lib/analysis/lookup-presentation.js +9 -15
- package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
- package/frontend/src/lib/analysis/utils.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
- package/frontend/src/lib/analysis/workspace-archive.js +3 -7
- package/lib/bounded-file.mjs +69 -0
- package/lib/bounded-json-response.mjs +157 -0
- package/lib/browser-library-profile.mjs +167 -38
- package/lib/lookup-cache.mjs +5 -1
- package/lib/rdap-transport.mjs +6 -6
- package/lib/safe-fetch.mjs +37 -32
- package/lib/threat-intelligence-runtime.mjs +1 -2
- package/lib/website-technology.mjs +2 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,8 +90,8 @@ and disclosures without making a network request. `commands --json` exposes the
|
|
|
90
90
|
installed command contracts for local wrappers without running collection.
|
|
91
91
|
Focused command help and the generated manual label every operation as offline
|
|
92
92
|
or networked and state its target, input, and concurrency boundaries. The
|
|
93
|
-
packaged [dual-use disclosure](DISCLOSURE)
|
|
94
|
-
of those capabilities.
|
|
93
|
+
packaged [dual-use disclosure](https://github.com/slicedearth/whoisleuth/blob/main/DISCLOSURE)
|
|
94
|
+
defines the supported defensive use of those capabilities.
|
|
95
95
|
|
|
96
96
|
Interactive output uses restrained semantic colour, width-aware wrapping, and
|
|
97
97
|
stderr-only progress for slower collection. Redirected and machine-readable
|
|
@@ -101,9 +101,13 @@ offline unless `--network` is explicitly supplied, and completion scripts are
|
|
|
101
101
|
printed without modifying shell configuration. Commands can use atomic private
|
|
102
102
|
`--output` files, Lookup can emit strict automation exits and target-free
|
|
103
103
|
versioned progress events, and Bulk can resume an exact validated compact
|
|
104
|
-
checkpoint.
|
|
104
|
+
checkpoint. Checkpoint schema 2 preserves each current row's observation time;
|
|
105
|
+
legacy schema-1 rows remain readable with unknown observation time rather than
|
|
106
|
+
being restamped on resume. Bulk machine output schema 3 distinguishes current
|
|
107
|
+
and resumed rows. A checkpoint write failure preserves completed output and returns
|
|
105
108
|
the partial-result exit code. Ctrl-C suppresses partial final output and
|
|
106
|
-
returns exit code 130
|
|
109
|
+
returns exit code 130. SIGTERM uses the same graceful cleanup path and returns
|
|
110
|
+
143; a second interrupt performs best-effort temporary-file
|
|
107
111
|
cleanup before exiting immediately.
|
|
108
112
|
Strict local profiles can supply only safe presentation, Fast-mode,
|
|
109
113
|
concurrency, and observer defaults; command-line options override them.
|
package/bin/whoisleuth.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
|
+
import EXIT_CODES from "../cli/exit-codes.mjs";
|
|
3
4
|
for (const stream of [process.stdout, process.stderr]) {
|
|
4
5
|
stream.on('error', (error) => {
|
|
5
6
|
if (error.code === 'EPIPE')
|
|
@@ -32,32 +33,42 @@ else {
|
|
|
32
33
|
}
|
|
33
34
|
catch (error) {
|
|
34
35
|
process.stderr.write(`Usage error: ${errorsModule.boundedCliErrorMessage(error, 'Invalid CLI profile')}\n`);
|
|
35
|
-
process.exitCode =
|
|
36
|
+
process.exitCode = EXIT_CODES.USAGE;
|
|
36
37
|
resolvedArgv = [];
|
|
37
38
|
}
|
|
38
39
|
const cancellation = new AbortController();
|
|
39
40
|
let interruptionCount = 0;
|
|
40
|
-
|
|
41
|
+
let requestedExitCode = 130;
|
|
42
|
+
const interrupt = (signal) => {
|
|
41
43
|
interruptionCount += 1;
|
|
42
|
-
if (interruptionCount === 1)
|
|
44
|
+
if (interruptionCount === 1) {
|
|
45
|
+
requestedExitCode = signal === 'SIGTERM' ? 143 : 130;
|
|
43
46
|
cancellation.abort(new DOMException('Aborted', 'AbortError'));
|
|
47
|
+
}
|
|
44
48
|
else {
|
|
45
49
|
outputModule.cleanupPendingOutputFilesSync();
|
|
46
|
-
process.exit(
|
|
50
|
+
process.exit(requestedExitCode);
|
|
47
51
|
}
|
|
48
52
|
};
|
|
49
|
-
|
|
50
|
-
const
|
|
53
|
+
const onSigint = () => interrupt('SIGINT');
|
|
54
|
+
const onSigterm = () => interrupt('SIGTERM');
|
|
55
|
+
process.on('SIGINT', onSigint);
|
|
56
|
+
process.on('SIGTERM', onSigterm);
|
|
57
|
+
const execution = resolvedArgv.length
|
|
58
|
+
? runnerModule.runCli(resolvedArgv, { signal: cancellation.signal })
|
|
59
|
+
: Promise.resolve(process.exitCode || EXIT_CODES.USAGE);
|
|
51
60
|
execution.then((code) => {
|
|
52
61
|
if (code === 130) {
|
|
53
|
-
process.removeListener('SIGINT',
|
|
54
|
-
process.
|
|
62
|
+
process.removeListener('SIGINT', onSigint);
|
|
63
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
64
|
+
process.exit(requestedExitCode);
|
|
55
65
|
}
|
|
56
66
|
process.exitCode = code;
|
|
57
67
|
}).catch((error) => {
|
|
58
68
|
process.stderr.write(`Internal CLI error: ${errorsModule.boundedCliErrorMessage(error)}\n`);
|
|
59
69
|
process.exitCode = 70;
|
|
60
70
|
}).finally(() => {
|
|
61
|
-
process.removeListener('SIGINT',
|
|
71
|
+
process.removeListener('SIGINT', onSigint);
|
|
72
|
+
process.removeListener('SIGTERM', onSigterm);
|
|
62
73
|
});
|
|
63
74
|
}
|
package/cli/bulk-checkpoint.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { open } from 'node:fs/promises';
|
|
3
3
|
import { recordOrNull } from "../lib/bounded-contract-normalizers.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
|
|
5
|
+
import { boundedCliInputError, CliUsageError } from "./errors.mjs";
|
|
5
6
|
import { writePrivateFile } from "./output-file.mjs";
|
|
6
7
|
export const CLI_BULK_CHECKPOINT_SCHEMA = 'whoisleuth.cli.bulk-checkpoint';
|
|
7
|
-
export const CLI_BULK_CHECKPOINT_VERSION =
|
|
8
|
+
export const CLI_BULK_CHECKPOINT_VERSION = 2;
|
|
9
|
+
const SUPPORTED_BULK_CHECKPOINT_VERSIONS = new Set([1, CLI_BULK_CHECKPOINT_VERSION]);
|
|
8
10
|
export const MAX_BULK_CHECKPOINT_BYTES = 16 * 1024 * 1024;
|
|
9
11
|
const MAX_CHECKPOINT_JSON_DEPTH = 12;
|
|
10
12
|
const MAX_CHECKPOINT_OBJECT_KEYS = 256;
|
|
@@ -51,7 +53,7 @@ function normalizeCompactResult(value) {
|
|
|
51
53
|
return null;
|
|
52
54
|
return result;
|
|
53
55
|
}
|
|
54
|
-
function normalizeCheckpointResult(value, queries, classifyQuery) {
|
|
56
|
+
function normalizeCheckpointResult(value, queries, classifyQuery, version = CLI_BULK_CHECKPOINT_VERSION) {
|
|
55
57
|
const item = recordOrNull(value);
|
|
56
58
|
const index = item?.index;
|
|
57
59
|
if (!Number.isSafeInteger(index) || Number(index) < 0 || Number(index) >= queries.length)
|
|
@@ -59,10 +61,17 @@ function normalizeCheckpointResult(value, queries, classifyQuery) {
|
|
|
59
61
|
const query = queries[Number(index)];
|
|
60
62
|
if (typeof item?.query !== 'string' || item.query !== query)
|
|
61
63
|
return null;
|
|
64
|
+
const observedAt = version >= 2
|
|
65
|
+
? item.observedAt === null
|
|
66
|
+
? null
|
|
67
|
+
: validTimestamp(item.observedAt) ? new Date(item.observedAt).toISOString() : undefined
|
|
68
|
+
: null;
|
|
69
|
+
if (observedAt === undefined)
|
|
70
|
+
return null;
|
|
62
71
|
if (item.ok === false) {
|
|
63
72
|
if (typeof item.error !== 'string' || !item.error || item.error.length > 300)
|
|
64
73
|
return null;
|
|
65
|
-
return { index: Number(index), query, ok: false, error: item.error };
|
|
74
|
+
return { index: Number(index), query, ok: false, error: item.error, observedAt };
|
|
66
75
|
}
|
|
67
76
|
const result = normalizeCompactResult(item.result);
|
|
68
77
|
if (item.ok !== true || !result)
|
|
@@ -71,7 +80,7 @@ function normalizeCheckpointResult(value, queries, classifyQuery) {
|
|
|
71
80
|
const storedClassified = recordOrNull(item.classified);
|
|
72
81
|
if (storedClassified?.type !== classified.type || storedClassified.value !== classified.value)
|
|
73
82
|
return null;
|
|
74
|
-
return { index: Number(index), query, ok: true, classified, result };
|
|
83
|
+
return { index: Number(index), query, ok: true, classified, result, observedAt };
|
|
75
84
|
}
|
|
76
85
|
function parseBulkCheckpoint(text, options) {
|
|
77
86
|
if (typeof text !== 'string' || Buffer.byteLength(text, 'utf8') > MAX_BULK_CHECKPOINT_BYTES) {
|
|
@@ -86,8 +95,11 @@ function parseBulkCheckpoint(text, options) {
|
|
|
86
95
|
}
|
|
87
96
|
const document = recordOrNull(parsed);
|
|
88
97
|
const expectedDigest = checkpointDigest(options.queries, options.deep);
|
|
89
|
-
|
|
90
|
-
|
|
98
|
+
const version = typeof document?.version === 'number' && Number.isSafeInteger(document.version)
|
|
99
|
+
? document.version
|
|
100
|
+
: 0;
|
|
101
|
+
if (document?.schema !== CLI_BULK_CHECKPOINT_SCHEMA || !SUPPORTED_BULK_CHECKPOINT_VERSIONS.has(version)) {
|
|
102
|
+
throw new CliUsageError(`Bulk checkpoint must use ${CLI_BULK_CHECKPOINT_SCHEMA} version 1 or ${CLI_BULK_CHECKPOINT_VERSION}.`);
|
|
91
103
|
}
|
|
92
104
|
if (document.mode !== (options.deep ? 'deep' : 'fast')
|
|
93
105
|
|| document.inputDigestSha256 !== expectedDigest
|
|
@@ -100,11 +112,11 @@ function parseBulkCheckpoint(text, options) {
|
|
|
100
112
|
const results = [];
|
|
101
113
|
const seen = new Set();
|
|
102
114
|
for (const candidate of document.results.slice(0, options.queries.length + 1)) {
|
|
103
|
-
const result = normalizeCheckpointResult(candidate, options.queries, options.classifyQuery);
|
|
115
|
+
const result = normalizeCheckpointResult(candidate, options.queries, options.classifyQuery, version);
|
|
104
116
|
if (!result || seen.has(result.index))
|
|
105
117
|
throw new CliUsageError('Bulk checkpoint contains an invalid or duplicate result.');
|
|
106
118
|
seen.add(result.index);
|
|
107
|
-
results.push(result);
|
|
119
|
+
results.push({ ...result, collectionOrigin: 'resumed_checkpoint' });
|
|
108
120
|
}
|
|
109
121
|
return {
|
|
110
122
|
schema: CLI_BULK_CHECKPOINT_SCHEMA,
|
|
@@ -118,16 +130,15 @@ function parseBulkCheckpoint(text, options) {
|
|
|
118
130
|
};
|
|
119
131
|
}
|
|
120
132
|
async function readCheckpointFile(path) {
|
|
121
|
-
const handle = await open(path, 'r');
|
|
122
133
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
134
|
+
return await readBoundedRegularTextFile(path, {
|
|
135
|
+
maximumBytes: MAX_BULK_CHECKPOINT_BYTES,
|
|
136
|
+
label: 'Bulk checkpoint input',
|
|
137
|
+
allowSymbolicLink: true,
|
|
138
|
+
});
|
|
128
139
|
}
|
|
129
|
-
|
|
130
|
-
|
|
140
|
+
catch (error) {
|
|
141
|
+
throw boundedCliInputError(error, 'Bulk checkpoint input');
|
|
131
142
|
}
|
|
132
143
|
}
|
|
133
144
|
async function checkpointFileExists(path) {
|
|
@@ -140,11 +151,12 @@ async function checkpointFileExists(path) {
|
|
|
140
151
|
const code = error && typeof error === 'object' && 'code' in error ? error.code : null;
|
|
141
152
|
if (code === 'ENOENT')
|
|
142
153
|
return false;
|
|
143
|
-
throw error;
|
|
154
|
+
throw boundedCliInputError(error, 'Bulk checkpoint input');
|
|
144
155
|
}
|
|
145
156
|
}
|
|
146
157
|
async function createBulkCheckpointWriter(options) {
|
|
147
158
|
const now = options.now || (() => new Date().toISOString());
|
|
159
|
+
const writeCheckpoint = options.writeFile ?? writePrivateFile;
|
|
148
160
|
const exists = await checkpointFileExists(options.path);
|
|
149
161
|
if (options.resume && !exists)
|
|
150
162
|
throw new CliUsageError('Bulk checkpoint does not exist; remove --resume to start a new checkpoint.');
|
|
@@ -155,7 +167,8 @@ async function createBulkCheckpointWriter(options) {
|
|
|
155
167
|
: null;
|
|
156
168
|
const startedAt = resumed?.startedAt || now();
|
|
157
169
|
const results = new Map((resumed?.results || []).map((item) => [item.index, item]));
|
|
158
|
-
let
|
|
170
|
+
let activeWrite = null;
|
|
171
|
+
let dirty = false;
|
|
159
172
|
let writeFailure = null;
|
|
160
173
|
let created = resumed !== null;
|
|
161
174
|
function document() {
|
|
@@ -168,19 +181,20 @@ async function createBulkCheckpointWriter(options) {
|
|
|
168
181
|
queryCount: options.queries.length,
|
|
169
182
|
startedAt,
|
|
170
183
|
updatedAt,
|
|
171
|
-
results: [...results.values()]
|
|
184
|
+
results: [...results.values()]
|
|
185
|
+
.sort((left, right) => left.index - right.index)
|
|
186
|
+
.map(({ collectionOrigin: _collectionOrigin, ...result }) => result),
|
|
172
187
|
};
|
|
173
188
|
}
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return;
|
|
189
|
+
async function drainWrites() {
|
|
190
|
+
while (dirty && !writeFailure) {
|
|
191
|
+
dirty = false;
|
|
178
192
|
try {
|
|
179
193
|
const content = `${JSON.stringify(document(), null, 2)}\n`;
|
|
180
194
|
if (Buffer.byteLength(content, 'utf8') > MAX_BULK_CHECKPOINT_BYTES) {
|
|
181
195
|
throw new CliUsageError(`Bulk checkpoints are limited to ${MAX_BULK_CHECKPOINT_BYTES} bytes.`);
|
|
182
196
|
}
|
|
183
|
-
await
|
|
197
|
+
await writeCheckpoint(options.path, content, {
|
|
184
198
|
force: created,
|
|
185
199
|
existingFileMessage: 'Bulk checkpoint already exists; use --resume or choose another path.',
|
|
186
200
|
});
|
|
@@ -189,6 +203,23 @@ async function createBulkCheckpointWriter(options) {
|
|
|
189
203
|
catch (error) {
|
|
190
204
|
writeFailure = error;
|
|
191
205
|
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function scheduleWrite() {
|
|
209
|
+
dirty = true;
|
|
210
|
+
if (activeWrite || writeFailure)
|
|
211
|
+
return;
|
|
212
|
+
const pending = drainWrites();
|
|
213
|
+
activeWrite = pending;
|
|
214
|
+
void pending.then(() => {
|
|
215
|
+
if (activeWrite === pending)
|
|
216
|
+
activeWrite = null;
|
|
217
|
+
if (dirty && !writeFailure)
|
|
218
|
+
scheduleWrite();
|
|
219
|
+
}, (error) => {
|
|
220
|
+
writeFailure ||= error;
|
|
221
|
+
if (activeWrite === pending)
|
|
222
|
+
activeWrite = null;
|
|
192
223
|
});
|
|
193
224
|
}
|
|
194
225
|
if (!resumed)
|
|
@@ -196,16 +227,22 @@ async function createBulkCheckpointWriter(options) {
|
|
|
196
227
|
return Object.freeze({
|
|
197
228
|
initialResults: Object.freeze([...results.values()].sort((left, right) => left.index - right.index)),
|
|
198
229
|
record(result) {
|
|
199
|
-
const
|
|
230
|
+
const observedAt = validTimestamp(result.observedAt) ? new Date(result.observedAt).toISOString() : now();
|
|
231
|
+
const normalized = normalizeCheckpointResult({ ...result, observedAt }, options.queries, options.classifyQuery);
|
|
200
232
|
if (!normalized) {
|
|
201
233
|
writeFailure ||= new CliUsageError('Bulk checkpoint result is not a bounded compact lookup result.');
|
|
202
234
|
return;
|
|
203
235
|
}
|
|
204
|
-
results.set(normalized.index, normalized);
|
|
236
|
+
results.set(normalized.index, { ...normalized, collectionOrigin: 'current_run' });
|
|
205
237
|
scheduleWrite();
|
|
206
238
|
},
|
|
207
239
|
async flush() {
|
|
208
|
-
|
|
240
|
+
while (activeWrite || dirty) {
|
|
241
|
+
if (!activeWrite)
|
|
242
|
+
scheduleWrite();
|
|
243
|
+
if (activeWrite)
|
|
244
|
+
await activeWrite;
|
|
245
|
+
}
|
|
209
246
|
if (writeFailure)
|
|
210
247
|
throw writeFailure;
|
|
211
248
|
},
|
|
@@ -26,7 +26,7 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
26
26
|
? await dependencies.readBulkInput(args.source)
|
|
27
27
|
: await readTextStreamBounded(args.source
|
|
28
28
|
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
29
|
-
: dependencies.stdin || process.stdin, MAX_BULK_INPUT_BYTES);
|
|
29
|
+
: dependencies.stdin || process.stdin, MAX_BULK_INPUT_BYTES, dependencies.signal);
|
|
30
30
|
}
|
|
31
31
|
catch (error) {
|
|
32
32
|
if (error instanceof CliUsageError)
|
|
@@ -56,6 +56,8 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
56
56
|
: null;
|
|
57
57
|
const indicator = context.beginProgress(`Collecting 0 of ${parsed.queries.length} targets`);
|
|
58
58
|
let completed = checkpoint?.initialResults.length || 0;
|
|
59
|
+
const resumedItems = new Map((checkpoint?.initialResults ?? []).map((item) => [item.index, item]));
|
|
60
|
+
const settledItems = new Map();
|
|
59
61
|
if (completed)
|
|
60
62
|
indicator.update(`Resumed ${completed} of ${parsed.queries.length} targets`);
|
|
61
63
|
let items;
|
|
@@ -69,12 +71,23 @@ async function runBulkCommand(args, dependencies, context) {
|
|
|
69
71
|
...(checkpoint ? { initialResults: checkpoint.initialResults } : {}),
|
|
70
72
|
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
71
73
|
onItemSettled: (item) => {
|
|
74
|
+
const observed = {
|
|
75
|
+
...item,
|
|
76
|
+
observedAt: context.now(),
|
|
77
|
+
collectionOrigin: 'current_run',
|
|
78
|
+
};
|
|
79
|
+
settledItems.set(item.index, observed);
|
|
72
80
|
completed += 1;
|
|
73
81
|
indicator.update(`Collected ${completed} of ${parsed.queries.length} targets`);
|
|
74
82
|
eventProgress.emit({ event: 'item_settled', index: item.index, ok: item.ok });
|
|
75
|
-
checkpoint?.record(
|
|
83
|
+
checkpoint?.record(observed);
|
|
76
84
|
},
|
|
77
85
|
});
|
|
86
|
+
items = items.map((item) => resumedItems.get(item.index) ?? settledItems.get(item.index) ?? {
|
|
87
|
+
...item,
|
|
88
|
+
observedAt: null,
|
|
89
|
+
collectionOrigin: 'current_run',
|
|
90
|
+
});
|
|
78
91
|
}
|
|
79
92
|
finally {
|
|
80
93
|
context.endProgress();
|
package/cli/bulk.mjs
CHANGED
|
@@ -3,17 +3,28 @@ import { abortable } from "../lib/abort.mjs";
|
|
|
3
3
|
import { CliUsageError } from "./arguments.mjs";
|
|
4
4
|
import { MAX_DEEP_BULK_QUERIES, MAX_FAST_BULK_QUERIES, } from "../lib/bulk-limits.mjs";
|
|
5
5
|
const MAX_BULK_INPUT_BYTES = 1024 * 1024;
|
|
6
|
-
async function readTextStreamBounded(stream, limit = MAX_BULK_INPUT_BYTES) {
|
|
6
|
+
async function readTextStreamBounded(stream, limit = MAX_BULK_INPUT_BYTES, signal) {
|
|
7
7
|
if (!stream || stream.isTTY)
|
|
8
8
|
return '';
|
|
9
|
+
const abort = () => stream.destroy?.(new DOMException('Aborted', 'AbortError'));
|
|
10
|
+
if (signal?.aborted)
|
|
11
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
12
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
9
13
|
const chunks = [];
|
|
10
14
|
let total = 0;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
try {
|
|
16
|
+
for await (const chunk of stream) {
|
|
17
|
+
if (signal?.aborted)
|
|
18
|
+
throw new DOMException('Aborted', 'AbortError');
|
|
19
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
20
|
+
total += buffer.length;
|
|
21
|
+
if (total > limit)
|
|
22
|
+
throw new CliUsageError(`Bulk input is limited to ${limit} bytes.`);
|
|
23
|
+
chunks.push(buffer);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
signal?.removeEventListener('abort', abort);
|
|
17
28
|
}
|
|
18
29
|
return Buffer.concat(chunks).toString('utf8');
|
|
19
30
|
}
|
package/cli/completion.mjs
CHANGED
|
@@ -2,9 +2,13 @@ import { CLI_COMMANDS } from "./arguments.mjs";
|
|
|
2
2
|
import { INVESTIGATION_PLAN_RECIPES } from "./investigation-plan.mjs";
|
|
3
3
|
const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force', '--config', '--profile']);
|
|
4
4
|
const OPTIONS_BY_COMMAND = Object.freeze({
|
|
5
|
+
manifest: ['--workflow', '--configuration-digest', '--json', '--quiet', '--no-color'],
|
|
6
|
+
'map-observations': ['--json', '--quiet', '--no-color'],
|
|
7
|
+
'oam-export': ['--json', '--quiet', '--no-color'],
|
|
5
8
|
lookup: ['--json', '--junit', '--markdown', '--html', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
|
|
6
9
|
bulk: ['--json', '--jsonl', '--junit', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--plan', '--fail-on', '--quiet', '--no-color'],
|
|
7
10
|
'ct-search': ['--json', '--quiet', '--no-color'],
|
|
11
|
+
'ct-intake': ['--json', '--quiet', '--no-color'],
|
|
8
12
|
discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
|
|
9
13
|
'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', '--plan', '--fail-on', '--json', '--jsonl', '--csv', '--domains', '--quiet', '--no-color'],
|
|
10
14
|
posture: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
|
|
@@ -30,6 +34,7 @@ const OPTIONS_BY_COMMAND = Object.freeze({
|
|
|
30
34
|
'domain-control': ['--json', '--quiet', '--no-color'],
|
|
31
35
|
'monitor-once': ['--previous', '--limit', '--concurrency', '--fail-on', '--json', '--junit', '--quiet', '--no-color'],
|
|
32
36
|
assurance: ['--json', '--quiet', '--no-color'],
|
|
37
|
+
'change-packet': ['--json', '--quiet', '--no-color'],
|
|
33
38
|
'sharing-review': ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
|
|
34
39
|
'workflow-plan': ['--json', '--quiet', '--no-color'],
|
|
35
40
|
'workflow-run': ['--approve-network', '--resume', '--json', '--quiet', '--no-color'],
|
|
@@ -43,9 +48,13 @@ const OPTIONS_BY_COMMAND = Object.freeze({
|
|
|
43
48
|
manual: [],
|
|
44
49
|
});
|
|
45
50
|
const COMMAND_DESCRIPTIONS = Object.freeze({
|
|
51
|
+
manifest: 'Build an evidence manifest offline',
|
|
52
|
+
'map-observations': 'Normalise passive DNS observations offline',
|
|
53
|
+
'oam-export': 'Export an observation-archive map offline',
|
|
46
54
|
lookup: 'Collect one domain, IP, or ASN',
|
|
47
55
|
bulk: 'Run bounded multi-target collection',
|
|
48
56
|
'ct-search': 'Search certificate observations',
|
|
57
|
+
'ct-intake': 'Normalise certificate observations offline',
|
|
49
58
|
discover: 'Generate lookalike candidates offline',
|
|
50
59
|
'discover-scan': 'Collect a supervised candidate review queue',
|
|
51
60
|
posture: 'Review DNS and mail posture',
|
|
@@ -71,6 +80,7 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
|
|
|
71
80
|
'domain-control': 'Build or review a domain control manifest',
|
|
72
81
|
'monitor-once': 'Run one bounded domain control review',
|
|
73
82
|
assurance: 'Review domain change, recovery, or retirement plans',
|
|
83
|
+
'change-packet': 'Build a reviewed change packet offline',
|
|
74
84
|
'sharing-review': 'Lint an artefact before deliberate sharing',
|
|
75
85
|
'workflow-plan': 'Plan a fixed investigation recipe',
|
|
76
86
|
'workflow-run': 'Execute approved fixed-recipe steps',
|
|
@@ -106,6 +116,13 @@ const FILE_OPTIONS = Object.freeze([
|
|
|
106
116
|
'--previous',
|
|
107
117
|
'--resume',
|
|
108
118
|
]);
|
|
119
|
+
const FILE_POSITIONAL_COMMANDS = new Set([
|
|
120
|
+
'manifest',
|
|
121
|
+
'map-observations',
|
|
122
|
+
'oam-export',
|
|
123
|
+
'ct-intake',
|
|
124
|
+
'change-packet',
|
|
125
|
+
]);
|
|
109
126
|
const TEXT_OPTIONS = Object.freeze([
|
|
110
127
|
'--families',
|
|
111
128
|
'--resolver',
|
|
@@ -121,7 +138,7 @@ const TEXT_OPTIONS = Object.freeze([
|
|
|
121
138
|
'--vantage',
|
|
122
139
|
]);
|
|
123
140
|
function commandOptions(command) {
|
|
124
|
-
return [...COMMON_OPTIONS, ...
|
|
141
|
+
return [...COMMON_OPTIONS, ...OPTIONS_BY_COMMAND[command]].join(' ');
|
|
125
142
|
}
|
|
126
143
|
function bashCompletion() {
|
|
127
144
|
const cases = CLI_COMMANDS.map((command) => ` ${command}) options="${commandOptions(command)}" ;;`).join('\n');
|
|
@@ -153,6 +170,9 @@ ${valueCases}
|
|
|
153
170
|
${cases}
|
|
154
171
|
*) options="--help" ;;
|
|
155
172
|
esac
|
|
173
|
+
case " ${[...FILE_POSITIONAL_COMMANDS].join(' ')} " in
|
|
174
|
+
*" \${command} "*) [[ "\${current}" != -* ]] && COMPREPLY=( $(compgen -f -- "\${current}") ) && return ;;
|
|
175
|
+
esac
|
|
156
176
|
COMPREPLY=( $(compgen -W "\${options}" -- "\${current}") )
|
|
157
177
|
}
|
|
158
178
|
complete -F _whoisleuth_completion whoisleuth
|
|
@@ -190,6 +210,10 @@ ${valueCases}
|
|
|
190
210
|
${cases}
|
|
191
211
|
*) options=(--help) ;;
|
|
192
212
|
esac
|
|
213
|
+
if [[ " ${[...FILE_POSITIONAL_COMMANDS].join(' ')} " == *" \${command} "* && "\${words[CURRENT]}" != -* ]]; then
|
|
214
|
+
_files
|
|
215
|
+
return
|
|
216
|
+
fi
|
|
193
217
|
compadd -- \${options[@]}
|
|
194
218
|
}
|
|
195
219
|
if [[ "\${funcstack[1]}" == "_whoisleuth" ]]; then
|
|
@@ -200,7 +224,7 @@ fi
|
|
|
200
224
|
`;
|
|
201
225
|
}
|
|
202
226
|
function fishCompletion() {
|
|
203
|
-
const commandLines = CLI_COMMANDS.map((command) => (`complete -c whoisleuth -n '__fish_use_subcommand' -a '${command}' -d '${COMMAND_DESCRIPTIONS[command]
|
|
227
|
+
const commandLines = CLI_COMMANDS.map((command) => (`complete -c whoisleuth -n '__fish_use_subcommand' -a '${command}' -d '${COMMAND_DESCRIPTIONS[command]}'${FILE_POSITIONAL_COMMANDS.has(command) ? ' -F' : ''}`));
|
|
204
228
|
const optionLine = (condition, option) => {
|
|
205
229
|
const name = option.replace(/^--/u, '');
|
|
206
230
|
const fileValue = FILE_OPTIONS.includes(option);
|
|
@@ -226,7 +250,7 @@ ${valueLines.join('\n')}
|
|
|
226
250
|
function powershellCompletion() {
|
|
227
251
|
const commandOptions = Object.fromEntries(CLI_COMMANDS.map((command) => [
|
|
228
252
|
command,
|
|
229
|
-
[...COMMON_OPTIONS, ...
|
|
253
|
+
[...COMMON_OPTIONS, ...OPTIONS_BY_COMMAND[command]],
|
|
230
254
|
]));
|
|
231
255
|
return `# WHOISleuth PowerShell completion
|
|
232
256
|
Register-ArgumentCompleter -Native -CommandName whoisleuth -ScriptBlock {
|
|
@@ -240,9 +264,16 @@ ${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @($
|
|
|
240
264
|
'completion' = @('bash', 'zsh', 'fish', 'powershell')
|
|
241
265
|
'workflow-plan' = @(${INVESTIGATION_PLAN_RECIPES.map((value) => `'${value}'`).join(', ')})
|
|
242
266
|
}
|
|
267
|
+
$fileCommands = @(${[...FILE_POSITIONAL_COMMANDS].map((command) => `'${command}'`).join(', ')})
|
|
243
268
|
$elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
|
|
244
269
|
$command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
|
|
245
270
|
$previous = if ($elements.Count -gt 1) { $elements[$elements.Count - 1] } else { '' }
|
|
271
|
+
if ($fileCommands -contains $command -and -not $wordToComplete.StartsWith('-')) {
|
|
272
|
+
Get-ChildItem -Path "${'$'}wordToComplete*" -File -ErrorAction SilentlyContinue | ForEach-Object {
|
|
273
|
+
[System.Management.Automation.CompletionResult]::new($_.FullName, $_.Name, 'ProviderItem', $_.FullName)
|
|
274
|
+
}
|
|
275
|
+
return
|
|
276
|
+
}
|
|
246
277
|
$candidates = if ($elements.Count -le 2) {
|
|
247
278
|
@($commands) + @('--help', '--version')
|
|
248
279
|
} elseif ($command -eq 'completion' -and $elements.Count -le 3) {
|
package/cli/config-profile.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { open } from 'node:fs/promises';
|
|
3
2
|
import { homedir } from 'node:os';
|
|
4
3
|
import { join, resolve } from 'node:path';
|
|
5
4
|
import { CliUsageError } from "./errors.mjs";
|
|
5
|
+
import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
|
|
6
6
|
export const CLI_CONFIG_SCHEMA = 'whoisleuth.cli.config';
|
|
7
7
|
export const CLI_CONFIG_VERSION = 1;
|
|
8
8
|
export const MAX_CLI_CONFIG_BYTES = 64 * 1024;
|
|
@@ -91,16 +91,11 @@ function parseProfileDocument(input) {
|
|
|
91
91
|
return Object.freeze({ schema: CLI_CONFIG_SCHEMA, version: CLI_CONFIG_VERSION, ...(root.defaultProfile ? { defaultProfile: root.defaultProfile } : {}), profiles: Object.freeze(profiles) });
|
|
92
92
|
}
|
|
93
93
|
async function readConfigFile(path) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return await handle.readFile({ encoding: 'utf8' });
|
|
100
|
-
}
|
|
101
|
-
finally {
|
|
102
|
-
await handle.close();
|
|
103
|
-
}
|
|
94
|
+
return readBoundedRegularTextFile(path, {
|
|
95
|
+
maximumBytes: MAX_CLI_CONFIG_BYTES,
|
|
96
|
+
label: 'CLI configuration',
|
|
97
|
+
allowSymbolicLink: true,
|
|
98
|
+
});
|
|
104
99
|
}
|
|
105
100
|
function suppliedGroups(argumentsList) {
|
|
106
101
|
const groups = new Set();
|
|
@@ -114,6 +109,14 @@ function suppliedGroups(argumentsList) {
|
|
|
114
109
|
return groups;
|
|
115
110
|
}
|
|
116
111
|
export async function resolveCliProfileArguments(argv, options = {}) {
|
|
112
|
+
// registry-scaffold owns --profile as the capability template to generate.
|
|
113
|
+
// Do not reinterpret it as a global CLI-default profile.
|
|
114
|
+
if (argv[0] === 'registry-scaffold') {
|
|
115
|
+
if (argv.includes('--config')) {
|
|
116
|
+
throw new CliUsageError('registry-scaffold does not accept global CLI configuration options.');
|
|
117
|
+
}
|
|
118
|
+
return [...argv];
|
|
119
|
+
}
|
|
117
120
|
const retained = [];
|
|
118
121
|
let configPath = null;
|
|
119
122
|
let profileName = null;
|
|
@@ -153,7 +156,7 @@ export async function resolveCliProfileArguments(argv, options = {}) {
|
|
|
153
156
|
catch (error) {
|
|
154
157
|
if (error instanceof CliUsageError)
|
|
155
158
|
throw error;
|
|
156
|
-
throw new CliUsageError(
|
|
159
|
+
throw new CliUsageError('CLI configuration could not be read as a bounded regular file.');
|
|
157
160
|
}
|
|
158
161
|
const document = parseProfileDocument(input);
|
|
159
162
|
const selectedName = profileName || document.defaultProfile;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
-
import { open } from 'node:fs/promises';
|
|
3
2
|
import { availabilityState, bulkDnsSummary } from "./bulk-output.mjs";
|
|
4
|
-
import { CliUsageError } from "./errors.mjs";
|
|
3
|
+
import { boundedCliInputError, CliUsageError } from "./errors.mjs";
|
|
5
4
|
import { writePrivateFile } from "./output-file.mjs";
|
|
6
5
|
import { isValidAsciiDomainName } from "../lib/hostname.mjs";
|
|
6
|
+
import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
|
|
7
7
|
export const CLI_DISCOVERY_OBSERVATION_SCHEMA = 'whoisleuth.cli.discovery-observation-snapshot';
|
|
8
8
|
export const CLI_DISCOVERY_OBSERVATION_VERSION = 2;
|
|
9
9
|
export const MAX_DISCOVERY_OBSERVATION_BYTES = 8 * 1024 * 1024;
|
|
@@ -158,22 +158,16 @@ function parseObservationSnapshot(text, expectedDigest) {
|
|
|
158
158
|
}
|
|
159
159
|
async function readExisting(path) {
|
|
160
160
|
try {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
return await handle.readFile({ encoding: 'utf8' });
|
|
168
|
-
}
|
|
169
|
-
finally {
|
|
170
|
-
await handle.close();
|
|
171
|
-
}
|
|
161
|
+
return await readBoundedRegularTextFile(path, {
|
|
162
|
+
maximumBytes: MAX_DISCOVERY_OBSERVATION_BYTES,
|
|
163
|
+
label: 'Discovery observation snapshot',
|
|
164
|
+
allowSymbolicLink: true,
|
|
165
|
+
});
|
|
172
166
|
}
|
|
173
167
|
catch (error) {
|
|
174
168
|
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT')
|
|
175
169
|
return null;
|
|
176
|
-
throw error;
|
|
170
|
+
throw boundedCliInputError(error, 'Discovery observation snapshot');
|
|
177
171
|
}
|
|
178
172
|
}
|
|
179
173
|
function currentAttempt(domain, item, previous, generatedAt, deep) {
|
|
@@ -75,6 +75,8 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
75
75
|
: null;
|
|
76
76
|
const indicator = context.beginProgress(`Collecting 0 of ${queries.length} generated candidates`);
|
|
77
77
|
let completed = checkpoint?.initialResults.length || 0;
|
|
78
|
+
const resumedItems = new Map((checkpoint?.initialResults ?? []).map((item) => [item.index, item]));
|
|
79
|
+
const settledItems = new Map();
|
|
78
80
|
if (completed)
|
|
79
81
|
indicator.update(`Resumed ${completed} of ${queries.length} generated candidates`);
|
|
80
82
|
let checkpointFailure = null;
|
|
@@ -90,12 +92,23 @@ async function runDiscoveryScanCommand(args, dependencies, context) {
|
|
|
90
92
|
...(checkpoint ? { initialResults: checkpoint.initialResults } : {}),
|
|
91
93
|
...(dependencies.signal ? { signal: dependencies.signal } : {}),
|
|
92
94
|
onItemSettled: (item) => {
|
|
95
|
+
const observed = {
|
|
96
|
+
...item,
|
|
97
|
+
observedAt: context.now(),
|
|
98
|
+
collectionOrigin: 'current_run',
|
|
99
|
+
};
|
|
100
|
+
settledItems.set(item.index, observed);
|
|
93
101
|
completed += 1;
|
|
94
102
|
indicator.update(`Collected ${completed} of ${queries.length} generated candidates`);
|
|
95
103
|
eventProgress.emit({ event: 'item_settled', index: item.index, ok: item.ok });
|
|
96
|
-
checkpoint?.record(
|
|
104
|
+
checkpoint?.record(observed);
|
|
97
105
|
},
|
|
98
106
|
});
|
|
107
|
+
items = items.map((item) => resumedItems.get(item.index) ?? settledItems.get(item.index) ?? {
|
|
108
|
+
...item,
|
|
109
|
+
observedAt: null,
|
|
110
|
+
collectionOrigin: 'current_run',
|
|
111
|
+
});
|
|
99
112
|
}
|
|
100
113
|
finally {
|
|
101
114
|
context.endProgress();
|