@slicedearth/whoisleuth-cli 1.32.2 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -5
- package/SECURITY.md +35 -0
- package/cli/arguments.mjs +88 -8
- package/cli/artifact-verify.mjs +28 -0
- package/cli/bulk-command-runner.mjs +3 -1
- package/cli/bulk-output.mjs +6 -1
- package/cli/command-catalogue.mjs +33 -0
- package/cli/completion.mjs +51 -7
- package/cli/doctor.mjs +78 -43
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/terminal.mjs +6 -6
- package/cli/lookup-command-runner.mjs +8 -0
- package/cli/lookup-diff.mjs +16 -5
- package/cli/lookup-plan.mjs +99 -0
- package/cli/lookup-timeline.mjs +123 -0
- package/cli/manual.mjs +2 -1
- package/cli/runner.mjs +105 -10
- package/docs/cli.md +100 -38
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -6
- package/lib/csv.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +3 -3
- package/package.json +6 -4
- package/third-party-notices.txt +2 -2
package/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/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>
|
|
@@ -284,12 +284,12 @@ function formatTerminalRegistrySupport(document) {
|
|
|
284
284
|
function formatTerminalBulk(items, metadata) {
|
|
285
285
|
const lines = items.map((item) => {
|
|
286
286
|
if (!item.ok)
|
|
287
|
-
return `! ${safeTerminalValue(item.query)}
|
|
287
|
+
return `! ${safeTerminalValue(item.query)}: ${safeTerminalValue(item.error, 'Lookup failed')}`;
|
|
288
288
|
const result = terminalRecord(item.result);
|
|
289
289
|
const availability = terminalRecord(result.availability);
|
|
290
290
|
const state = titleCase(availability.state);
|
|
291
291
|
const confidence = titleCase(availability.confidence);
|
|
292
|
-
return `✓ ${safeTerminalValue(item.query)}
|
|
292
|
+
return `✓ ${safeTerminalValue(item.query)}: ${state} (${confidence} confidence)`;
|
|
293
293
|
});
|
|
294
294
|
const succeeded = items.filter((item) => item.ok).length;
|
|
295
295
|
lines.push('');
|
|
@@ -367,7 +367,7 @@ function formatTerminalDiscover(document, mutationLabels = {}) {
|
|
|
367
367
|
const unicodeDetail = unicodeDomain && unicodeDomain !== candidateDomain
|
|
368
368
|
? ` [Unicode: ${safeTerminalValue(unicodeDomain)}]`
|
|
369
369
|
: '';
|
|
370
|
-
lines.push(`${safeTerminalValue(candidateDomain)}${unicodeDetail}
|
|
370
|
+
lines.push(`${safeTerminalValue(candidateDomain)}${unicodeDetail}: ${labels.join(', ') || 'Generated variant'}`);
|
|
371
371
|
}
|
|
372
372
|
if (!visible.length)
|
|
373
373
|
lines.push('No candidates were generated.');
|
|
@@ -396,7 +396,7 @@ function formatTerminalPosture(document) {
|
|
|
396
396
|
];
|
|
397
397
|
for (const value of checks) {
|
|
398
398
|
const item = terminalRecord(value);
|
|
399
|
-
lines.push(`[${safeTerminalValue(item.status, 'info').toUpperCase()}] ${safeTerminalValue(item.label)}
|
|
399
|
+
lines.push(`[${safeTerminalValue(item.status, 'info').toUpperCase()}] ${safeTerminalValue(item.label)}: ${safeTerminalValue(item.summary)}`);
|
|
400
400
|
if (item.detail)
|
|
401
401
|
lines.push(` Detail ${safeTerminalValue(item.detail)}`);
|
|
402
402
|
if (item.remediation)
|
|
@@ -444,7 +444,7 @@ function formatTerminalHttp(document) {
|
|
|
444
444
|
for (const value of attempts) {
|
|
445
445
|
const attempt = terminalRecord(value);
|
|
446
446
|
const outcome = attempt.httpStatus ? `HTTP ${attempt.httpStatus}` : attempt.error || attempt.outcome;
|
|
447
|
-
lines.push(`Attempt ${safeTerminalValue(attempt.url)}
|
|
447
|
+
lines.push(`Attempt ${safeTerminalValue(attempt.url)}: ${safeTerminalValue(outcome)}`);
|
|
448
448
|
}
|
|
449
449
|
for (const limitation of limitations)
|
|
450
450
|
lines.push(`Limitation ${safeTerminalValue(limitation)}`);
|
|
@@ -535,7 +535,7 @@ function formatTerminalTls(document) {
|
|
|
535
535
|
lines.push(`Error ${safeTerminalValue(diagnostics.error)}`);
|
|
536
536
|
for (const value of findings) {
|
|
537
537
|
const finding = terminalRecord(value);
|
|
538
|
-
lines.push(`Finding ${safeTerminalValue(finding.label)}
|
|
538
|
+
lines.push(`Finding ${safeTerminalValue(finding.label)}: ${safeTerminalValue(finding.detail)}`);
|
|
539
539
|
}
|
|
540
540
|
for (const limitation of limitations)
|
|
541
541
|
lines.push(`Limitation ${safeTerminalValue(limitation)}`);
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export const CLI_LOOKUP_PLAN_SCHEMA = 'whoisleuth.cli.lookup-plan';
|
|
2
|
+
export const CLI_LOOKUP_PLAN_VERSION = 1;
|
|
3
|
+
const RDAP = Object.freeze({
|
|
4
|
+
source: 'rdap',
|
|
5
|
+
purpose: 'Collect authoritative registration or allocation evidence where supported.',
|
|
6
|
+
disclosure: 'The normalized target is sent to the applicable RDAP bootstrap and service endpoints.',
|
|
7
|
+
conditional: false,
|
|
8
|
+
});
|
|
9
|
+
const WHOIS = Object.freeze({
|
|
10
|
+
source: 'whois',
|
|
11
|
+
purpose: 'Collect separately attributed registry and referral publications.',
|
|
12
|
+
disclosure: 'The normalized target is sent over bounded TCP connections to applicable WHOIS services.',
|
|
13
|
+
conditional: false,
|
|
14
|
+
});
|
|
15
|
+
const FAST_DOMAIN_EVIDENCE = Object.freeze({
|
|
16
|
+
source: 'domain_evidence',
|
|
17
|
+
purpose: 'Derive a registration state from RDAP, with bounded DNS delegation fallback when needed.',
|
|
18
|
+
disclosure: 'DNS resolvers may receive the registrable domain when RDAP does not provide a usable record.',
|
|
19
|
+
conditional: true,
|
|
20
|
+
});
|
|
21
|
+
const DEEP_DOMAIN_EVIDENCE = Object.freeze({
|
|
22
|
+
source: 'domain_evidence',
|
|
23
|
+
purpose: 'Collect bounded DNS, HTTP, TLS, page-identity, technology, and security-posture evidence.',
|
|
24
|
+
disclosure: 'DNS resolvers and the target website infrastructure receive the hostname through bounded probes.',
|
|
25
|
+
conditional: false,
|
|
26
|
+
});
|
|
27
|
+
const REGISTRAR_RDAP = Object.freeze({
|
|
28
|
+
source: 'registrar_rdap',
|
|
29
|
+
purpose: 'Collect a separately attributed registrar RDAP publication when registry evidence advertises one.',
|
|
30
|
+
disclosure: 'The registrable domain is sent to the advertised registrar RDAP service.',
|
|
31
|
+
conditional: true,
|
|
32
|
+
});
|
|
33
|
+
const NETWORK_CONTEXT = Object.freeze({
|
|
34
|
+
source: 'network_context',
|
|
35
|
+
purpose: 'Add allocation and routing context for public addresses observed during domain collection.',
|
|
36
|
+
disclosure: 'Observed public addresses may be sent to applicable RDAP services.',
|
|
37
|
+
conditional: true,
|
|
38
|
+
});
|
|
39
|
+
const REVERSE_DNS = Object.freeze({
|
|
40
|
+
source: 'reverse_dns',
|
|
41
|
+
purpose: 'Collect operator-published reverse-DNS context for a public IP address.',
|
|
42
|
+
disclosure: 'A DNS resolver receives the reverse lookup for the normalized IP address.',
|
|
43
|
+
conditional: false,
|
|
44
|
+
});
|
|
45
|
+
function plannedSources(classified, deep) {
|
|
46
|
+
if (!deep) {
|
|
47
|
+
return Object.freeze(classified.type === 'domain' ? [RDAP, FAST_DOMAIN_EVIDENCE] : [RDAP]);
|
|
48
|
+
}
|
|
49
|
+
if (classified.type === 'domain') {
|
|
50
|
+
return Object.freeze([RDAP, WHOIS, DEEP_DOMAIN_EVIDENCE, REGISTRAR_RDAP, NETWORK_CONTEXT]);
|
|
51
|
+
}
|
|
52
|
+
if (classified.type === 'ipv4' || classified.type === 'ipv6') {
|
|
53
|
+
return Object.freeze([RDAP, WHOIS, REVERSE_DNS]);
|
|
54
|
+
}
|
|
55
|
+
return Object.freeze([RDAP, WHOIS]);
|
|
56
|
+
}
|
|
57
|
+
function buildCliLookupPlan(query, classified, deep) {
|
|
58
|
+
return Object.freeze({
|
|
59
|
+
schema: CLI_LOOKUP_PLAN_SCHEMA,
|
|
60
|
+
version: CLI_LOOKUP_PLAN_VERSION,
|
|
61
|
+
mode: deep ? 'deep' : 'fast',
|
|
62
|
+
target: Object.freeze({
|
|
63
|
+
query,
|
|
64
|
+
type: classified.type,
|
|
65
|
+
normalized: classified.value,
|
|
66
|
+
...(classified.inputHostname ? { inputHostname: classified.inputHostname } : {}),
|
|
67
|
+
...(classified.registrableDomain ? { registrableDomain: classified.registrableDomain } : {}),
|
|
68
|
+
}),
|
|
69
|
+
planning: Object.freeze({
|
|
70
|
+
networkRequestsMade: false,
|
|
71
|
+
collectionRequiresNetwork: true,
|
|
72
|
+
sources: plannedSources(classified, deep),
|
|
73
|
+
}),
|
|
74
|
+
limitations: Object.freeze([
|
|
75
|
+
'This is a local preflight. It does not test source availability, feature configuration, cache state, redirects, referrals, or the exact number of requests a completed lookup may require.',
|
|
76
|
+
'Conditional sources may be skipped when prerequisite evidence is absent, unsupported, disabled, or unavailable.',
|
|
77
|
+
]),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function formatCliLookupPlan(plan) {
|
|
81
|
+
const lines = [
|
|
82
|
+
'WHOISleuth lookup preflight',
|
|
83
|
+
`Target: ${plan.target.normalized}`,
|
|
84
|
+
`Type: ${plan.target.type}`,
|
|
85
|
+
`Mode: ${plan.mode}`,
|
|
86
|
+
'Network requests made: no',
|
|
87
|
+
'',
|
|
88
|
+
'Planned collection:',
|
|
89
|
+
];
|
|
90
|
+
for (const source of plan.planning.sources) {
|
|
91
|
+
lines.push(` ${source.source}${source.conditional ? ' (conditional)' : ''}`);
|
|
92
|
+
lines.push(` ${source.purpose}`);
|
|
93
|
+
lines.push(` Disclosure: ${source.disclosure}`);
|
|
94
|
+
}
|
|
95
|
+
for (const limitation of plan.limitations)
|
|
96
|
+
lines.push('', `Limitation: ${limitation}`);
|
|
97
|
+
return `${lines.join('\n')}\n`;
|
|
98
|
+
}
|
|
99
|
+
export { buildCliLookupPlan, formatCliLookupPlan };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { CliUsageError } from "./errors.mjs";
|
|
3
|
+
import { buildCliLookupDiff } from "./lookup-diff.mjs";
|
|
4
|
+
import { parseSavedLookupDocument } from "./saved-lookup.mjs";
|
|
5
|
+
export const CLI_LOOKUP_TIMELINE_SCHEMA = 'whoisleuth.cli.lookup-timeline';
|
|
6
|
+
export const CLI_LOOKUP_TIMELINE_VERSION = 1;
|
|
7
|
+
export const MAX_LOOKUP_TIMELINE_OBSERVATIONS = 20;
|
|
8
|
+
export const MAX_LOOKUP_TIMELINE_INPUT_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
function buildCliLookupTimeline(inputs, generatedAt = new Date().toISOString()) {
|
|
10
|
+
if (inputs.length < 2 || inputs.length > MAX_LOOKUP_TIMELINE_OBSERVATIONS) {
|
|
11
|
+
throw new CliUsageError(`Lookup timeline requires from 2 to ${MAX_LOOKUP_TIMELINE_OBSERVATIONS} saved Lookup documents.`);
|
|
12
|
+
}
|
|
13
|
+
const totalBytes = inputs.reduce((total, input) => total + Buffer.byteLength(input, 'utf8'), 0);
|
|
14
|
+
if (totalBytes > MAX_LOOKUP_TIMELINE_INPUT_BYTES) {
|
|
15
|
+
throw new CliUsageError(`Lookup timeline input is limited to ${MAX_LOOKUP_TIMELINE_INPUT_BYTES} bytes in total.`);
|
|
16
|
+
}
|
|
17
|
+
const normalized = inputs.map((input, index) => ({
|
|
18
|
+
input,
|
|
19
|
+
document: parseSavedLookupDocument(input, { label: `Lookup timeline input ${index + 1}` }),
|
|
20
|
+
inputIndex: index,
|
|
21
|
+
}));
|
|
22
|
+
const domain = normalized[0]?.document.registrableDomain;
|
|
23
|
+
if (!domain || normalized.some((item) => item.document.registrableDomain !== domain)) {
|
|
24
|
+
throw new CliUsageError('Lookup timeline requires observations for exactly one domain.');
|
|
25
|
+
}
|
|
26
|
+
normalized.sort((left, right) => (Date.parse(left.document.generatedAt) - Date.parse(right.document.generatedAt)
|
|
27
|
+
|| left.inputIndex - right.inputIndex));
|
|
28
|
+
for (let index = 1; index < normalized.length; index++) {
|
|
29
|
+
if (normalized[index - 1]?.document.generatedAt === normalized[index]?.document.generatedAt) {
|
|
30
|
+
throw new CliUsageError('Lookup timeline observation times must be unique.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const observations = normalized.map((item, index) => ({
|
|
34
|
+
index,
|
|
35
|
+
generatedAt: item.document.generatedAt,
|
|
36
|
+
mode: item.document.mode,
|
|
37
|
+
}));
|
|
38
|
+
const transitions = normalized.slice(1).map((current, index) => {
|
|
39
|
+
const previous = normalized[index];
|
|
40
|
+
if (!previous)
|
|
41
|
+
throw new TypeError('Lookup timeline ordering failed.');
|
|
42
|
+
const diff = buildCliLookupDiff(previous.input, current.input, generatedAt, { domainMode: 'same' });
|
|
43
|
+
return {
|
|
44
|
+
index,
|
|
45
|
+
fromObservation: index,
|
|
46
|
+
toObservation: index + 1,
|
|
47
|
+
comparison: diff.comparison,
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const fields = {
|
|
51
|
+
conflicting: 0,
|
|
52
|
+
different: 0,
|
|
53
|
+
equal: 0,
|
|
54
|
+
missing: 0,
|
|
55
|
+
notRecorded: 0,
|
|
56
|
+
unavailable: 0,
|
|
57
|
+
};
|
|
58
|
+
let transitionsWithObservedChanges = 0;
|
|
59
|
+
for (const transition of transitions) {
|
|
60
|
+
const counts = transition.comparison.counts;
|
|
61
|
+
fields.conflicting += counts.conflicting;
|
|
62
|
+
fields.different += counts.different;
|
|
63
|
+
fields.equal += counts.equal;
|
|
64
|
+
fields.missing += counts.missing;
|
|
65
|
+
fields.notRecorded += counts.not_recorded;
|
|
66
|
+
fields.unavailable += counts.unavailable;
|
|
67
|
+
if (counts.conflicting + counts.different > 0)
|
|
68
|
+
transitionsWithObservedChanges += 1;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
schema: CLI_LOOKUP_TIMELINE_SCHEMA,
|
|
72
|
+
version: CLI_LOOKUP_TIMELINE_VERSION,
|
|
73
|
+
generatedAt,
|
|
74
|
+
domain,
|
|
75
|
+
observations,
|
|
76
|
+
transitions,
|
|
77
|
+
summary: {
|
|
78
|
+
observationCount: observations.length,
|
|
79
|
+
transitionCount: transitions.length,
|
|
80
|
+
transitionsWithObservedChanges,
|
|
81
|
+
fields,
|
|
82
|
+
},
|
|
83
|
+
privacy: {
|
|
84
|
+
filenamesRetained: 0,
|
|
85
|
+
rawRegistryPayloadsCopied: 0,
|
|
86
|
+
},
|
|
87
|
+
limitations: [
|
|
88
|
+
'The timeline compares only bounded normalized fields already present in the selected saved Lookup documents and makes no network request.',
|
|
89
|
+
'Input filenames and raw registry payloads are not copied into the timeline output.',
|
|
90
|
+
'Missing, unavailable, and not-recorded evidence remain separate from an observed difference.',
|
|
91
|
+
'A difference can reflect a domain change or changed collection conditions and does not by itself establish current state, ownership, intent, safety, or maliciousness.',
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function formatCliLookupTimeline(document) {
|
|
96
|
+
const output = [
|
|
97
|
+
'Same-domain observation timeline',
|
|
98
|
+
`Domain ${document.domain}`,
|
|
99
|
+
`Observations ${document.summary.observationCount}`,
|
|
100
|
+
`First observed ${document.observations[0]?.generatedAt || 'Unavailable'}`,
|
|
101
|
+
`Last observed ${document.observations.at(-1)?.generatedAt || 'Unavailable'}`,
|
|
102
|
+
`Changed transitions ${document.summary.transitionsWithObservedChanges}`,
|
|
103
|
+
];
|
|
104
|
+
for (const transition of document.transitions) {
|
|
105
|
+
const from = document.observations[transition.fromObservation];
|
|
106
|
+
const to = document.observations[transition.toObservation];
|
|
107
|
+
const counts = transition.comparison.counts;
|
|
108
|
+
const changedRows = transition.comparison.rows.filter((row) => (row.state === 'conflicting' || row.state === 'different' || row.state === 'missing'));
|
|
109
|
+
output.push('', `${from?.generatedAt || 'Unavailable'} to ${to?.generatedAt || 'Unavailable'}`, ` Observed changes ${counts.conflicting + counts.different}`, ` One-sided fields ${counts.missing}`, ` Unavailable ${counts.unavailable + counts.not_recorded}`);
|
|
110
|
+
if (!changedRows.length)
|
|
111
|
+
output.push(' No observed field changes in comparable evidence.');
|
|
112
|
+
for (const row of changedRows.slice(0, 10)) {
|
|
113
|
+
output.push(` ${row.label} [${row.state.replaceAll('_', ' ')}]: ${row.left} -> ${row.right}`);
|
|
114
|
+
}
|
|
115
|
+
if (changedRows.length > 10)
|
|
116
|
+
output.push(` ${changedRows.length - 10} additional changed fields omitted; use --json for the complete bounded comparison.`);
|
|
117
|
+
}
|
|
118
|
+
output.push('', 'Limitations:');
|
|
119
|
+
for (const limitation of document.limitations)
|
|
120
|
+
output.push(` - ${limitation}`);
|
|
121
|
+
return `${output.join('\n')}\n`;
|
|
122
|
+
}
|
|
123
|
+
export { buildCliLookupTimeline, formatCliLookupTimeline };
|
package/cli/manual.mjs
CHANGED
|
@@ -7,7 +7,8 @@ function roffText(value) {
|
|
|
7
7
|
function buildCliManual(options) {
|
|
8
8
|
const commands = options.commands.map((command) => {
|
|
9
9
|
const detail = options.details[command];
|
|
10
|
-
|
|
10
|
+
const collection = options.collections[command];
|
|
11
|
+
return `.SS ${roffText(command)}\n${roffText(detail.description)}\n.PP\n.B ${roffText(options.usage[command])}\n.PP\nExample: ${roffText(detail.example)}\n.PP\nCollection: ${roffText(collection.mode)}. ${roffText(collection.scope)}\n.PP\nBoundary: ${roffText(detail.boundary)}`;
|
|
11
12
|
}).join('\n');
|
|
12
13
|
return `.TH WHOISLEUTH 1 "" "WHOISleuth ${roffText(options.version)}" "User Commands"
|
|
13
14
|
.SH NAME
|