@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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/DISCLOSURE +1 -1
- package/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +67 -9
- package/cli/artifact-validation/lookup-evidence.mjs +18 -4
- package/cli/artifact-validation/signed-review.mjs +61 -64
- package/cli/artifact-verify.mjs +11 -2
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +6 -8
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +99 -6
- package/cli/case-pack.mjs +19 -4
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +330 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +7 -9
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/domain-feed.mjs +61 -0
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/json.mjs +1 -13
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +32 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +147 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +3 -4
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +220 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +19 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +85 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +132 -0
- package/cli/review-command-definitions.mjs +279 -0
- package/cli/review-command-runner.mjs +72 -159
- package/cli/risk-calibration.mjs +14 -12
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +73 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +4 -4
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +100 -0
- package/cli/workflow-command-definitions.mjs +103 -0
- package/cli/workflow-command-runner.mjs +40 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +322 -4
- package/docs/contextual-reviews.md +178 -0
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +35 -15
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +27 -11
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +75 -7
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +45 -25
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +27 -17
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +5 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +13 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +70 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/operation-budget.mjs +4 -0
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +46 -2
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +142 -139
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +404 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +15 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +348 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +151 -0
- package/packages/cases/case-evidence-model.mjs +89 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +124 -36
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +111 -118
- package/packages/cases/case-record-projection.mjs +23 -4
- package/packages/cases/case-record-version-input.mjs +41 -0
- package/packages/cases/case-report.mjs +93 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +159 -1
- package/packages/cases/case-response-model.mjs +16 -1
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +108 -20
- package/packages/cases/case-response-packet-render.mjs +35 -12
- package/packages/cases/case-response-packet.mjs +55 -13
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +60 -8
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +217 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +35 -15
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +360 -49
- package/packages/contracts/cli-command-semantics.mjs +5 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +25 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +37 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +144 -28
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +36 -29
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +36 -0
- package/packages/interchange/managed-indicator-set.mjs +223 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +79 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +87 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +132 -0
- package/packages/investigation/domain-transition-review.mjs +49 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +20 -0
- package/packages/investigation/investigation-manifest.mjs +35 -9
- package/packages/investigation/link-intake.mjs +209 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +235 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +48 -4
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +27 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +11 -3
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +24 -7
- package/packages/workspace/watchlist-history.mjs +59 -16
- package/packages/workspace/watchlist-store.mjs +152 -11
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -6,7 +6,7 @@ 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
8
|
import { normalizeResolverMxRecord } from "../packages/evidence/domain-control-runtime.mjs";
|
|
9
|
-
import { isValidAsciiHostname } from "
|
|
9
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
10
10
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
11
11
|
const DNS_DELEGATION_HEALTH_VERSION = 1;
|
|
12
12
|
const MAX_AUTHORITIES = 4;
|
|
@@ -99,15 +99,19 @@ function withTimeout(promise, timeoutMs) {
|
|
|
99
99
|
});
|
|
100
100
|
}
|
|
101
101
|
async function defaultAuthorityQuery(input) {
|
|
102
|
+
input.signal?.throwIfAborted();
|
|
102
103
|
const resolver = new dns.Resolver({
|
|
103
104
|
timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
|
|
104
105
|
tries: 1,
|
|
105
106
|
});
|
|
106
107
|
resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
|
|
108
|
+
const cancelled = () => resolver.cancel();
|
|
109
|
+
input.signal?.addEventListener('abort', cancelled, { once: true });
|
|
107
110
|
const [nameservers, soa] = await Promise.allSettled([
|
|
108
111
|
resolver.resolveNs(input.domain),
|
|
109
112
|
resolver.resolveSoa(input.domain),
|
|
110
|
-
]);
|
|
113
|
+
]).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
|
|
114
|
+
input.signal?.throwIfAborted();
|
|
111
115
|
const reason = nameservers.status === 'rejected'
|
|
112
116
|
? nameservers.reason
|
|
113
117
|
: soa.status === 'rejected'
|
|
@@ -188,18 +192,21 @@ function normaliseAuthorityValues(type, value) {
|
|
|
188
192
|
return normaliseAuthorityValueSet(type, value).values;
|
|
189
193
|
}
|
|
190
194
|
async function defaultAuthorityRecordQuery(input) {
|
|
195
|
+
input.signal?.throwIfAborted();
|
|
191
196
|
const resolver = new dns.Resolver({
|
|
192
197
|
timeout: Math.max(250, Math.min(input.timeoutMs, DNS_DELEGATION_TIMEOUT_MS)),
|
|
193
198
|
tries: 1,
|
|
194
199
|
});
|
|
195
200
|
resolver.setServers([net.isIP(input.address) === 6 ? `[${input.address}]:53` : input.address]);
|
|
201
|
+
const cancelled = () => resolver.cancel();
|
|
202
|
+
input.signal?.addEventListener('abort', cancelled, { once: true });
|
|
196
203
|
const queries = [
|
|
197
204
|
['A', resolver.resolve4(input.domain)],
|
|
198
205
|
['AAAA', resolver.resolve6(input.domain)],
|
|
199
206
|
['CAA', resolver.resolveCaa(input.domain)],
|
|
200
207
|
['MX', resolver.resolveMx(input.domain)],
|
|
201
208
|
];
|
|
202
|
-
|
|
209
|
+
const result = await Promise.all(queries.map(async ([type, promise]) => {
|
|
203
210
|
try {
|
|
204
211
|
const response = await withTimeout(promise, input.timeoutMs);
|
|
205
212
|
const normalised = normaliseAuthorityValueSet(type, response);
|
|
@@ -224,7 +231,9 @@ async function defaultAuthorityRecordQuery(input) {
|
|
|
224
231
|
discarded: 0,
|
|
225
232
|
};
|
|
226
233
|
}
|
|
227
|
-
}));
|
|
234
|
+
})).finally(() => { input.signal?.removeEventListener('abort', cancelled); resolver.cancel(); });
|
|
235
|
+
input.signal?.throwIfAborted();
|
|
236
|
+
return result;
|
|
228
237
|
}
|
|
229
238
|
function authorityRecordMatrix(authorities) {
|
|
230
239
|
const types = ['A', 'AAAA', 'CAA', 'MX'];
|
|
@@ -318,6 +327,7 @@ function skippedDnsDelegationHealth(detail = 'Authoritative delegation checks we
|
|
|
318
327
|
};
|
|
319
328
|
}
|
|
320
329
|
async function collectDnsDelegationHealth(domainValue, parentQueryValue, options = {}) {
|
|
330
|
+
options.signal?.throwIfAborted();
|
|
321
331
|
const domain = hostname(domainValue);
|
|
322
332
|
if (!domain)
|
|
323
333
|
return skippedDnsDelegationHealth('The domain was not eligible for authoritative delegation checks.');
|
|
@@ -338,7 +348,8 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
338
348
|
const timeoutMs = Math.max(250, Math.min(5000, Number(options.timeoutMs) || DNS_DELEGATION_TIMEOUT_MS));
|
|
339
349
|
const now = options.now || Date.now;
|
|
340
350
|
const started = now();
|
|
341
|
-
const
|
|
351
|
+
const pending = candidates.map(async (nameserver) => {
|
|
352
|
+
options.signal?.throwIfAborted();
|
|
342
353
|
const glue = registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses ?? [];
|
|
343
354
|
const resolved = glue.length
|
|
344
355
|
? glue
|
|
@@ -350,10 +361,11 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
350
361
|
: [])
|
|
351
362
|
.map(publicAddress)
|
|
352
363
|
.filter((address) => address !== null);
|
|
364
|
+
options.signal?.throwIfAborted();
|
|
353
365
|
const addresses = [...new Set(resolved)].slice(0, MAX_AUTHORITY_ADDRESSES);
|
|
354
366
|
const attempts = await Promise.all(addresses.map(async (address) => {
|
|
355
367
|
try {
|
|
356
|
-
const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs }), timeoutMs);
|
|
368
|
+
const raw = await withTimeout(queryAuthority({ domain, nameserver, address, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs);
|
|
357
369
|
const query = record(raw);
|
|
358
370
|
const code = boundedText(query.errorCode, 40);
|
|
359
371
|
const error = boundedText(query.error, MAX_ERROR_LENGTH);
|
|
@@ -384,6 +396,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
384
396
|
};
|
|
385
397
|
}
|
|
386
398
|
}));
|
|
399
|
+
options.signal?.throwIfAborted();
|
|
387
400
|
const successful = attempts.find((attempt) => attempt.state === 'success');
|
|
388
401
|
const partial = attempts.find((attempt) => attempt.state === 'partial');
|
|
389
402
|
const retained = successful ?? partial;
|
|
@@ -391,7 +404,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
391
404
|
const recordAddress = retained?.address ?? addresses[0];
|
|
392
405
|
if (queryAuthorityRecords && recordAddress) {
|
|
393
406
|
try {
|
|
394
|
-
recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs }), timeoutMs))
|
|
407
|
+
recordSets = (await withTimeout(queryAuthorityRecords({ domain, nameserver, address: recordAddress, timeoutMs, ...(options.signal ? { signal: options.signal } : {}) }), timeoutMs))
|
|
395
408
|
.filter((item) => ['A', 'AAAA', 'CAA', 'MX'].includes(item.type))
|
|
396
409
|
.slice(0, 4)
|
|
397
410
|
.map((item) => {
|
|
@@ -428,6 +441,7 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
428
441
|
}));
|
|
429
442
|
}
|
|
430
443
|
}
|
|
444
|
+
options.signal?.throwIfAborted();
|
|
431
445
|
return {
|
|
432
446
|
nameserver,
|
|
433
447
|
addressSource: glue.length ? 'registry_glue' : 'recursive_address',
|
|
@@ -445,7 +459,9 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
445
459
|
recordSets,
|
|
446
460
|
attempts,
|
|
447
461
|
};
|
|
448
|
-
})
|
|
462
|
+
});
|
|
463
|
+
const authorities = await Promise.all(pending).finally(() => Promise.allSettled(pending));
|
|
464
|
+
options.signal?.throwIfAborted();
|
|
449
465
|
const successfulAuthorities = authorities.filter((authority) => authority.state === 'success');
|
|
450
466
|
const partialAuthorities = authorities.filter((authority) => authority.state === 'partial');
|
|
451
467
|
const lameAuthorities = authorities.filter((authority) => authority.state === 'lame');
|
|
@@ -465,8 +481,10 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
465
481
|
.filter((primary) => primary !== null))];
|
|
466
482
|
const soaSerialConflict = soaSerials.length > 1;
|
|
467
483
|
const soaPrimaryConflict = soaPrimaries.length > 1;
|
|
468
|
-
const
|
|
469
|
-
|
|
484
|
+
const glueInventoryComplete = (parentStatus === 'success' && parentNameservers.length > 0 && parentQuery.truncated !== true)
|
|
485
|
+
|| (registry.nameservers.length > 0 && !registry.truncated);
|
|
486
|
+
const inBailiwick = eligibleAuthorities.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
|
|
487
|
+
const unobservedGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
|
|
470
488
|
const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
|
|
471
489
|
const recordMatrix = authorityRecordMatrix(authorities);
|
|
472
490
|
const recordCollectionAttempted = authorities.some((authority) => authority.recordSets.length > 0);
|
|
@@ -507,11 +525,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
507
525
|
: soaPrimaryConflict
|
|
508
526
|
? 'Authoritative servers published different SOA primary nameservers'
|
|
509
527
|
: 'Observed authority SOA serials and primary nameservers agree', retainedSoa.map((entry) => (`${entry.nameserver}: serial ${entry.soa.serial ?? 'unavailable'}, primary ${entry.soa.nsname ?? 'unavailable'}, refresh ${entry.soa.refresh ?? 'unavailable'}, retry ${entry.soa.retry ?? 'unavailable'}, expire ${entry.soa.expire ?? 'unavailable'}`)).join(' | ') || 'No complete SOA observation was available.', 'Confirm zone propagation and the intended SOA primary before relying on a DNS change or secondary-server state.'),
|
|
510
|
-
finding('in_bailiwick_glue', 'In-bailiwick glue', !
|
|
511
|
-
? '
|
|
512
|
-
:
|
|
513
|
-
? '
|
|
514
|
-
:
|
|
528
|
+
finding('in_bailiwick_glue', 'In-bailiwick glue', !glueInventoryComplete || unobservedGlue.length ? 'unknown' : 'healthy', !glueInventoryComplete
|
|
529
|
+
? 'The delegated nameserver inventory is unavailable or incomplete'
|
|
530
|
+
: !inBailiwick.length
|
|
531
|
+
? 'No in-bailiwick nameserver requires registry glue'
|
|
532
|
+
: unobservedGlue.length
|
|
533
|
+
? 'Registry address publication does not establish glue for every in-bailiwick nameserver'
|
|
534
|
+
: 'Registry publication includes observed glue for each in-bailiwick nameserver', unobservedGlue.length ? `No address publication retained for: ${unobservedGlue.join(', ')}. Optional RDAP address fields and recursive NS answers do not establish that parent-zone glue is absent.` : `In-bailiwick nameservers: ${inBailiwick.join(', ') || 'none'}.`, 'Verify parent-zone glue with the registry or DNS operator before deciding whether an update is needed.'),
|
|
515
535
|
finding('dnssec_delegation', 'DNSSEC delegation publication', dnssecMismatch ? 'warning'
|
|
516
536
|
: registry.delegationSigned === null && registry.dsRecordCount === 0 ? 'unknown' : 'healthy', dnssecMismatch
|
|
517
537
|
? 'Registry DNSSEC fields are internally inconsistent'
|
|
@@ -1,20 +1,16 @@
|
|
|
1
1
|
import { promises as dns } from 'node:dns';
|
|
2
2
|
import { parse as parseDomain } from 'tldts';
|
|
3
|
-
import {
|
|
3
|
+
import { validateDmarcExternalReporting } from "./domain-posture-analysis.mjs";
|
|
4
|
+
import { requireDmarcDomain, walkDmarcTree } from "./dmarc-discovery.mjs";
|
|
4
5
|
import { buildDnssecQuery, DNS_TYPE_NS, normalizeResolverEndpoint, parseDnssecResponse } from "./dnssec-chain-validation.mjs";
|
|
5
6
|
import { defaultTcpExchange } from "./service-binding-dns.mjs";
|
|
6
|
-
import { isValidAsciiHostname } from "
|
|
7
|
-
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
7
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
8
8
|
const MAX_PARENT_SERVERS = 2;
|
|
9
9
|
const MAX_NS = 16;
|
|
10
10
|
const QUERY_TIMEOUT_MS = 2200;
|
|
11
11
|
const REVIEW_TIMEOUT_MS = 10_000;
|
|
12
|
+
export const MAX_DMARC_REVIEW_QUERIES = 32;
|
|
12
13
|
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
14
|
/** Explicit query parameter; malformed opt-ins never broaden collection. */
|
|
19
15
|
export function parseInheritedDnsSelection(value) {
|
|
20
16
|
if (value === undefined || value === '')
|
|
@@ -26,53 +22,11 @@ export function parseInheritedDnsSelection(value) {
|
|
|
26
22
|
function check(id, label, status, summary, detail, records) {
|
|
27
23
|
return { id, label, status, summary, detail, records, remediation: '' };
|
|
28
24
|
}
|
|
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
25
|
/** RFC 9989 sections 4.10–4.10.2; at most eight owners including the exact name. */
|
|
53
26
|
export async function reviewInheritedDmarc(domain, exact, resolve) {
|
|
54
|
-
|
|
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);
|
|
27
|
+
const { rows, available, boundary, incomplete, organisationalDomain } = await walkDmarcTree(domain, exact, resolve, 'policy');
|
|
69
28
|
const explicit = rows[0].valid ? rows[0] : null;
|
|
70
|
-
const
|
|
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));
|
|
29
|
+
const selected = explicit ?? available.find(row => row.owner === organisationalDomain) ?? (boundary?.psd === 'y' ? boundary : null);
|
|
76
30
|
const summary = incomplete ? 'Inherited policy could not be determined from the collected records.'
|
|
77
31
|
: selected ? `${selected.owner === domain ? 'Exact-name' : 'Inherited'} policy published at _dmarc.${selected.owner}.`
|
|
78
32
|
: 'No applicable DMARC policy was found in the completed tree walk.';
|
|
@@ -90,7 +44,7 @@ function nameservers(value) {
|
|
|
90
44
|
return names.every(name => name.length <= 253 && isValidAsciiHostname(name)) ? [...new Set(names)].sort() : null;
|
|
91
45
|
}
|
|
92
46
|
export async function reviewParentDelegation(domain, recursive, dependencies, signal) {
|
|
93
|
-
|
|
47
|
+
requireDmarcDomain(domain);
|
|
94
48
|
const parsed = parseDomain(domain, { allowPrivateDomains: false });
|
|
95
49
|
const child = parsed.domain;
|
|
96
50
|
const parent = parsed.publicSuffix;
|
|
@@ -175,8 +129,8 @@ export async function reviewParentDelegation(domain, recursive, dependencies, si
|
|
|
175
129
|
}
|
|
176
130
|
return finish();
|
|
177
131
|
}
|
|
178
|
-
export async function
|
|
179
|
-
|
|
132
|
+
export async function collectDnsInheritanceReview(domain, exact, recursive, options = {}, injected) {
|
|
133
|
+
requireDmarcDomain(domain);
|
|
180
134
|
options.signal?.throwIfAborted();
|
|
181
135
|
const timeout = AbortSignal.timeout(REVIEW_TIMEOUT_MS);
|
|
182
136
|
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
@@ -188,10 +142,13 @@ export async function collectDnsInheritanceChecks(domain, exact, recursive, opti
|
|
|
188
142
|
resolve4: (name) => resolver.resolve4(name), resolve6: (name) => resolver.resolve6(name),
|
|
189
143
|
exchange: defaultTcpExchange, now: () => new Date(),
|
|
190
144
|
};
|
|
191
|
-
|
|
145
|
+
let questions = 0;
|
|
146
|
+
const cache = new Map([[`_dmarc.${domain}`, Promise.resolve(exact)]]);
|
|
147
|
+
const query = async (owner) => {
|
|
192
148
|
signal.throwIfAborted();
|
|
193
149
|
if (owner.length > 253)
|
|
194
150
|
return { records: [], error: 'The DNS question exceeds the name limit.' };
|
|
151
|
+
questions += 1;
|
|
195
152
|
try {
|
|
196
153
|
return { records: await dependencies.resolveTxt(owner), error: null, observedAt: dependencies.now().toISOString() };
|
|
197
154
|
}
|
|
@@ -200,17 +157,50 @@ export async function collectDnsInheritanceChecks(domain, exact, recursive, opti
|
|
|
200
157
|
return { records: [], error: MISSING.has(String(error?.code)) ? null : 'DNS observation unavailable.', observedAt: dependencies.now().toISOString() };
|
|
201
158
|
}
|
|
202
159
|
};
|
|
203
|
-
const
|
|
160
|
+
const resolve = (owner) => {
|
|
161
|
+
options.signal?.throwIfAborted();
|
|
162
|
+
const existing = cache.get(owner);
|
|
163
|
+
if (existing)
|
|
164
|
+
return existing;
|
|
165
|
+
if (questions >= MAX_DMARC_REVIEW_QUERIES)
|
|
166
|
+
return Promise.resolve({ records: [], error: 'The shared DMARC query budget was reached.' });
|
|
167
|
+
const pending = query(owner);
|
|
168
|
+
cache.set(owner, pending);
|
|
169
|
+
return pending;
|
|
170
|
+
};
|
|
171
|
+
const organisations = new Map();
|
|
172
|
+
const boundaryRecords = new Map();
|
|
173
|
+
const boundaryResults = new Map();
|
|
174
|
+
const resolveOrganisationalDomain = (owner) => {
|
|
175
|
+
const existing = organisations.get(owner);
|
|
176
|
+
if (existing)
|
|
177
|
+
return existing;
|
|
178
|
+
const pending = (async () => {
|
|
179
|
+
const walk = await walkDmarcTree(owner, await resolve(`_dmarc.${owner}`), resolve, 'organisation');
|
|
180
|
+
for (const row of walk.rows)
|
|
181
|
+
if (cache.has(`_dmarc.${row.owner}`))
|
|
182
|
+
boundaryRecords.set(row.owner, row.record);
|
|
183
|
+
boundaryResults.set(owner, `${owner}: organisational domain ${walk.organisationalDomain ?? 'unknown'}.`);
|
|
184
|
+
return walk.organisationalDomain;
|
|
185
|
+
})();
|
|
186
|
+
organisations.set(owner, pending);
|
|
187
|
+
return pending;
|
|
188
|
+
};
|
|
189
|
+
const operations = [
|
|
190
|
+
reviewInheritedDmarc(domain, exact, resolve),
|
|
191
|
+
reviewParentDelegation(domain, recursive, dependencies, signal),
|
|
192
|
+
validateDmarcExternalReporting(domain, exact, resolve, resolveOrganisationalDomain),
|
|
193
|
+
];
|
|
204
194
|
try {
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
195
|
+
const [policy, parent, reporting] = await Promise.allSettled(operations);
|
|
208
196
|
options.signal?.throwIfAborted();
|
|
209
|
-
const
|
|
210
|
-
|
|
197
|
+
const checks = [policy, parent].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.', []));
|
|
198
|
+
const dmarcAuthorizations = reporting.status === 'fulfilled' ? reporting.value : [];
|
|
199
|
+
if (organisations.size)
|
|
200
|
+
checks.push(check('dmarc_reporting_boundaries', 'DMARC reporting boundaries', reporting.status === 'rejected' || dmarcAuthorizations.some(item => !['self', 'authorized'].includes(item.state)) ? 'warning' : 'info', 'Reporting destinations were reviewed using DNS-derived organisational boundaries.', `${questions} additional DMARC TXT questions used; the shared limit is ${MAX_DMARC_REVIEW_QUERIES} within ten seconds. Each tree walk examines at most eight owners. Unavailable boundaries remain unknown; a missing optional authorisation record does not establish a configuration error.`, [...boundaryResults.values(), ...boundaryRecords.values()]));
|
|
201
|
+
return { checks, dmarcAuthorizations };
|
|
211
202
|
}
|
|
212
203
|
finally {
|
|
213
|
-
await Promise.allSettled(operations);
|
|
214
204
|
signal.removeEventListener('abort', cancel);
|
|
215
205
|
cancel();
|
|
216
206
|
}
|
package/lib/dns-intelligence.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Bounded DNS evidence for investigative deep scans. This collector keeps
|
|
2
|
-
//
|
|
2
|
+
// resolver-reported negative answers distinct from failures, preserves only record
|
|
3
3
|
// types that aid domain triage, and never treats shared infrastructure as proof
|
|
4
4
|
// of common ownership or maliciousness.
|
|
5
5
|
import { promises as dns } from 'node:dns';
|
|
@@ -11,7 +11,8 @@ import { createObservation } from "../packages/evidence/observation.mjs";
|
|
|
11
11
|
import { MAX_LOOKUP_DNS_RECORDS_PER_TYPE, MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS, } from "./lookup-network-evidence-bounds.mjs";
|
|
12
12
|
import { isPrivateAddress } from "./safe-fetch.mjs";
|
|
13
13
|
import { resolveServiceBindingRecords } from "./service-binding-dns.mjs";
|
|
14
|
-
import { canonicalRegistrableDomain } from "
|
|
14
|
+
import { canonicalRegistrableDomain } from "../packages/analysis/registrable-domain.mjs";
|
|
15
|
+
import { DNS_QUERY_OUTCOMES, dnsQueryFailureOutcome } from "../packages/evidence/dns-query-outcome.mjs";
|
|
15
16
|
const DNS_TIMEOUT_MS = 5000;
|
|
16
17
|
const MAX_RECORDS_PER_TYPE = MAX_LOOKUP_DNS_RECORDS_PER_TYPE;
|
|
17
18
|
const MAX_PTR_RECORDS = MAX_LOOKUP_REVERSE_DNS_PTR_RECORDS;
|
|
@@ -24,7 +25,6 @@ const MAX_SERVICE_ALPN_IDS = 16;
|
|
|
24
25
|
const MAX_SERVICE_ADDRESS_HINTS = 8;
|
|
25
26
|
const MAX_CAA_TREE_QUERIES = 8;
|
|
26
27
|
const DNS_CONTROL_CHARACTER_RE = /[\u0000-\u001f\u007f]/u;
|
|
27
|
-
const MISSING_CODES = new Set(['ENODATA', 'ENOTFOUND', 'ENONAME']);
|
|
28
28
|
function skippedDnsIntelligence(detail = 'DNS intelligence is disabled by deployment policy.', { includeExtendedContext = false, includeInheritedCaa = false, } = {}) {
|
|
29
29
|
const skipped = { status: 'skipped', error: null, truncated: false, discarded: 0 };
|
|
30
30
|
const recordTypes = ['a', 'aaaa', 'cname', 'ns', 'mx', 'spf', 'dmarc', 'caa'];
|
|
@@ -386,7 +386,7 @@ function normalizePtr(records) {
|
|
|
386
386
|
}
|
|
387
387
|
function withTimeout(factory, timeoutMs) {
|
|
388
388
|
return new Promise((resolve, reject) => {
|
|
389
|
-
const timer = setTimeout(() => reject(new Error('DNS query timed out')), timeoutMs);
|
|
389
|
+
const timer = setTimeout(() => reject(Object.assign(new Error('DNS query timed out'), { code: 'ETIMEOUT' })), timeoutMs);
|
|
390
390
|
Promise.resolve().then(factory).then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
391
391
|
});
|
|
392
392
|
}
|
|
@@ -396,6 +396,7 @@ async function query(factory, normalize, timeoutMs) {
|
|
|
396
396
|
const unusable = !normalized.records.length && (normalized.truncated || normalized.discarded > 0);
|
|
397
397
|
return {
|
|
398
398
|
status: unusable ? 'error' : normalized.records.length ? 'success' : 'not_found',
|
|
399
|
+
detail: unusable ? 'invalid_response' : normalized.records.length ? 'records' : 'empty_answer',
|
|
399
400
|
records: normalized.records,
|
|
400
401
|
error: unusable ? 'The returned record family could not be retained completely; absence is unknown.' : null,
|
|
401
402
|
truncated: normalized.truncated,
|
|
@@ -403,9 +404,9 @@ async function query(factory, normalize, timeoutMs) {
|
|
|
403
404
|
};
|
|
404
405
|
}
|
|
405
406
|
catch (error) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
return { status: 'error', records: [], error: boundedError(error), truncated: false, discarded: 0 };
|
|
407
|
+
const detail = dnsQueryFailureOutcome(error && typeof error === 'object' ? error.code : undefined);
|
|
408
|
+
const missing = DNS_QUERY_OUTCOMES[detail].status === 'not_found';
|
|
409
|
+
return { status: missing ? 'not_found' : 'error', detail, records: [], error: missing ? null : boundedError(error), truncated: false, discarded: 0 };
|
|
409
410
|
}
|
|
410
411
|
}
|
|
411
412
|
function completeQuery(result) {
|
|
@@ -415,6 +416,7 @@ function observedPresence(result) {
|
|
|
415
416
|
return result.records.length > 0 ? true : completeQuery(result) ? false : null;
|
|
416
417
|
}
|
|
417
418
|
async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
419
|
+
options.signal?.throwIfAborted();
|
|
418
420
|
const normalizedDomain = normalizeHostname(domain);
|
|
419
421
|
const queryLimit = Math.max(1, Math.min(MAX_CAA_TREE_QUERIES, Math.floor(options.queryLimit || MAX_CAA_TREE_QUERIES)));
|
|
420
422
|
const now = options.now || Date.now;
|
|
@@ -454,9 +456,11 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
454
456
|
const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
|
|
455
457
|
const queries = [];
|
|
456
458
|
for (const [index, owner] of owners.entries()) {
|
|
459
|
+
options.signal?.throwIfAborted();
|
|
457
460
|
const result = index === 0 && options.directResult
|
|
458
461
|
? options.directResult
|
|
459
462
|
: await query(() => resolver(owner), normalizeCaa, timeoutMs);
|
|
463
|
+
options.signal?.throwIfAborted();
|
|
460
464
|
queries.push({
|
|
461
465
|
owner,
|
|
462
466
|
status: result.status,
|
|
@@ -581,6 +585,7 @@ async function collectEffectiveCaaPolicy(domain, options = {}) {
|
|
|
581
585
|
};
|
|
582
586
|
}
|
|
583
587
|
async function collectDnsIntelligence(domain, options = {}) {
|
|
588
|
+
options.signal?.throwIfAborted();
|
|
584
589
|
const registrationDomain = options.registrationDomain ?? domain;
|
|
585
590
|
if (options.registrationDomain !== undefined && canonicalRegistrableDomain(domain) !== registrationDomain) {
|
|
586
591
|
throw new TypeError('DNS registration context must match the hostname being observed.');
|
|
@@ -591,7 +596,10 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
591
596
|
const timeoutMs = options.timeoutMs || DNS_TIMEOUT_MS;
|
|
592
597
|
const now = options.now || Date.now;
|
|
593
598
|
const started = now();
|
|
594
|
-
const invoke = (name, fallback, value = domain) => () =>
|
|
599
|
+
const invoke = (name, fallback, value = domain) => () => {
|
|
600
|
+
options.signal?.throwIfAborted();
|
|
601
|
+
return (resolvers[name] || fallback)(value);
|
|
602
|
+
};
|
|
595
603
|
const aPromise = query(invoke('resolve4', dns.resolve4), (records) => normalizeAddresses(records, 4), timeoutMs);
|
|
596
604
|
const aaaaPromise = query(invoke('resolve6', dns.resolve6), (records) => normalizeAddresses(records, 6), timeoutMs);
|
|
597
605
|
const cnamePromise = query(invoke('resolveCname', dns.resolveCname), normalizeHostnames, timeoutMs);
|
|
@@ -606,6 +614,7 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
606
614
|
directResult,
|
|
607
615
|
timeoutMs,
|
|
608
616
|
now,
|
|
617
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
609
618
|
...(options.observedAt ? { observedAt: options.observedAt } : {}),
|
|
610
619
|
}))
|
|
611
620
|
: Promise.resolve(null);
|
|
@@ -613,7 +622,7 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
613
622
|
? query(invoke('resolveSoa', dns.resolveSoa), normalizeSoa, timeoutMs)
|
|
614
623
|
: Promise.resolve(null);
|
|
615
624
|
const httpsPromise = includeExtendedContext
|
|
616
|
-
? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs })), normalizeServiceBindings, timeoutMs)
|
|
625
|
+
? query(invoke('resolveHttps', (value) => resolveServiceBindingRecords(value, 'HTTPS', { timeoutMs, ...(options.signal ? { signal: options.signal } : {}) })), normalizeServiceBindings, timeoutMs)
|
|
617
626
|
: Promise.resolve(null);
|
|
618
627
|
const registrationNsPromise = includeExtendedContext && registrationDomain !== domain
|
|
619
628
|
? query(invoke('resolveNs', dns.resolveNs, registrationDomain), normalizeHostnames, timeoutMs)
|
|
@@ -626,10 +635,11 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
626
635
|
...(options.queryAuthority ? { queryAuthority: options.queryAuthority } : {}),
|
|
627
636
|
timeoutMs,
|
|
628
637
|
now,
|
|
638
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
629
639
|
...(options.observedAt ? { observedAt: options.observedAt } : {}),
|
|
630
640
|
}))
|
|
631
641
|
: Promise.resolve(null);
|
|
632
|
-
const
|
|
642
|
+
const pending = [
|
|
633
643
|
aPromise,
|
|
634
644
|
aaaaPromise,
|
|
635
645
|
cnamePromise,
|
|
@@ -642,7 +652,12 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
642
652
|
soaPromise,
|
|
643
653
|
httpsPromise,
|
|
644
654
|
delegationPromise,
|
|
645
|
-
]
|
|
655
|
+
];
|
|
656
|
+
// A rejected continuation must not release the lookup while sibling queries
|
|
657
|
+
// are still running. Their existing bounded resolver deadlines remain active.
|
|
658
|
+
const [a, aaaa, cname, ns, mx, spf, dmarc, caa, caaPolicy, soa, https, delegation] = await Promise.all(pending)
|
|
659
|
+
.finally(() => Promise.allSettled(pending));
|
|
660
|
+
options.signal?.throwIfAborted();
|
|
646
661
|
const queries = {
|
|
647
662
|
a, aaaa, cname, ns, mx, spf, dmarc, caa,
|
|
648
663
|
...(soa ? { soa } : {}),
|
|
@@ -683,6 +698,7 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
683
698
|
diagnostics: {
|
|
684
699
|
...Object.fromEntries(Object.entries(queries).map(([name, item]) => [name, {
|
|
685
700
|
status: item.status,
|
|
701
|
+
detail: item.detail,
|
|
686
702
|
error: item.error,
|
|
687
703
|
truncated: item.truncated,
|
|
688
704
|
discarded: item.discarded,
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { exactKeys, requireRecord } from "./bounded-contract-normalizers.mjs";
|
|
2
|
+
const QUERY_KEYS = new Set(['owner', 'type', 'state']);
|
|
3
|
+
/** A positive legacy record proves its own query scope, never another type's absence. */
|
|
4
|
+
export function normaliseDnsQueryCoverage(value, options) {
|
|
5
|
+
const { label, maximum, state, records } = options;
|
|
6
|
+
if (options.legacy) {
|
|
7
|
+
return Object.freeze([...new Map(records.map((record) => [
|
|
8
|
+
`${record.owner}\u0000${record.type}`,
|
|
9
|
+
Object.freeze({ owner: record.owner, type: record.type, state }),
|
|
10
|
+
])).values()]);
|
|
11
|
+
}
|
|
12
|
+
if (!Array.isArray(value) || value.length > maximum)
|
|
13
|
+
throw new TypeError(`${label} must contain no more than ${maximum} query outcomes.`);
|
|
14
|
+
const byKey = new Map();
|
|
15
|
+
for (const [index, raw] of value.entries()) {
|
|
16
|
+
const path = `${label}[${index}]`;
|
|
17
|
+
const item = requireRecord(raw, path);
|
|
18
|
+
exactKeys(item, QUERY_KEYS, path);
|
|
19
|
+
const owner = options.owner(item.owner, `${path}.owner`);
|
|
20
|
+
const type = options.type(item.type, `${path}.type`);
|
|
21
|
+
if (item.state !== 'observed' && item.state !== 'partial' && item.state !== 'unavailable')
|
|
22
|
+
throw new TypeError(`${path}.state is unsupported.`);
|
|
23
|
+
if ((state === 'unavailable' && item.state !== 'unavailable') || (state === 'observed' && item.state !== 'observed')) {
|
|
24
|
+
throw new TypeError(`${path}.state contradicts the snapshot state.`);
|
|
25
|
+
}
|
|
26
|
+
const key = `${owner}\u0000${type}`;
|
|
27
|
+
if (byKey.has(key))
|
|
28
|
+
throw new TypeError(`${path} duplicates an owner and type.`);
|
|
29
|
+
byKey.set(key, Object.freeze({ owner, type, state: item.state }));
|
|
30
|
+
}
|
|
31
|
+
for (const record of records) {
|
|
32
|
+
const query = byKey.get(`${record.owner}\u0000${record.type}`);
|
|
33
|
+
if (!query || query.state === 'unavailable')
|
|
34
|
+
throw new TypeError(`${label} must declare an observed or partial query for every supplied record.`);
|
|
35
|
+
}
|
|
36
|
+
return Object.freeze([...byKey.values()]);
|
|
37
|
+
}
|
|
38
|
+
export function dnsQueryState(queries, owner, type) {
|
|
39
|
+
return queries.find((query) => query.owner === owner && query.type === type)?.state ?? 'not_queried';
|
|
40
|
+
}
|
package/lib/domain-assurance.mjs
CHANGED
|
@@ -243,7 +243,7 @@ function buildRecoveryDependencies(input) {
|
|
|
243
243
|
};
|
|
244
244
|
}
|
|
245
245
|
const RETIREMENT_CHECKS = Object.freeze([
|
|
246
|
-
['autoRenewDisabled', 'Auto-renew is
|
|
246
|
+
['autoRenewDisabled', 'Auto-renew is disabled as declared', true],
|
|
247
247
|
['registrarLockMaintained', 'Registrar lock remains enabled', true],
|
|
248
248
|
['websiteRetired', 'Web service is retired or intentionally redirected', true],
|
|
249
249
|
['mailRetired', 'Mail service is retired', true],
|
|
@@ -5,9 +5,10 @@ import { SORTED_JSON_V2, sha256ArtifactDigestV2 } from "../packages/evidence/art
|
|
|
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 = 4;
|
|
9
9
|
export const DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS = Object.freeze({
|
|
10
10
|
2: 1,
|
|
11
|
+
3: 2,
|
|
11
12
|
[DOMAIN_CHANGE_PACKET_VERSION]: DOMAIN_CHANGE_REVIEW_VERSION,
|
|
12
13
|
});
|
|
13
14
|
export const SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS = Object.freeze(Object.keys(DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS).map(Number));
|
|
@@ -43,16 +44,10 @@ function changeSummary(before, after) {
|
|
|
43
44
|
const beforeRows = rows(before);
|
|
44
45
|
const afterRows = rows(after);
|
|
45
46
|
const keys = [...new Set([...beforeRows.keys(), ...afterRows.keys()])].sort();
|
|
46
|
-
const
|
|
47
|
-
const representative = review.authoritativeRecordMatrix[0];
|
|
48
|
-
return Boolean(representative
|
|
49
|
-
&& representative.observations.length >= 2
|
|
50
|
-
&& representative.observations.every((item) => item.state === 'observed'));
|
|
51
|
-
};
|
|
52
|
-
const rowEvidence = (review, row) => {
|
|
47
|
+
const rowEvidence = (row) => {
|
|
53
48
|
if (!row)
|
|
54
49
|
return Object.freeze({
|
|
55
|
-
state:
|
|
50
|
+
state: 'unavailable',
|
|
56
51
|
values: Object.freeze([]),
|
|
57
52
|
});
|
|
58
53
|
if (row.observations.some((item) => item.state !== 'observed')) {
|
|
@@ -68,13 +63,15 @@ function changeSummary(before, after) {
|
|
|
68
63
|
});
|
|
69
64
|
};
|
|
70
65
|
const changed = [];
|
|
66
|
+
const reasons = keys.length > 500 ? ['The comparison exceeds 500 owner and record-type pairs.'] : [];
|
|
71
67
|
for (const key of keys.slice(0, 500)) {
|
|
72
68
|
const left = beforeRows.get(key);
|
|
73
69
|
const right = afterRows.get(key);
|
|
74
70
|
const [owner, type] = key.split('\u0000');
|
|
75
|
-
const beforeEvidence = rowEvidence(
|
|
76
|
-
const afterEvidence = rowEvidence(
|
|
71
|
+
const beforeEvidence = rowEvidence(left);
|
|
72
|
+
const afterEvidence = rowEvidence(right);
|
|
77
73
|
if (beforeEvidence.state !== 'complete' || afterEvidence.state !== 'complete') {
|
|
74
|
+
reasons.push(`Comparison evidence is incomplete for ${owner} ${type}.`);
|
|
78
75
|
continue;
|
|
79
76
|
}
|
|
80
77
|
if (JSON.stringify(beforeEvidence.values) === JSON.stringify(afterEvidence.values))
|
|
@@ -86,7 +83,7 @@ function changeSummary(before, after) {
|
|
|
86
83
|
afterValues: afterEvidence.values,
|
|
87
84
|
}));
|
|
88
85
|
}
|
|
89
|
-
return Object.freeze(changed);
|
|
86
|
+
return Object.freeze({ changed: Object.freeze(changed), reasons: Object.freeze(reasons) });
|
|
90
87
|
}
|
|
91
88
|
export async function buildDomainChangePacket(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
92
89
|
const input = record(inputRaw, 'Domain change packet input');
|
|
@@ -107,11 +104,13 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
|
|
|
107
104
|
const preChange = reviewDomainChange(input.preChange, generatedAt);
|
|
108
105
|
const postChange = reviewDomainChange(input.postChange, generatedAt);
|
|
109
106
|
const assurance = buildDomainAssurance(input.assurance, generatedAt);
|
|
107
|
+
const comparison = changeSummary(preChange, postChange);
|
|
110
108
|
const assuranceReasons = assurance.result.review.reasons;
|
|
111
109
|
const gateReasons = Object.freeze([
|
|
112
110
|
...preChange.gate.reasons.map((reason) => `Pre-change evidence: ${reason}`),
|
|
113
111
|
...postChange.gate.reasons.map((reason) => `Post-change evidence: ${reason}`),
|
|
114
112
|
...assuranceReasons.map((reason) => `Change plan: ${reason}`),
|
|
113
|
+
...comparison.reasons,
|
|
115
114
|
].slice(0, 100));
|
|
116
115
|
const unsigned = Object.freeze({
|
|
117
116
|
schema: DOMAIN_CHANGE_PACKET_SCHEMA,
|
|
@@ -122,7 +121,7 @@ export async function buildDomainChangePacket(inputRaw, generatedAtValue = new D
|
|
|
122
121
|
state: gateReasons.length ? 'review' : 'ready',
|
|
123
122
|
gate: Object.freeze({ pass: gateReasons.length === 0, reasons: gateReasons }),
|
|
124
123
|
summary: Object.freeze({
|
|
125
|
-
changedAuthoritativeRecordSets:
|
|
124
|
+
changedAuthoritativeRecordSets: comparison.changed,
|
|
126
125
|
preChangeState: preChange.state,
|
|
127
126
|
postChangeState: postChange.state,
|
|
128
127
|
assuranceState: assurance.result.review.state,
|