Skip to content

Commit 52778df

Browse files
schmugclaude
andcommitted
security: bound scan fan-out with a shared DNS budget + overall deadline
Orchestrator-level DoS umbrella for GHSA-f828-8wf8-vqp2. scan()/scanStreaming() previously awaited Promise.all across all analyzers with only a per-query 3s DNS timeout — no aggregate cap on total DNS queries or wall-clock. A single request combining a large DKIM selector list with a rua/ruf-stuffed _dmarc record on an attacker-controlled domain could drive a large outbound-DNS burst and long wall-clock on one rate-limit token. Adds two backstops, both threaded through every analyzer: - ScanBudget (src/dns/scan-budget.ts): one shared per-scan DNS-query pool that every queryTxt/queryMx/queryDoh draws from; exhaustion throws a DnsLookupError subclass so analyzers degrade to "could not verify" instead of crashing. - Overall deadline: one AbortController + setTimeout; each settled analyzer is raced against it, degrading to its synthetic fallback on a breach. The budget also holds the signal, so no new query is issued past the deadline. Defaults (DEFAULT_SCAN_LIMITS): 150 queries / 12s — generous for real multi-analyzer scans, overridable via an optional `limits` param (tests). Preserves the #378 per-analyzer settle contract and scanStreaming SSE semantics (every protocol still streams exactly once). DnsLookupError moved to src/dns/errors.ts so the budget can subclass it without depending on the DNS client (which tests mock). Refs GHSA-f828-8wf8-vqp2. Umbrella over the per-analyzer caps in #539. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: schmug <38227427+schmug@users.noreply.github.com>
1 parent 1af0711 commit 52778df

20 files changed

Lines changed: 818 additions & 292 deletions

