@slicedearth/whoisleuth-cli 1.32.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/LICENSE +661 -0
- package/LICENSES/Retire.js-Apache-2.0.txt +199 -0
- package/NOTICE +76 -0
- package/README.md +84 -0
- package/TRADEMARKS.md +41 -0
- package/bin/whoisleuth.mjs +35 -0
- package/cli/archive-inspect.mjs +198 -0
- package/cli/arguments.mjs +1022 -0
- package/cli/artifact-verify.mjs +210 -0
- package/cli/bulk-checkpoint.mjs +218 -0
- package/cli/bulk-command-runner.mjs +109 -0
- package/cli/bulk-output.mjs +112 -0
- package/cli/bulk.mjs +133 -0
- package/cli/compare.mjs +238 -0
- package/cli/completion.mjs +175 -0
- package/cli/csv.mjs +1 -0
- package/cli/discover.mjs +50 -0
- package/cli/discovery-command-runner.mjs +36 -0
- package/cli/discovery-observation-snapshot.mjs +315 -0
- package/cli/discovery-scan-command-runner.mjs +141 -0
- package/cli/discovery-scan.mjs +325 -0
- package/cli/discovery-snapshot.mjs +133 -0
- package/cli/discovery-workflow.mjs +73 -0
- package/cli/doctor.mjs +137 -0
- package/cli/errors.mjs +21 -0
- package/cli/evidence-command-arguments.mjs +145 -0
- package/cli/evidence-command-runner.mjs +106 -0
- package/cli/evidence-signing.mjs +215 -0
- package/cli/exit-codes.mjs +9 -0
- package/cli/export-evidence.mjs +28 -0
- package/cli/formatters/evidence-report.mjs +3 -0
- package/cli/formatters/html.mjs +69 -0
- package/cli/formatters/json.mjs +135 -0
- package/cli/formatters/markdown.mjs +3 -0
- package/cli/formatters/terminal.mjs +658 -0
- package/cli/http.mjs +27 -0
- package/cli/lookup-command-runner.mjs +87 -0
- package/cli/lookup-diff.mjs +171 -0
- package/cli/mail-review.mjs +204 -0
- package/cli/manual.mjs +34 -0
- package/cli/network-command-runner.mjs +88 -0
- package/cli/output-file.mjs +97 -0
- package/cli/page-compare.mjs +123 -0
- package/cli/posture.mjs +20 -0
- package/cli/progress-events.mjs +53 -0
- package/cli/progress.mjs +56 -0
- package/cli/registry-access.mjs +3 -0
- package/cli/registry-support.mjs +120 -0
- package/cli/risk-calibration.mjs +294 -0
- package/cli/runner-types.mjs +1 -0
- package/cli/runner.mjs +726 -0
- package/cli/saved-lookup.mjs +87 -0
- package/cli/source-reliability.mjs +727 -0
- package/cli/strict-exit.mjs +55 -0
- package/cli/terminal-presentation.mjs +147 -0
- package/docs/cli.md +827 -0
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +93 -0
- package/frontend/src/lib/analysis/acquisition-due-diligence.js +391 -0
- package/frontend/src/lib/analysis/artifact-integrity.js +21 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +421 -0
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +246 -0
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +192 -0
- package/frontend/src/lib/analysis/bulk-retry-plan.js +86 -0
- package/frontend/src/lib/analysis/bulk-review-export.js +62 -0
- package/frontend/src/lib/analysis/bulk-review-model.js +266 -0
- package/frontend/src/lib/analysis/bulk-session-model.js +515 -0
- package/frontend/src/lib/analysis/bulk-sort.js +49 -0
- package/frontend/src/lib/analysis/campaign-model.js +265 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +642 -0
- package/frontend/src/lib/analysis/case-migration-model.js +248 -0
- package/frontend/src/lib/analysis/case-model.js +5 -0
- package/frontend/src/lib/analysis/case-record-contracts.js +44 -0
- package/frontend/src/lib/analysis/case-record-core.js +232 -0
- package/frontend/src/lib/analysis/case-record-model.js +7 -0
- package/frontend/src/lib/analysis/case-record-operations.js +224 -0
- package/frontend/src/lib/analysis/case-response-model.js +640 -0
- package/frontend/src/lib/analysis/case-response-packet.js +573 -0
- package/frontend/src/lib/analysis/case-storage-model.js +93 -0
- package/frontend/src/lib/analysis/detection-rule-model.js +306 -0
- package/frontend/src/lib/analysis/dns-record-normalization.js +11 -0
- package/frontend/src/lib/analysis/http-summary.js +165 -0
- package/frontend/src/lib/analysis/investigation-guide.js +500 -0
- package/frontend/src/lib/analysis/investigation-template-model.js +144 -0
- package/frontend/src/lib/analysis/page-baseline.js +272 -0
- package/frontend/src/lib/analysis/page-similarity.js +179 -0
- package/frontend/src/lib/analysis/relationship-evidence.js +265 -0
- package/frontend/src/lib/analysis/relationship-observation-model.js +423 -0
- package/frontend/src/lib/analysis/scoring.js +135 -0
- package/frontend/src/lib/analysis/shortlist-model.js +209 -0
- package/frontend/src/lib/analysis/utils.js +265 -0
- package/frontend/src/lib/analysis/watchlist-history.js +643 -0
- package/frontend/src/lib/analysis/watchlist-store.js +127 -0
- package/frontend/src/lib/analysis/website-snapshot-model.js +302 -0
- package/frontend/src/lib/analysis/workspace-archive-crypto.js +282 -0
- package/frontend/src/lib/analysis/workspace-archive.js +472 -0
- package/lib/abort.mjs +26 -0
- package/lib/abusech-auth.mjs +19 -0
- package/lib/availability.mjs +716 -0
- package/lib/bounded-contract-normalizers.mjs +61 -0
- package/lib/browser-library-profile.mjs +310 -0
- package/lib/bulk-comparison-evidence.mjs +83 -0
- package/lib/classify.mjs +93 -0
- package/lib/client-behavior-profile.mjs +83 -0
- package/lib/credential-surface-profile.mjs +66 -0
- package/lib/csv.mjs +23 -0
- package/lib/ct-query.mjs +27 -0
- package/lib/ct-search.mjs +393 -0
- package/lib/distributed-operation-budget.mjs +461 -0
- package/lib/dns-delegation-health.mjs +344 -0
- package/lib/dns-intelligence.mjs +739 -0
- package/lib/dns-mx.mjs +24 -0
- package/lib/dns-resolver-selection.mjs +33 -0
- package/lib/domain-posture-analysis.mjs +254 -0
- package/lib/domain-posture-parsers.mjs +358 -0
- package/lib/domain-posture.mjs +745 -0
- package/lib/error-detail.mjs +10 -0
- package/lib/evidence-export.mjs +431 -0
- package/lib/evidence-report-markdown.mjs +71 -0
- package/lib/evidence-report.mjs +391 -0
- package/lib/external-intelligence-risk.mjs +108 -0
- package/lib/favicon.mjs +146 -0
- package/lib/feature-policy.mjs +70 -0
- package/lib/generated/retire-browser-catalog.mjs +8724 -0
- package/lib/generated/unicode-confusables-17.mjs +93 -0
- package/lib/hostname.mjs +38 -0
- package/lib/html-signals.mjs +680 -0
- package/lib/http-intelligence.mjs +232 -0
- package/lib/idn-confusable-policy.mjs +86 -0
- package/lib/idn-confusables.mjs +465 -0
- package/lib/lookup-cache.mjs +130 -0
- package/lib/lookup-progress.mjs +291 -0
- package/lib/lookup-source-progress.mjs +109 -0
- package/lib/lookup-threat-provider-inventory.mjs +13 -0
- package/lib/lookup.mjs +592 -0
- package/lib/observation.mjs +114 -0
- package/lib/observed-network-context.mjs +355 -0
- package/lib/operation-budget.mjs +489 -0
- package/lib/page-fingerprints.mjs +481 -0
- package/lib/page-role-profile.mjs +117 -0
- package/lib/perceptual-hash.mjs +451 -0
- package/lib/rdap-attempts.mjs +34 -0
- package/lib/rdap-bootstrap.mjs +173 -0
- package/lib/rdap-capabilities.mjs +292 -0
- package/lib/rdap-client.mjs +125 -0
- package/lib/rdap-normalization.mjs +691 -0
- package/lib/rdap-registrar.mjs +196 -0
- package/lib/rdap-transport.mjs +39 -0
- package/lib/rdap-types.mjs +1 -0
- package/lib/rdap-validation.mjs +94 -0
- package/lib/rdap.mjs +16 -0
- package/lib/registry-access.mjs +32 -0
- package/lib/registry-capabilities.mjs +117 -0
- package/lib/registry-capability-catalogue.mjs +1206 -0
- package/lib/registry-comparison.mjs +318 -0
- package/lib/registry-dates.mjs +190 -0
- package/lib/registry-insights.mjs +325 -0
- package/lib/response-policy.mjs +256 -0
- package/lib/risk-scoring.mjs +164 -0
- package/lib/safe-fetch.mjs +446 -0
- package/lib/security-txt.mjs +359 -0
- package/lib/service-binding-dns.mjs +608 -0
- package/lib/sslbl-certificates.generated.mjs +143 -0
- package/lib/sslbl-intelligence.mjs +241 -0
- package/lib/static-html-analysis.mjs +340 -0
- package/lib/structured-data-identity.mjs +323 -0
- package/lib/threat-intelligence-contract.mjs +5 -0
- package/lib/threat-intelligence-definition-registry.mjs +34 -0
- package/lib/threat-intelligence-fixtures.mjs +82 -0
- package/lib/threat-intelligence-manifests.mjs +512 -0
- package/lib/threat-intelligence-runtime.mjs +662 -0
- package/lib/threat-intelligence-targets.mjs +168 -0
- package/lib/threat-intelligence-types.mjs +3 -0
- package/lib/threatfox-intelligence.mjs +307 -0
- package/lib/tls-intelligence.mjs +814 -0
- package/lib/typosquat-generator.mjs +884 -0
- package/lib/urlhaus-intelligence.mjs +361 -0
- package/lib/urlscan-intelligence.mjs +349 -0
- package/lib/website-security-posture.mjs +325 -0
- package/lib/website-technology.mjs +437 -0
- package/lib/whois-authority.mjs +98 -0
- package/lib/whois-chain.mjs +261 -0
- package/lib/whois-contacts.mjs +163 -0
- package/lib/whois-contracts.mjs +3 -0
- package/lib/whois-normalization.mjs +27 -0
- package/lib/whois-transport.mjs +100 -0
- package/lib/whois-values.mjs +92 -0
- package/lib/whois.mjs +1187 -0
- package/package.json +55 -0
- package/third-party-notices.txt +5571 -0
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
// Owned-domain email/DNS posture audit. DNS and HTTPS collection lives here
|
|
2
|
+
// so Express and Netlify expose the same result shape; the parsers and report
|
|
3
|
+
// builder are pure, keeping policy interpretation independently testable.
|
|
4
|
+
import { promises as dns } from 'node:dns';
|
|
5
|
+
import { fetchRdapRecord } from "./rdap.mjs";
|
|
6
|
+
import { nonEmptyErrorMessage } from "./error-detail.mjs";
|
|
7
|
+
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
8
|
+
import { classifyMxRecords } from "./dns-mx.mjs";
|
|
9
|
+
import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
|
|
10
|
+
import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
|
|
11
|
+
const DNS_TIMEOUT_MS = 6000;
|
|
12
|
+
const POLICY_TIMEOUT_MS = 7000;
|
|
13
|
+
const MAX_POLICY_BYTES = 64 * 1024;
|
|
14
|
+
const MAX_DKIM_SELECTORS = 10;
|
|
15
|
+
const POSTURE_ENRICHMENT_DEADLINE_MS = 6500;
|
|
16
|
+
const MISSING_DNS_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
|
|
17
|
+
function errorRecord(value) {
|
|
18
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
19
|
+
? value
|
|
20
|
+
: {};
|
|
21
|
+
}
|
|
22
|
+
function asMxRecords(records) {
|
|
23
|
+
return records;
|
|
24
|
+
}
|
|
25
|
+
function trimTerminalDots(value) {
|
|
26
|
+
let end = value.length;
|
|
27
|
+
while (end > 0 && value.charCodeAt(end - 1) === 46)
|
|
28
|
+
end -= 1;
|
|
29
|
+
return end === value.length ? value : value.slice(0, end);
|
|
30
|
+
}
|
|
31
|
+
function trimEdgeDots(value) {
|
|
32
|
+
let start = 0;
|
|
33
|
+
const withoutTerminalDots = trimTerminalDots(value);
|
|
34
|
+
while (start < withoutTerminalDots.length && withoutTerminalDots.charCodeAt(start) === 46)
|
|
35
|
+
start += 1;
|
|
36
|
+
return start === 0 ? withoutTerminalDots : withoutTerminalDots.slice(start);
|
|
37
|
+
}
|
|
38
|
+
function normalizeAuditDomain(raw) {
|
|
39
|
+
try {
|
|
40
|
+
const input = trimTerminalDots(String(raw || '').trim());
|
|
41
|
+
const hostname = new URL(`https://${input}`).hostname.toLowerCase();
|
|
42
|
+
if (!hostname.includes('.') || hostname.length > 253)
|
|
43
|
+
return null;
|
|
44
|
+
if (!hostname.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label)))
|
|
45
|
+
return null;
|
|
46
|
+
return hostname;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function normalizeDkimSelectors(rawSelectors) {
|
|
53
|
+
if (!Array.isArray(rawSelectors))
|
|
54
|
+
return [];
|
|
55
|
+
return [...new Set(rawSelectors
|
|
56
|
+
.map((selector) => trimEdgeDots(String(selector || '').trim().toLowerCase()))
|
|
57
|
+
.filter((selector) => selector.length > 0 && selector.length <= 253)
|
|
58
|
+
.filter((selector) => selector.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))))]
|
|
59
|
+
.slice(0, MAX_DKIM_SELECTORS);
|
|
60
|
+
}
|
|
61
|
+
function normalizeMailProtectionProfile(value) {
|
|
62
|
+
return value === 'defensive_no_mail' || value === 'parked' ? value : 'standard';
|
|
63
|
+
}
|
|
64
|
+
function withTimeout(promise, label, timeoutMs = DNS_TIMEOUT_MS) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const timeout = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
|
67
|
+
promise.then((value) => { clearTimeout(timeout); resolve(value); }, (err) => { clearTimeout(timeout); reject(err); });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
async function resolveDns(label, factory, timeoutMs = DNS_TIMEOUT_MS) {
|
|
71
|
+
try {
|
|
72
|
+
return { records: await withTimeout(factory(), label, timeoutMs), error: null };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
const error = errorRecord(err);
|
|
76
|
+
if (typeof error.code === 'string' && MISSING_DNS_CODES.has(error.code))
|
|
77
|
+
return { records: [], error: null };
|
|
78
|
+
return { records: [], error: nonEmptyErrorMessage(err, String(err)) };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function check(id, label, status, summary, { detail = '', records = [], remediation = '' } = {}) {
|
|
82
|
+
return { id, label, status, summary, detail, records, remediation };
|
|
83
|
+
}
|
|
84
|
+
function queryFailureCheck(id, label, error) {
|
|
85
|
+
return check(id, label, 'info', 'Check could not be completed', {
|
|
86
|
+
detail: error,
|
|
87
|
+
remediation: 'Retry the audit before changing DNS; a resolver failure is not evidence that the policy is absent.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function spfCheck(query, expansion) {
|
|
91
|
+
if (query.error)
|
|
92
|
+
return queryFailureCheck('spf', 'SPF', query.error);
|
|
93
|
+
const parsed = parseSpfRecords(query.records);
|
|
94
|
+
if (parsed.records.length === 0) {
|
|
95
|
+
return check('spf', 'SPF', 'warning', 'No SPF policy published', {
|
|
96
|
+
remediation: 'Publish one SPF TXT record that authorizes every legitimate sender and ends in -all once verified.',
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (!parsed.valid) {
|
|
100
|
+
return check('spf', 'SPF', 'danger', 'SPF policy is invalid', {
|
|
101
|
+
detail: parsed.issues.join(' '), records: parsed.records,
|
|
102
|
+
remediation: 'Consolidate the policy into exactly one v=spf1 TXT record and validate its syntax.',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
const expansionDetails = expansion
|
|
106
|
+
? [
|
|
107
|
+
`Expanded policy: ${expansion.state}.`,
|
|
108
|
+
`Policy queries: ${expansion.lookupsUsed}/${expansion.lookupLimit}.`,
|
|
109
|
+
`DNS-querying terms observed: ${expansion.dnsLookupTerms}.`,
|
|
110
|
+
`Void answers: ${expansion.voidLookups}/${expansion.voidLookupLimit}.`,
|
|
111
|
+
...expansion.issues,
|
|
112
|
+
]
|
|
113
|
+
: [];
|
|
114
|
+
const details = [
|
|
115
|
+
`Terminal policy: ${parsed.terminalPolicy}.`,
|
|
116
|
+
`Top-level DNS-querying terms: ${parsed.dnsLookupTerms}.`,
|
|
117
|
+
...expansionDetails,
|
|
118
|
+
...parsed.issues,
|
|
119
|
+
];
|
|
120
|
+
if (parsed.terminalPolicy === 'pass') {
|
|
121
|
+
return check('spf', 'SPF', 'danger', 'Policy authorizes every sender (+all)', {
|
|
122
|
+
detail: details.join(' '), records: parsed.records,
|
|
123
|
+
remediation: 'Replace +all with an explicit sender allowlist and a restrictive terminal policy.',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
if (parsed.terminalPolicy === 'fail' && parsed.issues.length === 0 && (!expansion || expansion.state === 'complete')) {
|
|
127
|
+
return check('spf', 'SPF', 'pass', 'Restrictive fail-all policy', { detail: details.join(' '), records: parsed.records });
|
|
128
|
+
}
|
|
129
|
+
if (expansion && expansion.state !== 'complete') {
|
|
130
|
+
return check('spf', 'SPF', expansion.state === 'invalid' ? 'danger' : 'warning', 'SPF expansion is incomplete', {
|
|
131
|
+
detail: details.join(' '),
|
|
132
|
+
records: parsed.records,
|
|
133
|
+
remediation: 'Review unresolved, invalid, cyclic, or budget-limited include and redirect branches before treating the policy as complete.',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (parsed.terminalPolicy === 'redirect') {
|
|
137
|
+
return check('spf', 'SPF', 'info', 'Policy delegates evaluation with redirect', {
|
|
138
|
+
detail: details.join(' '), records: parsed.records,
|
|
139
|
+
remediation: 'Confirm the redirect target exists, remains under trusted control, and resolves within SPF lookup limits.',
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
return check('spf', 'SPF', 'warning', `Policy ends in ${parsed.terminalPolicy || 'an unknown result'}`, {
|
|
143
|
+
detail: details.join(' '), records: parsed.records,
|
|
144
|
+
remediation: parsed.terminalPolicy === 'softfail'
|
|
145
|
+
? 'Move from ~all to -all after confirming every legitimate sending service is authorized.'
|
|
146
|
+
: 'Add a restrictive -all terminal policy after confirming every legitimate sending service.',
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function dmarcCheck(query, authorizations = []) {
|
|
150
|
+
if (query.error)
|
|
151
|
+
return queryFailureCheck('dmarc', 'DMARC', query.error);
|
|
152
|
+
const parsed = parseDmarcRecords(query.records);
|
|
153
|
+
if (parsed.records.length === 0) {
|
|
154
|
+
return check('dmarc', 'DMARC', 'danger', 'No DMARC policy published', {
|
|
155
|
+
remediation: 'Publish _dmarc as a single v=DMARC1 record, begin with reporting, then move to quarantine or reject enforcement.',
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
if (!parsed.valid) {
|
|
159
|
+
return check('dmarc', 'DMARC', 'danger', 'DMARC policy is invalid', {
|
|
160
|
+
detail: parsed.issues.join(' '), records: parsed.records,
|
|
161
|
+
remediation: 'Publish one syntactically valid DMARC record and remove duplicate or unsupported tags.',
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
const details = [
|
|
165
|
+
`Domain policy: ${parsed.policy}.`,
|
|
166
|
+
`Subdomain policy: ${parsed.subdomainPolicy}.`,
|
|
167
|
+
`Non-existent subdomain policy: ${parsed.nonexistentSubdomainPolicy}.`,
|
|
168
|
+
parsed.aggregateReporting ? 'Aggregate reporting is configured.' : 'No aggregate reporting destination is configured.',
|
|
169
|
+
parsed.failureReporting ? 'Failure reporting is configured.' : 'No failure reporting destination is configured.',
|
|
170
|
+
...parsed.issues,
|
|
171
|
+
];
|
|
172
|
+
const external = authorizations.filter((authorization) => authorization.state !== 'self');
|
|
173
|
+
const unresolvedExternal = external.filter((authorization) => authorization.state !== 'authorized');
|
|
174
|
+
if (external.length > 0) {
|
|
175
|
+
details.push(`${external.length} external reporting destination${external.length === 1 ? '' : 's'} checked; `
|
|
176
|
+
+ `${unresolvedExternal.length} could not be authorized.`);
|
|
177
|
+
}
|
|
178
|
+
if (parsed.testMode) {
|
|
179
|
+
return check('dmarc', 'DMARC', 'warning', 'Policy is in test mode (t=y)', {
|
|
180
|
+
detail: details.join(' '), records: parsed.records,
|
|
181
|
+
remediation: 'Remove t=y once reports confirm legitimate mail passes alignment.',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
if (parsed.policy === 'none') {
|
|
185
|
+
return check('dmarc', 'DMARC', 'warning', 'Monitoring only (p=none)', {
|
|
186
|
+
detail: details.join(' '), records: parsed.records,
|
|
187
|
+
remediation: 'Use aggregate reports to fix alignment, then move to p=quarantine or p=reject.',
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (parsed.subdomainPolicy === 'none' || parsed.nonexistentSubdomainPolicy === 'none') {
|
|
191
|
+
return check('dmarc', 'DMARC', 'warning', `Domain enforced at p=${parsed.policy}; subdomain coverage is weaker`, {
|
|
192
|
+
detail: details.join(' '), records: parsed.records,
|
|
193
|
+
remediation: 'Set sp and np to quarantine or reject unless weaker subdomain treatment is intentional.',
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
if (unresolvedExternal.length > 0) {
|
|
197
|
+
return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; external reporting authorization is incomplete`, {
|
|
198
|
+
detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
|
|
199
|
+
records: parsed.records,
|
|
200
|
+
remediation: 'Publish the required external reporting authorization record or remove the unavailable destination.',
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
if (!parsed.aggregateReporting) {
|
|
204
|
+
return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; aggregate reporting is not configured`, {
|
|
205
|
+
detail: details.join(' '), records: parsed.records,
|
|
206
|
+
remediation: 'Add a monitored rua destination so authentication failures and abuse trends remain visible.',
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
if (parsed.legacyPct !== null) {
|
|
210
|
+
return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; legacy pct tag is still published`, {
|
|
211
|
+
detail: details.join(' '), records: parsed.records,
|
|
212
|
+
remediation: 'Remove the historic pct tag and use the current DMARC test-mode/enforcement controls.',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return check('dmarc', 'DMARC', 'pass', `Enforced at p=${parsed.policy}`, { detail: details.join(' '), records: parsed.records });
|
|
216
|
+
}
|
|
217
|
+
function mxCheck(query) {
|
|
218
|
+
if (query.error)
|
|
219
|
+
return queryFailureCheck('mx', 'Mail exchange', query.error);
|
|
220
|
+
const mxRecords = asMxRecords(query.records);
|
|
221
|
+
const classified = classifyMxRecords(mxRecords);
|
|
222
|
+
const records = mxRecords.map((record) => `${record.priority} ${record.exchange || '.'}`);
|
|
223
|
+
if (classified.hasNullMx) {
|
|
224
|
+
return check('mx', 'Mail exchange', 'pass', 'Null MX explicitly declines inbound mail', {
|
|
225
|
+
records,
|
|
226
|
+
detail: 'This is appropriate only if the domain is intentionally unable to receive email.',
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
if (classified.hasMx) {
|
|
230
|
+
return check('mx', 'Mail exchange', 'pass', `${classified.mxHosts.length} mail exchanger${classified.mxHosts.length === 1 ? '' : 's'} configured`, { records });
|
|
231
|
+
}
|
|
232
|
+
return check('mx', 'Mail exchange', 'warning', 'No explicit MX record', {
|
|
233
|
+
remediation: 'Publish valid MX records for a receiving domain, or a null MX if the domain intentionally accepts no mail.',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
function dnssecCheck(input) {
|
|
237
|
+
if (input.error)
|
|
238
|
+
return queryFailureCheck('dnssec', 'DNSSEC', input.error);
|
|
239
|
+
const value = String(input.value || '').toLowerCase();
|
|
240
|
+
if (value === 'signed')
|
|
241
|
+
return check('dnssec', 'DNSSEC', 'pass', 'Registry reports a signed delegation');
|
|
242
|
+
if (value === 'unsigned') {
|
|
243
|
+
return check('dnssec', 'DNSSEC', 'warning', 'Registry reports an unsigned delegation', {
|
|
244
|
+
remediation: 'Enable DNSSEC with the DNS provider and publish the DS record through the registrar.',
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return check('dnssec', 'DNSSEC', 'info', 'Delegation status is unavailable', {
|
|
248
|
+
detail: 'The registry record did not expose a conclusive DNSSEC delegation state.',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function dnssecDelegationConsistencyCheck(input, registry) {
|
|
252
|
+
if (registry.error) {
|
|
253
|
+
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 normalized registry evidence was unavailable.',
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
if (input.error) {
|
|
258
|
+
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'DNSSEC state is unavailable', {
|
|
259
|
+
detail: 'The registry DNSSEC state and retained DS record count could not be compared.',
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const value = String(input.value || '').toLowerCase();
|
|
263
|
+
const hasDsRecords = registry.dsRecordCount > 0;
|
|
264
|
+
if (value === 'signed' && hasDsRecords) {
|
|
265
|
+
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'pass', 'Signed state and retained DS records agree', {
|
|
266
|
+
detail: `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} retained.`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (value === 'unsigned' && !hasDsRecords) {
|
|
270
|
+
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'pass', 'Unsigned state and no retained DS records agree', {
|
|
271
|
+
detail: 'This is a consistency result, not a recommendation to leave DNSSEC disabled.',
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
if (registry.dsDataTruncated) {
|
|
275
|
+
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'Retained DS evidence is incomplete', {
|
|
276
|
+
detail: 'The normalized registry response reported truncated DS data, so apparent disagreement is inconclusive.',
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
if (value === 'signed' && !hasDsRecords) {
|
|
280
|
+
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'warning', 'Signed state reported without retained DS records', {
|
|
281
|
+
detail: 'This can reflect registry publication differences or incomplete upstream data and should be reviewed before changing delegation.',
|
|
282
|
+
remediation: 'Confirm the intended DS publication with the registrar, registry, and authoritative DNS provider.',
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (value === 'unsigned' && hasDsRecords) {
|
|
286
|
+
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'warning', 'Retained DS records conflict with the unsigned state', {
|
|
287
|
+
detail: `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} retained. The difference can be transient or source-limited.`,
|
|
288
|
+
remediation: 'Confirm the live delegation and DS publication before changing DNSSEC configuration.',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
return check('dnssec_delegation_consistency', 'DNSSEC delegation consistency', 'info', 'DNSSEC consistency is inconclusive', {
|
|
292
|
+
detail: `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} retained, but the registry DNSSEC state was not conclusive.`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function caaDisplay(record) {
|
|
296
|
+
const property = ['issue', 'issuewild', 'iodef', 'contactemail', 'contactphone'].find((key) => record[key] !== undefined);
|
|
297
|
+
return property ? `${record.critical || 0} ${property} "${record[property]}"` : JSON.stringify(record);
|
|
298
|
+
}
|
|
299
|
+
function caaCheck(query) {
|
|
300
|
+
if (query.error)
|
|
301
|
+
return queryFailureCheck('caa', 'CAA', query.error);
|
|
302
|
+
const records = query.records.map((record) => caaDisplay(record));
|
|
303
|
+
if (records.length > 0)
|
|
304
|
+
return check('caa', 'CAA', 'pass', `${records.length} certificate-authority rule${records.length === 1 ? '' : 's'} published`, { records });
|
|
305
|
+
return check('caa', 'CAA', 'warning', 'No CAA policy published', {
|
|
306
|
+
detail: 'No CAA record was returned for this exact name; a subdomain can still inherit policy from a parent name.',
|
|
307
|
+
remediation: 'Publish CAA issue/issuewild records to restrict which certificate authorities may issue for the domain.',
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function matchesMtaPattern(host, pattern) {
|
|
311
|
+
const normalizedHost = String(host || '').toLowerCase().replace(/\.+$/, '');
|
|
312
|
+
const normalizedPattern = String(pattern || '').toLowerCase().replace(/\.+$/, '');
|
|
313
|
+
if (normalizedPattern.startsWith('*.')) {
|
|
314
|
+
const suffix = normalizedPattern.slice(1);
|
|
315
|
+
return normalizedHost.endsWith(suffix) && normalizedHost.length > suffix.length;
|
|
316
|
+
}
|
|
317
|
+
return normalizedHost === normalizedPattern;
|
|
318
|
+
}
|
|
319
|
+
function mtaStsCheck(dnsQuery, policyFetch, mxQuery) {
|
|
320
|
+
if (dnsQuery.error)
|
|
321
|
+
return queryFailureCheck('mta_sts', 'MTA-STS', dnsQuery.error);
|
|
322
|
+
const dnsPolicy = parseMtaStsDnsRecords(dnsQuery.records);
|
|
323
|
+
const hasMx = !mxQuery.error && classifyMxRecords(asMxRecords(mxQuery.records)).hasMx;
|
|
324
|
+
if (dnsPolicy.records.length === 0) {
|
|
325
|
+
return check('mta_sts', 'MTA-STS', hasMx ? 'warning' : 'info', 'No MTA-STS policy advertised', {
|
|
326
|
+
remediation: hasMx ? 'Publish _mta-sts and serve a valid policy over HTTPS to require authenticated TLS for inbound mail.' : '',
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
if (!dnsPolicy.valid) {
|
|
330
|
+
return check('mta_sts', 'MTA-STS', 'danger', 'MTA-STS DNS record is invalid', {
|
|
331
|
+
detail: dnsPolicy.issues.join(' '), records: dnsPolicy.records,
|
|
332
|
+
remediation: 'Publish exactly one v=STSv1 DNS record with a non-empty id tag.',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
if (!policyFetch || policyFetch.error) {
|
|
336
|
+
return check('mta_sts', 'MTA-STS', 'danger', 'Policy is advertised but cannot be validated', {
|
|
337
|
+
detail: policyFetch?.error || 'The HTTPS policy was not fetched.', records: dnsPolicy.records,
|
|
338
|
+
remediation: 'Serve a text/plain policy with a valid certificate at https://mta-sts.<domain>/.well-known/mta-sts.txt.',
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const policy = parseMtaStsPolicy(policyFetch.text);
|
|
342
|
+
const records = [...dnsPolicy.records, ...String(policyFetch.text || '').trim().split(/\r?\n/).filter(Boolean)];
|
|
343
|
+
if (!policy.valid) {
|
|
344
|
+
return check('mta_sts', 'MTA-STS', 'danger', 'HTTPS policy is invalid', {
|
|
345
|
+
detail: policy.issues.join(' '), records,
|
|
346
|
+
remediation: 'Fix the STSv1 policy fields, max_age, mode, and MX patterns at the well-known HTTPS location.',
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
if (!policyFetch.contentType || !/^text\/plain(?:\s*;|$)/i.test(policyFetch.contentType)) {
|
|
350
|
+
return check('mta_sts', 'MTA-STS', 'danger', 'HTTPS policy has the wrong content type', {
|
|
351
|
+
detail: `${policyFetch.contentType ? `Received ${policyFetch.contentType}` : 'No Content-Type header was returned'}; MTA-STS policies should be served as text/plain.`, records,
|
|
352
|
+
remediation: 'Configure the policy host to return Content-Type: text/plain.',
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const mxHosts = mxQuery.error ? [] : classifyMxRecords(asMxRecords(mxQuery.records)).mxHosts;
|
|
356
|
+
const unmatched = mxHosts.filter((host) => !policy.mx.some((pattern) => matchesMtaPattern(host, pattern)));
|
|
357
|
+
if (unmatched.length) {
|
|
358
|
+
return check('mta_sts', 'MTA-STS', 'danger', 'Policy does not cover every published MX host', {
|
|
359
|
+
detail: `Unmatched MX: ${unmatched.join(', ')}.`, records,
|
|
360
|
+
remediation: 'Add each legitimate MX host (or an appropriate wildcard) to the HTTPS policy before enforcing it.',
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
if (policy.mode === 'enforce')
|
|
364
|
+
return check('mta_sts', 'MTA-STS', 'pass', 'Authenticated TLS is enforced', { detail: `Policy max_age: ${policy.maxAge} seconds.`, records });
|
|
365
|
+
if (policy.mode === 'testing') {
|
|
366
|
+
return check('mta_sts', 'MTA-STS', 'warning', 'Policy is in testing mode', {
|
|
367
|
+
detail: `Policy max_age: ${policy.maxAge} seconds.`, records,
|
|
368
|
+
remediation: 'Review TLS reports and move the policy to mode: enforce once every legitimate MX is covered.',
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return check('mta_sts', 'MTA-STS', 'warning', 'Policy explicitly disables enforcement (mode: none)', {
|
|
372
|
+
records, remediation: 'Use testing, then enforce, if this domain receives mail and its providers support authenticated TLS.',
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
function tlsRptCheck(query, mxQuery) {
|
|
376
|
+
if (query.error)
|
|
377
|
+
return queryFailureCheck('tls_rpt', 'TLS-RPT', query.error);
|
|
378
|
+
const parsed = parseTlsRptRecords(query.records);
|
|
379
|
+
if (parsed.records.length === 0) {
|
|
380
|
+
const hasMx = !mxQuery.error && classifyMxRecords(asMxRecords(mxQuery.records)).hasMx;
|
|
381
|
+
return check('tls_rpt', 'TLS-RPT', hasMx ? 'warning' : 'info', 'No SMTP TLS reporting policy', {
|
|
382
|
+
detail: hasMx ? '' : 'The domain has no receiving mail exchanger, so SMTP TLS reporting is not currently actionable.',
|
|
383
|
+
remediation: hasMx ? 'Publish a v=TLSRPTv1 record at _smtp._tls with a monitored rua destination.' : '',
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
if (!parsed.valid) {
|
|
387
|
+
return check('tls_rpt', 'TLS-RPT', 'danger', 'TLS reporting policy is invalid', {
|
|
388
|
+
detail: parsed.issues.join(' '), records: parsed.records,
|
|
389
|
+
remediation: 'Publish exactly one v=TLSRPTv1 record with at least one valid rua destination.',
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
return check('tls_rpt', 'TLS-RPT', 'pass', `${parsed.rua.length} report destination${parsed.rua.length === 1 ? '' : 's'} configured`, { records: parsed.records });
|
|
393
|
+
}
|
|
394
|
+
function bimiCheck(query, dmarcQuery) {
|
|
395
|
+
if (query.error)
|
|
396
|
+
return queryFailureCheck('bimi', 'BIMI', query.error);
|
|
397
|
+
const parsed = parseBimiRecords(query.records);
|
|
398
|
+
if (parsed.records.length === 0)
|
|
399
|
+
return check('bimi', 'BIMI', 'info', 'No default-selector BIMI record', { detail: 'BIMI is optional and does not affect mail authentication.' });
|
|
400
|
+
if (!parsed.valid) {
|
|
401
|
+
return check('bimi', 'BIMI', 'warning', 'Default-selector BIMI record is invalid', {
|
|
402
|
+
detail: parsed.issues.join(' '), records: parsed.records,
|
|
403
|
+
remediation: 'Publish one v=BIMI1 record with an HTTPS SVG logo location.',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const dmarc = dmarcQuery.error ? null : parseDmarcRecords(dmarcQuery.records);
|
|
407
|
+
const enforcementReady = dmarc?.valid
|
|
408
|
+
&& !dmarc.testMode
|
|
409
|
+
&& typeof dmarc.policy === 'string'
|
|
410
|
+
&& ['quarantine', 'reject'].includes(dmarc.policy)
|
|
411
|
+
&& typeof dmarc.subdomainPolicy === 'string'
|
|
412
|
+
&& ['quarantine', 'reject'].includes(dmarc.subdomainPolicy)
|
|
413
|
+
&& (dmarc.legacyPct === null || dmarc.legacyPct === 100);
|
|
414
|
+
if (!enforcementReady) {
|
|
415
|
+
return check('bimi', 'BIMI', 'warning', 'Record exists but DMARC is not BIMI-ready', {
|
|
416
|
+
detail: 'BIMI display generally requires enforced DMARC for the organizational domain and subdomains. Mailbox providers apply additional requirements.',
|
|
417
|
+
records: parsed.records,
|
|
418
|
+
remediation: 'Enforce DMARC at quarantine or reject for the domain and subdomains before relying on BIMI.',
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
return check('bimi', 'BIMI', 'pass', parsed.authority ? 'Logo and authority evidence published' : 'Logo published; provider-specific evidence may still be required', {
|
|
422
|
+
detail: 'Mailbox providers decide independently whether to display a logo.', records: parsed.records,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function dkimCheck(selectorQueries) {
|
|
426
|
+
selectorQueries = selectorQueries.filter((query) => query.retired !== true);
|
|
427
|
+
if (selectorQueries.length === 0) {
|
|
428
|
+
return check('dkim', 'DKIM', 'info', 'Not checked: no selectors configured', {
|
|
429
|
+
detail: 'DKIM selectors cannot be discovered reliably from DNS; configure the selectors used by each legitimate sending platform in the Brand Profile.',
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const results = selectorQueries.map(({ selector, records, error }) => error
|
|
433
|
+
? {
|
|
434
|
+
selector,
|
|
435
|
+
valid: false,
|
|
436
|
+
records: [],
|
|
437
|
+
keyType: null,
|
|
438
|
+
keyBits: null,
|
|
439
|
+
keyParseState: 'not_checked',
|
|
440
|
+
revoked: false,
|
|
441
|
+
testing: false,
|
|
442
|
+
issues: [error],
|
|
443
|
+
}
|
|
444
|
+
: parseDkimRecords(selector, records));
|
|
445
|
+
const valid = results.filter((result) => result.valid);
|
|
446
|
+
const records = results.flatMap((result) => result.records.map((record) => `${result.selector}: ${record}`));
|
|
447
|
+
if (valid.length === results.length) {
|
|
448
|
+
const testing = results.filter((result) => result.testing).map((result) => result.selector);
|
|
449
|
+
const weakRsa = results.filter((result) => result.keyType === 'rsa' && result.keyBits !== null && result.keyBits < 2048);
|
|
450
|
+
const unknownStrength = results.filter((result) => result.keyBits === null);
|
|
451
|
+
const needsReview = testing.length > 0 || weakRsa.length > 0 || unknownStrength.length > 0;
|
|
452
|
+
const detail = [
|
|
453
|
+
testing.length ? `Testing flag is enabled for: ${testing.join(', ')}.` : '',
|
|
454
|
+
weakRsa.length ? `RSA keys below 2048 bits: ${weakRsa.map((result) => `${result.selector} (${result.keyBits} bits)`).join(', ')}.` : '',
|
|
455
|
+
unknownStrength.length ? `Key strength could not be determined for: ${unknownStrength.map((result) => result.selector).join(', ')}.` : '',
|
|
456
|
+
...results.filter((result) => result.keyBits !== null).map((result) => `${result.selector}: ${result.keyType} ${result.keyBits}-bit key.`),
|
|
457
|
+
].filter(Boolean).join(' ');
|
|
458
|
+
return check('dkim', 'DKIM', needsReview ? 'warning' : 'pass', `${valid.length} configured selector${valid.length === 1 ? '' : 's'} publish valid keys`, {
|
|
459
|
+
detail, records,
|
|
460
|
+
remediation: testing.length
|
|
461
|
+
? 'Remove the DKIM t=y testing flag after validation.'
|
|
462
|
+
: weakRsa.length
|
|
463
|
+
? 'Rotate RSA DKIM keys below 2048 bits and verify the replacement selector before retirement.'
|
|
464
|
+
: unknownStrength.length
|
|
465
|
+
? 'Confirm the published public key can be parsed and meets the sending platform policy.'
|
|
466
|
+
: '',
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const failed = results.filter((result) => !result.valid);
|
|
470
|
+
return check('dkim', 'DKIM', 'warning', `${failed.length} configured selector${failed.length === 1 ? '' : 's'} could not be validated`, {
|
|
471
|
+
detail: failed.map((result) => `${result.selector}: ${result.issues.join(' ')}`).join(' '), records,
|
|
472
|
+
remediation: 'Confirm each selector is current, publish its public key, or remove retired selectors from the Brand Profile.',
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
function retiredDkimCheck(selectorQueries) {
|
|
476
|
+
const retired = selectorQueries.filter((query) => query.retired === true);
|
|
477
|
+
if (retired.length === 0)
|
|
478
|
+
return null;
|
|
479
|
+
const unavailable = retired.filter((query) => Boolean(query.error));
|
|
480
|
+
const published = retired.filter((query) => !query.error && query.records.length > 0);
|
|
481
|
+
const records = published.flatMap((query) => query.records.map((record) => `${query.selector}: ${String(Array.isArray(record) ? record.join('') : record || '')}`));
|
|
482
|
+
if (unavailable.length > 0) {
|
|
483
|
+
return check('dkim_retired', 'Retired DKIM selectors', 'info', 'Retired-selector review is incomplete', {
|
|
484
|
+
detail: unavailable.map((query) => `${query.selector}: ${query.error}`).join(' '),
|
|
485
|
+
records,
|
|
486
|
+
remediation: 'Retry before concluding that retired keys are no longer published.',
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
if (published.length > 0) {
|
|
490
|
+
return check('dkim_retired', 'Retired DKIM selectors', 'warning', `${published.length} retired selector${published.length === 1 ? '' : 's'} remain published`, {
|
|
491
|
+
detail: 'Continued publication is not proof that a key is still used, but it should be confirmed with the sending platform.',
|
|
492
|
+
records,
|
|
493
|
+
remediation: 'Confirm mail has moved to the active selector, then remove obsolete public keys according to the provider rotation plan.',
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
return check('dkim_retired', 'Retired DKIM selectors', 'pass', `${retired.length} retired selector${retired.length === 1 ? '' : 's'} are no longer published`);
|
|
497
|
+
}
|
|
498
|
+
function defensiveMailProfileCheck(profile, input) {
|
|
499
|
+
if (profile === 'standard')
|
|
500
|
+
return null;
|
|
501
|
+
const spf = input.spf.error ? null : parseSpfRecords(input.spf.records);
|
|
502
|
+
const dmarc = input.dmarc.error ? null : parseDmarcRecords(input.dmarc.records);
|
|
503
|
+
const mx = input.mx.error ? null : classifyMxRecords(asMxRecords(input.mx.records));
|
|
504
|
+
const requirements = {
|
|
505
|
+
nullMx: mx?.hasNullMx === true,
|
|
506
|
+
restrictiveSpf: spf?.valid === true && spf.terminalPolicy === 'fail' && spf.dnsLookupTerms === 0,
|
|
507
|
+
rejectingDmarc: dmarc?.valid === true
|
|
508
|
+
&& dmarc.policy === 'reject'
|
|
509
|
+
&& dmarc.subdomainPolicy === 'reject'
|
|
510
|
+
&& dmarc.nonexistentSubdomainPolicy === 'reject'
|
|
511
|
+
&& !dmarc.testMode,
|
|
512
|
+
};
|
|
513
|
+
const unavailable = Boolean(input.spf.error || input.dmarc.error || input.mx.error);
|
|
514
|
+
const passed = Object.values(requirements).filter(Boolean).length;
|
|
515
|
+
return check('defensive_mail_profile', profile === 'parked' ? 'Parked-domain mail posture' : 'Defensive no-mail posture', unavailable
|
|
516
|
+
? 'info'
|
|
517
|
+
: passed === 3
|
|
518
|
+
? 'pass'
|
|
519
|
+
: 'warning', unavailable
|
|
520
|
+
? 'The defensive mail profile could not be fully evaluated'
|
|
521
|
+
: passed === 3
|
|
522
|
+
? 'Null MX, restrictive SPF, and rejecting DMARC are observed'
|
|
523
|
+
: `${passed}/3 defensive mail controls are observed`, {
|
|
524
|
+
detail: `Null MX: ${requirements.nullMx ? 'observed' : 'not observed'}. Restrictive SPF without sender dependencies: ${requirements.restrictiveSpf ? 'observed' : 'not observed'}. Rejecting DMARC for domain and subdomains: ${requirements.rejectingDmarc ? 'observed' : 'not observed'}.`,
|
|
525
|
+
remediation: unavailable || passed === 3
|
|
526
|
+
? ''
|
|
527
|
+
: 'For a domain that should not send or receive mail, review null MX, v=spf1 -all, and enforced DMARC reject policy.',
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function registrationLockCheck(registry) {
|
|
531
|
+
if (registry.error)
|
|
532
|
+
return queryFailureCheck('registration_lock', 'Registration controls', registry.error);
|
|
533
|
+
const statuses = registry.statuses.map((status) => status.toLowerCase().replace(/[^a-z]/gu, ''));
|
|
534
|
+
if (statuses.length === 0) {
|
|
535
|
+
return check('registration_lock', 'Registration controls', 'info', 'Registry lock state is unavailable', {
|
|
536
|
+
detail: 'No normalized EPP status was returned. This does not describe registrar account security.',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const transferLocks = statuses.filter((status) => ['clienttransferprohibited', 'servertransferprohibited'].includes(status));
|
|
540
|
+
const changeLocks = statuses.filter((status) => [
|
|
541
|
+
'clientupdateprohibited',
|
|
542
|
+
'serverupdateprohibited',
|
|
543
|
+
'clientdeleteprohibited',
|
|
544
|
+
'serverdeleteprohibited',
|
|
545
|
+
].includes(status));
|
|
546
|
+
if (transferLocks.length === 0) {
|
|
547
|
+
return check('registration_lock', 'Registration controls', 'warning', 'No transfer restriction observed in registry status', {
|
|
548
|
+
detail: 'EPP status is point-in-time registry evidence and does not reveal registrar MFA, registry-lock enrolment, account recovery controls, or every registrar-side lock.',
|
|
549
|
+
records: registry.statuses,
|
|
550
|
+
remediation: 'Confirm the registrar transfer lock, MFA, recovery controls, and registry-lock options directly with the account owner.',
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return check('registration_lock', 'Registration controls', 'pass', 'Transfer restriction observed in registry status', {
|
|
554
|
+
detail: `${changeLocks.length} update or delete restriction${changeLocks.length === 1 ? '' : 's'} also observed. EPP status does not prove registrar account security.`,
|
|
555
|
+
records: registry.statuses,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
function nameserverCheck(query, registry) {
|
|
559
|
+
if (query.error)
|
|
560
|
+
return queryFailureCheck('nameservers', 'Nameserver delegation', query.error);
|
|
561
|
+
const records = query.records.map((record) => String(record || '').toLowerCase().replace(/\.+$/u, '')).filter(Boolean);
|
|
562
|
+
if (records.length === 0) {
|
|
563
|
+
return check('nameservers', 'Nameserver delegation', 'warning', 'No nameserver delegation returned', {
|
|
564
|
+
detail: 'A missing answer may reflect resolver or publication state and is not proof that a provider account or zone is absent.',
|
|
565
|
+
remediation: 'Confirm delegation at the registry and DNS provider before making a change.',
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
const registryNameservers = new Set(registry.nameservers.map((value) => value.toLowerCase().replace(/\.+$/u, '')));
|
|
569
|
+
const differs = registryNameservers.size > 0
|
|
570
|
+
&& (records.some((record) => !registryNameservers.has(record)) || registryNameservers.size !== records.length);
|
|
571
|
+
return check('nameservers', 'Nameserver delegation', differs ? 'warning' : 'pass', differs
|
|
572
|
+
? 'DNS and registry nameserver sets differ'
|
|
573
|
+
: `${records.length} nameserver${records.length === 1 ? '' : 's'} observed`, {
|
|
574
|
+
detail: `${registry.dsRecordCount} registry DS record${registry.dsRecordCount === 1 ? '' : 's'} observed.`
|
|
575
|
+
+ (differs ? ' The difference may be transient or publication-limited and should be reviewed before changing delegation.' : ''),
|
|
576
|
+
records,
|
|
577
|
+
remediation: differs ? 'Verify the intended delegation with both the registry and authoritative DNS provider.' : '',
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
function buildPostureReport(domain, input) {
|
|
581
|
+
const retiredDkim = retiredDkimCheck(input.dkim);
|
|
582
|
+
const defensiveMail = defensiveMailProfileCheck(input.mailProtectionProfile || 'standard', input);
|
|
583
|
+
const checks = [
|
|
584
|
+
spfCheck(input.spf, input.spfExpansion),
|
|
585
|
+
dmarcCheck(input.dmarc, input.dmarcAuthorizations),
|
|
586
|
+
mxCheck(input.mx),
|
|
587
|
+
dnssecCheck(input.dnssec),
|
|
588
|
+
...(input.registry ? [dnssecDelegationConsistencyCheck(input.dnssec, input.registry)] : []),
|
|
589
|
+
caaCheck(input.caa),
|
|
590
|
+
mtaStsCheck(input.mtaStsDns, input.mtaStsPolicy, input.mx),
|
|
591
|
+
tlsRptCheck(input.tlsRpt, input.mx),
|
|
592
|
+
bimiCheck(input.bimi, input.dmarc),
|
|
593
|
+
dkimCheck(input.dkim),
|
|
594
|
+
...(retiredDkim ? [retiredDkim] : []),
|
|
595
|
+
...(defensiveMail ? [defensiveMail] : []),
|
|
596
|
+
...(input.registry ? [registrationLockCheck(input.registry)] : []),
|
|
597
|
+
...(input.registry && input.nameservers ? [nameserverCheck(input.nameservers, input.registry)] : []),
|
|
598
|
+
];
|
|
599
|
+
const summary = { pass: 0, warning: 0, danger: 0, info: 0 };
|
|
600
|
+
for (const item of checks)
|
|
601
|
+
summary[item.status] += 1;
|
|
602
|
+
return { domain, summary, checks };
|
|
603
|
+
}
|
|
604
|
+
async function fetchMtaStsPolicy(domain) {
|
|
605
|
+
const controller = new AbortController();
|
|
606
|
+
const timeout = setTimeout(() => controller.abort(), POLICY_TIMEOUT_MS);
|
|
607
|
+
try {
|
|
608
|
+
const url = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
|
|
609
|
+
const res = await safeFetch(url, {
|
|
610
|
+
signal: controller.signal,
|
|
611
|
+
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; WHOISleuth-Posture/1.0)', Accept: 'text/plain' },
|
|
612
|
+
});
|
|
613
|
+
if (!res.ok) {
|
|
614
|
+
// Not reading this body - release it explicitly instead of leaving an
|
|
615
|
+
// unconsumed stream (and the connection it's tied to) open until
|
|
616
|
+
// undici's own idle-timeout eventually notices.
|
|
617
|
+
await res.body?.cancel().catch(() => { });
|
|
618
|
+
return { text: '', contentType: res.headers.get('content-type'), error: `Policy endpoint returned HTTP ${res.status}.` };
|
|
619
|
+
}
|
|
620
|
+
const body = await readTextCapped(res, MAX_POLICY_BYTES);
|
|
621
|
+
if (body.truncated)
|
|
622
|
+
return { text: '', contentType: res.headers.get('content-type'), error: `Policy exceeds ${MAX_POLICY_BYTES} bytes.` };
|
|
623
|
+
return { text: body.text, contentType: res.headers.get('content-type'), error: null };
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
return {
|
|
627
|
+
text: '',
|
|
628
|
+
contentType: null,
|
|
629
|
+
error: errorRecord(err).name === 'AbortError'
|
|
630
|
+
? 'Policy fetch timed out.'
|
|
631
|
+
: nonEmptyErrorMessage(err, String(err)),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
clearTimeout(timeout);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelectors = [], mailProtectionProfile = 'standard', } = {}) {
|
|
639
|
+
const normalizedDomain = normalizeAuditDomain(domain);
|
|
640
|
+
if (!normalizedDomain)
|
|
641
|
+
throw new Error('Invalid domain name for posture audit.');
|
|
642
|
+
domain = normalizedDomain;
|
|
643
|
+
const selectors = normalizeDkimSelectors(dkimSelectors);
|
|
644
|
+
const retiredSelectors = normalizeDkimSelectors(retiredDkimSelectors)
|
|
645
|
+
.filter((selector) => !selectors.includes(selector))
|
|
646
|
+
.slice(0, Math.max(0, MAX_DKIM_SELECTORS - selectors.length));
|
|
647
|
+
const normalizedMailProfile = normalizeMailProtectionProfile(mailProtectionProfile);
|
|
648
|
+
const [spf, dmarc, mx, nameservers, caa, mtaStsDns, tlsRpt, bimi, dkim, rdap] = await Promise.all([
|
|
649
|
+
resolveDns(`TXT ${domain}`, () => dns.resolveTxt(domain)),
|
|
650
|
+
resolveDns(`TXT _dmarc.${domain}`, () => dns.resolveTxt(`_dmarc.${domain}`)),
|
|
651
|
+
resolveDns(`MX ${domain}`, () => dns.resolveMx(domain)),
|
|
652
|
+
resolveDns(`NS ${domain}`, () => dns.resolveNs(domain)),
|
|
653
|
+
resolveDns(`CAA ${domain}`, () => dns.resolveCaa(domain)),
|
|
654
|
+
resolveDns(`TXT _mta-sts.${domain}`, () => dns.resolveTxt(`_mta-sts.${domain}`)),
|
|
655
|
+
resolveDns(`TXT _smtp._tls.${domain}`, () => dns.resolveTxt(`_smtp._tls.${domain}`)),
|
|
656
|
+
resolveDns(`TXT default._bimi.${domain}`, () => dns.resolveTxt(`default._bimi.${domain}`)),
|
|
657
|
+
Promise.all([
|
|
658
|
+
...selectors.map(async (selector) => ({
|
|
659
|
+
selector,
|
|
660
|
+
retired: false,
|
|
661
|
+
...await resolveDns(`TXT ${selector}._domainkey.${domain}`, () => dns.resolveTxt(`${selector}._domainkey.${domain}`)),
|
|
662
|
+
})),
|
|
663
|
+
...retiredSelectors.map(async (selector) => ({
|
|
664
|
+
selector,
|
|
665
|
+
retired: true,
|
|
666
|
+
...await resolveDns(`TXT ${selector}._domainkey.${domain}`, () => dns.resolveTxt(`${selector}._domainkey.${domain}`)),
|
|
667
|
+
})),
|
|
668
|
+
]),
|
|
669
|
+
fetchRdapRecord('domain', domain).catch((err) => ({
|
|
670
|
+
error: nonEmptyErrorMessage(err, String(err)),
|
|
671
|
+
})),
|
|
672
|
+
]);
|
|
673
|
+
const parsedMtaDns = mtaStsDns.error ? null : parseMtaStsDnsRecords(mtaStsDns.records);
|
|
674
|
+
const enrichmentDeadline = Date.now() + POSTURE_ENRICHMENT_DEADLINE_MS;
|
|
675
|
+
const resolveEnrichmentTxt = (name) => {
|
|
676
|
+
const remaining = Math.max(1, enrichmentDeadline - Date.now());
|
|
677
|
+
if (remaining <= 1)
|
|
678
|
+
return Promise.resolve({ records: [], error: 'The bounded posture-enrichment deadline was reached.' });
|
|
679
|
+
return resolveDns(`TXT ${name}`, () => dns.resolveTxt(name), Math.min(DNS_TIMEOUT_MS, remaining));
|
|
680
|
+
};
|
|
681
|
+
const [mtaStsPolicy, spfExpansion, dmarcAuthorizations] = await Promise.all([
|
|
682
|
+
parsedMtaDns?.valid ? fetchMtaStsPolicy(domain) : Promise.resolve(null),
|
|
683
|
+
expandSpfPolicy(domain, spf, resolveEnrichmentTxt),
|
|
684
|
+
validateDmarcExternalReporting(domain, dmarc, resolveEnrichmentTxt),
|
|
685
|
+
]);
|
|
686
|
+
const dnssec = !rdap
|
|
687
|
+
? { value: null, error: 'RDAP did not return a domain record.' }
|
|
688
|
+
: 'error' in rdap
|
|
689
|
+
? { value: null, error: rdap.error }
|
|
690
|
+
: {
|
|
691
|
+
value: rdap.parsed && 'dnssec' in rdap.parsed ? rdap.parsed.dnssec : null,
|
|
692
|
+
error: null,
|
|
693
|
+
};
|
|
694
|
+
const parsedDomain = rdap && !('error' in rdap) ? rdap.parsed : null;
|
|
695
|
+
const registry = parsedDomain
|
|
696
|
+
? {
|
|
697
|
+
statuses: parsedDomain.statuses,
|
|
698
|
+
nameservers: parsedDomain.nameservers,
|
|
699
|
+
dsRecordCount: parsedDomain.dsData.length,
|
|
700
|
+
dsDataTruncated: parsedDomain.dsDataTruncated,
|
|
701
|
+
error: null,
|
|
702
|
+
}
|
|
703
|
+
: {
|
|
704
|
+
statuses: [],
|
|
705
|
+
nameservers: [],
|
|
706
|
+
dsRecordCount: 0,
|
|
707
|
+
dsDataTruncated: false,
|
|
708
|
+
error: rdap && 'error' in rdap ? rdap.error : 'RDAP did not return normalized domain evidence.',
|
|
709
|
+
};
|
|
710
|
+
const externalDependencies = buildExternalDependencies({
|
|
711
|
+
domain,
|
|
712
|
+
nameservers,
|
|
713
|
+
mx,
|
|
714
|
+
spfExpansion,
|
|
715
|
+
dmarcAuthorizations,
|
|
716
|
+
});
|
|
717
|
+
const report = buildPostureReport(domain, {
|
|
718
|
+
spf,
|
|
719
|
+
dmarc,
|
|
720
|
+
mx,
|
|
721
|
+
nameservers,
|
|
722
|
+
caa,
|
|
723
|
+
mtaStsDns,
|
|
724
|
+
mtaStsPolicy,
|
|
725
|
+
tlsRpt,
|
|
726
|
+
bimi,
|
|
727
|
+
dkim,
|
|
728
|
+
dnssec,
|
|
729
|
+
registry,
|
|
730
|
+
spfExpansion,
|
|
731
|
+
dmarcAuthorizations,
|
|
732
|
+
mailProtectionProfile: normalizedMailProfile,
|
|
733
|
+
});
|
|
734
|
+
return {
|
|
735
|
+
...report,
|
|
736
|
+
checkedAt: new Date().toISOString(),
|
|
737
|
+
dkimSelectors: selectors,
|
|
738
|
+
retiredDkimSelectors: retiredSelectors,
|
|
739
|
+
mailProtectionProfile: normalizedMailProfile,
|
|
740
|
+
spfExpansion,
|
|
741
|
+
dmarcAuthorizations,
|
|
742
|
+
externalDependencies,
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, buildPostureReport, checkDomainPosture, };
|