@slicedearth/whoisleuth-cli 1.45.0 → 1.47.4
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/DISCLOSURE +7 -3
- package/NOTICE +5 -4
- package/README.md +59 -6
- package/bin/whoisleuth.mjs +5 -3
- package/cli/arguments.mjs +256 -9
- package/cli/artifact-structure.mjs +1833 -0
- package/cli/artifact-verify.mjs +242 -49
- package/cli/bounded-json.mjs +1 -0
- package/cli/bulk-checkpoint.mjs +2 -3
- package/cli/bulk-command-runner.mjs +2 -5
- package/cli/case-pack.mjs +819 -34
- package/cli/command-reference.mjs +48 -24
- package/cli/completion.mjs +16 -8
- package/cli/ct-event-intake.mjs +32 -2
- package/cli/discovery-scan-command-runner.mjs +4 -5
- package/cli/discovery-workflow.mjs +2 -3
- package/cli/doctor.mjs +2 -2
- package/cli/domain-control-monitor.mjs +21 -15
- package/cli/domain-control-observations.mjs +25 -2
- package/cli/evidence-command-runner.mjs +17 -11
- package/cli/evidence-signing.mjs +73 -45
- package/cli/export-evidence.mjs +11 -4
- package/cli/formatters/html.mjs +3 -3
- package/cli/formatters/terminal.mjs +3 -0
- package/cli/input.mjs +57 -0
- package/cli/interchange-report.mjs +226 -0
- package/cli/investigation-manifest.mjs +16 -17
- package/cli/investigation-run.mjs +71 -23
- package/cli/local-mmdb-review.mjs +45 -14
- package/cli/lookup-command-runner.mjs +2 -2
- package/cli/lookup-diff.mjs +9 -0
- package/cli/network-command-runner.mjs +78 -0
- package/cli/offline-evidence-review.mjs +28 -8
- package/cli/page-compare.mjs +8 -5
- package/cli/registry-cohort.mjs +385 -61
- package/cli/registry-support.mjs +2 -1
- package/cli/retained-artifact-diff.mjs +376 -0
- package/cli/risk-calibration.mjs +7 -5
- package/cli/runner.mjs +147 -185
- package/cli/saved-lookup.mjs +26 -3
- package/cli/sharing-review.mjs +12 -6
- package/docs/cli-reference.md +444 -70
- package/docs/cli.md +24 -5
- package/frontend/src/lib/analysis/acquisition-decision-packet.js +4 -4
- package/frontend/src/lib/analysis/artifact-integrity.js +42 -0
- package/frontend/src/lib/analysis/brand-profile-model.js +29 -5
- package/frontend/src/lib/analysis/bulk-domain-comparison.js +25 -5
- package/frontend/src/lib/analysis/bulk-mail-exposure.js +66 -15
- package/frontend/src/lib/analysis/bulk-retry-plan.js +1 -1
- package/frontend/src/lib/analysis/bulk-review-export.js +7 -6
- package/frontend/src/lib/analysis/bulk-review-model.js +3 -2
- package/frontend/src/lib/analysis/bulk-session-model.js +192 -26
- package/frontend/src/lib/analysis/bulk-sort.js +4 -0
- package/frontend/src/lib/analysis/case-brand-profile-references.js +85 -0
- package/frontend/src/lib/analysis/case-evidence-model.js +8 -0
- package/frontend/src/lib/analysis/case-investigation-branch-model.js +134 -0
- package/frontend/src/lib/analysis/case-migration-model.js +83 -31
- package/frontend/src/lib/analysis/case-record-contracts.js +4 -2
- package/frontend/src/lib/analysis/case-record-model.js +2 -0
- package/frontend/src/lib/analysis/case-record-operations.js +42 -12
- package/frontend/src/lib/analysis/case-report.js +39 -6
- package/frontend/src/lib/analysis/case-response-model.js +66 -7
- package/frontend/src/lib/analysis/case-response-packet.js +15 -5
- package/frontend/src/lib/analysis/comparison-ledger-bulk.js +366 -0
- package/frontend/src/lib/analysis/comparison-ledger-contract.js +241 -0
- package/frontend/src/lib/analysis/comparison-ledger-json.js +79 -0
- package/frontend/src/lib/analysis/comparison-ledger-serialization.js +51 -0
- package/frontend/src/lib/analysis/domain-control-manifest-core.js +145 -0
- package/frontend/src/lib/analysis/domain-control-records.js +88 -0
- package/frontend/src/lib/analysis/external-findings-import.js +70 -6
- package/frontend/src/lib/analysis/investigation-capsule.js +47 -13
- package/frontend/src/lib/analysis/investigation-guide.js +52 -10
- package/frontend/src/lib/analysis/investigation-template-model.js +9 -6
- package/frontend/src/lib/analysis/lookup-claim-passport.js +236 -0
- package/frontend/src/lib/analysis/lookup-claim-readiness.js +280 -0
- package/frontend/src/lib/analysis/lookup-decision-support.js +61 -6
- package/frontend/src/lib/analysis/lookup-presentation.js +62 -0
- package/frontend/src/lib/analysis/opaque-reference-id.js +9 -0
- package/frontend/src/lib/analysis/utils.js +26 -3
- package/frontend/src/lib/analysis/visualization-matrix.js +71 -28
- package/frontend/src/lib/analysis/visualization-models.js +63 -94
- package/frontend/src/lib/analysis/visualization-time-series.js +53 -12
- package/frontend/src/lib/analysis/website-snapshot-model.js +6 -0
- package/frontend/src/lib/analysis/workspace-archive.js +104 -14
- package/lib/application-version.mjs +3 -0
- package/lib/availability.mjs +14 -12
- package/lib/bounded-file.mjs +13 -0
- package/lib/bounded-json-response.mjs +17 -4
- package/lib/bounded-json.mjs +249 -0
- package/lib/certificate-extension-profile.mjs +2 -1
- package/lib/cryptographic-assurance.mjs +155 -0
- package/lib/ct-search.mjs +87 -47
- package/lib/dns-intelligence.mjs +3 -2
- package/lib/dns-resolver-selection.mjs +6 -4
- package/lib/dnssec-chain-validation.mjs +1053 -0
- package/lib/dnssec-evidence-validation.mjs +10 -3
- package/lib/domain-change-packet.mjs +7 -5
- package/lib/domain-control-manifest.mjs +17 -129
- package/lib/domain-posture.mjs +7 -4
- package/lib/evidence-export.mjs +751 -78
- package/lib/evidence-report-markdown.mjs +3 -0
- package/lib/evidence-report.mjs +5 -0
- package/lib/favicon.mjs +8 -5
- package/lib/http-evidence-bounds.mjs +3 -0
- package/lib/http-intelligence.mjs +2 -5
- package/lib/interchange-fidelity-registry.mjs +122 -0
- package/lib/local-geoip-evidence.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +9 -0
- package/lib/lookup-response-contract.mjs +691 -3
- package/lib/lookup.mjs +2 -2
- package/lib/observation.mjs +7 -6
- package/lib/outbound-identity.mjs +2 -3
- package/lib/perceptual-hash.mjs +12 -4
- package/lib/portable-generator.mjs +21 -0
- package/lib/provider-policy-admission.mjs +9 -4
- package/lib/rdap-normalization.mjs +18 -5
- package/lib/rdap-registrar.mjs +5 -1
- package/lib/registry-capabilities.mjs +3 -0
- package/lib/registry-capability-catalogue.mjs +12 -2
- package/lib/registry-comparison.mjs +28 -11
- package/lib/risk-calibration-summary.mjs +338 -0
- package/lib/rpki-evidence.mjs +9 -8
- package/lib/safe-fetch.mjs +59 -10
- package/lib/security-txt.mjs +26 -3
- package/lib/service-binding-dns.mjs +6 -1
- package/lib/smtp-transport-review.mjs +784 -0
- package/lib/sslbl-certificates.generated.mjs +136 -134
- package/lib/threat-intelligence-runtime.mjs +3 -11
- package/lib/threat-intelligence-types.mjs +18 -1
- package/lib/tls-intelligence.mjs +11 -5
- package/lib/tlsa-evidence.mjs +37 -7
- package/package.json +1 -1
- package/third-party-notices.txt +95 -5817
|
@@ -160,7 +160,8 @@ function validateDnssecEvidence(input) {
|
|
|
160
160
|
limitations: Object.freeze(limitations),
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
|
-
|
|
163
|
+
const incompleteInput = rejectedCount > 0 || truncated;
|
|
164
|
+
if (input.delegationSigned === false && dsRecords.length === 0 && !incompleteInput) {
|
|
164
165
|
findings.push('The supplied delegation state was unsigned and no DS records were supplied.');
|
|
165
166
|
return Object.freeze({
|
|
166
167
|
schema: DNSSEC_EVIDENCE_SCHEMA,
|
|
@@ -191,18 +192,24 @@ function validateDnssecEvidence(input) {
|
|
|
191
192
|
if (matched)
|
|
192
193
|
matchedDsCount += 1;
|
|
193
194
|
}
|
|
194
|
-
const
|
|
195
|
+
const observedConflict = input.delegationSigned === false && dsRecords.length > 0
|
|
195
196
|
|| input.delegationSigned === true && dsRecords.length === 0 && !truncated
|
|
196
197
|
|| signatureState === 'outside_window'
|
|
197
198
|
|| (dsRecords.length > 0 && dnskeys.length > 0 && matchedDsCount === 0 && unsupportedDigestCount < dsRecords.length);
|
|
199
|
+
const definiteConflict = observedConflict && !incompleteInput;
|
|
198
200
|
if (definiteConflict)
|
|
199
201
|
findings.push('The supplied delegation, signature-time, DS, or DNSKEY evidence conflicts.');
|
|
202
|
+
else if (observedConflict)
|
|
203
|
+
findings.push('The retained subset contains conflicting evidence, but omitted or rejected records prevent a definitive conflict conclusion.');
|
|
200
204
|
if (matchedDsCount > 0)
|
|
201
205
|
findings.push(`${matchedDsCount} supplied DS record(s) matched a supplied DNSKEY.`);
|
|
202
206
|
if (unsupportedDigestCount > 0)
|
|
203
207
|
findings.push(`${unsupportedDigestCount} DS digest type(s) were retained as unsupported.`);
|
|
204
208
|
if (signatureState === 'mixed')
|
|
205
209
|
findings.push('The supplied RRSIG validity windows were mixed at the observation time.');
|
|
210
|
+
if (input.delegationSigned === false && dsRecords.length === 0 && incompleteInput) {
|
|
211
|
+
findings.push('The retained subset contains no usable DS record, but omitted or rejected records prevent a definitive unsigned conclusion.');
|
|
212
|
+
}
|
|
206
213
|
if (rejectedCount > 0)
|
|
207
214
|
findings.push(`${rejectedCount} malformed record(s) were rejected.`);
|
|
208
215
|
if (truncated)
|
|
@@ -210,7 +217,7 @@ function validateDnssecEvidence(input) {
|
|
|
210
217
|
const completeLocalRelationship = dsRecords.length > 0
|
|
211
218
|
&& dnskeys.length > 0
|
|
212
219
|
&& matchedDsCount > 0
|
|
213
|
-
&& !
|
|
220
|
+
&& !observedConflict
|
|
214
221
|
&& rejectedCount === 0
|
|
215
222
|
&& !truncated
|
|
216
223
|
&& unsupportedDigestCount === 0
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
2
|
import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
|
|
3
3
|
import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
5
5
|
export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
|
|
6
6
|
export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
|
|
7
|
-
export const
|
|
7
|
+
export const DOMAIN_CHANGE_PACKET_INPUT_VERSION = 1;
|
|
8
|
+
export const DOMAIN_CHANGE_PACKET_VERSION = 2;
|
|
8
9
|
export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
|
|
9
10
|
const ROOT_KEYS = new Set([
|
|
10
11
|
'schema',
|
|
@@ -57,8 +58,8 @@ function changeSummary(before, after) {
|
|
|
57
58
|
}
|
|
58
59
|
export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
59
60
|
const input = record(inputRaw, 'Domain change packet input');
|
|
60
|
-
if (input.schema !== DOMAIN_CHANGE_PACKET_INPUT_SCHEMA || input.version !==
|
|
61
|
-
throw new TypeError(`Domain change packet input must use ${DOMAIN_CHANGE_PACKET_INPUT_SCHEMA} version
|
|
61
|
+
if (input.schema !== DOMAIN_CHANGE_PACKET_INPUT_SCHEMA || input.version !== DOMAIN_CHANGE_PACKET_INPUT_VERSION) {
|
|
62
|
+
throw new TypeError(`Domain change packet input must use ${DOMAIN_CHANGE_PACKET_INPUT_SCHEMA} version ${DOMAIN_CHANGE_PACKET_INPUT_VERSION}.`);
|
|
62
63
|
}
|
|
63
64
|
exactKeys(input, ROOT_KEYS, 'Domain change packet input');
|
|
64
65
|
const domain = requireDomainName(input.domain, 'domain');
|
|
@@ -105,7 +106,8 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
|
|
|
105
106
|
...unsigned,
|
|
106
107
|
integrity: Object.freeze({
|
|
107
108
|
algorithm: 'SHA-256',
|
|
108
|
-
|
|
109
|
+
canonicalization: SORTED_JSON_V2,
|
|
110
|
+
digestSha256: await sha256ArtifactDigestV2(unsigned),
|
|
109
111
|
}),
|
|
110
112
|
});
|
|
111
113
|
}
|
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { domainToASCII } from 'node:url';
|
|
3
|
-
import {
|
|
3
|
+
import { canonicalArtifactJsonV2, SORTED_JSON_V2, } from "../frontend/src/lib/analysis/artifact-integrity.js";
|
|
4
|
+
import { buildUnsignedDomainControlPassport, DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_PASSPORT_SCHEMA, MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES, normalizeDomainControlPassportDocument, } from "../frontend/src/lib/analysis/domain-control-manifest-core.js";
|
|
5
|
+
import { canonicalDomainControlRecordList } from "../frontend/src/lib/analysis/domain-control-records.js";
|
|
4
6
|
import { exactKeys } from "./bounded-contract-normalizers.mjs";
|
|
5
|
-
export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA =
|
|
6
|
-
export const DOMAIN_CONTROL_MANIFEST_SCHEMA =
|
|
7
|
-
export
|
|
7
|
+
export const DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA = DOMAIN_CONTROL_PASSPORT_INPUT_SCHEMA;
|
|
8
|
+
export const DOMAIN_CONTROL_MANIFEST_SCHEMA = DOMAIN_CONTROL_PASSPORT_SCHEMA;
|
|
9
|
+
export { DOMAIN_CONTROL_MANIFEST_VERSION };
|
|
8
10
|
export const DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA = 'whoisleuth.domain-control-review-input';
|
|
9
11
|
export const DOMAIN_CONTROL_REVIEW_SCHEMA = 'whoisleuth.domain-control-review';
|
|
10
12
|
export const DOMAIN_CONTROL_REVIEW_VERSION = 1;
|
|
11
|
-
export const MAX_DOMAIN_CONTROL_ENTRIES =
|
|
13
|
+
export const MAX_DOMAIN_CONTROL_ENTRIES = MAX_DOMAIN_CONTROL_PASSPORT_ENTRIES;
|
|
12
14
|
export const MAX_DOMAIN_CONTROL_RECORDS = 32;
|
|
13
|
-
const MANIFEST_KEYS = new Set(['schema', 'version', 'generatedAt', 'expiresAt', 'entries', 'limitations', 'integrity']);
|
|
14
|
-
const MANIFEST_ENTRY_KEYS = new Set(['domain', 'nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock', 'renewalReviewAt', 'note']);
|
|
15
|
-
const MANIFEST_INTEGRITY_KEYS = new Set(['algorithm', 'canonicalization', 'digestSha256']);
|
|
16
|
-
const MANIFEST_INPUT_KEYS = new Set(['schema', 'version', 'expiresAt', 'entries']);
|
|
17
15
|
const REVIEW_INPUT_KEYS = new Set(['schema', 'version', 'manifest', 'observations']);
|
|
18
16
|
const OBSERVATION_KEYS = new Set(['domain', 'fields']);
|
|
19
17
|
const OBSERVATION_FIELDS = new Set(['nameservers', 'ds', 'mx', 'caa', 'tlsIssuer', 'tlsSpkiSha256', 'registrarLock']);
|
|
20
18
|
const OBSERVATION_FIELD_KEYS = new Set(['state', 'values', 'source', 'observedAt']);
|
|
21
|
-
const DOMAIN_CONTROL_LIMITATIONS = Object.freeze([
|
|
22
|
-
'This analyst-authored manifest records intended domain-control state. It does not collect evidence or change registrar, DNS, mail, or certificate configuration.',
|
|
23
|
-
'Empty desired fields are unconfigured rather than claims that a record should be absent.',
|
|
24
|
-
]);
|
|
25
19
|
function record(value) {
|
|
26
20
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
27
21
|
? value
|
|
@@ -70,135 +64,29 @@ function spkiFingerprint(value) {
|
|
|
70
64
|
const text = boundedText(value, 64)?.toLowerCase();
|
|
71
65
|
return text && /^[a-f0-9]{64}$/u.test(text) ? text : null;
|
|
72
66
|
}
|
|
73
|
-
function normalizeEntry(value) {
|
|
74
|
-
const source = record(value);
|
|
75
|
-
const normalizedDomain = domain(source?.domain);
|
|
76
|
-
if (!source || !normalizedDomain)
|
|
77
|
-
return null;
|
|
78
|
-
const registrarLock = source.registrarLock === 'required' || source.registrarLock === 'not_required'
|
|
79
|
-
? source.registrarLock
|
|
80
|
-
: null;
|
|
81
|
-
return Object.freeze({
|
|
82
|
-
domain: normalizedDomain,
|
|
83
|
-
nameservers: Object.freeze(hostnameList(source.nameservers)),
|
|
84
|
-
ds: Object.freeze(textList(source.ds, 500)),
|
|
85
|
-
mx: Object.freeze(hostnameList(source.mx)),
|
|
86
|
-
caa: Object.freeze(textList(source.caa, 500)),
|
|
87
|
-
tlsIssuer: boundedText(source.tlsIssuer, 300)?.toLowerCase() ?? null,
|
|
88
|
-
tlsSpkiSha256: spkiFingerprint(source.tlsSpkiSha256),
|
|
89
|
-
registrarLock,
|
|
90
|
-
renewalReviewAt: timestamp(source.renewalReviewAt),
|
|
91
|
-
note: boundedText(source.note, 500),
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
function unsignedManifest(value) {
|
|
95
|
-
return value;
|
|
96
|
-
}
|
|
97
|
-
function integrityDigest(value) {
|
|
98
|
-
return `sha256:${createHash('sha256').update(canonicalArtifactJson(value)).digest('hex')}`;
|
|
99
|
-
}
|
|
100
67
|
export function buildDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
|
|
101
|
-
const
|
|
102
|
-
if (!source || source.schema !== DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA || source.version !== 1) {
|
|
103
|
-
throw new TypeError(`Domain control manifest input must use ${DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA} version 1.`);
|
|
104
|
-
}
|
|
105
|
-
exactKeys(source, MANIFEST_INPUT_KEYS, 'Domain control manifest input');
|
|
106
|
-
const generatedAt = timestamp(generatedAtValue);
|
|
107
|
-
const expiresAt = timestamp(source.expiresAt);
|
|
108
|
-
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt)) {
|
|
109
|
-
throw new TypeError('Domain control manifest expiry must be a valid time after generation.');
|
|
110
|
-
}
|
|
111
|
-
if (!Array.isArray(source.entries) || source.entries.length < 1 || source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES) {
|
|
112
|
-
throw new TypeError(`Domain control manifest input must contain between 1 and ${MAX_DOMAIN_CONTROL_ENTRIES} entries.`);
|
|
113
|
-
}
|
|
114
|
-
const entries = source.entries.map((entry, index) => {
|
|
115
|
-
exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest input entry ${index + 1}`);
|
|
116
|
-
return normalizeEntry(entry);
|
|
117
|
-
});
|
|
118
|
-
if (entries.some((entry) => entry === null))
|
|
119
|
-
throw new TypeError('Domain control manifest contains an invalid entry.');
|
|
120
|
-
const normalizedEntries = entries;
|
|
121
|
-
const domains = new Set(normalizedEntries.map((entry) => entry.domain));
|
|
122
|
-
if (domains.size !== normalizedEntries.length)
|
|
123
|
-
throw new TypeError('Domain control manifest entries must use unique domains.');
|
|
124
|
-
const base = Object.freeze({
|
|
125
|
-
schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
|
|
126
|
-
version: DOMAIN_CONTROL_MANIFEST_VERSION,
|
|
127
|
-
generatedAt,
|
|
128
|
-
expiresAt,
|
|
129
|
-
entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
|
|
130
|
-
limitations: DOMAIN_CONTROL_LIMITATIONS,
|
|
131
|
-
});
|
|
68
|
+
const unsigned = buildUnsignedDomainControlPassport(input, generatedAtValue);
|
|
132
69
|
return Object.freeze({
|
|
133
|
-
...
|
|
70
|
+
...unsigned,
|
|
134
71
|
integrity: Object.freeze({
|
|
135
72
|
algorithm: 'SHA-256',
|
|
136
|
-
canonicalization:
|
|
137
|
-
digestSha256:
|
|
73
|
+
canonicalization: SORTED_JSON_V2,
|
|
74
|
+
digestSha256: `sha256:${createHash('sha256').update(canonicalArtifactJsonV2(unsigned)).digest('hex')}`,
|
|
138
75
|
}),
|
|
139
76
|
});
|
|
140
77
|
}
|
|
141
78
|
export function verifyDomainControlManifest(value) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
if (!source
|
|
145
|
-
|| source.schema !== DOMAIN_CONTROL_MANIFEST_SCHEMA
|
|
146
|
-
|| source.version !== DOMAIN_CONTROL_MANIFEST_VERSION
|
|
147
|
-
|| !Array.isArray(source.entries)
|
|
148
|
-
|| source.entries.length < 1
|
|
149
|
-
|| source.entries.length > MAX_DOMAIN_CONTROL_ENTRIES
|
|
150
|
-
|| !Array.isArray(source.limitations)
|
|
151
|
-
|| source.limitations.length !== DOMAIN_CONTROL_LIMITATIONS.length
|
|
152
|
-
|| source.limitations.some((limitation, index) => limitation !== DOMAIN_CONTROL_LIMITATIONS[index])
|
|
153
|
-
|| !integrity
|
|
154
|
-
|| integrity.algorithm !== 'SHA-256'
|
|
155
|
-
|| integrity.canonicalization !== 'sorted-json-v1'
|
|
156
|
-
|| typeof integrity.digestSha256 !== 'string'
|
|
157
|
-
|| !/^sha256:[a-f0-9]{64}$/u.test(integrity.digestSha256)) {
|
|
158
|
-
throw new TypeError('Domain control manifest has an unsupported or malformed structure.');
|
|
159
|
-
}
|
|
160
|
-
exactKeys(source, MANIFEST_KEYS, 'Domain control manifest');
|
|
161
|
-
exactKeys(integrity, MANIFEST_INTEGRITY_KEYS, 'Domain control manifest integrity');
|
|
162
|
-
for (const [index, entry] of source.entries.entries()) {
|
|
163
|
-
exactKeys(entry, MANIFEST_ENTRY_KEYS, `Domain control manifest entry ${index + 1}`);
|
|
164
|
-
}
|
|
165
|
-
const generatedAt = timestamp(source.generatedAt);
|
|
166
|
-
const expiresAt = timestamp(source.expiresAt);
|
|
167
|
-
const entries = source.entries.map(normalizeEntry);
|
|
168
|
-
if (!generatedAt || !expiresAt || Date.parse(expiresAt) <= Date.parse(generatedAt) || entries.some((entry) => entry === null)) {
|
|
169
|
-
throw new TypeError('Domain control manifest has invalid normalised content.');
|
|
170
|
-
}
|
|
171
|
-
const normalizedEntries = entries;
|
|
172
|
-
if (new Set(normalizedEntries.map((entry) => entry.domain)).size !== normalizedEntries.length) {
|
|
173
|
-
throw new TypeError('Domain control manifest entries must use unique domains.');
|
|
174
|
-
}
|
|
175
|
-
const { integrity: _integrity, ...unsigned } = source;
|
|
176
|
-
const canonical = Object.freeze({
|
|
177
|
-
schema: DOMAIN_CONTROL_MANIFEST_SCHEMA,
|
|
178
|
-
version: DOMAIN_CONTROL_MANIFEST_VERSION,
|
|
179
|
-
generatedAt,
|
|
180
|
-
expiresAt,
|
|
181
|
-
entries: Object.freeze([...normalizedEntries].sort((left, right) => left.domain.localeCompare(right.domain))),
|
|
182
|
-
limitations: DOMAIN_CONTROL_LIMITATIONS,
|
|
183
|
-
});
|
|
184
|
-
let suppliedCanonical;
|
|
185
|
-
try {
|
|
186
|
-
suppliedCanonical = canonicalArtifactJson(unsigned);
|
|
187
|
-
}
|
|
188
|
-
catch {
|
|
189
|
-
throw new TypeError('Domain control manifest has invalid normalised content.');
|
|
190
|
-
}
|
|
191
|
-
if (suppliedCanonical !== canonicalArtifactJson(canonical)) {
|
|
192
|
-
throw new TypeError('Domain control manifest must use its canonical normalised content.');
|
|
193
|
-
}
|
|
194
|
-
if (`sha256:${createHash('sha256').update(suppliedCanonical).digest('hex')}` !== integrity.digestSha256) {
|
|
79
|
+
const normalized = normalizeDomainControlPassportDocument(value);
|
|
80
|
+
if (`sha256:${createHash('sha256').update(normalized.canonicalUnsigned).digest('hex')}` !== normalized.manifest.integrity.digestSha256) {
|
|
195
81
|
throw new TypeError('Domain control manifest failed its SHA-256 integrity check.');
|
|
196
82
|
}
|
|
197
|
-
return
|
|
83
|
+
return normalized.manifest;
|
|
198
84
|
}
|
|
199
85
|
function fieldValues(field, value) {
|
|
200
|
-
if (field === 'nameservers'
|
|
86
|
+
if (field === 'nameservers')
|
|
201
87
|
return hostnameList(value);
|
|
88
|
+
if (field === 'mx' || field === 'caa' || field === 'ds')
|
|
89
|
+
return canonicalDomainControlRecordList(Array.isArray(value) ? value : [value], field);
|
|
202
90
|
if (field === 'tlsSpkiSha256') {
|
|
203
91
|
const normalized = Array.isArray(value) ? value.map(spkiFingerprint).filter(Boolean) : [spkiFingerprint(value)].filter(Boolean);
|
|
204
92
|
return [...new Set(normalized)].sort().slice(0, MAX_DOMAIN_CONTROL_RECORDS);
|
package/lib/domain-posture.mjs
CHANGED
|
@@ -602,15 +602,18 @@ function buildPostureReport(domain, input) {
|
|
|
602
602
|
summary[item.status] += 1;
|
|
603
603
|
return { domain, summary, checks };
|
|
604
604
|
}
|
|
605
|
-
async function fetchMtaStsPolicy(domain) {
|
|
605
|
+
async function fetchMtaStsPolicy(domain, fetcher = safeFetch) {
|
|
606
606
|
const controller = new AbortController();
|
|
607
607
|
const timeout = setTimeout(() => controller.abort(), POLICY_TIMEOUT_MS);
|
|
608
608
|
try {
|
|
609
609
|
const url = `https://mta-sts.${domain}/.well-known/mta-sts.txt`;
|
|
610
|
-
|
|
610
|
+
// RFC 8461 section 3.3 requires a direct HTTPS 200 response and forbids
|
|
611
|
+
// following HTTP redirects for policy discovery. A zero-hop safe fetch
|
|
612
|
+
// also keeps the returned body bound to the required policy host/path.
|
|
613
|
+
const res = await fetcher(url, {
|
|
611
614
|
signal: controller.signal,
|
|
612
615
|
headers: whoisleuthRequestHeaders({ Accept: 'text/plain' }),
|
|
613
|
-
});
|
|
616
|
+
}, 0);
|
|
614
617
|
if (!res.ok) {
|
|
615
618
|
// Not reading this body - release it explicitly instead of leaving an
|
|
616
619
|
// unconsumed stream (and the connection it's tied to) open until
|
|
@@ -743,4 +746,4 @@ async function checkDomainPosture(domain, { dkimSelectors = [], retiredDkimSelec
|
|
|
743
746
|
externalDependencies,
|
|
744
747
|
};
|
|
745
748
|
}
|
|
746
|
-
export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, buildPostureReport, checkDomainPosture, };
|
|
749
|
+
export { normalizeAuditDomain, normalizeDkimSelectors, normalizeMailProtectionProfile, matchesMtaPattern, fetchMtaStsPolicy, buildPostureReport, checkDomainPosture, };
|