CLAUDE.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Live at dmarc.mx | Repo: github.com/schmug/dmarcheck
2727
- `src/index.ts` — Hono routes, content negotiation, rate limiting middleware
2828
- `src/dns/client.ts` — DNS abstraction over node:dns (NXDOMAIN returns null)
2929
- `src/analyzers/` — One module per protocol (dmarc, spf, dkim, bimi, mta-sts, mx, security-txt)
30-
- `src/orchestrator.ts` — Runs all analyzers in parallel and isolates each one: a single analyzer rejection surfaces as a synthetic `status: "fail"` result (with an `analyzer_error` validation; `lookup_error.code` on the types that carry it) instead of aborting the whole scan. Implemented via a per-analyzer `settle` wrapper so one failure never takes down its siblings, for both `scan` and `scanStreaming` (#378).
30+
- `src/orchestrator.ts` — Runs all analyzers in parallel and isolates each one: a single analyzer rejection surfaces as a synthetic `status: "fail"` result (with an `analyzer_error` validation; `lookup_error.code` on the types that carry it) instead of aborting the whole scan. Implemented via a per-analyzer `settle` wrapper so one failure never takes down its siblings, for both `scan` and `scanStreaming` (#378). Both entrypoints also enforce two DoS backstops (GHSA-f828-8wf8-vqp2): a single overall deadline (one `AbortController` + `setTimeout`; each settled analyzer is raced against it via `raceDeadline`, degrading to its synthetic fallback on a breach) and one shared per-scan DNS-query budget (`ScanBudget`, threaded into every analyzer's DNS calls), so neither total outbound queries nor wall-clock can scale with attacker input. Limits come from `src/dns/scan-budget.ts` (`DEFAULT_SCAN_LIMITS`); both are overridable via the optional `limits` parameter (used by tests). A breach degrades gracefully — partial results with a note, never a throw — and `scanStreaming` still streams every protocol exactly once.
31+
- `src/dns/scan-budget.ts``ScanBudget` (shared per-scan DNS-query pool + deadline-`AbortSignal`) and `DEFAULT_SCAN_LIMITS` (`maxDnsQueries`, `deadlineMs`). `queryTxt`/`queryMx`/`queryDoh` call `budget?.consume()` before any outbound query; exhaustion/deadline throw `ScanBudgetError`/`ScanDeadlineError` (both subclass `DnsLookupError`, so analyzers surface them as "could not verify" rather than a false "not configured"). `DnsLookupError` itself lives in `src/dns/errors.ts` (re-exported from `client.ts`) so the budget can subclass it without depending on the DNS client, which tests frequently mock.
3132
- `src/shared/scoring.ts` — Grade computation (F if no DMARC or p=none). Knobs are configurable per-deploy via the `SCORING_CONFIG` env var (`src/shared/scoring-config.ts` parses/validates it; absent/invalid → shipped defaults, so hosted dmarc.mx is unaffected). `computeGrade`/`computeGradeBreakdown` take an optional `Partial<ScoringConfig>`; `scan`/`scanStreaming` require it (compile-time enforcement that every call site threads the active config)
3233
- `src/shared/learn-anchors.ts` — Single source of truth for validation→learn-page "How to fix" deep links (#524): analyzers set the optional `Validation.learnAnchor`, `src/views/learn.ts` renders the matching `id=` attributes from the same constants, and `test/learn-anchors.test.ts` asserts both sides so anchor ids can't drift
3334
- `src/cache.ts` — SSE result caching
@@ -85,6 +86,7 @@ Live at dmarc.mx | Repo: github.com/schmug/dmarcheck
8586
- **Branch protection:** `main` is governed by the `main-protection` repository ruleset: requires a PR, requires the `check` status check, and blocks deletions and non-fast-forward pushes. CodeQL (`Analyze (actions)`, `Analyze (javascript-typescript)`) still runs on every PR but must **NOT** be re-added as a required status check — requiring those contexts deadlocked all merges (the "ruleset merge trap"); they were deliberately removed. `required_approving_review_count` is **0** by design — autonomous Claude Code routines (see the `claude-routines` repo) open and auto-merge PRs unattended. The human-review gate is **path-scoped**, not blanket: `require_code_owner_review` is on, so any PR touching a path in `.github/CODEOWNERS` (CI, lockfiles, security invariants, input validation, redirect posture, rate limiting, DB migrations, analyzer modules, orchestration, scoring) requires a code-owner approval before merge. This hybrid keeps routine PRs autonomous while forcing a human on the security-sensitive minority. **CODEOWNERS scope decision (issue #300):** `src/analyzers/**`, `src/orchestrator.ts`, and `src/shared/scoring.ts` are intentionally gated — a malicious-issue-driven PR adding a new analyzer or modifying orchestration/scoring could exfiltrate DNS data or manipulate grades without this gate. Enforcement is only live once the bot-identity split (#299) lands (routines currently run as admin, which bypasses CODEOWNERS). The repo Admin role bypasses the ruleset (`bypass_actors`, mode `always`) — **the autonomous bot must run as a distinct non-admin identity, never the admin account**, or the ruleset is advisory for it and CODEOWNERS cannot be satisfied (no self-approval).
8687
- **Secret scanning:** Secret scanning, push protection, non-provider patterns, and validity checks are all enabled in repo settings. Never commit `.env`, tokens, or wrangler secrets.
8788
- **Input validation:** User-supplied domains are restricted to `[a-z0-9.-]` in `normalizeDomain` (`src/index.ts`). DKIM selectors are restricted to `[A-Za-z0-9._-]` in `parseSelectors`. HTML output never interpolates raw user input into inline `<script>` blocks — use `data-*` attributes via `esc()` instead.
89+
- **Scan fan-out cap (DoS, GHSA-f828-8wf8-vqp2):** the orchestrator bounds every scan with one overall deadline AND one shared per-scan DNS-query budget (`ScanBudget` from `src/dns/scan-budget.ts`, threaded through every analyzer into `queryTxt`/`queryMx`/`queryDoh`). This is the umbrella over the per-analyzer caps (DKIM selectors, MTA-STS body, DMARC rua/ruf): combining a large selector list with a rua/ruf-stuffed `_dmarc` record on an attacker-controlled domain cannot drive total outbound DNS or wall-clock past the limits, all on one rate-limit token. Do NOT remove the budget threading or the deadline race when editing the orchestrator or analyzers; keep DNS query calls drawing from the shared pool. A breach degrades gracefully (partial results + a note), never throws.
8890
- **MTA-STS fetch redirect mode:** `src/analyzers/mta-sts.ts` uses `redirect: "manual"` for the policy fetch. Do NOT change it to `"error"` — that throws in the Cloudflare Workers fetch runtime and breaks every scan (regressed twice via PRs #58 and #92). `"manual"` is RFC 8461 §3.3-compliant: redirects yield an opaque-redirect `Response` rejected by the existing `resp.type === "opaqueredirect"` / `!resp.ok` guards.
8991
- **security.txt fetch redirect mode:** `src/analyzers/security-txt.ts` deliberately uses `redirect: "follow"` (not `"manual"`) — RFC 9116 §3 does not forbid following redirects, and real-world deployments commonly redirect (e.g. gov.uk → www.gov.uk → vdp.cabinetoffice.gov.uk). MTA-STS's `manual` posture is a security requirement of RFC 8461 §3.3 specifically; security.txt has no equivalent rule, so the user-friendly choice is to follow.
9092
- **DCO sign-off & squash-merge default:** `.github/workflows/dco.yml` enforces a `Signed-off-by:` trailer on every non-bot PR commit (OSPS LE-01.01). The commit that lands on `main` keeps those trailers because the repo's squash default is `squash_merge_commit_message = COMMIT_MESSAGES` (GitHub pre-fills the squash body with the concatenated commit messages). **Do not change that setting to `PR_BODY`** — it would move the sign-off requirement to the PR description and reopen the gap. Bots are identified by `[bot]` in their author name, the `github-actions` author name, or the `cursoragent@cursor.com` author email (Cursor's cloud agent authors as "Cursor Agent" with no `[bot]` suffix); all other commits — including those using GitHub's privacy noreply email (`12345678+alice@users.noreply.github.com`) — are subject to DCO enforcement (#434).

src/analyzers/bimi.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DnsLookupError, queryTxt } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import type { TxtRecord } from "../dns/types.js";
34
import { LEARN_ANCHORS, learnAnchorHref } from "../shared/learn-anchors.js";
45
import { parseTags } from "../shared/parse-tags.js";
@@ -17,8 +18,11 @@ const LOGO_MAX_BYTES = 1 * 1024 * 1024; // 1 MB
1718
const CERT_TIMEOUT_MS = 30_000;
1819
const CERT_MAX_BYTES = 100 * 1024; // 100 KB
1920

20-
export function prefetchBimiDns(domain: string): Promise<TxtRecord | null> {
21-
return queryTxt(`default._bimi.${domain}`).catch((err) => {
21+
export function prefetchBimiDns(
22+
domain: string,
23+
budget?: ScanBudget,
24+
): Promise<TxtRecord | null> {
25+
return queryTxt(`default._bimi.${domain}`, budget).catch((err) => {
2226
if (err instanceof DnsLookupError) return null;
2327
throw err;
2428
});
@@ -183,11 +187,12 @@ export async function analyzeBimi(
183187
domain: string,
184188
dmarcPolicy: string | null,
185189
prefetchedDns?: TxtRecord | null,
190+
budget?: ScanBudget,
186191
): Promise<BimiResult> {
187192
const txt =
188193
prefetchedDns !== undefined
189194
? prefetchedDns
190-
: await queryTxt(`default._bimi.${domain}`);
195+
: await queryTxt(`default._bimi.${domain}`, budget);
191196

192197
if (!txt) {
193198
const validations: Validation[] = [

src/analyzers/dane.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DnsLookupError, queryDoh } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import type {
34
DaneHostResult,
45
DaneResult,
@@ -54,6 +55,7 @@ function parseTlsaRecord(data: string): DaneTlsaRecord | null {
5455
export async function analyzeDane(
5556
_domain: string,
5657
mxExchanges: string[],
58+
budget?: ScanBudget,
5759
): Promise<DaneResult> {
5860
const validations: Validation[] = [];
5961

@@ -74,7 +76,7 @@ export async function analyzeDane(
7476
await Promise.all(
7577
mxExchanges.map(async (exchange) => {
7678
try {
77-
const response = await queryDoh(`_25._tcp.${exchange}`, "TLSA");
79+
const response = await queryDoh(`_25._tcp.${exchange}`, "TLSA", budget);
7880
successfulQueries++;
7981
if (!response) {
8082
hosts.push({ exchange, tlsaRecords: [], dnssecValidated: false });

src/analyzers/dkim.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { queryTxt } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import { LEARN_ANCHORS, learnAnchorHref } from "../shared/learn-anchors.js";
34
import { parseTags } from "../shared/parse-tags.js";
45
import type { DkimResult, DkimSelectorResult, Validation } from "./types.js";
@@ -55,6 +56,7 @@ export async function analyzeDkim(
5556
domain: string,
5657
customSelectors: string[] = [],
5758
providerNames: string[] = [],
59+
budget?: ScanBudget,
5860
): Promise<DkimResult> {
5961
const unique = [...new Set([...COMMON_SELECTORS, ...customSelectors])];
6062
const prioritized = providerNames.flatMap(
@@ -67,7 +69,7 @@ export async function analyzeDkim(
6769
];
6870

6971
const results = await Promise.allSettled(
70-
allSelectors.map((sel) => probeSelector(domain, sel)),
72+
allSelectors.map((sel) => probeSelector(domain, sel, budget)),
7173
);
7274

7375
const selectors: Record<string, DkimSelectorResult> = {};
@@ -162,8 +164,9 @@ export async function analyzeDkim(
162164
async function probeSelector(
163165
domain: string,
164166
selector: string,
167+
budget?: ScanBudget,
165168
): Promise<DkimSelectorResult> {
166-
const txt = await queryTxt(`${selector}._domainkey.${domain}`);
169+
const txt = await queryTxt(`${selector}._domainkey.${domain}`, budget);
167170
if (!txt) return { found: false };
168171

169172
const dkimRecord = txt.entries.find(

src/analyzers/dmarc.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DnsLookupError, queryTxt } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import { LEARN_ANCHORS, learnAnchorHref } from "../shared/learn-anchors.js";
34
import { parseTags } from "../shared/parse-tags.js";
45
import type { DmarcResult, Validation } from "./types.js";
@@ -58,30 +59,36 @@ async function checkReportingAuthorization(
5859
tagValue: string,
5960
tagName: "rua" | "ruf",
6061
validations: Validation[],
61-
budget: ReportAuthBudget,
62+
// Two distinct budgets: `authBudget` is the per-analyzer rua/ruf cap
63+
// (GHSA-vcw3-wvwx-6fg5), shared across the rua+ruf passes; `scanBudget` is the
64+
// orchestrator-wide shared DNS-query pool (GHSA-f828-8wf8-vqp2) passed down to
65+
// queryTxt. The auth cap bounds this analyzer's fan-out; the scan budget bounds
66+
// the whole scan's combined fan-out across all analyzers.
67+
authBudget: ReportAuthBudget,
68+
scanBudget?: ScanBudget,
6269
): Promise<void> {
6370
const uris = parseReportUris(tagValue);
6471
for (const uri of uris) {
6572
const reportingDomain = extractMailtoDomain(uri);
6673
if (!reportingDomain) continue;
6774
// Same domain — no external authorization needed
6875
if (reportingDomain === localDomain.toLowerCase()) continue;
69-
// External lookup required — enforce the shared cap before querying.
70-
if (budget.remaining <= 0) {
71-
if (!budget.capReported) {
76+
// External lookup required — enforce the per-analyzer cap before querying.
77+
if (authBudget.remaining <= 0) {
78+
if (!authBudget.capReported) {
7279
validations.push({
7380
status: "warn",
7481
message: `More than ${MAX_REPORT_AUTH_LOOKUPS} external report destinations configured (rua/ruf) — additional destinations were not verified for report authorization`,
7582
});
76-
budget.capReported = true;
83+
authBudget.capReported = true;
7784
}
7885
break;
7986
}
80-
budget.remaining--;
87+
authBudget.remaining--;
8188
const authName = `${localDomain}._report._dmarc.${reportingDomain}`;
8289
let authRecord: Awaited<ReturnType<typeof queryTxt>>;
8390
try {
84-
authRecord = await queryTxt(authName);
91+
authRecord = await queryTxt(authName, scanBudget);
8592
} catch (err) {
8693
if (err instanceof DnsLookupError) {
8794
validations.push({
@@ -104,10 +111,13 @@ async function checkReportingAuthorization(
104111
}
105112
}
106113

107-
export async function analyzeDmarc(domain: string): Promise<DmarcResult> {
114+
export async function analyzeDmarc(
115+
domain: string,
116+
budget?: ScanBudget,
117+
): Promise<DmarcResult> {
108118
let txt: Awaited<ReturnType<typeof queryTxt>>;
109119
try {
110-
txt = await queryTxt(`_dmarc.${domain}`);
120+
txt = await queryTxt(`_dmarc.${domain}`, budget);
111121
} catch (err) {
112122
if (err instanceof DnsLookupError) {
113123
return {
@@ -231,6 +241,7 @@ export async function analyzeDmarc(domain: string): Promise<DmarcResult> {
231241
"rua",
232242
validations,
233243
reportAuthBudget,
244+
budget,
234245
);
235246
} else {
236247
validations.push({
@@ -251,6 +262,7 @@ export async function analyzeDmarc(domain: string): Promise<DmarcResult> {
251262
"ruf",
252263
validations,
253264
reportAuthBudget,
265+
budget,
254266
);
255267
}
256268

src/analyzers/dnssec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { DnsLookupError, queryDoh } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import type { DnssecResult, Validation } from "./types.js";
34

4-
export async function analyzeDnssec(domain: string): Promise<DnssecResult> {
5+
export async function analyzeDnssec(
6+
domain: string,
7+
budget?: ScanBudget,
8+
): Promise<DnssecResult> {
59
const validations: Validation[] = [];
610

711
let response: Awaited<ReturnType<typeof queryDoh>>;
@@ -10,7 +14,7 @@ export async function analyzeDnssec(domain: string): Promise<DnssecResult> {
1014
// signal that the parent has signed the delegation — the presence of DS
1115
// records (plus the AD flag from a validating resolver) is the standard
1216
// way to determine DNSSEC status without doing a full chain-of-trust walk.
13-
response = await queryDoh(domain, "DS");
17+
response = await queryDoh(domain, "DS", budget);
1418
} catch (err) {
1519
if (err instanceof DnsLookupError) {
1620
validations.push({

src/analyzers/mta-sts.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { queryTxt } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import type { MtaStsPolicy, MtaStsResult, Validation } from "./types.js";
34

4-
export async function analyzeMtaSts(domain: string): Promise<MtaStsResult> {
5+
export async function analyzeMtaSts(
6+
domain: string,
7+
budget?: ScanBudget,
8+
): Promise<MtaStsResult> {
59
const [dnsResult, policyResult] = await Promise.allSettled([
6-
queryTxt(`_mta-sts.${domain}`),
10+
queryTxt(`_mta-sts.${domain}`, budget),
711
fetchPolicy(domain),
812
]);
913

src/analyzers/mx.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DnsLookupError, queryMx } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import type { EmailProvider, MxRecord, MxResult, Validation } from "./types.js";
34

45
interface ProviderSignature {
@@ -112,10 +113,13 @@ export function detectProviders(
112113
return providers;
113114
}
114115

115-
export async function analyzeMx(domain: string): Promise<MxResult> {
116+
export async function analyzeMx(
117+
domain: string,
118+
budget?: ScanBudget,
119+
): Promise<MxResult> {
116120
let rawRecords: Awaited<ReturnType<typeof queryMx>>;
117121
try {
118-
rawRecords = await queryMx(domain);
122+
rawRecords = await queryMx(domain, budget);
119123
} catch (err) {
120124
if (err instanceof DnsLookupError) {
121125
return {

src/analyzers/spf.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import { DnsLookupError, queryTxt } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import { LEARN_ANCHORS, learnAnchorHref } from "../shared/learn-anchors.js";
34
import type { SpfIncludeNode, SpfResult, Validation } from "./types.js";
45

56
const MAX_LOOKUPS = 10;
67

7-
export async function analyzeSpf(domain: string): Promise<SpfResult> {
8+
export async function analyzeSpf(
9+
domain: string,
10+
budget?: ScanBudget,
11+
): Promise<SpfResult> {
812
const ctx: ResolutionContext = {
913
lookups: 0,
1014
visited: new Set(),
@@ -15,7 +19,7 @@ export async function analyzeSpf(domain: string): Promise<SpfResult> {
1519

1620
let tree: SpfIncludeNode | null;
1721
try {
18-
tree = await resolveSpfTree(domain, ctx, 0);
22+
tree = await resolveSpfTree(domain, ctx, 0, budget);
1923
} catch (err) {
2024
if (err instanceof DnsLookupError) {
2125
return {
@@ -176,6 +180,7 @@ async function resolveSpfTree(
176180
domain: string,
177181
ctx: ResolutionContext,
178182
depth: number,
183+
budget?: ScanBudget,
179184
): Promise<SpfIncludeNode | null> {
180185
if (depth > 10) return null; // Prevent infinite recursion
181186
if (ctx.lookups > MAX_LOOKUPS) return null; // Prevent excessive DNS queries
@@ -187,7 +192,7 @@ async function resolveSpfTree(
187192
}
188193
ctx.visited.add(normalizedDomain);
189194

190-
const txt = await queryTxt(domain);
195+
const txt = await queryTxt(domain, budget);
191196
if (!txt) {
192197
// NXDOMAIN/NODATA. For include:/redirect= targets (depth > 0) this is a
193198
// void lookup under RFC 7208 §4.6.4; the root domain returning null just
@@ -243,7 +248,7 @@ async function resolveSpfTree(
243248
? []
244249
: await Promise.allSettled(
245250
includeTargets.map((target) =>
246-
resolveSpfTree(target, ctx, depth + 1),
251+
resolveSpfTree(target, ctx, depth + 1, budget),
247252
),
248253
);
249254

@@ -255,7 +260,7 @@ async function resolveSpfTree(
255260

256261
// Handle redirect (processed after all mechanisms)
257262
if (redirect && ctx.lookups <= MAX_LOOKUPS) {
258-
const redirectNode = await resolveSpfTree(redirect, ctx, depth + 1);
263+
const redirectNode = await resolveSpfTree(redirect, ctx, depth + 1, budget);
259264
if (redirectNode) {
260265
includes.push(redirectNode);
261266
}

src/analyzers/tls-rpt.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { DnsLookupError, queryTxt } from "../dns/client.js";
2+
import type { ScanBudget } from "../dns/scan-budget.js";
23
import { parseTags } from "../shared/parse-tags.js";
34
import type { TlsRptResult, Validation } from "./types.js";
45

@@ -7,10 +8,13 @@ import type { TlsRptResult, Validation } from "./types.js";
78
// MTAs where to send TLS failure reports, but its presence or absence does
89
// not change the enforcement posture.
910

10-
export async function analyzeTlsRpt(domain: string): Promise<TlsRptResult> {
11+
export async function analyzeTlsRpt(
12+
domain: string,
13+
budget?: ScanBudget,
14+
): Promise<TlsRptResult> {
1115
let txt: Awaited<ReturnType<typeof queryTxt>>;
1216
try {
13-
txt = await queryTxt(`_smtp._tls.${domain}`);
17+
txt = await queryTxt(`_smtp._tls.${domain}`, budget);
1418
} catch (err) {
1519
if (err instanceof DnsLookupError) {
1620
return {

0 commit comments

Comments
 (0)