@slicedearth/whoisleuth-cli 1.34.0 → 1.39.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 +19 -2
- package/bin/whoisleuth.mjs +41 -24
- package/cli/arguments.mjs +326 -3
- package/cli/artifact-verify.mjs +60 -14
- package/cli/bulk.mjs +1 -2
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +36 -2
- package/cli/doctor.mjs +11 -5
- package/cli/evidence-command-arguments.mjs +1 -1
- package/cli/evidence-command-runner.mjs +2 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/export-evidence.mjs +9 -2
- package/cli/formatters/html.mjs +1 -1
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +14 -4
- package/cli/investigation-plan.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-command-runner.mjs +4 -1
- package/cli/lookup-plan.mjs +3 -3
- package/cli/lookup-reconcile.mjs +141 -0
- package/cli/lookup-timeline.mjs +1 -1
- package/cli/mail-review.mjs +48 -3
- package/cli/offline-evidence-review.mjs +159 -0
- package/cli/registry-doctor.mjs +125 -0
- package/cli/registry-support.mjs +1 -4
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +342 -15
- package/cli/saved-lookup.mjs +2 -2
- package/cli/sharing-review.mjs +202 -0
- package/cli/source-reliability.mjs +1 -1
- package/docs/cli-reference.md +1110 -0
- package/docs/cli.md +224 -847
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +6 -6
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +12 -12
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +1 -1
- package/frontend/src/lib/analysis/bulk-retry-plan.js +3 -10
- package/frontend/src/lib/analysis/bulk-session-model.js +7 -2
- package/frontend/src/lib/analysis/bulk-source-coverage.js +76 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +26 -4
- package/frontend/src/lib/analysis/case-migration-model.js +6 -1
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-core.js +5 -1
- package/frontend/src/lib/analysis/case-record-operations.js +6 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/http-summary.js +2 -1
- package/frontend/src/lib/analysis/investigation-capsule.js +105 -0
- package/frontend/src/lib/analysis/lookup-asset-graph.js +806 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +480 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +74 -0
- package/frontend/src/lib/analysis/lookup-response.js +2 -0
- package/frontend/src/lib/analysis/lookup-source-refresh.js +304 -0
- package/frontend/src/lib/analysis/lookup-summary-model.js +398 -0
- package/frontend/src/lib/analysis/page-similarity.js +1 -1
- package/frontend/src/lib/analysis/relationship-evidence.js +3 -3
- package/frontend/src/lib/analysis/relationship-observation-model.js +4 -4
- package/frontend/src/lib/analysis/scoring.js +8 -88
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- package/frontend/src/lib/analysis/utils.js +7 -40
- package/frontend/src/lib/analysis/visualization-bounds.js +26 -0
- package/frontend/src/lib/analysis/visualization-force-graph.js +352 -0
- package/frontend/src/lib/analysis/visualization-matrix.js +88 -0
- package/frontend/src/lib/analysis/visualization-models.js +370 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +57 -3
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +2 -2
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +30 -10
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/bulk-limits.mjs +13 -0
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.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-assurance.mjs +368 -0
- package/lib/domain-control-manifest.mjs +382 -0
- package/lib/domain-posture.mjs +11 -10
- package/lib/encrypted-dns-contract.mjs +81 -0
- package/lib/evidence-export.mjs +17 -3
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +4 -1
- package/lib/favicon.mjs +3 -8
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- 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 +87 -0
- package/lib/local-geoip-evidence.mjs +89 -0
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/outbound-identity.mjs +9 -0
- package/lib/outbound-request-bounds.mjs +9 -0
- package/lib/page-fingerprints.mjs +1 -1
- 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-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +247 -0
- package/lib/rdap.mjs +7 -2
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capabilities.mjs +31 -1
- package/lib/registry-capability-catalogue.mjs +15 -14
- package/lib/registry-insights.mjs +6 -6
- package/lib/response-policy.mjs +81 -6
- package/lib/risk-scoring.mjs +206 -83
- package/lib/rpki-evidence.mjs +105 -0
- package/lib/safe-fetch.mjs +2 -1
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/semantic-version.mjs +54 -0
- package/lib/static-html-analysis.mjs +27 -0
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +11 -6
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/tlsa-evidence.mjs +194 -0
- package/lib/urlhaus-intelligence.mjs +11 -6
- package/lib/urlscan-intelligence.mjs +10 -5
- package/lib/website-security-posture.mjs +16 -9
- package/lib/website-technology.mjs +1 -1
- package/lib/whois-chain.mjs +14 -152
- package/lib/whois.mjs +2 -2
- package/package.json +6 -2
- package/third-party-notices.txt +911 -19
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { domainToASCII } from 'node:url';
|
|
3
|
+
import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { exactKeys } from "./bounded-contract-normalizers.mjs";
|
|
5
|
+
export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
|
|
6
|
+
export const DOMAIN_CONTROL_MANIFEST_SCHEMA = 'whoisleuth.domain-control-manifest';
|
|
7
|
+
export const DOMAIN_CONTROL_MANIFEST_VERSION = 1;
|
|
8
|
+
export const DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.domain-control-review-input';
|
|
9
|
+
export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
|
|
10
|
+
export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
|
|
11
|
+
export const MAX_DOMAIN_CONTROL_ENTRIES = 100;
|
|
12
|
+
export const MAX_DOMAIN_CONTROL_RECORDS = 32;
|
|
13
|
+
const MANIFEST_KEYS = new Set(['schema', 'version', 'generatedAt', 'expiresAt', 'entries', 'limitations', 'integrity']);
|
|
14
|
+
const MANIFEST_ENTRY_KEYS = new Set(['domain', 'nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock', 'renewalReviewAt', 'note']);
|
|
15
|
+
const MANIFEST_INTEGRITY_KEYS = new Set(['algorithm', 'canonicalization', 'digestSha256']);
|
|
16
|
+
const MANIFEST_INPUT_KEYS = new Set(['schema', 'version', 'expiresAt', 'entries']);
|
|
17
|
+
const REVIEW_INPUT_KEYS = new Set(['schema', 'version', 'manifest', 'observations']);
|
|
18
|
+
const OBSERVATION_KEYS = new Set(['domain', 'fields']);
|
|
19
|
+
const OBSERVATION_FIELDS = new Set(['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']);
|
|
20
|
+
const OBSERVATION_FIELD_KEYS = new Set(['state', 'values', 'source', 'observedAt']);
|
|
21
|
+
const DOMAIN_CONTROL_LIMITATIONS = Object.freeze([
|
|
22
|
+
'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
|
|
23
|
+
'Empty desired fields are unconfigured rather than claims that a record should be absent.',
|
|
24
|
+
]);
|
|
25
|
+
function record(value) {
|
|
26
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
27
|
+
? value
|
|
28
|
+
: null;
|
|
29
|
+
}
|
|
30
|
+
function boundedText(value, maximum = 300) {
|
|
31
|
+
if (typeof value !== 'string' || value.length > maximum * 4 || /[\u0000-\u001f\u007f]/u.test(value))
|
|
32
|
+
return null;
|
|
33
|
+
return value.replace(/\s+/gu, ' ').trim().slice(0, maximum) || null;
|
|
34
|
+
}
|
|
35
|
+
function timestamp(value) {
|
|
36
|
+
const text = boundedText(value, 64);
|
|
37
|
+
if (!text)
|
|
38
|
+
return null;
|
|
39
|
+
const parsed = Date.parse(text);
|
|
40
|
+
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
|
|
41
|
+
}
|
|
42
|
+
function domain(value) {
|
|
43
|
+
const text = boundedText(value, 253)?.toLowerCase().replace(/\.$/u, '');
|
|
44
|
+
if (!text)
|
|
45
|
+
return null;
|
|
46
|
+
const ascii = domainToASCII(text);
|
|
47
|
+
if (!ascii || ascii.length > 253 || ascii.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
|
|
48
|
+
return null;
|
|
49
|
+
return ascii;
|
|
50
|
+
}
|
|
51
|
+
function hostnameList(value) {
|
|
52
|
+
if (!Array.isArray(value))
|
|
53
|
+
return [];
|
|
54
|
+
return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
|
|
55
|
+
.map(domain)
|
|
56
|
+
.filter((item) => item !== null))]
|
|
57
|
+
.sort()
|
|
58
|
+
.slice(0, MAX_DOMAIN_CONTROL_RECORDS);
|
|
59
|
+
}
|
|
60
|
+
function textList(value, maximum = 300) {
|
|
61
|
+
if (!Array.isArray(value))
|
|
62
|
+
return [];
|
|
63
|
+
return [...new Set(value.slice(0, MAX_DOMAIN_CONTROL_RECORDS * 4)
|
|
64
|
+
.map((item) => boundedText(item, maximum)?.toLowerCase() ?? null)
|
|
65
|
+
.filter((item) => item !== null))]
|
|
66
|
+
.sort()
|
|
67
|
+
.slice(0, MAX_DOMAIN_CONTROL_RECORDS);
|
|
68
|
+
}
|
|
69
|
+
function spkiFingerprint(value) {
|
|
70
|
+
const text = boundedText(value, 64)?.toLowerCase();
|
|
71
|
+
return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
|
|
72
|
+
}
|
|
73
|
+
function normalizeEntry(value) {
|
|
74
|
+
const source = record(value);
|
|
75
|
+
const normalizedDomain = domain(source?.domain);
|
|
76
|
+
if (!source || !normalizedDomain)
|
|
77
|
+
return null;
|
|
78
|
+
const registrarLock = source.registrarLock === 'required' || source.registrarLock === 'not_required'
|
|
79
|
+
? source.registrarLock
|
|
80
|
+
: null;
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
domain: normalizedDomain,
|
|
83
|
+
nameservers: Object.freeze(hostnameList(source.nameservers)),
|
|
84
|
+
ds: Object.freeze(textList(source.ds, 500)),
|
|
85
|
+
mx: Object.freeze(hostnameList(source.mx)),
|
|
86
|
+
caa: Object.freeze(textList(source.caa, 500)),
|
|
87
|
+
tlsIssuer: boundedText(source.tlsIssuer, 300)?.toLowerCase() ?? null,
|
|
88
|
+
tlsSpkiSha256: spkiFingerprint(source.tlsSpkiSha256),
|
|
89
|
+
registrarLock,
|
|
90
|
+
renewalReviewAt: timestamp(source.renewalReviewAt),
|
|
91
|
+
note: boundedText(source.note, 500),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function unsignedManifest(value) {
|
|
95
|
+
return value;
|
|
96
|
+
}
|
|
97
|
+
function integrityDigest(value) {
|
|
98
|
+
return `sha256:${createHash('sha256').update(canonicalArtifactJson(value)).digest('hex')}`;
|
|
99
|
+
}
|
|
100
|
+
export function buildDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
|
|
101
|
+
const source = record(input);
|
|
102
|
+
if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
|
|
103
|
+
throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
|
|
104
|
+
}
|
|
105
|
+
exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
|
|
106
|
+
const generatedAt = timestamp(generatedAtValue);
|
|
107
|
+
const expiresAt = timestamp(source.expiresAt);
|
|
108
|
+
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
|
|
109
|
+
throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
|
|
112
|
+
throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
|
|
113
|
+
}
|
|
114
|
+
const entries = source.entries.map((entry, index) => {
|
|
115
|
+
exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest input entry ${index + 1}`);
|
|
116
|
+
return normalizeEntry(entry);
|
|
117
|
+
});
|
|
118
|
+
if (entries.some((entry) => entry === null))
|
|
119
|
+
throw new TypeError('Domain control manifest contains an invalid entry.');
|
|
120
|
+
const normalizedEntries = entries;
|
|
121
|
+
const domains = new Set(normalizedEntries.map((entry) => entry.domain));
|
|
122
|
+
if (domains.size !== normalizedEntries.length)
|
|
123
|
+
throw new TypeError('Domain control manifest entries must use unique domains.');
|
|
124
|
+
const base = Object.freeze({
|
|
125
|
+
schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
|
|
126
|
+
version: DOMAIN_CONTROL_MANIFEST_VERSION,
|
|
127
|
+
generatedAt,
|
|
128
|
+
expiresAt,
|
|
129
|
+
entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
|
|
130
|
+
limitations: DOMAIN_CONTROL_LIMITATIONS,
|
|
131
|
+
});
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
...base,
|
|
134
|
+
integrity: Object.freeze({
|
|
135
|
+
algorithm: 'SHA-256',
|
|
136
|
+
canonicalization: 'sorted-json-v1',
|
|
137
|
+
digestSha256: integrityDigest(unsignedManifest(base)),
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
export function verifyDomainControlManifest(value) {
|
|
142
|
+
const source = record(value);
|
|
143
|
+
const integrity = record(source?.integrity);
|
|
144
|
+
if (!source
|
|
145
|
+
|| source.schema !== DOMAIN_CONTROL_MANIFEST_SCHEMA
|
|
146
|
+
|| source.version !== DOMAIN_CONTROL_MANIFEST_VERSION
|
|
147
|
+
|| !Array.isArray(source.entries)
|
|
148
|
+
|| source.entries.length < 1
|
|
149
|
+
|| source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
|
|
150
|
+
|| !Array.isArray(source.limitations)
|
|
151
|
+
|| source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
|
|
152
|
+
|| source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
|
|
153
|
+
|| !integrity
|
|
154
|
+
|| integrity.algorithm !== 'SHA-256'
|
|
155
|
+
|| integrity.canonicalization !== 'sorted-json-v1'
|
|
156
|
+
|| typeof integrity.digestSha256 !== 'string'
|
|
157
|
+
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
158
|
+
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
159
|
+
}
|
|
160
|
+
exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
|
|
161
|
+
exactKeys(integrity, MANIFEST_INTEGRITY_KEYS, 'Domain control manifest integrity');
|
|
162
|
+
for (const [index, entry] of source.entries.entries()) {
|
|
163
|
+
exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest entry ${index + 1}`);
|
|
164
|
+
}
|
|
165
|
+
const generatedAt = timestamp(source.generatedAt);
|
|
166
|
+
const expiresAt = timestamp(source.expiresAt);
|
|
167
|
+
const entries = source.entries.map(normalizeEntry);
|
|
168
|
+
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
|
|
169
|
+
throw new TypeError('Domain control manifest has invalid normalised content.');
|
|
170
|
+
}
|
|
171
|
+
const normalizedEntries = entries;
|
|
172
|
+
if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
|
|
173
|
+
throw new TypeError('Domain control manifest entries must use unique domains.');
|
|
174
|
+
}
|
|
175
|
+
const { integrity: _integrity, ...unsigned } = source;
|
|
176
|
+
const canonical = Object.freeze({
|
|
177
|
+
schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
|
|
178
|
+
version: DOMAIN_CONTROL_MANIFEST_VERSION,
|
|
179
|
+
generatedAt,
|
|
180
|
+
expiresAt,
|
|
181
|
+
entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
|
|
182
|
+
limitations: DOMAIN_CONTROL_LIMITATIONS,
|
|
183
|
+
});
|
|
184
|
+
let suppliedCanonical;
|
|
185
|
+
try {
|
|
186
|
+
suppliedCanonical = canonicalArtifactJson(unsigned);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
throw new TypeError('Domain control manifest has invalid normalised content.');
|
|
190
|
+
}
|
|
191
|
+
if (suppliedCanonical !== canonicalArtifactJson(canonical)) {
|
|
192
|
+
throw new TypeError('Domain control manifest must use its canonical normalised content.');
|
|
193
|
+
}
|
|
194
|
+
if (`sha256:${createHash('sha256').update(suppliedCanonical).digest('hex')}` !== integrity.digestSha256) {
|
|
195
|
+
throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
|
|
196
|
+
}
|
|
197
|
+
return source;
|
|
198
|
+
}
|
|
199
|
+
function fieldValues(field, value) {
|
|
200
|
+
if (field === 'nameservers' || field === 'mx')
|
|
201
|
+
return hostnameList(value);
|
|
202
|
+
if (field === 'tlsSpkiSha256') {
|
|
203
|
+
const normalized = Array.isArray(value) ? value.map(spkiFingerprint).filter(Boolean) : [spkiFingerprint(value)].filter(Boolean);
|
|
204
|
+
return [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
|
|
205
|
+
}
|
|
206
|
+
if (field === 'registrarLock') {
|
|
207
|
+
const values = Array.isArray(value) ? value : [value];
|
|
208
|
+
return [...new Set(values.filter((item) => item === 'required' || item === 'not_required'))];
|
|
209
|
+
}
|
|
210
|
+
return textList(Array.isArray(value) ? value : [value], 500);
|
|
211
|
+
}
|
|
212
|
+
function normalizeObservation(value) {
|
|
213
|
+
const source = record(value);
|
|
214
|
+
const normalizedDomain = domain(source?.domain);
|
|
215
|
+
const rawFields = record(source?.fields);
|
|
216
|
+
if (!source || !normalizedDomain || !rawFields)
|
|
217
|
+
return null;
|
|
218
|
+
exactKeys(source, OBSERVATION_KEYS, 'Domain control observation');
|
|
219
|
+
exactKeys(rawFields, OBSERVATION_FIELDS, 'Domain control observation fields');
|
|
220
|
+
const fields = {};
|
|
221
|
+
for (const field of ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']) {
|
|
222
|
+
const raw = record(rawFields[field]);
|
|
223
|
+
if (!raw)
|
|
224
|
+
continue;
|
|
225
|
+
exactKeys(raw, OBSERVATION_FIELD_KEYS, `Domain control observation field ${field}`);
|
|
226
|
+
const state = raw.state;
|
|
227
|
+
if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
|
|
228
|
+
return null;
|
|
229
|
+
const sourceLabel = boundedText(raw.source, 120);
|
|
230
|
+
if (!sourceLabel)
|
|
231
|
+
return null;
|
|
232
|
+
fields[field] = Object.freeze({
|
|
233
|
+
state,
|
|
234
|
+
values: Object.freeze(fieldValues(field, raw.values)),
|
|
235
|
+
source: sourceLabel,
|
|
236
|
+
observedAt: timestamp(raw.observedAt),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return Object.freeze({ domain: normalizedDomain, fields: Object.freeze(fields) });
|
|
240
|
+
}
|
|
241
|
+
function desiredValues(entry, field) {
|
|
242
|
+
if (field === 'tlsIssuer')
|
|
243
|
+
return entry.tlsIssuer ? [entry.tlsIssuer] : [];
|
|
244
|
+
if (field === 'tlsSpkiSha256')
|
|
245
|
+
return entry.tlsSpkiSha256 ? [entry.tlsSpkiSha256] : [];
|
|
246
|
+
if (field === 'registrarLock')
|
|
247
|
+
return entry.registrarLock ? [entry.registrarLock] : [];
|
|
248
|
+
return [...entry[field]];
|
|
249
|
+
}
|
|
250
|
+
function compareField(entry, observation, field) {
|
|
251
|
+
const desired = desiredValues(entry, field);
|
|
252
|
+
if (!desired.length)
|
|
253
|
+
return Object.freeze({
|
|
254
|
+
field, state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
|
|
255
|
+
explanation: 'No desired value was configured for this field.',
|
|
256
|
+
});
|
|
257
|
+
const evidence = observation?.fields[field];
|
|
258
|
+
if (!evidence)
|
|
259
|
+
return Object.freeze({
|
|
260
|
+
field, state: 'unavailable', desired: Object.freeze(desired), observed: Object.freeze([]), source: null, observedAt: null,
|
|
261
|
+
explanation: 'No separately attributed observation was supplied for this field.',
|
|
262
|
+
});
|
|
263
|
+
if (evidence.state !== 'observed')
|
|
264
|
+
return Object.freeze({
|
|
265
|
+
field,
|
|
266
|
+
state: evidence.state,
|
|
267
|
+
desired: Object.freeze(desired),
|
|
268
|
+
observed: evidence.values,
|
|
269
|
+
source: evidence.source,
|
|
270
|
+
observedAt: evidence.observedAt,
|
|
271
|
+
explanation: evidence.state === 'partial'
|
|
272
|
+
? 'The supplied observation was partial, so a difference is inconclusive.'
|
|
273
|
+
: `The supplied source reported this field as ${evidence.state}.`,
|
|
274
|
+
});
|
|
275
|
+
const aligned = desired.length === evidence.values.length && desired.every((item, index) => item === evidence.values[index]);
|
|
276
|
+
return Object.freeze({
|
|
277
|
+
field,
|
|
278
|
+
state: aligned ? 'aligned' : 'drift',
|
|
279
|
+
desired: Object.freeze(desired),
|
|
280
|
+
observed: evidence.values,
|
|
281
|
+
source: evidence.source,
|
|
282
|
+
observedAt: evidence.observedAt,
|
|
283
|
+
explanation: aligned
|
|
284
|
+
? 'The complete supplied observation matches the desired value.'
|
|
285
|
+
: 'The complete supplied observation differs from the desired value.',
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
function renewalComparison(entry, now) {
|
|
289
|
+
if (!entry.renewalReviewAt)
|
|
290
|
+
return Object.freeze({
|
|
291
|
+
field: 'renewalReviewAt', state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
|
|
292
|
+
explanation: 'No renewal review date was configured.',
|
|
293
|
+
});
|
|
294
|
+
const due = Date.parse(entry.renewalReviewAt) <= Date.parse(now);
|
|
295
|
+
return Object.freeze({
|
|
296
|
+
field: 'renewalReviewAt', state: due ? 'due' : 'aligned', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
|
|
297
|
+
explanation: due ? 'The configured renewal review date is due.' : 'The configured renewal review date is still in the future.',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
|
|
301
|
+
const source = record(input);
|
|
302
|
+
if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== 1) {
|
|
303
|
+
throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
|
|
304
|
+
}
|
|
305
|
+
exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
|
|
306
|
+
const generatedAt = timestamp(generatedAtValue);
|
|
307
|
+
if (!generatedAt)
|
|
308
|
+
throw new TypeError('Domain control review time is invalid.');
|
|
309
|
+
const manifest = verifyDomainControlManifest(source.manifest);
|
|
310
|
+
if (!Array.isArray(source.observations) || source.observations.length > MAX_DOMAIN_CONTROL_ENTRIES * 2) {
|
|
311
|
+
throw new TypeError('Domain control observations must be a bounded array.');
|
|
312
|
+
}
|
|
313
|
+
const observations = source.observations.map(normalizeObservation);
|
|
314
|
+
if (observations.some((item) => item === null))
|
|
315
|
+
throw new TypeError('Domain control review contains an invalid observation.');
|
|
316
|
+
const normalizedObservations = observations;
|
|
317
|
+
if (new Set(normalizedObservations.map((item) => item.domain)).size !== normalizedObservations.length) {
|
|
318
|
+
throw new TypeError('Domain control observations must use unique domains.');
|
|
319
|
+
}
|
|
320
|
+
const observationMap = new Map(normalizedObservations.map((item) => [item.domain, item]));
|
|
321
|
+
const fields = ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock'];
|
|
322
|
+
const domains = manifest.entries.map((entry) => {
|
|
323
|
+
const observation = observationMap.get(entry.domain) ?? null;
|
|
324
|
+
const comparisons = [...fields.map((field) => compareField(entry, observation, field)), renewalComparison(entry, generatedAt)];
|
|
325
|
+
const state = comparisons.some((item) => item.state === 'drift' || item.state === 'due')
|
|
326
|
+
? 'drift'
|
|
327
|
+
: comparisons.some((item) => item.state === 'partial' || item.state === 'unavailable' || item.state === 'unsupported')
|
|
328
|
+
? 'partial'
|
|
329
|
+
: 'aligned';
|
|
330
|
+
return Object.freeze({ domain: entry.domain, state, comparisons: Object.freeze(comparisons) });
|
|
331
|
+
});
|
|
332
|
+
const comparisons = domains.flatMap((item) => item.comparisons);
|
|
333
|
+
const counts = Object.fromEntries(['aligned', 'drift', 'partial', 'unavailable', 'unsupported', 'not_configured', 'due']
|
|
334
|
+
.map((state) => [state, comparisons.filter((item) => item.state === state).length]));
|
|
335
|
+
const expired = Date.parse(manifest.expiresAt) <= Date.parse(generatedAt);
|
|
336
|
+
return Object.freeze({
|
|
337
|
+
schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
|
|
338
|
+
version: DOMAIN_CONTROL_REVIEW_VERSION,
|
|
339
|
+
generatedAt,
|
|
340
|
+
state: expired ? 'expired' : domains.some((item) => item.state === 'drift') ? 'drift' : domains.some((item) => item.state === 'partial') ? 'partial' : 'aligned',
|
|
341
|
+
manifest: Object.freeze({
|
|
342
|
+
generatedAt: manifest.generatedAt,
|
|
343
|
+
expiresAt: manifest.expiresAt,
|
|
344
|
+
digestSha256: manifest.integrity.digestSha256,
|
|
345
|
+
expired,
|
|
346
|
+
}),
|
|
347
|
+
counts: Object.freeze(counts),
|
|
348
|
+
domains: Object.freeze(domains),
|
|
349
|
+
ignoredObservationCount: normalizedObservations.filter((item) => !manifest.entries.some((entry) => entry.domain === item.domain)).length,
|
|
350
|
+
limitations: Object.freeze([
|
|
351
|
+
'This local review compares analyst-authored desired state with separately supplied observations and performs no collection or configuration change.',
|
|
352
|
+
'Only complete observations can produce drift. Partial, unavailable, unsupported, or missing evidence remains inconclusive.',
|
|
353
|
+
'A valid manifest digest or signature establishes file integrity, not the correctness of the desired state or supplied observations.',
|
|
354
|
+
]),
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
export function formatDomainControlResult(value) {
|
|
358
|
+
if (value.schema === DOMAIN_CONTROL_MANIFEST_SCHEMA) {
|
|
359
|
+
return [
|
|
360
|
+
'Domain control manifest',
|
|
361
|
+
`Domains ${value.entries.length}`,
|
|
362
|
+
`Expires ${value.expiresAt}`,
|
|
363
|
+
`Digest ${value.integrity.digestSha256}`,
|
|
364
|
+
'',
|
|
365
|
+
'The manifest records desired state only and performs no collection or configuration change.',
|
|
366
|
+
'',
|
|
367
|
+
].join('\n');
|
|
368
|
+
}
|
|
369
|
+
return [
|
|
370
|
+
'Domain control review',
|
|
371
|
+
`State ${value.state}`,
|
|
372
|
+
`Domains ${value.domains.length}`,
|
|
373
|
+
`Drift ${value.counts.drift ?? 0}`,
|
|
374
|
+
`Partial ${(value.counts.partial ?? 0) + (value.counts.unavailable ?? 0) + (value.counts.unsupported ?? 0)}`,
|
|
375
|
+
`Due ${value.counts.due ?? 0}`,
|
|
376
|
+
'',
|
|
377
|
+
...value.domains.map((item) => `${item.domain} ${item.state}`),
|
|
378
|
+
'',
|
|
379
|
+
...value.limitations.map((item) => `Limitation: ${item}`),
|
|
380
|
+
'',
|
|
381
|
+
].join('\n');
|
|
382
|
+
}
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { promises as dns } from 'node:dns';
|
|
|
5
5
|
import { fetchRdapRecord } from "./rdap.mjs";
|
|
6
6
|
import { nonEmptyErrorMessage } from "./error-detail.mjs";
|
|
7
7
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
8
|
+
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
8
9
|
import { classifyMxRecords } from "./dns-mx.mjs";
|
|
9
10
|
import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
|
|
10
11
|
import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
|
|
@@ -93,7 +94,7 @@ function spfCheck(query, expansion) {
|
|
|
93
94
|
const parsed = parseSpfRecords(query.records);
|
|
94
95
|
if (parsed.records.length === 0) {
|
|
95
96
|
return check('spf', 'SPF', 'warning', 'No SPF policy published', {
|
|
96
|
-
remediation: 'Publish one SPF TXT record that
|
|
97
|
+
remediation: 'Publish one SPF TXT record that authorises every legitimate sender and ends in -all once verified.',
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
100
|
if (!parsed.valid) {
|
|
@@ -118,7 +119,7 @@ function spfCheck(query, expansion) {
|
|
|
118
119
|
...parsed.issues,
|
|
119
120
|
];
|
|
120
121
|
if (parsed.terminalPolicy === 'pass') {
|
|
121
|
-
return check('spf', 'SPF', 'danger', 'Policy
|
|
122
|
+
return check('spf', 'SPF', 'danger', 'Policy authorises every sender (+all)', {
|
|
122
123
|
detail: details.join(' '), records: parsed.records,
|
|
123
124
|
remediation: 'Replace +all with an explicit sender allowlist and a restrictive terminal policy.',
|
|
124
125
|
});
|
|
@@ -142,7 +143,7 @@ function spfCheck(query, expansion) {
|
|
|
142
143
|
return check('spf', 'SPF', 'warning', `Policy ends in ${parsed.terminalPolicy || 'an unknown result'}`, {
|
|
143
144
|
detail: details.join(' '), records: parsed.records,
|
|
144
145
|
remediation: parsed.terminalPolicy === 'softfail'
|
|
145
|
-
? 'Move from ~all to -all after confirming every legitimate sending service is
|
|
146
|
+
? 'Move from ~all to -all after confirming every legitimate sending service is authorised.'
|
|
146
147
|
: 'Add a restrictive -all terminal policy after confirming every legitimate sending service.',
|
|
147
148
|
});
|
|
148
149
|
}
|
|
@@ -197,7 +198,7 @@ function dmarcCheck(query, authorizations = []) {
|
|
|
197
198
|
return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; external reporting authorization is incomplete`, {
|
|
198
199
|
detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
|
|
199
200
|
records: parsed.records,
|
|
200
|
-
remediation: 'Publish the required external reporting
|
|
201
|
+
remediation: 'Publish the required external reporting authorisation record or remove the unavailable destination.',
|
|
201
202
|
});
|
|
202
203
|
}
|
|
203
204
|
if (!parsed.aggregateReporting) {
|
|
@@ -251,7 +252,7 @@ function dnssecCheck(input) {
|
|
|
251
252
|
function dnssecDelegationConsistencyCheck(input, registry) {
|
|
252
253
|
if (registry.error) {
|
|
253
254
|
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Registry evidence is unavailable', {
|
|
254
|
-
detail: 'DNSSEC delegation and retained DS records could not be compared because
|
|
255
|
+
detail: 'DNSSEC delegation and retained DS records could not be compared because normalised registry evidence was unavailable.',
|
|
255
256
|
});
|
|
256
257
|
}
|
|
257
258
|
if (input.error) {
|
|
@@ -273,7 +274,7 @@ function dnssecDelegationConsistencyCheck(input, registry) {
|
|
|
273
274
|
}
|
|
274
275
|
if (registry.dsDataTruncated) {
|
|
275
276
|
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
|
|
276
|
-
detail: 'The
|
|
277
|
+
detail: 'The normalised registry response reported truncated DS data, so apparent disagreement is inconclusive.',
|
|
277
278
|
});
|
|
278
279
|
}
|
|
279
280
|
if (value === 'signed' && !hasDsRecords) {
|
|
@@ -413,7 +414,7 @@ function bimiCheck(query, dmarcQuery) {
|
|
|
413
414
|
&& (dmarc.legacyPct === null || dmarc.legacyPct === 100);
|
|
414
415
|
if (!enforcementReady) {
|
|
415
416
|
return check('bimi', 'BIMI', 'warning', 'Record exists but DMARC is not BIMI-ready', {
|
|
416
|
-
detail: 'BIMI display generally requires enforced DMARC for the
|
|
417
|
+
detail: 'BIMI display generally requires enforced DMARC for the organisational domain and subdomains. Mailbox providers apply additional requirements.',
|
|
417
418
|
records: parsed.records,
|
|
418
419
|
remediation: 'Enforce DMARC at quarantine or reject for the domain and subdomains before relying on BIMI.',
|
|
419
420
|
});
|
|
@@ -533,7 +534,7 @@ function registrationLockCheck(registry) {
|
|
|
533
534
|
const statuses = registry.statuses.map((status) => status.toLowerCase().replace(/[^a-z]/gu, ''));
|
|
534
535
|
if (statuses.length === 0) {
|
|
535
536
|
return check('registration_lock', 'Registration controls', 'info', 'Registry lock state is unavailable', {
|
|
536
|
-
detail: 'No
|
|
537
|
+
detail: 'No normalised EPP status was returned. This does not describe registrar account security.',
|
|
537
538
|
});
|
|
538
539
|
}
|
|
539
540
|
const transferLocks = statuses.filter((status) => ['clienttransferprohibited', 'servertransferprohibited'].includes(status));
|
|
@@ -608,7 +609,7 @@ async function fetchMtaStsPolicy(domain) {
|
|
|
608
609
|
const url = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
|
|
609
610
|
const res = await safeFetch(url, {
|
|
610
611
|
signal: controller.signal,
|
|
611
|
-
headers: {
|
|
612
|
+
headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
|
|
612
613
|
});
|
|
613
614
|
if (!res.ok) {
|
|
614
615
|
// Not reading this body - release it explicitly instead of leaving an
|
|
@@ -705,7 +706,7 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
705
706
|
nameservers: [],
|
|
706
707
|
dsRecordCount: 0,
|
|
707
708
|
dsDataTruncated: false,
|
|
708
|
-
error: rdap && 'error' in rdap ? rdap.error : 'RDAP did not return
|
|
709
|
+
error: rdap && 'error' in rdap ? rdap.error : 'RDAP did not return normalised domain evidence.',
|
|
709
710
|
};
|
|
710
711
|
const externalDependencies = buildExternalDependencies({
|
|
711
712
|
domain,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const ENCRYPTED_DNS_CONTRACT_SCHEMA = 'whoisleuth.encrypted-dns-adapter';
|
|
2
|
+
const ENCRYPTED_DNS_CONTRACT_VERSION = 1;
|
|
3
|
+
const DNS_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'DNSKEY', 'DS', 'HTTPS', 'MX', 'NS', 'RRSIG', 'SOA', 'SVCB', 'TLSA', 'TXT']);
|
|
4
|
+
function record(value) {
|
|
5
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
6
|
+
? value
|
|
7
|
+
: null;
|
|
8
|
+
}
|
|
9
|
+
function httpsUrl(value) {
|
|
10
|
+
if (typeof value !== 'string' || value.length > 2048)
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value);
|
|
14
|
+
return url.protocol === 'https:' && !url.username && !url.password ? url.toString() : null;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function normalizeEncryptedDnsAdapter(value) {
|
|
21
|
+
const source = record(value);
|
|
22
|
+
if (!source)
|
|
23
|
+
throw new TypeError('Encrypted DNS adapter must be an object.');
|
|
24
|
+
const id = typeof source.id === 'string' && /^[a-z][a-z0-9-]{0,47}$/u.test(source.id) ? source.id : null;
|
|
25
|
+
const label = typeof source.label === 'string' && source.label.length <= 80 && !/[\u0000-\u001f\u007f]/u.test(source.label)
|
|
26
|
+
? source.label.trim()
|
|
27
|
+
: null;
|
|
28
|
+
const endpoint = httpsUrl(source.endpoint);
|
|
29
|
+
const termsUrl = httpsUrl(source.termsUrl);
|
|
30
|
+
const privacyUrl = httpsUrl(source.privacyUrl);
|
|
31
|
+
const reviewedAt = typeof source.reviewedAt === 'string' && Number.isFinite(Date.parse(source.reviewedAt))
|
|
32
|
+
? new Date(source.reviewedAt).toISOString()
|
|
33
|
+
: null;
|
|
34
|
+
const method = source.method === 'GET' || source.method === 'POST' ? source.method : null;
|
|
35
|
+
const representation = source.representation === 'dns-json' || source.representation === 'dns-wire'
|
|
36
|
+
? source.representation
|
|
37
|
+
: null;
|
|
38
|
+
const retention = source.queryRetention;
|
|
39
|
+
const maxResponseBytes = Number(source.maxResponseBytes);
|
|
40
|
+
const timeoutMs = Number(source.timeoutMs);
|
|
41
|
+
if (!id || !label || !endpoint || !termsUrl || !privacyUrl || !reviewedAt || !method || !representation
|
|
42
|
+
|| !['none', 'limited', 'provider_defined', 'unknown'].includes(String(retention))
|
|
43
|
+
|| !Number.isInteger(maxResponseBytes) || maxResponseBytes < 1024 || maxResponseBytes > 2 * 1024 * 1024
|
|
44
|
+
|| !Number.isInteger(timeoutMs) || timeoutMs < 250 || timeoutMs > 15_000) {
|
|
45
|
+
throw new TypeError('Encrypted DNS adapter did not satisfy the bounded privacy and transport contract.');
|
|
46
|
+
}
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
id,
|
|
49
|
+
label,
|
|
50
|
+
endpoint,
|
|
51
|
+
method,
|
|
52
|
+
representation,
|
|
53
|
+
termsUrl,
|
|
54
|
+
privacyUrl,
|
|
55
|
+
reviewedAt,
|
|
56
|
+
queryRetention: retention,
|
|
57
|
+
maxResponseBytes,
|
|
58
|
+
timeoutMs,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function planEncryptedDnsQuery(adapter, input) {
|
|
62
|
+
const name = typeof input.name === 'string' ? input.name.trim().toLowerCase().replace(/\.$/u, '') : '';
|
|
63
|
+
const type = typeof input.type === 'string' ? input.type.trim().toUpperCase() : '';
|
|
64
|
+
if (!name || name.length > 253 || !/^[a-z0-9._-]+$/u.test(name) || !DNS_TYPES.has(type)) {
|
|
65
|
+
throw new TypeError('Encrypted DNS query name or record type was invalid or unsupported.');
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
schema: ENCRYPTED_DNS_CONTRACT_SCHEMA,
|
|
69
|
+
version: ENCRYPTED_DNS_CONTRACT_VERSION,
|
|
70
|
+
state: 'approval_required',
|
|
71
|
+
adapter: Object.freeze({ id: adapter.id, label: adapter.label, endpoint: adapter.endpoint }),
|
|
72
|
+
query: Object.freeze({ name, type }),
|
|
73
|
+
disclosure: Object.freeze({
|
|
74
|
+
target: name,
|
|
75
|
+
recipient: new URL(adapter.endpoint).hostname,
|
|
76
|
+
queryRetention: adapter.queryRetention,
|
|
77
|
+
}),
|
|
78
|
+
limitation: 'This plan does not execute a request. Provider availability, answer authority, validation, filtering, retention, and regional behaviour remain untested.',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
export { ENCRYPTED_DNS_CONTRACT_SCHEMA, ENCRYPTED_DNS_CONTRACT_VERSION, normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, };
|
package/lib/evidence-export.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { compareRdapPublications, compareRegistrySources } from "./registry-comparison.mjs";
|
|
2
2
|
import { buildRegistryInsights } from "./registry-insights.mjs";
|
|
3
|
+
import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "./project-metadata.mjs";
|
|
4
|
+
import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
|
|
3
5
|
export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
|
|
4
|
-
export const LOOKUP_EVIDENCE_SCHEMA_VERSION =
|
|
6
|
+
export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 24;
|
|
5
7
|
const REGISTRAR_RDAP_STATUSES = new Set([
|
|
6
8
|
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
7
9
|
]);
|
|
@@ -37,6 +39,14 @@ function boundedTimestamp(value) {
|
|
|
37
39
|
const time = Date.parse(text);
|
|
38
40
|
return Number.isFinite(time) ? new Date(time).toISOString() : null;
|
|
39
41
|
}
|
|
42
|
+
function boundedSemanticVersion(value) {
|
|
43
|
+
try {
|
|
44
|
+
return normalizeBoundedSemanticVersion(value, 'Evidence application');
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
40
50
|
function boundedEndpoint(value) {
|
|
41
51
|
const text = boundedString(value, 2048);
|
|
42
52
|
if (!text)
|
|
@@ -359,7 +369,7 @@ function registrarPublicationComparison(body, registryParsed) {
|
|
|
359
369
|
});
|
|
360
370
|
}
|
|
361
371
|
export function buildLookupEvidence(response, options = {}) {
|
|
362
|
-
const { generatedAt = new Date().toISOString(), idnAnalysis = null } = options;
|
|
372
|
+
const { generatedAt = new Date().toISOString(), idnAnalysis = null, applicationVersion = null } = options;
|
|
363
373
|
const body = recordOrNull(response) || {};
|
|
364
374
|
const rdap = recordOrNull(body.rdap);
|
|
365
375
|
const whois = recordOrNull(body.whois);
|
|
@@ -389,7 +399,11 @@ export function buildLookupEvidence(response, options = {}) {
|
|
|
389
399
|
schema: LOOKUP_EVIDENCE_SCHEMA,
|
|
390
400
|
schemaVersion: LOOKUP_EVIDENCE_SCHEMA_VERSION,
|
|
391
401
|
generatedAt,
|
|
392
|
-
application: {
|
|
402
|
+
application: {
|
|
403
|
+
name: 'WHOISleuth',
|
|
404
|
+
version: boundedSemanticVersion(applicationVersion),
|
|
405
|
+
projectUrl: WHOISLEUTH_SOURCE_REPOSITORY_URL,
|
|
406
|
+
},
|
|
393
407
|
query: {
|
|
394
408
|
submitted: body.query || null,
|
|
395
409
|
type: body.type || null,
|
|
@@ -38,7 +38,7 @@ function formatLookupEvidenceMarkdown(document, options = {}) {
|
|
|
38
38
|
lines.push('', '## Registry-source comparison');
|
|
39
39
|
appendFields(lines, report.comparison.health);
|
|
40
40
|
if (!report.comparison.fields.length) {
|
|
41
|
-
lines.push('- No comparable
|
|
41
|
+
lines.push('- No comparable normalised fields were published.');
|
|
42
42
|
}
|
|
43
43
|
else {
|
|
44
44
|
for (const field of report.comparison.fields) {
|
package/lib/evidence-report.mjs
CHANGED
|
@@ -89,6 +89,7 @@ function reportField(label, value, fallback = 'Not reported') {
|
|
|
89
89
|
}
|
|
90
90
|
function buildLookupEvidenceReport(document, options = {}) {
|
|
91
91
|
const report = objectOrEmpty(document);
|
|
92
|
+
const application = objectOrEmpty(report.application);
|
|
92
93
|
const query = objectOrEmpty(report.query);
|
|
93
94
|
const diagnostics = objectOrEmpty(report.diagnostics);
|
|
94
95
|
const rdapDiagnostics = objectOrEmpty(diagnostics.rdap);
|
|
@@ -300,6 +301,8 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
300
301
|
notice: 'Human-readable summary. The versioned JSON export remains the full-fidelity evidence package; raw RDAP JSON and WHOIS response bodies are deliberately omitted here.',
|
|
301
302
|
metadata: [
|
|
302
303
|
reportField('Generated', report.generatedAt),
|
|
304
|
+
reportField('Generator', application.version ? `${application.name || 'WHOISleuth'} ${application.version}` : application.name || 'WHOISleuth'),
|
|
305
|
+
reportField('Project', application.projectUrl),
|
|
303
306
|
reportField('Report contract', `${cleanReportText(report.schema)} v${cleanReportText(report.schemaVersion)}`),
|
|
304
307
|
],
|
|
305
308
|
query: [
|
|
@@ -375,7 +378,7 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
375
378
|
...registryAccessFields,
|
|
376
379
|
],
|
|
377
380
|
limitations: [
|
|
378
|
-
'This report
|
|
381
|
+
'This report summarises point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
|
|
379
382
|
'Missing, skipped, partial, unsupported, or failed sources are inconclusive rather than negative evidence.',
|
|
380
383
|
'The Risk score is a versioned heuristic review priority, not a probability or determination of maliciousness. Review its listed factors and the underlying source states.',
|
|
381
384
|
'Raw registry payloads and full WHOIS referral responses are available only in the JSON evidence package and may contain public contact data.',
|