@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
|
@@ -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/mail-review.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { getDomain } from 'tldts';
|
|
3
|
+
import { analyzeTlsaEvidence } from "../lib/tlsa-evidence.mjs";
|
|
3
4
|
import { CliUsageError } from "./errors.mjs";
|
|
4
5
|
export const CLI_MAIL_REVIEW_SCHEMA = 'whoisleuth.cli.mail-review';
|
|
5
|
-
export const CLI_MAIL_REVIEW_VERSION =
|
|
6
|
+
export const CLI_MAIL_REVIEW_VERSION = 2;
|
|
6
7
|
export const MAX_MAIL_REVIEW_INPUT_BYTES = 16 * 1024 * 1024;
|
|
7
8
|
export const MAX_MAIL_REVIEW_ROWS = 500;
|
|
8
9
|
function record(value) {
|
|
@@ -53,6 +54,21 @@ function mailState(input) {
|
|
|
53
54
|
return 'no_explicit_mx';
|
|
54
55
|
return 'evidence_incomplete';
|
|
55
56
|
}
|
|
57
|
+
function alignMailDaneEvidence(report, hosts) {
|
|
58
|
+
const aligned = report.service?.transport === 'tcp'
|
|
59
|
+
&& report.service.port === 25
|
|
60
|
+
&& hosts.includes(report.service.hostname);
|
|
61
|
+
if (aligned || report.state === 'invalid')
|
|
62
|
+
return report;
|
|
63
|
+
return Object.freeze({
|
|
64
|
+
...report,
|
|
65
|
+
state: 'invalid',
|
|
66
|
+
limitations: Object.freeze([
|
|
67
|
+
...report.limitations,
|
|
68
|
+
'The TLSA service name did not identify port 25 on an MX hostname retained for this domain.',
|
|
69
|
+
]),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
56
72
|
function normalizeMailRow(value) {
|
|
57
73
|
const item = record(value);
|
|
58
74
|
if (item.ok !== true)
|
|
@@ -70,6 +86,18 @@ function normalizeMailRow(value) {
|
|
|
70
86
|
const hasSpf = boolean(availability.hasSpf) ?? boolean(summary.hasSpf);
|
|
71
87
|
const hasDmarc = boolean(availability.hasDmarc) ?? boolean(summary.hasDmarc);
|
|
72
88
|
const state = mailState({ dnsStatus, hasMx, hasNullMx, hasSpf, hasDmarc });
|
|
89
|
+
const tlsaInput = record(item.tlsaEvidence);
|
|
90
|
+
const dane = Object.keys(tlsaInput).length
|
|
91
|
+
? alignMailDaneEvidence(analyzeTlsaEvidence({
|
|
92
|
+
serviceName: tlsaInput.serviceName,
|
|
93
|
+
dnssecState: tlsaInput.dnssecState,
|
|
94
|
+
pkixValidationState: tlsaInput.pkixValidationState,
|
|
95
|
+
records: tlsaInput.records,
|
|
96
|
+
certificateDerBase64: tlsaInput.certificateDerBase64,
|
|
97
|
+
spkiDerBase64: tlsaInput.spkiDerBase64,
|
|
98
|
+
authorityMaterials: tlsaInput.authorityMaterials,
|
|
99
|
+
}), hosts)
|
|
100
|
+
: null;
|
|
73
101
|
return {
|
|
74
102
|
domain,
|
|
75
103
|
state,
|
|
@@ -80,9 +108,11 @@ function normalizeMailRow(value) {
|
|
|
80
108
|
hasDmarc,
|
|
81
109
|
mxHosts: hosts,
|
|
82
110
|
providerDomains: providerDomains(hosts),
|
|
111
|
+
dane,
|
|
83
112
|
limitations: [
|
|
84
113
|
...(dnsStatus === 'partial' ? ['DNS evidence was partial.'] : []),
|
|
85
114
|
...(state === 'no_explicit_mx' ? ['No explicit MX is not equivalent to a null MX or proof that delivery is impossible.'] : []),
|
|
115
|
+
...(dane?.state === 'matched' ? [] : dane ? ['Supplied TLSA evidence did not establish one complete DNSSEC-validated match.'] : ['No TLSA evidence was supplied for offline DANE comparison.']),
|
|
86
116
|
'Mail configuration does not establish use, control, intent, safety, or maliciousness.',
|
|
87
117
|
],
|
|
88
118
|
};
|
|
@@ -142,6 +172,17 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
142
172
|
};
|
|
143
173
|
for (const row of rows)
|
|
144
174
|
counts[row.state] += 1;
|
|
175
|
+
const daneCounts = {
|
|
176
|
+
matched: 0,
|
|
177
|
+
different: 0,
|
|
178
|
+
partial: 0,
|
|
179
|
+
untrusted: 0,
|
|
180
|
+
unavailable: 0,
|
|
181
|
+
invalid: 0,
|
|
182
|
+
not_supplied: 0,
|
|
183
|
+
};
|
|
184
|
+
for (const row of rows)
|
|
185
|
+
daneCounts[row.dane?.state ?? 'not_supplied'] += 1;
|
|
145
186
|
const providerMap = new Map();
|
|
146
187
|
for (const row of rows) {
|
|
147
188
|
for (const provider of row.providerDomains) {
|
|
@@ -165,11 +206,13 @@ function buildCliMailReview(textValue, generatedAt = new Date().toISOString()) {
|
|
|
165
206
|
version: CLI_MAIL_REVIEW_VERSION,
|
|
166
207
|
generatedAt,
|
|
167
208
|
counts,
|
|
209
|
+
daneCounts,
|
|
168
210
|
rows,
|
|
169
211
|
providerRelationships,
|
|
170
212
|
limitations: [
|
|
171
213
|
'This review is passive and uses DNS evidence already retained in a WHOISleuth Bulk result; it makes no network request.',
|
|
172
214
|
'Null MX, no explicit MX, receiving mail, authentication gaps, and incomplete evidence remain separate states.',
|
|
215
|
+
'Optional TLSA evidence is compared offline. A DANE match requires separately validated DNSSEC evidence from the same observation.',
|
|
173
216
|
'SMTP delivery, mailbox existence, catch-all behavior, banner collection, and message acceptance were not tested.',
|
|
174
217
|
],
|
|
175
218
|
};
|
|
@@ -183,12 +226,14 @@ function formatCliMailReview(document) {
|
|
|
183
226
|
`Null MX ${document.counts.null_mx}`,
|
|
184
227
|
`No explicit MX ${document.counts.no_explicit_mx}`,
|
|
185
228
|
`Incomplete ${document.counts.evidence_incomplete + document.counts.mail_auth_incomplete}`,
|
|
229
|
+
`DANE matched ${document.daneCounts.matched}`,
|
|
186
230
|
'',
|
|
187
231
|
];
|
|
188
232
|
for (const row of document.rows) {
|
|
189
233
|
lines.push(`${row.domain} ${row.state.replaceAll('_', ' ')}`);
|
|
190
234
|
lines.push(` MX providers ${row.providerDomains.join(', ') || 'None observed'}`);
|
|
191
235
|
lines.push(` SPF / DMARC ${row.hasSpf === null ? 'unknown' : row.hasSpf ? 'observed' : 'not observed'} / ${row.hasDmarc === null ? 'unknown' : row.hasDmarc ? 'observed' : 'not observed'}`);
|
|
236
|
+
lines.push(` DANE / TLSA ${row.dane?.state ?? 'not supplied'}`);
|
|
192
237
|
}
|
|
193
238
|
if (document.providerRelationships.length) {
|
|
194
239
|
lines.push('', 'Shared mail-provider relationships');
|
|
@@ -201,4 +246,4 @@ function formatCliMailReview(document) {
|
|
|
201
246
|
lines.push(` - ${limitation}`);
|
|
202
247
|
return `${lines.join('\n')}\n`;
|
|
203
248
|
}
|
|
204
|
-
export { buildCliMailReview, formatCliMailReview, mailState, normalizeMailRow };
|
|
249
|
+
export { alignMailDaneEvidence, buildCliMailReview, formatCliMailReview, mailState, normalizeMailRow };
|
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
|
|
@@ -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'),
|