@slicedearth/whoisleuth-cli 2.3.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 +19 -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 +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- 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 +83 -61
- 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-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- 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 +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -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 +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -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/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-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 +45 -0
- package/cli/runner.mjs +55 -6
- 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 +76 -30
- package/docs/cli.md +289 -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 +43 -3
- 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-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- 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 +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- 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 +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- 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-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 +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -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 +117 -146
- 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 +111 -71
- 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/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 +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- 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 +38 -587
- 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 +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- 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 +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -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 +0 -1
- 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 +160 -225
- 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 +155 -27
- 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 +89 -49
- 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 +161 -141
- 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 +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- 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 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { domainToASCII } from 'node:url';
|
|
3
3
|
import { canonicalArtifactJsonFor, } from "../packages/evidence/artifact-integrity.mjs";
|
|
4
|
-
import { DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, } from "../packages/contracts/domain-control-manifest.mjs";
|
|
5
|
-
import { DOMAIN_CONTROL_REVIEW_FIELDS, DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, DOMAIN_CONTROL_REVIEW_COUNT_KEYS, DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_LIMITATIONS, DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS, DOMAIN_CONTROL_REVIEW_ROOT_KEYS, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, } from "../packages/contracts/domain-control-review.mjs";
|
|
6
|
-
import { assertDomainControlManifestByteBudget, buildUnsignedDomainControlManifest, canonicalDomainControlRecordList, normalizeDomainControlManifestDocument, } from "../packages/evidence/domain-control-runtime.mjs";
|
|
4
|
+
import { DOMAIN_CONTROL_MANIFEST_CURRENT_CANONICALIZATION, DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_TEXT_INPUT_BOUND_FACTOR, MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH, MAX_CANONICAL_DOMAIN_CONTROL_RECORDS, MAX_CURRENT_DOMAIN_CONTROL_RECORDS, DOMAIN_CONTROL_RECORD_LIST_FIELDS, DOMAIN_CONTROL_RECORD_MODE_OPTIONS, PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION, MAX_DOMAIN_CONTROL_DS_PRESENTATION_LENGTH, MAX_DOMAIN_CONTROL_INPUT_RECORDS, MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES, MAX_DOMAIN_CONTROL_TIMESTAMP_LENGTH, } from "../packages/contracts/domain-control-manifest.mjs";
|
|
5
|
+
import { DOMAIN_CONTROL_REVIEW_FIELDS, DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, DOMAIN_CONTROL_REVIEW_COUNT_KEYS, DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_KEYS, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_LIMITATIONS, DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS, DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS, DOMAIN_CONTROL_REVIEW_ROOT_KEYS, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION, SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS, PUBLIC_DOMAIN_CONTROL_REVIEW_COUNT_KEYS, PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS, PUBLIC_DOMAIN_CONTROL_REVIEW_LIMITATIONS, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, } from "../packages/contracts/domain-control-review.mjs";
|
|
6
|
+
import { assertDomainControlManifestByteBudget, buildUnsignedDomainControlManifest, canonicalDomainControlRecordList, domainControlRecordMode, domainControlEvidenceAdmission, normalizeDomainControlManifestDocument, } from "../packages/evidence/domain-control-runtime.mjs";
|
|
7
7
|
import { exactKeys } from "./bounded-contract-normalizers.mjs";
|
|
8
8
|
import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
9
9
|
export { DOMAIN_CONTROL_MANIFEST_INPUT_SCHEMA, DOMAIN_CONTROL_MANIFEST_SCHEMA, DOMAIN_CONTROL_MANIFEST_VERSION, DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA, DOMAIN_CONTROL_REVIEW_SCHEMA, DOMAIN_CONTROL_REVIEW_VERSION, };
|
|
10
10
|
export const MAX_DOMAIN_CONTROL_ENTRIES = MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES;
|
|
11
|
-
export const MAX_DOMAIN_CONTROL_RECORDS =
|
|
11
|
+
export const MAX_DOMAIN_CONTROL_RECORDS = MAX_CURRENT_DOMAIN_CONTROL_RECORDS;
|
|
12
12
|
const REVIEW_INPUT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_INPUT_KEYS);
|
|
13
13
|
const OBSERVATION_KEYS = new Set(DOMAIN_CONTROL_REVIEW_OBSERVATION_KEYS);
|
|
14
14
|
const OBSERVATION_FIELDS = new Set(DOMAIN_CONTROL_REVIEW_FIELDS);
|
|
15
15
|
const OBSERVATION_FIELD_KEYS = new Set(DOMAIN_CONTROL_REVIEW_OBSERVATION_FIELD_KEYS);
|
|
16
16
|
const REVIEW_ROOT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_ROOT_KEYS);
|
|
17
17
|
const REVIEW_MANIFEST_KEYS = new Set(DOMAIN_CONTROL_REVIEW_MANIFEST_SUMMARY_KEYS);
|
|
18
|
-
const REVIEW_COUNT_KEYS = new Set(DOMAIN_CONTROL_REVIEW_COUNT_KEYS);
|
|
19
18
|
const REVIEW_DOMAIN_KEYS = new Set(DOMAIN_CONTROL_REVIEW_DOMAIN_KEYS);
|
|
20
19
|
const REVIEW_COMPARISON_KEYS = new Set(DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS);
|
|
21
20
|
function record(value) {
|
|
@@ -186,25 +185,25 @@ function boundedDataArray(value, maximum) {
|
|
|
186
185
|
return null;
|
|
187
186
|
}
|
|
188
187
|
}
|
|
189
|
-
function fieldValues(field, value) {
|
|
188
|
+
function fieldValues(field, value, maximum) {
|
|
190
189
|
if (field === 'nameservers') {
|
|
191
190
|
const normalized = value.map(domain);
|
|
192
191
|
if (normalized.some((item) => item === null))
|
|
193
192
|
return null;
|
|
194
193
|
const values = hostnameList(normalized);
|
|
195
|
-
return values.length <=
|
|
194
|
+
return values.length <= maximum ? values : null;
|
|
196
195
|
}
|
|
197
196
|
if (field === 'mx' || field === 'caa' || field === 'ds') {
|
|
198
197
|
const normalized = value.flatMap((item) => canonicalDomainControlRecordList([item], field));
|
|
199
198
|
const values = [...new Set(normalized)].sort();
|
|
200
|
-
return normalized.length === value.length && values.length <=
|
|
199
|
+
return normalized.length === value.length && values.length <= maximum ? values : null;
|
|
201
200
|
}
|
|
202
201
|
if (field === 'tlsSpkiSha256') {
|
|
203
202
|
const normalized = value.map(spkiFingerprint);
|
|
204
203
|
if (normalized.some((item) => item === null))
|
|
205
204
|
return null;
|
|
206
205
|
const values = [...new Set(normalized)].sort();
|
|
207
|
-
return values.length <=
|
|
206
|
+
return values.length <= maximum ? values : null;
|
|
208
207
|
}
|
|
209
208
|
if (field === 'registrarLock') {
|
|
210
209
|
return value.every((item) => item === 'required' || item === 'not_required')
|
|
@@ -215,9 +214,9 @@ function fieldValues(field, value) {
|
|
|
215
214
|
if (normalized.some((item) => item === null))
|
|
216
215
|
return null;
|
|
217
216
|
const values = textList(normalized, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH);
|
|
218
|
-
return values.length <=
|
|
217
|
+
return values.length <= maximum ? values : null;
|
|
219
218
|
}
|
|
220
|
-
function normalizeObservation(value) {
|
|
219
|
+
function normalizeObservation(value, legacy) {
|
|
221
220
|
const source = record(value);
|
|
222
221
|
const normalizedDomain = domain(source?.domain);
|
|
223
222
|
const rawFields = record(source?.fields);
|
|
@@ -242,12 +241,12 @@ function normalizeObservation(value) {
|
|
|
242
241
|
const values = boundedDataArray(raw.values, MAX_DOMAIN_CONTROL_INPUT_RECORDS);
|
|
243
242
|
if (!sourceLabel || !values)
|
|
244
243
|
return null;
|
|
245
|
-
const normalizedValues = fieldValues(field, values);
|
|
246
|
-
const observedAt = raw.observedAt === null ? null : timestamp(raw.observedAt,
|
|
244
|
+
const normalizedValues = fieldValues(field, values, legacy ? MAX_CANONICAL_DOMAIN_CONTROL_RECORDS : MAX_DOMAIN_CONTROL_RECORDS);
|
|
245
|
+
const observedAt = raw.observedAt === null ? null : timestamp(raw.observedAt, legacy);
|
|
247
246
|
if (!normalizedValues || (raw.observedAt !== null && !observedAt))
|
|
248
247
|
return null;
|
|
249
248
|
fields[field] = Object.freeze({
|
|
250
|
-
state,
|
|
249
|
+
state: field === 'registrarLock' && state === 'observed' && normalizedValues.length !== 1 ? 'partial' : state,
|
|
251
250
|
values: Object.freeze(normalizedValues),
|
|
252
251
|
source: sourceLabel,
|
|
253
252
|
observedAt,
|
|
@@ -264,18 +263,22 @@ function desiredValues(entry, field) {
|
|
|
264
263
|
return entry.registrarLock ? [entry.registrarLock] : [];
|
|
265
264
|
return [...entry[field]];
|
|
266
265
|
}
|
|
267
|
-
function compareField(entry, observation, field) {
|
|
266
|
+
function compareField(entry, observation, field, now) {
|
|
268
267
|
const desired = desiredValues(entry, field);
|
|
269
|
-
|
|
268
|
+
const expectation = DOMAIN_CONTROL_RECORD_LIST_FIELDS.includes(field)
|
|
269
|
+
? domainControlRecordMode(entry, field) : desired.length ? 'expect_records' : 'unconfigured';
|
|
270
|
+
if (expectation === 'unconfigured')
|
|
270
271
|
return Object.freeze({
|
|
271
272
|
field, state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
|
|
272
273
|
explanation: 'No desired value was configured for this field.',
|
|
274
|
+
expectation,
|
|
273
275
|
});
|
|
274
276
|
const evidence = observation?.fields[field];
|
|
275
277
|
if (!evidence)
|
|
276
278
|
return Object.freeze({
|
|
277
279
|
field, state: 'unavailable', desired: Object.freeze(desired), observed: Object.freeze([]), source: null, observedAt: null,
|
|
278
280
|
explanation: 'No separately attributed observation was supplied for this field.',
|
|
281
|
+
expectation,
|
|
279
282
|
});
|
|
280
283
|
if (evidence.state !== 'observed')
|
|
281
284
|
return Object.freeze({
|
|
@@ -288,7 +291,13 @@ function compareField(entry, observation, field) {
|
|
|
288
291
|
explanation: evidence.state === 'partial'
|
|
289
292
|
? 'The supplied observation was partial, so a difference is inconclusive.'
|
|
290
293
|
: `The supplied source reported this field as ${evidence.state}.`,
|
|
294
|
+
expectation,
|
|
291
295
|
});
|
|
296
|
+
const limitation = domainControlEvidenceAdmission(evidence.observedAt, now);
|
|
297
|
+
if (limitation)
|
|
298
|
+
return Object.freeze({ field, state: 'partial', desired: Object.freeze(desired), observed: evidence.values, source: evidence.source, observedAt: evidence.observedAt, explanation: limitation, expectation });
|
|
299
|
+
if (expectation === 'observe_only')
|
|
300
|
+
return Object.freeze({ field, state: 'observed', desired: Object.freeze([]), observed: evidence.values, source: evidence.source, observedAt: evidence.observedAt, explanation: 'Observation only: no required record set is compared.', expectation });
|
|
292
301
|
const aligned = desired.length === evidence.values.length && desired.every((item, index) => item === evidence.values[index]);
|
|
293
302
|
return Object.freeze({
|
|
294
303
|
field,
|
|
@@ -297,9 +306,12 @@ function compareField(entry, observation, field) {
|
|
|
297
306
|
observed: evidence.values,
|
|
298
307
|
source: evidence.source,
|
|
299
308
|
observedAt: evidence.observedAt,
|
|
300
|
-
explanation:
|
|
301
|
-
? 'The complete
|
|
302
|
-
:
|
|
309
|
+
explanation: expectation === 'expect_none'
|
|
310
|
+
? aligned ? 'The complete source observation contains no records, as expected.' : 'The complete source observation contains records where none are expected.'
|
|
311
|
+
: aligned
|
|
312
|
+
? 'The complete supplied observation matches the desired value.'
|
|
313
|
+
: 'The complete supplied observation differs from the desired value.',
|
|
314
|
+
expectation,
|
|
303
315
|
});
|
|
304
316
|
}
|
|
305
317
|
function renewalComparison(entry, now) {
|
|
@@ -307,16 +319,36 @@ function renewalComparison(entry, now) {
|
|
|
307
319
|
return Object.freeze({
|
|
308
320
|
field: 'renewalReviewAt', state: 'not_configured', desired: Object.freeze([]), observed: Object.freeze([]), source: null, observedAt: null,
|
|
309
321
|
explanation: 'No renewal review date was configured.',
|
|
322
|
+
expectation: 'unconfigured',
|
|
310
323
|
});
|
|
311
324
|
const due = Date.parse(entry.renewalReviewAt) <= Date.parse(now);
|
|
312
325
|
return Object.freeze({
|
|
313
326
|
field: 'renewalReviewAt', state: due ? 'due' : 'aligned', desired: Object.freeze([entry.renewalReviewAt]), observed: Object.freeze([]), source: null, observedAt: null,
|
|
314
327
|
explanation: due ? 'The configured renewal review date is due.' : 'The configured renewal review date is still in the future.',
|
|
328
|
+
expectation: 'expect_records',
|
|
315
329
|
});
|
|
316
330
|
}
|
|
331
|
+
function aggregateComparisonState(comparisons, legacy = false) {
|
|
332
|
+
if (comparisons.some((item) => item.state === 'drift' || item.state === 'due'))
|
|
333
|
+
return 'drift';
|
|
334
|
+
if (comparisons.some((item) => ['partial', 'unavailable', 'unsupported'].includes(item.state)))
|
|
335
|
+
return 'partial';
|
|
336
|
+
if (legacy || comparisons.some((item) => item.state === 'aligned'))
|
|
337
|
+
return 'aligned';
|
|
338
|
+
return comparisons.some((item) => item.state === 'observed') ? 'observed' : 'not_configured';
|
|
339
|
+
}
|
|
340
|
+
function aggregateDomainState(domains, legacy = false) {
|
|
341
|
+
if (domains.some((item) => item.state === 'drift'))
|
|
342
|
+
return 'drift';
|
|
343
|
+
if (domains.some((item) => item.state === 'partial'))
|
|
344
|
+
return 'partial';
|
|
345
|
+
if (legacy || domains.some((item) => item.state === 'aligned'))
|
|
346
|
+
return 'aligned';
|
|
347
|
+
return domains.some((item) => item.state === 'observed') ? 'observed' : 'not_configured';
|
|
348
|
+
}
|
|
317
349
|
export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
|
|
318
350
|
const source = record(input);
|
|
319
|
-
if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version
|
|
351
|
+
if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS.includes(source.version)) {
|
|
320
352
|
throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
|
|
321
353
|
}
|
|
322
354
|
exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
|
|
@@ -324,11 +356,14 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
324
356
|
if (!generatedAt)
|
|
325
357
|
throw new TypeError('Domain control review time must be valid and include an explicit timezone.');
|
|
326
358
|
const manifest = verifyDomainControlManifest(source.manifest);
|
|
359
|
+
const legacy = source.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
|
|
360
|
+
if (legacy && manifest.version !== PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION)
|
|
361
|
+
throw new TypeError('Public review input requires its published manifest contract; use the current review input version for explicit expectations.');
|
|
327
362
|
const rawObservations = boundedDataArray(source.observations, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS);
|
|
328
363
|
if (!rawObservations) {
|
|
329
364
|
throw new TypeError('Domain control observations must be a bounded array.');
|
|
330
365
|
}
|
|
331
|
-
const observations = rawObservations.map(normalizeObservation);
|
|
366
|
+
const observations = rawObservations.map((value) => normalizeObservation(value, legacy));
|
|
332
367
|
if (observations.some((item) => item === null))
|
|
333
368
|
throw new TypeError('Domain control review contains an invalid observation.');
|
|
334
369
|
const normalizedObservations = observations;
|
|
@@ -339,23 +374,19 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
339
374
|
const fields = DOMAIN_CONTROL_REVIEW_FIELDS;
|
|
340
375
|
const domains = manifest.entries.map((entry) => {
|
|
341
376
|
const observation = observationMap.get(entry.domain) ?? null;
|
|
342
|
-
const comparisons = [...fields.map((field) => compareField(entry, observation, field)), renewalComparison(entry, generatedAt)];
|
|
343
|
-
const state = comparisons
|
|
344
|
-
? 'drift'
|
|
345
|
-
: comparisons.some((item) => item.state === 'partial' || item.state === 'unavailable' || item.state === 'unsupported')
|
|
346
|
-
? 'partial'
|
|
347
|
-
: 'aligned';
|
|
377
|
+
const comparisons = [...fields.map((field) => compareField(entry, observation, field, generatedAt)), renewalComparison(entry, generatedAt)];
|
|
378
|
+
const state = aggregateComparisonState(comparisons);
|
|
348
379
|
return Object.freeze({ domain: entry.domain, state, comparisons: Object.freeze(comparisons) });
|
|
349
380
|
});
|
|
350
381
|
const comparisons = domains.flatMap((item) => item.comparisons);
|
|
351
|
-
const counts = Object.fromEntries(
|
|
382
|
+
const counts = Object.fromEntries(DOMAIN_CONTROL_REVIEW_COUNT_KEYS
|
|
352
383
|
.map((state) => [state, comparisons.filter((item) => item.state === state).length]));
|
|
353
384
|
const expired = Date.parse(manifest.expiresAt) <= Date.parse(generatedAt);
|
|
354
385
|
return Object.freeze({
|
|
355
386
|
schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
|
|
356
387
|
version: DOMAIN_CONTROL_REVIEW_VERSION,
|
|
357
388
|
generatedAt,
|
|
358
|
-
state: expired ? 'expired' :
|
|
389
|
+
state: expired ? 'expired' : aggregateDomainState(domains),
|
|
359
390
|
manifest: Object.freeze({
|
|
360
391
|
generatedAt: manifest.generatedAt,
|
|
361
392
|
expiresAt: manifest.expiresAt,
|
|
@@ -370,9 +401,14 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
370
401
|
}
|
|
371
402
|
export function validateDomainControlReviewDocument(value) {
|
|
372
403
|
const root = exactReviewRecord(value, REVIEW_ROOT_KEYS, 'Domain control review document');
|
|
373
|
-
if (root.schema !== DOMAIN_CONTROL_REVIEW_SCHEMA || root.version
|
|
404
|
+
if (root.schema !== DOMAIN_CONTROL_REVIEW_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS.includes(root.version)) {
|
|
374
405
|
throw new TypeError(`Domain control review document must use ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
|
|
375
406
|
}
|
|
407
|
+
const legacy = root.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
|
|
408
|
+
const countKeys = legacy ? PUBLIC_DOMAIN_CONTROL_REVIEW_COUNT_KEYS : DOMAIN_CONTROL_REVIEW_COUNT_KEYS;
|
|
409
|
+
const comparisonKeys = legacy ? new Set(PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS) : REVIEW_COMPARISON_KEYS;
|
|
410
|
+
const recordMaximum = legacy ? MAX_CANONICAL_DOMAIN_CONTROL_RECORDS : MAX_DOMAIN_CONTROL_RECORDS;
|
|
411
|
+
const limitations = legacy ? PUBLIC_DOMAIN_CONTROL_REVIEW_LIMITATIONS : DOMAIN_CONTROL_REVIEW_LIMITATIONS;
|
|
376
412
|
const generatedAt = exactReviewTimestamp(root.generatedAt, 'Domain control review document generatedAt');
|
|
377
413
|
const manifestInput = exactReviewRecord(root.manifest, REVIEW_MANIFEST_KEYS, 'Domain control review document manifest');
|
|
378
414
|
const manifestGeneratedAt = exactReviewTimestamp(manifestInput.generatedAt, 'Domain control review document manifest generatedAt');
|
|
@@ -390,9 +426,9 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
390
426
|
digestSha256: manifestInput.digestSha256,
|
|
391
427
|
expired,
|
|
392
428
|
});
|
|
393
|
-
const countInput = exactReviewRecord(root.counts,
|
|
429
|
+
const countInput = exactReviewRecord(root.counts, new Set(countKeys), 'Domain control review document counts');
|
|
394
430
|
const maximumComparisons = MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES * (DOMAIN_CONTROL_REVIEW_FIELDS.length + 1);
|
|
395
|
-
const suppliedCounts = Object.fromEntries(
|
|
431
|
+
const suppliedCounts = Object.fromEntries(countKeys.map((state) => [
|
|
396
432
|
state,
|
|
397
433
|
exactReviewInteger(countInput[state], maximumComparisons, `Domain control review document count ${state}`),
|
|
398
434
|
]));
|
|
@@ -401,8 +437,8 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
401
437
|
throw new TypeError('Domain control review document domains must be a bounded non-empty array.');
|
|
402
438
|
}
|
|
403
439
|
const comparisonFields = [...DOMAIN_CONTROL_REVIEW_FIELDS, 'renewalReviewAt'];
|
|
404
|
-
const comparisonStates = new Set(
|
|
405
|
-
const calculatedCounts = Object.fromEntries(
|
|
440
|
+
const comparisonStates = new Set(countKeys);
|
|
441
|
+
const calculatedCounts = Object.fromEntries(countKeys.map((state) => [state, 0]));
|
|
406
442
|
const domains = domainInput.map((domainValue, domainIndex) => {
|
|
407
443
|
const item = exactReviewRecord(domainValue, REVIEW_DOMAIN_KEYS, `Domain control review document domain ${domainIndex + 1}`);
|
|
408
444
|
const normalizedDomain = domain(item.domain);
|
|
@@ -414,7 +450,7 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
414
450
|
throw new TypeError(`Domain control review document domain ${domainIndex + 1} comparisons are incomplete.`);
|
|
415
451
|
}
|
|
416
452
|
const comparisons = comparisonsInput.map((comparisonValue, comparisonIndex) => {
|
|
417
|
-
const comparison = exactReviewRecord(comparisonValue,
|
|
453
|
+
const comparison = exactReviewRecord(comparisonValue, comparisonKeys, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1}`);
|
|
418
454
|
const expectedField = comparisonFields[comparisonIndex];
|
|
419
455
|
if (comparison.field !== expectedField
|
|
420
456
|
|| typeof comparison.state !== 'string'
|
|
@@ -428,8 +464,8 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
428
464
|
: expectedField === 'caa'
|
|
429
465
|
? MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH
|
|
430
466
|
: MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH;
|
|
431
|
-
const desired = exactReviewStringArray(comparison.desired,
|
|
432
|
-
const observed = exactReviewStringArray(comparison.observed,
|
|
467
|
+
const desired = exactReviewStringArray(comparison.desired, recordMaximum, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} desired values`);
|
|
468
|
+
const observed = exactReviewStringArray(comparison.observed, recordMaximum, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} observed values`);
|
|
433
469
|
const source = comparison.source === null
|
|
434
470
|
? null
|
|
435
471
|
: exactReviewText(comparison.source, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} source`);
|
|
@@ -439,6 +475,41 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
439
475
|
if (expectedField === 'renewalReviewAt' && (observed.length || source !== null || observedAt !== null)) {
|
|
440
476
|
throw new TypeError(`Domain control review document domain ${domainIndex + 1} renewal comparison is inconsistent.`);
|
|
441
477
|
}
|
|
478
|
+
let expectation;
|
|
479
|
+
if (!legacy) {
|
|
480
|
+
if (expectedField !== 'renewalReviewAt') {
|
|
481
|
+
for (const values of [desired, observed]) {
|
|
482
|
+
const canonical = fieldValues(expectedField, values, recordMaximum);
|
|
483
|
+
if (!canonical || canonical.length !== values.length || canonical.some((value, index) => value !== values[index]))
|
|
484
|
+
throw new TypeError('Domain control review values are not canonical for their field.');
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!DOMAIN_CONTROL_RECORD_MODE_OPTIONS.some((mode) => mode.value === comparison.expectation))
|
|
488
|
+
throw new TypeError('Domain control review comparison expectation is invalid.');
|
|
489
|
+
expectation = comparison.expectation;
|
|
490
|
+
if ((expectation === 'expect_records') !== (desired.length > 0)
|
|
491
|
+
|| (!DOMAIN_CONTROL_RECORD_LIST_FIELDS.includes(expectedField) && !['unconfigured', 'expect_records'].includes(expectation))
|
|
492
|
+
|| (expectation === 'unconfigured' && (state !== 'not_configured' || observed.length || source !== null || observedAt !== null))
|
|
493
|
+
|| (state === 'not_configured' && expectation !== 'unconfigured')
|
|
494
|
+
|| (state === 'observed' && expectation !== 'observe_only')
|
|
495
|
+
|| (['aligned', 'drift', 'due'].includes(state) && ['unconfigured', 'observe_only'].includes(expectation))) {
|
|
496
|
+
throw new TypeError('Domain control review comparison expectation is inconsistent.');
|
|
497
|
+
}
|
|
498
|
+
if (expectedField === 'renewalReviewAt' && expectation === 'expect_records') {
|
|
499
|
+
if (desired.length !== 1 || normalizeExplicitIsoTimestamp(desired[0]) !== desired[0]
|
|
500
|
+
|| state !== (Date.parse(desired[0]) <= Date.parse(generatedAt) ? 'due' : 'aligned'))
|
|
501
|
+
throw new TypeError('Domain control renewal review state is inconsistent.');
|
|
502
|
+
}
|
|
503
|
+
else if (state === 'due')
|
|
504
|
+
throw new TypeError('Only renewal review dates can be due.');
|
|
505
|
+
if (expectedField !== 'renewalReviewAt' && ['aligned', 'drift', 'observed'].includes(state)) {
|
|
506
|
+
if (!source || domainControlEvidenceAdmission(observedAt, generatedAt))
|
|
507
|
+
throw new TypeError('Domain control review requires current source timing for a conclusive comparison.');
|
|
508
|
+
const equal = desired.length === observed.length && desired.every((value, index) => value === observed[index]);
|
|
509
|
+
if ((state === 'aligned' && !equal) || (state === 'drift' && equal))
|
|
510
|
+
throw new TypeError('Domain control review comparison result does not match its records.');
|
|
511
|
+
}
|
|
512
|
+
}
|
|
442
513
|
return Object.freeze({
|
|
443
514
|
field: expectedField,
|
|
444
515
|
state,
|
|
@@ -447,15 +518,10 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
447
518
|
source,
|
|
448
519
|
observedAt,
|
|
449
520
|
explanation: exactReviewText(comparison.explanation, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} explanation`),
|
|
521
|
+
...(expectation !== undefined ? { expectation } : {}),
|
|
450
522
|
});
|
|
451
523
|
});
|
|
452
|
-
const expectedState = comparisons
|
|
453
|
-
? 'drift'
|
|
454
|
-
: comparisons.some((comparison) => comparison.state === 'partial'
|
|
455
|
-
|| comparison.state === 'unavailable'
|
|
456
|
-
|| comparison.state === 'unsupported')
|
|
457
|
-
? 'partial'
|
|
458
|
-
: 'aligned';
|
|
524
|
+
const expectedState = aggregateComparisonState(comparisons, legacy);
|
|
459
525
|
if (item.state !== expectedState) {
|
|
460
526
|
throw new TypeError(`Domain control review document domain ${domainIndex + 1} state is inconsistent.`);
|
|
461
527
|
}
|
|
@@ -464,37 +530,31 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
464
530
|
if (new Set(domains.map((item) => item.domain)).size !== domains.length) {
|
|
465
531
|
throw new TypeError('Domain control review document domains must be unique.');
|
|
466
532
|
}
|
|
467
|
-
for (const state of
|
|
533
|
+
for (const state of countKeys) {
|
|
468
534
|
if (suppliedCounts[state] !== calculatedCounts[state]) {
|
|
469
535
|
throw new TypeError('Domain control review document counts are inconsistent.');
|
|
470
536
|
}
|
|
471
537
|
}
|
|
472
|
-
const expectedState = expired
|
|
473
|
-
? 'expired'
|
|
474
|
-
: domains.some((item) => item.state === 'drift')
|
|
475
|
-
? 'drift'
|
|
476
|
-
: domains.some((item) => item.state === 'partial')
|
|
477
|
-
? 'partial'
|
|
478
|
-
: 'aligned';
|
|
538
|
+
const expectedState = expired ? 'expired' : aggregateDomainState(domains, legacy);
|
|
479
539
|
if (root.state !== expectedState)
|
|
480
540
|
throw new TypeError('Domain control review document state is inconsistent.');
|
|
481
541
|
const ignoredObservationCount = exactReviewInteger(root.ignoredObservationCount, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, 'Domain control review document ignored observation count');
|
|
482
|
-
const limitationsInput = boundedDataArray(root.limitations,
|
|
542
|
+
const limitationsInput = boundedDataArray(root.limitations, limitations.length);
|
|
483
543
|
if (!limitationsInput
|
|
484
|
-
|| limitationsInput.length !==
|
|
485
|
-
|| limitationsInput.some((item, index) => item !==
|
|
544
|
+
|| limitationsInput.length !== limitations.length
|
|
545
|
+
|| limitationsInput.some((item, index) => item !== limitations[index])) {
|
|
486
546
|
throw new TypeError('Domain control review document limitations are invalid.');
|
|
487
547
|
}
|
|
488
548
|
return Object.freeze({
|
|
489
549
|
schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
|
|
490
|
-
version:
|
|
550
|
+
version: root.version,
|
|
491
551
|
generatedAt,
|
|
492
552
|
state: expectedState,
|
|
493
553
|
manifest,
|
|
494
554
|
counts: Object.freeze(suppliedCounts),
|
|
495
555
|
domains: Object.freeze(domains),
|
|
496
556
|
ignoredObservationCount,
|
|
497
|
-
limitations
|
|
557
|
+
limitations,
|
|
498
558
|
});
|
|
499
559
|
}
|
|
500
560
|
export function formatDomainControlResult(value) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// injected so every traversal can be fixture-tested and the caller retains the
|
|
3
3
|
// existing DNS timeout and missing/error semantics.
|
|
4
4
|
import { parse as parseDomain } from 'tldts';
|
|
5
|
-
import { parseDmarcRecords, parseSpfRecords } from "./domain-posture-parsers.mjs";
|
|
5
|
+
import { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
|
|
6
6
|
const SPF_LOOKUP_LIMIT = 10;
|
|
7
7
|
const SPF_VOID_LOOKUP_LIMIT = 2;
|
|
8
8
|
const SPF_MAX_DEPTH = 5;
|
|
@@ -158,12 +158,15 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
|
158
158
|
};
|
|
159
159
|
}
|
|
160
160
|
function reportDestinationDomain(value) {
|
|
161
|
-
|
|
162
|
-
if (!/^mailto:/iu.test(raw))
|
|
161
|
+
if (typeof value !== 'string')
|
|
163
162
|
return null;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
163
|
+
// The obsolete DMARC report-size suffix remains readable, but arbitrary
|
|
164
|
+
// text after an exclamation mark must not repair a malformed destination.
|
|
165
|
+
const uri = value.trim().replace(/![0-9]+[kmgt]?$/iu, '');
|
|
166
|
+
if (/[!,;]/u.test(uri))
|
|
167
|
+
return null;
|
|
168
|
+
const destination = parseReportingDestination(uri);
|
|
169
|
+
return destination?.scheme === 'mailto' ? strictHostname(destination.host) : null;
|
|
167
170
|
}
|
|
168
171
|
async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt) {
|
|
169
172
|
if (dmarcQuery.error)
|
|
@@ -200,12 +203,22 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
|
|
|
200
203
|
continue;
|
|
201
204
|
}
|
|
202
205
|
const recordName = `${ownerDomain}._report._dmarc.${destinationDomain}`;
|
|
206
|
+
if (recordName.length > 253) {
|
|
207
|
+
results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable', error: 'The reporting authorisation query exceeds the DNS name limit.' });
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
203
210
|
let query = cache.get(recordName);
|
|
204
211
|
if (!query) {
|
|
205
212
|
query = await resolveTxt(recordName);
|
|
206
213
|
cache.set(recordName, query);
|
|
207
214
|
}
|
|
208
|
-
const authorized = query.records.some((record) =>
|
|
215
|
+
const authorized = query.records.some((record) => {
|
|
216
|
+
const tags = parseDmarcReportingAuthorization(record);
|
|
217
|
+
if (!tags)
|
|
218
|
+
return false;
|
|
219
|
+
// A recipient override cannot redirect authorisation to another host.
|
|
220
|
+
return tags.rua === undefined || tags.rua.split(',').every((destination) => reportDestinationDomain(destination) === destinationDomain);
|
|
221
|
+
});
|
|
209
222
|
results.push({
|
|
210
223
|
destination: destinationDomain,
|
|
211
224
|
reportType: item.reportType,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// synthetic records and the Express/Netlify paths share identical behavior.
|
|
4
4
|
import { createPublicKey } from 'node:crypto';
|
|
5
5
|
import { isIP } from 'node:net';
|
|
6
|
+
import { domainToASCII } from 'node:url';
|
|
6
7
|
function joinTxtRecords(records) {
|
|
7
8
|
if (!Array.isArray(records))
|
|
8
9
|
return [];
|
|
@@ -76,8 +77,9 @@ function parseSpfRecords(records) {
|
|
|
76
77
|
return true;
|
|
77
78
|
}
|
|
78
79
|
const hostname = value.toLowerCase().replace(/\.$/u, '');
|
|
79
|
-
if (hostname.length > 253 || !hostname.includes('.') || !hostname.split('.').every((part) => /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/u.test(part))) {
|
|
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))) {
|
|
80
81
|
result.issues.push(`${label} has an invalid literal domain-spec.`);
|
|
82
|
+
invalidSyntax = true;
|
|
81
83
|
return false;
|
|
82
84
|
}
|
|
83
85
|
return true;
|
|
@@ -195,9 +197,73 @@ function parseSpfRecords(records) {
|
|
|
195
197
|
result.valid = !invalidSyntax;
|
|
196
198
|
return result;
|
|
197
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
|
+
}
|
|
198
264
|
function parseDmarcRecords(records) {
|
|
199
265
|
const allRecords = joinTxtRecords(records);
|
|
200
|
-
const dmarcRecords = allRecords.filter(
|
|
266
|
+
const dmarcRecords = allRecords.filter(hasDmarcVersion);
|
|
201
267
|
const result = {
|
|
202
268
|
records: dmarcRecords,
|
|
203
269
|
valid: false,
|
|
@@ -255,8 +321,12 @@ function parseDmarcRecords(records) {
|
|
|
255
321
|
result.failureReporting = Boolean(parsed.tags.ruf);
|
|
256
322
|
result.aggregateDestinations = (parsed.tags.rua || '').split(',').map((value) => value.trim()).filter(Boolean);
|
|
257
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.');
|
|
258
327
|
result.valid = parsed.duplicates.length === 0
|
|
259
328
|
&& parsed.malformed.length === 0
|
|
329
|
+
&& !emptyDestination
|
|
260
330
|
&& Boolean(result.policy && result.subdomainPolicy && result.nonexistentSubdomainPolicy)
|
|
261
331
|
&& (!parsed.tags.t || /^[yn]$/i.test(parsed.tags.t));
|
|
262
332
|
return result;
|
|
@@ -333,7 +403,7 @@ function parseMtaStsPolicy(text) {
|
|
|
333
403
|
}
|
|
334
404
|
function parseTlsRptRecords(records) {
|
|
335
405
|
const allRecords = joinTxtRecords(records);
|
|
336
|
-
const policyRecords = allRecords.filter((record) => /^v\
|
|
406
|
+
const policyRecords = allRecords.filter((record) => /^v=TLSRPTv1(?:[ \t]*;|$)/u.test(record));
|
|
337
407
|
const result = { records: policyRecords, valid: false, record: policyRecords[0] || null, rua: [], issues: [] };
|
|
338
408
|
if (policyRecords.length === 0) {
|
|
339
409
|
result.issues.push('No TLS-RPT policy record was found.');
|
|
@@ -344,14 +414,26 @@ function parseTlsRptRecords(records) {
|
|
|
344
414
|
return result;
|
|
345
415
|
}
|
|
346
416
|
const parsed = parseTagList(policyRecords[0]);
|
|
347
|
-
|
|
417
|
+
const destinations = (parsed.tags.rua || '').split(',').map((value) => value.trim());
|
|
418
|
+
result.rua = destinations.filter(Boolean);
|
|
348
419
|
if (result.rua.length === 0)
|
|
349
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.');
|
|
350
432
|
if (parsed.duplicates.length)
|
|
351
433
|
result.issues.push(`Duplicate TLS-RPT tag${parsed.duplicates.length === 1 ? '' : 's'}: ${parsed.duplicates.join(', ')}.`);
|
|
352
434
|
if (parsed.malformed.length)
|
|
353
435
|
result.issues.push(`Malformed TLS-RPT field${parsed.malformed.length === 1 ? '' : 's'}: ${parsed.malformed.join(', ')}.`);
|
|
354
|
-
result.valid = result.
|
|
436
|
+
result.valid = result.issues.length === 0;
|
|
355
437
|
return result;
|
|
356
438
|
}
|
|
357
439
|
function parseBimiRecords(records) {
|
|
@@ -466,4 +548,4 @@ function parseDkimRecords(selector, records) {
|
|
|
466
548
|
result.valid = result.issues.length === 0;
|
|
467
549
|
return result;
|
|
468
550
|
}
|
|
469
|
-
export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };
|
|
551
|
+
export { joinTxtRecords, parseTagList, parseSpfRecords, parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseMtaStsDnsRecords, parseMtaStsPolicy, parseTlsRptRecords, parseBimiRecords, parseDkimRecords, };
|