@slicedearth/whoisleuth-cli 2.3.0 → 2.5.1
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 +8 -2
- 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/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -408
- package/cli/artifact-validation/case-response.mjs +65 -44
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +15 -9
- package/cli/artifact-validation/signed-review.mjs +136 -68
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +92 -24
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +40 -5
- 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 +1 -1
- package/cli/bulk-command-runner.mjs +4 -2
- package/cli/bulk-output.mjs +29 -3
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +212 -0
- package/cli/case-pack.mjs +50 -32
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -206
- package/cli/command-definition.mjs +329 -0
- package/cli/command-reference.mjs +94 -1357
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +8 -6
- package/cli/ct-event-intake.mjs +16 -16
- package/cli/discover.mjs +5 -4
- package/cli/discovery-observation-snapshot.mjs +46 -18
- package/cli/discovery-scan-command-runner.mjs +5 -3
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +5 -5
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +151 -103
- package/cli/errors.mjs +37 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +18 -8
- package/cli/evidence-signing.mjs +5 -4
- package/cli/external-observation-mapping.mjs +9 -10
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +24 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +33 -0
- package/cli/inline-command-families.mjs +6 -46
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +107 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +8 -10
- 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 +38 -147
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +256 -35
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/local-mmdb-review.mjs +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +33 -39
- package/cli/lookup-diff.mjs +47 -12
- package/cli/lookup-plan.mjs +16 -14
- package/cli/lookup-reconcile.mjs +15 -12
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +75 -21
- package/cli/manual.mjs +1 -0
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +45 -31
- package/cli/offline-evidence-review.mjs +39 -6
- package/cli/output-file.mjs +31 -5
- package/cli/posture.mjs +1 -1
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +10 -9
- package/cli/review-arguments.mjs +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +104 -149
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +74 -37
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -0
- package/cli/sharing-review.mjs +8 -80
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +75 -84
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +2 -2
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +75 -13
- package/docs/cli-reference.md +103 -30
- package/docs/cli.md +510 -25
- package/docs/contextual-reviews.md +155 -0
- package/lib/abort.mjs +4 -1
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +27 -754
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +2 -290
- package/lib/browser-library-profile.mjs +99 -130
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +89 -46
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +36 -22
- package/lib/dns-inheritance-review.mjs +207 -0
- package/lib/dns-intelligence.mjs +44 -29
- package/lib/dns-mx.mjs +1 -1
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +18 -14
- package/lib/domain-change-review.mjs +40 -16
- package/lib/domain-control-flight-recorder.mjs +301 -124
- package/lib/domain-control-manifest.mjs +126 -58
- package/lib/domain-posture-analysis.mjs +41 -11
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +120 -32
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +568 -0
- package/lib/evidence-export.mjs +12 -593
- package/lib/evidence-report.mjs +7 -2
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +50 -4
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +2742 -1034
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +87 -85
- package/lib/http-evidence-bounds.mjs +2 -7
- package/lib/http-intelligence.mjs +52 -27
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +2 -1
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +79 -13
- package/lib/lookup-response-contract.mjs +1077 -0
- package/lib/lookup-response.mjs +11 -24
- package/lib/lookup-source-progress.mjs +24 -35
- package/lib/lookup.mjs +49 -28
- package/lib/observed-network-context.mjs +43 -27
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +87 -134
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +79 -24
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +26 -14
- 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 +2 -36
- package/lib/registrar-standing-contract.mjs +2 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +7 -3
- package/lib/registry-capability-catalogue.mjs +29 -20
- 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 +2 -338
- package/lib/risk-scoring.mjs +2 -345
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/security-txt.mjs +10 -7
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +35 -0
- package/lib/smtp-transport-review.mjs +75 -34
- package/lib/sslbl-certificates.generated.mjs +141 -138
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +2 -1
- 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 +2 -36
- package/lib/typosquat-generator.mjs +29 -16
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-enrichment.mjs +388 -0
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +55 -5
- 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-normalization.mjs +3 -1
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-parser.mjs +5 -3
- package/lib/whois-transport.mjs +23 -7
- package/package.json +14 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +229 -69
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +55 -0
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +98 -48
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +149 -0
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +4 -3
- package/packages/cases/case-record-operations.mjs +123 -133
- package/packages/cases/case-record-projection.mjs +30 -20
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +114 -67
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +756 -0
- package/packages/cases/case-response-model.mjs +49 -1129
- package/packages/cases/case-response-outcomes.mjs +338 -0
- package/packages/cases/case-response-packet-render.mjs +157 -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 +58 -593
- package/packages/cases/case-response-records.mjs +93 -0
- package/packages/cases/case-response-review-inputs.mjs +382 -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 +26 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- 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/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +3 -2
- package/packages/comparison/relationship-evidence.mjs +103 -14
- package/packages/comparison/relationship-provenance.mjs +99 -0
- package/packages/contracts/analyst-interchange.mjs +234 -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 +370 -114
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/context-review.mjs +24 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +98 -44
- package/packages/contracts/domain-control-manifest.mjs +128 -74
- package/packages/contracts/domain-control-monitor.mjs +59 -45
- package/packages/contracts/domain-control-review.mjs +200 -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/har-review.mjs +2 -0
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +18 -0
- package/packages/contracts/ip-address.mjs +44 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/passive-mail.mjs +10 -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 +219 -238
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +108 -0
- package/packages/evidence/observation.mjs +18 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -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/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +93 -61
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +389 -0
- package/packages/investigation/acquisition-decision-packet.mjs +1 -1
- package/packages/investigation/bulk-mail-exposure.mjs +9 -14
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- 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/link-intake.mjs +179 -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/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +160 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +39 -31
- package/packages/workspace/bulk-session-model.mjs +112 -27
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +1 -1
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +45 -6
- package/packages/workspace/page-baseline.mjs +20 -17
- package/packages/workspace/relationship-observation-model.mjs +54 -35
- package/packages/workspace/shortlist-model.mjs +14 -5
- package/packages/workspace/watchlist-history.mjs +30 -14
- package/packages/workspace/watchlist-store.mjs +24 -6
- package/packages/workspace/website-snapshot-model.mjs +98 -33
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +90 -25
- package/third-party-notices.txt +577 -17
- package/lib/hostname.mjs +0 -38
- 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, domainControlReviewCountKeys, domainControlReviewLimitations, 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_COMPARISON_KEYS, 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,44 @@ 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
|
-
field: 'renewalReviewAt', state: due ? 'due' : '
|
|
326
|
+
field: 'renewalReviewAt', state: due ? 'due' : 'not_due', 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, version = DOMAIN_CONTROL_REVIEW_VERSION) {
|
|
332
|
+
if (comparisons.some((item) => item.state === 'drift' || (version < 3 && item.state === 'due')))
|
|
333
|
+
return 'drift';
|
|
334
|
+
if (comparisons.some((item) => ['partial', 'unavailable', 'unsupported'].includes(item.state)))
|
|
335
|
+
return 'partial';
|
|
336
|
+
if (comparisons.some((item) => item.state === 'due'))
|
|
337
|
+
return 'due';
|
|
338
|
+
if (version === 1 || comparisons.some((item) => item.state === 'aligned'))
|
|
339
|
+
return 'aligned';
|
|
340
|
+
if (comparisons.some((item) => item.state === 'observed'))
|
|
341
|
+
return 'observed';
|
|
342
|
+
return comparisons.some((item) => item.state === 'not_due') ? 'not_due' : 'not_configured';
|
|
343
|
+
}
|
|
344
|
+
function aggregateDomainState(domains, version = DOMAIN_CONTROL_REVIEW_VERSION) {
|
|
345
|
+
if (domains.some((item) => item.state === 'drift'))
|
|
346
|
+
return 'drift';
|
|
347
|
+
if (domains.some((item) => item.state === 'partial'))
|
|
348
|
+
return 'partial';
|
|
349
|
+
if (domains.some((item) => item.state === 'due'))
|
|
350
|
+
return 'due';
|
|
351
|
+
if (version === 1 || domains.some((item) => item.state === 'aligned'))
|
|
352
|
+
return 'aligned';
|
|
353
|
+
if (domains.some((item) => item.state === 'observed'))
|
|
354
|
+
return 'observed';
|
|
355
|
+
return domains.some((item) => item.state === 'not_due') ? 'not_due' : 'not_configured';
|
|
356
|
+
}
|
|
317
357
|
export function reviewDomainControlManifest(input, generatedAtValue = new Date().toISOString()) {
|
|
318
358
|
const source = record(input);
|
|
319
|
-
if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || source.version
|
|
359
|
+
if (!source || source.schema !== DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS.includes(source.version)) {
|
|
320
360
|
throw new TypeError(`Domain control review input must use ${DOMAIN_CONTROL_REVIEW_INPUT_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
|
|
321
361
|
}
|
|
322
362
|
exactKeys(source, REVIEW_INPUT_KEYS, 'Domain control review input');
|
|
@@ -324,11 +364,14 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
324
364
|
if (!generatedAt)
|
|
325
365
|
throw new TypeError('Domain control review time must be valid and include an explicit timezone.');
|
|
326
366
|
const manifest = verifyDomainControlManifest(source.manifest);
|
|
367
|
+
const legacy = source.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
|
|
368
|
+
if (legacy && manifest.version !== PUBLIC_DOMAIN_CONTROL_MANIFEST_VERSION)
|
|
369
|
+
throw new TypeError('Public review input requires its published manifest contract; use the current review input version for explicit expectations.');
|
|
327
370
|
const rawObservations = boundedDataArray(source.observations, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS);
|
|
328
371
|
if (!rawObservations) {
|
|
329
372
|
throw new TypeError('Domain control observations must be a bounded array.');
|
|
330
373
|
}
|
|
331
|
-
const observations = rawObservations.map(normalizeObservation);
|
|
374
|
+
const observations = rawObservations.map((value) => normalizeObservation(value, legacy));
|
|
332
375
|
if (observations.some((item) => item === null))
|
|
333
376
|
throw new TypeError('Domain control review contains an invalid observation.');
|
|
334
377
|
const normalizedObservations = observations;
|
|
@@ -339,23 +382,19 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
339
382
|
const fields = DOMAIN_CONTROL_REVIEW_FIELDS;
|
|
340
383
|
const domains = manifest.entries.map((entry) => {
|
|
341
384
|
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';
|
|
385
|
+
const comparisons = [...fields.map((field) => compareField(entry, observation, field, generatedAt)), renewalComparison(entry, generatedAt)];
|
|
386
|
+
const state = aggregateComparisonState(comparisons);
|
|
348
387
|
return Object.freeze({ domain: entry.domain, state, comparisons: Object.freeze(comparisons) });
|
|
349
388
|
});
|
|
350
389
|
const comparisons = domains.flatMap((item) => item.comparisons);
|
|
351
|
-
const counts = Object.fromEntries(
|
|
390
|
+
const counts = Object.fromEntries(DOMAIN_CONTROL_REVIEW_COUNT_KEYS
|
|
352
391
|
.map((state) => [state, comparisons.filter((item) => item.state === state).length]));
|
|
353
392
|
const expired = Date.parse(manifest.expiresAt) <= Date.parse(generatedAt);
|
|
354
393
|
return Object.freeze({
|
|
355
394
|
schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
|
|
356
395
|
version: DOMAIN_CONTROL_REVIEW_VERSION,
|
|
357
396
|
generatedAt,
|
|
358
|
-
state: expired ? 'expired' :
|
|
397
|
+
state: expired ? 'expired' : aggregateDomainState(domains),
|
|
359
398
|
manifest: Object.freeze({
|
|
360
399
|
generatedAt: manifest.generatedAt,
|
|
361
400
|
expiresAt: manifest.expiresAt,
|
|
@@ -370,9 +409,14 @@ export function reviewDomainControlManifest(input, generatedAtValue = new Date()
|
|
|
370
409
|
}
|
|
371
410
|
export function validateDomainControlReviewDocument(value) {
|
|
372
411
|
const root = exactReviewRecord(value, REVIEW_ROOT_KEYS, 'Domain control review document');
|
|
373
|
-
if (root.schema !== DOMAIN_CONTROL_REVIEW_SCHEMA || root.version
|
|
412
|
+
if (root.schema !== DOMAIN_CONTROL_REVIEW_SCHEMA || !SUPPORTED_DOMAIN_CONTROL_REVIEW_VERSIONS.includes(root.version)) {
|
|
374
413
|
throw new TypeError(`Domain control review document must use ${DOMAIN_CONTROL_REVIEW_SCHEMA} version ${DOMAIN_CONTROL_REVIEW_VERSION}.`);
|
|
375
414
|
}
|
|
415
|
+
const legacy = root.version === PUBLIC_DOMAIN_CONTROL_REVIEW_VERSION;
|
|
416
|
+
const countKeys = domainControlReviewCountKeys(root.version);
|
|
417
|
+
const comparisonKeys = legacy ? new Set(PUBLIC_DOMAIN_CONTROL_REVIEW_COMPARISON_KEYS) : REVIEW_COMPARISON_KEYS;
|
|
418
|
+
const recordMaximum = legacy ? MAX_CANONICAL_DOMAIN_CONTROL_RECORDS : MAX_DOMAIN_CONTROL_RECORDS;
|
|
419
|
+
const limitations = domainControlReviewLimitations(root.version);
|
|
376
420
|
const generatedAt = exactReviewTimestamp(root.generatedAt, 'Domain control review document generatedAt');
|
|
377
421
|
const manifestInput = exactReviewRecord(root.manifest, REVIEW_MANIFEST_KEYS, 'Domain control review document manifest');
|
|
378
422
|
const manifestGeneratedAt = exactReviewTimestamp(manifestInput.generatedAt, 'Domain control review document manifest generatedAt');
|
|
@@ -390,9 +434,9 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
390
434
|
digestSha256: manifestInput.digestSha256,
|
|
391
435
|
expired,
|
|
392
436
|
});
|
|
393
|
-
const countInput = exactReviewRecord(root.counts,
|
|
437
|
+
const countInput = exactReviewRecord(root.counts, new Set(countKeys), 'Domain control review document counts');
|
|
394
438
|
const maximumComparisons = MAX_DOMAIN_CONTROL_MANIFEST_ENTRIES * (DOMAIN_CONTROL_REVIEW_FIELDS.length + 1);
|
|
395
|
-
const suppliedCounts = Object.fromEntries(
|
|
439
|
+
const suppliedCounts = Object.fromEntries(countKeys.map((state) => [
|
|
396
440
|
state,
|
|
397
441
|
exactReviewInteger(countInput[state], maximumComparisons, `Domain control review document count ${state}`),
|
|
398
442
|
]));
|
|
@@ -401,8 +445,8 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
401
445
|
throw new TypeError('Domain control review document domains must be a bounded non-empty array.');
|
|
402
446
|
}
|
|
403
447
|
const comparisonFields = [...DOMAIN_CONTROL_REVIEW_FIELDS, 'renewalReviewAt'];
|
|
404
|
-
const comparisonStates = new Set(
|
|
405
|
-
const calculatedCounts = Object.fromEntries(
|
|
448
|
+
const comparisonStates = new Set(countKeys);
|
|
449
|
+
const calculatedCounts = Object.fromEntries(countKeys.map((state) => [state, 0]));
|
|
406
450
|
const domains = domainInput.map((domainValue, domainIndex) => {
|
|
407
451
|
const item = exactReviewRecord(domainValue, REVIEW_DOMAIN_KEYS, `Domain control review document domain ${domainIndex + 1}`);
|
|
408
452
|
const normalizedDomain = domain(item.domain);
|
|
@@ -414,7 +458,7 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
414
458
|
throw new TypeError(`Domain control review document domain ${domainIndex + 1} comparisons are incomplete.`);
|
|
415
459
|
}
|
|
416
460
|
const comparisons = comparisonsInput.map((comparisonValue, comparisonIndex) => {
|
|
417
|
-
const comparison = exactReviewRecord(comparisonValue,
|
|
461
|
+
const comparison = exactReviewRecord(comparisonValue, comparisonKeys, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1}`);
|
|
418
462
|
const expectedField = comparisonFields[comparisonIndex];
|
|
419
463
|
if (comparison.field !== expectedField
|
|
420
464
|
|| typeof comparison.state !== 'string'
|
|
@@ -428,8 +472,8 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
428
472
|
: expectedField === 'caa'
|
|
429
473
|
? MAX_DOMAIN_CONTROL_CAA_PRESENTATION_LENGTH
|
|
430
474
|
: MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH;
|
|
431
|
-
const desired = exactReviewStringArray(comparison.desired,
|
|
432
|
-
const observed = exactReviewStringArray(comparison.observed,
|
|
475
|
+
const desired = exactReviewStringArray(comparison.desired, recordMaximum, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} desired values`);
|
|
476
|
+
const observed = exactReviewStringArray(comparison.observed, recordMaximum, maximumValueCharacters, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} observed values`);
|
|
433
477
|
const source = comparison.source === null
|
|
434
478
|
? null
|
|
435
479
|
: exactReviewText(comparison.source, MAX_DOMAIN_CONTROL_REVIEW_SOURCE_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} source`);
|
|
@@ -439,6 +483,41 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
439
483
|
if (expectedField === 'renewalReviewAt' && (observed.length || source !== null || observedAt !== null)) {
|
|
440
484
|
throw new TypeError(`Domain control review document domain ${domainIndex + 1} renewal comparison is inconsistent.`);
|
|
441
485
|
}
|
|
486
|
+
let expectation;
|
|
487
|
+
if (!legacy) {
|
|
488
|
+
if (expectedField !== 'renewalReviewAt') {
|
|
489
|
+
for (const values of [desired, observed]) {
|
|
490
|
+
const canonical = fieldValues(expectedField, values, recordMaximum);
|
|
491
|
+
if (!canonical || canonical.length !== values.length || canonical.some((value, index) => value !== values[index]))
|
|
492
|
+
throw new TypeError('Domain control review values are not canonical for their field.');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (!DOMAIN_CONTROL_RECORD_MODE_OPTIONS.some((mode) => mode.value === comparison.expectation))
|
|
496
|
+
throw new TypeError('Domain control review comparison expectation is invalid.');
|
|
497
|
+
expectation = comparison.expectation;
|
|
498
|
+
if ((expectation === 'expect_records') !== (desired.length > 0)
|
|
499
|
+
|| (!DOMAIN_CONTROL_RECORD_LIST_FIELDS.includes(expectedField) && !['unconfigured', 'expect_records'].includes(expectation))
|
|
500
|
+
|| (expectation === 'unconfigured' && (state !== 'not_configured' || observed.length || source !== null || observedAt !== null))
|
|
501
|
+
|| (state === 'not_configured' && expectation !== 'unconfigured')
|
|
502
|
+
|| (state === 'observed' && expectation !== 'observe_only')
|
|
503
|
+
|| (['aligned', 'drift', 'due', 'not_due'].includes(state) && ['unconfigured', 'observe_only'].includes(expectation))) {
|
|
504
|
+
throw new TypeError('Domain control review comparison expectation is inconsistent.');
|
|
505
|
+
}
|
|
506
|
+
if (expectedField === 'renewalReviewAt' && expectation === 'expect_records') {
|
|
507
|
+
if (desired.length !== 1 || normalizeExplicitIsoTimestamp(desired[0]) !== desired[0]
|
|
508
|
+
|| state !== (Date.parse(desired[0]) <= Date.parse(generatedAt) ? 'due' : root.version === 2 ? 'aligned' : 'not_due'))
|
|
509
|
+
throw new TypeError('Domain control renewal review state is inconsistent.');
|
|
510
|
+
}
|
|
511
|
+
else if (state === 'due' || state === 'not_due')
|
|
512
|
+
throw new TypeError('Only renewal review dates can be due or not due.');
|
|
513
|
+
if (expectedField !== 'renewalReviewAt' && ['aligned', 'drift', 'observed'].includes(state)) {
|
|
514
|
+
if (!source || domainControlEvidenceAdmission(observedAt, generatedAt))
|
|
515
|
+
throw new TypeError('Domain control review requires current source timing for a conclusive comparison.');
|
|
516
|
+
const equal = desired.length === observed.length && desired.every((value, index) => value === observed[index]);
|
|
517
|
+
if ((state === 'aligned' && !equal) || (state === 'drift' && equal))
|
|
518
|
+
throw new TypeError('Domain control review comparison result does not match its records.');
|
|
519
|
+
}
|
|
520
|
+
}
|
|
442
521
|
return Object.freeze({
|
|
443
522
|
field: expectedField,
|
|
444
523
|
state,
|
|
@@ -447,15 +526,10 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
447
526
|
source,
|
|
448
527
|
observedAt,
|
|
449
528
|
explanation: exactReviewText(comparison.explanation, MAX_DOMAIN_CONTROL_REVIEW_TEXT_LENGTH, `Domain control review document domain ${domainIndex + 1} comparison ${comparisonIndex + 1} explanation`),
|
|
529
|
+
...(expectation !== undefined ? { expectation } : {}),
|
|
450
530
|
});
|
|
451
531
|
});
|
|
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';
|
|
532
|
+
const expectedState = aggregateComparisonState(comparisons, root.version);
|
|
459
533
|
if (item.state !== expectedState) {
|
|
460
534
|
throw new TypeError(`Domain control review document domain ${domainIndex + 1} state is inconsistent.`);
|
|
461
535
|
}
|
|
@@ -464,37 +538,31 @@ export function validateDomainControlReviewDocument(value) {
|
|
|
464
538
|
if (new Set(domains.map((item) => item.domain)).size !== domains.length) {
|
|
465
539
|
throw new TypeError('Domain control review document domains must be unique.');
|
|
466
540
|
}
|
|
467
|
-
for (const state of
|
|
541
|
+
for (const state of countKeys) {
|
|
468
542
|
if (suppliedCounts[state] !== calculatedCounts[state]) {
|
|
469
543
|
throw new TypeError('Domain control review document counts are inconsistent.');
|
|
470
544
|
}
|
|
471
545
|
}
|
|
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';
|
|
546
|
+
const expectedState = expired ? 'expired' : aggregateDomainState(domains, root.version);
|
|
479
547
|
if (root.state !== expectedState)
|
|
480
548
|
throw new TypeError('Domain control review document state is inconsistent.');
|
|
481
549
|
const ignoredObservationCount = exactReviewInteger(root.ignoredObservationCount, MAX_DOMAIN_CONTROL_REVIEW_OBSERVATIONS, 'Domain control review document ignored observation count');
|
|
482
|
-
const limitationsInput = boundedDataArray(root.limitations,
|
|
550
|
+
const limitationsInput = boundedDataArray(root.limitations, limitations.length);
|
|
483
551
|
if (!limitationsInput
|
|
484
|
-
|| limitationsInput.length !==
|
|
485
|
-
|| limitationsInput.some((item, index) => item !==
|
|
552
|
+
|| limitationsInput.length !== limitations.length
|
|
553
|
+
|| limitationsInput.some((item, index) => item !== limitations[index])) {
|
|
486
554
|
throw new TypeError('Domain control review document limitations are invalid.');
|
|
487
555
|
}
|
|
488
556
|
return Object.freeze({
|
|
489
557
|
schema: DOMAIN_CONTROL_REVIEW_SCHEMA,
|
|
490
|
-
version:
|
|
558
|
+
version: root.version,
|
|
491
559
|
generatedAt,
|
|
492
560
|
state: expectedState,
|
|
493
561
|
manifest,
|
|
494
562
|
counts: Object.freeze(suppliedCounts),
|
|
495
563
|
domains: Object.freeze(domains),
|
|
496
564
|
ignoredObservationCount,
|
|
497
|
-
limitations
|
|
565
|
+
limitations,
|
|
498
566
|
});
|
|
499
567
|
}
|
|
500
568
|
export function formatDomainControlResult(value) {
|
|
@@ -2,7 +2,8 @@
|
|
|
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 {
|
|
5
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
6
|
+
import { parseDmarcRecords, parseDmarcReportingAuthorization, parseReportingDestination, parseSpfRecords } from "./domain-posture-parsers.mjs";
|
|
6
7
|
const SPF_LOOKUP_LIMIT = 10;
|
|
7
8
|
const SPF_VOID_LOOKUP_LIMIT = 2;
|
|
8
9
|
const SPF_MAX_DEPTH = 5;
|
|
@@ -158,14 +159,18 @@ async function expandSpfPolicy(rootDomain, rootQuery, resolveTxt) {
|
|
|
158
159
|
};
|
|
159
160
|
}
|
|
160
161
|
function reportDestinationDomain(value) {
|
|
161
|
-
|
|
162
|
-
if (!/^mailto:/iu.test(raw))
|
|
162
|
+
if (typeof value !== 'string')
|
|
163
163
|
return null;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
// The obsolete DMARC report-size suffix remains readable, but arbitrary
|
|
165
|
+
// text after an exclamation mark must not repair a malformed destination.
|
|
166
|
+
const uri = value.trim().replace(/![0-9]+[kmgt]?$/iu, '');
|
|
167
|
+
if (/[!,;]/u.test(uri))
|
|
168
|
+
return null;
|
|
169
|
+
const destination = parseReportingDestination(uri);
|
|
170
|
+
const domain = destination?.scheme === 'mailto' ? strictHostname(destination.host) : null;
|
|
171
|
+
return domain && isValidAsciiHostname(domain) ? domain : null;
|
|
167
172
|
}
|
|
168
|
-
async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt) {
|
|
173
|
+
async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTxt, resolveOrganisationalDomain) {
|
|
169
174
|
if (dmarcQuery.error)
|
|
170
175
|
return [];
|
|
171
176
|
const parsed = parseDmarcRecords(dmarcQuery.records);
|
|
@@ -189,7 +194,7 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
|
|
|
189
194
|
});
|
|
190
195
|
continue;
|
|
191
196
|
}
|
|
192
|
-
if (destinationDomain === ownerDomain
|
|
197
|
+
if (destinationDomain === ownerDomain) {
|
|
193
198
|
results.push({
|
|
194
199
|
destination: destinationDomain,
|
|
195
200
|
reportType: item.reportType,
|
|
@@ -199,19 +204,44 @@ async function validateDmarcExternalReporting(ownerDomain, dmarcQuery, resolveTx
|
|
|
199
204
|
});
|
|
200
205
|
continue;
|
|
201
206
|
}
|
|
207
|
+
const ownerOrganisation = resolveOrganisationalDomain ? await resolveOrganisationalDomain(ownerDomain) : null;
|
|
208
|
+
const recipientOrganisation = ownerOrganisation && resolveOrganisationalDomain ? await resolveOrganisationalDomain(destinationDomain) : null;
|
|
209
|
+
if (ownerOrganisation && ownerOrganisation === recipientOrganisation) {
|
|
210
|
+
results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'self', error: null });
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const externalConfirmed = Boolean(ownerOrganisation && recipientOrganisation);
|
|
214
|
+
// Descendant reporting previously made no authorisation request. Do not
|
|
215
|
+
// broaden default collection to resolve its unknown organisational scope.
|
|
216
|
+
if (!resolveOrganisationalDomain && destinationDomain.endsWith(`.${ownerDomain}`)) {
|
|
217
|
+
results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable',
|
|
218
|
+
error: 'The organisational boundary was not checked. Select the additional DNS review to determine whether external reporting authorisation is required.' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
202
221
|
const recordName = `${ownerDomain}._report._dmarc.${destinationDomain}`;
|
|
222
|
+
if (recordName.length > 253) {
|
|
223
|
+
results.push({ destination: destinationDomain, reportType: item.reportType, recordName: null, state: 'unavailable', error: 'The reporting authorisation query exceeds the DNS name limit.' });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
203
226
|
let query = cache.get(recordName);
|
|
204
227
|
if (!query) {
|
|
205
228
|
query = await resolveTxt(recordName);
|
|
206
229
|
cache.set(recordName, query);
|
|
207
230
|
}
|
|
208
|
-
const authorized = query.records.some((record) =>
|
|
231
|
+
const authorized = query.records.some((record) => {
|
|
232
|
+
const tags = parseDmarcReportingAuthorization(record);
|
|
233
|
+
if (!tags)
|
|
234
|
+
return false;
|
|
235
|
+
// A recipient override cannot redirect authorisation to another host.
|
|
236
|
+
return tags.rua === undefined || tags.rua.split(',').every((destination) => reportDestinationDomain(destination) === destinationDomain);
|
|
237
|
+
});
|
|
209
238
|
results.push({
|
|
210
239
|
destination: destinationDomain,
|
|
211
240
|
reportType: item.reportType,
|
|
212
241
|
recordName,
|
|
213
|
-
state: query.error ? 'unavailable' : authorized ? 'authorized' : 'not_found',
|
|
214
|
-
error: query.error
|
|
242
|
+
state: query.error ? 'unavailable' : authorized ? 'authorized' : externalConfirmed ? 'not_found' : 'unavailable',
|
|
243
|
+
error: query.error ?? (!authorized && !externalConfirmed
|
|
244
|
+
? 'The organisational boundary is unknown; no authorisation requirement can be inferred from the missing record.' : null),
|
|
215
245
|
});
|
|
216
246
|
}
|
|
217
247
|
return results;
|
|
@@ -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, };
|