@slicedearth/whoisleuth-cli 1.32.2 → 1.36.1
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 +43 -5
- package/SECURITY.md +35 -0
- package/cli/arguments.mjs +180 -8
- package/cli/artifact-verify.mjs +32 -0
- package/cli/bulk-command-runner.mjs +3 -1
- package/cli/bulk-output.mjs +6 -1
- package/cli/bulk.mjs +1 -2
- package/cli/command-catalogue.mjs +33 -0
- package/cli/completion.mjs +70 -7
- package/cli/doctor.mjs +78 -43
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/terminal.mjs +13 -7
- package/cli/investigation-plan.mjs +112 -0
- package/cli/lookup-command-runner.mjs +8 -0
- package/cli/lookup-diff.mjs +16 -5
- package/cli/lookup-plan.mjs +99 -0
- package/cli/lookup-timeline.mjs +123 -0
- package/cli/mail-review.mjs +47 -2
- package/cli/manual.mjs +2 -1
- package/cli/offline-evidence-review.mjs +141 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/runner.mjs +214 -11
- package/docs/cli.md +200 -45
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/lib/availability.mjs +30 -10
- package/lib/bulk-limits.mjs +13 -0
- package/lib/csv.mjs +1 -1
- package/lib/ct-response-bounds.mjs +9 -0
- package/lib/ct-search.mjs +66 -12
- package/lib/dns-delegation-health.mjs +56 -5
- package/lib/dnssec-evidence-validation.mjs +235 -0
- package/lib/domain-control-manifest.mjs +339 -0
- package/lib/domain-posture.mjs +2 -1
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +3 -3
- package/lib/evidence-report.mjs +3 -0
- package/lib/favicon.mjs +3 -8
- package/lib/html-signals.mjs +4 -0
- package/lib/http-evidence-bounds.mjs +4 -0
- package/lib/http-intelligence.mjs +2 -1
- package/lib/ip-prefix.mjs +54 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/perceptual-hash-comparison.mjs +34 -0
- package/lib/perceptual-hash.mjs +3 -31
- package/lib/project-metadata.mjs +8 -0
- package/lib/provider-policy-admission.mjs +49 -0
- package/lib/rdap-search-workbench.mjs +157 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +11 -10
- package/lib/response-policy.mjs +81 -6
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threatfox-intelligence.mjs +10 -5
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +10 -5
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +11 -4
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +7 -5
- package/third-party-notices.txt +2 -2
package/cli/completion.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { CLI_COMMANDS } from "./arguments.mjs";
|
|
2
|
+
import { INVESTIGATION_PLAN_RECIPES } from "./investigation-plan.mjs";
|
|
2
3
|
const COMMON_OPTIONS = Object.freeze(['--help', '--output', '--force']);
|
|
3
4
|
const OPTIONS_BY_COMMAND = Object.freeze({
|
|
4
|
-
lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--summary', '--verbose', '--strict-exit', '--events', '--quiet', '--no-color'],
|
|
5
|
-
bulk: ['--json', '--jsonl', '--csv', '--domains', '--registered-only', '--inconclusive-only', '--fast', '--deep', '--concurrency', '--checkpoint', '--resume', '--events', '--quiet', '--no-color'],
|
|
5
|
+
lookup: ['--json', '--markdown', '--html', '--fast', '--deep', '--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'],
|
|
6
7
|
'ct-search': ['--json', '--quiet', '--no-color'],
|
|
7
8
|
discover: ['--tlds', '--preset', '--families', '--keyboard', '--dictionary', '--snapshot', '--json', '--jsonl', '--domains', '--quiet', '--no-color'],
|
|
8
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'],
|
|
@@ -19,9 +20,14 @@ const OPTIONS_BY_COMMAND = Object.freeze({
|
|
|
19
20
|
compare: ['--json', '--quiet', '--no-color'],
|
|
20
21
|
'page-compare': ['--json', '--quiet', '--no-color'],
|
|
21
22
|
'mail-review': ['--json', '--quiet', '--no-color'],
|
|
23
|
+
'review-evidence': ['--json', '--quiet', '--no-color'],
|
|
24
|
+
'domain-control': ['--json', '--quiet', '--no-color'],
|
|
25
|
+
'workflow-plan': ['--json', '--quiet', '--no-color'],
|
|
22
26
|
diff: ['--json', '--quiet', '--no-color'],
|
|
27
|
+
timeline: ['--json', '--quiet', '--no-color'],
|
|
23
28
|
export: ['--markdown', '--html', '--compact'],
|
|
24
29
|
completion: [],
|
|
30
|
+
commands: ['--json', '--quiet', '--no-color'],
|
|
25
31
|
doctor: ['--network', '--json', '--quiet', '--no-color'],
|
|
26
32
|
manual: [],
|
|
27
33
|
});
|
|
@@ -36,7 +42,7 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
|
|
|
36
42
|
tls: 'Inspect one TLS connection',
|
|
37
43
|
'registry-support': 'Explain local registry coverage',
|
|
38
44
|
'risk-calibrate': 'Replay reviewed Risk labels offline',
|
|
39
|
-
'verify-artifact': 'Validate
|
|
45
|
+
'verify-artifact': 'Validate saved evidence offline',
|
|
40
46
|
'inspect-archive': 'Inspect an archive locally',
|
|
41
47
|
'sign-artifact': 'Sign a reviewed artifact locally',
|
|
42
48
|
'verify-signature': 'Verify a signed evidence package',
|
|
@@ -44,9 +50,14 @@ const COMMAND_DESCRIPTIONS = Object.freeze({
|
|
|
44
50
|
compare: 'Compare registry publications in one lookup',
|
|
45
51
|
'page-compare': 'Compare saved static page evidence',
|
|
46
52
|
'mail-review': 'Review saved passive mail evidence',
|
|
53
|
+
'review-evidence': 'Review supplied evidence offline',
|
|
54
|
+
'domain-control': 'Build or review a domain control manifest',
|
|
55
|
+
'workflow-plan': 'Plan a fixed investigation recipe',
|
|
47
56
|
diff: 'Compare two saved domain lookups',
|
|
57
|
+
timeline: 'Build same-domain history from saved lookups',
|
|
48
58
|
export: 'Convert a lookup to an evidence report',
|
|
49
59
|
completion: 'Print shell completion',
|
|
60
|
+
commands: 'List installed command contracts',
|
|
50
61
|
doctor: 'Check the local CLI runtime',
|
|
51
62
|
manual: 'Print the generated manual page',
|
|
52
63
|
});
|
|
@@ -92,7 +103,11 @@ _whoisleuth_completion() {
|
|
|
92
103
|
return
|
|
93
104
|
fi
|
|
94
105
|
if [[ "\${command}" == "completion" && \${COMP_CWORD} -eq 2 ]]; then
|
|
95
|
-
COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${current}") )
|
|
106
|
+
COMPREPLY=( $(compgen -W "bash zsh fish powershell" -- "\${current}") )
|
|
107
|
+
return
|
|
108
|
+
fi
|
|
109
|
+
if [[ "\${command}" == "workflow-plan" && \${COMP_CWORD} -eq 2 ]]; then
|
|
110
|
+
COMPREPLY=( $(compgen -W "${INVESTIGATION_PLAN_RECIPES.join(' ')}" -- "\${current}") )
|
|
96
111
|
return
|
|
97
112
|
fi
|
|
98
113
|
case "\${previous}" in
|
|
@@ -125,7 +140,11 @@ _whoisleuth() {
|
|
|
125
140
|
command="\${words[2]}"
|
|
126
141
|
previous="\${words[CURRENT-1]}"
|
|
127
142
|
if [[ "\${command}" == "completion" && CURRENT -eq 3 ]]; then
|
|
128
|
-
compadd -- bash zsh fish
|
|
143
|
+
compadd -- bash zsh fish powershell
|
|
144
|
+
return
|
|
145
|
+
fi
|
|
146
|
+
if [[ "\${command}" == "workflow-plan" && CURRENT -eq 3 ]]; then
|
|
147
|
+
compadd -- ${INVESTIGATION_PLAN_RECIPES.join(' ')}
|
|
129
148
|
return
|
|
130
149
|
fi
|
|
131
150
|
case "\${previous}" in
|
|
@@ -160,16 +179,60 @@ complete -c whoisleuth -f
|
|
|
160
179
|
complete -c whoisleuth -n '__fish_use_subcommand' -l help
|
|
161
180
|
complete -c whoisleuth -n '__fish_use_subcommand' -l version
|
|
162
181
|
${commandLines.join('\n')}
|
|
163
|
-
complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish'
|
|
182
|
+
complete -c whoisleuth -n '__fish_seen_subcommand_from completion' -a 'bash zsh fish powershell'
|
|
183
|
+
complete -c whoisleuth -n '__fish_seen_subcommand_from workflow-plan' -a '${INVESTIGATION_PLAN_RECIPES.join(' ')}'
|
|
164
184
|
${optionLines.join('\n')}
|
|
165
185
|
${valueLines.join('\n')}
|
|
166
186
|
`;
|
|
167
187
|
}
|
|
188
|
+
function powershellCompletion() {
|
|
189
|
+
const commandOptions = Object.fromEntries(CLI_COMMANDS.map((command) => [
|
|
190
|
+
command,
|
|
191
|
+
[...COMMON_OPTIONS, ...(OPTIONS_BY_COMMAND[command] || [])],
|
|
192
|
+
]));
|
|
193
|
+
return `# WHOISleuth PowerShell completion
|
|
194
|
+
Register-ArgumentCompleter -Native -CommandName whoisleuth -ScriptBlock {
|
|
195
|
+
param($wordToComplete, $commandAst, $cursorPosition)
|
|
196
|
+
$commands = @(${CLI_COMMANDS.map((command) => `'${command}'`).join(', ')})
|
|
197
|
+
$options = @{
|
|
198
|
+
${Object.entries(commandOptions).map(([command, options]) => ` '${command}' = @(${options.map((option) => `'${option}'`).join(', ')})`).join('\n')}
|
|
199
|
+
}
|
|
200
|
+
$values = @{
|
|
201
|
+
${Object.entries(VALUE_OPTIONS).map(([option, values]) => ` '${option}' = @(${values.map((value) => `'${value}'`).join(', ')})`).join('\n')}
|
|
202
|
+
'completion' = @('bash', 'zsh', 'fish', 'powershell')
|
|
203
|
+
'workflow-plan' = @(${INVESTIGATION_PLAN_RECIPES.map((value) => `'${value}'`).join(', ')})
|
|
204
|
+
}
|
|
205
|
+
$elements = @($commandAst.CommandElements | ForEach-Object { $_.Extent.Text })
|
|
206
|
+
$command = if ($elements.Count -gt 1) { $elements[1] } else { '' }
|
|
207
|
+
$previous = if ($elements.Count -gt 1) { $elements[$elements.Count - 1] } else { '' }
|
|
208
|
+
$candidates = if ($elements.Count -le 2) {
|
|
209
|
+
@($commands) + @('--help', '--version')
|
|
210
|
+
} elseif ($command -eq 'completion' -and $elements.Count -le 3) {
|
|
211
|
+
$values['completion']
|
|
212
|
+
} elseif ($command -eq 'workflow-plan' -and $elements.Count -le 3) {
|
|
213
|
+
$values['workflow-plan']
|
|
214
|
+
} elseif ($values.ContainsKey($previous)) {
|
|
215
|
+
$values[$previous]
|
|
216
|
+
} elseif ($options.ContainsKey($command)) {
|
|
217
|
+
$options[$command]
|
|
218
|
+
} else {
|
|
219
|
+
@('--help')
|
|
220
|
+
}
|
|
221
|
+
foreach ($candidate in $candidates) {
|
|
222
|
+
if ($candidate.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase)) {
|
|
223
|
+
[System.Management.Automation.CompletionResult]::new($candidate, $candidate, 'ParameterValue', $candidate)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
`;
|
|
228
|
+
}
|
|
168
229
|
function buildShellCompletion(shell) {
|
|
169
230
|
if (shell === 'bash')
|
|
170
231
|
return bashCompletion();
|
|
171
232
|
if (shell === 'zsh')
|
|
172
233
|
return zshCompletion();
|
|
173
|
-
|
|
234
|
+
if (shell === 'fish')
|
|
235
|
+
return fishCompletion();
|
|
236
|
+
return powershellCompletion();
|
|
174
237
|
}
|
|
175
238
|
export { buildShellCompletion, };
|
package/cli/doctor.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolvePublicAddresses } from "../lib/safe-fetch.mjs";
|
|
1
|
+
import { resolvePublicAddresses, safeFetch } from "../lib/safe-fetch.mjs";
|
|
2
2
|
import { withTimeout } from "../lib/abort.mjs";
|
|
3
3
|
import { whoisQuery } from "../lib/whois-transport.mjs";
|
|
4
4
|
const DOCTOR_SCHEMA = 'whoisleuth.cli.doctor';
|
|
@@ -6,6 +6,7 @@ const DOCTOR_VERSION = 1;
|
|
|
6
6
|
const MINIMUM_NODE_MAJOR = 24;
|
|
7
7
|
const NETWORK_HOST = 'whois.iana.org';
|
|
8
8
|
const NETWORK_QUERY = 'example.com';
|
|
9
|
+
const NETWORK_HTTPS_URL = 'https://data.iana.org/rdap/dns.json';
|
|
9
10
|
const NETWORK_TIMEOUT_MS = 6_000;
|
|
10
11
|
const MAX_DOCTOR_DETAIL_LENGTH = 180;
|
|
11
12
|
function boundedDetail(value, fallback) {
|
|
@@ -57,58 +58,92 @@ async function buildDoctorReport(options) {
|
|
|
57
58
|
id: 'network',
|
|
58
59
|
label: 'Network checks',
|
|
59
60
|
state: 'skipped',
|
|
60
|
-
detail: 'Not requested. Run doctor --network to test bounded public DNS and WHOIS connectivity.',
|
|
61
|
+
detail: 'Not requested. Run doctor --network to test bounded public DNS, HTTPS, and WHOIS connectivity.',
|
|
61
62
|
}));
|
|
62
63
|
}
|
|
63
64
|
else {
|
|
64
65
|
const resolve = options.resolveAddresses || resolvePublicAddresses;
|
|
66
|
+
const fetchHttps = options.fetchHttps || safeFetch;
|
|
65
67
|
const query = options.queryWhois || whoisQuery;
|
|
66
68
|
const networkTimeoutMs = Number.isSafeInteger(options.networkTimeoutMs)
|
|
67
69
|
&& Number(options.networkTimeoutMs) >= 1
|
|
68
70
|
&& Number(options.networkTimeoutMs) <= NETWORK_TIMEOUT_MS
|
|
69
71
|
? Number(options.networkTimeoutMs)
|
|
70
72
|
: NETWORK_TIMEOUT_MS;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
73
|
+
const networkChecks = await Promise.all([
|
|
74
|
+
(async () => {
|
|
75
|
+
try {
|
|
76
|
+
const addresses = await withTimeout(() => resolve(NETWORK_HOST), networkTimeoutMs, `Public DNS check timed out after ${networkTimeoutMs} ms.`);
|
|
77
|
+
return Object.freeze({
|
|
78
|
+
id: 'public_dns',
|
|
79
|
+
label: 'Public DNS',
|
|
80
|
+
state: addresses.length > 0 ? 'pass' : 'partial',
|
|
81
|
+
detail: addresses.length > 0
|
|
82
|
+
? `${addresses.length} public address${addresses.length === 1 ? '' : 'es'} validated; addresses were not retained.`
|
|
83
|
+
: 'The fixed diagnostic host returned no validated public addresses.',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
id: 'public_dns',
|
|
89
|
+
label: 'Public DNS',
|
|
90
|
+
state: 'partial',
|
|
91
|
+
detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'Public DNS check failed.'),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
})(),
|
|
95
|
+
(async () => {
|
|
96
|
+
try {
|
|
97
|
+
const response = await withTimeout(() => fetchHttps(NETWORK_HTTPS_URL, {
|
|
98
|
+
headers: { accept: 'application/json' },
|
|
99
|
+
signal: AbortSignal.timeout(networkTimeoutMs),
|
|
100
|
+
}), networkTimeoutMs, `HTTPS check timed out after ${networkTimeoutMs} ms.`);
|
|
101
|
+
const status = response.status;
|
|
102
|
+
await response.body?.cancel().catch(() => { });
|
|
103
|
+
return Object.freeze({
|
|
104
|
+
id: 'https_transport',
|
|
105
|
+
label: 'HTTPS transport',
|
|
106
|
+
state: response.ok ? 'pass' : 'partial',
|
|
107
|
+
detail: response.ok
|
|
108
|
+
? `The fixed IANA RDAP bootstrap endpoint returned HTTP ${status}; response content was not retained.`
|
|
109
|
+
: `The fixed IANA RDAP bootstrap endpoint returned HTTP ${status}.`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
return Object.freeze({
|
|
114
|
+
id: 'https_transport',
|
|
115
|
+
label: 'HTTPS transport',
|
|
116
|
+
state: 'partial',
|
|
117
|
+
detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'HTTPS transport check failed.'),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
})(),
|
|
121
|
+
(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const response = await withTimeout(() => query(NETWORK_HOST, NETWORK_QUERY, {
|
|
124
|
+
timeoutMs: Math.max(1, networkTimeoutMs - 1_000),
|
|
125
|
+
totalDeadlineMs: networkTimeoutMs,
|
|
126
|
+
}), networkTimeoutMs, `WHOIS check timed out after ${networkTimeoutMs} ms.`);
|
|
127
|
+
return Object.freeze({
|
|
128
|
+
id: 'whois_transport',
|
|
129
|
+
label: 'WHOIS transport',
|
|
130
|
+
state: response.trim() ? 'pass' : 'partial',
|
|
131
|
+
detail: response.trim()
|
|
132
|
+
? 'A bounded port 43 response was received; response content was not retained.'
|
|
133
|
+
: 'The fixed diagnostic query returned an empty response.',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
return Object.freeze({
|
|
138
|
+
id: 'whois_transport',
|
|
139
|
+
label: 'WHOIS transport',
|
|
140
|
+
state: 'partial',
|
|
141
|
+
detail: boundedDetail(error && typeof error === 'object' && 'message' in error ? error.message : error, 'WHOIS transport check failed.'),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
})(),
|
|
145
|
+
]);
|
|
146
|
+
checks.push(...networkChecks);
|
|
112
147
|
}
|
|
113
148
|
const state = checks.some((check) => check.state === 'partial') ? 'partial' : 'pass';
|
|
114
149
|
return Object.freeze({
|
package/cli/export-evidence.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import { projectCliLookupComparisonInput } from "./compare.mjs";
|
|
2
3
|
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
4
|
+
const require = createRequire(import.meta.url);
|
|
5
|
+
const { version: APPLICATION_VERSION } = require('../package.json');
|
|
3
6
|
function objectOrNull(value) {
|
|
4
7
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
8
|
}
|
|
@@ -14,7 +17,11 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
|
|
|
14
17
|
throw new TypeError('Lookup evidence export dependency is required.');
|
|
15
18
|
}
|
|
16
19
|
const buildLookupEvidence = dependency.buildLookupEvidence;
|
|
17
|
-
const result = objectOrNull(buildLookupEvidence(source, {
|
|
20
|
+
const result = objectOrNull(buildLookupEvidence(source, {
|
|
21
|
+
generatedAt,
|
|
22
|
+
idnAnalysis: null,
|
|
23
|
+
applicationVersion: APPLICATION_VERSION,
|
|
24
|
+
}));
|
|
18
25
|
if (!result
|
|
19
26
|
|| result.schema !== dependency.LOOKUP_EVIDENCE_SCHEMA
|
|
20
27
|
|| result.schemaVersion !== dependency.LOOKUP_EVIDENCE_SCHEMA_VERSION) {
|
|
@@ -25,4 +32,4 @@ function buildCliEvidenceExport(text, evidenceModule, generatedAt = new Date().t
|
|
|
25
32
|
function formatCliEvidenceExport(document, compact = false) {
|
|
26
33
|
return `${JSON.stringify(document, null, compact ? 0 : 2)}\n`;
|
|
27
34
|
}
|
|
28
|
-
export { buildCliEvidenceExport, formatCliEvidenceExport };
|
|
35
|
+
export { APPLICATION_VERSION, buildCliEvidenceExport, formatCliEvidenceExport };
|
package/cli/formatters/html.mjs
CHANGED
|
@@ -28,7 +28,7 @@ function renderRegistrarComparison(comparison) {
|
|
|
28
28
|
}
|
|
29
29
|
function formatLookupEvidenceHtml(document) {
|
|
30
30
|
const report = buildLookupEvidenceReport(document);
|
|
31
|
-
const title = `Lookup evidence report
|
|
31
|
+
const title = `Lookup evidence report: ${report.title}`;
|
|
32
32
|
return `<!doctype html>
|
|
33
33
|
<html lang="en">
|
|
34
34
|
<head>
|
|
@@ -271,7 +271,6 @@ function formatTerminalRegistrySupport(document) {
|
|
|
271
271
|
`WHOIS scope ${supportLabel(whois.queryScope)}`,
|
|
272
272
|
`WHOIS encoding ${supportLabel(whois.encodingProfile)}`,
|
|
273
273
|
`WHOIS parser ${supportLabel(whois.parserProfile)}`,
|
|
274
|
-
`Fallback ${profile.fallbackProfile ? supportLabel(profile.fallbackProfile) : 'None'}`,
|
|
275
274
|
`Fixture states ${fixtures.length ? fixtures.map((value) => supportLabel(value)).join(', ') : 'None documented'}`,
|
|
276
275
|
];
|
|
277
276
|
for (const file of files)
|
|
@@ -284,12 +283,12 @@ function formatTerminalRegistrySupport(document) {
|
|
|
284
283
|
function formatTerminalBulk(items, metadata) {
|
|
285
284
|
const lines = items.map((item) => {
|
|
286
285
|
if (!item.ok)
|
|
287
|
-
return `! ${safeTerminalValue(item.query)}
|
|
286
|
+
return `! ${safeTerminalValue(item.query)}: ${safeTerminalValue(item.error, 'Lookup failed')}`;
|
|
288
287
|
const result = terminalRecord(item.result);
|
|
289
288
|
const availability = terminalRecord(result.availability);
|
|
290
289
|
const state = titleCase(availability.state);
|
|
291
290
|
const confidence = titleCase(availability.confidence);
|
|
292
|
-
return `✓ ${safeTerminalValue(item.query)}
|
|
291
|
+
return `✓ ${safeTerminalValue(item.query)}: ${state} (${confidence} confidence)`;
|
|
293
292
|
});
|
|
294
293
|
const succeeded = items.filter((item) => item.ok).length;
|
|
295
294
|
lines.push('');
|
|
@@ -302,6 +301,11 @@ function formatTerminalBulk(items, metadata) {
|
|
|
302
301
|
}
|
|
303
302
|
function formatTerminalCtSearch(document) {
|
|
304
303
|
const matches = Array.isArray(document.matches) ? document.matches : [];
|
|
304
|
+
const certificateGroups = Array.isArray(document.certificateGroups) ? document.certificateGroups : [];
|
|
305
|
+
const crossDomainGroups = certificateGroups.filter((value) => {
|
|
306
|
+
const group = terminalRecord(value);
|
|
307
|
+
return Array.isArray(group.domains) && group.domains.length > 1;
|
|
308
|
+
});
|
|
305
309
|
const visible = matches.slice(0, MAX_CT_TERMINAL_MATCHES);
|
|
306
310
|
const observation = terminalRecord(document.observation);
|
|
307
311
|
const lines = [
|
|
@@ -310,7 +314,9 @@ function formatTerminalCtSearch(document) {
|
|
|
310
314
|
`Certificates ${safeTerminalValue(document.certCount, '0')}`,
|
|
311
315
|
`Observed hosts ${safeTerminalValue(Array.isArray(document.domains) ? document.domains.length : 0, '0')}`,
|
|
312
316
|
`Matches ${safeTerminalValue(matches.length, '0')}`,
|
|
317
|
+
`Issuance groups ${safeTerminalValue(certificateGroups.length, '0')} (${safeTerminalValue(crossDomainGroups.length, '0')} cross-domain)`,
|
|
313
318
|
`Truncated ${document.truncated ? 'Yes' : 'No'}`,
|
|
319
|
+
`Group cap ${document.certificateGroupsTruncated ? 'Reached' : 'Not reached'}`,
|
|
314
320
|
'',
|
|
315
321
|
];
|
|
316
322
|
if (!visible.length) {
|
|
@@ -367,7 +373,7 @@ function formatTerminalDiscover(document, mutationLabels = {}) {
|
|
|
367
373
|
const unicodeDetail = unicodeDomain && unicodeDomain !== candidateDomain
|
|
368
374
|
? ` [Unicode: ${safeTerminalValue(unicodeDomain)}]`
|
|
369
375
|
: '';
|
|
370
|
-
lines.push(`${safeTerminalValue(candidateDomain)}${unicodeDetail}
|
|
376
|
+
lines.push(`${safeTerminalValue(candidateDomain)}${unicodeDetail}: ${labels.join(', ') || 'Generated variant'}`);
|
|
371
377
|
}
|
|
372
378
|
if (!visible.length)
|
|
373
379
|
lines.push('No candidates were generated.');
|
|
@@ -396,7 +402,7 @@ function formatTerminalPosture(document) {
|
|
|
396
402
|
];
|
|
397
403
|
for (const value of checks) {
|
|
398
404
|
const item = terminalRecord(value);
|
|
399
|
-
lines.push(`[${safeTerminalValue(item.status, 'info').toUpperCase()}] ${safeTerminalValue(item.label)}
|
|
405
|
+
lines.push(`[${safeTerminalValue(item.status, 'info').toUpperCase()}] ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.summary)}`);
|
|
400
406
|
if (item.detail)
|
|
401
407
|
lines.push(` Detail ${safeTerminalValue(item.detail)}`);
|
|
402
408
|
if (item.remediation)
|
|
@@ -444,7 +450,7 @@ function formatTerminalHttp(document) {
|
|
|
444
450
|
for (const value of attempts) {
|
|
445
451
|
const attempt = terminalRecord(value);
|
|
446
452
|
const outcome = attempt.httpStatus ? `HTTP ${attempt.httpStatus}` : attempt.error || attempt.outcome;
|
|
447
|
-
lines.push(`Attempt ${safeTerminalValue(attempt.url)}
|
|
453
|
+
lines.push(`Attempt ${safeTerminalValue(attempt.url)}: ${safeTerminalValue(outcome)}`);
|
|
448
454
|
}
|
|
449
455
|
for (const limitation of limitations)
|
|
450
456
|
lines.push(`Limitation ${safeTerminalValue(limitation)}`);
|
|
@@ -535,7 +541,7 @@ function formatTerminalTls(document) {
|
|
|
535
541
|
lines.push(`Error ${safeTerminalValue(diagnostics.error)}`);
|
|
536
542
|
for (const value of findings) {
|
|
537
543
|
const finding = terminalRecord(value);
|
|
538
|
-
lines.push(`Finding ${safeTerminalValue(finding.label)}
|
|
544
|
+
lines.push(`Finding ${safeTerminalValue(finding.label)}: ${safeTerminalValue(finding.detail)}`);
|
|
539
545
|
}
|
|
540
546
|
for (const limitation of limitations)
|
|
541
547
|
lines.push(`Limitation ${safeTerminalValue(limitation)}`);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { domainToASCII } from 'node:url';
|
|
2
|
+
export const CLI_INVESTIGATION_PLAN_SCHEMA = 'whoisleuth.cli.investigation-plan';
|
|
3
|
+
export const CLI_INVESTIGATION_PLAN_VERSION = 1;
|
|
4
|
+
export const INVESTIGATION_PLAN_RECIPES = [
|
|
5
|
+
'domain-triage',
|
|
6
|
+
'lookalike-review',
|
|
7
|
+
'owned-domain-review',
|
|
8
|
+
'historical-comparison',
|
|
9
|
+
];
|
|
10
|
+
const RECIPES = Object.freeze({
|
|
11
|
+
'domain-triage': Object.freeze({
|
|
12
|
+
label: 'New domain triage',
|
|
13
|
+
requiresDomain: true,
|
|
14
|
+
objective: 'Collect and preserve a source-aware registration, DNS, HTTP, TLS, page, and network-context review.',
|
|
15
|
+
steps: (domain) => Object.freeze([
|
|
16
|
+
step('collect', 'Collect a Deep lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Review source health and limitations before using missing fields.'),
|
|
17
|
+
step('export', 'Create a portable evidence report', 'export', ['<saved-lookup.json>'], 'offline', 'analyst_selection', 'whoisleuth.lookup-evidence', 'Select the reviewed lookup file; the plan never guesses a path.'),
|
|
18
|
+
step('verify', 'Verify the exported artifact', 'verify-artifact', ['<evidence.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.offline-artifact-verification', 'Keep verification distinct from a claim that the observations are correct or current.'),
|
|
19
|
+
]),
|
|
20
|
+
}),
|
|
21
|
+
'lookalike-review': Object.freeze({
|
|
22
|
+
label: 'Lookalike candidate review',
|
|
23
|
+
requiresDomain: false,
|
|
24
|
+
objective: 'Generate a bounded candidate queue, collect only the selected scope, and retain a reviewed candidate lookup.',
|
|
25
|
+
steps: (subject) => Object.freeze([
|
|
26
|
+
step('generate', 'Generate candidates offline', 'discover', [subject, '--preset', 'all', '--json'], 'offline', 'none', 'whoisleuth.cli.discover', 'Review mutation families and suppressions before collection.'),
|
|
27
|
+
step('scan', 'Collect a bounded candidate queue', 'discover-scan', [subject, '--fast', '--scan-limit', '50', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.discovery-scan', 'Fast collection is a triage boundary; partial or inconclusive authority evidence remains explicit.'),
|
|
28
|
+
step('inspect', 'Deep-review one selected candidate', 'lookup', ['<selected-domain>', '--deep', '--json'], 'network', 'analyst_selection', 'whoisleuth.cli.lookup', 'Select a candidate deliberately; generation does not prove registration, control, intent, or maliciousness.'),
|
|
29
|
+
]),
|
|
30
|
+
}),
|
|
31
|
+
'owned-domain-review': Object.freeze({
|
|
32
|
+
label: 'Owned domain posture review',
|
|
33
|
+
requiresDomain: true,
|
|
34
|
+
objective: 'Review current passive posture and compare supplied observations with an analyst-authored control manifest.',
|
|
35
|
+
steps: (domain) => Object.freeze([
|
|
36
|
+
step('posture', 'Collect bounded DNS posture', 'posture', [domain, '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.posture', 'Review mail profile and delegation evidence before interpreting missing records.'),
|
|
37
|
+
step('lookup', 'Collect supporting Deep evidence', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'Retain separately attributed registration, DNS, TLS, and page observations.'),
|
|
38
|
+
step('manifest', 'Review the domain control manifest', 'domain-control', ['<review-input.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.domain-control-review', 'Only complete supplied observations may produce drift.'),
|
|
39
|
+
]),
|
|
40
|
+
}),
|
|
41
|
+
'historical-comparison': Object.freeze({
|
|
42
|
+
label: 'Historical observation comparison',
|
|
43
|
+
requiresDomain: true,
|
|
44
|
+
objective: 'Collect a current observation and compare it with analyst-selected saved observations without merging source states.',
|
|
45
|
+
steps: (domain) => Object.freeze([
|
|
46
|
+
step('current', 'Collect the current lookup', 'lookup', [domain, '--deep', '--json'], 'network', 'network_disclosure', 'whoisleuth.cli.lookup', 'A current request does not refresh or validate older provider-reported history.'),
|
|
47
|
+
step('diff', 'Compare two selected observations', 'diff', ['<previous.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-diff', 'Equal, different, conflicting, and unavailable evidence remain separate.'),
|
|
48
|
+
step('timeline', 'Build a bounded local timeline', 'timeline', ['<oldest.json>', '<newer.json>', '<current.json>', '--json'], 'offline', 'analyst_selection', 'whoisleuth.cli.lookup-timeline', 'Choose two to twenty same-domain files in chronological scope.'),
|
|
49
|
+
]),
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
function step(id, label, command, args, mode, approval, produces, completion) {
|
|
53
|
+
return Object.freeze({ id, label, command, arguments: Object.freeze([...args]), mode, approval, produces, completion });
|
|
54
|
+
}
|
|
55
|
+
function boundedSubject(value) {
|
|
56
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 253 || /[\u0000-\u001f\u007f]/u.test(value)) {
|
|
57
|
+
throw new TypeError('Investigation-plan subject must be bounded text without control characters.');
|
|
58
|
+
}
|
|
59
|
+
return value.replace(/\s+/gu, ' ').trim();
|
|
60
|
+
}
|
|
61
|
+
function normalizedDomain(value) {
|
|
62
|
+
const candidate = domainToASCII(value.toLowerCase().replace(/\.$/u, ''));
|
|
63
|
+
if (!candidate || candidate.length > 253 || !candidate.includes('.'))
|
|
64
|
+
return null;
|
|
65
|
+
return candidate.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label))
|
|
66
|
+
? candidate
|
|
67
|
+
: null;
|
|
68
|
+
}
|
|
69
|
+
export function buildInvestigationPlan(recipeId, subjectValue, generatedAtValue = new Date().toISOString()) {
|
|
70
|
+
const recipe = RECIPES[recipeId];
|
|
71
|
+
if (!recipe)
|
|
72
|
+
throw new TypeError('Investigation-plan recipe is unsupported.');
|
|
73
|
+
const suppliedSubject = boundedSubject(subjectValue);
|
|
74
|
+
const subject = recipe.requiresDomain ? normalizedDomain(suppliedSubject) : suppliedSubject.toLowerCase();
|
|
75
|
+
if (!subject)
|
|
76
|
+
throw new TypeError('This investigation-plan recipe requires one valid domain.');
|
|
77
|
+
const parsedTime = Date.parse(generatedAtValue);
|
|
78
|
+
if (!Number.isFinite(parsedTime))
|
|
79
|
+
throw new TypeError('Investigation-plan generation time is invalid.');
|
|
80
|
+
const steps = recipe.steps(subject);
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
schema: CLI_INVESTIGATION_PLAN_SCHEMA,
|
|
83
|
+
version: CLI_INVESTIGATION_PLAN_VERSION,
|
|
84
|
+
generatedAt: new Date(parsedTime).toISOString(),
|
|
85
|
+
recipe: Object.freeze({ id: recipeId, label: recipe.label, objective: recipe.objective }),
|
|
86
|
+
subject,
|
|
87
|
+
execution: 'plan_only',
|
|
88
|
+
steps,
|
|
89
|
+
limitations: Object.freeze([
|
|
90
|
+
'This document is a fixed plan. It does not execute commands, expand placeholders, make requests, read files, change cases, or submit reports.',
|
|
91
|
+
'Network steps require deliberate execution and disclose the selected target to the sources described by that command.',
|
|
92
|
+
'Analyst-selection steps require reviewed local artifacts; placeholders are never interpreted as file paths by this planner.',
|
|
93
|
+
]),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
export function formatInvestigationPlan(plan) {
|
|
97
|
+
return [
|
|
98
|
+
`Investigation plan: ${plan.recipe.label}`,
|
|
99
|
+
`Subject ${plan.subject}`,
|
|
100
|
+
`Mode ${plan.execution}`,
|
|
101
|
+
'',
|
|
102
|
+
...plan.steps.flatMap((item, index) => [
|
|
103
|
+
`${index + 1}. ${item.label}`,
|
|
104
|
+
` ${item.command} ${item.arguments.join(' ')}`,
|
|
105
|
+
` ${item.mode}; approval: ${item.approval.replaceAll('_', ' ')}`,
|
|
106
|
+
` ${item.completion}`,
|
|
107
|
+
]),
|
|
108
|
+
'',
|
|
109
|
+
...plan.limitations.map((item) => `Limitation: ${item}`),
|
|
110
|
+
'',
|
|
111
|
+
].join('\n');
|
|
112
|
+
}
|
|
@@ -8,6 +8,7 @@ import { formatLookupEvidenceHtml } from "./formatters/html.mjs";
|
|
|
8
8
|
import { buildCliLookupDocument, formatJsonDocument } from "./formatters/json.mjs";
|
|
9
9
|
import { formatLookupEvidenceMarkdown } from "./formatters/markdown.mjs";
|
|
10
10
|
import { formatTerminalLookup } from "./formatters/terminal.mjs";
|
|
11
|
+
import { buildCliLookupPlan, formatCliLookupPlan } from "./lookup-plan.mjs";
|
|
11
12
|
import { createCliProgressEvents } from "./progress-events.mjs";
|
|
12
13
|
import { lookupStrictExitFindings } from "./strict-exit.mjs";
|
|
13
14
|
async function runLookupCommand(args, dependencies, context) {
|
|
@@ -33,6 +34,13 @@ async function runLookupCommand(args, dependencies, context) {
|
|
|
33
34
|
if ((args.output === 'markdown' || args.output === 'html') && classified.type !== 'domain') {
|
|
34
35
|
throw new CliUsageError('Markdown and HTML reports support domain lookups only.');
|
|
35
36
|
}
|
|
37
|
+
if (args.plan) {
|
|
38
|
+
const plan = buildCliLookupPlan(query, classified, args.deep);
|
|
39
|
+
context.writeStdout(args.output === 'json'
|
|
40
|
+
? formatJsonDocument(plan)
|
|
41
|
+
: context.terminal(formatCliLookupPlan(plan), args.color));
|
|
42
|
+
return EXIT_CODES.SUCCESS;
|
|
43
|
+
}
|
|
36
44
|
const indicator = context.beginProgress(args.deep
|
|
37
45
|
? 'Collecting deep Lookup evidence'
|
|
38
46
|
: 'Collecting registration evidence');
|
package/cli/lookup-diff.mjs
CHANGED
|
@@ -119,13 +119,20 @@ function lookupComparisonInput(document) {
|
|
|
119
119
|
sourceCoverage: sourceCoverage(document, availability),
|
|
120
120
|
};
|
|
121
121
|
}
|
|
122
|
-
function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString()) {
|
|
122
|
+
function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOString(), options = {}) {
|
|
123
123
|
const left = parseSavedLookupDocument(leftText, { label: 'Left lookup input' });
|
|
124
124
|
const right = parseSavedLookupDocument(rightText, { label: 'Right lookup input' });
|
|
125
|
-
|
|
125
|
+
const domainMode = options.domainMode ?? 'different';
|
|
126
|
+
if (domainMode === 'different' && left.registrableDomain === right.registrableDomain) {
|
|
126
127
|
throw new CliUsageError('Lookup diff requires documents for two different domains.');
|
|
127
128
|
}
|
|
128
|
-
|
|
129
|
+
if (domainMode === 'same' && left.registrableDomain !== right.registrableDomain) {
|
|
130
|
+
throw new CliUsageError('Lookup history requires observations for the same domain.');
|
|
131
|
+
}
|
|
132
|
+
const comparison = buildBulkDomainComparison(lookupComparisonInput(left), lookupComparisonInput(right), domainMode === 'same' ? right.generatedAt : null, {
|
|
133
|
+
allowSameDomain: domainMode === 'same',
|
|
134
|
+
...(domainMode === 'same' ? { now: Date.parse(right.generatedAt) } : {}),
|
|
135
|
+
});
|
|
129
136
|
if (!comparison)
|
|
130
137
|
throw new CliUsageError('Lookup documents could not be compared.');
|
|
131
138
|
return {
|
|
@@ -136,9 +143,13 @@ function buildCliLookupDiff(leftText, rightText, generatedAt = new Date().toISOS
|
|
|
136
143
|
right: { domain: right.registrableDomain, generatedAt: right.generatedAt, mode: right.mode },
|
|
137
144
|
comparison,
|
|
138
145
|
limitations: [
|
|
139
|
-
|
|
146
|
+
domainMode === 'same'
|
|
147
|
+
? 'This comparison uses bounded observations already present in two saved Lookup documents for the same domain and makes no network request.'
|
|
148
|
+
: 'This command compares bounded observations already present in two saved Lookup documents and makes no network request.',
|
|
140
149
|
'A missing value remains distinct from unavailable collection and from an observed difference.',
|
|
141
|
-
|
|
150
|
+
domainMode === 'same'
|
|
151
|
+
? 'An observed difference can reflect a domain change or changed collection conditions and does not by itself establish current state, intent, safety, or maliciousness.'
|
|
152
|
+
: 'Shared infrastructure or matching values do not establish common ownership, control, intent, safety, or maliciousness.',
|
|
142
153
|
],
|
|
143
154
|
};
|
|
144
155
|
}
|