@slicedearth/whoisleuth-cli 1.34.0 → 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 +2 -0
- package/cli/arguments.mjs +92 -0
- package/cli/artifact-verify.mjs +4 -0
- package/cli/bulk.mjs +1 -2
- package/cli/completion.mjs +19 -0
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/terminal.mjs +7 -1
- package/cli/investigation-plan.mjs +112 -0
- package/cli/mail-review.mjs +47 -2
- package/cli/offline-evidence-review.mjs +141 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/runner.mjs +109 -1
- package/docs/cli.md +102 -9
- 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/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.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 +2 -2
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
2
|
+
import { normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, } from "../lib/encrypted-dns-contract.mjs";
|
|
3
|
+
import { validateDnssecEvidence } from "../lib/dnssec-evidence-validation.mjs";
|
|
4
|
+
import { buildLocalGeoIpDatabase, lookupLocalGeoIp, } from "../lib/local-geoip-evidence.mjs";
|
|
5
|
+
import { normalizeRdapSearchHelp, planRdapReverseSearch, } from "../lib/rdap-search-workbench.mjs";
|
|
6
|
+
import { reviewRpkiRoute } from "../lib/rpki-evidence.mjs";
|
|
7
|
+
import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
|
|
8
|
+
import { CliUsageError } from "./errors.mjs";
|
|
9
|
+
const OFFLINE_EVIDENCE_REVIEW_SCHEMA = 'whoisleuth.cli.offline-evidence-review';
|
|
10
|
+
const OFFLINE_EVIDENCE_REVIEW_VERSION = 1;
|
|
11
|
+
const MAX_OFFLINE_EVIDENCE_INPUT_BYTES = 16 * 1024 * 1024;
|
|
12
|
+
function record(value) {
|
|
13
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
14
|
+
}
|
|
15
|
+
function parseInput(value) {
|
|
16
|
+
if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > MAX_OFFLINE_EVIDENCE_INPUT_BYTES) {
|
|
17
|
+
throw new CliUsageError(`Offline evidence input is limited to ${MAX_OFFLINE_EVIDENCE_INPUT_BYTES} bytes.`);
|
|
18
|
+
}
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(value.replace(/^\uFEFF/u, ''));
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new CliUsageError('Offline evidence review requires one valid JSON document.');
|
|
25
|
+
}
|
|
26
|
+
const document = record(parsed);
|
|
27
|
+
if (document.version !== 1 || typeof document.schema !== 'string') {
|
|
28
|
+
throw new CliUsageError('Offline evidence review requires a supported versioned input schema.');
|
|
29
|
+
}
|
|
30
|
+
return document;
|
|
31
|
+
}
|
|
32
|
+
function buildOfflineEvidenceReview(value, generatedAt = new Date().toISOString()) {
|
|
33
|
+
const input = parseInput(value);
|
|
34
|
+
let kind;
|
|
35
|
+
let result;
|
|
36
|
+
if (input.schema === 'whoisleuth.rdap-search-input') {
|
|
37
|
+
kind = 'rdap_search';
|
|
38
|
+
const request = record(input.request);
|
|
39
|
+
const help = normalizeRdapSearchHelp(input.help);
|
|
40
|
+
result = Object.freeze({
|
|
41
|
+
help,
|
|
42
|
+
plan: planRdapReverseSearch(help, {
|
|
43
|
+
searchableResourceType: String(request.searchableResourceType ?? ''),
|
|
44
|
+
relatedResourceType: String(request.relatedResourceType ?? ''),
|
|
45
|
+
property: String(request.property ?? ''),
|
|
46
|
+
value: request.value,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else if (input.schema === 'whoisleuth.dnssec-evidence-input') {
|
|
51
|
+
kind = 'dnssec';
|
|
52
|
+
result = validateDnssecEvidence({
|
|
53
|
+
ownerName: input.ownerName,
|
|
54
|
+
delegationSigned: input.delegationSigned,
|
|
55
|
+
dsRecords: input.dsRecords,
|
|
56
|
+
dnskeyRecords: input.dnskeyRecords,
|
|
57
|
+
rrSigRecords: input.rrSigRecords,
|
|
58
|
+
observedAt: input.observedAt,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
else if (input.schema === 'whoisleuth.tlsa-evidence-input') {
|
|
62
|
+
kind = 'tlsa';
|
|
63
|
+
result = analyzeTlsaEvidence({
|
|
64
|
+
serviceName: input.serviceName,
|
|
65
|
+
dnssecState: input.dnssecState,
|
|
66
|
+
pkixValidationState: input.pkixValidationState,
|
|
67
|
+
records: input.records,
|
|
68
|
+
certificateDerBase64: input.certificateDerBase64,
|
|
69
|
+
spkiDerBase64: input.spkiDerBase64,
|
|
70
|
+
authorityMaterials: input.authorityMaterials,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
else if (input.schema === 'whoisleuth.rpki-route-input') {
|
|
74
|
+
kind = 'rpki';
|
|
75
|
+
result = reviewRpkiRoute({
|
|
76
|
+
routePrefix: input.routePrefix,
|
|
77
|
+
originAsn: input.originAsn,
|
|
78
|
+
authorizations: input.authorizations,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
else if (input.schema === 'whoisleuth.local-geoip-query') {
|
|
82
|
+
kind = 'geoip';
|
|
83
|
+
result = lookupLocalGeoIp(buildLocalGeoIpDatabase(input.database), input.address);
|
|
84
|
+
}
|
|
85
|
+
else if (input.schema === 'whoisleuth.encrypted-dns-plan-input') {
|
|
86
|
+
kind = 'encrypted_dns';
|
|
87
|
+
const query = record(input.query);
|
|
88
|
+
result = planEncryptedDnsQuery(normalizeEncryptedDnsAdapter(input.adapter), { name: query.name, type: query.type });
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
throw new CliUsageError('Offline evidence review does not recognize this input schema.');
|
|
92
|
+
}
|
|
93
|
+
return Object.freeze({
|
|
94
|
+
schema: OFFLINE_EVIDENCE_REVIEW_SCHEMA,
|
|
95
|
+
version: OFFLINE_EVIDENCE_REVIEW_VERSION,
|
|
96
|
+
generatedAt,
|
|
97
|
+
kind,
|
|
98
|
+
result,
|
|
99
|
+
limitations: Object.freeze([
|
|
100
|
+
'The review is local and uses only the supplied document. It does not refresh, transmit, or independently establish the current completeness of the evidence.',
|
|
101
|
+
]),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function formatOfflineEvidenceReview(document) {
|
|
105
|
+
const result = record(document.result);
|
|
106
|
+
const state = typeof result.state === 'string' ? result.state : 'reviewed';
|
|
107
|
+
const lines = [
|
|
108
|
+
'Offline evidence review',
|
|
109
|
+
`Kind ${document.kind.replaceAll('_', ' ')}`,
|
|
110
|
+
`State ${state.replaceAll('_', ' ')}`,
|
|
111
|
+
];
|
|
112
|
+
if (document.kind === 'rdap_search') {
|
|
113
|
+
const help = record(result.help);
|
|
114
|
+
const plan = record(result.plan);
|
|
115
|
+
lines.push(`Help ${String(help.state ?? 'unavailable').replaceAll('_', ' ')}`);
|
|
116
|
+
lines.push(`Plan ${String(plan.state ?? 'unavailable').replaceAll('_', ' ')}`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
for (const [label, key] of [
|
|
120
|
+
['Records', 'records'],
|
|
121
|
+
['Rejected', 'rejectedCount'],
|
|
122
|
+
['Matches', 'matchingAuthorizationCount'],
|
|
123
|
+
]) {
|
|
124
|
+
const candidate = result[key];
|
|
125
|
+
if (Array.isArray(candidate))
|
|
126
|
+
lines.push(`${label.padEnd(7)}${candidate.length}`);
|
|
127
|
+
else if (typeof candidate === 'number')
|
|
128
|
+
lines.push(`${label.padEnd(7)}${candidate}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
lines.push('', 'Limitations:');
|
|
132
|
+
for (const limitation of document.limitations)
|
|
133
|
+
lines.push(` - ${limitation}`);
|
|
134
|
+
const nestedLimitations = Array.isArray(result.limitations) ? result.limitations : [];
|
|
135
|
+
for (const limitation of nestedLimitations) {
|
|
136
|
+
if (typeof limitation === 'string')
|
|
137
|
+
lines.push(` - ${limitation}`);
|
|
138
|
+
}
|
|
139
|
+
return `${lines.join('\n')}\n`;
|
|
140
|
+
}
|
|
141
|
+
export { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, OFFLINE_EVIDENCE_REVIEW_SCHEMA, OFFLINE_EVIDENCE_REVIEW_VERSION, buildOfflineEvidenceReview, formatOfflineEvidenceReview, };
|
package/cli/registry-support.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { registryStandardsCoverageSnapshot, } from "../lib/registry-capabilities.mjs";
|
|
2
2
|
const REGISTRY_SUPPORT_SCHEMA = 'whoisleuth.cli.registry-support';
|
|
3
|
-
const REGISTRY_SUPPORT_SCHEMA_VERSION =
|
|
3
|
+
const REGISTRY_SUPPORT_SCHEMA_VERSION = 3;
|
|
4
4
|
const REGISTRY_STANDARDS_COVERAGE_SCHEMA = 'whoisleuth.registry-standards-coverage';
|
|
5
5
|
const MAX_REGISTRY_SUPPORT_TEXT_LENGTH = 2048;
|
|
6
6
|
const MAX_REGISTRY_SUPPORT_REFERENCES = 20;
|
|
@@ -100,9 +100,6 @@ function buildRegistrySupportDocument(requestedInput, capability, catalogueVersi
|
|
|
100
100
|
encodingProfile: boundedText(capability.whoisEncodingProfile, 'unknown', 64),
|
|
101
101
|
parserProfile: boundedText(capability.whoisParserProfile, 'unknown', 128),
|
|
102
102
|
},
|
|
103
|
-
fallbackProfile: capability.fallbackProfile
|
|
104
|
-
? boundedText(capability.fallbackProfile, 'unknown', 128)
|
|
105
|
-
: null,
|
|
106
103
|
},
|
|
107
104
|
verification: {
|
|
108
105
|
fixtureScenarios: boundedList(capability.fixtureScenarios, 'token'),
|
package/cli/runner.mjs
CHANGED
|
@@ -26,8 +26,12 @@ import { buildCliLookupDiff, formatCliLookupDiff } from "./lookup-diff.mjs";
|
|
|
26
26
|
import { MAX_LOOKUP_TIMELINE_INPUT_BYTES, buildCliLookupTimeline, formatCliLookupTimeline, } from "./lookup-timeline.mjs";
|
|
27
27
|
import { buildCliPageComparison, formatCliPageComparison } from "./page-compare.mjs";
|
|
28
28
|
import { MAX_MAIL_REVIEW_INPUT_BYTES, buildCliMailReview, formatCliMailReview, } from "./mail-review.mjs";
|
|
29
|
+
import { MAX_OFFLINE_EVIDENCE_INPUT_BYTES, buildOfflineEvidenceReview, formatOfflineEvidenceReview, } from "./offline-evidence-review.mjs";
|
|
30
|
+
import { DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, buildDomainControlManifest, formatDomainControlResult, reviewDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
|
|
29
31
|
import { runLookupCommand } from "./lookup-command-runner.mjs";
|
|
30
32
|
import { buildCliManual } from "./manual.mjs";
|
|
33
|
+
import { buildInvestigationPlan, formatInvestigationPlan } from "./investigation-plan.mjs";
|
|
34
|
+
import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "../lib/project-metadata.mjs";
|
|
31
35
|
import { createBufferedOutput, writePrivateFile } from "./output-file.mjs";
|
|
32
36
|
import { createTerminalProgress } from "./progress.mjs";
|
|
33
37
|
import { buildRegistrySupportDocument } from "./registry-support.mjs";
|
|
@@ -66,6 +70,9 @@ Review saved evidence:
|
|
|
66
70
|
compare Compare saved registry publications.
|
|
67
71
|
page-compare Compare saved static page and TLS evidence.
|
|
68
72
|
mail-review Review saved passive mail exposure evidence.
|
|
73
|
+
review-evidence Review supplied DNS, routing, GeoIP, or RDAP evidence offline.
|
|
74
|
+
domain-control Build or review an integrity-protected desired-state manifest.
|
|
75
|
+
workflow-plan Plan a fixed investigation recipe without executing it.
|
|
69
76
|
diff Compare two saved domain observations.
|
|
70
77
|
timeline Compare a sequence of observations for one domain.
|
|
71
78
|
export Convert a saved lookup into an evidence report.
|
|
@@ -90,7 +97,7 @@ Diagnostics are written to stderr. Fast lookup is the default; deep collection
|
|
|
90
97
|
must be requested explicitly and can disclose a target to additional sources.
|
|
91
98
|
|
|
92
99
|
Copyright 2026 slicedearth. Licensed under AGPL-3.0-only.
|
|
93
|
-
Source and licence:
|
|
100
|
+
Source and licence: ${WHOISLEUTH_SOURCE_REPOSITORY_URL}
|
|
94
101
|
`;
|
|
95
102
|
const COMMAND_USAGE = Object.freeze({
|
|
96
103
|
completion: 'whoisleuth completion <bash|zsh|fish|powershell>',
|
|
@@ -115,6 +122,9 @@ const COMMAND_USAGE = Object.freeze({
|
|
|
115
122
|
compare: 'whoisleuth compare [lookup.json] [--json] [--quiet] [--no-color]',
|
|
116
123
|
'page-compare': 'whoisleuth page-compare <left.json> <right.json> [--json] [--quiet] [--no-color]',
|
|
117
124
|
'mail-review': 'whoisleuth mail-review [bulk.json|bulk.jsonl] [--json] [--quiet] [--no-color]',
|
|
125
|
+
'review-evidence': 'whoisleuth review-evidence [evidence.json] [--json] [--quiet] [--no-color]',
|
|
126
|
+
'domain-control': 'whoisleuth domain-control [manifest-input.json|review-input.json] [--json] [--quiet] [--no-color]',
|
|
127
|
+
'workflow-plan': 'whoisleuth workflow-plan <recipe> <domain|brand> [--json] [--quiet] [--no-color]',
|
|
118
128
|
diff: 'whoisleuth diff <left.json> <right.json> [--json] [--quiet] [--no-color]',
|
|
119
129
|
timeline: 'whoisleuth timeline <observation.json> <observation.json> [...] [--json] [--quiet] [--no-color]',
|
|
120
130
|
export: 'whoisleuth export [lookup.json] [--markdown|--html|--compact]',
|
|
@@ -230,6 +240,21 @@ const COMMAND_DETAILS = Object.freeze({
|
|
|
230
240
|
example: 'whoisleuth mail-review candidates.json --json',
|
|
231
241
|
boundary: 'Review is offline and sends no SMTP traffic. Missing or partial DNS evidence remains inconclusive.',
|
|
232
242
|
},
|
|
243
|
+
'review-evidence': {
|
|
244
|
+
description: 'Review one versioned DNSSEC, TLSA, RPKI, GeoIP, encrypted-DNS, or RDAP planning document offline.',
|
|
245
|
+
example: 'whoisleuth review-evidence dnssec-evidence.json --json',
|
|
246
|
+
boundary: 'The command reads only the supplied document. It performs no DNS, RDAP, BGP, GeoIP-provider, TLS, HTTP, or SMTP request.',
|
|
247
|
+
},
|
|
248
|
+
'domain-control': {
|
|
249
|
+
description: 'Build an integrity-protected desired-state manifest or compare one with supplied observations.',
|
|
250
|
+
example: 'whoisleuth domain-control domain-control-input.json --json',
|
|
251
|
+
boundary: 'The command is offline and changes no registrar, DNS, mail, or certificate configuration. Only complete supplied observations can produce drift.',
|
|
252
|
+
},
|
|
253
|
+
'workflow-plan': {
|
|
254
|
+
description: 'Build a fixed domain-investigation plan from existing bounded CLI commands.',
|
|
255
|
+
example: 'whoisleuth workflow-plan domain-triage example.test --json',
|
|
256
|
+
boundary: 'Planning is offline and plan-only. It does not execute commands, expand placeholders, read files, make requests, or submit evidence.',
|
|
257
|
+
},
|
|
233
258
|
diff: {
|
|
234
259
|
description: 'Compare bounded evidence retained in two saved domain lookups.',
|
|
235
260
|
example: 'whoisleuth diff first.json second.json --json',
|
|
@@ -269,6 +294,9 @@ const COMMAND_COLLECTION = Object.freeze({
|
|
|
269
294
|
compare: { mode: 'offline', scope: 'Reads one saved Lookup and compares its separately attributed registry publications.' },
|
|
270
295
|
'page-compare': { mode: 'offline', scope: 'Reads two saved Lookup documents and executes no page code.' },
|
|
271
296
|
'mail-review': { mode: 'offline', scope: 'Reads one saved Bulk result and sends no DNS or SMTP traffic.' },
|
|
297
|
+
'review-evidence': { mode: 'offline', scope: 'Reads one bounded versioned evidence or request-planning document and performs no collection.' },
|
|
298
|
+
'domain-control': { mode: 'offline', scope: 'Reads one bounded desired-state or review document and performs no collection or configuration change.' },
|
|
299
|
+
'workflow-plan': { mode: 'offline', scope: 'Builds a fixed typed recipe and executes none of its network or file steps.' },
|
|
272
300
|
diff: { mode: 'offline', scope: 'Reads two saved Lookup documents for different domains.' },
|
|
273
301
|
timeline: { mode: 'offline', scope: 'Reads 2 to 20 saved observations for one domain, capped at 32 MiB in total.' },
|
|
274
302
|
export: { mode: 'offline', scope: 'Reads one saved Lookup and writes one bounded report.' },
|
|
@@ -644,6 +672,86 @@ async function runParsedCli(args, dependencies = {}) {
|
|
|
644
672
|
: terminal(formatCliMailReview(document), args.color));
|
|
645
673
|
return EXIT_CODES.SUCCESS;
|
|
646
674
|
}
|
|
675
|
+
if (args.action === 'review-evidence') {
|
|
676
|
+
failureLabel = 'Offline evidence review';
|
|
677
|
+
let input;
|
|
678
|
+
try {
|
|
679
|
+
input = dependencies.readArtifactInput
|
|
680
|
+
? await dependencies.readArtifactInput(args.source)
|
|
681
|
+
: await readSavedLookupInputBounded(args.source
|
|
682
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
683
|
+
: dependencies.stdin || process.stdin, {
|
|
684
|
+
limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
|
|
685
|
+
label: 'Offline evidence input',
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
catch (error) {
|
|
689
|
+
if (error instanceof CliUsageError)
|
|
690
|
+
throw error;
|
|
691
|
+
throw new CliUsageError(`Could not read offline evidence input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
692
|
+
}
|
|
693
|
+
if (!input.trim())
|
|
694
|
+
throw new CliUsageError('review-evidence requires one JSON file or a document on stdin.');
|
|
695
|
+
const document = buildOfflineEvidenceReview(input, commandContext.now());
|
|
696
|
+
if (!args.quiet)
|
|
697
|
+
write(stdout, args.output === 'json'
|
|
698
|
+
? formatJsonDocument(document)
|
|
699
|
+
: terminal(formatOfflineEvidenceReview(document), args.color));
|
|
700
|
+
return EXIT_CODES.SUCCESS;
|
|
701
|
+
}
|
|
702
|
+
if (args.action === 'domain-control') {
|
|
703
|
+
failureLabel = 'Domain control review';
|
|
704
|
+
let input;
|
|
705
|
+
try {
|
|
706
|
+
input = dependencies.readArtifactInput
|
|
707
|
+
? await dependencies.readArtifactInput(args.source)
|
|
708
|
+
: await readSavedLookupInputBounded(args.source
|
|
709
|
+
? createReadStream(args.source, { highWaterMark: 64 * 1024 })
|
|
710
|
+
: dependencies.stdin || process.stdin, {
|
|
711
|
+
limit: MAX_OFFLINE_EVIDENCE_INPUT_BYTES,
|
|
712
|
+
label: 'Domain control input',
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
if (error instanceof CliUsageError)
|
|
717
|
+
throw error;
|
|
718
|
+
throw new CliUsageError(`Could not read domain control input: ${boundedCliErrorMessage(error, 'Input could not be read')}`);
|
|
719
|
+
}
|
|
720
|
+
if (!input.trim())
|
|
721
|
+
throw new CliUsageError('domain-control requires one JSON file or a document on stdin.');
|
|
722
|
+
let parsed;
|
|
723
|
+
try {
|
|
724
|
+
parsed = JSON.parse(input);
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
throw new CliUsageError('Domain control input is not valid JSON.');
|
|
728
|
+
}
|
|
729
|
+
const schema = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
730
|
+
? parsed.schema
|
|
731
|
+
: null;
|
|
732
|
+
const document = schema === DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA
|
|
733
|
+
? buildDomainControlManifest(parsed, commandContext.now())
|
|
734
|
+
: schema === DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA
|
|
735
|
+
? reviewDomainControlManifest(parsed, commandContext.now())
|
|
736
|
+
: null;
|
|
737
|
+
if (!document) {
|
|
738
|
+
throw new CliUsageError(`Domain control input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} or ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA}.`);
|
|
739
|
+
}
|
|
740
|
+
if (!args.quiet)
|
|
741
|
+
write(stdout, args.output === 'json'
|
|
742
|
+
? formatJsonDocument(document)
|
|
743
|
+
: terminal(formatDomainControlResult(document), args.color));
|
|
744
|
+
return EXIT_CODES.SUCCESS;
|
|
745
|
+
}
|
|
746
|
+
if (args.action === 'workflow-plan') {
|
|
747
|
+
failureLabel = 'Investigation plan';
|
|
748
|
+
const document = buildInvestigationPlan(args.recipe, args.subject, commandContext.now());
|
|
749
|
+
if (!args.quiet)
|
|
750
|
+
write(stdout, args.output === 'json'
|
|
751
|
+
? formatJsonDocument(document)
|
|
752
|
+
: terminal(formatInvestigationPlan(document), args.color));
|
|
753
|
+
return EXIT_CODES.SUCCESS;
|
|
754
|
+
}
|
|
647
755
|
if (args.action === 'diff') {
|
|
648
756
|
failureLabel = 'Lookup evidence diff';
|
|
649
757
|
const readDiffInput = dependencies.readDiffInput || (async (source) => (readSavedLookupInputBounded(createReadStream(source, { highWaterMark: 64 * 1024 }), {
|
package/docs/cli.md
CHANGED
|
@@ -77,6 +77,9 @@ node bin/whoisleuth.mts lookup example.com --deep --json > lookup.json
|
|
|
77
77
|
node bin/whoisleuth.mts compare lookup.json --json
|
|
78
78
|
node bin/whoisleuth.mts page-compare official.json candidate.json --json
|
|
79
79
|
node bin/whoisleuth.mts mail-review bulk.json --json
|
|
80
|
+
node bin/whoisleuth.mts review-evidence dnssec-evidence.json --json
|
|
81
|
+
node bin/whoisleuth.mts domain-control domain-control-input.json --json
|
|
82
|
+
node bin/whoisleuth.mts workflow-plan domain-triage example.test --json
|
|
80
83
|
node bin/whoisleuth.mts diff first-lookup.json second-lookup.json --json
|
|
81
84
|
node bin/whoisleuth.mts timeline first-observation.json second-observation.json latest-observation.json --json
|
|
82
85
|
node bin/whoisleuth.mts export lookup.json > evidence.json
|
|
@@ -181,7 +184,7 @@ Commands that query RDAP, WHOIS, DNS, HTTP, TLS, or Certificate Transparency do
|
|
|
181
184
|
so directly from the machine running the CLI. They do not use the hosted login,
|
|
182
185
|
hosted session, or deployment usage controls; upstream providers can see and
|
|
183
186
|
rate-limit the local machine's network address. Offline `discover`, `compare`,
|
|
184
|
-
`page-compare`, `mail-review`, `diff`, `timeline`, `risk-calibrate`, `verify-artifact`, `source-report`, `export`,
|
|
187
|
+
`page-compare`, `mail-review`, `review-evidence`, `domain-control`, `workflow-plan`, `diff`, `timeline`, `risk-calibrate`, `verify-artifact`, `source-report`, `export`,
|
|
185
188
|
`commands`, `completion`, and `manual` operations make no network requests. Commands write
|
|
186
189
|
to stdout unless the analyst deliberately selects a local output file.
|
|
187
190
|
|
|
@@ -338,7 +341,7 @@ machine access is not evidence that a domain is unregistered or safe.
|
|
|
338
341
|
This release supports `lookup`, `bulk`, `ct-search`, `discover`, `discover-scan`, `posture`,
|
|
339
342
|
`http`, `tls`, `registry-support`, `risk-calibrate`, `verify-artifact`,
|
|
340
343
|
`inspect-archive`, `sign-artifact`, `verify-signature`, `source-report`,
|
|
341
|
-
`compare`, `page-compare`, `mail-review`, `diff`, `timeline`, `export`, `doctor`, `commands`, `completion`, and `manual`. Additional command families
|
|
344
|
+
`compare`, `page-compare`, `mail-review`, `review-evidence`, `domain-control`, `workflow-plan`, `diff`, `timeline`, `export`, `doctor`, `commands`, `completion`, and `manual`. Additional command families
|
|
342
345
|
are added as separate bounded increments rather than exposing incomplete
|
|
343
346
|
aliases.
|
|
344
347
|
|
|
@@ -348,13 +351,13 @@ aliases.
|
|
|
348
351
|
catalogue for one domain or suffix. It distinguishes an explicit
|
|
349
352
|
fixture-backed or access-documented suffix profile from the generic IANA
|
|
350
353
|
discovery profile and reports the RDAP and WHOIS discovery/access paths,
|
|
351
|
-
WHOIS query and parser profiles,
|
|
354
|
+
WHOIS query and parser profiles, fixture scenarios,
|
|
352
355
|
verification references, documentation references, and the catalogue's
|
|
353
356
|
limitation. The same document includes a dated, official-source aggregate of
|
|
354
357
|
generic, generic-restricted, sponsored, and infrastructure RDAP coverage. That
|
|
355
358
|
snapshot is separate from the suffix profile and from live reachability.
|
|
356
359
|
|
|
357
|
-
Terminal output is bounded and control-safe. `--json` emits version
|
|
360
|
+
Terminal output is bounded and control-safe. `--json` emits version 3 of the
|
|
358
361
|
`whoisleuth.cli.registry-support` schema. Unknown but syntactically valid
|
|
359
362
|
suffixes retain the generic `discovery_only` profile; malformed input exits
|
|
360
363
|
with code 2. The command never probes a registry or tests current reachability.
|
|
@@ -554,11 +557,15 @@ does not call the hosted WHOISleuth deployment. Quote a multi-word keyword so
|
|
|
554
557
|
the shell passes it as one argument.
|
|
555
558
|
|
|
556
559
|
Terminal output summarizes certificate rows, observed hostnames, canonical
|
|
557
|
-
registrable-domain matches, observation times,
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
560
|
+
registrable-domain matches, observation times, bounded certificate-issuance
|
|
561
|
+
groups, and completeness. An issuance group contains names observed together
|
|
562
|
+
in one public certificate record; a cross-domain group is a review lead, not
|
|
563
|
+
an attribution or ownership finding. Terminal output shows at most 100 matches
|
|
564
|
+
and five hostnames per match, with explicit omission notes. `--json` returns
|
|
565
|
+
the complete bounded structured result in the versioned
|
|
566
|
+
`whoisleuth.cli.ct-search` schema. The certificate-group cap is reported
|
|
567
|
+
separately from the registrable-domain result cap. CT observations do not prove
|
|
568
|
+
that a website is active or malicious.
|
|
562
569
|
|
|
563
570
|
## Lookalike discovery
|
|
564
571
|
|
|
@@ -791,6 +798,92 @@ The command makes no DNS or SMTP request and retains no source path. It does
|
|
|
791
798
|
not test message acceptance, relay behavior, mailbox existence, catch-all
|
|
792
799
|
behavior, SMTP banners, or whether a mail server is rogue, safe, or malicious.
|
|
793
800
|
|
|
801
|
+
An analyst may add a bounded `tlsaEvidence` object to a version-2 Bulk item for
|
|
802
|
+
offline DANE review. It must name the exact `_25._tcp.<mx-host>` service, and
|
|
803
|
+
that host must occur in the same item's retained MX evidence. A certificate
|
|
804
|
+
association match is complete only when `dnssecState` is `validated`. TLSA
|
|
805
|
+
usages 0 and 1 additionally require `pkixValidationState: "validated"`.
|
|
806
|
+
Usages 0 and 2 compare against the bounded `authorityMaterials` array; usages
|
|
807
|
+
1 and 3 compare against the supplied leaf certificate or SPKI. Mismatched
|
|
808
|
+
service names, missing certificate roles, unvalidated prerequisites, malformed
|
|
809
|
+
material, and truncation remain invalid, partial, or untrusted rather than
|
|
810
|
+
becoming a DANE match.
|
|
811
|
+
|
|
812
|
+
## Offline supplied-evidence review
|
|
813
|
+
|
|
814
|
+
`review-evidence [evidence.json]` accepts one version-1 JSON input and performs
|
|
815
|
+
only deterministic local parsing, comparison, or request planning. Input is
|
|
816
|
+
capped at 16 MiB. The command never retrieves DNS, opens SMTP, contacts an
|
|
817
|
+
RDAP server, submits a resolver query, or looks up an address in a hosted
|
|
818
|
+
GeoIP service.
|
|
819
|
+
|
|
820
|
+
Supported input schemas are:
|
|
821
|
+
|
|
822
|
+
- `whoisleuth.dnssec-evidence-input`: compares bounded DS and DNSKEY records,
|
|
823
|
+
recalculates DNSKEY key tags and supported DS digests, and reviews supplied
|
|
824
|
+
RRSIG validity windows at `observedAt`. `consistent` describes only the
|
|
825
|
+
supplied DS/DNSKEY relationship. It does not authenticate a chain to a root
|
|
826
|
+
trust anchor, validate RRSIG cryptography, or prove that a missing record is
|
|
827
|
+
absent.
|
|
828
|
+
- `whoisleuth.tlsa-evidence-input`: binds TLSA records to one explicit
|
|
829
|
+
`_port._transport.hostname` service and compares supplied leaf or authority
|
|
830
|
+
certificate/SPKI bytes. `matched` requires independently validated DNSSEC;
|
|
831
|
+
PKIX-TA and PKIX-EE usages also require an independently validated PKIX path.
|
|
832
|
+
The command does not retrieve the certificate or negotiate STARTTLS.
|
|
833
|
+
- `whoisleuth.rdap-search-input`: normalizes a supplied RDAP search-help
|
|
834
|
+
response and prepares an exact supported reverse-search request without
|
|
835
|
+
sending it.
|
|
836
|
+
- `whoisleuth.rpki-route-input`: compares a route prefix and origin ASN with a
|
|
837
|
+
bounded analyst-supplied VRP set.
|
|
838
|
+
- `whoisleuth.local-geoip-query`: queries an analyst-supplied bounded prefix
|
|
839
|
+
database whose source, version, and licence metadata remain in the result.
|
|
840
|
+
- `whoisleuth.encrypted-dns-plan-input`: validates an explicitly reviewed
|
|
841
|
+
encrypted-DNS provider contract and prepares a bounded query plan. It does
|
|
842
|
+
not execute the plan.
|
|
843
|
+
|
|
844
|
+
The common output is `whoisleuth.cli.offline-evidence-review` version 1. It
|
|
845
|
+
retains the nested result's explicit state and limitations. A locally
|
|
846
|
+
consistent relationship is not converted into a claim about current
|
|
847
|
+
publication, ownership, safety, or maliciousness.
|
|
848
|
+
|
|
849
|
+
## Domain control manifests
|
|
850
|
+
|
|
851
|
+
`domain-control [input.json]` builds or reviews a bounded desired-state
|
|
852
|
+
manifest without making a request or changing configuration. A manifest input
|
|
853
|
+
uses `whoisleuth.domain-control-manifest-input` version 1, includes an expiry,
|
|
854
|
+
and may record expected nameservers, DS, MX, CAA, TLS issuer, TLS SPKI
|
|
855
|
+
fingerprint, registrar-lock preference, and a renewal-review date for up to 100
|
|
856
|
+
domains. Empty fields mean unconfigured; they do not require a record to be
|
|
857
|
+
absent.
|
|
858
|
+
|
|
859
|
+
The emitted `whoisleuth.domain-control-manifest` version 1 is normalized and
|
|
860
|
+
protected by a canonical SHA-256 digest. It can also be passed to
|
|
861
|
+
`sign-artifact` for an optional local Ed25519 signature. Integrity and
|
|
862
|
+
signature verification detect changes and authenticate possession of the
|
|
863
|
+
selected key; neither establishes that the desired state is correct.
|
|
864
|
+
|
|
865
|
+
A review input uses `whoisleuth.domain-control-review-input` version 1 and
|
|
866
|
+
contains one manifest plus separately attributed observations. Only a complete
|
|
867
|
+
`observed` field may produce `drift`. Partial, unavailable, unsupported, and
|
|
868
|
+
missing observations remain inconclusive, and unrelated observations are
|
|
869
|
+
counted but ignored. The review performs no DNS, RDAP, HTTP, TLS, SMTP, or
|
|
870
|
+
registrar request.
|
|
871
|
+
|
|
872
|
+
## Fixed investigation plans
|
|
873
|
+
|
|
874
|
+
`workflow-plan <recipe> <domain|brand>` turns one of four fixed recipes into a
|
|
875
|
+
versioned `whoisleuth.cli.investigation-plan` document. Supported recipes are
|
|
876
|
+
`domain-triage`, `lookalike-review`, `owned-domain-review`, and
|
|
877
|
+
`historical-comparison`. Each step names an existing CLI command, carries its
|
|
878
|
+
arguments as a JSON array, declares whether it is offline or networked, and
|
|
879
|
+
marks network-disclosure or analyst-selection approval gates.
|
|
880
|
+
|
|
881
|
+
The command is plan-only. It does not execute a step, construct a shell script,
|
|
882
|
+
interpret a placeholder as a file path, read an artifact, make a request,
|
|
883
|
+
change a case, or submit evidence. Analysts deliberately run selected commands
|
|
884
|
+
after reviewing their collection boundaries. This provides repeatable
|
|
885
|
+
domain-specific workflows without an arbitrary automation or plugin surface.
|
|
886
|
+
|
|
794
887
|
## Optional local rendered capture
|
|
795
888
|
|
|
796
889
|
`packages/web-capture` is a private repo-local Playwright package, not part of
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { normalizeBulkSessionResult, } from "./bulk-session-model.js";
|
|
2
|
+
import { limitedBulkSources } from "./bulk-source-coverage.js";
|
|
2
3
|
export const MAX_BULK_RETRY_ROWS = 200;
|
|
3
4
|
export const BULK_REVIEW_STALE_AFTER_DAYS = 7;
|
|
4
|
-
const LIMITED_STATES = new Set([
|
|
5
|
-
'error',
|
|
6
|
-
'partial',
|
|
7
|
-
'skipped',
|
|
8
|
-
'unavailable',
|
|
9
|
-
'unsupported',
|
|
10
|
-
]);
|
|
11
5
|
function freshness(value, now = Date.now()) {
|
|
12
6
|
if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) {
|
|
13
7
|
return { observedAt: null, ageDays: null, state: 'unknown' };
|
|
@@ -21,9 +15,7 @@ function freshness(value, now = Date.now()) {
|
|
|
21
15
|
};
|
|
22
16
|
}
|
|
23
17
|
function retryRow(value, isStale) {
|
|
24
|
-
const limitedSources = value.sourceCoverage
|
|
25
|
-
.filter((item) => LIMITED_STATES.has(item.state))
|
|
26
|
-
.map((item) => item.source)
|
|
18
|
+
const limitedSources = limitedBulkSources(value.domain, value.sourceCoverage)
|
|
27
19
|
.sort()
|
|
28
20
|
.slice(0, 12);
|
|
29
21
|
const reasons = [];
|
|
@@ -56,6 +48,7 @@ export function buildBulkRetryPlan(values, mode, observedAtRaw = null, now = Dat
|
|
|
56
48
|
'This repeats the selected lookup profile; the current API does not request only one upstream source.',
|
|
57
49
|
`Evidence is marked stale after ${BULK_REVIEW_STALE_AFTER_DAYS} days for review purposes; this is a local observation age, not an upstream record age.`,
|
|
58
50
|
'Optional third-party intelligence providers are not contacted by Bulk.',
|
|
51
|
+
'Sources deliberately skipped by the selected scan profile or deployment policy do not independently trigger a retry.',
|
|
59
52
|
'A retry can remain partial or fail and does not convert unavailable evidence into absence.',
|
|
60
53
|
],
|
|
61
54
|
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Authority-aware interpretation for compact Bulk source observations. Raw
|
|
2
|
+
// source states remain unchanged; this layer only decides whether an observed
|
|
3
|
+
// limitation was unexpected for the registry's published service paths.
|
|
4
|
+
import { registryCapabilityFor } from "../../../../lib/registry-capabilities.mjs";
|
|
5
|
+
const MAX_SOURCE_OBSERVATIONS = 32;
|
|
6
|
+
const MAX_SOURCE_TEXT_LENGTH = 40;
|
|
7
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
8
|
+
const LIMITED_SOURCE_STATES = new Set([
|
|
9
|
+
'error',
|
|
10
|
+
'failed',
|
|
11
|
+
'inconclusive',
|
|
12
|
+
'partial',
|
|
13
|
+
'rate_limited',
|
|
14
|
+
'unavailable',
|
|
15
|
+
'unsupported',
|
|
16
|
+
]);
|
|
17
|
+
function text(value) {
|
|
18
|
+
return typeof value === 'string'
|
|
19
|
+
&& value.length <= MAX_SOURCE_TEXT_LENGTH
|
|
20
|
+
&& !CONTROL_RE.test(value)
|
|
21
|
+
? value.trim().toLowerCase()
|
|
22
|
+
: '';
|
|
23
|
+
}
|
|
24
|
+
function record(value) {
|
|
25
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
26
|
+
? value
|
|
27
|
+
: null;
|
|
28
|
+
}
|
|
29
|
+
function observations(value) {
|
|
30
|
+
return Array.isArray(value)
|
|
31
|
+
? value.slice(0, MAX_SOURCE_OBSERVATIONS).flatMap((entry) => {
|
|
32
|
+
const item = record(entry);
|
|
33
|
+
const source = text(item?.source);
|
|
34
|
+
const state = text(item?.state);
|
|
35
|
+
return source && state ? [{ source, state }] : [];
|
|
36
|
+
})
|
|
37
|
+
: [];
|
|
38
|
+
}
|
|
39
|
+
function expectedUnsupportedSources(domain) {
|
|
40
|
+
const capability = registryCapabilityFor(domain);
|
|
41
|
+
if (!capability)
|
|
42
|
+
return new Set();
|
|
43
|
+
const expected = new Set();
|
|
44
|
+
if (capability.rdapAccessProfile === 'no-iana-service')
|
|
45
|
+
expected.add('rdap');
|
|
46
|
+
if (capability.whoisAccessProfile === 'no-iana-service')
|
|
47
|
+
expected.add('whois');
|
|
48
|
+
return expected;
|
|
49
|
+
}
|
|
50
|
+
export function isExpectedUnsupportedBulkSource(domain, observation) {
|
|
51
|
+
const source = text(observation.source);
|
|
52
|
+
const state = text(observation.state);
|
|
53
|
+
return state === 'unsupported' && expectedUnsupportedSources(domain).has(source);
|
|
54
|
+
}
|
|
55
|
+
export function limitedBulkSources(domain, coverage) {
|
|
56
|
+
const expectedUnsupported = expectedUnsupportedSources(domain);
|
|
57
|
+
return observations(coverage)
|
|
58
|
+
.filter(({ source, state }) => (LIMITED_SOURCE_STATES.has(state)
|
|
59
|
+
&& !(state === 'unsupported' && expectedUnsupported.has(source))))
|
|
60
|
+
.map(({ source }) => source);
|
|
61
|
+
}
|
|
62
|
+
export function classifyBulkSourceCoverage(domain, coverage) {
|
|
63
|
+
const retained = observations(coverage);
|
|
64
|
+
if (!retained.length)
|
|
65
|
+
return 'unrecorded';
|
|
66
|
+
return limitedBulkSources(domain, retained).length ? 'limited' : 'complete';
|
|
67
|
+
}
|
|
68
|
+
export function describeBulkSourceCoverage(domain, observation) {
|
|
69
|
+
const source = text(observation.source);
|
|
70
|
+
const state = text(observation.state);
|
|
71
|
+
if (!source || !state)
|
|
72
|
+
return 'unrecorded source';
|
|
73
|
+
return isExpectedUnsupportedBulkSource(domain, observation)
|
|
74
|
+
? `${source}: unsupported (no IANA-published service)`
|
|
75
|
+
: `${source}: ${state}`;
|
|
76
|
+
}
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// connection attempts. Bulk/watchlist/case records deliberately keep only this
|
|
4
4
|
// smaller derived shape: no paths, queries, header values, attempt errors, or
|
|
5
5
|
// redirect inventories.
|
|
6
|
+
import { MAX_HTTP_EVIDENCE_REDIRECTS } from "../../../../lib/http-evidence-bounds.mjs";
|
|
6
7
|
export const HTTP_SUMMARY_VERSION = 1;
|
|
7
8
|
export const MAX_HTTP_SUMMARY_ORIGIN_LENGTH = 300;
|
|
8
9
|
export const MAX_HTTP_SUMMARY_CONTENT_TYPE_LENGTH = 100;
|
|
9
|
-
export const MAX_HTTP_SUMMARY_REDIRECTS =
|
|
10
|
+
export const MAX_HTTP_SUMMARY_REDIRECTS = MAX_HTTP_EVIDENCE_REDIRECTS;
|
|
10
11
|
export const MAX_HTTP_SECURITY_HEADER_INPUTS = 20;
|
|
11
12
|
export const HTTP_SECURITY_HEADER_TOKENS = Object.freeze([
|
|
12
13
|
'content-security-policy',
|