@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
package/lib/dns-mx.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Pure MX classification shared by deep DNS intelligence and the owned-domain
|
|
2
|
+
// posture audit.
|
|
3
|
+
// Keeping the null-MX rule pure lets it be tested against synthetic record
|
|
4
|
+
// arrays without touching the network or mocking DNS.
|
|
5
|
+
//
|
|
6
|
+
// RFC 7505 "null MX" - a single record pointing at the root domain (".") is
|
|
7
|
+
// a domain explicitly declaring it accepts no mail, not "mail is
|
|
8
|
+
// configured". Node's resolver strips the trailing dot from the target, so
|
|
9
|
+
// the root comes back as "" as well as ".".
|
|
10
|
+
function classifyMxRecords(records) {
|
|
11
|
+
const realRecords = records.filter((record) => record.exchange !== '.' && record.exchange !== '');
|
|
12
|
+
// Distinct from simply having no MX record at all - a null MX is a
|
|
13
|
+
// deliberate "this domain never accepts mail" declaration, not an absence
|
|
14
|
+
// of configuration. Not currently used for scoring (both cases already
|
|
15
|
+
// correctly score as hasMx: false); exposed for callers that want to tell
|
|
16
|
+
// the two apart.
|
|
17
|
+
const hasNullMx = records.length > 0 && realRecords.length === 0;
|
|
18
|
+
return {
|
|
19
|
+
hasMx: realRecords.length > 0,
|
|
20
|
+
hasNullMx,
|
|
21
|
+
mxHosts: realRecords.map((record) => record.exchange),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export { classifyMxRecords };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Resolver } from 'node:dns/promises';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
const MAX_SELECTED_DNS_RESOLVERS = 3;
|
|
4
|
+
const MAX_SELECTED_DNS_RESOLVER_TEXT = 256;
|
|
5
|
+
function normalizeSelectedDnsResolvers(value) {
|
|
6
|
+
if (typeof value !== 'string' || !value.trim() || value.length > MAX_SELECTED_DNS_RESOLVER_TEXT) {
|
|
7
|
+
throw new TypeError(`DNS resolver selection must contain at most ${MAX_SELECTED_DNS_RESOLVER_TEXT} characters.`);
|
|
8
|
+
}
|
|
9
|
+
const values = [...new Set(value.split(',').map((item) => item.trim()).filter(Boolean))];
|
|
10
|
+
if (!values.length || values.length > MAX_SELECTED_DNS_RESOLVERS || values.some((item) => isIP(item) === 0)) {
|
|
11
|
+
throw new TypeError(`DNS resolver selection requires 1 to ${MAX_SELECTED_DNS_RESOLVERS} literal IPv4 or IPv6 addresses.`);
|
|
12
|
+
}
|
|
13
|
+
return values;
|
|
14
|
+
}
|
|
15
|
+
function createSelectedDnsResolvers(servers) {
|
|
16
|
+
if (!Array.isArray(servers) || !servers.length || servers.length > MAX_SELECTED_DNS_RESOLVERS
|
|
17
|
+
|| servers.some((item) => typeof item !== 'string' || isIP(item) === 0)) {
|
|
18
|
+
throw new TypeError('Validated DNS resolver addresses are required.');
|
|
19
|
+
}
|
|
20
|
+
const resolver = new Resolver();
|
|
21
|
+
resolver.setServers([...servers]);
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
resolve4: (value) => resolver.resolve4(value),
|
|
24
|
+
resolve6: (value) => resolver.resolve6(value),
|
|
25
|
+
resolveCname: (value) => resolver.resolveCname(value),
|
|
26
|
+
resolveNs: (value) => resolver.resolveNs(value),
|
|
27
|
+
resolveMx: (value) => resolver.resolveMx(value),
|
|
28
|
+
resolveTxt: (value) => resolver.resolveTxt(value),
|
|
29
|
+
resolveCaa: (value) => resolver.resolveCaa(value),
|
|
30
|
+
resolveSoa: (value) => resolver.resolveSoa(value),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export { MAX_SELECTED_DNS_RESOLVERS, MAX_SELECTED_DNS_RESOLVER_TEXT, createSelectedDnsResolvers, normalizeSelectedDnsResolvers, };
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// Bounded analysis for owned-domain posture evidence. Network access is
|
|
2
|
+
// injected so every traversal can be fixture-tested and the caller retains the
|
|
3
|
+
// existing DNS timeout and missing/error semantics.
|
|
4
|
+
import { parse as parseDomain } from 'tldts';
|
|
5
|
+
import { parseDmarcRecords, parseSpfRecords } from "./domain-posture-parsers.mjs";
|
|
6
|
+
const SPF_LOOKUP_LIMIT = 10;
|
|
7
|
+
const SPF_VOID_LOOKUP_LIMIT = 2;
|
|
8
|
+
const SPF_MAX_DEPTH = 5;
|
|
9
|
+
const SPF_MAX_BRANCHES = 32;
|
|
10
|
+
const MAX_DMARC_DESTINATIONS = 10;
|
|
11
|
+
const MAX_DEPENDENCIES = 64;
|
|
12
|
+
function strictHostname(value) {
|
|
13
|
+
const raw = String(value || '').trim().toLowerCase().replace(/\.+$/u, '');
|
|
14
|
+
if (!raw || raw.length > 253 || raw.includes('%'))
|
|
15
|
+
return null;
|
|
16
|
+
if (!raw.split('.').every((label) => /^(?:_?[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?)$/iu.test(label)))
|
|
17
|
+
return null;
|
|
18
|
+
return raw.includes('.') ? raw : null;
|
|
19
|
+
}
|
|
20
|
+
function branchFor(item, state, terminalPolicy, dnsLookupTerms, issues) {
|
|
21
|
+
return {
|
|
22
|
+
domain: item.domain,
|
|
23
|
+
parent: item.parent,
|
|
24
|
+
relation: item.relation,
|
|
25
|
+
depth: item.depth,
|
|
26
|
+
state,
|
|
27
|
+
terminalPolicy,
|
|
28
|
+
dnsLookupTerms,
|
|
29
|
+
issues: issues.slice(0, 12),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
33
|
+
const queue = [{
|
|
34
|
+
domain: rootDomain,
|
|
35
|
+
parent: null,
|
|
36
|
+
relation: 'root',
|
|
37
|
+
depth: 0,
|
|
38
|
+
path: [rootDomain],
|
|
39
|
+
records: rootQuery.records,
|
|
40
|
+
error: rootQuery.error,
|
|
41
|
+
}];
|
|
42
|
+
const branches = [];
|
|
43
|
+
const issues = [];
|
|
44
|
+
let lookupsUsed = 1;
|
|
45
|
+
let voidLookups = 0;
|
|
46
|
+
let dnsLookupTerms = 0;
|
|
47
|
+
let boundReached = false;
|
|
48
|
+
while (queue.length > 0 && branches.length < SPF_MAX_BRANCHES) {
|
|
49
|
+
const item = queue.shift();
|
|
50
|
+
if (!item)
|
|
51
|
+
break;
|
|
52
|
+
if (item.depth > SPF_MAX_DEPTH) {
|
|
53
|
+
boundReached = true;
|
|
54
|
+
branches.push(branchFor(item, 'limit', null, 0, [`Maximum SPF expansion depth ${SPF_MAX_DEPTH} was reached.`]));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (item.path.slice(0, -1).includes(item.domain)) {
|
|
58
|
+
branches.push(branchFor(item, 'cycle', null, 0, ['The include or redirect chain contains a cycle.']));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
let query;
|
|
62
|
+
if (item.records !== undefined) {
|
|
63
|
+
query = { records: item.records, error: item.error || null };
|
|
64
|
+
}
|
|
65
|
+
else if (lookupsUsed >= SPF_LOOKUP_LIMIT) {
|
|
66
|
+
boundReached = true;
|
|
67
|
+
branches.push(branchFor(item, 'limit', null, 0, [`Maximum SPF policy-query budget ${SPF_LOOKUP_LIMIT} was reached.`]));
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
lookupsUsed += 1;
|
|
72
|
+
query = await resolveTxt(item.domain);
|
|
73
|
+
}
|
|
74
|
+
if (query.error) {
|
|
75
|
+
branches.push(branchFor(item, 'unavailable', null, 0, [query.error]));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (query.records.length === 0) {
|
|
79
|
+
voidLookups += 1;
|
|
80
|
+
branches.push(branchFor(item, 'not_found', null, 0, ['No SPF policy record was returned for this branch.']));
|
|
81
|
+
if (voidLookups > SPF_VOID_LOOKUP_LIMIT) {
|
|
82
|
+
boundReached = true;
|
|
83
|
+
issues.push(`The SPF expansion exceeded the ${SPF_VOID_LOOKUP_LIMIT}-answer void-lookup budget.`);
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const parsed = parseSpfRecords(query.records);
|
|
88
|
+
dnsLookupTerms += parsed.dnsLookupTerms;
|
|
89
|
+
branches.push(branchFor(item, parsed.valid ? 'success' : 'invalid', parsed.terminalPolicy, parsed.dnsLookupTerms, parsed.issues));
|
|
90
|
+
if (!parsed.valid)
|
|
91
|
+
continue;
|
|
92
|
+
if (dnsLookupTerms > SPF_LOOKUP_LIMIT) {
|
|
93
|
+
boundReached = true;
|
|
94
|
+
issues.push(`The expanded policy declares more than ${SPF_LOOKUP_LIMIT} DNS-querying terms.`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (voidLookups > SPF_VOID_LOOKUP_LIMIT)
|
|
98
|
+
continue;
|
|
99
|
+
const dependencies = [
|
|
100
|
+
...parsed.includes.map((domain) => ({ relation: 'include', domain })),
|
|
101
|
+
...(parsed.redirect ? [{ relation: 'redirect', domain: parsed.redirect }] : []),
|
|
102
|
+
];
|
|
103
|
+
for (const dependency of dependencies) {
|
|
104
|
+
const normalized = strictHostname(dependency.domain);
|
|
105
|
+
if (!normalized) {
|
|
106
|
+
branches.push(branchFor({
|
|
107
|
+
domain: String(dependency.domain || '').slice(0, 253),
|
|
108
|
+
parent: item.domain,
|
|
109
|
+
relation: dependency.relation,
|
|
110
|
+
depth: item.depth + 1,
|
|
111
|
+
path: item.path,
|
|
112
|
+
}, 'invalid', null, 0, ['The SPF dependency is not a literal public hostname; macros are not expanded by this audit.']));
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
queue.push({
|
|
116
|
+
domain: normalized,
|
|
117
|
+
parent: item.domain,
|
|
118
|
+
relation: dependency.relation,
|
|
119
|
+
depth: item.depth + 1,
|
|
120
|
+
path: [...item.path, normalized],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (queue.length > 0 || branches.length >= SPF_MAX_BRANCHES) {
|
|
125
|
+
boundReached = true;
|
|
126
|
+
issues.push(`The SPF branch inventory was capped at ${SPF_MAX_BRANCHES} entries.`);
|
|
127
|
+
}
|
|
128
|
+
const root = branches[0];
|
|
129
|
+
const incomplete = boundReached || branches.some((branch) => !['success'].includes(branch.state));
|
|
130
|
+
const state = root?.state === 'unavailable'
|
|
131
|
+
? 'unavailable'
|
|
132
|
+
: root?.state === 'invalid'
|
|
133
|
+
? 'invalid'
|
|
134
|
+
: incomplete
|
|
135
|
+
? 'partial'
|
|
136
|
+
: 'complete';
|
|
137
|
+
return {
|
|
138
|
+
version: 1,
|
|
139
|
+
state,
|
|
140
|
+
lookupLimit: SPF_LOOKUP_LIMIT,
|
|
141
|
+
lookupsUsed,
|
|
142
|
+
voidLookupLimit: SPF_VOID_LOOKUP_LIMIT,
|
|
143
|
+
voidLookups,
|
|
144
|
+
maxDepth: SPF_MAX_DEPTH,
|
|
145
|
+
dnsLookupTerms,
|
|
146
|
+
branches,
|
|
147
|
+
issues: [...new Set(issues)].slice(0, 12),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function reportDestinationDomain(value) {
|
|
151
|
+
const raw = String(value || '').trim();
|
|
152
|
+
if (!/^mailto:/iu.test(raw))
|
|
153
|
+
return null;
|
|
154
|
+
const address = raw.slice(7).split('!')[0] || '';
|
|
155
|
+
const at = address.lastIndexOf('@');
|
|
156
|
+
return at > 0 ? strictHostname(address.slice(at + 1)) : null;
|
|
157
|
+
}
|
|
158
|
+
async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt) {
|
|
159
|
+
if (dmarcQuery.error)
|
|
160
|
+
return [];
|
|
161
|
+
const parsed = parseDmarcRecords(dmarcQuery.records);
|
|
162
|
+
if (!parsed.valid)
|
|
163
|
+
return [];
|
|
164
|
+
const destinations = [
|
|
165
|
+
...parsed.aggregateDestinations.map((destination) => ({ destination, reportType: 'aggregate' })),
|
|
166
|
+
...parsed.failureDestinations.map((destination) => ({ destination, reportType: 'failure' })),
|
|
167
|
+
].slice(0, MAX_DMARC_DESTINATIONS);
|
|
168
|
+
const results = [];
|
|
169
|
+
const cache = new Map();
|
|
170
|
+
for (const item of destinations) {
|
|
171
|
+
const destinationDomain = reportDestinationDomain(item.destination);
|
|
172
|
+
if (!destinationDomain) {
|
|
173
|
+
results.push({
|
|
174
|
+
destination: item.destination.slice(0, 512),
|
|
175
|
+
reportType: item.reportType,
|
|
176
|
+
recordName: null,
|
|
177
|
+
state: 'invalid_destination',
|
|
178
|
+
error: null,
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (destinationDomain === ownerDomain || destinationDomain.endsWith(`.${ownerDomain}`)) {
|
|
183
|
+
results.push({
|
|
184
|
+
destination: destinationDomain,
|
|
185
|
+
reportType: item.reportType,
|
|
186
|
+
recordName: null,
|
|
187
|
+
state: 'self',
|
|
188
|
+
error: null,
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const recordName = `${ownerDomain}._report._dmarc.${destinationDomain}`;
|
|
193
|
+
let query = cache.get(recordName);
|
|
194
|
+
if (!query) {
|
|
195
|
+
query = await resolveTxt(recordName);
|
|
196
|
+
cache.set(recordName, query);
|
|
197
|
+
}
|
|
198
|
+
const authorized = query.records.some((record) => (String(Array.isArray(record) ? record.join('') : record || '').trim().toUpperCase().startsWith('V=DMARC1')));
|
|
199
|
+
results.push({
|
|
200
|
+
destination: destinationDomain,
|
|
201
|
+
reportType: item.reportType,
|
|
202
|
+
recordName,
|
|
203
|
+
state: query.error ? 'unavailable' : authorized ? 'authorized' : 'not_found',
|
|
204
|
+
error: query.error,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return results;
|
|
208
|
+
}
|
|
209
|
+
function dependencyScope(ownerDomain, target) {
|
|
210
|
+
const owner = parseDomain(ownerDomain).domain;
|
|
211
|
+
const dependency = parseDomain(target).domain;
|
|
212
|
+
if (!owner || !dependency)
|
|
213
|
+
return 'unknown';
|
|
214
|
+
return owner === dependency ? 'same_registrable_domain' : 'external';
|
|
215
|
+
}
|
|
216
|
+
function buildExternalDependencies({ domain, nameservers, mx, spfExpansion, dmarcAuthorizations, }) {
|
|
217
|
+
const output = [];
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
const add = (kind, targetValue, source, state) => {
|
|
220
|
+
const target = strictHostname(targetValue);
|
|
221
|
+
if (!target)
|
|
222
|
+
return;
|
|
223
|
+
const key = `${kind}:${target}`;
|
|
224
|
+
if (seen.has(key) || output.length >= MAX_DEPENDENCIES)
|
|
225
|
+
return;
|
|
226
|
+
seen.add(key);
|
|
227
|
+
output.push({
|
|
228
|
+
kind,
|
|
229
|
+
target,
|
|
230
|
+
source,
|
|
231
|
+
scope: dependencyScope(domain, target),
|
|
232
|
+
state,
|
|
233
|
+
limitation: 'A shared or external dependency is an operational review lead, not evidence of common ownership, insecurity, exploitability, or availability.',
|
|
234
|
+
});
|
|
235
|
+
};
|
|
236
|
+
for (const nameserver of nameservers.records)
|
|
237
|
+
add('nameserver', nameserver, 'DNS NS', nameservers.error ? 'unavailable' : 'observed');
|
|
238
|
+
for (const record of mx.records) {
|
|
239
|
+
const exchange = record && typeof record === 'object' && !Array.isArray(record)
|
|
240
|
+
? record.exchange
|
|
241
|
+
: null;
|
|
242
|
+
add('mail_exchange', exchange, 'DNS MX', mx.error ? 'unavailable' : 'observed');
|
|
243
|
+
}
|
|
244
|
+
for (const branch of spfExpansion.branches) {
|
|
245
|
+
if (branch.relation === 'root')
|
|
246
|
+
continue;
|
|
247
|
+
add(branch.relation === 'include' ? 'spf_include' : 'spf_redirect', branch.domain, `SPF ${branch.relation}`, branch.state === 'success' ? 'observed' : 'unavailable');
|
|
248
|
+
}
|
|
249
|
+
for (const authorization of dmarcAuthorizations) {
|
|
250
|
+
add('dmarc_reporting', authorization.destination, `DMARC ${authorization.reportType} reporting`, ['authorized', 'self'].includes(authorization.state) ? 'observed' : 'unavailable');
|
|
251
|
+
}
|
|
252
|
+
return output;
|
|
253
|
+
}
|
|
254
|
+
export { SPF_LOOKUP_LIMIT, SPF_MAX_DEPTH, SPF_VOID_LOOKUP_LIMIT, buildExternalDependencies, expandSpfPolicy, reportDestinationDomain, validateDmarcExternalReporting, };
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// Pure parsers for the DNS policies used by the owned-domain posture audit.
|
|
2
|
+
// Kept separate from DNS/network I/O so policy edge cases can be tested with
|
|
3
|
+
// synthetic records and the Express/Netlify paths share identical behavior.
|
|
4
|
+
import { createPublicKey } from 'node:crypto';
|
|
5
|
+
function joinTxtRecords(records) {
|
|
6
|
+
if (!Array.isArray(records))
|
|
7
|
+
return [];
|
|
8
|
+
return records
|
|
9
|
+
.map((record) => Array.isArray(record) ? record.join('') : String(record || ''))
|
|
10
|
+
.map((record) => record.trim())
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
}
|
|
13
|
+
function parseTagList(record) {
|
|
14
|
+
const tags = {};
|
|
15
|
+
const duplicates = [];
|
|
16
|
+
const malformed = [];
|
|
17
|
+
for (const part of String(record || '').split(';')) {
|
|
18
|
+
const trimmed = part.trim();
|
|
19
|
+
if (!trimmed)
|
|
20
|
+
continue;
|
|
21
|
+
const equals = trimmed.indexOf('=');
|
|
22
|
+
if (equals <= 0) {
|
|
23
|
+
malformed.push(trimmed);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const key = trimmed.slice(0, equals).trim().toLowerCase();
|
|
27
|
+
const value = trimmed.slice(equals + 1).trim();
|
|
28
|
+
if (!/^[a-z][a-z0-9_.-]*$/i.test(key)) {
|
|
29
|
+
malformed.push(trimmed);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (Object.prototype.hasOwnProperty.call(tags, key))
|
|
33
|
+
duplicates.push(key);
|
|
34
|
+
else
|
|
35
|
+
tags[key] = value;
|
|
36
|
+
}
|
|
37
|
+
return { tags, duplicates, malformed };
|
|
38
|
+
}
|
|
39
|
+
function parseSpfRecords(records) {
|
|
40
|
+
const allRecords = joinTxtRecords(records);
|
|
41
|
+
const spfRecords = allRecords.filter((record) => /^v=spf1(?:\s|$)/i.test(record));
|
|
42
|
+
const result = {
|
|
43
|
+
records: spfRecords,
|
|
44
|
+
valid: false,
|
|
45
|
+
record: spfRecords[0] || null,
|
|
46
|
+
terminalPolicy: null,
|
|
47
|
+
redirect: null,
|
|
48
|
+
includes: [],
|
|
49
|
+
dnsLookupTerms: 0,
|
|
50
|
+
issues: [],
|
|
51
|
+
};
|
|
52
|
+
if (spfRecords.length === 0) {
|
|
53
|
+
result.issues.push('No SPF record was found.');
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
if (spfRecords.length > 1) {
|
|
57
|
+
result.issues.push('Multiple SPF records produce a permanent SPF error.');
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
const record = spfRecords[0];
|
|
61
|
+
if (!record)
|
|
62
|
+
return result;
|
|
63
|
+
const terms = record.split(/\s+/).slice(1).filter(Boolean);
|
|
64
|
+
const allIndex = terms.findIndex((term) => /^[+?~-]?all$/i.test(term));
|
|
65
|
+
const allTerm = allIndex === -1 ? null : terms[allIndex];
|
|
66
|
+
const redirectTerm = terms.find((term) => /^redirect=/i.test(term));
|
|
67
|
+
const lookupTermRe = /^[+?~-]?(?:include:|a(?::|\/|$)|mx(?::|\/|$)|ptr(?::|$)|exists:)/i;
|
|
68
|
+
result.dnsLookupTerms = terms.filter((term) => lookupTermRe.test(term)).length + (redirectTerm ? 1 : 0);
|
|
69
|
+
result.redirect = redirectTerm ? redirectTerm.slice(redirectTerm.indexOf('=') + 1) : null;
|
|
70
|
+
result.includes = terms
|
|
71
|
+
.map((term) => term.match(/^[+?~-]?include:(.+)$/i)?.[1] || '')
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
if (terms.some((term) => /^[+?~-]?ptr(?::|$)/i.test(term))) {
|
|
74
|
+
result.issues.push('The deprecated ptr mechanism should not be used.');
|
|
75
|
+
}
|
|
76
|
+
if (result.dnsLookupTerms > 10) {
|
|
77
|
+
result.issues.push('The record has more than 10 top-level DNS-querying terms.');
|
|
78
|
+
}
|
|
79
|
+
if (allTerm) {
|
|
80
|
+
const qualifier = /^[?~-]/.test(allTerm) ? (allTerm[0] ?? '+') : '+';
|
|
81
|
+
result.terminalPolicy = { '-': 'fail', '~': 'softfail', '?': 'neutral', '+': 'pass' }[qualifier] || null;
|
|
82
|
+
if (allIndex !== terms.length - 1)
|
|
83
|
+
result.issues.push('Terms after the all mechanism are unreachable.');
|
|
84
|
+
}
|
|
85
|
+
else if (result.redirect) {
|
|
86
|
+
result.terminalPolicy = 'redirect';
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
result.terminalPolicy = 'none';
|
|
90
|
+
result.issues.push('No all mechanism or redirect modifier defines a terminal policy.');
|
|
91
|
+
}
|
|
92
|
+
result.valid = true;
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
function parseDmarcRecords(records) {
|
|
96
|
+
const allRecords = joinTxtRecords(records);
|
|
97
|
+
const dmarcRecords = allRecords.filter((record) => /^v\s*=\s*dmarc1(?:\s*;|$)/i.test(record));
|
|
98
|
+
const result = {
|
|
99
|
+
records: dmarcRecords,
|
|
100
|
+
valid: false,
|
|
101
|
+
record: dmarcRecords[0] || null,
|
|
102
|
+
tags: {},
|
|
103
|
+
policy: null,
|
|
104
|
+
subdomainPolicy: null,
|
|
105
|
+
nonexistentSubdomainPolicy: null,
|
|
106
|
+
testMode: false,
|
|
107
|
+
enforced: false,
|
|
108
|
+
aggregateReporting: false,
|
|
109
|
+
failureReporting: false,
|
|
110
|
+
aggregateDestinations: [],
|
|
111
|
+
failureDestinations: [],
|
|
112
|
+
legacyPct: null,
|
|
113
|
+
issues: [],
|
|
114
|
+
};
|
|
115
|
+
if (dmarcRecords.length === 0) {
|
|
116
|
+
result.issues.push('No DMARC policy record was found.');
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
if (dmarcRecords.length > 1) {
|
|
120
|
+
result.issues.push('Multiple DMARC policy records make policy discovery fail.');
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
const parsed = parseTagList(dmarcRecords[0]);
|
|
124
|
+
result.tags = parsed.tags;
|
|
125
|
+
if (parsed.duplicates.length)
|
|
126
|
+
result.issues.push(`Duplicate DMARC tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
|
|
127
|
+
if (parsed.malformed.length)
|
|
128
|
+
result.issues.push(`Malformed DMARC field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
|
|
129
|
+
const allowedPolicies = new Set(['none', 'quarantine', 'reject']);
|
|
130
|
+
const policy = (parsed.tags.p || 'none').toLowerCase();
|
|
131
|
+
const subdomainPolicy = (parsed.tags.sp || policy).toLowerCase();
|
|
132
|
+
const nonexistentPolicy = (parsed.tags.np || parsed.tags.sp || policy).toLowerCase();
|
|
133
|
+
if (!allowedPolicies.has(policy))
|
|
134
|
+
result.issues.push(`Unsupported DMARC p value: ${parsed.tags.p}.`);
|
|
135
|
+
if (!allowedPolicies.has(subdomainPolicy))
|
|
136
|
+
result.issues.push(`Unsupported DMARC sp value: ${parsed.tags.sp}.`);
|
|
137
|
+
if (!allowedPolicies.has(nonexistentPolicy))
|
|
138
|
+
result.issues.push(`Unsupported DMARC np value: ${parsed.tags.np}.`);
|
|
139
|
+
if (parsed.tags.t && !/^[yn]$/i.test(parsed.tags.t))
|
|
140
|
+
result.issues.push(`Unsupported DMARC t value: ${parsed.tags.t}.`);
|
|
141
|
+
if (parsed.tags.pct !== undefined) {
|
|
142
|
+
const pct = Number(parsed.tags.pct);
|
|
143
|
+
result.legacyPct = Number.isInteger(pct) && pct >= 0 && pct <= 100 ? pct : null;
|
|
144
|
+
result.issues.push('The legacy pct tag is historic in the current DMARC specification.');
|
|
145
|
+
}
|
|
146
|
+
result.policy = allowedPolicies.has(policy) ? policy : null;
|
|
147
|
+
result.subdomainPolicy = allowedPolicies.has(subdomainPolicy) ? subdomainPolicy : null;
|
|
148
|
+
result.nonexistentSubdomainPolicy = allowedPolicies.has(nonexistentPolicy) ? nonexistentPolicy : null;
|
|
149
|
+
result.testMode = /^y$/i.test(parsed.tags.t || 'n');
|
|
150
|
+
result.enforced = Boolean(result.policy && result.policy !== 'none' && !result.testMode);
|
|
151
|
+
result.aggregateReporting = Boolean(parsed.tags.rua);
|
|
152
|
+
result.failureReporting = Boolean(parsed.tags.ruf);
|
|
153
|
+
result.aggregateDestinations = (parsed.tags.rua || '').split(',').map((value) => value.trim()).filter(Boolean);
|
|
154
|
+
result.failureDestinations = (parsed.tags.ruf || '').split(',').map((value) => value.trim()).filter(Boolean);
|
|
155
|
+
result.valid = parsed.duplicates.length === 0
|
|
156
|
+
&& parsed.malformed.length === 0
|
|
157
|
+
&& Boolean(result.policy && result.subdomainPolicy && result.nonexistentSubdomainPolicy)
|
|
158
|
+
&& (!parsed.tags.t || /^[yn]$/i.test(parsed.tags.t));
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
function parseMtaStsDnsRecords(records) {
|
|
162
|
+
const allRecords = joinTxtRecords(records);
|
|
163
|
+
const policyRecords = allRecords.filter((record) => /^v\s*=\s*stsv1(?:\s*;|$)/i.test(record));
|
|
164
|
+
const result = { records: policyRecords, valid: false, record: policyRecords[0] || null, id: null, issues: [] };
|
|
165
|
+
if (policyRecords.length === 0) {
|
|
166
|
+
result.issues.push('No MTA-STS DNS record was found.');
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
169
|
+
if (policyRecords.length > 1) {
|
|
170
|
+
result.issues.push('Multiple MTA-STS DNS records make the policy invalid.');
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
const parsed = parseTagList(policyRecords[0]);
|
|
174
|
+
result.id = parsed.tags.id || null;
|
|
175
|
+
if (!result.id)
|
|
176
|
+
result.issues.push('The MTA-STS DNS record has no policy id.');
|
|
177
|
+
if (parsed.duplicates.length)
|
|
178
|
+
result.issues.push(`Duplicate MTA-STS tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
|
|
179
|
+
if (parsed.malformed.length)
|
|
180
|
+
result.issues.push(`Malformed MTA-STS field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
|
|
181
|
+
result.valid = Boolean(result.id) && parsed.duplicates.length === 0 && parsed.malformed.length === 0;
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
function parseMtaStsPolicy(text) {
|
|
185
|
+
const fields = {};
|
|
186
|
+
const mx = [];
|
|
187
|
+
const duplicates = [];
|
|
188
|
+
const malformed = [];
|
|
189
|
+
for (const line of String(text || '').split(/\r?\n/)) {
|
|
190
|
+
const trimmed = line.trim();
|
|
191
|
+
if (!trimmed)
|
|
192
|
+
continue;
|
|
193
|
+
const colon = trimmed.indexOf(':');
|
|
194
|
+
if (colon <= 0) {
|
|
195
|
+
malformed.push(trimmed);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const key = trimmed.slice(0, colon).trim().toLowerCase();
|
|
199
|
+
const value = trimmed.slice(colon + 1).trim();
|
|
200
|
+
if (key === 'mx')
|
|
201
|
+
mx.push(value.toLowerCase().replace(/\.+$/, ''));
|
|
202
|
+
else if (Object.prototype.hasOwnProperty.call(fields, key))
|
|
203
|
+
duplicates.push(key);
|
|
204
|
+
else
|
|
205
|
+
fields[key] = value;
|
|
206
|
+
}
|
|
207
|
+
const mode = (fields.mode || '').toLowerCase();
|
|
208
|
+
const maxAge = /^\d+$/.test(fields.max_age || '') ? Number(fields.max_age) : null;
|
|
209
|
+
const issues = [];
|
|
210
|
+
if (fields.version !== 'STSv1')
|
|
211
|
+
issues.push('The policy version must be STSv1.');
|
|
212
|
+
if (!['enforce', 'testing', 'none'].includes(mode))
|
|
213
|
+
issues.push('The policy mode must be enforce, testing, or none.');
|
|
214
|
+
if (maxAge === null || !Number.isSafeInteger(maxAge))
|
|
215
|
+
issues.push('The policy max_age must be a non-negative integer.');
|
|
216
|
+
if (mode !== 'none' && mx.length === 0)
|
|
217
|
+
issues.push('An enforcing or testing policy needs at least one mx pattern.');
|
|
218
|
+
if (duplicates.length)
|
|
219
|
+
issues.push(`Duplicate MTA-STS policy field${duplicates.length === 1 ? '' : 's'}: ${duplicates.join(', ')}.`);
|
|
220
|
+
if (malformed.length)
|
|
221
|
+
issues.push(`Malformed MTA-STS policy line${malformed.length === 1 ? '' : 's'}: ${malformed.join(', ')}.`);
|
|
222
|
+
return {
|
|
223
|
+
valid: issues.length === 0,
|
|
224
|
+
version: fields.version || null,
|
|
225
|
+
mode: mode || null,
|
|
226
|
+
maxAge,
|
|
227
|
+
mx,
|
|
228
|
+
issues,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
function parseTlsRptRecords(records) {
|
|
232
|
+
const allRecords = joinTxtRecords(records);
|
|
233
|
+
const policyRecords = allRecords.filter((record) => /^v\s*=\s*tlsrptv1(?:\s*;|$)/i.test(record));
|
|
234
|
+
const result = { records: policyRecords, valid: false, record: policyRecords[0] || null, rua: [], issues: [] };
|
|
235
|
+
if (policyRecords.length === 0) {
|
|
236
|
+
result.issues.push('No TLS-RPT policy record was found.');
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
if (policyRecords.length > 1) {
|
|
240
|
+
result.issues.push('Multiple TLS-RPT policy records make reporting invalid.');
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
const parsed = parseTagList(policyRecords[0]);
|
|
244
|
+
result.rua = (parsed.tags.rua || '').split(',').map((value) => value.trim()).filter(Boolean);
|
|
245
|
+
if (result.rua.length === 0)
|
|
246
|
+
result.issues.push('The TLS-RPT record has no aggregate report destination.');
|
|
247
|
+
if (parsed.duplicates.length)
|
|
248
|
+
result.issues.push(`Duplicate TLS-RPT tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
|
|
249
|
+
if (parsed.malformed.length)
|
|
250
|
+
result.issues.push(`Malformed TLS-RPT field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
|
|
251
|
+
result.valid = result.rua.length > 0 && parsed.duplicates.length === 0 && parsed.malformed.length === 0;
|
|
252
|
+
return result;
|
|
253
|
+
}
|
|
254
|
+
function parseBimiRecords(records) {
|
|
255
|
+
const allRecords = joinTxtRecords(records);
|
|
256
|
+
const bimiRecords = allRecords.filter((record) => /^v\s*=\s*bimi1(?:\s*;|$)/i.test(record));
|
|
257
|
+
const result = { records: bimiRecords, valid: false, record: bimiRecords[0] || null, logo: null, authority: null, issues: [] };
|
|
258
|
+
if (bimiRecords.length === 0) {
|
|
259
|
+
result.issues.push('No default-selector BIMI record was found.');
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
if (bimiRecords.length > 1) {
|
|
263
|
+
result.issues.push('Multiple BIMI records were found at the default selector.');
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
const parsed = parseTagList(bimiRecords[0]);
|
|
267
|
+
result.logo = parsed.tags.l || null;
|
|
268
|
+
result.authority = parsed.tags.a || null;
|
|
269
|
+
if (!result.logo)
|
|
270
|
+
result.issues.push('The BIMI record has no logo location.');
|
|
271
|
+
if (result.logo && !/^https:\/\//i.test(result.logo))
|
|
272
|
+
result.issues.push('The BIMI logo location must use HTTPS.');
|
|
273
|
+
if (result.authority && !/^https:\/\//i.test(result.authority))
|
|
274
|
+
result.issues.push('The BIMI authority evidence location must use HTTPS.');
|
|
275
|
+
if (parsed.duplicates.length)
|
|
276
|
+
result.issues.push(`Duplicate BIMI tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
|
|
277
|
+
if (parsed.malformed.length)
|
|
278
|
+
result.issues.push(`Malformed BIMI field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
|
|
279
|
+
result.valid = result.issues.length === 0;
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
function parseDkimRecords(selector, records) {
|
|
283
|
+
const allRecords = joinTxtRecords(records);
|
|
284
|
+
const result = {
|
|
285
|
+
selector,
|
|
286
|
+
records: allRecords,
|
|
287
|
+
valid: false,
|
|
288
|
+
keyType: null,
|
|
289
|
+
keyBits: null,
|
|
290
|
+
keyParseState: 'not_checked',
|
|
291
|
+
revoked: false,
|
|
292
|
+
testing: false,
|
|
293
|
+
issues: [],
|
|
294
|
+
};
|
|
295
|
+
if (allRecords.length === 0) {
|
|
296
|
+
result.issues.push(`No DKIM record was found for selector ${selector}.`);
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
if (allRecords.length > 1) {
|
|
300
|
+
result.issues.push(`Multiple TXT records were found for DKIM selector ${selector}.`);
|
|
301
|
+
return result;
|
|
302
|
+
}
|
|
303
|
+
const parsed = parseTagList(allRecords[0]);
|
|
304
|
+
const version = parsed.tags.v || 'DKIM1';
|
|
305
|
+
result.keyType = (parsed.tags.k || 'rsa').toLowerCase();
|
|
306
|
+
result.revoked = parsed.tags.p === '';
|
|
307
|
+
result.testing = (parsed.tags.t || '').split(':').includes('y');
|
|
308
|
+
if (version.toUpperCase() !== 'DKIM1')
|
|
309
|
+
result.issues.push(`Unsupported DKIM version: ${version}.`);
|
|
310
|
+
if (!Object.prototype.hasOwnProperty.call(parsed.tags, 'p'))
|
|
311
|
+
result.issues.push('The DKIM record has no public-key p tag.');
|
|
312
|
+
if (result.revoked)
|
|
313
|
+
result.issues.push('The DKIM key is revoked (empty p tag).');
|
|
314
|
+
if (!result.revoked
|
|
315
|
+
&& Object.prototype.hasOwnProperty.call(parsed.tags, 'p')
|
|
316
|
+
&& parsed.tags.p) {
|
|
317
|
+
const encodedKey = parsed.tags.p.replace(/\s+/g, '');
|
|
318
|
+
if (encodedKey.length > 16_384 || !/^[A-Za-z0-9+/]+={0,2}$/.test(encodedKey)) {
|
|
319
|
+
result.keyParseState = 'invalid';
|
|
320
|
+
result.issues.push('The DKIM public key is not bounded valid base64.');
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
try {
|
|
324
|
+
const key = createPublicKey({
|
|
325
|
+
key: Buffer.from(encodedKey, 'base64'),
|
|
326
|
+
format: 'der',
|
|
327
|
+
type: 'spki',
|
|
328
|
+
});
|
|
329
|
+
const actualType = key.asymmetricKeyType;
|
|
330
|
+
result.keyParseState = 'parsed';
|
|
331
|
+
if (actualType === 'rsa' || actualType === 'rsa-pss') {
|
|
332
|
+
result.keyBits = key.asymmetricKeyDetails?.modulusLength || null;
|
|
333
|
+
}
|
|
334
|
+
else if (actualType === 'ed25519') {
|
|
335
|
+
result.keyBits = 256;
|
|
336
|
+
}
|
|
337
|
+
if ((result.keyType === 'rsa' && actualType !== 'rsa' && actualType !== 'rsa-pss')
|
|
338
|
+
|| (result.keyType === 'ed25519' && actualType !== 'ed25519')) {
|
|
339
|
+
result.issues.push(`The DKIM k tag declares ${result.keyType}, but the public key is ${actualType || 'an unsupported type'}.`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
result.keyParseState = 'invalid';
|
|
344
|
+
result.issues.push('The DKIM public key could not be parsed as SubjectPublicKeyInfo.');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (!['rsa', 'ed25519'].includes(result.keyType || '')) {
|
|
349
|
+
result.issues.push(`Unsupported DKIM key algorithm: ${result.keyType}.`);
|
|
350
|
+
}
|
|
351
|
+
if (parsed.duplicates.length)
|
|
352
|
+
result.issues.push(`Duplicate DKIM tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
|
|
353
|
+
if (parsed.malformed.length)
|
|
354
|
+
result.issues.push(`Malformed DKIM field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
|
|
355
|
+
result.valid = result.issues.length === 0;
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };
|