@slicedearth/whoisleuth-cli 1.39.1 → 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 (90) hide show
  1. package/README.md +23 -4
  2. package/bin/whoisleuth.mjs +31 -9
  3. package/cli/archive-inspect.mjs +37 -2
  4. package/cli/arguments.mjs +361 -256
  5. package/cli/artifact-verify.mjs +35 -1
  6. package/cli/bulk-checkpoint.mjs +72 -39
  7. package/cli/bulk-command-runner.mjs +34 -4
  8. package/cli/bulk.mjs +18 -7
  9. package/cli/case-pack.mjs +136 -0
  10. package/cli/ci-report.mjs +73 -0
  11. package/cli/collection-preflight.mjs +59 -0
  12. package/cli/command-reference.mjs +395 -0
  13. package/cli/completion.mjs +67 -13
  14. package/cli/config-profile.mjs +183 -0
  15. package/cli/ct-event-intake.mjs +159 -0
  16. package/cli/discovery-observation-snapshot.mjs +8 -14
  17. package/cli/discovery-scan-command-runner.mjs +29 -2
  18. package/cli/discovery-snapshot.mjs +8 -15
  19. package/cli/domain-control-monitor.mjs +104 -0
  20. package/cli/domain-control-observations.mjs +235 -0
  21. package/cli/errors.mjs +7 -1
  22. package/cli/evidence-command-arguments.mjs +11 -0
  23. package/cli/evidence-command-runner.mjs +1 -0
  24. package/cli/external-observation-mapping.mjs +162 -0
  25. package/cli/fail-policy.mjs +64 -0
  26. package/cli/formatters/html.mjs +1 -1
  27. package/cli/formatters/json.mjs +12 -4
  28. package/cli/formatters/terminal.mjs +1 -1
  29. package/cli/http.mjs +2 -1
  30. package/cli/investigation-manifest.mjs +113 -0
  31. package/cli/investigation-run.mjs +112 -0
  32. package/cli/lookup-brief.mjs +116 -0
  33. package/cli/lookup-command-runner.mjs +8 -1
  34. package/cli/lookup-diff.mjs +17 -19
  35. package/cli/lookup-reconcile.mjs +1 -1
  36. package/cli/mail-review.mjs +5 -5
  37. package/cli/manual.mjs +1 -1
  38. package/cli/network-command-runner.mjs +4 -1
  39. package/cli/offline-evidence-review.mjs +105 -8
  40. package/cli/open-asset-model-bridge.mjs +169 -0
  41. package/cli/registry-cohort.mjs +121 -0
  42. package/cli/registry-doctor.mjs +106 -11
  43. package/cli/registry-fixture-scaffold.mjs +74 -0
  44. package/cli/runner.mjs +388 -315
  45. package/docs/cli-reference.md +266 -21
  46. package/docs/cli.md +43 -7
  47. package/frontend/src/lib/analysis/acquisition-due-diligence.js +2 -2
  48. package/frontend/src/lib/analysis/brand-profile-model.js +50 -6
  49. package/frontend/src/lib/analysis/case-evidence-model.js +1 -1
  50. package/frontend/src/lib/analysis/case-record-core.js +1 -1
  51. package/frontend/src/lib/analysis/case-record-operations.js +1 -1
  52. package/frontend/src/lib/analysis/case-report.js +573 -0
  53. package/frontend/src/lib/analysis/case-response-model.js +30 -1
  54. package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
  55. package/frontend/src/lib/analysis/external-findings-import.js +320 -0
  56. package/frontend/src/lib/analysis/lookup-asset-graph.js +8 -8
  57. package/frontend/src/lib/analysis/lookup-decision-support.js +9 -2
  58. package/frontend/src/lib/analysis/lookup-presentation.js +14 -30
  59. package/frontend/src/lib/analysis/lookup-source-refresh.js +36 -63
  60. package/frontend/src/lib/analysis/scoring.js +1 -1
  61. package/frontend/src/lib/analysis/utils.js +1 -1
  62. package/frontend/src/lib/analysis/visualization-models.js +2 -3
  63. package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -4
  64. package/frontend/src/lib/analysis/workspace-archive.js +5 -7
  65. package/lib/abusech-auth.mjs +1 -1
  66. package/lib/availability.mjs +1 -1
  67. package/lib/bounded-contract-normalizers.mjs +33 -0
  68. package/lib/bounded-file.mjs +69 -0
  69. package/lib/bounded-json-response.mjs +157 -0
  70. package/lib/browser-library-profile.mjs +167 -38
  71. package/lib/dns-convergence-review.mjs +183 -0
  72. package/lib/dns-delegation-health.mjs +196 -3
  73. package/lib/domain-change-packet.mjs +133 -0
  74. package/lib/domain-change-review.mjs +307 -0
  75. package/lib/domain-control-flight-recorder.mjs +268 -0
  76. package/lib/domain-portfolio-review.mjs +151 -0
  77. package/lib/evidence-export.mjs +1 -1
  78. package/lib/html-signals.mjs +5 -11
  79. package/lib/lookup-cache.mjs +5 -1
  80. package/lib/nameserver-preflight-review.mjs +159 -0
  81. package/lib/page-language-signals.mjs +124 -0
  82. package/lib/rdap-transport.mjs +6 -6
  83. package/lib/risk-scoring.mjs +42 -1
  84. package/lib/safe-fetch.mjs +37 -32
  85. package/lib/static-html-analysis.mjs +29 -1
  86. package/lib/threat-intelligence-runtime.mjs +1 -2
  87. package/lib/trust-store-comparison.mjs +146 -0
  88. package/lib/website-technology.mjs +22 -12
  89. package/lib/zone-intent-review.mjs +534 -0
  90. package/package.json +1 -1
