@slicedearth/whoisleuth-cli 2.2.0 → 2.4.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/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Kept separate from DNS/network I/O so policy edge cases can be tested with
|
|
3
3
|
// synthetic records and the Express/Netlify paths share identical behavior.
|
|
4
4
|
import { createPublicKey } from 'node:crypto';
|
|
5
|
+
import { isIP } from 'node:net';
|
|
6
|
+
import { domainToASCII } from 'node:url';
|
|
5
7
|
function joinTxtRecords(records) {
|
|
6
8
|
if (!Array.isArray(records))
|
|
7
9
|
return [];
|
|
@@ -46,6 +48,7 @@ function parseSpfRecords(records) {
|
|
|
46
48
|
terminalPolicy: null,
|
|
47
49
|
redirect: null,
|
|
48
50
|
includes: [],
|
|
51
|
+
authorizingTerms: [],
|
|
49
52
|
dnsLookupTerms: 0,
|
|
50
53
|
issues: [],
|
|
51
54
|
};
|
|
@@ -61,6 +64,108 @@ function parseSpfRecords(records) {
|
|
|
61
64
|
if (!record)
|
|
62
65
|
return result;
|
|
63
66
|
const terms = record.split(/\s+/).slice(1).filter(Boolean);
|
|
67
|
+
let invalidSyntax = false;
|
|
68
|
+
const modifierNames = new Set();
|
|
69
|
+
const literalDomain = (value, label) => {
|
|
70
|
+
if (!value) {
|
|
71
|
+
result.issues.push(`${label} requires a domain-spec.`);
|
|
72
|
+
invalidSyntax = true;
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (value.includes('%')) {
|
|
76
|
+
result.issues.push(`${label} uses SPF macros that this bounded audit does not evaluate.`);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
const hostname = value.toLowerCase().replace(/\.$/u, '');
|
|
80
|
+
if (/[^\x00-\x7f]/u.test(value) || hostname.length > 253 || !hostname.includes('.') || !hostname.split('.').every((part) => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
|
|
81
|
+
result.issues.push(`${label} has an invalid literal domain-spec.`);
|
|
82
|
+
invalidSyntax = true;
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
return true;
|
|
86
|
+
};
|
|
87
|
+
for (const term of terms) {
|
|
88
|
+
const qualifier = /^[+?~-]/u.test(term) ? term[0] : '+';
|
|
89
|
+
const body = qualifier === '+' && term[0] !== '+' ? term : term.slice(1);
|
|
90
|
+
const lower = body.toLowerCase();
|
|
91
|
+
const modifier = body.match(/^([a-z][a-z0-9_.-]*)=(.*)$/iu);
|
|
92
|
+
if (modifier) {
|
|
93
|
+
const name = modifier[1].toLowerCase();
|
|
94
|
+
const value = modifier[2];
|
|
95
|
+
if (modifierNames.has(name)) {
|
|
96
|
+
result.issues.push(`Duplicate SPF modifier: ${name}.`);
|
|
97
|
+
invalidSyntax = true;
|
|
98
|
+
}
|
|
99
|
+
modifierNames.add(name);
|
|
100
|
+
if (name === 'redirect') {
|
|
101
|
+
literalDomain(value, 'The redirect modifier');
|
|
102
|
+
if (qualifier !== '+') {
|
|
103
|
+
result.issues.push('SPF modifiers cannot have a qualifier.');
|
|
104
|
+
invalidSyntax = true;
|
|
105
|
+
}
|
|
106
|
+
result.authorizingTerms.push(term);
|
|
107
|
+
}
|
|
108
|
+
else if (name === 'exp') {
|
|
109
|
+
literalDomain(value, 'The exp modifier');
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
result.issues.push(`The ${name} SPF modifier is not evaluated by this bounded audit.`);
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (lower === 'all')
|
|
117
|
+
continue;
|
|
118
|
+
const include = body.match(/^include:(.*)$/iu);
|
|
119
|
+
if (include) {
|
|
120
|
+
literalDomain(include[1], 'The include mechanism');
|
|
121
|
+
if (qualifier !== '-')
|
|
122
|
+
result.authorizingTerms.push(term);
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const exists = body.match(/^exists:(.*)$/iu);
|
|
126
|
+
if (exists) {
|
|
127
|
+
literalDomain(exists[1], 'The exists mechanism');
|
|
128
|
+
if (qualifier !== '-')
|
|
129
|
+
result.authorizingTerms.push(term);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const ptr = body.match(/^ptr(?::(.*))?$/iu);
|
|
133
|
+
if (ptr) {
|
|
134
|
+
if (ptr[1] !== undefined)
|
|
135
|
+
literalDomain(ptr[1], 'The ptr mechanism');
|
|
136
|
+
if (qualifier !== '-')
|
|
137
|
+
result.authorizingTerms.push(term);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const address = body.match(/^(ip4|ip6):(.*)$/iu);
|
|
141
|
+
if (address) {
|
|
142
|
+
const family = address[1].toLowerCase() === 'ip4' ? 4 : 6;
|
|
143
|
+
const [literal, prefix, ...extra] = address[2].split('/');
|
|
144
|
+
const maximumPrefix = family === 4 ? 32 : 128;
|
|
145
|
+
if (extra.length || isIP(literal || '') !== family || (prefix !== undefined && (!/^\d+$/u.test(prefix) || Number(prefix) > maximumPrefix))) {
|
|
146
|
+
result.issues.push(`The ${address[1].toLowerCase()} mechanism has an invalid address or prefix length.`);
|
|
147
|
+
invalidSyntax = true;
|
|
148
|
+
}
|
|
149
|
+
if (qualifier !== '-')
|
|
150
|
+
result.authorizingTerms.push(term);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const hostMechanism = body.match(/^(a|mx)(?::([^/]+))?(?:\/(\d+))?(?:\/\/(\d+))?$/iu);
|
|
154
|
+
if (hostMechanism) {
|
|
155
|
+
if (hostMechanism[2] !== undefined)
|
|
156
|
+
literalDomain(hostMechanism[2], `The ${hostMechanism[1].toLowerCase()} mechanism`);
|
|
157
|
+
if ((hostMechanism[3] !== undefined && Number(hostMechanism[3]) > 32)
|
|
158
|
+
|| (hostMechanism[4] !== undefined && Number(hostMechanism[4]) > 128)) {
|
|
159
|
+
result.issues.push(`The ${hostMechanism[1].toLowerCase()} mechanism has an invalid prefix length.`);
|
|
160
|
+
invalidSyntax = true;
|
|
161
|
+
}
|
|
162
|
+
if (qualifier !== '-')
|
|
163
|
+
result.authorizingTerms.push(term);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
result.issues.push(`Unsupported or malformed SPF term: ${term}.`);
|
|
167
|
+
invalidSyntax = true;
|
|
168
|
+
}
|
|
64
169
|
const allIndex = terms.findIndex((term) => /^[+?~-]?all$/i.test(term));
|
|
65
170
|
const allTerm = allIndex === -1 ? null : terms[allIndex];
|
|
66
171
|
const redirectTerm = terms.find((term) => /^redirect=/i.test(term));
|
|
@@ -89,12 +194,76 @@ function parseSpfRecords(records) {
|
|
|
89
194
|
result.terminalPolicy = 'none';
|
|
90
195
|
result.issues.push('No all mechanism or redirect modifier defines a terminal policy.');
|
|
91
196
|
}
|
|
92
|
-
result.valid =
|
|
197
|
+
result.valid = !invalidSyntax;
|
|
93
198
|
return result;
|
|
94
199
|
}
|
|
200
|
+
function hasDmarcVersion(record) {
|
|
201
|
+
return /^[vV][ \t]*=[ \t]*DMARC1(?:[ \t]*;|[ \t]*$)/u.test(record);
|
|
202
|
+
}
|
|
203
|
+
function parseDmarcReportingAuthorization(record) {
|
|
204
|
+
const text = joinTxtRecords([record])[0] || '';
|
|
205
|
+
if (!hasDmarcVersion(text))
|
|
206
|
+
return null;
|
|
207
|
+
const fields = text.split(';');
|
|
208
|
+
if (!fields[fields.length - 1]?.trim())
|
|
209
|
+
fields.pop();
|
|
210
|
+
// RFC 9990 section 4 requires a complete tag list, not a version prefix.
|
|
211
|
+
if (fields.some((field) => !/^[ \t]*[a-zA-Z]+[ \t]*=[ \t]*[\x20-\x3a\x3c-\x7e]+[ \t]*$/u.test(field)))
|
|
212
|
+
return null;
|
|
213
|
+
const parsed = parseTagList(text);
|
|
214
|
+
return parsed.duplicates.length || parsed.malformed.length ? null : parsed.tags;
|
|
215
|
+
}
|
|
216
|
+
function parseReportingDestination(value) {
|
|
217
|
+
// Validate URI spelling before URL parsing can repair whitespace, escapes or
|
|
218
|
+
// missing authority delimiters. DNS collection already bounds the input.
|
|
219
|
+
if (!value || /[^a-zA-Z0-9._~:/?#\[\]@!$&'()*+,;=%-]/u.test(value) || /%(?![a-fA-F0-9]{2})/u.test(value))
|
|
220
|
+
return null;
|
|
221
|
+
if (/^mailto:/iu.test(value)) {
|
|
222
|
+
const parts = value.slice(7).split('?');
|
|
223
|
+
if (parts.length > 2 || value.includes('#'))
|
|
224
|
+
return null;
|
|
225
|
+
const headers = parts[1];
|
|
226
|
+
if (headers !== undefined && !headers.split('&').every((field) => /^[a-zA-Z0-9._~!$'()*+,:@%\-]*=[a-zA-Z0-9._~!$'()*+,:@%\-]*$/u.test(field)))
|
|
227
|
+
return null;
|
|
228
|
+
let address;
|
|
229
|
+
try {
|
|
230
|
+
address = decodeURIComponent(parts[0] || '');
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const at = address.lastIndexOf('@');
|
|
236
|
+
if (at <= 0)
|
|
237
|
+
return null;
|
|
238
|
+
const local = address.slice(0, at);
|
|
239
|
+
const dotAtom = /^[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*$/u;
|
|
240
|
+
const quoted = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\[\x20-\x7e])*"$/u;
|
|
241
|
+
if (!dotAtom.test(local) && !quoted.test(local))
|
|
242
|
+
return null;
|
|
243
|
+
const domain = address.slice(at + 1);
|
|
244
|
+
if (domain.startsWith('[') && domain.endsWith(']')) {
|
|
245
|
+
const literal = domain.slice(1, -1);
|
|
246
|
+
return isIP(literal.replace(/^IPv6:/iu, '')) ? { scheme: 'mailto', host: domain } : null;
|
|
247
|
+
}
|
|
248
|
+
const host = domainToASCII(domain).toLowerCase();
|
|
249
|
+
if (!host || host.length > 253 || !host.split('.').every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label)))
|
|
250
|
+
return null;
|
|
251
|
+
return { scheme: 'mailto', host };
|
|
252
|
+
}
|
|
253
|
+
const authority = /^https:\/\/([^/?#]+)/iu.exec(value)?.[1];
|
|
254
|
+
if (!authority || authority.includes('@') || value.includes('#'))
|
|
255
|
+
return null;
|
|
256
|
+
try {
|
|
257
|
+
const uri = new URL(value);
|
|
258
|
+
return uri.hostname && !uri.username && !uri.password && !uri.hash ? { scheme: 'https', host: uri.hostname } : null;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
95
264
|
function parseDmarcRecords(records) {
|
|
96
265
|
const allRecords = joinTxtRecords(records);
|
|
97
|
-
const dmarcRecords = allRecords.filter(
|
|
266
|
+
const dmarcRecords = allRecords.filter(hasDmarcVersion);
|
|
98
267
|
const result = {
|
|
99
268
|
records: dmarcRecords,
|
|
100
269
|
valid: false,
|
|
@@ -152,8 +321,12 @@ function parseDmarcRecords(records) {
|
|
|
152
321
|
result.failureReporting = Boolean(parsed.tags.ruf);
|
|
153
322
|
result.aggregateDestinations = (parsed.tags.rua || '').split(',').map((value) => value.trim()).filter(Boolean);
|
|
154
323
|
result.failureDestinations = (parsed.tags.ruf || '').split(',').map((value) => value.trim()).filter(Boolean);
|
|
324
|
+
const emptyDestination = [parsed.tags.rua, parsed.tags.ruf].some((value) => value !== undefined && value.split(',').some((destination) => !destination.trim()));
|
|
325
|
+
if (emptyDestination)
|
|
326
|
+
result.issues.push('A DMARC reporting URI list contains an empty destination.');
|
|
155
327
|
result.valid = parsed.duplicates.length === 0
|
|
156
328
|
&& parsed.malformed.length === 0
|
|
329
|
+
&& !emptyDestination
|
|
157
330
|
&& Boolean(result.policy && result.subdomainPolicy && result.nonexistentSubdomainPolicy)
|
|
158
331
|
&& (!parsed.tags.t || /^[yn]$/i.test(parsed.tags.t));
|
|
159
332
|
return result;
|
|
@@ -230,7 +403,7 @@ function parseMtaStsPolicy(text) {
|
|
|
230
403
|
}
|
|
231
404
|
function parseTlsRptRecords(records) {
|
|
232
405
|
const allRecords = joinTxtRecords(records);
|
|
233
|
-
const policyRecords = allRecords.filter((record) => /^v\
|
|
406
|
+
const policyRecords = allRecords.filter((record) => /^v=TLSRPTv1(?:[ \t]*;|$)/u.test(record));
|
|
234
407
|
const result = { records: policyRecords, valid: false, record: policyRecords[0] || null, rua: [], issues: [] };
|
|
235
408
|
if (policyRecords.length === 0) {
|
|
236
409
|
result.issues.push('No TLS-RPT policy record was found.');
|
|
@@ -241,14 +414,26 @@ function parseTlsRptRecords(records) {
|
|
|
241
414
|
return result;
|
|
242
415
|
}
|
|
243
416
|
const parsed = parseTagList(policyRecords[0]);
|
|
244
|
-
|
|
417
|
+
const destinations = (parsed.tags.rua || '').split(',').map((value) => value.trim());
|
|
418
|
+
result.rua = destinations.filter(Boolean);
|
|
245
419
|
if (result.rua.length === 0)
|
|
246
420
|
result.issues.push('The TLS-RPT record has no aggregate report destination.');
|
|
421
|
+
if (destinations.some((destination) => /[!,;]/u.test(destination) || !parseReportingDestination(destination))) {
|
|
422
|
+
result.issues.push('TLS-RPT destinations must be complete supported mailto or HTTPS URIs; reserved reporting delimiters must be percent-encoded.');
|
|
423
|
+
}
|
|
424
|
+
const fields = (policyRecords[0] || '').split(';');
|
|
425
|
+
if (!fields[fields.length - 1]?.trim())
|
|
426
|
+
fields.pop();
|
|
427
|
+
if (fields.slice(1).some((field) => {
|
|
428
|
+
const text = field.trim();
|
|
429
|
+
return text.startsWith('rua=') ? false : !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,31}=[\x21-\x3a\x3c\x3e-\x7e]+$/u.test(text) || /^rua=/iu.test(text);
|
|
430
|
+
}))
|
|
431
|
+
result.issues.push('The TLS-RPT record contains an invalid field name or value.');
|
|
247
432
|
if (parsed.duplicates.length)
|
|
248
433
|
result.issues.push(`Duplicate TLS-RPT tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
|
|
249
434
|
if (parsed.malformed.length)
|
|
250
435
|
result.issues.push(`Malformed TLS-RPT field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
|
|
251
|
-
result.valid = result.
|
|
436
|
+
result.valid = result.issues.length === 0;
|
|
252
437
|
return result;
|
|
253
438
|
}
|
|
254
439
|
function parseBimiRecords(records) {
|
|
@@ -321,8 +506,14 @@ function parseDkimRecords(selector, records) {
|
|
|
321
506
|
}
|
|
322
507
|
else {
|
|
323
508
|
try {
|
|
509
|
+
const decodedKey = Buffer.from(encodedKey, 'base64');
|
|
510
|
+
if (result.keyType === 'ed25519' && decodedKey.length !== 32) {
|
|
511
|
+
throw new Error('Ed25519 DKIM keys use an exact 32-byte raw public key.');
|
|
512
|
+
}
|
|
324
513
|
const key = createPublicKey({
|
|
325
|
-
key:
|
|
514
|
+
key: result.keyType === 'ed25519'
|
|
515
|
+
? Buffer.concat([Buffer.from('302a300506032b6570032100', 'hex'), decodedKey])
|
|
516
|
+
: decodedKey,
|
|
326
517
|
format: 'der',
|
|
327
518
|
type: 'spki',
|
|
328
519
|
});
|
|
@@ -341,7 +532,9 @@ function parseDkimRecords(selector, records) {
|
|
|
341
532
|
}
|
|
342
533
|
catch {
|
|
343
534
|
result.keyParseState = 'invalid';
|
|
344
|
-
result.issues.push(
|
|
535
|
+
result.issues.push(result.keyType === 'ed25519'
|
|
536
|
+
? 'The Ed25519 DKIM public key must be exactly 32 raw bytes.'
|
|
537
|
+
: 'The DKIM public key could not be parsed as SubjectPublicKeyInfo.');
|
|
345
538
|
}
|
|
346
539
|
}
|
|
347
540
|
}
|
|
@@ -355,4 +548,4 @@ function parseDkimRecords(selector, records) {
|
|
|
355
548
|
result.valid = result.issues.length === 0;
|
|
356
549
|
return result;
|
|
357
550
|
}
|
|
358
|
-
export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };
|
|
551
|
+
export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -7,6 +7,10 @@ import { nonEmptyErrorMessage } from "./error-detail.mjs";
|
|
|
7
7
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
8
8
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
9
9
|
import { classifyMxRecords } from "./dns-mx.mjs";
|
|
10
|
+
import { collectDnsInheritanceChecks } from "./dns-inheritance-review.mjs";
|
|
11
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
12
|
+
import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECK_RECORDS, POSTURE_CHECK_SOURCES } from "../packages/evidence/domain-posture-context.mjs";
|
|
13
|
+
import { canonicalPostureRecords } from "../packages/evidence/domain-control-runtime.mjs";
|
|
10
14
|
import { parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, } from "./domain-posture-parsers.mjs";
|
|
11
15
|
import { buildExternalDependencies, expandSpfPolicy, validateDmarcExternalReporting, } from "./domain-posture-analysis.mjs";
|
|
12
16
|
const DNS_TIMEOUT_MS = 6000;
|
|
@@ -23,6 +27,22 @@ function errorRecord(value) {
|
|
|
23
27
|
function asMxRecords(records) {
|
|
24
28
|
return records;
|
|
25
29
|
}
|
|
30
|
+
function completeMxRecords(records) {
|
|
31
|
+
const normalized = [];
|
|
32
|
+
for (const raw of records) {
|
|
33
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
34
|
+
return null;
|
|
35
|
+
const value = raw;
|
|
36
|
+
const exchange = typeof value.exchange === 'string' ? value.exchange.trim().toLowerCase().replace(/\.+$/u, '') : null;
|
|
37
|
+
const priority = Number(value.priority);
|
|
38
|
+
if (exchange === null || !Number.isInteger(priority) || priority < 0 || priority > 65_535)
|
|
39
|
+
return null;
|
|
40
|
+
if (exchange && !normalizeAuditDomain(exchange))
|
|
41
|
+
return null;
|
|
42
|
+
normalized.push({ exchange, priority });
|
|
43
|
+
}
|
|
44
|
+
return normalized;
|
|
45
|
+
}
|
|
26
46
|
function trimTerminalDots(value) {
|
|
27
47
|
let end = value.length;
|
|
28
48
|
while (end > 0 && value.charCodeAt(end - 1) === 46)
|
|
@@ -72,14 +92,23 @@ function withTimeout(promise, label, timeoutMs = DNS_TIMEOUT_MS, timers = {
|
|
|
72
92
|
});
|
|
73
93
|
}
|
|
74
94
|
async function resolveDns(label, factory, timeoutMs = DNS_TIMEOUT_MS, timers) {
|
|
95
|
+
const completed = (result) => {
|
|
96
|
+
let observedAt = null;
|
|
97
|
+
try {
|
|
98
|
+
const at = timers?.now?.();
|
|
99
|
+
observedAt = at instanceof Date && Number.isFinite(at.getTime()) ? at.toISOString() : null;
|
|
100
|
+
}
|
|
101
|
+
catch { /* Unknown source time remains explicit. */ }
|
|
102
|
+
return { ...result, observedAt };
|
|
103
|
+
};
|
|
75
104
|
try {
|
|
76
|
-
return { records: await withTimeout(factory(), label, timeoutMs, timers), error: null };
|
|
105
|
+
return completed({ records: await withTimeout(factory(), label, timeoutMs, timers), error: null });
|
|
77
106
|
}
|
|
78
107
|
catch (err) {
|
|
79
108
|
const error = errorRecord(err);
|
|
80
109
|
if (typeof error.code === 'string' && MISSING_DNS_CODES.has(error.code))
|
|
81
|
-
return { records: [], error: null };
|
|
82
|
-
return { records: [], error: nonEmptyErrorMessage(err, String(err)) };
|
|
110
|
+
return completed({ records: [], error: null });
|
|
111
|
+
return completed({ records: [], error: nonEmptyErrorMessage(err, String(err)) });
|
|
83
112
|
}
|
|
84
113
|
}
|
|
85
114
|
function check(id, label, status, summary, { detail = '', records = [], remediation = '' } = {}) {
|
|
@@ -175,6 +204,11 @@ function dmarcCheck(query, authorizations = []) {
|
|
|
175
204
|
];
|
|
176
205
|
const external = authorizations.filter((authorization) => authorization.state !== 'self');
|
|
177
206
|
const unresolvedExternal = external.filter((authorization) => authorization.state !== 'authorized');
|
|
207
|
+
const intendedDestinations = parsed.aggregateDestinations.length + parsed.failureDestinations.length;
|
|
208
|
+
const omittedDestinations = Math.max(0, intendedDestinations - authorizations.length);
|
|
209
|
+
if (omittedDestinations) {
|
|
210
|
+
details.push(`Reporting authorisation coverage: ${authorizations.length} of ${intendedDestinations} destinations reviewed; ${omittedDestinations} not checked within the bounded collection. Omitted destinations are not assumed authorised.`);
|
|
211
|
+
}
|
|
178
212
|
if (external.length > 0) {
|
|
179
213
|
details.push(`${external.length} external reporting destination${external.length === 1 ? '' : 's'} checked; `
|
|
180
214
|
+ `${unresolvedExternal.length} could not be authorized.`);
|
|
@@ -197,11 +231,13 @@ function dmarcCheck(query, authorizations = []) {
|
|
|
197
231
|
remediation: 'Set sp and np to quarantine or reject unless weaker subdomain treatment is intentional.',
|
|
198
232
|
});
|
|
199
233
|
}
|
|
200
|
-
if (unresolvedExternal.length > 0) {
|
|
201
|
-
return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy};
|
|
234
|
+
if (unresolvedExternal.length > 0 || omittedDestinations > 0) {
|
|
235
|
+
return check('dmarc', 'DMARC', 'warning', `Enforced at p=${parsed.policy}; reporting authorisation is incomplete`, {
|
|
202
236
|
detail: `${details.join(' ')} ${unresolvedExternal.map((authorization) => `${authorization.destination}: ${authorization.state}.`).join(' ')}`,
|
|
203
237
|
records: parsed.records,
|
|
204
|
-
remediation:
|
|
238
|
+
remediation: omittedDestinations > 0
|
|
239
|
+
? 'Review the unchecked destinations and confirm any required external reporting authorisation before relying on delivery.'
|
|
240
|
+
: 'Publish the required external reporting authorisation record or remove the unavailable destination.',
|
|
205
241
|
});
|
|
206
242
|
}
|
|
207
243
|
if (!parsed.aggregateReporting) {
|
|
@@ -316,7 +352,11 @@ function matchesMtaPattern(host, pattern) {
|
|
|
316
352
|
const normalizedPattern = String(pattern || '').toLowerCase().replace(/\.+$/, '');
|
|
317
353
|
if (normalizedPattern.startsWith('*.')) {
|
|
318
354
|
const suffix = normalizedPattern.slice(1);
|
|
319
|
-
|
|
355
|
+
if (!normalizedHost.endsWith(suffix))
|
|
356
|
+
return false;
|
|
357
|
+
const label = normalizedHost.slice(0, -suffix.length);
|
|
358
|
+
// MTA-STS permits a wildcard for exactly one leftmost DNS label.
|
|
359
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u.test(label);
|
|
320
360
|
}
|
|
321
361
|
return normalizedHost === normalizedPattern;
|
|
322
362
|
}
|
|
@@ -356,7 +396,22 @@ function mtaStsCheck(dnsQuery, policyFetch, mxQuery) {
|
|
|
356
396
|
remediation: 'Configure the policy host to return Content-Type: text/plain.',
|
|
357
397
|
});
|
|
358
398
|
}
|
|
359
|
-
|
|
399
|
+
if (mxQuery.error) {
|
|
400
|
+
return check('mta_sts', 'MTA-STS', 'info', 'Policy is valid but MX coverage could not be evaluated', {
|
|
401
|
+
detail: mxQuery.error,
|
|
402
|
+
records,
|
|
403
|
+
remediation: 'Retry the MX lookup before concluding that the policy covers every current mail exchanger.',
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
const reviewedMx = completeMxRecords(mxQuery.records);
|
|
407
|
+
if (!reviewedMx) {
|
|
408
|
+
return check('mta_sts', 'MTA-STS', 'info', 'Policy is valid but MX coverage is incomplete', {
|
|
409
|
+
detail: 'One or more MX records were malformed or outside the bounded hostname and priority contract.',
|
|
410
|
+
records,
|
|
411
|
+
remediation: 'Retry and review the MX evidence before concluding that the policy covers every current mail exchanger.',
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
const mxHosts = classifyMxRecords(reviewedMx).mxHosts;
|
|
360
415
|
const unmatched = mxHosts.filter((host) => !policy.mx.some((pattern) => matchesMtaPattern(host, pattern)));
|
|
361
416
|
if (unmatched.length) {
|
|
362
417
|
return check('mta_sts', 'MTA-STS', 'danger', 'Policy does not cover every published MX host', {
|
|
@@ -507,7 +562,7 @@ function defensiveMailProfileCheck(profile, input) {
|
|
|
507
562
|
const mx = input.mx.error ? null : classifyMxRecords(asMxRecords(input.mx.records));
|
|
508
563
|
const requirements = {
|
|
509
564
|
nullMx: mx?.hasNullMx === true,
|
|
510
|
-
restrictiveSpf: spf?.valid === true && spf.terminalPolicy === 'fail' && spf.
|
|
565
|
+
restrictiveSpf: spf?.valid === true && spf.terminalPolicy === 'fail' && spf.authorizingTerms.length === 0,
|
|
511
566
|
rejectingDmarc: dmarc?.valid === true
|
|
512
567
|
&& dmarc.policy === 'reject'
|
|
513
568
|
&& dmarc.subdomainPolicy === 'reject'
|
|
@@ -534,10 +589,11 @@ function defensiveMailProfileCheck(profile, input) {
|
|
|
534
589
|
function registrationLockCheck(registry) {
|
|
535
590
|
if (registry.error)
|
|
536
591
|
return queryFailureCheck('registration_lock', 'Registration controls', registry.error);
|
|
537
|
-
const statuses = registry.statuses
|
|
592
|
+
const statuses = canonicalPostureRecords('registration_lock', registry.statuses) ?? [];
|
|
538
593
|
if (statuses.length === 0) {
|
|
539
594
|
return check('registration_lock', 'Registration controls', 'info', 'Registry lock state is unavailable', {
|
|
540
595
|
detail: 'No normalised EPP status was returned. This does not describe registrar account security.',
|
|
596
|
+
records: registry.statuses,
|
|
541
597
|
});
|
|
542
598
|
}
|
|
543
599
|
const transferLocks = statuses.filter((status) => ['clienttransferprohibited', 'servertransferprohibited'].includes(status));
|
|
@@ -604,11 +660,36 @@ function buildPostureReport(domain, input) {
|
|
|
604
660
|
...(input.registry && input.nameservers ? [nameserverCheck(input.nameservers, input.registry)] : []),
|
|
605
661
|
];
|
|
606
662
|
const summary = { pass: 0, warning: 0, danger: 0, info: 0 };
|
|
607
|
-
for (const item of checks)
|
|
663
|
+
for (const item of checks) {
|
|
608
664
|
summary[item.status] += 1;
|
|
665
|
+
const source = POSTURE_CHECK_SOURCES[item.id];
|
|
666
|
+
const query = item.id === 'nameservers' ? input.nameservers : item.id === 'mx' ? input.mx : item.id === 'caa' ? input.caa : undefined;
|
|
667
|
+
const registry = item.id === 'registration_lock' ? input.registry : undefined;
|
|
668
|
+
if (!source || (!query && !registry))
|
|
669
|
+
continue;
|
|
670
|
+
const omittedRecords = Math.max(0, item.records.length - MAX_POSTURE_CHECK_RECORDS);
|
|
671
|
+
const records = item.records.slice(0, MAX_POSTURE_CHECK_RECORDS);
|
|
672
|
+
const error = query?.error ?? registry?.error;
|
|
673
|
+
const retained = canonicalPostureRecords(item.id, records);
|
|
674
|
+
const rawRecords = query?.records ?? registry?.statuses ?? [];
|
|
675
|
+
const raw = item.id === 'caa'
|
|
676
|
+
? canonicalPostureRecords(item.id, rawRecords.map((record) => record && typeof record === 'object' ? caaDisplay(record) : record))
|
|
677
|
+
: canonicalPostureRecords(item.id, rawRecords);
|
|
678
|
+
const complete = !error && omittedRecords === 0 && retained !== null && raw !== null
|
|
679
|
+
&& retained.length === raw.length && retained.every((value, index) => value === raw[index])
|
|
680
|
+
&& (!registry || (registry.statusesComplete === true && records.length > 0));
|
|
681
|
+
item.sourceContext = {
|
|
682
|
+
version: DOMAIN_POSTURE_COMPARISON_VERSION, source,
|
|
683
|
+
observedAt: normalizeExplicitIsoTimestamp(query?.observedAt ?? registry?.observedAt),
|
|
684
|
+
state: error ? 'unavailable' : complete ? 'complete' : 'partial',
|
|
685
|
+
omittedRecords: complete ? 0 : omittedRecords || null,
|
|
686
|
+
};
|
|
687
|
+
item.records = records;
|
|
688
|
+
}
|
|
609
689
|
return { domain, summary, checks };
|
|
610
690
|
}
|
|
611
|
-
async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
|
|
691
|
+
async function fetchMtaStsPolicy(domain, fetcher = safeFetch, signal) {
|
|
692
|
+
signal?.throwIfAborted();
|
|
612
693
|
const controller = new AbortController();
|
|
613
694
|
const timeout = setTimeout(() => controller.abort(), POLICY_TIMEOUT_MS);
|
|
614
695
|
try {
|
|
@@ -617,10 +698,10 @@ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
|
|
|
617
698
|
// following HTTP redirects for policy discovery. A zero-hop safe fetch
|
|
618
699
|
// also keeps the returned body bound to the required policy host/path.
|
|
619
700
|
const res = await fetcher(url, {
|
|
620
|
-
signal: controller.signal,
|
|
701
|
+
signal: signal ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
621
702
|
headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
|
|
622
703
|
}, 0);
|
|
623
|
-
if (
|
|
704
|
+
if (res.status !== 200) {
|
|
624
705
|
// Not reading this body - release it explicitly instead of leaving an
|
|
625
706
|
// unconsumed stream (and the connection it's tied to) open until
|
|
626
707
|
// undici's own idle-timeout eventually notices.
|
|
@@ -628,11 +709,13 @@ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
|
|
|
628
709
|
return { text: '', contentType: res.headers.get('content-type'), error: `Policy endpoint returned HTTP ${res.status}.` };
|
|
629
710
|
}
|
|
630
711
|
const body = await readTextCapped(res, MAX_POLICY_BYTES);
|
|
712
|
+
signal?.throwIfAborted();
|
|
631
713
|
if (body.truncated)
|
|
632
714
|
return { text: '', contentType: res.headers.get('content-type'), error: `Policy exceeds ${MAX_POLICY_BYTES} bytes.` };
|
|
633
715
|
return { text: body.text, contentType: res.headers.get('content-type'), error: null };
|
|
634
716
|
}
|
|
635
717
|
catch (err) {
|
|
718
|
+
signal?.throwIfAborted();
|
|
636
719
|
return {
|
|
637
720
|
text: '',
|
|
638
721
|
contentType: null,
|
|
@@ -645,17 +728,8 @@ async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
|
|
|
645
728
|
clearTimeout(timeout);
|
|
646
729
|
}
|
|
647
730
|
}
|
|
648
|
-
async function
|
|
649
|
-
|
|
650
|
-
resolveMx: (name) => dns.resolveMx(name),
|
|
651
|
-
resolveNs: (name) => dns.resolveNs(name),
|
|
652
|
-
resolveCaa: (name) => dns.resolveCaa(name),
|
|
653
|
-
fetchRdapRecord,
|
|
654
|
-
fetchMtaStsPolicy: (name) => fetchMtaStsPolicy(name),
|
|
655
|
-
now: () => new Date(),
|
|
656
|
-
setTimer: (callback, milliseconds) => setTimeout(callback, milliseconds),
|
|
657
|
-
clearTimer: (handle) => clearTimeout(handle),
|
|
658
|
-
}) {
|
|
731
|
+
async function collectDomainPosture(domain, { dkimSelectors = [], retiredDkimSelectors = [], mailProtectionProfile = 'standard', includeInheritedDns, signal, }, dependencies) {
|
|
732
|
+
signal?.throwIfAborted();
|
|
659
733
|
const normalizedDomain = normalizeAuditDomain(domain);
|
|
660
734
|
if (!normalizedDomain)
|
|
661
735
|
throw new Error('Invalid domain name for posture audit.');
|
|
@@ -686,10 +760,11 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
686
760
|
...await resolveDns(`TXT ${selector}._domainkey.${domain}`, () => dependencies.resolveTxt(`${selector}._domainkey.${domain}`), DNS_TIMEOUT_MS, dependencies),
|
|
687
761
|
})),
|
|
688
762
|
]),
|
|
689
|
-
dependencies.fetchRdapRecord('domain', domain).catch((err) => ({
|
|
763
|
+
dependencies.fetchRdapRecord('domain', domain, signal ? { signal } : {}).catch((err) => ({
|
|
690
764
|
error: nonEmptyErrorMessage(err, String(err)),
|
|
691
765
|
})),
|
|
692
766
|
]);
|
|
767
|
+
signal?.throwIfAborted();
|
|
693
768
|
const parsedMtaDns = mtaStsDns.error ? null : parseMtaStsDnsRecords(mtaStsDns.records);
|
|
694
769
|
const enrichmentStartedAt = dependencies.now();
|
|
695
770
|
if (!(enrichmentStartedAt instanceof Date) || !Number.isFinite(enrichmentStartedAt.getTime())) {
|
|
@@ -697,6 +772,7 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
697
772
|
}
|
|
698
773
|
const enrichmentDeadline = enrichmentStartedAt.getTime() + POSTURE_ENRICHMENT_DEADLINE_MS;
|
|
699
774
|
const resolveEnrichmentTxt = (name) => {
|
|
775
|
+
signal?.throwIfAborted();
|
|
700
776
|
const observedAt = dependencies.now();
|
|
701
777
|
if (!(observedAt instanceof Date) || !Number.isFinite(observedAt.getTime())) {
|
|
702
778
|
return Promise.resolve({ records: [], error: 'The bounded posture-enrichment clock was unavailable.' });
|
|
@@ -706,11 +782,21 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
706
782
|
return Promise.resolve({ records: [], error: 'The bounded posture-enrichment deadline was reached.' });
|
|
707
783
|
return resolveDns(`TXT ${name}`, () => dependencies.resolveTxt(name), Math.min(DNS_TIMEOUT_MS, remaining), dependencies);
|
|
708
784
|
};
|
|
709
|
-
const
|
|
710
|
-
parsedMtaDns?.valid ? dependencies.fetchMtaStsPolicy(domain) : Promise.resolve(null),
|
|
785
|
+
const enrichment = [
|
|
786
|
+
parsedMtaDns?.valid ? dependencies.fetchMtaStsPolicy(domain, signal) : Promise.resolve(null),
|
|
711
787
|
expandSpfPolicy(domain, spf, resolveEnrichmentTxt),
|
|
712
788
|
validateDmarcExternalReporting(domain, dmarc, resolveEnrichmentTxt),
|
|
713
|
-
|
|
789
|
+
includeInheritedDns === true
|
|
790
|
+
? (dependencies.collectDnsInheritanceChecks
|
|
791
|
+
? dependencies.collectDnsInheritanceChecks(domain, dmarc, nameservers, signal ? { signal } : {})
|
|
792
|
+
: Promise.reject(new TypeError('The explicit DNS review collector is unavailable.')))
|
|
793
|
+
: Promise.resolve([]),
|
|
794
|
+
];
|
|
795
|
+
// A rejected policy request must not release the operation's capacity while
|
|
796
|
+
// other started enrichment collectors are still settling.
|
|
797
|
+
const [mtaStsPolicy, spfExpansion, dmarcAuthorizations, inheritanceChecks] = await Promise.all(enrichment)
|
|
798
|
+
.finally(() => Promise.allSettled(enrichment));
|
|
799
|
+
signal?.throwIfAborted();
|
|
714
800
|
const dnssec = !rdap
|
|
715
801
|
? { value: null, error: 'RDAP did not return a domain record.' }
|
|
716
802
|
: 'error' in rdap
|
|
@@ -727,6 +813,8 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
727
813
|
dsRecordCount: parsedDomain.dsData.length,
|
|
728
814
|
dsDataTruncated: parsedDomain.dsDataTruncated,
|
|
729
815
|
error: null,
|
|
816
|
+
observedAt: normalizeExplicitIsoTimestamp(rdap && !('error' in rdap) ? rdap.fetchedAt : null),
|
|
817
|
+
statusesComplete: parsedDomain.statusesTruncated === false && parsedDomain.serverTruncated === false,
|
|
730
818
|
}
|
|
731
819
|
: {
|
|
732
820
|
statuses: [],
|
|
@@ -763,6 +851,10 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
763
851
|
if (!(checkedAt instanceof Date) || !Number.isFinite(checkedAt.getTime())) {
|
|
764
852
|
throw new TypeError('Domain-posture completion time must be valid.');
|
|
765
853
|
}
|
|
854
|
+
for (const item of inheritanceChecks) {
|
|
855
|
+
report.checks.push(item);
|
|
856
|
+
report.summary[item.status] += 1;
|
|
857
|
+
}
|
|
766
858
|
return {
|
|
767
859
|
...report,
|
|
768
860
|
checkedAt: checkedAt.toISOString(),
|
|
@@ -774,4 +866,30 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
774
866
|
externalDependencies,
|
|
775
867
|
};
|
|
776
868
|
}
|
|
869
|
+
async function checkDomainPosture(domain, options = {}, dependencies) {
|
|
870
|
+
options.signal?.throwIfAborted();
|
|
871
|
+
// Cancelling one audit must never cancel another audit's resolver.
|
|
872
|
+
const resolver = dependencies ? null : new dns.Resolver();
|
|
873
|
+
const cancel = () => resolver?.cancel();
|
|
874
|
+
options.signal?.addEventListener('abort', cancel, { once: true });
|
|
875
|
+
try {
|
|
876
|
+
options.signal?.throwIfAborted();
|
|
877
|
+
return await collectDomainPosture(domain, options, dependencies ?? {
|
|
878
|
+
resolveTxt: name => resolver.resolveTxt(name),
|
|
879
|
+
resolveMx: name => resolver.resolveMx(name),
|
|
880
|
+
resolveNs: name => resolver.resolveNs(name),
|
|
881
|
+
resolveCaa: name => resolver.resolveCaa(name),
|
|
882
|
+
fetchRdapRecord,
|
|
883
|
+
fetchMtaStsPolicy: (name, signal) => fetchMtaStsPolicy(name, safeFetch, signal),
|
|
884
|
+
now: () => new Date(),
|
|
885
|
+
setTimer: (callback, milliseconds) => setTimeout(callback, milliseconds),
|
|
886
|
+
clearTimer: handle => clearTimeout(handle),
|
|
887
|
+
collectDnsInheritanceChecks,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
finally {
|
|
891
|
+
options.signal?.removeEventListener('abort', cancel);
|
|
892
|
+
cancel();
|
|
893
|
+
}
|
|
894
|
+
}
|
|
777
895
|
export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, fetchMtaStsPolicy, buildPostureReport, checkDomainPosture, };
|