Initial curated CertScope source from fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e
This commit is contained in:
commit
7348faf6c0
65 files changed
+12509
No files matched your search
@@ -0,0 +1,186 @@
|
||||
import { Resolver } from "node:dns/promises";
|
||||
import { createDeadline, remainingMs, withDeadline } from "./deadline";
|
||||
|
||||
export const DNS_TIMEOUT_MS = 4_000;
|
||||
|
||||
export type ProbeError = { probe: string; message: string };
|
||||
export type DnsFamilyResult = { addresses: string[]; status: "ok" | "absent" | "failed"; error: ProbeError | null };
|
||||
export type ResolverLike = Pick<Resolver, "resolve4" | "resolve6" | "resolveMx" | "resolveNs" | "resolveTxt" | "cancel">;
|
||||
export type ResolverFactory = () => ResolverLike;
|
||||
type Captured<T> = { value: T | null; error: ProbeError | null };
|
||||
|
||||
export type DmarcPosture = {
|
||||
status: "missing" | "invalid" | "monitor" | "quarantine" | "reject";
|
||||
policy: "none" | "quarantine" | "reject" | null;
|
||||
pct: number | null;
|
||||
record: string | null;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type SpfPosture = {
|
||||
status: "missing" | "invalid" | "present" | "hardfail" | "softfail" | "neutral" | "permissive" | "incomplete";
|
||||
allQualifier: "-" | "~" | "?" | "+" | null;
|
||||
record: string | null;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function isAbsentDnsError(error: unknown): boolean {
|
||||
const code = typeof error === "object" && error && "code" in error
|
||||
? String((error as { code?: unknown }).code)
|
||||
: "";
|
||||
return code === "ENODATA" || code === "ENOTFOUND" || code === "EAI_NONAME";
|
||||
}
|
||||
|
||||
function errorCode(error: unknown) {
|
||||
return typeof error === "object" && error !== null && "code" in error
|
||||
? String((error as { code?: unknown }).code ?? "")
|
||||
: "";
|
||||
}
|
||||
|
||||
function failureMessage(probe: string, error: unknown) {
|
||||
return errorCode(error) === "ETIMEOUT"
|
||||
? probe + " DNS query timed out."
|
||||
: probe + " DNS probe failed.";
|
||||
}
|
||||
|
||||
async function resolveWithBudget<T>(
|
||||
probe: string,
|
||||
operation: (resolver: ResolverLike) => Promise<T>,
|
||||
deadline: number,
|
||||
resolverFactory: ResolverFactory,
|
||||
): Promise<Captured<T>> {
|
||||
const resolver = resolverFactory();
|
||||
try {
|
||||
if (!remainingMs(deadline)) return { value: null, error: { probe, message: probe + " DNS query timed out." } };
|
||||
const value = await withDeadline(
|
||||
operation(resolver),
|
||||
deadline,
|
||||
() => resolver.cancel(),
|
||||
"DNS resolution timed out.",
|
||||
);
|
||||
return { value, error: null };
|
||||
} catch (error) {
|
||||
if (isAbsentDnsError(error)) return { value: null, error: null };
|
||||
return { value: null, error: { probe, message: failureMessage(probe, error) } };
|
||||
} finally {
|
||||
resolver.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveDnsFamily(host: string, family: 4 | 6, deadline = createDeadline(DNS_TIMEOUT_MS), resolverFactory: ResolverFactory = () => new Resolver()): Promise<DnsFamilyResult> {
|
||||
const probe = family === 4 ? "A" : "AAAA";
|
||||
const captured = await resolveWithBudget(probe, (resolver) => family === 4 ? resolver.resolve4(host) : resolver.resolve6(host), deadline, resolverFactory);
|
||||
return familyResult(captured);
|
||||
}
|
||||
|
||||
async function capture<T>(probe: string, fn: (resolver: ResolverLike) => Promise<T>, deadline: number, resolverFactory: ResolverFactory): Promise<Captured<T>> {
|
||||
return resolveWithBudget(probe, fn, deadline, resolverFactory);
|
||||
}
|
||||
|
||||
const flattenTxt = (records: string[][]) => records.map((parts) => parts.join(""));
|
||||
const isSpfRecord = (value: string) => value.trimStart().toLowerCase().startsWith("v=spf1");
|
||||
|
||||
/**
|
||||
* This deliberately reads only the visible terminal `all` mechanism. It does
|
||||
* not resolve includes, macros or SPF lookup chains.
|
||||
*/
|
||||
export function summarizeSpf(records: string[]): SpfPosture {
|
||||
if (!records.length) return { status: "missing", allQualifier: null, record: null, reason: "No SPF record was observed." };
|
||||
if (records.length !== 1) return { status: "invalid", allQualifier: null, record: records[0] ?? null, reason: "Multiple SPF records were observed." };
|
||||
const record = records[0];
|
||||
const mechanisms = record.trim().split(/\s+/).slice(1).filter(Boolean);
|
||||
const allIndexes = mechanisms.map((mechanism, index) => /^[+\-~?]?all$/i.test(mechanism) ? index : -1).filter((index) => index >= 0);
|
||||
if (allIndexes.length !== 1) return { status: "incomplete", allQualifier: null, record, reason: "No single terminal all mechanism was observed." };
|
||||
const index = allIndexes[0];
|
||||
if (index !== mechanisms.length - 1) return { status: "invalid", allQualifier: null, record, reason: "The SPF all mechanism is not terminal." };
|
||||
type SpfQualifier = NonNullable<SpfPosture["allQualifier"]>;
|
||||
const qualifier: SpfQualifier = (mechanisms[index][0] === "-" || mechanisms[index][0] === "~" || mechanisms[index][0] === "?" || mechanisms[index][0] === "+")
|
||||
? mechanisms[index][0] as SpfQualifier
|
||||
: "+";
|
||||
const posture: Record<SpfQualifier, Pick<SpfPosture, "status" | "reason">> = {
|
||||
"-": { status: "hardfail", reason: "The terminal -all mechanism rejects unauthorized senders." },
|
||||
"~": { status: "softfail", reason: "The terminal ~all mechanism soft-fails unauthorized senders." },
|
||||
"?": { status: "neutral", reason: "The terminal ?all mechanism is neutral for unauthorized senders." },
|
||||
"+": { status: "permissive", reason: "The terminal +all mechanism authorizes every sender." },
|
||||
};
|
||||
return { ...posture[qualifier], allQualifier: qualifier, record };
|
||||
}
|
||||
|
||||
export function parseDmarcRecord(record: string): { valid: boolean; policy: DmarcPosture["policy"]; pct: number | null; tags: Record<string, string> } {
|
||||
const tags = new Map<string, string>();
|
||||
let invalid = false;
|
||||
for (const part of record.split(";")) {
|
||||
const trimmed = part.trim();
|
||||
if (!trimmed) continue;
|
||||
const [rawName, ...rawValue] = trimmed.split("=");
|
||||
const name = rawName.trim().toLowerCase();
|
||||
const value = rawValue.join("=").trim().toLowerCase();
|
||||
if (!/^[a-z][a-z0-9-]*$/.test(name) || !value || tags.has(name)) invalid = true;
|
||||
else tags.set(name, value);
|
||||
}
|
||||
if (tags.get("v") !== "dmarc1") invalid = true;
|
||||
const policyValue = tags.get("p");
|
||||
const policy = policyValue === "none" || policyValue === "quarantine" || policyValue === "reject" ? policyValue : null;
|
||||
if (!policy) invalid = true;
|
||||
const pctRaw = tags.get("pct");
|
||||
const pct = pctRaw === undefined ? 100 : /^\d+$/.test(pctRaw) ? Number(pctRaw) : null;
|
||||
if (pct === null || !Number.isInteger(pct) || pct < 0 || pct > 100) invalid = true;
|
||||
return { valid: !invalid, policy, pct, tags: Object.fromEntries(tags) };
|
||||
}
|
||||
|
||||
export function summarizeDmarc(records: string[]): DmarcPosture {
|
||||
if (!records.length) return { status: "missing", policy: null, pct: null, record: null, reason: "No DMARC record was observed." };
|
||||
if (records.length !== 1) return { status: "invalid", policy: null, pct: null, record: records[0] ?? null, reason: "Multiple DMARC records were observed." };
|
||||
const parsed = parseDmarcRecord(records[0]);
|
||||
if (!parsed.valid || !parsed.policy) return { status: "invalid", policy: parsed.policy, pct: parsed.pct, record: records[0], reason: "The DMARC record is malformed or uses an unsupported policy." };
|
||||
const monitorOnly = parsed.policy === "none" || parsed.pct === 0;
|
||||
const status = monitorOnly ? "monitor" : parsed.policy === "quarantine" ? "quarantine" : "reject";
|
||||
return { status, policy: parsed.policy, pct: parsed.pct, record: records[0], reason: parsed.policy === "none" ? "p=none monitors failures without enforcement." : parsed.pct === 0 ? "pct=0 applies the policy to no messages." : "An enforcing DMARC policy was observed." };
|
||||
}
|
||||
|
||||
export interface DnsCollectionOptions {
|
||||
deadline?: number;
|
||||
resolverFactory?: ResolverFactory;
|
||||
}
|
||||
|
||||
function familyResult(captured: Captured<string[] | null>): DnsFamilyResult {
|
||||
const addresses = captured.value ?? [];
|
||||
return { addresses, status: captured.error ? "failed" : addresses.length ? "ok" : "absent", error: captured.error };
|
||||
}
|
||||
|
||||
export async function collectDns(host: string, options: DnsCollectionOptions = {}) {
|
||||
const deadline = options.deadline ?? createDeadline(DNS_TIMEOUT_MS);
|
||||
const resolverFactory = options.resolverFactory ?? (() => new Resolver());
|
||||
const [a, aaaa, mx, ns, txtRaw, dmarcRaw] = await Promise.all([
|
||||
capture("A", (resolver) => resolver.resolve4(host), deadline, resolverFactory),
|
||||
capture("AAAA", (resolver) => resolver.resolve6(host), deadline, resolverFactory),
|
||||
capture("MX", (resolver) => resolver.resolveMx(host), deadline, resolverFactory),
|
||||
capture("NS", (resolver) => resolver.resolveNs(host), deadline, resolverFactory),
|
||||
capture("TXT", (resolver) => resolver.resolveTxt(host), deadline, resolverFactory),
|
||||
capture("DMARC", (resolver) => resolver.resolveTxt("_dmarc." + host), deadline, resolverFactory),
|
||||
]);
|
||||
|
||||
const txt = flattenTxt(txtRaw.value ?? []);
|
||||
const dmarc = flattenTxt(dmarcRaw.value ?? []);
|
||||
const errors = [a.error, aaaa.error, mx.error, ns.error, txtRaw.error, dmarcRaw.error]
|
||||
.filter((item): item is ProbeError => Boolean(item));
|
||||
const valuesAvailable = Boolean((a.value?.length ?? 0) + (aaaa.value?.length ?? 0) + (mx.value?.length ?? 0) + (ns.value?.length ?? 0) + txt.length + dmarc.length);
|
||||
const status = errors.length ? (valuesAvailable ? "partial" : "failed") : "ok";
|
||||
const ipv4 = familyResult(a);
|
||||
const ipv6 = familyResult(aaaa);
|
||||
|
||||
return {
|
||||
addresses: [...ipv4.addresses, ...ipv6.addresses],
|
||||
families: { ipv4, ipv6 },
|
||||
a: a.value ?? [],
|
||||
aaaa: aaaa.value ?? [],
|
||||
mx: mx.value ?? [],
|
||||
ns: ns.value ?? [],
|
||||
txt,
|
||||
spf: txt.filter(isSpfRecord),
|
||||
dmarc,
|
||||
spfPosture: summarizeSpf(txt.filter(isSpfRecord)),
|
||||
dmarcPosture: summarizeDmarc(dmarc),
|
||||
_meta: { status, partial: status === "partial", errors },
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user