@@ -1,32 +1,43 @@
1
1
  import { CLI_COMMANDS } from "./arguments.mjs";
2
2
  import { INVESTIGATION_PLAN_RECIPES } from "./investigation-plan.mjs";
3
- const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force']);
3
+ const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force', '--config', '--profile']);
4
4
  const OPTIONS_BY_COMMAND = Object.freeze({
5
- lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
6
- bulk: ['--json', '--jsonl', '--csv', '--domains', '--queries', '--registered-only', '--inconclusive-only', '--errors-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
5
+ manifest: ['--workflow', '--configuration-digest', '--json', '--quiet', '--no-color'],
6
+ 'map-observations': ['--json', '--quiet', '--no-color'],
7
+ 'oam-export': ['--json', '--quiet', '--no-color'],
8
+ lookup: ['--json', '--junit', '--markdown', '--html', '--fast', '--deep', '--observer', '--vantage', '--plan', '--summary', '--verbose', '--strict-exit', '--fail-on', '--events', '--quiet', '--no-color'],
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
- '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'],
10
- posture: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--quiet', '--no-color'],
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'],
14
+ posture: ['--selectors', '--retired-selectors', '--mail-profile', '--json', '--sarif', '--owned-domain', '--quiet', '--no-color'],
11
15
  http: ['--json', '--quiet', '--no-color'],
12
16
  tls: ['--json', '--quiet', '--no-color'],
13
17
  'registry-support': ['--json', '--quiet', '--no-color'],
14
18
  'registry-doctor': ['--json', '--quiet', '--no-color'],
19
+ 'registry-cohort': ['--json', '--quiet', '--no-color'],
20
+ 'registry-scaffold': ['--profile', '--suffix', '--scenario'],
15
21
  'risk-calibrate': ['--json', '--quiet', '--no-color'],
16
22
  'lookalike-calibrate': ['--json', '--quiet', '--no-color'],
17
23
  'verify-artifact': ['--passphrase-file', '--json', '--quiet', '--no-color'],
18
- 'inspect-archive': ['--passphrase-file', '--search', '--require-match', '--reveal', '--json', '--quiet', '--no-color'],
24
+ 'inspect-archive': ['--passphrase-file', '--search', '--require-match', '--reveal', '--expect-content-digest', '--json', '--quiet', '--no-color'],
19
25
  'sign-artifact': ['--private-key-file'],
20
26
  'verify-signature': ['--public-key-file', '--json', '--quiet', '--no-color'],
21
27
  'source-report': ['--json', '--quiet', '--no-color'],
22
28
  compare: ['--json', '--quiet', '--no-color'],
23
29
  'page-compare': ['--json', '--quiet', '--no-color'],
24
30
  'mail-review': ['--json', '--quiet', '--no-color'],
25
- 'review-evidence': ['--mmdb', '--json', '--quiet', '--no-color'],
31
+ 'review-evidence': ['--mmdb', '--json', '--strict-exit', '--quiet', '--no-color'],
32
+ brief: ['--json', '--quiet', '--no-color'],
33
+ 'case-pack': ['--audience', '--reviewed', '--json', '--quiet', '--no-color'],
26
34
  'domain-control': ['--json', '--quiet', '--no-color'],
35
+ 'monitor-once': ['--previous', '--limit', '--concurrency', '--fail-on', '--json', '--junit', '--quiet', '--no-color'],
27
36
  assurance: ['--json', '--quiet', '--no-color'],
37
+ 'change-packet': ['--json', '--quiet', '--no-color'],
28
38
  'sharing-review': ['--marking', '--recipient-scope', '--purpose', '--human-reviewed', '--personal-data-reviewed', '--redactions-confirmed', '--json', '--quiet', '--no-color'],
29
39
  'workflow-plan': ['--json', '--quiet', '--no-color'],
40
+ 'workflow-run': ['--approve-network', '--resume', '--json', '--quiet', '--no-color'],
30
41
  diff: ['--json', '--quiet', '--no-color'],
31
42
  reconcile: ['--json', '--quiet', '--no-color'],
32
43
  timeline: ['--json', '--quiet', '--no-color'],
@@ -37,9 +48,13 @@ const OPTIONS_BY_COMMAND = Object.freeze({
37
48
  manual: [],
38
49
  });
39
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',
40
54
  lookup: 'Collect one domain, IP, or ASN',
41
55
  bulk: 'Run bounded multi-target collection',
42
56
  'ct-search': 'Search certificate observations',
57
+ 'ct-intake': 'Normalise certificate observations offline',
43
58
  discover: 'Generate lookalike candidates offline',
44
59
  'discover-scan': 'Collect a supervised candidate review queue',
45
60
  posture: 'Review DNS and mail posture',
@@ -47,6 +62,8 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
47
62
  tls: 'Inspect one TLS connection',
48
63
  'registry-support': 'Explain local registry coverage',
49
64
  'registry-doctor': 'Diagnose saved registry collection',
65
+ 'registry-cohort': 'Aggregate target-free registry quality cohorts',
66
+ 'registry-scaffold': 'Create a sanitised registry fixture scaffold',
50
67
  'risk-calibrate': 'Replay reviewed Risk labels offline',
51
68
  'lookalike-calibrate': 'Summarise reviewed lookalike yield offline',
52
69
  'verify-artifact': 'Validate saved evidence offline',
@@ -58,10 +75,15 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
58
75
  'page-compare': 'Compare saved static page evidence',
59
76
  'mail-review': 'Review saved passive mail evidence',
60
77
  'review-evidence': 'Review supplied evidence offline',
78
+ brief: 'Build a decision brief from a saved lookup',
79
+ 'case-pack': 'Build a reviewed case package',
61
80
  'domain-control': 'Build or review a domain control manifest',
81
+ 'monitor-once': 'Run one bounded domain control review',
62
82
  assurance: 'Review domain change, recovery, or retirement plans',
83
+ 'change-packet': 'Build a reviewed change packet offline',
63
84
  'sharing-review': 'Lint an artefact before deliberate sharing',
64
85
  'workflow-plan': 'Plan a fixed investigation recipe',
86
+ 'workflow-run': 'Execute approved fixed-recipe steps',
65
87
  diff: 'Compare two saved domain lookups',
66
88
  reconcile: 'Reconcile independently labelled observations',
67
89
  timeline: 'Build same-domain history from saved lookups',
@@ -77,10 +99,12 @@ const VALUE_OPTIONS = Object.freeze({
77
99
  '--mail-profile': ['standard', 'defensive-no-mail', 'parked'],
78
100
  '--marking': ['clear', 'green', 'amber', 'amber-strict', 'red'],
79
101
  '--recipient-scope': ['public', 'community', 'organization', 'named-recipients'],
102
+ '--audience': ['internal', 'trusted', 'public'],
80
103
  '--concurrency': ['1', '2', '3', '4', '5', '6', '7', '8'],
81
104
  });
82
105
  const FILE_OPTIONS = Object.freeze([
83
106
  '--checkpoint',
107
+ '--config',
84
108
  '--allowlist',
85
109
  '--dictionary',
86
110
  '--output',
@@ -89,10 +113,22 @@ const FILE_OPTIONS = Object.freeze([
89
113
  '--public-key-file',
90
114
  '--snapshot',
91
115
  '--observation-snapshot',
116
+ '--previous',
117
+ '--resume',
118
+ ]);
119
+ const FILE_POSITIONAL_COMMANDS = new Set([
120
+ 'manifest',
121
+ 'map-observations',
122
+ 'oam-export',
123
+ 'ct-intake',
124
+ 'change-packet',
92
125
  ]);
93
126
  const TEXT_OPTIONS = Object.freeze([
94
127
  '--families',
95
128
  '--resolver',
129
+ '--fail-on',
130
+ '--expect-content-digest',
131
+ '--profile',
96
132
  '--purpose',
97
133
  '--observer',
98
134
  '--retired-selectors',
@@ -102,7 +138,7 @@ const TEXT_OPTIONS = Object.freeze([
102
138
  '--vantage',
103
139
  ]);
104
140
  function commandOptions(command) {
105
- return [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])].join(' ');
141
+ return [...COMMON_OPTIONS, ...OPTIONS_BY_COMMAND[command]].join(' ');
106
142
  }
107
143
  function bashCompletion() {
108
144
  const cases = CLI_COMMANDS.map((command) => ` ${command}) options="${commandOptions(command)}" ;;`).join('\n');
@@ -134,6 +170,9 @@ ${valueCases}
134
170
  ${cases}
135
171
  *) options="--help" ;;
136
172
  esac
173
+ case " ${[...FILE_POSITIONAL_COMMANDS].join(' ')} " in
174
+ *" \${command} "*) [[ "\${current}" != -* ]] && COMPREPLY=( $(compgen -f -- "\${current}") ) && return ;;
175
+ esac
137
176
  COMPREPLY=( $(compgen -W "\${options}" -- "\${current}") )
138
177
  }
139
178
  complete -F _whoisleuth_completion whoisleuth
@@ -171,6 +210,10 @@ ${valueCases}
171
210
  ${cases}
172
211
  *) options=(--help) ;;
173
212
  esac
213
+ if [[ " ${[...FILE_POSITIONAL_COMMANDS].join(' ')} " == *" \${command} "* && "\${words[CURRENT]}" != -* ]]; then
214
+ _files
215
+ return
216
+ fi
174
217
  compadd -- \${options[@]}
175
218
  }
