@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.
Files changed (42) hide show
  1. package/README.md +8 -4
  2. package/bin/whoisleuth.mjs +20 -9
  3. package/cli/bulk-checkpoint.mjs +65 -28
  4. package/cli/bulk-command-runner.mjs +15 -2
  5. package/cli/bulk.mjs +18 -7
  6. package/cli/completion.mjs +34 -3
  7. package/cli/config-profile.mjs +15 -12
  8. package/cli/discovery-observation-snapshot.mjs +8 -14
  9. package/cli/discovery-scan-command-runner.mjs +14 -1
  10. package/cli/discovery-snapshot.mjs +8 -15
  11. package/cli/errors.mjs +7 -1
  12. package/cli/formatters/json.mjs +12 -4
  13. package/cli/formatters/terminal.mjs +1 -1
  14. package/cli/http.mjs +2 -1
  15. package/cli/lookup-reconcile.mjs +1 -1
  16. package/cli/mail-review.mjs +5 -5
  17. package/cli/manual.mjs +1 -1
  18. package/cli/registry-fixture-scaffold.mjs +4 -3
  19. package/cli/runner.mjs +20 -9
  20. package/docs/cli-reference.md +26 -7
  21. package/docs/cli.md +1 -0
  22. package/frontend/src/lib/analysis/acquisition-due-diligence.js +1 -1
  23. package/frontend/src/lib/analysis/brand-profile-model.js +4 -2
  24. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  25. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  26. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  27. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  28. package/frontend/src/lib/analysis/lookup-decision-support.js +2 -2
  29. package/frontend/src/lib/analysis/lookup-presentation.js +9 -15
  30. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  31. package/frontend/src/lib/analysis/utils.js +1 -1
  32. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  33. package/frontend/src/lib/analysis/workspace-archive.js +3 -7
  34. package/lib/bounded-file.mjs +69 -0
  35. package/lib/bounded-json-response.mjs +157 -0
  36. package/lib/browser-library-profile.mjs +167 -38
  37. package/lib/lookup-cache.mjs +5 -1
  38. package/lib/rdap-transport.mjs +6 -6
  39. package/lib/safe-fetch.mjs +37 -32
  40. package/lib/threat-intelligence-runtime.mjs +1 -2
  41. package/lib/website-technology.mjs +2 -6
  42. 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) defines the supported defensive use
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. A checkpoint write failure preserves completed output and returns
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; a second interrupt performs best-effort temporary-file
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.
@@ -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 = 64;
36
+ process.exitCode = EXIT_CODES.USAGE;
36
37
  resolvedArgv = [];
37
38
  }
38
39
  const cancellation = new AbortController();
39
40
  let interruptionCount = 0;
40
- const interrupt = () => {
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(130);
50
+ process.exit(requestedExitCode);
47
51
  }
48
52
  };
49
- process.on('SIGINT', interrupt);
50
- const execution = resolvedArgv.length ? runnerModule.runCli(resolvedArgv, { signal: cancellation.signal }) : Promise.resolve(process.exitCode || 64);
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', interrupt);
54
- process.exit(130);
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', interrupt);
71
+ process.removeListener('SIGINT', onSigint);
72
+ process.removeListener('SIGTERM', onSigterm);
62
73
  });
63
74
  }
@@ -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 { CliUsageError } from "./errors.mjs";
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 = 1;
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
- if (document?.schema !== CLI_BULK_CHECKPOINT_SCHEMA || document.version !== CLI_BULK_CHECKPOINT_VERSION) {
90
- throw new CliUsageError(`Bulk checkpoint must use ${CLI_BULK_CHECKPOINT_SCHEMA} version ${CLI_BULK_CHECKPOINT_VERSION}.`);
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
- const stats = await handle.stat();
124
- if (!stats.isFile() || stats.size > MAX_BULK_CHECKPOINT_BYTES) {
125
- throw new CliUsageError(`Bulk checkpoint input is limited to ${MAX_BULK_CHECKPOINT_BYTES} bytes.`);
126
- }
127
- return await handle.readFile({ encoding: 'utf8' });
134
+ return await readBoundedRegularTextFile(path, {
135
+ maximumBytes: MAX_BULK_CHECKPOINT_BYTES,
136
+ label: 'Bulk checkpoint input',
137
+ allowSymbolicLink: true,
138
+ });
128
139
  }
129
- finally {
130
- await handle.close();
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 chain = Promise.resolve();
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()].sort((left, right) => left.index - right.index),
184
+ results: [...results.values()]
185
+ .sort((left, right) => left.index - right.index)
186
+ .map(({ collectionOrigin: _collectionOrigin, ...result }) => result),
172
187
  };
173
188
  }
174
- function scheduleWrite() {
175
- chain = chain.then(async () => {
176
- if (writeFailure)
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 writePrivateFile(options.path, content, {
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 normalized = normalizeCheckpointResult(result, options.queries, options.classifyQuery);
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
- await chain;
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(item);
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
- for await (const chunk of stream) {
12
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
13
- total += buffer.length;
14
- if (total > limit)
15
- throw new CliUsageError(`Bulk input is limited to ${limit} bytes.`);
16
- chunks.push(buffer);
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
  }
@@ -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, ...(OPTIONS_BY_COMMAND[command] || [])].join(' ');
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] || 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, ...(OPTIONS_BY_COMMAND[command] || [])],
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) {
@@ -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
- const handle = await open(path, 'r');
95
- try {
96
- const metadata = await handle.stat();
97
- if (metadata.size > MAX_CLI_CONFIG_BYTES)
98
- throw new CliUsageError('CLI configuration is limited to 64 KiB.');
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(`Could not read CLI configuration from ${selectedPath}.`);
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
- const handle = await open(path, 'r');
162
- try {
163
- const metadata = await handle.stat();
164
- if (!metadata.isFile() || metadata.size > MAX_DISCOVERY_OBSERVATION_BYTES) {
165
- throw new CliUsageError(`Discovery observation snapshots are limited to ${MAX_DISCOVERY_OBSERVATION_BYTES} bytes.`);
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(item);
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();