@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,10 +1,11 @@
|
|
|
1
1
|
// Bounded delegation-health evidence for deep single-domain Lookup. Registry
|
|
2
|
-
// publication, recursive
|
|
2
|
+
// publication, recursive resolver answers, and direct nameserver queries
|
|
3
3
|
// remain separately attributed. The result never participates in registration
|
|
4
4
|
// availability and never treats a failed query as record absence.
|
|
5
5
|
import { promises as dns } from 'node:dns';
|
|
6
6
|
import * as net from 'node:net';
|
|
7
7
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
8
|
+
import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
|
|
8
9
|
import { isValidAsciiHostname } from "./hostname.mjs";
|
|
9
10
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
10
11
|
const DNS_DELEGATION_HEALTH_VERSION = 1;
|
|
@@ -148,10 +149,9 @@ function normaliseAuthorityValueSet(type, value) {
|
|
|
148
149
|
}
|
|
149
150
|
const source = record(item);
|
|
150
151
|
if (type === 'MX') {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return [`${priority} ${exchange}`];
|
|
152
|
+
const mx = normalizeResolverMxRecord(item);
|
|
153
|
+
if (mx)
|
|
154
|
+
return [`${mx.priority} ${mx.exchange}`];
|
|
155
155
|
rejected += 1;
|
|
156
156
|
return [];
|
|
157
157
|
}
|
|
@@ -291,6 +291,7 @@ function finding(id, label, state, summary, detail, remediation) {
|
|
|
291
291
|
function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks were not collected for this lookup.') {
|
|
292
292
|
return {
|
|
293
293
|
delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
|
|
294
|
+
domain: null,
|
|
294
295
|
...createObservation({
|
|
295
296
|
status: 'skipped',
|
|
296
297
|
scanMode: 'deep',
|
|
@@ -326,9 +327,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
326
327
|
? parentQuery.status
|
|
327
328
|
: 'error';
|
|
328
329
|
const parentNameservers = hostnames(parentQuery.records);
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
330
|
+
const eligibleAuthorities = [...new Set([...parentNameservers, ...registry.nameservers])].sort();
|
|
331
|
+
const candidates = eligibleAuthorities.slice(0, MAX_AUTHORITIES);
|
|
332
|
+
const omittedAuthorityCount = eligibleAuthorities.length - candidates.length;
|
|
332
333
|
const resolve4 = options.resolve4 || dns.resolve4;
|
|
333
334
|
const resolve6 = options.resolve6 || dns.resolve6;
|
|
334
335
|
const queryAuthority = options.queryAuthority || defaultAuthorityQuery;
|
|
@@ -473,13 +474,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
473
474
|
const recordPartial = recordMatrix.filter((row) => row.state === 'partial');
|
|
474
475
|
const recordInsufficient = recordMatrix.filter((row) => row.state === 'insufficient');
|
|
475
476
|
const findings = [
|
|
476
|
-
finding('parent_registry_ns', '
|
|
477
|
+
finding('parent_registry_ns', 'Recursive and registry nameservers', !parentNameservers.length || !registry.nameservers.length
|
|
477
478
|
? 'unknown'
|
|
478
479
|
: sameSet(parentNameservers, registry.nameservers) ? 'healthy' : 'warning', !parentNameservers.length || !registry.nameservers.length
|
|
479
480
|
? 'Comparison is incomplete'
|
|
480
481
|
: sameSet(parentNameservers, registry.nameservers)
|
|
481
|
-
? '
|
|
482
|
-
: '
|
|
482
|
+
? 'Recursive observation and registry publication agree'
|
|
483
|
+
: 'Recursive observation and registry publication differ', `Recursive resolver: ${parentNameservers.join(', ') || 'unavailable'}. Registry publication: ${registry.nameservers.join(', ') || 'unavailable'}.`, 'Confirm the intended delegation with the registry and DNS operator before changing nameservers.'),
|
|
483
484
|
finding('authority_reachability', 'Direct nameserver reachability', lameAuthorities.length ? 'danger'
|
|
484
485
|
: partialAuthorities.length || unreachableAuthorities.length ? 'warning'
|
|
485
486
|
: successfulAuthorities.length ? 'healthy' : 'unknown', lameAuthorities.length
|
|
@@ -496,7 +497,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
496
497
|
? 'No direct authority answer was available'
|
|
497
498
|
: inconsistentAuthoritySets
|
|
498
499
|
? 'Direct nameserver answers are inconsistent'
|
|
499
|
-
: 'Direct nameserver answers agree with the
|
|
500
|
+
: 'Direct nameserver answers agree with the recursive observation', successfulAuthorities.map((authority) => `${authority.nameserver}: ${authority.nameservers.join(', ') || 'no NS answer'}`).join(' | ') || 'No direct answers.', 'Align the NS set served by every authority with the intended parent delegation before cutover.'),
|
|
500
501
|
finding('authority_soa_consistency', 'Authoritative SOA consistency', retainedSoa.length < 2 || soaSerials.length < 1 || soaPrimaries.length < 1
|
|
501
502
|
? 'unknown'
|
|
502
503
|
: soaSerialConflict || soaPrimaryConflict ? 'warning' : 'healthy', retainedSoa.length < 2
|
|
@@ -530,6 +531,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
530
531
|
];
|
|
531
532
|
const collectionIncomplete = parentStatus !== 'success'
|
|
532
533
|
|| candidates.length === 0
|
|
534
|
+
|| omittedAuthorityCount > 0
|
|
533
535
|
|| parentQuery.truncated === true
|
|
534
536
|
|| registry.truncated
|
|
535
537
|
|| authorities.some((authority) => authority.state !== 'success')
|
|
@@ -540,6 +542,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
540
542
|
const observedAt = (options.observedAt || (() => new Date().toISOString()))();
|
|
541
543
|
return {
|
|
542
544
|
delegationHealthVersion: DNS_DELEGATION_HEALTH_VERSION,
|
|
545
|
+
domain,
|
|
543
546
|
...createObservation({
|
|
544
547
|
status,
|
|
545
548
|
observedAt,
|
|
@@ -549,9 +552,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
549
552
|
complete: !collectionIncomplete,
|
|
550
553
|
truncated: parentQuery.truncated === true
|
|
551
554
|
|| registry.truncated
|
|
555
|
+
|| omittedAuthorityCount > 0
|
|
552
556
|
|| (recordCollectionAttempted && recordMatrix.some((row) => row.observations.some((item) => item.truncated))),
|
|
553
557
|
limitations: [
|
|
554
|
-
'The
|
|
558
|
+
'The initial nameserver set comes from a recursive resolver, not a direct parent-server query; registry nameservers and glue come from the separately attributed RDAP publication.',
|
|
559
|
+
...(omittedAuthorityCount > 0
|
|
560
|
+
? [`${candidates.length} of ${eligibleAuthorities.length} eligible nameservers were selected; ${omittedAuthorityCount} were not queried.`]
|
|
561
|
+
: []),
|
|
555
562
|
'Direct queries use at most four selected nameservers and two validated public addresses per nameserver.',
|
|
556
563
|
'Extended A, AAAA, CAA, and MX comparison uses only one selected validated public address per nameserver and caps each record set at sixteen values.',
|
|
557
564
|
'A direct answer does not prove global reachability, and a failed query is not evidence that the record is absent.',
|
|
@@ -563,6 +570,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
563
570
|
],
|
|
564
571
|
diagnostics: {
|
|
565
572
|
authorityCount: authorities.length,
|
|
573
|
+
eligibleAuthorityCount: eligibleAuthorities.length,
|
|
574
|
+
queriedAuthorityCount: authorities.filter((authority) => authority.attempts.length > 0).length,
|
|
575
|
+
omittedAuthorityCount,
|
|
566
576
|
queriedAddressCount: authorities.reduce((sum, authority) => sum + authority.attempts.length, 0),
|
|
567
577
|
successfulAuthorityCount: successfulAuthorities.length,
|
|
568
578
|
partialAuthorityCount: partialAuthorities.length,
|
|
@@ -576,7 +586,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
576
586
|
? 'The bounded delegation-health collection completed.'
|
|
577
587
|
: status === 'partial'
|
|
578
588
|
? 'The delegation-health collection is partial; review each source state before changing DNS.'
|
|
579
|
-
: 'The delegation-health collection could not confirm a
|
|
589
|
+
: 'The delegation-health collection could not confirm a recursive or direct authority observation.',
|
|
580
590
|
parent: {
|
|
581
591
|
state: parentStatus,
|
|
582
592
|
nameservers: parentNameservers,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { promises as dns } from 'node:dns';
|
|
2
|
+
import { parse as parseDomain } from 'tldts';
|
|
3
|
+
import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
|
|
4
|
+
import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
|
|
5
|
+
import { defaultTcpExchange } from "./service-binding-dns.mjs";
|
|
6
|
+
import { isValidAsciiHostname } from "./hostname.mjs";
|
|
7
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
8
|
+
const MAX_PARENT_SERVERS = 2;
|
|
9
|
+
const MAX_NS = 16;
|
|
10
|
+
const QUERY_TIMEOUT_MS = 2200;
|
|
11
|
+
const REVIEW_TIMEOUT_MS = 10_000;
|
|
12
|
+
const MISSING = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
|
|
13
|
+
function requireDomain(domain) {
|
|
14
|
+
if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
|
|
15
|
+
throw new TypeError('The additional DNS review requires a normalised domain.');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Explicit query parameter; malformed opt-ins never broaden collection. */
|
|
19
|
+
export function parseInheritedDnsSelection(value) {
|
|
20
|
+
if (value === undefined || value === '')
|
|
21
|
+
return undefined;
|
|
22
|
+
if (value === '1')
|
|
23
|
+
return true;
|
|
24
|
+
throw new TypeError('includeInheritedDns must be 1 when requested.');
|
|
25
|
+
}
|
|
26
|
+
function check(id, label, status, summary, detail, records) {
|
|
27
|
+
return { id, label, status, summary, detail, records, remediation: '' };
|
|
28
|
+
}
|
|
29
|
+
function policyObservation(owner, observation) {
|
|
30
|
+
const records = observation.records;
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
|
|
33
|
+
const parts = typeof raw === 'string' ? [raw] : raw;
|
|
34
|
+
return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
|
|
35
|
+
if (typeof part !== 'string' || part.length > 4096)
|
|
36
|
+
return false;
|
|
37
|
+
bytes += Buffer.byteLength(part, 'utf8');
|
|
38
|
+
return bytes <= 65_536;
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
const parsed = parseDmarcRecords(admitted ? records : []);
|
|
42
|
+
const unavailable = Boolean(observation.error) || !admitted;
|
|
43
|
+
const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(parsed.tags.psd?.toLowerCase() ?? 'u');
|
|
44
|
+
const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
|
|
45
|
+
const description = valid
|
|
46
|
+
? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${parsed.tags.psd?.toLowerCase() ?? 'u'}; test=${parsed.testMode ? 'yes' : 'no'}`
|
|
47
|
+
: state;
|
|
48
|
+
const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
|
|
49
|
+
? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
|
|
50
|
+
return { owner, parsed, valid, unavailable, record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
|
|
51
|
+
}
|
|
52
|
+
/** RFC 9989 sections 4.10–4.10.2; at most eight owners including the exact name. */
|
|
53
|
+
export async function reviewInheritedDmarc(domain, exact, resolve) {
|
|
54
|
+
requireDomain(domain);
|
|
55
|
+
const rows = [policyObservation(domain, exact)];
|
|
56
|
+
const labels = domain.split('.');
|
|
57
|
+
let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
|
|
58
|
+
if (!rows[0].valid && !rows[0].unavailable) {
|
|
59
|
+
while (next.length) {
|
|
60
|
+
const owner = next.join('.');
|
|
61
|
+
const row = policyObservation(owner, await resolve(`_dmarc.${owner}`));
|
|
62
|
+
rows.push(row);
|
|
63
|
+
if (row.unavailable || (row.valid && ['n', 'y'].includes(row.parsed.tags.psd?.toLowerCase() ?? '')))
|
|
64
|
+
break;
|
|
65
|
+
next = next.slice(1);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const available = rows.filter(row => row.valid);
|
|
69
|
+
const explicit = rows[0].valid ? rows[0] : null;
|
|
70
|
+
const boundary = available.find(row => row.parsed.tags.psd?.toLowerCase() === 'n' || row.parsed.tags.psd?.toLowerCase() === 'y');
|
|
71
|
+
const organisational = boundary?.parsed.tags.psd?.toLowerCase() === 'y'
|
|
72
|
+
? domain.split('.').slice(-(boundary.owner.split('.').length + 1)).join('.')
|
|
73
|
+
: boundary?.owner ?? available.at(-1)?.owner ?? null;
|
|
74
|
+
const selected = explicit ?? available.find(row => row.owner === organisational) ?? (boundary?.parsed.tags.psd?.toLowerCase() === 'y' ? boundary : null);
|
|
75
|
+
const incomplete = rows.some(row => row.unavailable || (!row.valid && row.parsed.records.length === 1));
|
|
76
|
+
const summary = incomplete ? 'Inherited policy could not be determined from the collected records.'
|
|
77
|
+
: selected ? `${selected.owner === domain ? 'Exact-name' : 'Inherited'} policy published at _dmarc.${selected.owner}.`
|
|
78
|
+
: 'No applicable DMARC policy was found in the completed tree walk.';
|
|
79
|
+
const detail = !incomplete && selected
|
|
80
|
+
? selected.owner === domain
|
|
81
|
+
? `Published policy: ${selected.parsed.policy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}.`
|
|
82
|
+
: `For an existing name: ${selected.parsed.subdomainPolicy}. For a nonexistent name: ${selected.parsed.nonexistentSubdomainPolicy}. Test mode: ${selected.parsed.testMode ? 'yes' : 'no'}. Name existence is not inferred from this review.`
|
|
83
|
+
: 'Invalid or unavailable records do not establish absence or a usable inherited policy.';
|
|
84
|
+
return check('dmarc_inheritance', 'Inherited DMARC policy', incomplete || !selected ? 'warning' : 'info', summary, `${detail} Discovery follows RFC 9989 with at most eight owners. This is publication evidence, not message authentication or a guarantee of receiver behaviour.`, rows.map(row => row.record));
|
|
85
|
+
}
|
|
86
|
+
function nameservers(value) {
|
|
87
|
+
if (!Array.isArray(value) || value.length > MAX_NS)
|
|
88
|
+
return null;
|
|
89
|
+
const names = value.map(raw => typeof raw === 'string' && raw.length <= 254 ? raw.toLowerCase().replace(/\.$/u, '') : '');
|
|
90
|
+
return names.every(name => name.length <= 253 && isValidAsciiHostname(name)) ? [...new Set(names)].sort() : null;
|
|
91
|
+
}
|
|
92
|
+
export async function reviewParentDelegation(domain, recursive, dependencies, signal) {
|
|
93
|
+
requireDomain(domain);
|
|
94
|
+
const parsed = parseDomain(domain, { allowPrivateDomains: false });
|
|
95
|
+
const child = parsed.domain;
|
|
96
|
+
const parent = parsed.publicSuffix;
|
|
97
|
+
const records = [];
|
|
98
|
+
let referrals = 0;
|
|
99
|
+
let differs = false;
|
|
100
|
+
let parentDiffers = false;
|
|
101
|
+
let firstReferral = null;
|
|
102
|
+
let incomplete = false;
|
|
103
|
+
const observed = recursive.error || domain !== child ? null : nameservers(recursive.records);
|
|
104
|
+
const finish = () => check('parent_delegation', 'Direct parent delegation', differs || parentDiffers || incomplete || !referrals ? 'warning' : 'info', parentDiffers ? 'Sampled parent servers returned different delegation referrals.'
|
|
105
|
+
: differs ? 'Parent referral and recursive nameserver observations differ.'
|
|
106
|
+
: referrals ? `${referrals} parent server${referrals === 1 ? '' : 's'} returned a delegation referral for ${child}.`
|
|
107
|
+
: 'No direct parent referral was established.', `Registration-domain scope: ${child ?? 'unavailable'}; parent candidate: ${parent ?? 'unavailable'}. At most two discovered parent servers are queried over pinned public-address DNS/TCP, without recursion. This is a sample, not full delegation or DNSSEC validation.${incomplete ? ' Some discovery or direct observations were unavailable or incomplete.' : ''}${!observed ? ' No complete same-scope recursive comparison is available.' : ''}`, records);
|
|
108
|
+
if (!child || !parent || child === parent)
|
|
109
|
+
return finish();
|
|
110
|
+
signal?.throwIfAborted();
|
|
111
|
+
let discovered;
|
|
112
|
+
try {
|
|
113
|
+
discovered = nameservers(await dependencies.resolveNs(parent));
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
signal?.throwIfAborted();
|
|
117
|
+
incomplete = true;
|
|
118
|
+
return finish();
|
|
119
|
+
}
|
|
120
|
+
if (!discovered?.length) {
|
|
121
|
+
incomplete = true;
|
|
122
|
+
return finish();
|
|
123
|
+
}
|
|
124
|
+
records.push(`Recursive DNS NS ${parent} · ${dependencies.now().toISOString()} · ${discovered.length} discovered; ${Math.min(MAX_PARENT_SERVERS, discovered.length)} selected.`);
|
|
125
|
+
for (const server of discovered.slice(0, MAX_PARENT_SERVERS)) {
|
|
126
|
+
signal?.throwIfAborted();
|
|
127
|
+
const resolutions = await Promise.allSettled([dependencies.resolve4(server), dependencies.resolve6(server)]);
|
|
128
|
+
signal?.throwIfAborted();
|
|
129
|
+
const failed = resolutions.some(result => result.status === 'fulfilled'
|
|
130
|
+
? !Array.isArray(result.value) || result.value.length > 16
|
|
131
|
+
: !MISSING.has(String(result.reason?.code)));
|
|
132
|
+
if (failed) {
|
|
133
|
+
incomplete = true;
|
|
134
|
+
records.push(`${server}: public-address discovery unavailable or rejected.`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const addresses = resolutions.flatMap(result => result.status === 'fulfilled' ? result.value : []);
|
|
138
|
+
const endpoints = addresses.map(normalizeResolverEndpoint);
|
|
139
|
+
if (!addresses.length || endpoints.some(endpoint => !endpoint)) {
|
|
140
|
+
incomplete = true;
|
|
141
|
+
records.push(`${server}: public-address discovery unavailable or rejected.`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const endpoint = endpoints.filter((value) => value !== null)
|
|
145
|
+
.sort((a, b) => a.address < b.address ? -1 : a.address > b.address ? 1 : 0)[0];
|
|
146
|
+
const query = buildDnssecQuery(child, DNS_TYPE_NS);
|
|
147
|
+
query.writeUInt16BE(0, 2); // No recursion or authenticated-data assertion is requested.
|
|
148
|
+
try {
|
|
149
|
+
const responseBytes = await dependencies.exchange(query, endpoint, { timeoutMs: QUERY_TIMEOUT_MS, ...(signal ? { signal } : {}) });
|
|
150
|
+
signal?.throwIfAborted();
|
|
151
|
+
if (responseBytes.byteLength > 65_535)
|
|
152
|
+
throw new RangeError('The DNS response exceeds the wire limit.');
|
|
153
|
+
const response = parseDnssecResponse(responseBytes, { transactionId: query.readUInt16BE(0), name: child, type: DNS_TYPE_NS });
|
|
154
|
+
const referred = nameservers(response.records.filter(row => row.section === 'authority' && row.owner === child && row.type === DNS_TYPE_NS && row.class === 1)
|
|
155
|
+
.flatMap(row => row.data.kind === 'NS' ? [row.data.name] : []));
|
|
156
|
+
records.push(`Direct DNS/TCP ${server} [${endpoint.address}] · ${dependencies.now().toISOString()} · ${child} NS · response ${response.rcode}.`);
|
|
157
|
+
if (response.rcode !== 0 || !referred?.length) {
|
|
158
|
+
incomplete = true;
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
referrals += 1;
|
|
162
|
+
if (firstReferral && JSON.stringify(firstReferral) !== JSON.stringify(referred))
|
|
163
|
+
parentDiffers = true;
|
|
164
|
+
firstReferral ??= referred;
|
|
165
|
+
for (const name of referred)
|
|
166
|
+
records.push(`${server} authority section: ${child} NS ${name}`);
|
|
167
|
+
if (observed && JSON.stringify(observed) !== JSON.stringify(referred))
|
|
168
|
+
differs = true;
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
signal?.throwIfAborted();
|
|
172
|
+
incomplete = true;
|
|
173
|
+
records.push(`${server}: bounded direct referral query failed.`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return finish();
|
|
177
|
+
}
|
|
178
|
+
export async function collectDnsInheritanceChecks(domain, exact, recursive, options = {}, injected) {
|
|
179
|
+
requireDomain(domain);
|
|
180
|
+
options.signal?.throwIfAborted();
|
|
181
|
+
const timeout = AbortSignal.timeout(REVIEW_TIMEOUT_MS);
|
|
182
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
183
|
+
const resolver = injected ? null : new dns.Resolver({ timeout: QUERY_TIMEOUT_MS, tries: 1 });
|
|
184
|
+
const cancel = () => resolver?.cancel();
|
|
185
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
186
|
+
const dependencies = injected ?? {
|
|
187
|
+
resolveTxt: (name) => resolver.resolveTxt(name), resolveNs: (name) => resolver.resolveNs(name),
|
|
188
|
+
resolve4: (name) => resolver.resolve4(name), resolve6: (name) => resolver.resolve6(name),
|
|
189
|
+
exchange: defaultTcpExchange, now: () => new Date(),
|
|
190
|
+
};
|
|
191
|
+
const resolve = async (owner) => {
|
|
192
|
+
signal.throwIfAborted();
|
|
193
|
+
if (owner.length > 253)
|
|
194
|
+
return { records: [], error: 'The DNS question exceeds the name limit.' };
|
|
195
|
+
try {
|
|
196
|
+
return { records: await dependencies.resolveTxt(owner), error: null, observedAt: dependencies.now().toISOString() };
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
options.signal?.throwIfAborted();
|
|
200
|
+
return { records: [], error: MISSING.has(String(error?.code)) ? null : 'DNS observation unavailable.', observedAt: dependencies.now().toISOString() };
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
const operations = [reviewInheritedDmarc(domain, exact, resolve), reviewParentDelegation(domain, recursive, dependencies, signal)];
|
|
204
|
+
try {
|
|
205
|
+
return await Promise.all(operations);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
options.signal?.throwIfAborted();
|
|
209
|
+
const settled = await Promise.allSettled(operations);
|
|
210
|
+
return settled.map((result, index) => result.status === 'fulfilled' ? result.value : check(index === 0 ? 'dmarc_inheritance' : 'parent_delegation', index === 0 ? 'Inherited DMARC policy' : 'Direct parent delegation', 'warning', 'The additional review could not complete.', 'No absence or effective-policy conclusion is available from the incomplete review.', []));
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
await Promise.allSettled(operations);
|
|
214
|
+
signal.removeEventListener('abort', cancel);
|
|
215
|
+
cancel();
|
|
216
|
+
}
|
|
217
|
+
}
|
package/lib/dns-intelligence.mjs
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
import { promises as dns } from 'node:dns';
|
|
6
6
|
import * as net from 'node:net';
|
|
7
7
|
import { classifyMxRecords } from "./dns-mx.mjs";
|
|
8
|
+
import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
|
|
8
9
|
import { collectDnsDelegationHealth, skippedDnsDelegationHealth, } from "./dns-delegation-health.mjs";
|
|
9
10
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
10
11
|
import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
|
|
11
12
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
12
13
|
import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
|
|
14
|
+
import { canonicalRegistrableDomain } from "./registrable-domain.mjs";
|
|
13
15
|
const DNS_TIMEOUT_MS = 5000;
|
|
14
16
|
const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
|
|
15
17
|
const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
|
|
@@ -123,7 +125,7 @@ function boundedUnique(values, limit = MAX_RECORDS_PER_TYPE) {
|
|
|
123
125
|
}
|
|
124
126
|
function normalizeAddresses(records, family) {
|
|
125
127
|
const values = [];
|
|
126
|
-
let discarded = 0;
|
|
128
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
127
129
|
for (const record of Array.isArray(records) ? records : []) {
|
|
128
130
|
const value = typeof record === 'string' ? record : record && typeof record === 'object' ? record.address : null;
|
|
129
131
|
if (typeof value !== 'string' || net.isIP(value) !== family)
|
|
@@ -135,7 +137,7 @@ function normalizeAddresses(records, family) {
|
|
|
135
137
|
}
|
|
136
138
|
function normalizeHostnames(records) {
|
|
137
139
|
const values = [];
|
|
138
|
-
let discarded = 0;
|
|
140
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
139
141
|
for (const record of Array.isArray(records) ? records : []) {
|
|
140
142
|
const value = normalizeHostname(record);
|
|
141
143
|
if (value)
|
|
@@ -147,19 +149,15 @@ function normalizeHostnames(records) {
|
|
|
147
149
|
}
|
|
148
150
|
function normalizeMx(records) {
|
|
149
151
|
const byKey = new Map();
|
|
150
|
-
let discarded = 0;
|
|
152
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
151
153
|
for (const record of Array.isArray(records) ? records : []) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
continue;
|
|
155
|
-
}
|
|
156
|
-
const value = record;
|
|
157
|
-
const exchange = value.exchange === '' || value.exchange === '.' ? '' : normalizeHostname(value.exchange);
|
|
158
|
-
const priority = Number(value.priority);
|
|
159
|
-
if (exchange === null || !Number.isInteger(priority) || priority < 0 || priority > 65535) {
|
|
154
|
+
const value = normalizeResolverMxRecord(record);
|
|
155
|
+
if (!value) {
|
|
160
156
|
discarded += 1;
|
|
161
157
|
continue;
|
|
162
158
|
}
|
|
159
|
+
const { priority } = value;
|
|
160
|
+
const exchange = value.exchange === '.' ? '' : value.exchange;
|
|
163
161
|
byKey.set(`${priority}:${exchange}`, { priority, exchange });
|
|
164
162
|
}
|
|
165
163
|
const values = [...byKey.values()].sort((a, b) => a.priority - b.priority || a.exchange.localeCompare(b.exchange));
|
|
@@ -167,7 +165,7 @@ function normalizeMx(records) {
|
|
|
167
165
|
}
|
|
168
166
|
function normalizeTxtPolicies(records, prefix) {
|
|
169
167
|
const values = [];
|
|
170
|
-
let discarded = 0;
|
|
168
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
171
169
|
for (const chunks of Array.isArray(records) ? records : []) {
|
|
172
170
|
if (!Array.isArray(chunks) || chunks.some((chunk) => typeof chunk !== 'string')) {
|
|
173
171
|
discarded += 1;
|
|
@@ -185,7 +183,7 @@ function normalizeTxtPolicies(records, prefix) {
|
|
|
185
183
|
}
|
|
186
184
|
function normalizeCaa(records) {
|
|
187
185
|
const byKey = new Map();
|
|
188
|
-
let discarded = 0;
|
|
186
|
+
let discarded = Array.isArray(records) ? 0 : 1;
|
|
189
187
|
for (const record of Array.isArray(records) ? records : []) {
|
|
190
188
|
const entry = record && typeof record === 'object' ? record : {};
|
|
191
189
|
const critical = Number(entry.critical);
|
|
@@ -395,10 +393,11 @@ function withTimeout(factory, timeoutMs) {
|
|
|
395
393
|
async function query(factory, normalize, timeoutMs) {
|
|
396
394
|
try {
|
|
397
395
|
const normalized = normalize(await withTimeout(factory, timeoutMs));
|
|
396
|
+
const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
|
|
398
397
|
return {
|
|
399
|
-
status: normalized.records.length ? 'success' : 'not_found',
|
|
398
|
+
status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
|
|
400
399
|
records: normalized.records,
|
|
401
|
-
error: null,
|
|
400
|
+
error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
|
|
402
401
|
truncated: normalized.truncated,
|
|
403
402
|
discarded: normalized.discarded || 0,
|
|
404
403
|
};
|
|
@@ -409,6 +408,12 @@ async function query(factory, normalize, timeoutMs) {
|
|
|
409
408
|
return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
|
|
410
409
|
}
|
|
411
410
|
}
|
|
411
|
+
function completeQuery(result) {
|
|
412
|
+
return result.status !== 'error' && !result.truncated && result.discarded === 0;
|
|
413
|
+
}
|
|
414
|
+
function observedPresence(result) {
|
|
415
|
+
return result.records.length > 0 ? true : completeQuery(result) ? false : null;
|
|
416
|
+
}
|
|
412
417
|
async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
413
418
|
const normalizedDomain = normalizeHostname(domain);
|
|
414
419
|
const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
|
|
@@ -459,7 +464,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
459
464
|
truncated: result.truncated,
|
|
460
465
|
discarded: result.discarded,
|
|
461
466
|
});
|
|
462
|
-
if (result.status === 'error') {
|
|
467
|
+
if (result.status === 'error' && !result.truncated && result.discarded === 0) {
|
|
463
468
|
return {
|
|
464
469
|
...createObservation({
|
|
465
470
|
status: index === 0 ? 'error' : 'partial',
|
|
@@ -576,6 +581,10 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
576
581
|
};
|
|
577
582
|
}
|
|
578
583
|
async function collectDnsIntelligence(domain, options = {}) {
|
|
584
|
+
const registrationDomain = options.registrationDomain ?? domain;
|
|
585
|
+
if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
|
|
586
|
+
throw new TypeError('DNS registration context must match the hostname being observed.');
|
|
587
|
+
}
|
|
579
588
|
const resolvers = options.resolvers || {};
|
|
580
589
|
const includeExtendedContext = options.includeExtendedContext === true;
|
|
581
590
|
const includeInheritedCaa = options.includeInheritedCaa === true;
|
|
@@ -606,8 +615,11 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
606
615
|
const httpsPromise = includeExtendedContext
|
|
607
616
|
? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
|
|
608
617
|
: Promise.resolve(null);
|
|
618
|
+
const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
|
|
619
|
+
? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
|
|
620
|
+
: nsPromise;
|
|
609
621
|
const delegationPromise = includeExtendedContext
|
|
610
|
-
?
|
|
622
|
+
? registrationNsPromise.then((parentNameservers) => collectDnsDelegationHealth(registrationDomain, parentNameservers, {
|
|
611
623
|
registryEvidence: options.registryEvidence,
|
|
612
624
|
resolve4: (resolvers.resolve4 || dns.resolve4),
|
|
613
625
|
resolve6: (resolvers.resolve6 || dns.resolve6),
|
|
@@ -645,7 +657,8 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
645
657
|
|| discardedCount > 0
|
|
646
658
|
|| (caaPolicy !== null && caaPolicy.complete !== true)
|
|
647
659
|
|| (delegation !== null && delegation.status !== 'success');
|
|
648
|
-
const classifiedMx =
|
|
660
|
+
const classifiedMx = classifyMxRecords(mx.records);
|
|
661
|
+
const mxComplete = completeQuery(mx);
|
|
649
662
|
return {
|
|
650
663
|
...createObservation({
|
|
651
664
|
status: errorCount === values.length ? 'error' : incomplete ? 'partial' : 'success',
|
|
@@ -703,11 +716,11 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
703
716
|
...(soa ? { soa: soa.records } : {}),
|
|
704
717
|
...(https ? { https: https.records } : {}),
|
|
705
718
|
},
|
|
706
|
-
hasMx: classifiedMx ?
|
|
707
|
-
hasNullMx:
|
|
708
|
-
mxHosts: classifiedMx
|
|
709
|
-
hasSpf: spf
|
|
710
|
-
hasDmarc: dmarc
|
|
719
|
+
hasMx: classifiedMx.hasMx ? true : mxComplete ? false : null,
|
|
720
|
+
hasNullMx: mxComplete ? classifiedMx.hasNullMx : null,
|
|
721
|
+
mxHosts: classifiedMx.mxHosts,
|
|
722
|
+
hasSpf: observedPresence(spf),
|
|
723
|
+
hasDmarc: observedPresence(dmarc),
|
|
711
724
|
delegation,
|
|
712
725
|
...(caaPolicy ? { caaPolicy } : {}),
|
|
713
726
|
};
|
package/lib/dns-mx.mjs
CHANGED
|
@@ -14,7 +14,7 @@ function classifyMxRecords(records) {
|
|
|
14
14
|
// of configuration. Not currently used for scoring (both cases already
|
|
15
15
|
// correctly score as hasMx: false); exposed for callers that want to tell
|
|
16
16
|
// the two apart.
|
|
17
|
-
const hasNullMx = records.length
|
|
17
|
+
const hasNullMx = records.length === 1 && records[0]?.priority === 0 && realRecords.length === 0;
|
|
18
18
|
return {
|
|
19
19
|
hasMx: realRecords.length > 0,
|
|
20
20
|
hasNullMx,
|
|
@@ -130,6 +130,9 @@ function signatureTimeState(value, observedAt) {
|
|
|
130
130
|
}
|
|
131
131
|
function validateDnssecEvidence(input) {
|
|
132
132
|
const normalizedOwner = ownerName(input.ownerName);
|
|
133
|
+
const missingDs = input.dsRecords === undefined;
|
|
134
|
+
const malformedContainers = [input.dsRecords, input.dnskeyRecords, input.rrSigRecords]
|
|
135
|
+
.filter((value) => value !== undefined && !Array.isArray(value)).length;
|
|
133
136
|
const rawDs = Array.isArray(input.dsRecords) ? input.dsRecords : [];
|
|
134
137
|
const rawDnskeys = Array.isArray(input.dnskeyRecords) ? input.dnskeyRecords : [];
|
|
135
138
|
const rawRrsigs = Array.isArray(input.rrSigRecords) ? input.rrSigRecords : [];
|
|
@@ -138,7 +141,8 @@ function validateDnssecEvidence(input) {
|
|
|
138
141
|
const rrsigs = rawRrsigs.slice(0, MAX_DNSSEC_RECORDS).map(normalizeRrsig).filter((item) => item !== null);
|
|
139
142
|
const rejectedCount = Math.min(rawDs.length, MAX_DNSSEC_RECORDS) - dsRecords.length
|
|
140
143
|
+ Math.min(rawDnskeys.length, MAX_DNSSEC_RECORDS) - dnskeys.length
|
|
141
|
-
+ Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length
|
|
144
|
+
+ Math.min(rawRrsigs.length, MAX_DNSSEC_RECORDS) - rrsigs.length
|
|
145
|
+
+ malformedContainers;
|
|
142
146
|
const truncated = rawDs.length > MAX_DNSSEC_RECORDS
|
|
143
147
|
|| rawDnskeys.length > MAX_DNSSEC_RECORDS
|
|
144
148
|
|| rawRrsigs.length > MAX_DNSSEC_RECORDS;
|
|
@@ -166,7 +170,11 @@ function validateDnssecEvidence(input) {
|
|
|
166
170
|
limitations: Object.freeze(limitations),
|
|
167
171
|
});
|
|
168
172
|
}
|
|
169
|
-
const incompleteInput = rejectedCount > 0 || truncated;
|
|
173
|
+
const incompleteInput = missingDs || rejectedCount > 0 || truncated;
|
|
174
|
+
if (missingDs)
|
|
175
|
+
findings.push('No DS record collection was supplied. Omitted input is not a verified empty collection.');
|
|
176
|
+
if (malformedContainers)
|
|
177
|
+
findings.push(`${malformedContainers} record collection container(s) had an invalid type; their contents are unknown.`);
|
|
170
178
|
if (input.delegationSigned === false && dsRecords.length === 0 && !incompleteInput) {
|
|
171
179
|
findings.push('The supplied delegation state was unsigned and no DS records were supplied.');
|
|
172
180
|
return Object.freeze({
|
|
@@ -217,7 +225,7 @@ function validateDnssecEvidence(input) {
|
|
|
217
225
|
findings.push('The retained subset contains no usable DS record, but omitted or rejected records prevent a definitive unsigned conclusion.');
|
|
218
226
|
}
|
|
219
227
|
if (rejectedCount > 0)
|
|
220
|
-
findings.push(`${rejectedCount} malformed record(s) were rejected.`);
|
|
228
|
+
findings.push(`${rejectedCount} malformed record(s) or collection container(s) were rejected.`);
|
|
221
229
|
if (truncated)
|
|
222
230
|
findings.push('The supplied record set exceeded the review bound and was truncated.');
|
|
223
231
|
const completeLocalRelationship = dsRecords.length > 0
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
2
|
import { DOMAIN_ASSURANCE_INPUT_SCHEMA, buildDomainAssurance, } from "./domain-assurance.mjs";
|
|
3
|
-
import { DOMAIN_CHANGE_INPUT_SCHEMA, reviewDomainChange, } from "./domain-change-review.mjs";
|
|
3
|
+
import { DOMAIN_CHANGE_INPUT_SCHEMA, DOMAIN_CHANGE_REVIEW_VERSION, reviewDomainChange, } from "./domain-change-review.mjs";
|
|
4
4
|
import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/artifact-integrity.mjs";
|
|
5
5
|
export const DOMAIN_CHANGE_PACKET_INPUT_SCHEMA = 'whoisleuth.domain-change-packet.input';
|
|
6
6
|
export const DOMAIN_CHANGE_PACKET_SCHEMA = 'whoisleuth.domain-change-packet';
|
|
7
7
|
export const DOMAIN_CHANGE_PACKET_INPUT_VERSION = 1;
|
|
8
|
-
export const DOMAIN_CHANGE_PACKET_VERSION =
|
|
8
|
+
export const DOMAIN_CHANGE_PACKET_VERSION = 3;
|
|
9
|
+
export const DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS = Object.freeze({
|
|
10
|
+
2: 1,
|
|
11
|
+
[DOMAIN_CHANGE_PACKET_VERSION]: DOMAIN_CHANGE_REVIEW_VERSION,
|
|
12
|
+
});
|
|
13
|
+
export const SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS = Object.freeze(Object.keys(DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS).map(Number));
|
|
9
14
|
export const MAX_DOMAIN_CHANGE_PACKET_INPUT_BYTES = 6 * 1024 * 1024;
|
|
10
15
|
const ROOT_KEYS = new Set([
|
|
11
16
|
'schema',
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
2
|
import { normaliseRdata } from "./zone-intent-review.mjs";
|
|
3
3
|
export const DOMAIN_CHANGE_INPUT_SCHEMA = 'whoisleuth.domain-change.input';
|
|
4
|
+
export const DOMAIN_CHANGE_INPUT_VERSION = 1;
|
|
4
5
|
export const DOMAIN_CHANGE_REVIEW_SCHEMA = 'whoisleuth.domain-change.review';
|
|
5
|
-
export const DOMAIN_CHANGE_REVIEW_VERSION =
|
|
6
|
+
export const DOMAIN_CHANGE_REVIEW_VERSION = 2;
|
|
6
7
|
export const MAX_DOMAIN_CHANGE_VANTAGES = 16;
|
|
7
8
|
export const MAX_DOMAIN_CHANGE_RECORDS = 500;
|
|
8
9
|
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'authoritySnapshots', 'resolverSnapshots', 'acmeDependencies', 'certificate', 'hsts']);
|
|
@@ -89,6 +90,9 @@ function normaliseSnapshots(value, apex, label) {
|
|
|
89
90
|
throw new TypeError(`${label} labels must be unique.`);
|
|
90
91
|
return Object.freeze(snapshots);
|
|
91
92
|
}
|
|
93
|
+
function snapshotProvenance(snapshot) {
|
|
94
|
+
return Object.freeze({ label: snapshot.label, source: snapshot.source, state: snapshot.state, observedAt: snapshot.observedAt });
|
|
95
|
+
}
|
|
92
96
|
function recordMatrix(snapshots) {
|
|
93
97
|
const keys = [...new Set(snapshots.flatMap((snapshot) => snapshot.records.map((item) => `${item.owner}\u0000${item.type}`)))].sort();
|
|
94
98
|
return Object.freeze(keys.map((key) => {
|
|
@@ -97,9 +101,7 @@ function recordMatrix(snapshots) {
|
|
|
97
101
|
const matches = snapshot.records.filter((item) => item.owner === recordOwner && item.type === type);
|
|
98
102
|
const ttls = matches.map((item) => item.ttl).filter((ttl) => ttl !== null);
|
|
99
103
|
return Object.freeze({
|
|
100
|
-
|
|
101
|
-
source: snapshot.source,
|
|
102
|
-
state: snapshot.state,
|
|
104
|
+
...snapshotProvenance(snapshot),
|
|
103
105
|
values: Object.freeze([...new Set(matches.map((item) => item.value))].sort()),
|
|
104
106
|
ttlRange: ttls.length ? Object.freeze({
|
|
105
107
|
minimum: Math.min(...ttls),
|
|
@@ -200,7 +202,7 @@ function normaliseCertificate(value) {
|
|
|
200
202
|
}
|
|
201
203
|
function reviewCertificate(certificate) {
|
|
202
204
|
if (!certificate)
|
|
203
|
-
return Object.freeze({ state: 'not_supplied', continuity: 'unknown', findings: Object.freeze([]) });
|
|
205
|
+
return Object.freeze({ state: 'not_supplied', observedAt: null, continuity: 'unknown', findings: Object.freeze([]) });
|
|
204
206
|
const continuity = !certificate.currentSpkiSha256 || !certificate.plannedSpkiSha256
|
|
205
207
|
? 'unknown'
|
|
206
208
|
: certificate.currentSpkiSha256 === certificate.plannedSpkiSha256 ? 'retained' : 'changes';
|
|
@@ -210,7 +212,7 @@ function reviewCertificate(certificate) {
|
|
|
210
212
|
...(certificate.embeddedSctCount === 0
|
|
211
213
|
? ['No embedded certificate-transparency timestamp was observed; this does not establish that the certificate was not logged by another mechanism.'] : []),
|
|
212
214
|
];
|
|
213
|
-
return Object.freeze({ state: certificate.state, continuity, findings: Object.freeze(findings) });
|
|
215
|
+
return Object.freeze({ state: certificate.state, observedAt: certificate.observedAt, continuity, findings: Object.freeze(findings) });
|
|
214
216
|
}
|
|
215
217
|
function normaliseHsts(value) {
|
|
216
218
|
if (value === null || value === undefined)
|
|
@@ -236,8 +238,8 @@ function serviceInventory(snapshots) {
|
|
|
236
238
|
}
|
|
237
239
|
export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
238
240
|
const input = requireRecord(inputRaw, 'Domain change input');
|
|
239
|
-
if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || input.version !==
|
|
240
|
-
throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version
|
|
241
|
+
if (input.schema !== DOMAIN_CHANGE_INPUT_SCHEMA || input.version !== DOMAIN_CHANGE_INPUT_VERSION)
|
|
242
|
+
throw new TypeError(`Domain change input must use ${DOMAIN_CHANGE_INPUT_SCHEMA} version ${DOMAIN_CHANGE_INPUT_VERSION}.`);
|
|
241
243
|
exactKeys(input, ROOT_KEYS, 'Domain change input');
|
|
242
244
|
const apex = domain(input.domain, 'domain');
|
|
243
245
|
const authoritySnapshots = normaliseSnapshots(input.authoritySnapshots ?? [], apex, 'authoritySnapshots');
|
|
@@ -285,6 +287,10 @@ export function reviewDomainChange(inputRaw, generatedAtValue = new Date().toISO
|
|
|
285
287
|
generatedAt: timestamp(generatedAtValue, 'generatedAt'),
|
|
286
288
|
domain: apex,
|
|
287
289
|
state: reviewRequired ? 'review' : 'ready',
|
|
290
|
+
sourceObservations: Object.freeze({
|
|
291
|
+
authorities: Object.freeze(authoritySnapshots.map(snapshotProvenance)),
|
|
292
|
+
resolvers: Object.freeze(resolverSnapshots.map(snapshotProvenance)),
|
|
293
|
+
}),
|
|
288
294
|
authoritativeRecordMatrix: authorityMatrix,
|
|
289
295
|
resolverDivergenceMatrix: resolverMatrix,
|
|
290
296
|
dnssecAutomation: reviewDnssecAutomation(authorityMatrix),
|