176
219
  if [[ "\${funcstack[1]}" == "_whoisleuth" ]]; then
@@ -181,13 +224,16 @@ fi
181
224
  `;
182
225
  }
183
226
  function fishCompletion() {
184
- const commandLines = CLI_COMMANDS.map((command) => (`complete -c whoisleuth -n '__fish_use_subcommand' -a '${command}' -d '${COMMAND_DESCRIPTIONS[command] || command}'`));
185
- const optionLines = Object.entries(OPTIONS_BY_COMMAND).flatMap(([command, options]) => ([...COMMON_OPTIONS, ...options].map((option) => {
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' : ''}`));
228
+ const optionLine = (condition, option) => {
186
229
  const name = option.replace(/^--/u, '');
187
230
  const fileValue = FILE_OPTIONS.includes(option);
188
231
  const requiresValue = fileValue || [...Object.keys(VALUE_OPTIONS), ...TEXT_OPTIONS].includes(option);
189
- return `complete -c whoisleuth -n '__fish_seen_subcommand_from ${command}' -l ${name}${requiresValue ? ' -r' : ''}${fileValue ? ' -F' : ''}`;
190
- })));
232
+ return `complete -c whoisleuth -n '${condition}' -l ${name}${requiresValue ? ' -r' : ''}${fileValue ? ' -F' : ''}`;
233
+ };
234
+ const commonCondition = `__fish_seen_subcommand_from ${CLI_COMMANDS.join(' ')}`;
235
+ const commonOptionLines = COMMON_OPTIONS.map((option) => optionLine(commonCondition, option));
236
+ const optionLines = Object.entries(OPTIONS_BY_COMMAND).flatMap(([command, options]) => (options.map((option) => optionLine(`__fish_seen_subcommand_from ${command}`, option))));
191
237
  const valueLines = Object.entries(VALUE_OPTIONS).flatMap(([option, values]) => (values.map((value) => `complete -c whoisleuth -n '__fish_prev_arg_in ${option}' -a '${value}'`)));
