@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -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,12 +183,17 @@ 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);
|
|
192
|
-
const
|
|
193
|
-
const
|
|
190
|
+
const nodeTags = ['issue', 'issuewild', 'iodef'].filter((tag) => Object.hasOwn(entry, tag));
|
|
191
|
+
const hasPair = Object.hasOwn(entry, 'tag') || Object.hasOwn(entry, 'value');
|
|
192
|
+
const tag = hasPair
|
|
193
|
+
? String(entry.tag || '').trim().toLowerCase()
|
|
194
|
+
: nodeTags.length === 1 ? nodeTags[0] : '';
|
|
195
|
+
const rawValue = hasPair ? entry.value : tag ? entry[tag] : undefined;
|
|
196
|
+
const value = typeof rawValue === 'string' ? rawValue.trim() : '';
|
|
194
197
|
if (!Number.isInteger(critical) || critical < 0 || critical > 255 || !/^[a-z0-9-]{1,15}$/.test(tag) || !value || value.length > MAX_POLICY_LENGTH || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
195
198
|
discarded += 1;
|
|
196
199
|
continue;
|
|
@@ -390,10 +393,11 @@ function withTimeout(factory, timeoutMs) {
|
|
|
390
393
|
async function query(factory, normalize, timeoutMs) {
|
|
391
394
|
try {
|
|
392
395
|
const normalized = normalize(await withTimeout(factory, timeoutMs));
|
|
396
|
+
const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
|
|
393
397
|
return {
|
|
394
|
-
status: normalized.records.length ? 'success' : 'not_found',
|
|
398
|
+
status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
|
|
395
399
|
records: normalized.records,
|
|
396
|
-
error: null,
|
|
400
|
+
error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
|
|
397
401
|
truncated: normalized.truncated,
|
|
398
402
|
discarded: normalized.discarded || 0,
|
|
399
403
|
};
|
|
@@ -404,6 +408,12 @@ async function query(factory, normalize, timeoutMs) {
|
|
|
404
408
|
return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
|
|
405
409
|
}
|
|
406
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
|
+
}
|
|
407
417
|
async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
408
418
|
const normalizedDomain = normalizeHostname(domain);
|
|
409
419
|
const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
|
|
@@ -454,7 +464,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
454
464
|
truncated: result.truncated,
|
|
455
465
|
discarded: result.discarded,
|
|
456
466
|
});
|
|
457
|
-
if (result.status === 'error') {
|
|
467
|
+
if (result.status === 'error' && !result.truncated && result.discarded === 0) {
|
|
458
468
|
return {
|
|
459
469
|
...createObservation({
|
|
460
470
|
status: index === 0 ? 'error' : 'partial',
|
|
@@ -511,6 +521,35 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
511
521
|
records: result.records,
|
|
512
522
|
};
|
|
513
523
|
}
|
|
524
|
+
if (result.truncated || result.discarded > 0) {
|
|
525
|
+
return {
|
|
526
|
+
...createObservation({
|
|
527
|
+
status: 'partial',
|
|
528
|
+
observedAt: (options.observedAt || (() => new Date().toISOString()))(),
|
|
529
|
+
scanMode: 'deep',
|
|
530
|
+
source: 'dns',
|
|
531
|
+
durationMs: Math.max(0, now() - started),
|
|
532
|
+
complete: false,
|
|
533
|
+
truncated: result.truncated,
|
|
534
|
+
limitations,
|
|
535
|
+
diagnostics: {
|
|
536
|
+
tree: {
|
|
537
|
+
status: 'partial',
|
|
538
|
+
detail: `The CAA answer at ${owner} contained discarded or truncated records, so parent inheritance was not evaluated.`,
|
|
539
|
+
count: queries.length,
|
|
540
|
+
truncated: result.truncated,
|
|
541
|
+
discarded: result.discarded,
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
}),
|
|
545
|
+
policyVersion: 1,
|
|
546
|
+
queryLimit,
|
|
547
|
+
queriedOwners: queries,
|
|
548
|
+
effectiveOwner: null,
|
|
549
|
+
inherited: null,
|
|
550
|
+
records: [],
|
|
551
|
+
};
|
|
552
|
+
}
|
|
514
553
|
}
|
|
515
554
|
return {
|
|
516
555
|
...createObservation({
|
|
@@ -542,6 +581,10 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
542
581
|
};
|
|
543
582
|
}
|
|
544
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
|
+
}
|
|
545
588
|
const resolvers = options.resolvers || {};
|
|
546
589
|
const includeExtendedContext = options.includeExtendedContext === true;
|
|
547
590
|
const includeInheritedCaa = options.includeInheritedCaa === true;
|
|
@@ -572,8 +615,11 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
572
615
|
const httpsPromise = includeExtendedContext
|
|
573
616
|
? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
|
|
574
617
|
: Promise.resolve(null);
|
|
618
|
+
const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
|
|
619
|
+
? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
|
|
620
|
+
: nsPromise;
|
|
575
621
|
const delegationPromise = includeExtendedContext
|
|
576
|
-
?
|
|
622
|
+
? registrationNsPromise.then((parentNameservers) => collectDnsDelegationHealth(registrationDomain, parentNameservers, {
|
|
577
623
|
registryEvidence: options.registryEvidence,
|
|
578
624
|
resolve4: (resolvers.resolve4 || dns.resolve4),
|
|
579
625
|
resolve6: (resolvers.resolve6 || dns.resolve6),
|
|
@@ -611,7 +657,8 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
611
657
|
|| discardedCount > 0
|
|
612
658
|
|| (caaPolicy !== null && caaPolicy.complete !== true)
|
|
613
659
|
|| (delegation !== null && delegation.status !== 'success');
|
|
614
|
-
const classifiedMx =
|
|
660
|
+
const classifiedMx = classifyMxRecords(mx.records);
|
|
661
|
+
const mxComplete = completeQuery(mx);
|
|
615
662
|
return {
|
|
616
663
|
...createObservation({
|
|
617
664
|
status: errorCount === values.length ? 'error' : incomplete ? 'partial' : 'success',
|
|
@@ -669,11 +716,11 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
669
716
|
...(soa ? { soa: soa.records } : {}),
|
|
670
717
|
...(https ? { https: https.records } : {}),
|
|
671
718
|
},
|
|
672
|
-
hasMx: classifiedMx ?
|
|
673
|
-
hasNullMx:
|
|
674
|
-
mxHosts: classifiedMx
|
|
675
|
-
hasSpf: spf
|
|
676
|
-
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),
|
|
677
724
|
delegation,
|
|
678
725
|
...(caaPolicy ? { caaPolicy } : {}),
|
|
679
726
|
};
|
|
@@ -706,7 +753,7 @@ async function collectReverseDnsIntelligence(address, options = {}) {
|
|
|
706
753
|
const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
|
|
707
754
|
const now = options.now || Date.now;
|
|
708
755
|
const started = now();
|
|
709
|
-
const ptr = await query(() => (options.resolver || dns.
|
|
756
|
+
const ptr = await query(() => (options.resolver || dns.reverse)(address), normalizePtr, timeoutMs);
|
|
710
757
|
const incomplete = ptr.status === 'error' || ptr.truncated || ptr.discarded > 0;
|
|
711
758
|
return {
|
|
712
759
|
...createObservation({
|
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,
|
|
@@ -718,6 +718,9 @@ function validateNegativeProof(response, name, missingType, keys, zone, now, req
|
|
|
718
718
|
const verification = verifyRrset(response, exactNsec.owner, DNS_TYPE_NSEC, keys, zone, now);
|
|
719
719
|
if (verification.state !== 'valid')
|
|
720
720
|
return verification;
|
|
721
|
+
if (exactNsec.data.types.has(DNS_TYPE_CNAME)) {
|
|
722
|
+
return { state: 'unsupported', detail: 'The validated NSEC owner is an alias; the target RRset cannot be treated as authenticated absence without a validated alias walk.' };
|
|
723
|
+
}
|
|
721
724
|
const delegation = exactNsec.data.types.has(DNS_TYPE_NS) && !exactNsec.data.types.has(DNS_TYPE_SOA);
|
|
722
725
|
if (!exactNsec.data.types.has(missingType) && (!requireDelegation || delegation)) {
|
|
723
726
|
return { state: 'valid', detail: 'A validated NSEC record proved the requested RRset absent.' };
|
|
@@ -758,6 +761,9 @@ function validateNegativeProof(response, name, missingType, keys, zone, now, req
|
|
|
758
761
|
sawBogus = true;
|
|
759
762
|
continue;
|
|
760
763
|
}
|
|
764
|
+
if (exact && record.data.types.has(DNS_TYPE_CNAME)) {
|
|
765
|
+
return { state: 'unsupported', detail: 'The validated NSEC3 owner is an alias; the target RRset cannot be treated as authenticated absence without a validated alias walk.' };
|
|
766
|
+
}
|
|
761
767
|
if (exact && record.data.types.has(missingType))
|
|
762
768
|
return { state: 'bogus', detail: 'The validated NSEC3 bitmap contains the RR type reported as absent.' };
|
|
763
769
|
if (requireDelegation && exact && (!record.data.types.has(DNS_TYPE_NS) || record.data.types.has(DNS_TYPE_SOA)))
|
|
@@ -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
|