@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- 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 +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- 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 +329 -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 +3 -3
- 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/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/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 +33 -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 +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- 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 +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -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 +39 -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 +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -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 +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -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/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 +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- 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 +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- 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 +7 -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-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 +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- 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 +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- 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/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +345 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -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-recheck-model.mjs +90 -32
- 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 +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/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 +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -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/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/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -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/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +1 -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 +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -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 +195 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- 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 +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- 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
package/lib/ct-search.mjs
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
// Express server and the Netlify Functions.
|
|
9
9
|
import { parse } from 'tldts';
|
|
10
10
|
import { setTimeout as sleep } from 'node:timers/promises';
|
|
11
|
-
import { normalizeCtQuery } from "
|
|
11
|
+
import { normalizeCtQuery } from "../packages/analysis/ct-query.mjs";
|
|
12
12
|
import { safeFetch, readTextCapped } from "./safe-fetch.mjs";
|
|
13
13
|
import { whoisleuthRequestHeaders } from "./outbound-identity.mjs";
|
|
14
14
|
import { createObservation } from "../packages/evidence/observation.mjs";
|
|
15
|
-
import { isValidAsciiHostname } from "
|
|
15
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
16
16
|
import { normalizeCtTimestamp } from "../packages/evidence/observation.mjs";
|
|
17
|
-
import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "
|
|
17
|
+
import { MAX_CT_RESPONSE_CERTIFICATE_GROUPS, MAX_CT_RESPONSE_DOMAINS_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_GROUP, MAX_CT_RESPONSE_HOSTNAMES_PER_MATCH, MAX_CT_RESPONSE_RESULTS, MAX_CT_RESPONSE_TIMESTAMP_LENGTH, } from "../packages/analysis/ct-response-bounds.mjs";
|
|
18
18
|
const CRT_SH_TIMEOUT_MS = 20000;
|
|
19
19
|
const MAX_RESULTS = MAX_CT_RESPONSE_RESULTS;
|
|
20
20
|
// crt.sh's response size scales with how many certificates ever matched the
|
|
@@ -38,6 +38,27 @@ const RETRY_DELAY_MS = 1500;
|
|
|
38
38
|
// delay before a status-code retry above), so this caps the worst case at
|
|
39
39
|
// two attempts (~40s) instead of stacking on top of MAX_RETRIES.
|
|
40
40
|
const MAX_TIMEOUT_RETRIES = 1;
|
|
41
|
+
const COLLECTION_ERRORS = {
|
|
42
|
+
CT_TIMEOUT: [504, 'Certificate search timed out. Try a narrower keyword or try again later.'],
|
|
43
|
+
CT_OVERLOADED: [503, 'Certificate search is temporarily overloaded. Try again later.'],
|
|
44
|
+
CT_QUERY_TOO_BROAD: [422, 'Certificate search returned too many results. Try a narrower keyword.'],
|
|
45
|
+
CT_INVALID_RESPONSE: [502, 'Certificate search returned invalid UTF-8 or an unexpected response format (expected a JSON array). Try again later.'],
|
|
46
|
+
CT_UPSTREAM_ERROR: [502, 'Certificate search could not retrieve a response from its source. Try again later.'],
|
|
47
|
+
};
|
|
48
|
+
export class CtCollectionError extends Error {
|
|
49
|
+
code;
|
|
50
|
+
constructor(code) {
|
|
51
|
+
super(COLLECTION_ERRORS[code][1]);
|
|
52
|
+
this.code = code;
|
|
53
|
+
this.name = 'CtCollectionError';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Only this finite collection vocabulary may cross an HTTP error boundary. */
|
|
57
|
+
export function ctCollectionErrorResponse(error) {
|
|
58
|
+
return error instanceof CtCollectionError
|
|
59
|
+
? { statusCode: COLLECTION_ERRORS[error.code][0], body: { error: COLLECTION_ERRORS[error.code][1], errorCode: error.code } }
|
|
60
|
+
: null;
|
|
61
|
+
}
|
|
41
62
|
// Row-count defense: a hostile or malformed response could fit a very large
|
|
42
63
|
// number of tiny rows into the 5 MB byte cap and impose excessive
|
|
43
64
|
// iteration/set overhead after parsing. Legitimate responses under the byte
|
|
@@ -75,61 +96,76 @@ function normalizeHostname(raw) {
|
|
|
75
96
|
function delay(ms, signal) {
|
|
76
97
|
return sleep(ms, undefined, signal ? { signal } : {});
|
|
77
98
|
}
|
|
78
|
-
async function fetchCrtSh(keyword,
|
|
99
|
+
async function fetchCrtSh(keyword, dependencies = {}) {
|
|
79
100
|
dependencies.signal?.throwIfAborted();
|
|
80
101
|
const fetcher = dependencies.fetcher || safeFetch;
|
|
81
102
|
const wait = dependencies.delay || delay;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
103
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
104
|
+
dependencies.signal?.throwIfAborted();
|
|
105
|
+
const controller = new AbortController();
|
|
106
|
+
const signal = dependencies.signal ? AbortSignal.any([dependencies.signal, controller.signal]) : controller.signal;
|
|
107
|
+
const timeout = setTimeout(() => controller.abort(), CRT_SH_TIMEOUT_MS);
|
|
108
|
+
let retryDelay = 0;
|
|
87
109
|
try {
|
|
88
|
-
res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
110
|
+
const res = await fetcher(`https://crt.sh/?q=${encodeURIComponent(keyword)}&output=json`, {
|
|
89
111
|
headers: whoisleuthRequestHeaders({ Accept: 'application/json' }),
|
|
90
112
|
signal,
|
|
91
113
|
}, 0);
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
await res.body?.cancel().catch(() => { });
|
|
116
|
+
if (RETRYABLE_STATUSES.has(res.status) && attempt < MAX_RETRIES) {
|
|
117
|
+
retryDelay = RETRY_DELAY_MS * (attempt + 1);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
throw new CtCollectionError(RETRYABLE_STATUSES.has(res.status) || res.status === 429 ? 'CT_OVERLOADED' : 'CT_UPSTREAM_ERROR');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
let text;
|
|
125
|
+
try {
|
|
126
|
+
const body = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
|
|
127
|
+
signal.throwIfAborted();
|
|
128
|
+
if (body.truncated)
|
|
129
|
+
throw new CtCollectionError('CT_QUERY_TOO_BROAD');
|
|
130
|
+
text = body.text;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
// Preserve transport/deadline failures for the attempt retry below.
|
|
134
|
+
if (error instanceof TypeError && !signal.aborted) {
|
|
135
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
let data;
|
|
140
|
+
try {
|
|
141
|
+
data = JSON.parse(text);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
145
|
+
}
|
|
146
|
+
if (!Array.isArray(data))
|
|
147
|
+
throw new CtCollectionError('CT_INVALID_RESPONSE');
|
|
148
|
+
if (data.length > MAX_CT_ROWS)
|
|
149
|
+
throw new CtCollectionError('CT_QUERY_TOO_BROAD');
|
|
150
|
+
return data;
|
|
111
151
|
}
|
|
112
|
-
throw new Error(RETRYABLE_STATUSES.has(res.status)
|
|
113
|
-
? `crt.sh is temporarily overloaded (${res.status} after ${attempt + 1} attempts) - it's a free public service that intermittently struggles under load; try again in a moment.`
|
|
114
|
-
: `crt.sh returned ${res.status}`);
|
|
115
|
-
}
|
|
116
|
-
const { text, truncated } = await readTextCapped(res, CRT_SH_MAX_BYTES, { fatalUtf8: true });
|
|
117
|
-
signal.throwIfAborted();
|
|
118
|
-
if (truncated) {
|
|
119
|
-
throw new Error(`crt.sh returned more than ${CRT_SH_MAX_BYTES / (1024 * 1024)}MB of results for "${keyword}" - try a narrower/more specific keyword.`);
|
|
120
152
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
153
|
+
catch (error) {
|
|
154
|
+
dependencies.signal?.throwIfAborted();
|
|
155
|
+
if (!controller.signal.aborted && (!(error instanceof Error) || error.name !== 'AbortError'))
|
|
156
|
+
throw error;
|
|
157
|
+
if (attempt >= MAX_TIMEOUT_RETRIES)
|
|
158
|
+
throw new CtCollectionError('CT_TIMEOUT');
|
|
124
159
|
}
|
|
125
|
-
|
|
126
|
-
|
|
160
|
+
finally {
|
|
161
|
+
// End this attempt before delay/retry: body reads share the same deadline
|
|
162
|
+
// as headers, and earlier timers cannot fire during a later attempt.
|
|
163
|
+
clearTimeout(timeout);
|
|
127
164
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
finally {
|
|
131
|
-
clearTimeout(timeout);
|
|
165
|
+
if (retryDelay)
|
|
166
|
+
await wait(retryDelay, dependencies.signal);
|
|
132
167
|
}
|
|
168
|
+
throw new CtCollectionError('CT_UPSTREAM_ERROR');
|
|
133
169
|
}
|
|
134
170
|
// ---------------------------------------------------------------------------
|
|
135
171
|
// Certificate identity
|
|
@@ -485,7 +521,7 @@ async function searchCertificateTransparency(keyword, dependencies = {}) {
|
|
|
485
521
|
}),
|
|
486
522
|
};
|
|
487
523
|
}
|
|
488
|
-
const data = await fetchCrtSh(trimmed,
|
|
524
|
+
const data = await fetchCrtSh(trimmed, dependencies);
|
|
489
525
|
const summary = summarizeCtResults(data);
|
|
490
526
|
return {
|
|
491
527
|
certCount: data.length,
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isValidAsciiHostname } from "../packages/contracts/domain-name.mjs";
|
|
2
|
+
import { normalizeExplicitIsoTimestamp } from "../packages/evidence/observation.mjs";
|
|
3
|
+
import { parseDmarcRecords } from "./domain-posture-parsers.mjs";
|
|
4
|
+
export function requireDmarcDomain(domain) {
|
|
5
|
+
if (typeof domain !== 'string' || domain.length > 253 || !isValidAsciiHostname(domain) || domain !== domain.toLowerCase()) {
|
|
6
|
+
throw new TypeError('The additional DNS review requires a normalised domain.');
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function dmarcPolicyObservation(owner, observation) {
|
|
10
|
+
const records = observation.records;
|
|
11
|
+
let bytes = 0;
|
|
12
|
+
const admitted = Array.isArray(records) && records.length <= 64 && records.every(raw => {
|
|
13
|
+
const parts = typeof raw === 'string' ? [raw] : raw;
|
|
14
|
+
return Array.isArray(parts) && parts.length <= 256 && parts.every(part => {
|
|
15
|
+
if (typeof part !== 'string' || part.length > 4096)
|
|
16
|
+
return false;
|
|
17
|
+
bytes += Buffer.byteLength(part, 'utf8');
|
|
18
|
+
return bytes <= 65_536;
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
const parsed = parseDmarcRecords(admitted ? records : []);
|
|
22
|
+
const unavailable = Boolean(observation.error) || !admitted;
|
|
23
|
+
const psd = parsed.tags.psd?.toLowerCase() ?? 'u';
|
|
24
|
+
const valid = !unavailable && parsed.valid && ['u', 'n', 'y'].includes(psd);
|
|
25
|
+
const incomplete = unavailable || (!valid && parsed.records.length === 1);
|
|
26
|
+
const state = unavailable ? 'unavailable' : valid ? 'observed' : parsed.records.length ? 'invalid' : 'not found';
|
|
27
|
+
const description = valid
|
|
28
|
+
? `p=${parsed.policy}; sp=${parsed.subdomainPolicy}; np=${parsed.nonexistentSubdomainPolicy}; psd=${psd}; test=${parsed.testMode ? 'yes' : 'no'}`
|
|
29
|
+
: state;
|
|
30
|
+
const time = typeof observation.observedAt === 'string' && observation.observedAt.length <= 64
|
|
31
|
+
? normalizeExplicitIsoTimestamp(observation.observedAt) : null;
|
|
32
|
+
return { owner, parsed, psd, valid, unavailable, incomplete,
|
|
33
|
+
record: `Recursive DNS TXT _dmarc.${owner} · ${time ?? 'time unavailable'} · ${description}` };
|
|
34
|
+
}
|
|
35
|
+
/** RFC 9989 sections 4.10–4.10.2: at most eight owners, never PSL inference. */
|
|
36
|
+
export async function walkDmarcTree(domain, exact, resolve, purpose) {
|
|
37
|
+
requireDmarcDomain(domain);
|
|
38
|
+
const rows = [dmarcPolicyObservation(domain, exact)];
|
|
39
|
+
const first = rows[0];
|
|
40
|
+
const labels = domain.split('.');
|
|
41
|
+
let next = labels.length >= 8 ? labels.slice(-7) : labels.slice(1);
|
|
42
|
+
const policyOnly = purpose === 'policy' && first.valid;
|
|
43
|
+
if (!policyOnly && !first.unavailable && !(first.valid && ['n', 'y'].includes(first.psd))) {
|
|
44
|
+
while (next.length) {
|
|
45
|
+
const owner = next.join('.');
|
|
46
|
+
const row = dmarcPolicyObservation(owner, await resolve(`_dmarc.${owner}`));
|
|
47
|
+
rows.push(row);
|
|
48
|
+
if (row.unavailable || (row.valid && ['n', 'y'].includes(row.psd)))
|
|
49
|
+
break;
|
|
50
|
+
next = next.slice(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const available = rows.filter(row => row.valid);
|
|
54
|
+
const boundary = available.find(row => row.psd === 'n' || (row.psd === 'y' && row.owner !== domain));
|
|
55
|
+
const incomplete = rows.some(row => row.incomplete);
|
|
56
|
+
// An exact policy alone does not establish the organisational boundary.
|
|
57
|
+
const organisationalDomain = incomplete || (policyOnly && !['n', 'y'].includes(first.psd)) ? null
|
|
58
|
+
: boundary?.psd === 'y'
|
|
59
|
+
? labels.slice(-(boundary.owner.split('.').length + 1)).join('.')
|
|
60
|
+
: boundary?.owner ?? available.at(-1)?.owner ?? domain;
|
|
61
|
+
return { rows, available, boundary, incomplete, organisationalDomain };
|
|
62
|
+
}
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { exactKeys, requireBoundedString, requireDomainName, requireIsoTimestamp, requireRecord, } from "./bounded-contract-normalizers.mjs";
|
|
2
2
|
import { normaliseRdata } from "./zone-intent-review.mjs";
|
|
3
|
+
import { dnsQueryState, normaliseDnsQueryCoverage } from "./dns-query-coverage.mjs";
|
|
3
4
|
export const DNS_CONVERGENCE_INPUT_SCHEMA = 'whoisleuth.dns-convergence.input';
|
|
4
5
|
export const DNS_CONVERGENCE_REVIEW_SCHEMA = 'whoisleuth.dns-convergence.review';
|
|
5
|
-
export const
|
|
6
|
+
export const DNS_CONVERGENCE_INPUT_VERSION = 2;
|
|
7
|
+
export const SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS = Object.freeze([1, DNS_CONVERGENCE_INPUT_VERSION]);
|
|
8
|
+
export const DNS_CONVERGENCE_REVIEW_VERSION = 2;
|
|
6
9
|
export const MAX_DNS_CONVERGENCE_SNAPSHOTS = 100;
|
|
7
10
|
export const MAX_DNS_CONVERGENCE_RECORDS = 2_000;
|
|
8
11
|
const RECORD_TYPES = new Set(['A', 'AAAA', 'CAA', 'CNAME', 'MX', 'NS', 'SOA', 'SRV', 'SVCB', 'HTTPS', 'TLSA', 'TXT']);
|
|
9
12
|
const ROOT_KEYS = new Set(['schema', 'version', 'domain', 'expected', 'snapshots']);
|
|
10
13
|
const EXPECTED_KEYS = new Set(['owner', 'type', 'values']);
|
|
11
14
|
const SNAPSHOT_KEYS = new Set(['observer', 'source', 'observedAt', 'state', 'records']);
|
|
15
|
+
const COVERED_SNAPSHOT_KEYS = new Set([...SNAPSHOT_KEYS, 'queries']);
|
|
12
16
|
const RECORD_KEYS = new Set(['owner', 'type', 'value', 'ttl']);
|
|
13
17
|
function type(value, label) {
|
|
14
18
|
const candidate = requireBoundedString(value, label, 16).toUpperCase();
|
|
@@ -83,8 +87,8 @@ function expectedRows(value, apex) {
|
|
|
83
87
|
}
|
|
84
88
|
export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toISOString()) {
|
|
85
89
|
const input = requireRecord(inputRaw, 'DNS convergence input');
|
|
86
|
-
if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || input.version
|
|
87
|
-
throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1.`);
|
|
90
|
+
if (input.schema !== DNS_CONVERGENCE_INPUT_SCHEMA || !SUPPORTED_DNS_CONVERGENCE_INPUT_VERSIONS.includes(input.version)) {
|
|
91
|
+
throw new TypeError(`DNS convergence input must use ${DNS_CONVERGENCE_INPUT_SCHEMA} version 1 or ${DNS_CONVERGENCE_INPUT_VERSION}.`);
|
|
88
92
|
}
|
|
89
93
|
exactKeys(input, ROOT_KEYS, 'DNS convergence input');
|
|
90
94
|
const apex = requireDomainName(input.domain, 'domain');
|
|
@@ -93,9 +97,10 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
93
97
|
throw new TypeError(`snapshots must contain between 2 and ${MAX_DNS_CONVERGENCE_SNAPSHOTS} entries.`);
|
|
94
98
|
}
|
|
95
99
|
let recordCount = 0;
|
|
100
|
+
let queryCount = 0;
|
|
96
101
|
const snapshots = input.snapshots.map((raw, index) => {
|
|
97
102
|
const item = requireRecord(raw, `snapshots[${index}]`);
|
|
98
|
-
exactKeys(item, SNAPSHOT_KEYS, `snapshots[${index}]`);
|
|
103
|
+
exactKeys(item, input.version === 1 ? SNAPSHOT_KEYS : COVERED_SNAPSHOT_KEYS, `snapshots[${index}]`);
|
|
99
104
|
if (!Array.isArray(item.records))
|
|
100
105
|
throw new TypeError(`snapshots[${index}].records must be an array.`);
|
|
101
106
|
recordCount += item.records.length;
|
|
@@ -104,12 +109,20 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
104
109
|
const observationState = state(item.state, `snapshots[${index}].state`);
|
|
105
110
|
if (observationState === 'unavailable' && item.records.length)
|
|
106
111
|
throw new TypeError(`snapshots[${index}] cannot contain records when unavailable.`);
|
|
112
|
+
const records = Object.freeze(item.records.map((entry, recordIndex) => record(entry, apex, `snapshots[${index}].records[${recordIndex}]`)));
|
|
113
|
+
const queries = normaliseDnsQueryCoverage(item.queries, {
|
|
114
|
+
legacy: input.version === 1, label: `snapshots[${index}].queries`,
|
|
115
|
+
maximum: MAX_DNS_CONVERGENCE_RECORDS - queryCount, state: observationState, records,
|
|
116
|
+
owner: (value, label) => owner(value, apex, label), type,
|
|
117
|
+
});
|
|
118
|
+
queryCount += queries.length;
|
|
107
119
|
return Object.freeze({
|
|
108
120
|
observer: requireBoundedString(item.observer, `snapshots[${index}].observer`, 120),
|
|
109
121
|
source: requireBoundedString(item.source, `snapshots[${index}].source`, 240),
|
|
110
122
|
observedAt: requireIsoTimestamp(item.observedAt, `snapshots[${index}].observedAt`),
|
|
111
123
|
state: observationState,
|
|
112
|
-
records
|
|
124
|
+
records,
|
|
125
|
+
queries,
|
|
113
126
|
});
|
|
114
127
|
}).sort((left, right) => left.observedAt.localeCompare(right.observedAt) || left.observer.localeCompare(right.observer));
|
|
115
128
|
const observers = [...new Set(snapshots.map((item) => item.observer))];
|
|
@@ -118,7 +131,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
118
131
|
const latestByObserver = observers.map((observer) => snapshots.filter((item) => item.observer === observer).at(-1));
|
|
119
132
|
const keys = [...new Set([
|
|
120
133
|
...expected.map((item) => `${item.owner}\u0000${item.type}`),
|
|
121
|
-
...snapshots.flatMap((item) => item.
|
|
134
|
+
...snapshots.flatMap((item) => item.queries.map((entry) => `${entry.owner}\u0000${entry.type}`)),
|
|
122
135
|
])].sort();
|
|
123
136
|
const rows = keys.map((key) => {
|
|
124
137
|
const [recordOwner = '', recordType = ''] = key.split('\u0000');
|
|
@@ -134,7 +147,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
134
147
|
observer: snapshot.observer,
|
|
135
148
|
source: snapshot.source,
|
|
136
149
|
observedAt: snapshot.observedAt,
|
|
137
|
-
state: snapshot.
|
|
150
|
+
state: dnsQueryState(snapshot.queries, recordOwner, recordType),
|
|
138
151
|
values: Object.freeze(values),
|
|
139
152
|
cacheUntil,
|
|
140
153
|
});
|
|
@@ -178,7 +191,7 @@ export function reviewDnsConvergence(inputRaw, generatedAtValue = new Date().toI
|
|
|
178
191
|
'This local workbench compares only supplied observations and makes no DNS request.',
|
|
179
192
|
'Resolver differences can reflect caches, split-horizon policy, interception, collection timing, or propagation; they do not establish which value is authoritative.',
|
|
180
193
|
'Cache-until times are simple observation-time plus supplied TTL projections and do not reveal actual cache age, refresh behaviour, or resolver policy.',
|
|
181
|
-
'
|
|
194
|
+
'Only an explicitly observed query can establish an empty answer. Unqueried, partial and unavailable record types are never interpreted as absence; legacy inputs establish scope only for their supplied records.',
|
|
182
195
|
]),
|
|
183
196
|
});
|
|
184
197
|
}
|
|
@@ -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;
|
|
@@ -465,8 +465,10 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
465
465
|
.filter((primary) => primary !== null))];
|
|
466
466
|
const soaSerialConflict = soaSerials.length > 1;
|
|
467
467
|
const soaPrimaryConflict = soaPrimaries.length > 1;
|
|
468
|
-
const
|
|
469
|
-
|
|
468
|
+
const glueInventoryComplete = (parentStatus === 'success' && parentNameservers.length > 0 && parentQuery.truncated !== true)
|
|
469
|
+
|| (registry.nameservers.length > 0 && !registry.truncated);
|
|
470
|
+
const inBailiwick = eligibleAuthorities.filter((nameserver) => (nameserver === domain || nameserver.endsWith(`.${domain}`)));
|
|
471
|
+
const unobservedGlue = inBailiwick.filter((nameserver) => (!(registry.nameserverDetails.find((item) => item.name === nameserver)?.addresses.length)));
|
|
470
472
|
const dnssecMismatch = (registry.delegationSigned === true && registry.dsRecordCount === 0) || (registry.delegationSigned === false && registry.dsRecordCount > 0);
|
|
471
473
|
const recordMatrix = authorityRecordMatrix(authorities);
|
|
472
474
|
const recordCollectionAttempted = authorities.some((authority) => authority.recordSets.length > 0);
|
|
@@ -507,11 +509,13 @@ async function collectDnsDelegationHealth(domainValue, parentQueryValue, options
|
|
|
507
509
|
: soaPrimaryConflict
|
|
508
510
|
? 'Authoritative servers published different SOA primary nameservers'
|
|
509
511
|
: '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
|
-
:
|
|
512
|
+
finding('in_bailiwick_glue', 'In-bailiwick glue', !glueInventoryComplete || unobservedGlue.length ? 'unknown' : 'healthy', !glueInventoryComplete
|
|
513
|
+
? 'The delegated nameserver inventory is unavailable or incomplete'
|
|
514
|
+
: !inBailiwick.length
|
|
515
|
+
? 'No in-bailiwick nameserver requires registry glue'
|
|
516
|
+
: unobservedGlue.length
|
|
517
|
+
? 'Registry address publication does not establish glue for every in-bailiwick nameserver'
|
|
518
|
+
: '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
519
|
finding('dnssec_delegation', 'DNSSEC delegation publication', dnssecMismatch ? 'warning'
|
|
516
520
|
: registry.delegationSigned === null && registry.dsRecordCount === 0 ? 'unknown' : 'healthy', dnssecMismatch
|
|
517
521
|
? '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) {
|
|
@@ -683,6 +684,7 @@ async function collectDnsIntelligence(domain, options = {}) {
|
|
|
683
684
|
diagnostics: {
|
|
684
685
|
...Object.fromEntries(Object.entries(queries).map(([name, item]) => [name, {
|
|
685
686
|
status: item.status,
|
|
687
|
+
detail: item.detail,
|
|
686
688
|
error: item.error,
|
|
687
689
|
truncated: item.truncated,
|
|
688
690
|
discarded: item.discarded,
|