@slicedearth/whoisleuth-cli 1.36.1 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -2
- package/bin/whoisleuth.mjs +52 -24
- package/cli/archive-inspect.mjs +37 -2
- package/cli/arguments.mjs +493 -157
- package/cli/artifact-verify.mjs +91 -15
- package/cli/bulk-checkpoint.mjs +7 -11
- package/cli/bulk-command-runner.mjs +19 -2
- package/cli/case-pack.mjs +136 -0
- package/cli/ci-report.mjs +73 -0
- package/cli/collection-preflight.mjs +59 -0
- package/cli/command-reference.mjs +395 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +49 -11
- package/cli/config-profile.mjs +180 -0
- package/cli/ct-event-intake.mjs +159 -0
- package/cli/discovery-scan-command-runner.mjs +15 -1
- package/cli/doctor.mjs +11 -5
- package/cli/domain-control-monitor.mjs +104 -0
- package/cli/domain-control-observations.mjs +235 -0
- package/cli/evidence-command-arguments.mjs +12 -1
- package/cli/evidence-command-runner.mjs +3 -2
- package/cli/evidence-signing.mjs +4 -4
- package/cli/external-observation-mapping.mjs +162 -0
- package/cli/fail-policy.mjs +64 -0
- package/cli/formatters/html.mjs +2 -2
- package/cli/formatters/json.mjs +13 -1
- package/cli/formatters/terminal.mjs +7 -3
- package/cli/investigation-manifest.mjs +113 -0
- package/cli/investigation-plan.mjs +2 -2
- package/cli/investigation-run.mjs +112 -0
- package/cli/local-mmdb-review.mjs +76 -0
- package/cli/lookalike-calibration.mjs +121 -0
- package/cli/lookup-brief.mjs +116 -0
- package/cli/lookup-command-runner.mjs +12 -2
- package/cli/lookup-diff.mjs +17 -19
- 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 +1 -1
- package/cli/network-command-runner.mjs +4 -1
- package/cli/offline-evidence-review.mjs +125 -10
- package/cli/open-asset-model-bridge.mjs +169 -0
- package/cli/registry-cohort.mjs +121 -0
- package/cli/registry-doctor.mjs +220 -0
- package/cli/registry-fixture-scaffold.mjs +73 -0
- package/cli/risk-calibration.mjs +117 -9
- package/cli/runner.mjs +561 -280
- 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 +1336 -0
- package/docs/cli.md +259 -940
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +1 -1
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +7 -7
- package/frontend/src/lib/analysis/brand-profile-model.js +46 -4
- 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-session-model.js +7 -2
- 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-report.js +573 -0
- package/frontend/src/lib/analysis/case-response-model.js +30 -1
- package/frontend/src/lib/analysis/case-response-packet.js +7 -7
- package/frontend/src/lib/analysis/detection-rule-model.js +36 -0
- package/frontend/src/lib/analysis/evidence-coverage-ledger.js +328 -0
- package/frontend/src/lib/analysis/external-findings-import.js +320 -0
- 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 +487 -0
- package/frontend/src/lib/analysis/lookup-investigation-brief.js +153 -0
- package/frontend/src/lib/analysis/lookup-presentation.js +64 -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 +9 -89
- package/frontend/src/lib/analysis/shortlist-model.js +8 -4
- 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 +369 -0
- package/frontend/src/lib/analysis/visualization-time-series.js +144 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +1 -1
- package/frontend/src/lib/analysis/workspace-archive.js +4 -2
- package/lib/abusech-auth.mjs +1 -1
- package/lib/analyst-taxonomy.mjs +40 -0
- package/lib/availability.mjs +2 -2
- package/lib/bounded-contract-normalizers.mjs +33 -0
- package/lib/browser-library-profile.mjs +15 -2
- package/lib/certificate-extension-profile.mjs +71 -0
- package/lib/credential-surface-profile.mjs +1 -1
- package/lib/dns-convergence-review.mjs +183 -0
- package/lib/dns-delegation-health.mjs +196 -3
- package/lib/domain-assurance.mjs +368 -0
- package/lib/domain-change-packet.mjs +133 -0
- package/lib/domain-change-review.mjs +307 -0
- package/lib/domain-control-flight-recorder.mjs +268 -0
- package/lib/domain-control-manifest.mjs +50 -7
- package/lib/domain-portfolio-review.mjs +151 -0
- package/lib/domain-posture.mjs +9 -9
- package/lib/encrypted-dns-contract.mjs +1 -1
- package/lib/evidence-export.mjs +1 -1
- package/lib/evidence-report-markdown.mjs +1 -1
- package/lib/evidence-report.mjs +1 -1
- package/lib/generated/cisa-kev-catalog.mjs +1668 -0
- package/lib/html-signals.mjs +5 -11
- package/lib/ip-prefix.mjs +34 -1
- package/lib/local-geoip-evidence.mjs +2 -2
- package/lib/lookup-response-contract.mjs +346 -0
- package/lib/nameserver-preflight-review.mjs +159 -0
- package/lib/observed-network-context.mjs +1 -1
- package/lib/opportunity-scoring.mjs +107 -0
- package/lib/page-fingerprints.mjs +1 -1
- package/lib/page-language-signals.mjs +124 -0
- package/lib/rdap-capabilities.mjs +6 -6
- package/lib/rdap-search-workbench.mjs +96 -6
- package/lib/registry-access.mjs +1 -1
- package/lib/registry-capability-catalogue.mjs +4 -4
- package/lib/registry-insights.mjs +6 -6
- package/lib/risk-scoring.mjs +247 -83
- package/lib/scoring-evidence-quality.mjs +107 -0
- package/lib/security-txt.mjs +3 -3
- package/lib/static-html-analysis.mjs +29 -1
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threatfox-intelligence.mjs +1 -1
- package/lib/tls-intelligence.mjs +13 -3
- package/lib/trust-store-comparison.mjs +146 -0
- package/lib/urlhaus-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +5 -5
- package/lib/website-technology.mjs +25 -11
- package/lib/whois-chain.mjs +1 -1
- package/lib/zone-intent-review.mjs +534 -0
- package/package.json +5 -1
- package/third-party-notices.txt +911 -19
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { domainToASCII } from 'node:url';
|
|
3
3
|
import { canonicalArtifactJson } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { exactKeys } from "./bounded-contract-normalizers.mjs";
|
|
4
5
|
export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = 'whoisleuth.domain-control-manifest-input';
|
|
5
6
|
export const DOMAIN_CONTROL_MANIFEST_SCHEMA = 'whoisleuth.domain-control-manifest';
|
|
6
7
|
export const DOMAIN_CONTROL_MANIFEST_VERSION = 1;
|
|
@@ -9,6 +10,18 @@ export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
|
|
|
9
10
|
export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
|
|
10
11
|
export const MAX_DOMAIN_CONTROL_ENTRIES = 100;
|
|
11
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
|
+
]);
|
|
12
25
|
function record(value) {
|
|
13
26
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
14
27
|
? value
|
|
@@ -89,6 +102,7 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
|
|
|
89
102
|
if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
|
|
90
103
|
throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
|
|
91
104
|
}
|
|
105
|
+
exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
|
|
92
106
|
const generatedAt = timestamp(generatedAtValue);
|
|
93
107
|
const expiresAt = timestamp(source.expiresAt);
|
|
94
108
|
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
|
|
@@ -97,7 +111,10 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
|
|
|
97
111
|
if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
|
|
98
112
|
throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
|
|
99
113
|
}
|
|
100
|
-
const entries = source.entries.map(
|
|
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
|
+
});
|
|
101
118
|
if (entries.some((entry) => entry === null))
|
|
102
119
|
throw new TypeError('Domain control manifest contains an invalid entry.');
|
|
103
120
|
const normalizedEntries = entries;
|
|
@@ -110,10 +127,7 @@ export function buildDomainControlManifest(input, generatedAtValue = new Date().
|
|
|
110
127
|
generatedAt,
|
|
111
128
|
expiresAt,
|
|
112
129
|
entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
|
|
113
|
-
limitations:
|
|
114
|
-
'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
|
|
115
|
-
'Empty desired fields are unconfigured rather than claims that a record should be absent.',
|
|
116
|
-
]),
|
|
130
|
+
limitations: DOMAIN_CONTROL_LIMITATIONS,
|
|
117
131
|
});
|
|
118
132
|
return Object.freeze({
|
|
119
133
|
...base,
|
|
@@ -134,6 +148,8 @@ export function verifyDomainControlManifest(value) {
|
|
|
134
148
|
|| source.entries.length < 1
|
|
135
149
|
|| source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
|
|
136
150
|
|| !Array.isArray(source.limitations)
|
|
151
|
+
|| source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
|
|
152
|
+
|| source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
|
|
137
153
|
|| !integrity
|
|
138
154
|
|| integrity.algorithm !== 'SHA-256'
|
|
139
155
|
|| integrity.canonicalization !== 'sorted-json-v1'
|
|
@@ -141,18 +157,41 @@ export function verifyDomainControlManifest(value) {
|
|
|
141
157
|
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
142
158
|
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
143
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
|
+
}
|
|
144
165
|
const generatedAt = timestamp(source.generatedAt);
|
|
145
166
|
const expiresAt = timestamp(source.expiresAt);
|
|
146
167
|
const entries = source.entries.map(normalizeEntry);
|
|
147
168
|
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
|
|
148
|
-
throw new TypeError('Domain control manifest has invalid
|
|
169
|
+
throw new TypeError('Domain control manifest has invalid normalised content.');
|
|
149
170
|
}
|
|
150
171
|
const normalizedEntries = entries;
|
|
151
172
|
if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
|
|
152
173
|
throw new TypeError('Domain control manifest entries must use unique domains.');
|
|
153
174
|
}
|
|
154
175
|
const { integrity: _integrity, ...unsigned } = source;
|
|
155
|
-
|
|
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) {
|
|
156
195
|
throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
|
|
157
196
|
}
|
|
158
197
|
return source;
|
|
@@ -176,11 +215,14 @@ function normalizeObservation(value) {
|
|
|
176
215
|
const rawFields = record(source?.fields);
|
|
177
216
|
if (!source || !normalizedDomain || !rawFields)
|
|
178
217
|
return null;
|
|
218
|
+
exactKeys(source, OBSERVATION_KEYS, 'Domain control observation');
|
|
219
|
+
exactKeys(rawFields, OBSERVATION_FIELDS, 'Domain control observation fields');
|
|
179
220
|
const fields = {};
|
|
180
221
|
for (const field of ['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']) {
|
|
181
222
|
const raw = record(rawFields[field]);
|
|
182
223
|
if (!raw)
|
|
183
224
|
continue;
|
|
225
|
+
exactKeys(raw, OBSERVATION_FIELD_KEYS, `Domain control observation field ${field}`);
|
|
184
226
|
const state = raw.state;
|
|
185
227
|
if (state !== 'observed' && state !== 'partial' && state !== 'unavailable' && state !== 'unsupported')
|
|
186
228
|
return null;
|
|
@@ -260,6 +302,7 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
260
302
|
if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version !== 1) {
|
|
261
303
|
throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version 1.`);
|
|
262
304
|
}
|
|
305
|
+
exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
|
|
263
306
|
const generatedAt = timestamp(generatedAtValue);
|
|
264
307
|
if (!generatedAt)
|
|
265
308
|
throw new TypeError('Domain control review time is invalid.');
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
|
+
export const DOMAIN_PORTFOLIO_INPUT_SCHEMA = 'whoisleuth.domain-portfolio.input';
|
|
3
|
+
export const DOMAIN_PORTFOLIO_REVIEW_SCHEMA = 'whoisleuth.domain-portfolio.review';
|
|
4
|
+
export const DOMAIN_PORTFOLIO_REVIEW_VERSION = 1;
|
|
5
|
+
export const MAX_PORTFOLIO_ASSETS = 500;
|
|
6
|
+
const ROOT_KEYS = new Set(['schema', 'version', 'portfolioLabel', 'assets']);
|
|
7
|
+
const ASSET_KEYS = new Set([
|
|
8
|
+
'domain', 'criticality', 'registrar', 'registrarAccount', 'expiresAt', 'autoRenew', 'dnsProviders', 'mailProviders',
|
|
9
|
+
'certificateProviders', 'recoveryDomains', 'reviewedAt',
|
|
10
|
+
]);
|
|
11
|
+
function text(value, label, maximum = 160) {
|
|
12
|
+
return requireBoundedString(value, label, maximum);
|
|
13
|
+
}
|
|
14
|
+
function optionalText(value, label, maximum = 160) {
|
|
15
|
+
if (value === null || value === undefined || value === '')
|
|
16
|
+
return null;
|
|
17
|
+
return text(value, label, maximum);
|
|
18
|
+
}
|
|
19
|
+
function timestamp(value, label, optional = false) {
|
|
20
|
+
if (optional && (value === null || value === undefined || value === ''))
|
|
21
|
+
return null;
|
|
22
|
+
return requireIsoTimestamp(value, label);
|
|
23
|
+
}
|
|
24
|
+
function domain(value, label) {
|
|
25
|
+
return requireDomainName(value, label);
|
|
26
|
+
}
|
|
27
|
+
function textList(value, label) {
|
|
28
|
+
if (!Array.isArray(value) || value.length > 16)
|
|
29
|
+
throw new TypeError(`${label} must contain no more than 16 labels.`);
|
|
30
|
+
return [...new Set(value.map((item, index) => text(item, `${label}[${index}]`, 120)))].sort();
|
|
31
|
+
}
|
|
32
|
+
function domainList(value, label) {
|
|
33
|
+
if (!Array.isArray(value) || value.length > 16)
|
|
34
|
+
throw new TypeError(`${label} must contain no more than 16 domains.`);
|
|
35
|
+
return [...new Set(value.map((item, index) => domain(item, `${label}[${index}]`)))].sort();
|
|
36
|
+
}
|
|
37
|
+
function nullableBoolean(value, label) {
|
|
38
|
+
if (value === null || value === undefined)
|
|
39
|
+
return null;
|
|
40
|
+
if (typeof value !== 'boolean')
|
|
41
|
+
throw new TypeError(`${label} must be true, false, or null.`);
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
export function reviewDomainPortfolio(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
45
|
+
const input = requireRecord(inputRaw, 'Domain portfolio input');
|
|
46
|
+
if (input.schema !== DOMAIN_PORTFOLIO_INPUT_SCHEMA || input.version !== 1)
|
|
47
|
+
throw new TypeError(`Domain portfolio input must use ${DOMAIN_PORTFOLIO_INPUT_SCHEMA} version 1.`);
|
|
48
|
+
exactKeys(input, ROOT_KEYS, 'Domain portfolio input');
|
|
49
|
+
if (!Array.isArray(input.assets) || input.assets.length < 1 || input.assets.length > MAX_PORTFOLIO_ASSETS) {
|
|
50
|
+
throw new TypeError(`Domain portfolio input must contain from 1 to ${MAX_PORTFOLIO_ASSETS} assets.`);
|
|
51
|
+
}
|
|
52
|
+
const portfolioLabel = text(input.portfolioLabel, 'portfolioLabel', 120);
|
|
53
|
+
const assets = input.assets.map((raw, index) => {
|
|
54
|
+
const item = requireRecord(raw, `assets[${index}]`);
|
|
55
|
+
exactKeys(item, ASSET_KEYS, `assets[${index}]`);
|
|
56
|
+
const criticality = item.criticality;
|
|
57
|
+
if (criticality !== 'low' && criticality !== 'standard' && criticality !== 'high' && criticality !== 'critical') {
|
|
58
|
+
throw new TypeError(`assets[${index}].criticality is unsupported.`);
|
|
59
|
+
}
|
|
60
|
+
return Object.freeze({
|
|
61
|
+
domain: domain(item.domain, `assets[${index}].domain`),
|
|
62
|
+
criticality,
|
|
63
|
+
registrar: optionalText(item.registrar, `assets[${index}].registrar`, 120),
|
|
64
|
+
registrarAccount: optionalText(item.registrarAccount, `assets[${index}].registrarAccount`, 120),
|
|
65
|
+
expiresAt: timestamp(item.expiresAt, `assets[${index}].expiresAt`, true),
|
|
66
|
+
autoRenew: nullableBoolean(item.autoRenew, `assets[${index}].autoRenew`),
|
|
67
|
+
dnsProviders: Object.freeze(textList(item.dnsProviders ?? [], `assets[${index}].dnsProviders`)),
|
|
68
|
+
mailProviders: Object.freeze(textList(item.mailProviders ?? [], `assets[${index}].mailProviders`)),
|
|
69
|
+
certificateProviders: Object.freeze(textList(item.certificateProviders ?? [], `assets[${index}].certificateProviders`)),
|
|
70
|
+
recoveryDomains: Object.freeze(domainList(item.recoveryDomains ?? [], `assets[${index}].recoveryDomains`)),
|
|
71
|
+
reviewedAt: timestamp(item.reviewedAt, `assets[${index}].reviewedAt`),
|
|
72
|
+
});
|
|
73
|
+
}).sort((left, right) => left.domain.localeCompare(right.domain));
|
|
74
|
+
if (new Set(assets.map((asset) => asset.domain)).size !== assets.length)
|
|
75
|
+
throw new TypeError('Domain portfolio assets must use unique domains.');
|
|
76
|
+
const dependencyRows = [];
|
|
77
|
+
for (const asset of assets) {
|
|
78
|
+
const critical = asset.criticality === 'critical' || asset.criticality === 'high';
|
|
79
|
+
if (asset.registrar)
|
|
80
|
+
dependencyRows.push({ type: 'registrar', provider: asset.registrar, domain: asset.domain, critical });
|
|
81
|
+
for (const provider of asset.dnsProviders)
|
|
82
|
+
dependencyRows.push({ type: 'dns', provider, domain: asset.domain, critical });
|
|
83
|
+
for (const provider of asset.mailProviders)
|
|
84
|
+
dependencyRows.push({ type: 'mail', provider, domain: asset.domain, critical });
|
|
85
|
+
for (const provider of asset.certificateProviders)
|
|
86
|
+
dependencyRows.push({ type: 'certificate', provider, domain: asset.domain, critical });
|
|
87
|
+
for (const provider of asset.recoveryDomains)
|
|
88
|
+
dependencyRows.push({ type: 'recovery', provider, domain: asset.domain, critical });
|
|
89
|
+
}
|
|
90
|
+
const grouped = new Map();
|
|
91
|
+
for (const row of dependencyRows) {
|
|
92
|
+
const key = `${row.type}\u0000${row.provider.toLowerCase()}`;
|
|
93
|
+
grouped.set(key, [...(grouped.get(key) ?? []), row]);
|
|
94
|
+
}
|
|
95
|
+
const simulations = [...grouped.values()].map((rows) => Object.freeze({
|
|
96
|
+
dependencyType: rows[0].type,
|
|
97
|
+
provider: rows[0].provider,
|
|
98
|
+
affectedDomains: Object.freeze([...new Set(rows.map((row) => row.domain))].sort()),
|
|
99
|
+
affectedCriticalDomains: Object.freeze([...new Set(rows.filter((row) => row.critical).map((row) => row.domain))].sort()),
|
|
100
|
+
share: Math.round((new Set(rows.map((row) => row.domain)).size / assets.length) * 1000) / 10,
|
|
101
|
+
})).sort((left, right) => (right.affectedDomains.length - left.affectedDomains.length
|
|
102
|
+
|| left.dependencyType.localeCompare(right.dependencyType)
|
|
103
|
+
|| left.provider.localeCompare(right.provider)));
|
|
104
|
+
const now = Date.parse(timestamp(generatedAtValue, 'generatedAt'));
|
|
105
|
+
const renewalQueue = assets.flatMap((asset) => {
|
|
106
|
+
const expiry = asset.expiresAt ? Date.parse(asset.expiresAt) : Number.NaN;
|
|
107
|
+
const days = Number.isFinite(expiry) ? Math.ceil((expiry - now) / 86_400_000) : null;
|
|
108
|
+
const reasons = [
|
|
109
|
+
...(days === null ? ['Expiry is unavailable.'] : days <= 90 ? [`Expiry is ${days < 0 ? `${Math.abs(days)} days overdue` : `due in ${days} days`}.`] : []),
|
|
110
|
+
...(asset.autoRenew === false ? ['Auto-renew is recorded as disabled.'] : asset.autoRenew === null ? ['Auto-renew state is unavailable.'] : []),
|
|
111
|
+
...(!asset.registrarAccount ? ['Registrar account label is unavailable.'] : []),
|
|
112
|
+
];
|
|
113
|
+
return reasons.length ? [Object.freeze({ domain: asset.domain, criticality: asset.criticality, expiresAt: asset.expiresAt, daysUntilExpiry: days, reasons: Object.freeze(reasons) })] : [];
|
|
114
|
+
}).sort((left, right) => ((left.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER) - (right.daysUntilExpiry ?? Number.MAX_SAFE_INTEGER)
|
|
115
|
+
|| left.domain.localeCompare(right.domain)));
|
|
116
|
+
const owned = new Set(assets.map((asset) => asset.domain));
|
|
117
|
+
const recoveryCycles = assets.flatMap((asset) => asset.recoveryDomains
|
|
118
|
+
.filter((recoveryDomain) => owned.has(recoveryDomain))
|
|
119
|
+
.map((recoveryDomain) => Object.freeze({
|
|
120
|
+
protectedDomain: asset.domain,
|
|
121
|
+
recoveryDomain,
|
|
122
|
+
reciprocal: assets.find((candidate) => candidate.domain === recoveryDomain)?.recoveryDomains.includes(asset.domain) ?? false,
|
|
123
|
+
})));
|
|
124
|
+
const unknownCounts = Object.freeze({
|
|
125
|
+
registrar: assets.filter((asset) => !asset.registrar).length,
|
|
126
|
+
registrarAccount: assets.filter((asset) => !asset.registrarAccount).length,
|
|
127
|
+
expiry: assets.filter((asset) => !asset.expiresAt).length,
|
|
128
|
+
autoRenew: assets.filter((asset) => asset.autoRenew === null).length,
|
|
129
|
+
dns: assets.filter((asset) => !asset.dnsProviders.length).length,
|
|
130
|
+
mail: assets.filter((asset) => !asset.mailProviders.length).length,
|
|
131
|
+
certificate: assets.filter((asset) => !asset.certificateProviders.length).length,
|
|
132
|
+
recovery: assets.filter((asset) => !asset.recoveryDomains.length).length,
|
|
133
|
+
});
|
|
134
|
+
return Object.freeze({
|
|
135
|
+
schema: DOMAIN_PORTFOLIO_REVIEW_SCHEMA,
|
|
136
|
+
version: DOMAIN_PORTFOLIO_REVIEW_VERSION,
|
|
137
|
+
generatedAt: new Date(now).toISOString(),
|
|
138
|
+
portfolioLabel,
|
|
139
|
+
assets: Object.freeze(assets),
|
|
140
|
+
simulations: Object.freeze(simulations),
|
|
141
|
+
renewalQueue: Object.freeze(renewalQueue),
|
|
142
|
+
recoveryCycles: Object.freeze(recoveryCycles),
|
|
143
|
+
unknownCounts,
|
|
144
|
+
limitations: Object.freeze([
|
|
145
|
+
'This local review uses analyst-supplied portfolio assertions and makes no registrar, DNS, mail, certificate, account, or recovery request.',
|
|
146
|
+
'Provider and account labels are not proof of current configuration, ownership, control, availability, or contractual responsibility.',
|
|
147
|
+
'Failure simulations show exact supplied dependency concentration only; they do not predict an outage or establish that providers share infrastructure.',
|
|
148
|
+
'Do not include credentials, recovery codes, personal contact details, account identifiers, or other secrets in this input.',
|
|
149
|
+
]),
|
|
150
|
+
});
|
|
151
|
+
}
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -94,7 +94,7 @@ function spfCheck(query, expansion) {
|
|
|
94
94
|
const parsed = parseSpfRecords(query.records);
|
|
95
95
|
if (parsed.records.length === 0) {
|
|
96
96
|
return check('spf', 'SPF', 'warning', 'No SPF policy published', {
|
|
97
|
-
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.',
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
if (!parsed.valid) {
|
|
@@ -119,7 +119,7 @@ function spfCheck(query, expansion) {
|
|
|
119
119
|
...parsed.issues,
|
|
120
120
|
];
|
|
121
121
|
if (parsed.terminalPolicy === 'pass') {
|
|
122
|
-
return check('spf', 'SPF', 'danger', 'Policy
|
|
122
|
+
return check('spf', 'SPF', 'danger', 'Policy authorises every sender (+all)', {
|
|
123
123
|
detail: details.join(' '), records: parsed.records,
|
|
124
124
|
remediation: 'Replace +all with an explicit sender allowlist and a restrictive terminal policy.',
|
|
125
125
|
});
|
|
@@ -143,7 +143,7 @@ function spfCheck(query, expansion) {
|
|
|
143
143
|
return check('spf', 'SPF', 'warning', `Policy ends in ${parsed.terminalPolicy || 'an unknown result'}`, {
|
|
144
144
|
detail: details.join(' '), records: parsed.records,
|
|
145
145
|
remediation: parsed.terminalPolicy === 'softfail'
|
|
146
|
-
? '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.'
|
|
147
147
|
: 'Add a restrictive -all terminal policy after confirming every legitimate sending service.',
|
|
148
148
|
});
|
|
149
149
|
}
|
|
@@ -198,7 +198,7 @@ function dmarcCheck(query, authorizations = []) {
|
|
|
198
198
|
return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; external reporting authorization is incomplete`, {
|
|
199
199
|
detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
|
|
200
200
|
records: parsed.records,
|
|
201
|
-
remediation: 'Publish the required external reporting
|
|
201
|
+
remediation: 'Publish the required external reporting authorisation record or remove the unavailable destination.',
|
|
202
202
|
});
|
|
203
203
|
}
|
|
204
204
|
if (!parsed.aggregateReporting) {
|
|
@@ -252,7 +252,7 @@ function dnssecCheck(input) {
|
|
|
252
252
|
function dnssecDelegationConsistencyCheck(input, registry) {
|
|
253
253
|
if (registry.error) {
|
|
254
254
|
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Registry evidence is unavailable', {
|
|
255
|
-
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.',
|
|
256
256
|
});
|
|
257
257
|
}
|
|
258
258
|
if (input.error) {
|
|
@@ -274,7 +274,7 @@ function dnssecDelegationConsistencyCheck(input, registry) {
|
|
|
274
274
|
}
|
|
275
275
|
if (registry.dsDataTruncated) {
|
|
276
276
|
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
|
|
277
|
-
detail: 'The
|
|
277
|
+
detail: 'The normalised registry response reported truncated DS data, so apparent disagreement is inconclusive.',
|
|
278
278
|
});
|
|
279
279
|
}
|
|
280
280
|
if (value === 'signed' && !hasDsRecords) {
|
|
@@ -414,7 +414,7 @@ function bimiCheck(query, dmarcQuery) {
|
|
|
414
414
|
&& (dmarc.legacyPct === null || dmarc.legacyPct === 100);
|
|
415
415
|
if (!enforcementReady) {
|
|
416
416
|
return check('bimi', 'BIMI', 'warning', 'Record exists but DMARC is not BIMI-ready', {
|
|
417
|
-
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.',
|
|
418
418
|
records: parsed.records,
|
|
419
419
|
remediation: 'Enforce DMARC at quarantine or reject for the domain and subdomains before relying on BIMI.',
|
|
420
420
|
});
|
|
@@ -534,7 +534,7 @@ function registrationLockCheck(registry) {
|
|
|
534
534
|
const statuses = registry.statuses.map((status) => status.toLowerCase().replace(/[^a-z]/gu, ''));
|
|
535
535
|
if (statuses.length === 0) {
|
|
536
536
|
return check('registration_lock', 'Registration controls', 'info', 'Registry lock state is unavailable', {
|
|
537
|
-
detail: 'No
|
|
537
|
+
detail: 'No normalised EPP status was returned. This does not describe registrar account security.',
|
|
538
538
|
});
|
|
539
539
|
}
|
|
540
540
|
const transferLocks = statuses.filter((status) => ['clienttransferprohibited', 'servertransferprohibited'].includes(status));
|
|
@@ -706,7 +706,7 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
706
706
|
nameservers: [],
|
|
707
707
|
dsRecordCount: 0,
|
|
708
708
|
dsDataTruncated: false,
|
|
709
|
-
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.',
|
|
710
710
|
};
|
|
711
711
|
const externalDependencies = buildExternalDependencies({
|
|
712
712
|
domain,
|
|
@@ -75,7 +75,7 @@ function planEncryptedDnsQuery(adapter, input) {
|
|
|
75
75
|
recipient: new URL(adapter.endpoint).hostname,
|
|
76
76
|
queryRetention: adapter.queryRetention,
|
|
77
77
|
}),
|
|
78
|
-
limitation: 'This plan does not execute a request. Provider availability, answer authority, validation, filtering, retention, and regional
|
|
78
|
+
limitation: 'This plan does not execute a request. Provider availability, answer authority, validation, filtering, retention, and regional behaviour remain untested.',
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
export { ENCRYPTED_DNS_CONTRACT_SCHEMA, ENCRYPTED_DNS_CONTRACT_VERSION, normalizeEncryptedDnsAdapter, planEncryptedDnsQuery, };
|
package/lib/evidence-export.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { buildRegistryInsights } from "./registry-insights.mjs";
|
|
|
3
3
|
import { WHOISLEUTH_SOURCE_REPOSITORY_URL } from "./project-metadata.mjs";
|
|
4
4
|
import { normalizeBoundedSemanticVersion } from "./semantic-version.mjs";
|
|
5
5
|
export const LOOKUP_EVIDENCE_SCHEMA = 'whoisleuth.lookup-evidence';
|
|
6
|
-
export const LOOKUP_EVIDENCE_SCHEMA_VERSION =
|
|
6
|
+
export const LOOKUP_EVIDENCE_SCHEMA_VERSION = 25;
|
|
7
7
|
const REGISTRAR_RDAP_STATUSES = new Set([
|
|
8
8
|
'success', 'partial', 'error', 'unsupported', 'not_found', 'skipped', 'disabled',
|
|
9
9
|
]);
|
|
@@ -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
|
@@ -378,7 +378,7 @@ function buildLookupEvidenceReport(document, options = {}) {
|
|
|
378
378
|
...registryAccessFields,
|
|
379
379
|
],
|
|
380
380
|
limitations: [
|
|
381
|
-
'This report
|
|
381
|
+
'This report summarises point-in-time observations and registry publications. It does not prove ownership, activity, availability, or maliciousness.',
|
|
382
382
|
'Missing, skipped, partial, unsupported, or failed sources are inconclusive rather than negative evidence.',
|
|
383
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.',
|
|
384
384
|
'Raw registry payloads and full WHOIS referral responses are available only in the JSON evidence package and may contain public contact data.',
|