192
238
  return `# WHOISleuth fish completion
193
239
  complete -c whoisleuth -f
@@ -196,6 +242,7 @@ complete -c whoisleuth -n '__fish_use_subcommand' -l version
196
242
  ${commandLines.join('\n')}
197
243
  complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell'
198
244
  complete -c whoisleuth -n '__fish_seen_subcommand_from workflow-plan' -a '${INVESTIGATION_PLAN_RECIPES.join(' ')}'
245
+ ${commonOptionLines.join('\n')}
199
246
  ${optionLines.join('\n')}
200
247
  ${valueLines.join('\n')}
201
248
  `;
@@ -203,7 +250,7 @@ ${valueLines.join('\n')}
203
250
  function powershellCompletion() {
204
251
  const commandOptions = Object.fromEntries(CLI_COMMANDS.map((command) => [
205
252
  command,
206
- [...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])],
253
+ [...COMMON_OPTIONS, ...OPTIONS_BY_COMMAND[command]],
207
254
  ]));
208
255
  return `# WHOISleuth PowerShell completion
209
256
  Register-ArgumentCompleter -Native -CommandName whoisleuth -ScriptBlock {
@@ -217,9 +264,16 @@ ${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @($
217
264
  'completion' = @('bash', 'zsh', 'fish', 'powershell')
218
265
  'workflow-plan' = @(${INVESTIGATION_PLAN_RECIPES.map((value) => `'${value}'`).join(', ')})
219
266
  }
267
+ $fileCommands = @(${[...FILE_POSITIONAL_COMMANDS].map((command) => `'${command}'`).join(', ')})
220
268
  $elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
221
269
  $command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
222
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
+ }
223
277
  $candidates = if ($elements.Count -le 2) {
224
278
  @($commands) + @('--help', '--version')
225
279
  } elseif ($command -eq 'completion' -and $elements.Count -le 3) {
@@ -0,0 +1,183 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ import { CliUsageError } from "./errors.mjs";
5
+ import { readBoundedRegularTextFile } from "../lib/bounded-file.mjs";
6
+ export const CLI_CONFIG_SCHEMA = 'whoisleuth.cli.config';
7
+ export const CLI_CONFIG_VERSION = 1;
8
+ export const MAX_CLI_CONFIG_BYTES = 64 * 1024;
9
+ const SAFE_BOOLEAN_DEFAULTS = new Set(['--no-color', '--summary', '--verbose', '--fast']);
10
+ const SAFE_VALUE_DEFAULTS = new Set(['--concurrency', '--observer', '--vantage']);
11
+ const OPTION_GROUPS = Object.freeze({
12
+ '--no-color': 'colour',
13
+ '--summary': 'detail',
14
+ '--verbose': 'detail',
15
+ '--fast': 'scan',
16
+ '--concurrency': 'concurrency',
17
+ '--observer': 'observer',
18
+ '--vantage': 'vantage',
19
+ });
20
+ function validProfileName(value) {
21
+ return typeof value === 'string' && /^[a-z0-9][a-z0-9_-]{0,39}$/u.test(value);
22
+ }
23
+ function safeArgumentText(value) {
24
+ if (typeof value !== 'string' || !value || value.length > 253 || /[\u0000-\u001f\u007f]/u.test(value)) {
25
+ throw new CliUsageError('CLI profile arguments must be bounded text without control characters.');
26
+ }
27
+ return value;
28
+ }
29
+ function validateProfileArguments(value) {
30
+ if (!Array.isArray(value) || value.length > 16)
31
+ throw new CliUsageError('Each CLI profile is limited to 16 safe default arguments.');
32
+ const argumentsList = value.map(safeArgumentText);
33
+ const validated = [];
34
+ const groups = new Set();
35
+ for (let index = 0; index < argumentsList.length; index += 1) {
36
+ const argument = argumentsList[index];
37
+ if (SAFE_BOOLEAN_DEFAULTS.has(argument)) {
38
+ const group = OPTION_GROUPS[argument];
39
+ if (groups.has(group))
40
+ throw new CliUsageError(`CLI profile contains conflicting ${group} defaults.`);
41
+ groups.add(group);
42
+ validated.push(argument);
43
+ continue;
44
+ }
45
+ if (!SAFE_VALUE_DEFAULTS.has(argument)) {
46
+ throw new CliUsageError(`CLI profiles cannot set ${argument}. Only presentation labels, fast mode, and bounded concurrency are supported.`);
47
+ }
48
+ const next = argumentsList[++index];
49
+ if (!next || next.startsWith('-'))
50
+ throw new CliUsageError(`${argument} in a CLI profile requires one value.`);
51
+ const group = OPTION_GROUPS[argument];
52
+ if (groups.has(group))
53
+ throw new CliUsageError(`CLI profile contains duplicate ${group} defaults.`);
54
+ if (argument === '--concurrency' && (!/^\d+$/u.test(next) || Number(next) < 1 || Number(next) > 8)) {
55
+ throw new CliUsageError('CLI profile concurrency must be an integer from 1 to 8.');
56
+ }
57
+ groups.add(group);
58
+ validated.push(argument, next);
59
+ }
60
+ return validated;
61
+ }
62
+ function parseProfileDocument(input) {
63
+ if (Buffer.byteLength(input, 'utf8') > MAX_CLI_CONFIG_BYTES)
64
+ throw new CliUsageError('CLI configuration is limited to 64 KiB.');
65
+ let parsed;
66
+ try {
67
+ parsed = JSON.parse(input.replace(/^\uFEFF/u, ''));
68
+ }
69
+ catch {
70
+ throw new CliUsageError('CLI configuration must be valid JSON.');
71
+ }
72
+ const root = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
73
+ if (root.schema !== CLI_CONFIG_SCHEMA || root.version !== CLI_CONFIG_VERSION || !root.profiles || typeof root.profiles !== 'object' || Array.isArray(root.profiles)) {
74
+ throw new CliUsageError(`CLI configuration must use ${CLI_CONFIG_SCHEMA} version ${CLI_CONFIG_VERSION}.`);
75
+ }
76
+ if (Object.keys(root).some((key) => !['schema', 'version', 'defaultProfile', 'profiles'].includes(key)))
77
+ throw new CliUsageError('CLI configuration contains an unsupported root field.');
78
+ if (root.defaultProfile !== undefined && !validProfileName(root.defaultProfile))
79
+ throw new CliUsageError('CLI configuration defaultProfile is invalid.');
80
+ const profileEntries = Object.entries(root.profiles);
81
+ if (profileEntries.length < 1 || profileEntries.length > 20)
82
+ throw new CliUsageError('CLI configuration requires from 1 to 20 profiles.');
83
+ const profiles = Object.fromEntries(profileEntries.map(([name, value]) => {
84
+ if (!validProfileName(name))
85
+ throw new CliUsageError(`CLI profile name "${name}" is invalid.`);
86
+ const profile = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
87
+ if (Object.keys(profile).some((key) => key !== 'arguments'))
88
+ throw new CliUsageError(`CLI profile "${name}" contains an unsupported field.`);
89
+ return [name, Object.freeze({ arguments: Object.freeze(validateProfileArguments(profile.arguments)) })];
90
+ }));
91
+ return Object.freeze({ schema: CLI_CONFIG_SCHEMA, version: CLI_CONFIG_VERSION, ...(root.defaultProfile ? { defaultProfile: root.defaultProfile } : {}), profiles: Object.freeze(profiles) });
92
+ }
93
+ async function readConfigFile(path) {
94
+ return readBoundedRegularTextFile(path, {
95
+ maximumBytes: MAX_CLI_CONFIG_BYTES,
96
+ label: 'CLI configuration',
97
+ allowSymbolicLink: true,
98
+ });
99
+ }
100
+ function suppliedGroups(argumentsList) {
101
+ const groups = new Set();
102
+ for (const argument of argumentsList) {
103
+ const group = OPTION_GROUPS[argument];
104
+ if (group)
105
+ groups.add(group);
106
+ if (argument === '--deep')
107
+ groups.add('scan');
108
+ }
109
+ return groups;
110
+ }
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
+ }
120
+ const retained = [];
121
+ let configPath = null;
122
+ let profileName = null;
123
+ for (let index = 0; index < argv.length; index += 1) {
124
+ const argument = argv[index];
125
+ if (argument === undefined)
126
+ break;
127
+ if (argument === '--config' || argument === '--profile') {
128
+ const next = argv[++index];
129
+ if (!next || next.startsWith('-'))
130
+ throw new CliUsageError(`${argument} requires one value.`);
131
+ if (argument === '--config') {
132
+ if (configPath !== null)
133
+ throw new CliUsageError('--config may be supplied only once.');
134
+ configPath = next;
135
+ }
136
+ else {
137
+ if (profileName !== null)
138
+ throw new CliUsageError('--profile may be supplied only once.');
139
+ if (!validProfileName(next))
140
+ throw new CliUsageError('--profile requires a lower-case name of at most 40 characters.');
141
+ profileName = next;
142
+ }
143
+ }
144
+ else
145
+ retained.push(argument);
146
+ }
147
+ if (!configPath && !profileName)
148
+ return retained;
149
+ const environment = options.environment || process.env;
150
+ const baseDirectory = environment.XDG_CONFIG_HOME || join(options.homeDirectory || homedir(), '.config');
151
+ const selectedPath = resolve(configPath || join(baseDirectory, 'whoisleuth', 'config.json'));
152
+ let input;
153
+ try {
154
+ input = await (options.readConfig || readConfigFile)(selectedPath);
155
+ }
156
+ catch (error) {
157
+ if (error instanceof CliUsageError)
158
+ throw error;
159
+ throw new CliUsageError('CLI configuration could not be read as a bounded regular file.');
160
+ }
161
+ const document = parseProfileDocument(input);
162
+ const selectedName = profileName || document.defaultProfile;
163
+ if (!selectedName)
164
+ throw new CliUsageError('CLI configuration requires --profile or a defaultProfile.');
165
+ const selected = document.profiles[selectedName];
166
+ if (!selected)
167
+ throw new CliUsageError(`CLI profile "${selectedName}" was not found.`);
168
+ if (!retained.length)
169
+ throw new CliUsageError('A CLI profile must accompany a command.');
170
+ const explicitGroups = suppliedGroups(retained.slice(1));
171
+ const defaults = [];
172
+ for (let index = 0; index < selected.arguments.length; index += 1) {
173
+ const argument = selected.arguments[index];
174
+ const group = OPTION_GROUPS[argument];
175
+ const hasValue = SAFE_VALUE_DEFAULTS.has(argument);
176
+ const value = hasValue ? selected.arguments[++index] : undefined;
177
+ if (explicitGroups.has(group))
178
+ continue;
179
+ defaults.push(argument, ...(value ? [value] : []));
180
+ }
181
+ return [retained[0], ...defaults, ...retained.slice(1)];
182
+ }
183
+ export { parseProfileDocument };
@@ -0,0 +1,159 @@
1
+ import { domainToASCII } from 'node:url';
2
+ import { exactKeys, requireBoundedString, requireIsoTimestamp, requireRecord, } from "../lib/bounded-contract-normalizers.mjs";
3
+ export const CT_EVENT_BATCH_SCHEMA = 'whoisleuth.ct-event-batch';
4
+ export const CT_EVENT_BATCH_VERSION = 1;
5
+ export const MAX_CT_EVENT_INPUT_BYTES = 4 * 1024 * 1024;
6
+ export const MAX_CT_EVENTS = 500;
7
+ export const MAX_CT_EVENT_NAMES = 100;
8
+ export const MAX_CT_EXTERNAL_FINDINGS = 100;
9
+ export const MAX_CT_EXTERNAL_DOMAINS = 25;
10
+ export const MAX_CT_EXTERNAL_FINDINGS_PER_DOMAIN = 20;
11
+ const ROOT_KEYS = new Set(['schema', 'version', 'source', 'events']);
12
+ const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
13
+ const EVENT_KEYS = new Set([
14
+ 'logId',
15
+ 'observedAt',
16
+ 'certificateSha256',
17
+ 'dnsNames',
18
+ 'issuer',
19
+ 'notAfter',
20
+ 'completeness',
21
+ 'limitations',
22
+ ]);
23
+ const SHA256_RE = /^[a-f0-9]{64}$/u;
24
+ function optionalText(value, label, maximum) {
25
+ if (value === undefined || value === null || value === '')
26
+ return null;
27
+ return requireBoundedString(value, label, maximum);
28
+ }
29
+ function domain(value, label) {
30
+ const supplied = requireBoundedString(value, label, 255).toLowerCase().replace(/^\*\./u, '').replace(/\.$/u, '');
31
+ const ascii = domainToASCII(supplied).toLowerCase();
32
+ if (!ascii || ascii.length > 253 || !ascii.includes('.') || ascii.split('.').some((part) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(part))) {
33
+ throw new TypeError(`${label} must be a valid DNS name.`);
34
+ }
35
+ return ascii;
36
+ }
37
+ function boundedLimitations(value, label) {
38
+ if (value === undefined)
39
+ return Object.freeze([]);
40
+ if (!Array.isArray(value) || value.length > 8)
41
+ throw new TypeError(`${label} must contain no more than 8 entries.`);
42
+ return Object.freeze([...new Set(value.map((item, index) => requireBoundedString(item, `${label}[${index}]`, 240)))]);
43
+ }
44
+ export function buildCtEventFindings(inputRaw) {
45
+ const input = requireRecord(inputRaw, 'Certificate event batch');
46
+ if (input.schema !== CT_EVENT_BATCH_SCHEMA || input.version !== CT_EVENT_BATCH_VERSION) {
47
+ throw new TypeError(`Certificate event input must use ${CT_EVENT_BATCH_SCHEMA} version ${CT_EVENT_BATCH_VERSION}.`);
48
+ }
49
+ exactKeys(input, ROOT_KEYS, 'Certificate event batch');
50
+ const sourceInput = requireRecord(input.source, 'source');
51
+ exactKeys(sourceInput, SOURCE_KEYS, 'source');
52
+ const source = Object.freeze({
53
+ name: requireBoundedString(sourceInput.name, 'source.name', 80),
54
+ reference: optionalText(sourceInput.reference, 'source.reference', 500),
55
+ collectedAt: sourceInput.collectedAt === null || sourceInput.collectedAt === undefined
56
+ ? null
57
+ : requireIsoTimestamp(sourceInput.collectedAt, 'source.collectedAt'),
58
+ });
59
+ if (!Array.isArray(input.events) || input.events.length < 1 || input.events.length > MAX_CT_EVENTS) {
60
+ throw new TypeError(`events must contain between 1 and ${MAX_CT_EVENTS} entries.`);
61
+ }
62
+ const candidates = input.events.flatMap((raw, eventIndex) => {
63
+ const event = requireRecord(raw, `events[${eventIndex}]`);
64
+ exactKeys(event, EVENT_KEYS, `events[${eventIndex}]`);
65
+ const logId = requireBoundedString(event.logId, `events[${eventIndex}].logId`, 200);
66
+ const observedAt = requireIsoTimestamp(event.observedAt, `events[${eventIndex}].observedAt`);
67
+ const certificateSha256 = requireBoundedString(event.certificateSha256, `events[${eventIndex}].certificateSha256`, 64).toLowerCase();
68
+ if (!SHA256_RE.test(certificateSha256))
69
+ throw new TypeError(`events[${eventIndex}].certificateSha256 must be a SHA-256 hexadecimal digest.`);
70
+ if (!Array.isArray(event.dnsNames) || event.dnsNames.length < 1 || event.dnsNames.length > MAX_CT_EVENT_NAMES) {
71
+ throw new TypeError(`events[${eventIndex}].dnsNames must contain between 1 and ${MAX_CT_EVENT_NAMES} entries.`);
72
+ }
73
+ const names = [...new Set(event.dnsNames.map((name, nameIndex) => domain(name, `events[${eventIndex}].dnsNames[${nameIndex}]`)))].sort();
74
+ const issuer = optionalText(event.issuer, `events[${eventIndex}].issuer`, 160);
75
+ const notAfter = event.notAfter === null || event.notAfter === undefined
76
+ ? null
77
+ : requireIsoTimestamp(event.notAfter, `events[${eventIndex}].notAfter`);
78
+ const completeness = event.completeness;
79
+ if (completeness !== 'complete' && completeness !== 'partial') {
80
+ throw new TypeError(`events[${eventIndex}].completeness must be complete or partial.`);
81
+ }
82
+ const limitations = boundedLimitations(event.limitations, `events[${eventIndex}].limitations`);
83
+ return names.map((name) => ({
84
+ domain: name,
85
+ category: 'certificate',
86
+ evidenceClass: 'deployment_observation',
87
+ summary: `Supplied certificate …${certificateSha256.slice(-12)} included ${name} among ${names.length} bounded DNS name${names.length === 1 ? '' : 's'}.`,
88
+ observedAt,
89
+ completeness,
90
+ limitations,
91
+ reference: `Certificate event ${logId}`,
92
+ structuredObservation: {
93
+ sourceSchema: 'whoisleuth.certificate-observation-rows',
94
+ sourceVersion: 1,
95
+ field: 'certificateSha256',
96
+ value: certificateSha256,
97
+ issuer,
98
+ notAfter,
99
+ },
100
+ }));
101
+ });
102
+ const seen = new Set();
103
+ const unique = candidates
104
+ .sort((left, right) => left.domain.localeCompare(right.domain)
105
+ || left.observedAt.localeCompare(right.observedAt)
106
+ || left.structuredObservation.value.localeCompare(right.structuredObservation.value))
107
+ .filter((item) => {
108
+ const key = `${item.domain}\u0000${item.observedAt}\u0000${item.structuredObservation.value}`;
109
+ if (seen.has(key))
110
+ return false;
111
+ seen.add(key);
112
+ return true;
113
+ });
114
+ const domainCounts = new Map();
115
+ const acceptedDomains = new Set();
116
+ const selected = [];
117
+ for (const finding of unique) {
118
+ if (!acceptedDomains.has(finding.domain) && acceptedDomains.size >= MAX_CT_EXTERNAL_DOMAINS)
119
+ continue;
120
+ const domainCount = domainCounts.get(finding.domain) ?? 0;
121
+ if (domainCount >= MAX_CT_EXTERNAL_FINDINGS_PER_DOMAIN)
122
+ continue;
123
+ acceptedDomains.add(finding.domain);
124
+ domainCounts.set(finding.domain, domainCount + 1);
125
+ selected.push(finding);
126
+ if (selected.length >= MAX_CT_EXTERNAL_FINDINGS)
127
+ break;
128
+ }
129
+ const truncated = selected.length < unique.length;
130
+ const findings = selected.map((finding) => Object.freeze({
131
+ ...finding,
132
+ limitations: Object.freeze([
133
+ ...finding.limitations,
134
+ 'The supplied event is an observation, not proof that the certificate was served by the named domain or that the domain operator requested it.',
135
+ ...(truncated ? [`The batch contained ${unique.length} unique domain observations; this import preserves the first ${MAX_CT_EXTERNAL_FINDINGS} in deterministic order.`] : []),
136
+ ].slice(0, 8)),
137
+ }));
138
+ return Object.freeze({
139
+ schema: 'whoisleuth.external-findings',
140
+ schemaVersion: 3,
141
+ source,
142
+ findings: Object.freeze(findings),
143
+ });
144
+ }
145
+ export function formatCtEventFindings(document) {
146
+ const domains = new Set(document.findings.map((finding) => finding.domain));
147
+ const certificates = new Set(document.findings.map((finding) => finding.structuredObservation.value));
148
+ return [
149
+ 'Certificate event intake',
150
+ `Source ${document.source.name}`,
151
+ `Findings ${document.findings.length}`,
152
+ `Domains ${domains.size}`,
153
+ `Certificates ${certificates.size}`,
154
+ '',
155
+ 'The JSON form can be imported through the Console external-findings workflow.',
156
+ 'A certificate event is a review lead, not proof that the certificate was served or requested by the domain operator.',
157
+ '',
158
+ ].join('\n');
159
+ }
@@ -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) {