Initial curated CertScope source from fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:30:09 +02:00
commit 7348faf6c0
65 files changed
+12509

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { checkRateLimit, clientKeyFromHeaders, MAX_IN_FLIGHT_INSPECTIONS, RATE_LIMIT_MAX_REQUESTS, RATE_LIMIT_WINDOW_MS, tryAcquireInspection } from "./admission";
describe("inspection admission", () => {
it("rejects work beyond the bounded in-flight budget", () => {
const releases = Array.from({ length: MAX_IN_FLIGHT_INSPECTIONS }, () => tryAcquireInspection());
expect(releases.every(Boolean)).toBe(true);
expect(tryAcquireInspection()).toBeNull();
releases.forEach((release) => release?.());
const release = tryAcquireInspection();
expect(release).not.toBeNull();
release?.();
});
it("releases capacity at most once", () => {
const release = tryAcquireInspection();
expect(release).not.toBeNull();
release?.();
release?.();
const releases = Array.from({ length: MAX_IN_FLIGHT_INSPECTIONS }, () => tryAcquireInspection());
expect(releases.every(Boolean)).toBe(true);
releases.forEach((item) => item?.());
});
it("applies a bounded per-client sliding-window limit", () => {
const key = "test-client-" + Math.random();
const now = 1_000_000;
for (let index = 0; index < RATE_LIMIT_MAX_REQUESTS; index += 1) {
expect(checkRateLimit(key, now + index)).toMatchObject({ allowed: true });
}
expect(checkRateLimit(key, now + RATE_LIMIT_MAX_REQUESTS)).toMatchObject({ allowed: false });
expect(checkRateLimit(key, now + RATE_LIMIT_WINDOW_MS + 1)).toMatchObject({ allowed: true });
});
it("does not trust forwarded client identity unless proxy trust is enabled", () => {
const headers = { get: (name: string) => name === "x-forwarded-for" ? "198.51.100.10, 10.0.0.1" : null };
const previous = process.env.CERTSCOPE_TRUST_PROXY;
delete process.env.CERTSCOPE_TRUST_PROXY;
expect(clientKeyFromHeaders(headers)).toBe("direct");
process.env.CERTSCOPE_TRUST_PROXY = "true";
expect(clientKeyFromHeaders(headers)).toBe("proxy:198.51.100.10");
if (previous === undefined) delete process.env.CERTSCOPE_TRUST_PROXY;
else process.env.CERTSCOPE_TRUST_PROXY = previous;
});
});
+51
View File
@@ -0,0 +1,51 @@
export const MAX_IN_FLIGHT_INSPECTIONS = 4;
export const RATE_LIMIT_WINDOW_MS = 60_000;
export const RATE_LIMIT_MAX_REQUESTS = 30;
const MAX_TRACKED_CLIENTS = 1_024;
let inFlight = 0;
const requestWindows = new Map<string, number[]>();
export function tryAcquireInspection() {
if (inFlight >= MAX_IN_FLIGHT_INSPECTIONS) return null;
inFlight += 1;
let released = false;
return () => {
if (released) return;
released = true;
inFlight = Math.max(0, inFlight - 1);
};
}
export function clientKeyFromHeaders(headers: { get(name: string): string | null }) {
if (process.env.CERTSCOPE_TRUST_PROXY !== "true") return "direct";
const forwarded = headers.get("x-forwarded-for") ?? headers.get("x-real-ip") ?? "unknown";
const client = forwarded.split(",", 1)[0]?.trim().slice(0, 128) || "unknown";
return "proxy:" + client;
}
export function checkRateLimit(key: string, now = Date.now()) {
const windowStart = now - RATE_LIMIT_WINDOW_MS;
const previous = requestWindows.get(key) ?? [];
const recent = previous.filter((timestamp) => timestamp > windowStart);
if (recent.length >= RATE_LIMIT_MAX_REQUESTS) {
const retryAfterMs = Math.max(1_000, (recent[0] ?? now) + RATE_LIMIT_WINDOW_MS - now);
requestWindows.set(key, recent);
return { allowed: false, retryAfterSeconds: Math.ceil(retryAfterMs / 1_000) };
}
recent.push(now);
requestWindows.set(key, recent);
if (requestWindows.size > MAX_TRACKED_CLIENTS) {
for (const [client, timestamps] of requestWindows) {
if (!timestamps.some((timestamp) => timestamp > windowStart)) requestWindows.delete(client);
if (requestWindows.size <= MAX_TRACKED_CLIENTS) break;
}
if (requestWindows.size > MAX_TRACKED_CLIENTS) {
const oldestClient = requestWindows.keys().next().value;
if (typeof oldestClient === "string" && oldestClient !== key) requestWindows.delete(oldestClient);
}
}
return { allowed: true, retryAfterSeconds: 0 };
}
+42
View File
@@ -0,0 +1,42 @@
export const DEADLINE_EXPIRED_CODE = "ETIMEOUT";
export function createDeadline(budgetMs: number, now = Date.now()) {
return now + Math.max(0, budgetMs);
}
export function remainingMs(deadline: number, now = Date.now()) {
return Math.max(0, deadline - now);
}
export function timeoutError(message: string) {
const error = new Error(message) as Error & { code: string };
error.code = DEADLINE_EXPIRED_CODE;
return error;
}
export async function withDeadline<T>(
operation: Promise<T>,
deadline: number,
onTimeout: () => void,
message: string,
): Promise<T> {
const remaining = remainingMs(deadline);
if (!remaining) {
onTimeout();
throw timeoutError(message);
}
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<T>((_, reject) => {
timer = setTimeout(() => {
onTimeout();
reject(timeoutError(message));
}, remaining);
});
try {
return await Promise.race([operation, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
+93
View File
@@ -0,0 +1,93 @@
import type { InspectionResult } from "./types";
export const demoInspection: InspectionResult = {
host: "portfolio-demo.invalid",
inspectedAt: "2026-09-09T16:00:00.000Z",
addresses: ["203.0.113.42"],
completeness: {
status: "complete",
complete: 3,
total: 3,
collectors: { dns: "ok", tls: "ok", http: "ok" },
issues: [],
},
dns: {
addresses: ["203.0.113.42"],
families: {
ipv4: { addresses: ["203.0.113.42"], status: "ok", error: null },
ipv6: { addresses: [], status: "absent", error: null },
},
a: ["203.0.113.42"],
aaaa: [],
mx: [{ exchange: "mail.portfolio-demo.invalid", priority: 10 }],
ns: ["ns1.example.net", "ns2.example.net"],
txt: ["v=spf1 include:_spf.example.net -all"],
spf: ["v=spf1 include:_spf.example.net -all"],
dmarc: ["v=DMARC1; p=none; rua=mailto:dmarc@example.net"],
spfPosture: { status: "present", reason: "One SPF record was observed." },
dmarcPosture: { status: "monitor", policy: "none", pct: 100, record: "v=DMARC1; p=none; rua=mailto:dmarc@example.net", reason: "p=none monitors failures without enforcement." },
_meta: { status: "ok", partial: false, errors: [] },
},
tls: {
address: "203.0.113.42",
protocol: "TLSv1.3",
authorized: true,
authorizationError: null,
hostnameValid: true,
hostnameError: null,
subject: { CN: "portfolio-demo.invalid" },
issuer: { O: "Example Trust Services", CN: "Example RSA CA" },
subjectaltname: "DNS:portfolio-demo.invalid",
validFrom: "Sep 01 00:00:00 2026 GMT",
validTo: "Dec 01 23:59:59 2026 GMT",
daysRemaining: 83,
fingerprint256: "2A:8D:DE:MO:FI:XT:UR:E0",
_meta: { status: "ok", errors: [] },
},
http: {
http: { url: "http://portfolio-demo.invalid/", address: "203.0.113.42", addresses: ["203.0.113.42"], status: 301, location: "https://portfolio-demo.invalid/" },
httpRedirectsToHttps: true,
httpRedirectTarget: { url: "https://portfolio-demo.invalid/", addresses: ["203.0.113.42"], validated: true, resolution: { status: "ok", partial: false, errors: [] } },
httpsChain: [{
url: "https://portfolio-demo.invalid/",
address: "203.0.113.42",
addresses: ["203.0.113.42"],
status: 200,
location: null,
headers: {
"strict-transport-security": "max-age=31536000; includeSubDomains",
"content-security-policy": null,
"x-content-type-options": "nosniff",
"referrer-policy": "strict-origin-when-cross-origin",
"x-frame-options": "DENY",
server: "nginx",
"x-powered-by": null,
},
}],
finalUrl: "https://portfolio-demo.invalid/",
_meta: { status: "ok", errors: [] },
},
findings: [
{
id: "http.csp_missing",
category: "http",
severity: "warning",
title: "Content-Security-Policy is missing",
summary: "No CSP header was observed on the final HTTPS response.",
evidence: ["http.httpsChain[].headers.content-security-policy"],
remediation: "Deploy a tested CSP appropriate for the application.",
scoreImpact: -6,
},
{
id: "dns.dmarc_monitor",
category: "dns",
severity: "info",
title: "DMARC is not fully enforcing",
summary: "DMARC uses p=none, so failing mail is monitored rather than rejected or quarantined.",
evidence: ["dns.dmarc"],
remediation: "Move to quarantine or reject after validating legitimate mail sources.",
scoreImpact: 0,
},
],
score: { overall: 98, deductions: 2, categories: { dns: 100, tls: 100, http: 94 } },
};
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import { DIAGNOSTIC_RULES, evaluateDiagnostics } from "./diagnostics";
const okMeta: Record<string, unknown> = { status: "ok", errors: [] };
const baseDns: Record<string, unknown> = { mx: [], spf: [], dmarc: [], _meta: okMeta };
const baseTls: Record<string, unknown> = { authorized: true, hostnameValid: true, daysRemaining: 90, _meta: okMeta };
const secureHeaders: Record<string, string> = {
"strict-transport-security": "max-age=31536000; includeSubDomains",
"content-security-policy": "default-src 'self'; object-src 'none'; base-uri 'self'",
"x-content-type-options": "nosniff",
"referrer-policy": "strict-origin-when-cross-origin",
"x-frame-options": "DENY",
};
const baseHttp: Record<string, unknown> = { http: { status: 301 }, httpRedirectsToHttps: true, httpsChain: [{ status: 200, headers: secureHeaders }], _meta: okMeta };
function ids(dns: Record<string, unknown> = baseDns, tls: Record<string, unknown> = baseTls, http: Record<string, unknown> = baseHttp) {
return evaluateDiagnostics(dns, tls, http).map((finding) => finding.id);
}
describe("diagnostics", () => {
it("has unique stable rule identifiers", () => {
const ids = DIAGNOSTIC_RULES.map((rule) => rule.id);
expect(new Set(ids).size).toBe(ids.length);
});
it("finds mail and HTTP posture gaps deterministically", () => {
const found = ids(
{ mx: [{ exchange: "mail.example.com" }], spf: [], dmarc: [], _meta: okMeta },
baseTls,
{ http: { status: 200 }, httpRedirectsToHttps: false, httpsChain: [{ status: 200, headers: { "strict-transport-security": null, "content-security-policy": null, "x-content-type-options": null } }], _meta: okMeta },
);
expect(found).toEqual([
"http.hsts_missing",
"http.csp_missing",
"http.redirect",
"dns.dmarc_missing",
"dns.spf_missing",
"http.frame_protection",
"http.nosniff_missing",
"http.referrer_policy_missing",
]);
});
it("keeps partial collectors informational and does not deduct missing evidence", () => {
const findings = evaluateDiagnostics(
{ mx: [{ exchange: "mail.example.com" }], spf: [], dmarc: [], _meta: { status: "partial", errors: [{ probe: "TXT", message: "timeout" }, { probe: "DMARC", message: "timeout" }] } },
{ authorized: true, hostnameValid: true, daysRemaining: 90, _meta: okMeta },
{ http: { error: "timeout" }, httpRedirectsToHttps: false, httpsChain: [{ error: "timeout" }], _meta: { status: "partial", errors: [{ probe: "HTTPS", message: "timeout" }] } },
);
expect(findings.map((finding) => finding.id)).toEqual(["collector.dns_partial", "collector.http_partial"]);
expect(findings.every((finding) => finding.scoreImpact === 0)).toBe(true);
});
it("does not score headers when the HTTPS chain has no terminal response", () => {
expect(ids(baseDns, baseTls, { http: { status: 301 }, httpRedirectsToHttps: true, httpsChain: [{ status: 302, location: "/next", headers: {} }, { url: "/next", error: "redirect failed" }], _meta: { status: "partial", errors: [{ probe: "HTTPS", message: "redirect failed" }] } })).toEqual(["collector.http_partial"]);
});
it("deduplicates the generic TLS verification penalty when specific causes exist", () => {
const found = ids(baseDns, { authorized: false, authorizationError: "CERT_HAS_EXPIRED", hostnameValid: false, hostnameError: "hostname mismatch", daysRemaining: -2, _meta: okMeta }, baseHttp);
expect(found).toContain("tls.expired");
expect(found).toContain("tls.hostname");
expect(found).not.toContain("tls.verification");
});
it("reports an untrusted issuer when no more specific TLS cause is present", () => {
expect(ids(baseDns, { authorized: false, authorizationError: "UNABLE_TO_VERIFY_LEAF_SIGNATURE", hostnameValid: true, daysRemaining: 90, _meta: okMeta }, baseHttp)).toContain("tls.verification");
});
it("reports malformed certificate validity dates without adding generic trust noise", () => {
const found = ids(baseDns, { authorized: false, authorizationError: "CERT_INVALID", hostnameValid: true, validityDatesValid: false, _meta: okMeta }, baseHttp);
expect(found).toContain("tls.validity_dates");
expect(found).not.toContain("tls.verification");
});
it("does not score expiry states from an incoherent certificate window", () => {
const found = ids(baseDns, {
authorized: true,
hostnameValid: true,
validFrom: "Jul 02 00:00:00 2026 GMT",
validTo: "Jul 01 00:00:00 2026 GMT",
validityDatesValid: false,
notYetValid: true,
daysRemaining: -1,
_meta: okMeta,
}, baseHttp);
expect(found).toContain("tls.validity_dates");
expect(found).not.toContain("tls.expired");
expect(found).not.toContain("tls.not_yet_valid");
expect(found).not.toContain("tls.expiry_critical");
expect(found).not.toContain("tls.expiry_warning");
});
it("reports HTTPS downgrades and loops as findings", () => {
const downgrade = ids(baseDns, baseTls, { ...baseHttp, httpsDowngrade: true, httpsChain: [{ status: 302, location: "http://example.com/", headers: secureHeaders }, { error: "HTTPS redirect downgrade is not allowed.", code: "HTTPS_DOWNGRADE" }], _meta: { status: "partial", errors: [{ probe: "HTTPS", message: "downgrade" }] } });
expect(downgrade).toContain("http.https_downgrade");
const loop = ids(baseDns, baseTls, { ...baseHttp, httpsChain: [{ status: 302, location: "/", headers: secureHeaders }, { error: "Redirect loop detected.", code: "REDIRECT_LOOP" }], _meta: { status: "partial", errors: [{ probe: "HTTPS", message: "loop" }] } });
expect(loop).toContain("http.redirect_loop");
});
it("surfaces redirects rejected by the target policy", () => {
expect(ids(baseDns, baseTls, {
...baseHttp,
httpsChain: [{ status: 302, location: "http://127.0.0.1/", headers: secureHeaders }, { code: "REDIRECT_TARGET_BLOCKED", error: "Target rejected" }],
_meta: { status: "partial", errors: [{ probe: "HTTPS", message: "target blocked" }] },
})).toContain("http.redirect_blocked");
});
it("classifies terminal HTTP status without using a green status", () => {
expect(ids(baseDns, baseTls, { ...baseHttp, httpsChain: [{ status: 404, headers: secureHeaders }] })).toContain("http.terminal_client_error");
expect(ids(baseDns, baseTls, { ...baseHttp, httpsChain: [{ status: 503, headers: secureHeaders }] })).toContain("http.terminal_server_error");
});
it("evaluates HSTS, CSP and nosniff values rather than presence alone", () => {
const findings = ids(baseDns, baseTls, { ...baseHttp, httpsChain: [{ status: 200, headers: { ...secureHeaders, "strict-transport-security": "max-age=0", "content-security-policy": "default-src * 'unsafe-eval'", "x-content-type-options": "text/plain" } }] });
expect(findings).toContain("http.hsts_disabled");
expect(findings).toContain("http.csp_weak");
expect(findings).toContain("http.nosniff_invalid");
});
it("does not require mail authentication for an explicit null MX", () => {
const findings = ids({ mx: [{ exchange: ".", priority: 0 }], spf: [], dmarc: [], _meta: okMeta }, baseTls, baseHttp);
expect(findings).not.toContain("dns.spf_missing");
expect(findings).not.toContain("dns.dmarc_missing");
});
it("reports multiple SPF records as a distinct configuration error", () => {
expect(ids({ mx: [{ exchange: "mail.example.com" }], spf: ["v=spf1 -all", "v=spf1 ~all"], dmarc: [], _meta: okMeta }, baseTls, baseHttp)).toContain("dns.spf_multiple");
});
it("reports malformed MX answers while preserving null-MX semantics", () => {
expect(ids({ mx: [{ exchange: "", priority: 10 }], spf: [], dmarc: [], _meta: okMeta }, baseTls, baseHttp)).toContain("dns.mx_invalid");
expect(ids({ mx: [{ exchange: "", priority: 0 }], spf: [], dmarc: [], _meta: okMeta }, baseTls, baseHttp)).not.toContain("dns.mx_invalid");
expect(ids({ mx: [{ exchange: ".", priority: 0 }], spf: [], dmarc: [], _meta: okMeta }, baseTls, baseHttp)).not.toContain("dns.mx_invalid");
});
it("recognizes DMARC pct=0 as non-enforcing without mislabeling the record invalid", () => {
const findings = ids({ mx: [{ exchange: "mail.example.com" }], spf: ["v=spf1 -all"], dmarc: ["v=DMARC1; p=reject; pct=0"], _meta: okMeta }, baseTls, baseHttp);
expect(findings).toContain("dns.dmarc_monitor");
expect(findings).not.toContain("dns.dmarc_invalid");
});
});
+584
View File
@@ -0,0 +1,584 @@
import { parseDmarcRecord, summarizeSpf } from "./dns";
import { analyzeHeaders, type HeaderMap } from "./headers";
import type { Finding } from "./types";
type Context = {
dns: Record<string, unknown>;
tls: Record<string, unknown>;
http: Record<string, unknown>;
};
type ProbeError = { probe?: string; message?: string };
export type DiagnosticRule = { id: string; evaluate: (context: Context) => Finding | null };
const meta = (value: Record<string, unknown>) => value._meta as Record<string, unknown> | undefined;
const metaStatus = (value: Record<string, unknown>) => {
const status = meta(value)?.status;
return status === "ok" || status === "partial" || status === "failed" ? status : "failed";
};
const probeFailed = (value: Record<string, unknown>, probe: string) => {
const errors = meta(value)?.errors;
return Array.isArray(errors) && errors.some((error) =>
typeof error === "object" && error !== null &&
String((error as ProbeError).probe ?? "").toUpperCase() === probe.toUpperCase(),
);
};
const finiteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
const httpStatus = (value: unknown): value is number => finiteNumber(value) && Number.isInteger(value) && value >= 100 && value <= 599;
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null;
const hasMailService = (dns: Record<string, unknown>) => Array.isArray(dns.mx) && dns.mx.some((record) => {
if (!isRecord(record)) return false;
const exchange = record.exchange;
const normalizedExchange = typeof exchange === "string" ? exchange.trim() : "";
return normalizedExchange !== "" && normalizedExchange !== ".";
});
const hasInvalidMx = (dns: Record<string, unknown>) => Array.isArray(dns.mx) && dns.mx.some((record) => {
if (!isRecord(record) || typeof record.exchange !== "string") return true;
const exchange = record.exchange.trim();
if (!exchange || exchange === ".") return record.priority !== 0;
return record.priority !== undefined && (!finiteNumber(record.priority) || !Number.isInteger(record.priority) || record.priority < 0 || record.priority > 65_535);
});
const isSpfRecord = (value: unknown) => typeof value === "string" && value.trimStart().toLowerCase().startsWith("v=spf1");
const plainHttpResult = (http: Record<string, unknown>) => {
const result = http.http;
return isRecord(result) && !("error" in result) ? result : null;
};
const terminalHttpsResult = (http: Record<string, unknown>) => {
const chain = Array.isArray(http.httpsChain) ? http.httpsChain.filter(isRecord) : [];
const last = chain.at(-1);
return last && !("error" in last) ? last : null;
};
const terminalHeaders = (http: Record<string, unknown>) => {
const terminal = terminalHttpsResult(http);
if (!terminal || !isRecord(terminal.headers)) return null;
return terminal.headers as HeaderMap;
};
const hasChainCode = (http: Record<string, unknown>, code: string) =>
Array.isArray(http.httpsChain) && http.httpsChain.some((hop) => isRecord(hop) && hop.code === code);
const hasBlockedRedirect = (http: Record<string, unknown>) =>
hasChainCode(http, "REDIRECT_TARGET_BLOCKED") || hasChainCode(http, "INVALID_REDIRECT") ||
(isRecord(http.httpRedirectTarget) && (http.httpRedirectTarget.code === "REDIRECT_TARGET_BLOCKED" || http.httpRedirectTarget.code === "INVALID_REDIRECT"));
const tlsHasError = (tls: Record<string, unknown>, code: string) => String(tls.authorizationError ?? "").toUpperCase().includes(code);
export const DIAGNOSTIC_RULES: DiagnosticRule[] = [
{
id: "collector.dns_partial",
evaluate: ({ dns }) => metaStatus(dns) !== "ok" ? {
id: "collector.dns_partial", category: "dns", severity: "info",
title: "Some DNS probes were incomplete",
summary: "At least one DNS record-type query failed; available evidence is still shown.",
evidence: ["dns._meta"],
remediation: "Retry later or compare against an authoritative resolver before acting on missing records.",
scoreImpact: 0,
} : null,
},
{
id: "collector.tls_partial",
evaluate: ({ tls }) => metaStatus(tls) !== "ok" ? {
id: "collector.tls_partial", category: "tls", severity: "info",
title: "TLS probe was incomplete",
summary: "The TLS connection did not produce a complete certificate result; no certificate deduction is applied.",
evidence: ["tls._meta"],
remediation: "Retry from the expected network path and inspect the TLS probe evidence.",
scoreImpact: 0,
} : null,
},
{
id: "collector.http_partial",
evaluate: ({ http }) => metaStatus(http) !== "ok" ? {
id: "collector.http_partial", category: "http", severity: "info",
title: "Some HTTP probes were incomplete",
summary: "One or more HTTP/HTTPS requests or redirect hops failed; successful evidence is preserved.",
evidence: ["http._meta"],
remediation: "Inspect the failed hop and retry from the expected network path.",
scoreImpact: 0,
} : null,
},
{
id: "tls.verification",
evaluate: ({ tls }) => tls.authorized === false &&
tls.hostnameValid !== false &&
tls.notYetValid !== true &&
tls.validityDatesValid !== false &&
!(finiteNumber(tls.daysRemaining) && tls.daysRemaining < 0) &&
!tlsHasError(tls, "CERT_HAS_EXPIRED") ? {
id: "tls.verification", category: "tls", severity: "critical",
title: "TLS trust verification failed",
summary: String(tls.authorizationError ?? "The certificate chain could not be verified."),
evidence: ["tls.authorized", "tls.authorizationError"],
remediation: "Verify the certificate chain and issuer configuration.",
scoreImpact: -30,
} : null,
},
{
id: "tls.validity_dates",
evaluate: ({ tls }) => metaStatus(tls) === "ok" && tls.validityDatesValid === false ? {
id: "tls.validity_dates", category: "tls", severity: "warning",
title: "Certificate validity dates are malformed",
summary: "The peer certificate did not expose a coherent valid-from/valid-to window.",
evidence: ["tls.validFrom", "tls.validTo", "tls.validityDatesValid"],
remediation: "Serve a certificate with parseable validity dates and a valid chronological window.",
scoreImpact: -12,
} : null,
},
{
id: "tls.hostname",
evaluate: ({ tls }) => tls.hostnameValid === false ? {
id: "tls.hostname", category: "tls", severity: "critical",
title: "Certificate hostname mismatch",
summary: String(tls.hostnameError ?? "The certificate does not match the requested hostname."),
evidence: ["tls.hostnameValid", "tls.subjectaltname"],
remediation: "Issue a certificate whose SANs cover the hostname.",
scoreImpact: -25,
} : null,
},
{
id: "tls.expired",
evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining < 0 ? {
id: "tls.expired", category: "tls", severity: "critical",
title: "Certificate has expired",
summary: "The certificate expired " + Math.abs(tls.daysRemaining) + " day(s) ago.",
evidence: ["tls.daysRemaining", "tls.validTo"],
remediation: "Renew and deploy a valid certificate immediately.",
scoreImpact: -35,
} : null,
},
{
id: "tls.not_yet_valid",
evaluate: ({ tls }) => tls.validityDatesValid !== false && tls.notYetValid === true ? {
id: "tls.not_yet_valid", category: "tls", severity: "critical",
title: "Certificate is not valid yet",
summary: "The certificate valid-from date is in the future.",
evidence: ["tls.validFrom", "tls.notYetValid"],
remediation: "Deploy a certificate whose validity window includes the current time.",
scoreImpact: -20,
} : null,
},
{
id: "tls.expiry_critical",
evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining >= 0 && tls.daysRemaining <= 14 ? {
id: "tls.expiry_critical", category: "tls", severity: "critical",
title: "Certificate expires very soon",
summary: "Only " + tls.daysRemaining + " day(s) of certificate validity remain.",
evidence: ["tls.daysRemaining", "tls.validTo"],
remediation: "Renew and deploy the replacement certificate now.",
scoreImpact: -15,
} : null,
},
{
id: "tls.expiry_warning",
evaluate: ({ tls }) => tls.validityDatesValid !== false && finiteNumber(tls.daysRemaining) && tls.daysRemaining > 14 && tls.daysRemaining <= 30 ? {
id: "tls.expiry_warning", category: "tls", severity: "warning",
title: "Certificate expires soon",
summary: tls.daysRemaining + " day(s) of certificate validity remain.",
evidence: ["tls.daysRemaining", "tls.validTo"],
remediation: "Schedule certificate renewal.",
scoreImpact: -8,
} : null,
},
{
id: "http.https_downgrade",
evaluate: ({ http }) => http.httpsDowngrade === true || hasChainCode(http, "HTTPS_DOWNGRADE") ? {
id: "http.https_downgrade", category: "http", severity: "critical",
title: "HTTPS redirect downgrades to HTTP",
summary: "The HTTPS redirect chain attempted to leave HTTPS; the downgraded response was not treated as a secure terminal result.",
evidence: ["http.httpsChain[].code", "http.httpsChain[].url"],
remediation: "Keep every redirect in the HTTPS scheme and remove the downgrade hop.",
scoreImpact: -15,
} : null,
},
{
id: "http.redirect_loop",
evaluate: ({ http }) => hasChainCode(http, "REDIRECT_LOOP") ? {
id: "http.redirect_loop", category: "http", severity: "warning",
title: "HTTPS redirect loop detected",
summary: "The normalized redirect chain revisited a URL and was stopped safely.",
evidence: ["http.httpsChain[].code", "http.httpsChain[].url"],
remediation: "Remove the redirect cycle and leave one canonical HTTPS destination.",
scoreImpact: -8,
} : null,
},
{
id: "http.redirect_blocked",
evaluate: ({ http }) => hasBlockedRedirect(http) ? {
id: "http.redirect_blocked", category: "http", severity: "warning",
title: "Redirect target was blocked by policy",
summary: "A redirect target did not satisfy the public-host, scheme or port policy; no request was made to that target.",
evidence: ["http.httpsChain[].code", "http.httpRedirectTarget.code"],
remediation: "Keep redirects on public hostnames and default HTTP(S) ports without credentials.",
scoreImpact: -8,
} : null,
},
{
id: "http.redirect_limit",
evaluate: ({ http }) => hasChainCode(http, "REDIRECT_LIMIT") ? {
id: "http.redirect_limit", category: "http", severity: "info",
title: "HTTPS redirect limit reached",
summary: "The redirect chain was bounded before a terminal response was observed.",
evidence: ["http.httpsChain[].code"],
remediation: "Reduce redirect hops so the canonical HTTPS response is reached directly.",
scoreImpact: 0,
} : null,
},
{
id: "http.redirect",
evaluate: ({ http }) => plainHttpResult(http) && http.httpRedirectsToHttps === false ? {
id: "http.redirect", category: "http", severity: "warning",
title: "HTTP does not redirect directly to HTTPS",
summary: "The port 80 response did not advertise a validated HTTPS redirect.",
evidence: ["http.httpRedirectsToHttps", "http.http.location"],
remediation: "Redirect plain HTTP requests to the HTTPS origin.",
scoreImpact: -5,
} : null,
},
{
id: "http.terminal_client_error",
evaluate: ({ http }) => {
const terminal = terminalHttpsResult(http);
const status = terminal?.status;
return httpStatus(status) && status >= 400 && status < 500 ? {
id: "http.terminal_client_error", category: "http", severity: "warning",
title: "HTTPS response returned a client error",
summary: "The terminal HTTPS response returned HTTP " + status + ".",
evidence: ["http.httpsChain[].status"],
remediation: "Confirm that the inspected hostname serves the intended resource and status.",
scoreImpact: -4,
} : null;
},
},
{
id: "http.terminal_server_error",
evaluate: ({ http }) => {
const terminal = terminalHttpsResult(http);
const status = terminal?.status;
return httpStatus(status) && status >= 500 ? {
id: "http.terminal_server_error", category: "http", severity: "critical",
title: "HTTPS response returned a server error",
summary: "The terminal HTTPS response returned HTTP " + status + ".",
evidence: ["http.httpsChain[].status"],
remediation: "Investigate the origin service and restore a healthy HTTPS response.",
scoreImpact: -10,
} : null;
},
},
{
id: "http.hsts_missing",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).hsts : null;
return posture?.status === "missing" ? {
id: "http.hsts_missing", category: "http", severity: "warning",
title: "HSTS header is missing",
summary: "The final HTTPS response does not advertise HTTP Strict Transport Security.",
evidence: ["http.httpsChain[].headers.strict-transport-security"],
remediation: "Enable HSTS after confirming HTTPS is correct for the site.",
scoreImpact: -8,
} : null;
},
},
{
id: "http.hsts_invalid",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).hsts : null;
return posture?.status === "invalid" ? {
id: "http.hsts_invalid", category: "http", severity: "warning",
title: "HSTS policy is invalid",
summary: posture.reason,
evidence: ["http.httpsChain[].headers.strict-transport-security"],
remediation: "Publish one HSTS header with a non-negative numeric max-age.",
scoreImpact: -6,
} : null;
},
},
{
id: "http.hsts_disabled",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).hsts : null;
return posture?.status === "disabled" ? {
id: "http.hsts_disabled", category: "http", severity: "warning",
title: "HSTS is disabled",
summary: posture.reason,
evidence: ["http.httpsChain[].headers.strict-transport-security"],
remediation: "Use a positive HSTS max-age after confirming HTTPS coverage.",
scoreImpact: -8,
} : null;
},
},
{
id: "http.hsts_weak",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).hsts : null;
return posture?.status === "weak" || posture?.status === "invalid" ? {
id: "http.hsts_weak", category: "http", severity: "warning",
title: "HSTS policy is short-lived",
summary: posture.reason,
evidence: ["http.httpsChain[].headers.strict-transport-security"],
remediation: "After validating HTTPS coverage, use a long-lived HSTS max-age.",
scoreImpact: -4,
} : null;
},
},
{
id: "http.csp_missing",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).csp : null;
return posture?.status === "missing" ? {
id: "http.csp_missing", category: "http", severity: "warning",
title: "Content-Security-Policy is missing",
summary: "No CSP header was observed on the final HTTPS response.",
evidence: ["http.httpsChain[].headers.content-security-policy"],
remediation: "Deploy a tested CSP appropriate for the application.",
scoreImpact: -6,
} : null;
},
},
{
id: "http.csp_weak",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).csp : null;
return posture?.status === "weak" ? {
id: "http.csp_weak", category: "http", severity: "warning",
title: "Content-Security-Policy is weak",
summary: posture.reason,
evidence: ["http.httpsChain[].headers.content-security-policy"],
remediation: "Replace broad sources and unsafe directives with a tested restrictive policy.",
scoreImpact: -4,
} : null;
},
},
{
id: "http.nosniff_missing",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const hasHeader = headers && headers["x-content-type-options"] !== null && headers["x-content-type-options"] !== undefined && headers["x-content-type-options"] !== "";
return headers && !hasHeader ? {
id: "http.nosniff_missing", category: "http", severity: "warning",
title: "X-Content-Type-Options is missing",
summary: "The final HTTPS response does not set nosniff protection.",
evidence: ["http.httpsChain[].headers.x-content-type-options"],
remediation: "Set X-Content-Type-Options: nosniff.",
scoreImpact: -3,
} : null;
},
},
{
id: "http.nosniff_invalid",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const value = headers?.["x-content-type-options"];
const present = value !== null && value !== undefined && value !== "";
return headers && present && analyzeHeaders(headers).nosniff.status !== "strong" ? {
id: "http.nosniff_invalid", category: "http", severity: "warning",
title: "X-Content-Type-Options is invalid",
summary: "The header is present but does not have the required nosniff value.",
evidence: ["http.httpsChain[].headers.x-content-type-options"],
remediation: "Set the exact value X-Content-Type-Options: nosniff.",
scoreImpact: -3,
} : null;
},
},
{
id: "http.referrer_policy",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).referrerPolicy : null;
return posture?.status === "weak" || posture?.status === "invalid" ? {
id: "http.referrer_policy", category: "http", severity: "warning",
title: "Referrer-Policy needs attention",
summary: posture.reason,
evidence: ["http.httpsChain[].headers.referrer-policy"],
remediation: "Use a recognized restrictive policy such as strict-origin-when-cross-origin.",
scoreImpact: -2,
} : null;
},
},
{
id: "http.referrer_policy_missing",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).referrerPolicy : null;
return posture?.status === "missing" ? {
id: "http.referrer_policy_missing", category: "http", severity: "warning",
title: "Referrer-Policy is missing",
summary: posture.reason,
evidence: ["http.httpsChain[].headers.referrer-policy"],
remediation: "Set a restrictive Referrer-Policy such as strict-origin-when-cross-origin.",
scoreImpact: -2,
} : null;
},
},
{
id: "http.frame_protection",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
const posture = headers ? analyzeHeaders(headers).frameProtection : null;
return posture?.status === "missing" || posture?.status === "weak" || posture?.status === "invalid" ? {
id: "http.frame_protection", category: "http", severity: "warning",
title: "Clickjacking protection needs attention",
summary: posture.reason,
evidence: ["http.httpsChain[].headers.content-security-policy", "http.httpsChain[].headers.x-frame-options"],
remediation: "Use CSP frame-ancestors or X-Frame-Options: DENY/SAMEORIGIN.",
scoreImpact: -4,
} : null;
},
},
{
id: "http.server_disclosure",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
return headers?.server ? {
id: "http.server_disclosure", category: "http", severity: "info",
title: "Server identity is disclosed",
summary: "The response exposes a Server header, which may help fingerprint the origin.",
evidence: ["http.httpsChain[].headers.server"],
remediation: "Remove unnecessary version detail from server identification where practical.",
scoreImpact: 0,
} : null;
},
},
{
id: "http.powered_by_disclosure",
evaluate: ({ http }) => {
const headers = terminalHeaders(http);
return headers?.["x-powered-by"] ? {
id: "http.powered_by_disclosure", category: "http", severity: "info",
title: "X-Powered-By is disclosed",
summary: "The response exposes an implementation-identifying X-Powered-By header.",
evidence: ["http.httpsChain[].headers.x-powered-by"],
remediation: "Remove X-Powered-By unless the disclosure is intentional.",
scoreImpact: 0,
} : null;
},
},
{
id: "dns.spf_missing",
evaluate: ({ dns }) => {
const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : [];
return hasMailService(dns) && !spf.length && !probeFailed(dns, "TXT") ? {
id: "dns.spf_missing", category: "dns", severity: "warning",
title: "Mail domain has no SPF record",
summary: "MX records exist but no v=spf1 TXT record was found.",
evidence: ["dns.mx", "dns.spf"],
remediation: "Publish one SPF record that covers all legitimate senders.",
scoreImpact: -4,
} : null;
},
},
{
id: "dns.spf_multiple",
evaluate: ({ dns }) => {
const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : [];
return spf.length > 1 ? {
id: "dns.spf_multiple", category: "dns", severity: "critical",
title: "Multiple SPF records were found",
summary: "Multiple v=spf1 records are invalid because receivers cannot combine them reliably.",
evidence: ["dns.spf"],
remediation: "Merge the mechanisms into one SPF record and remove the duplicates.",
scoreImpact: -10,
} : null;
},
},
{
id: "dns.spf_permissive",
evaluate: ({ dns }) => {
const spf = Array.isArray(dns.spf) ? dns.spf.filter(isSpfRecord) : [];
const posture = summarizeSpf(spf);
return posture.status === "permissive" ? {
id: "dns.spf_permissive", category: "dns", severity: "warning",
title: "SPF authorizes every sender",
summary: posture.reason,
evidence: ["dns.spf", "dns.spfPosture"],
remediation: "Replace +all with a sender policy that ends in -all after validating legitimate sources.",
scoreImpact: -5,
} : null;
},
},
{
id: "dns.dmarc_missing",
evaluate: ({ dns }) => {
const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc : [];
return hasMailService(dns) && !dmarc.length && !probeFailed(dns, "DMARC") ? {
id: "dns.dmarc_missing", category: "dns", severity: "warning",
title: "Mail domain has no DMARC record",
summary: "MX records exist but no _dmarc TXT record was found.",
evidence: ["dns.mx", "dns.dmarc"],
remediation: "Publish a DMARC policy and validate legitimate senders first.",
scoreImpact: -4,
} : null;
},
},
{
id: "dns.dmarc_multiple",
evaluate: ({ dns }) => {
const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc : [];
return dmarc.length > 1 ? {
id: "dns.dmarc_multiple", category: "dns", severity: "warning",
title: "Multiple DMARC records were found",
summary: "Multiple DMARC records make policy selection ambiguous.",
evidence: ["dns.dmarc"],
remediation: "Publish one authoritative DMARC record at _dmarc.",
scoreImpact: -6,
} : null;
},
},
{
id: "dns.dmarc_invalid",
evaluate: ({ dns }) => {
const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc.filter((value): value is string => typeof value === "string") : [];
return dmarc.length === 1 && !parseDmarcRecord(dmarc[0]).valid ? {
id: "dns.dmarc_invalid", category: "dns", severity: "warning",
title: "DMARC record is invalid",
summary: "The DMARC record is malformed or does not contain a supported p= policy.",
evidence: ["dns.dmarc"],
remediation: "Correct the DMARC version, policy and tag syntax.",
scoreImpact: -6,
} : null;
},
},
{
id: "dns.dmarc_monitor",
evaluate: ({ dns }) => {
const dmarc = Array.isArray(dns.dmarc) ? dns.dmarc.filter((value): value is string => typeof value === "string") : [];
const parsed = dmarc.length === 1 ? parseDmarcRecord(dmarc[0]) : null;
return parsed && parsed.valid && (parsed.policy === "none" || parsed.pct === 0) ? {
id: "dns.dmarc_monitor", category: "dns", severity: "info",
title: "DMARC is not fully enforcing",
summary: parsed.policy === "none" ? "DMARC uses p=none, so failing mail is monitored rather than rejected or quarantined." : "DMARC uses pct=0, so the policy applies to no messages.",
evidence: ["dns.dmarc"],
remediation: "Move to quarantine or reject after validating legitimate senders.",
scoreImpact: 0,
} : null;
},
},
{
id: "dns.mx_invalid",
evaluate: ({ dns }) => hasInvalidMx(dns) ? {
id: "dns.mx_invalid", category: "dns", severity: "warning",
title: "MX record data is malformed",
summary: "At least one MX answer has an invalid exchange or priority value.",
evidence: ["dns.mx"],
remediation: "Publish valid MX records, or use a null MX with exchange '.' and priority 0 when mail is not accepted.",
scoreImpact: -6,
} : null,
},
];
const severityRank: Record<Finding["severity"], number> = { critical: 0, warning: 1, info: 2, good: 3 };
export function evaluateDiagnostics(
dns: Record<string, unknown>,
tls: Record<string, unknown>,
http: Record<string, unknown>,
): Finding[] {
const context = { dns, tls, http };
return DIAGNOSTIC_RULES
.map((rule) => rule.evaluate(context))
.filter((value): value is Finding => Boolean(value))
.sort((left, right) => severityRank[left.severity] - severityRank[right.severity] || left.scoreImpact - right.scoreImpact || left.id.localeCompare(right.id));
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { collectDns, isAbsentDnsError, parseDmarcRecord, summarizeDmarc, summarizeSpf, type ResolverFactory, type ResolverLike } from "./dns";
function codedError(code: string) {
return Object.assign(new Error(code), { code });
}
describe("DNS error and family handling", () => {
it.each(["ENODATA", "ENOTFOUND", "EAI_NONAME"])('treats %s as legitimate record absence', (code) => {
expect(isAbsentDnsError({ code })).toBe(true);
});
it.each(["ESERVFAIL", "ETIMEOUT", "EREFUSED"])('treats %s as a probe failure', (code) => {
expect(isAbsentDnsError({ code })).toBe(false);
});
it("keeps IPv4 evidence when the IPv6 family times out", async () => {
const resolverFactory: ResolverFactory = () => ({
resolve4: (async () => ["1.1.1.1"]) as unknown as ResolverLike["resolve4"],
resolve6: (async () => { throw codedError("ETIMEOUT"); }) as ResolverLike["resolve6"],
resolveMx: (async () => []) as ResolverLike["resolveMx"],
resolveNs: (async () => ["ns.example.com"]) as ResolverLike["resolveNs"],
resolveTxt: (async () => [["v=spf1 -all"]]) as ResolverLike["resolveTxt"],
cancel: () => undefined,
});
const result = await collectDns("example.com", { resolverFactory });
expect(result.a).toEqual(["1.1.1.1"]);
expect(result.aaaa).toEqual([]);
expect(result.addresses).toEqual(["1.1.1.1"]);
expect(result.families).toMatchObject({
ipv4: { status: "ok", addresses: ["1.1.1.1"], error: null },
ipv6: { status: "failed", addresses: [] },
});
expect(result._meta.status).toBe("partial");
expect(result._meta.partial).toBe(true);
expect(result._meta.errors).toEqual(expect.arrayContaining([expect.objectContaining({ probe: "AAAA" })]));
});
it("represents an all-family failure as failed without fabricating addresses", async () => {
const resolverFactory: ResolverFactory = () => ({
resolve4: (async () => { throw codedError("ESERVFAIL"); }) as ResolverLike["resolve4"],
resolve6: (async () => { throw codedError("ETIMEOUT"); }) as ResolverLike["resolve6"],
resolveMx: (async () => []) as ResolverLike["resolveMx"],
resolveNs: (async () => []) as ResolverLike["resolveNs"],
resolveTxt: (async () => { throw codedError("ESERVFAIL"); }) as ResolverLike["resolveTxt"],
cancel: () => undefined,
});
const result = await collectDns("example.com", { resolverFactory });
expect(result.a).toEqual([]);
expect(result.aaaa).toEqual([]);
expect(result.families.ipv4.status).toBe("failed");
expect(result.families.ipv6.status).toBe("failed");
expect(result._meta.status).toBe("failed");
expect(result._meta.partial).toBe(false);
});
});
describe("mail record parsing", () => {
it("parses enforcing and monitor-only DMARC policies", () => {
expect(parseDmarcRecord("v=DMARC1; p=reject; pct=50")).toMatchObject({ valid: true, policy: "reject", pct: 50 });
expect(summarizeDmarc(["v=DMARC1; p=none"])).toMatchObject({ status: "monitor", policy: "none" });
expect(summarizeDmarc(["v=DMARC1; p=quarantine"])).toMatchObject({ status: "quarantine", policy: "quarantine" });
});
it("rejects malformed or duplicate DMARC records", () => {
expect(parseDmarcRecord("v=DMARC1; p=invalid").valid).toBe(false);
expect(summarizeDmarc(["v=DMARC1; p=reject", "v=DMARC1; p=none"]).status).toBe("invalid");
});
it("describes only the visible terminal SPF all mechanism", () => {
expect(summarizeSpf(["v=spf1 include:_spf.example -all"])).toMatchObject({ status: "hardfail", allQualifier: "-" });
expect(summarizeSpf(["v=spf1 ~all"])).toMatchObject({ status: "softfail", allQualifier: "~" });
expect(summarizeSpf(["v=spf1 ?all"])).toMatchObject({ status: "neutral", allQualifier: "?" });
expect(summarizeSpf(["v=spf1 +all"])).toMatchObject({ status: "permissive", allQualifier: "+" });
expect(summarizeSpf(["v=spf1 mx"])).toMatchObject({ status: "incomplete" });
expect(summarizeSpf(["v=spf1 -all include:_spf.example"])).toMatchObject({ status: "invalid" });
});
});
+186
View File
@@ -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 },
};
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { analyzeHeaders, frameProtectionStatus, isNosniff, parseCsp, parseFrameProtection, parseHsts, parseReferrerPolicy, referrerPolicyStatus } from "./headers";
describe("security header parsing", () => {
it("distinguishes missing, disabled, weak and strong HSTS", () => {
expect(parseHsts(null).status).toBe("missing");
expect(parseHsts("max-age=0").status).toBe("disabled");
expect(parseHsts("max-age=3600").status).toBe("weak");
expect(parseHsts("max-age=31536000; includeSubDomains; preload")).toMatchObject({
status: "strong",
maxAge: 31_536_000,
includeSubDomains: true,
preload: true,
});
expect(parseHsts("max-age=abc").status).toBe("invalid");
expect(parseHsts("max-age=10; max-age=20").status).toBe("invalid");
expect(parseHsts("max-age=31536000; includeSubDomains=true").status).toBe("invalid");
});
it("evaluates CSP sources in their effective directive context", () => {
expect(parseCsp(null).status).toBe("missing");
expect(parseCsp("default-src *").status).toBe("weak");
expect(parseCsp("default-src https://*").wildcard).toBe(true);
expect(parseCsp("default-src https:").wildcard).toBe(true);
expect(parseCsp("default-src 'self'; script-src 'self' 'unsafe-eval'").unsafeEval).toBe(true);
expect(parseCsp("default-src 'self'; script-src 'self' 'unsafe-inline'").status).toBe("weak");
expect(parseCsp("default-src 'self'; script-src data:; object-src 'none'; base-uri 'self'").status).toBe("weak");
expect(parseCsp("default-src 'self'; img-src data: blob:; object-src 'none'; base-uri 'self'").status).toBe("strong");
expect(parseCsp("default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'").status).toBe("strong");
});
it("keeps only the first duplicate CSP directive as browsers do", () => {
const csp = parseCsp("default-src 'self'; object-src 'none'; base-uri 'self'; script-src 'self'; script-src 'unsafe-eval'");
expect(csp.directives["script-src"]).toEqual(["'self'"]);
expect(csp.duplicates).toEqual(["script-src"]);
expect(csp.unsafeEval).toBe(false);
expect(csp.status).toBe("strong");
});
it("evaluates value semantics and modern frame protection", () => {
expect(isNosniff("NoSniff")).toBe(true);
expect(isNosniff("text/plain")).toBe(false);
expect(referrerPolicyStatus("strict-origin-when-cross-origin")).toBe("good");
expect(referrerPolicyStatus("unsafe-url")).toBe("weak");
expect(referrerPolicyStatus("not-a-policy")).toBe("invalid");
expect(frameProtectionStatus({ "content-security-policy": "default-src 'self'; frame-ancestors 'none'" })).toBe("strong");
expect(frameProtectionStatus({ "x-frame-options": "SAMEORIGIN" })).toBe("good");
expect(frameProtectionStatus({ "x-frame-options": "ALLOW-FROM https://example.com" })).toBe("invalid");
expect(frameProtectionStatus({})).toBe("missing");
});
it("uses the last recognized Referrer-Policy fallback value", () => {
expect(parseReferrerPolicy("no-referrer, strict-origin-when-cross-origin")).toMatchObject({ effective: "strict-origin-when-cross-origin", status: "good" });
expect(parseReferrerPolicy("future-policy, no-referrer")).toMatchObject({ effective: "no-referrer", status: "strong" });
expect(parseReferrerPolicy("strict-origin, future-policy")).toMatchObject({ effective: "strict-origin", status: "good" });
expect(parseReferrerPolicy(" unsafe-url , unknown ")).toMatchObject({ effective: "unsafe-url", status: "weak" });
expect(parseReferrerPolicy("future-policy")).toMatchObject({ effective: null, status: "invalid" });
});
it("distinguishes broad and restrictive frame-ancestor policies", () => {
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors 'self'" }).status).toBe("strong");
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors https://portal.example" }).status).toBe("good");
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors https:" }).status).toBe("weak");
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors *" }).status).toBe("weak");
expect(parseFrameProtection({ "content-security-policy": "frame-ancestors 'none' https://portal.example" }).status).toBe("invalid");
});
it("returns a complete normalized posture object", () => {
const posture = analyzeHeaders({
"strict-transport-security": "max-age=31536000",
"content-security-policy": "default-src 'self'; object-src 'none'; base-uri 'self'",
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"x-frame-options": "DENY",
});
expect(posture.hsts.status).toBe("strong");
expect(posture.csp.status).toBe("strong");
expect(posture.nosniff.status).toBe("strong");
expect(posture.frameProtection.status).toBe("strong");
expect(posture.aggregate).toMatchObject({ healthy: 5, attention: 0, label: "5 / 5 controls healthy" });
});
});
+259
View File
@@ -0,0 +1,259 @@
export type HeaderValue = string | string[] | null | undefined;
export type HeaderMap = Record<string, HeaderValue>;
const DAY_SECONDS = 86_400;
const STRONG_HSTS_SECONDS = 31_536_000;
export type HeaderControlId = "hsts" | "csp" | "nosniff" | "referrer-policy" | "frame-protection";
export type HeaderControlStatus = "strong" | "good" | "weak" | "missing" | "invalid" | "unavailable";
export type HeaderControlPosture = {
id: HeaderControlId;
status: HeaderControlStatus;
reason: string;
};
export type HeaderAggregate = {
evidenceAvailable: boolean;
healthy: number;
attention: number;
unavailable: number;
total: number;
label: string;
};
function firstHeader(value: HeaderValue) {
if (Array.isArray(value)) return value.join(", ");
return typeof value === "string" ? value : null;
}
function directiveMap(value: string) {
const directives = new Map<string, string | null>();
const duplicate = new Set<string>();
for (const raw of value.split(";")) {
const part = raw.trim();
if (!part) continue;
const [name, ...rawValue] = part.split("=");
const normalizedName = name.trim().toLowerCase();
if (!/^[a-z][a-z0-9-]*$/.test(normalizedName)) return { directives, duplicate, invalid: true };
if (directives.has(normalizedName)) {
duplicate.add(normalizedName);
continue;
}
directives.set(normalizedName, rawValue.length ? rawValue.join("=").trim() : null);
}
return { directives, duplicate, invalid: false };
}
export type HstsPosture = {
status: "missing" | "invalid" | "disabled" | "weak" | "strong";
maxAge: number | null;
includeSubDomains: boolean;
preload: boolean;
reason: string;
};
export function parseHsts(value: HeaderValue): HstsPosture {
const raw = firstHeader(value);
if (!raw) return { status: "missing", maxAge: null, includeSubDomains: false, preload: false, reason: "Header is missing." };
const parsed = directiveMap(raw);
const maxAgeRaw = parsed.directives.get("max-age");
const maxAge = maxAgeRaw !== null && maxAgeRaw !== undefined && /^\d+$/.test(maxAgeRaw) ? Number(maxAgeRaw) : null;
const invalid = parsed.invalid || parsed.duplicate.has("max-age") || maxAge === null || !Number.isSafeInteger(maxAge)
|| (parsed.directives.has("includesubdomains") && parsed.directives.get("includesubdomains") !== null)
|| (parsed.directives.has("preload") && parsed.directives.get("preload") !== null);
const includeSubDomains = parsed.directives.has("includesubdomains");
const preload = parsed.directives.has("preload");
if (invalid) return { status: "invalid", maxAge, includeSubDomains, preload, reason: "max-age must be a single non-negative integer." };
if (maxAge === 0) return { status: "disabled", maxAge, includeSubDomains, preload, reason: "max-age=0 disables HSTS." };
if (maxAge < DAY_SECONDS) return { status: "weak", maxAge, includeSubDomains, preload, reason: "The policy expires in less than one day." };
if (maxAge < STRONG_HSTS_SECONDS) return { status: "weak", maxAge, includeSubDomains, preload, reason: "The policy is shorter than the recommended one-year duration." };
return { status: "strong", maxAge, includeSubDomains, preload, reason: "Long-lived HSTS policy observed." };
}
export type CspPosture = {
status: "missing" | "invalid" | "weak" | "strong";
directives: Record<string, string[]>;
duplicates: string[];
wildcard: boolean;
unsafeEval: boolean;
unsafeInline: boolean;
frameAncestors: string[];
reason: string;
};
const HIGH_RISK_DIRECTIVES = new Set(["default-src", "script-src", "script-src-elem", "object-src", "base-uri"]);
const BROAD_SOURCES = new Set(["*", "http:", "https:", "http://*", "https://*"]);
function sourceIsBroad(source: string, directive: string) {
if (BROAD_SOURCES.has(source)) return true;
return HIGH_RISK_DIRECTIVES.has(directive) && (source === "data:" || source === "blob:");
}
/**
* Parses one CSP policy. Browser CSP keeps the first occurrence of a duplicate
* directive, so later duplicates are retained only as configuration evidence.
*/
export function parseCsp(value: HeaderValue): CspPosture {
const raw = firstHeader(value);
if (!raw) return { status: "missing", directives: {}, duplicates: [], wildcard: false, unsafeEval: false, unsafeInline: false, frameAncestors: [], reason: "Header is missing." };
const directives: Record<string, string[]> = {};
const duplicates = new Set<string>();
let malformed = false;
for (const rawDirective of raw.split(";")) {
const tokens = rawDirective.trim().split(/\s+/).filter(Boolean);
if (!tokens.length) continue;
if (!/^[a-z][a-z0-9-]*$/i.test(tokens[0])) {
malformed = true;
continue;
}
const name = tokens.shift()!.toLowerCase();
if (name in directives) {
duplicates.add(name);
continue;
}
directives[name] = tokens.map((token) => token.toLowerCase());
}
const effectiveScript = directives["script-src"] ?? directives["default-src"] ?? [];
const effectiveScriptElement = directives["script-src-elem"] ?? effectiveScript;
const unsafeEval = effectiveScript.includes("'unsafe-eval'") || effectiveScriptElement.includes("'unsafe-eval'");
const unsafeInline = effectiveScript.includes("'unsafe-inline'") || effectiveScriptElement.includes("'unsafe-inline'");
const broadDirectives = Object.entries(directives).filter(([name, sources]) =>
sources.some((source) => sourceIsBroad(source, name)),
);
const wildcard = broadDirectives.length > 0;
const frameAncestors = directives["frame-ancestors"] ?? [];
const missingObjectSrc = !directives["object-src"]?.includes("'none'");
const missingBaseUri = !directives["base-uri"]?.includes("'self'") && !directives["base-uri"]?.includes("'none'");
const weak = wildcard || unsafeEval || unsafeInline || missingObjectSrc || missingBaseUri;
const reason = malformed
? "The policy contains malformed directives."
: unsafeEval
? "The effective script policy permits unsafe-eval."
: unsafeInline
? "The effective script policy permits unsafe-inline."
: wildcard
? "A high-impact directive permits broad sources."
: missingObjectSrc
? "object-src 'none' is missing."
: missingBaseUri
? "base-uri 'self' or 'none' is missing."
: "Restrictive policy observed.";
return { status: malformed ? "invalid" : weak ? "weak" : "strong", directives, duplicates: [...duplicates], wildcard, unsafeEval, unsafeInline, frameAncestors, reason };
}
export type ReferrerPolicyPosture = HeaderControlPosture & { effective: string | null };
const STRONG_REFERRER_POLICIES = new Set(["no-referrer", "same-origin"]);
const GOOD_REFERRER_POLICIES = new Set(["strict-origin", "strict-origin-when-cross-origin"]);
const WEAK_REFERRER_POLICIES = new Set(["origin", "origin-when-cross-origin", "no-referrer-when-downgrade", "unsafe-url"]);
export function parseReferrerPolicy(value: HeaderValue): ReferrerPolicyPosture {
const raw = firstHeader(value);
if (!raw?.trim()) return { id: "referrer-policy", status: "missing", effective: null, reason: "Header is missing." };
const policies = raw.split(",").map((policy) => policy.trim().toLowerCase()).filter(Boolean);
const recognized = policies.filter((policy) => STRONG_REFERRER_POLICIES.has(policy) || GOOD_REFERRER_POLICIES.has(policy) || WEAK_REFERRER_POLICIES.has(policy));
const effective = recognized.at(-1) ?? null;
if (!effective) return { id: "referrer-policy", status: "invalid", effective: null, reason: "No supported Referrer-Policy value was observed." };
if (STRONG_REFERRER_POLICIES.has(effective)) return { id: "referrer-policy", status: "strong", effective, reason: "The effective policy minimizes referrer disclosure." };
if (GOOD_REFERRER_POLICIES.has(effective)) return { id: "referrer-policy", status: "good", effective, reason: "The effective policy limits cross-origin referrer detail." };
return { id: "referrer-policy", status: "weak", effective, reason: effective === "unsafe-url" ? "The effective policy may disclose full URLs cross-origin." : "The effective policy permits more referrer detail than strict-origin policies." };
}
/** Backwards-compatible concise status for rule callers. */
export function referrerPolicyStatus(value: HeaderValue) {
return parseReferrerPolicy(value).status;
}
export type FrameProtectionPosture = HeaderControlPosture & { source: "csp" | "x-frame-options" | "none" };
function frameAncestorsPosture(csp: CspPosture): FrameProtectionPosture | null {
if (!("frame-ancestors" in csp.directives)) return null;
const sources = csp.frameAncestors;
if (!sources.length) return { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors has no usable source expression." };
if (sources.includes("*")) return { id: "frame-protection", status: "weak", source: "csp", reason: "frame-ancestors permits every embedding origin." };
if (sources.some((source) => source === "http:" || source === "https:" || source === "data:" || source === "blob:" || source.includes("*"))) {
return { id: "frame-protection", status: "weak", source: "csp", reason: "frame-ancestors permits a broad scheme or wildcard origin." };
}
if (sources.includes("'none'")) return sources.length === 1
? { id: "frame-protection", status: "strong", source: "csp", reason: "frame-ancestors 'none' blocks framing." }
: { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors 'none' cannot be combined with other sources." };
if (sources.includes("'self'")) return { id: "frame-protection", status: "strong", source: "csp", reason: "frame-ancestors restricts framing to the same origin." };
const explicitOrigins = sources.every((source) => /^https?:\/\/[a-z0-9.-]+(?::\d+)?$/i.test(source));
return explicitOrigins
? { id: "frame-protection", status: "good", source: "csp", reason: "frame-ancestors lists explicit embedding origins." }
: { id: "frame-protection", status: "invalid", source: "csp", reason: "frame-ancestors contains an unsupported source expression." };
}
export function parseFrameProtection(headers: HeaderMap): FrameProtectionPosture {
const csp = parseCsp(headers["content-security-policy"]);
const cspPosture = frameAncestorsPosture(csp);
if (cspPosture) return cspPosture;
const xFrame = firstHeader(headers["x-frame-options"])?.trim().toLowerCase();
if (xFrame === "deny") return { id: "frame-protection", status: "strong", source: "x-frame-options", reason: "X-Frame-Options: DENY blocks framing." };
if (xFrame === "sameorigin") return { id: "frame-protection", status: "good", source: "x-frame-options", reason: "X-Frame-Options restricts framing to the same origin." };
if (xFrame) return { id: "frame-protection", status: "invalid", source: "x-frame-options", reason: "X-Frame-Options has an unsupported value." };
return { id: "frame-protection", status: "missing", source: "none", reason: "No frame-ancestors policy or X-Frame-Options protection was observed." };
}
/** Backwards-compatible concise status for rule callers. */
export function frameProtectionStatus(headers: HeaderMap) {
return parseFrameProtection(headers).status;
}
function hstsControl(hsts: HstsPosture): HeaderControlPosture {
const status: HeaderControlStatus = hsts.status === "disabled" ? "weak" : hsts.status;
return { id: "hsts", status, reason: hsts.reason };
}
function nosniffControl(value: HeaderValue): HeaderControlPosture {
const raw = firstHeader(value);
if (!raw?.trim()) return { id: "nosniff", status: "missing", reason: "Header is missing." };
return raw.trim().toLowerCase() === "nosniff"
? { id: "nosniff", status: "strong", reason: "The required nosniff value was observed." }
: { id: "nosniff", status: "invalid", reason: "The header does not use the required nosniff value." };
}
export function isNosniff(value: HeaderValue) {
return nosniffControl(value).status === "strong";
}
function cspControl(csp: CspPosture): HeaderControlPosture {
return { id: "csp", status: csp.status, reason: csp.reason };
}
export function summarizeHeaderControls(controls: HeaderControlPosture[]): HeaderAggregate {
const healthy = controls.filter((control) => control.status === "strong" || control.status === "good").length;
const unavailable = controls.filter((control) => control.status === "unavailable").length;
const attention = controls.length - healthy - unavailable;
const evidenceAvailable = unavailable !== controls.length;
const label = !evidenceAvailable
? "Evidence unavailable"
: unavailable
? `${healthy} / ${controls.length - unavailable} controls healthy · evidence incomplete`
: attention
? `${attention} control${attention === 1 ? " needs" : "s need"} attention`
: `${healthy} / ${controls.length} controls healthy`;
return { evidenceAvailable, healthy, attention, unavailable, total: controls.length, label };
}
export function unavailableHeaderPosture() {
const controls: HeaderControlPosture[] = ["hsts", "csp", "nosniff", "referrer-policy", "frame-protection"].map((id) => ({
id: id as HeaderControlId,
status: "unavailable",
reason: "No terminal HTTPS response was available for evaluation.",
}));
return { controls, aggregate: summarizeHeaderControls(controls) };
}
export function analyzeHeaders(headers: HeaderMap) {
const hsts = parseHsts(headers["strict-transport-security"]);
const csp = parseCsp(headers["content-security-policy"]);
const nosniff = nosniffControl(headers["x-content-type-options"]);
const referrerPolicy = parseReferrerPolicy(headers["referrer-policy"]);
const frameProtection = parseFrameProtection(headers);
const controls = [hstsControl(hsts), cspControl(csp), nosniff, referrerPolicy, frameProtection];
return { hsts, csp, nosniff, referrerPolicy, frameProtection, controls, aggregate: summarizeHeaderControls(controls) };
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { statusForHttp } from "./http-status";
describe("HTTP status semantics", () => {
it.each([
[100, "Informational response", "muted"],
[200, "Healthy response", "green"],
[302, "Redirect response", "muted"],
[404, "Client error", "amber"],
[503, "Server error", "red"],
] as const)("classifies %s without misleading status colors", (status, label, tone) => {
expect(statusForHttp(status)).toEqual({ label, tone });
});
it.each([null, undefined, 99, 600, 200.5, "200"])("treats %s as incomplete", (status) => {
expect(statusForHttp(status).label).toBe("Incomplete");
expect(statusForHttp(status).tone).toBe("muted");
});
});
+17
View File
@@ -0,0 +1,17 @@
export type HttpStatusTone = "green" | "amber" | "red" | "muted";
export type HttpStatusSemantics = {
label: "Incomplete" | "Informational response" | "Healthy response" | "Redirect response" | "Client error" | "Server error";
tone: HttpStatusTone;
};
export function statusForHttp(status: unknown): HttpStatusSemantics {
if (typeof status !== "number" || !Number.isInteger(status) || status < 100 || status > 599) {
return { label: "Incomplete", tone: "muted" };
}
if (status < 200) return { label: "Informational response", tone: "muted" };
if (status < 300) return { label: "Healthy response", tone: "green" };
if (status < 400) return { label: "Redirect response", tone: "muted" };
if (status < 500) return { label: "Client error", tone: "amber" };
return { label: "Server error", tone: "red" };
}
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from "vitest";
import { followRedirectChain, requestOnce, validateProbeUrl } from "./http";
const response = (url: string, status: number, location: string | null = null) => ({ url, status, location, headers: {} });
const publicResolution = (addresses: string[]) => ({ addresses, families: { ipv4: { addresses, status: "ok" as const, error: null }, ipv6: { addresses: [], status: "absent" as const, error: null } }, _meta: { status: "ok" as const, partial: false, errors: [] } });
describe("HTTP target policy", () => {
it.each(["ftp://example.com/", "https://user:pass@example.com/", "https://example.com:8443/", "https://127.0.0.1/"])('rejects unsafe target %s', (value) => {
expect(() => validateProbeUrl(new URL(value))).toThrow();
});
it("accepts standard public-host HTTP(S) targets", () => {
expect(() => validateProbeUrl(new URL("https://example.com/path?q=1"))).not.toThrow();
});
});
describe("redirect chain", () => {
it("follows bounded valid redirects with an injected transport", async () => {
const requester = async (url: URL) => url.pathname === "/"
? response(url.toString(), 302, "/final")
: response(url.toString(), 200);
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
expect(chain).toHaveLength(2);
expect(chain[1].status).toBe(200);
});
it("records relative and protocol-relative redirects", async () => {
const requester = async (url: URL) => url.pathname === "/" ? response(url.toString(), 301, "//example.com/final") : response(url.toString(), 204);
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
expect(chain.at(-1)?.status).toBe(204);
});
it("blocks HTTPS to HTTP downgrades before requesting the target", async () => {
const requested: string[] = [];
const requester = async (url: URL) => { requested.push(url.toString()); return response(url.toString(), 302, "http://example.com/"); };
const chain = await followRedirectChain(new URL("https://example.com/"), requester);
expect(chain.at(-1)).toMatchObject({ code: "HTTPS_DOWNGRADE" });
expect(requested).toEqual(["https://example.com/"]);
});
it("detects self-loops and multi-hop normalized loops", async () => {
const self = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 301, url.toString()));
expect(self.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
const cycle = await followRedirectChain(new URL("https://example.com/a"), async (url) => url.pathname === "/a" ? response(url.toString(), 302, "/b") : response(url.toString(), 302, "/a"));
expect(cycle.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
const equivalent = await followRedirectChain(new URL("https://EXAMPLE.com./"), async (url) => response(url.toString(), 302, "https://example.com/"));
expect(equivalent.at(-1)).toMatchObject({ code: "REDIRECT_LOOP" });
});
it("records unsafe redirects and redirect budget exhaustion", async () => {
const unsafe = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "http://127.0.0.1/admin"));
expect(unsafe.at(-1)).toMatchObject({ error: expect.any(String), code: "REDIRECT_TARGET_BLOCKED" });
const limited = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "/again"), 1);
expect(limited.at(-1)).toMatchObject({ code: "REDIRECT_LIMIT" });
});
it("redacts redirect userinfo from error evidence", async () => {
const chain = await followRedirectChain(new URL("https://example.com/"), async (url) => response(url.toString(), 302, "https://user:secret@example.com/private"));
expect(chain.at(-1)).toMatchObject({ code: "REDIRECT_TARGET_BLOCKED" });
expect(JSON.stringify(chain)).not.toContain("secret");
});
it("enforces the total deadline across slow hops", async () => {
const started = Date.now();
const chain = await followRedirectChain(new URL("https://example.com/"), async (url) => {
await new Promise((resolve) => setTimeout(resolve, 50));
return response(url.toString(), 200);
}, 3, Date.now() + 10);
expect(chain.at(-1)?.error).toBe("HTTP request timed out.");
expect(Date.now() - started).toBeLessThan(100);
});
});
describe("multi-address HTTP strategy", () => {
it("tries the next validated public address after a transport failure", async () => {
const attempts: string[] = [];
const result = await requestOnce(new URL("https://example.com/"), Date.now() + 1_000, {
resolve: async () => publicResolution(["1.1.1.1", "2.2.2.2"]),
request: async (url, address) => {
attempts.push(address);
if (address === "1.1.1.1") throw Object.assign(new Error("refused"), { code: "ECONNREFUSED" });
return response(url.toString(), 200);
},
});
expect(result).toMatchObject({ status: 200, address: "2.2.2.2" });
expect(attempts).toEqual(["1.1.1.1", "2.2.2.2"]);
expect(result.attempts).toEqual([{ address: "1.1.1.1", error: "HTTP request failed (ECONNREFUSED)." }]);
});
it("rejects an unsafe address returned by a resolver before any request", async () => {
const attempted: string[] = [];
await expect(requestOnce(new URL("https://example.com/"), Date.now() + 1_000, {
resolve: async () => publicResolution(["1.1.1.1", "127.0.0.1"]),
request: async (_url, address) => { attempted.push(address); return response("https://example.com/", 200); },
})).rejects.toThrow(/non-public address space/);
expect(attempted).toEqual([]);
});
});
+293
View File
@@ -0,0 +1,293 @@
import http from "node:http";
import https from "node:https";
import { analyzeHeaders, type HeaderMap } from "./headers";
import { createDeadline, remainingMs, timeoutError, withDeadline } from "./deadline";
import { InspectionInputError, InspectionPolicyError, InspectionResolutionError, normalizeHost, resolvePublicHostDetailed, validateResolvedAddresses } from "./policy";
import { CERTSCOPE_VERSION } from "../version";
const HEADER_NAMES = [
"strict-transport-security",
"content-security-policy",
"x-content-type-options",
"referrer-policy",
"x-frame-options",
"server",
"x-powered-by",
];
export const HTTP_TIMEOUT_MS = 5_000;
export const HTTP_TOTAL_TIMEOUT_MS = 15_000;
export const DEFAULT_MAX_REDIRECTS = 3;
export const MAX_ADDRESS_ATTEMPTS = 6;
export const HTTPS_DOWNGRADE_ERROR = "HTTPS redirect downgrade is not allowed.";
export const REDIRECT_LOOP_ERROR = "Redirect loop detected.";
export type HttpHop = Record<string, unknown>;
type Requester = (url: URL, deadline: number) => Promise<HttpHop>;
export type CandidateRequester = (url: URL, address: string, deadline: number) => Promise<HttpHop>;
export interface HttpRequestOptions {
resolve?: typeof resolvePublicHostDetailed;
request?: CandidateRequester;
}
function safeUrl(value: string | URL) {
const text = typeof value === "string" ? value : value.toString();
try {
const parsed = new URL(text);
parsed.username = "";
parsed.password = "";
return parsed.toString();
} catch {
return text.replace(/(\/\/)[^/\s@]+@/g, "$1[redacted]@");
}
}
function sanitizedHop(hop: HttpHop): HttpHop {
return typeof hop.location === "string" ? { ...hop, location: safeUrl(hop.location) } : hop;
}
export function validateProbeUrl(url: URL) {
if (!["http:", "https:"].includes(url.protocol)) throw new InspectionPolicyError("Redirect used an unsupported protocol.");
if (url.username || url.password) throw new InspectionPolicyError("Redirect URLs with credentials are not allowed.");
if (url.port && url.port !== (url.protocol === "https:" ? "443" : "80")) {
throw new InspectionPolicyError("Redirect to a non-default port is not allowed.");
}
normalizeHost(url.hostname);
}
function errorCode(error: unknown) {
return typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code ?? "")
: "";
}
function transportError(error: unknown) {
if (error instanceof InspectionInputError || error instanceof InspectionPolicyError || error instanceof InspectionResolutionError) return error.message;
if (errorCode(error) === "ETIMEOUT") return "HTTP request timed out.";
const code = errorCode(error);
return code ? "HTTP request failed (" + code + ")." : "HTTP request failed.";
}
function requestCandidate(url: URL, address: string, deadline: number): Promise<HttpHop> {
const budget = Math.min(HTTP_TIMEOUT_MS, remainingMs(deadline));
if (!budget) return Promise.reject(timeoutError("HTTP request timed out."));
const host = normalizeHost(url.hostname);
const client = url.protocol === "https:" ? https : http;
return new Promise((resolve, reject) => {
let settled = false;
// The request constructor can throw before a timer exists; the callback
// therefore needs an intentionally late-bound timer reference.
// eslint-disable-next-line prefer-const
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
callback();
};
let request: ReturnType<typeof client.request>;
try {
request = client.request({
hostname: address,
port: url.protocol === "https:" ? 443 : 80,
path: url.pathname + url.search,
method: "GET",
servername: url.protocol === "https:" ? host : undefined,
rejectUnauthorized: url.protocol === "https:" ? true : undefined,
headers: { host, "user-agent": "CertScope/" + CERTSCOPE_VERSION },
timeout: budget,
}, (response) => {
if (settled) {
response.destroy();
return;
}
const headers = Object.fromEntries(
HEADER_NAMES.map((name) => [name, response.headers[name] ?? null]),
) as HeaderMap;
finish(() => {
response.destroy();
resolve({
url: url.toString(),
address,
status: response.statusCode ?? null,
location: response.headers.location ?? null,
headers,
headerPosture: analyzeHeaders(headers),
});
});
});
} catch (error) {
finish(() => reject(error));
return;
}
const abortOnTimeout = () => finish(() => {
request.destroy();
reject(timeoutError("HTTP request timed out."));
});
timer = setTimeout(abortOnTimeout, budget);
request.once("timeout", abortOnTimeout);
request.once("error", (error) => finish(() => reject(error)));
request.end();
});
}
export async function requestOnce(url: URL, deadline = createDeadline(HTTP_TIMEOUT_MS), options: HttpRequestOptions = {}): Promise<HttpHop> {
validateProbeUrl(url);
const resolution = await (options.resolve ?? resolvePublicHostDetailed)(url.hostname, deadline);
const addresses = validateResolvedAddresses(resolution.addresses);
const request = options.request ?? requestCandidate;
const attempts: Array<Record<string, unknown>> = [];
for (const address of addresses.slice(0, MAX_ADDRESS_ATTEMPTS)) {
if (!remainingMs(deadline)) break;
try {
const result = await request(url, address, deadline);
return { ...result, address: result.address ?? address, addresses, resolution: resolution._meta, attempts };
} catch (error) {
attempts.push({ address, error: transportError(error) });
}
}
const message = remainingMs(deadline) ? "HTTP request failed for all resolved addresses." : "HTTP probe deadline exhausted.";
return { url: safeUrl(url), addresses, attempts, error: message, resolution: resolution._meta };
}
async function safeRequest(url: URL, requester: Requester, deadline: number, markPolicyBlock = false): Promise<HttpHop> {
try {
validateProbeUrl(url);
if (!remainingMs(deadline)) return { url: safeUrl(url), error: "HTTP probe deadline exhausted." };
return await withDeadline(requester(url, deadline), deadline, () => undefined, "HTTP request timed out.");
} catch (error) {
return {
url: safeUrl(url),
error: transportError(error),
...(markPolicyBlock && (error instanceof InspectionInputError || error instanceof InspectionPolicyError)
? { code: "REDIRECT_TARGET_BLOCKED" }
: {}),
};
}
}
function normalizedUrlKey(url: URL) {
const host = normalizeHost(url.hostname);
const port = url.port && url.port !== (url.protocol === "https:" ? "443" : "80") ? ":" + url.port : "";
return url.protocol.toLowerCase() + "//" + host + port + (url.pathname || "/") + url.search;
}
export async function followRedirectChain(
start: URL,
requester: Requester = requestOnce,
maxRedirects = DEFAULT_MAX_REDIRECTS,
deadline = createDeadline(HTTP_TOTAL_TIMEOUT_MS),
): Promise<HttpHop[]> {
const chain: HttpHop[] = [];
const redirectLimit = Math.max(0, Math.floor(maxRedirects));
let current = start;
const visited = new Set<string>();
for (let index = 0; index <= redirectLimit; index++) {
let key: string;
try {
key = normalizedUrlKey(current);
} catch (error) {
chain.push({ url: safeUrl(current), error: error instanceof Error ? error.message : "Invalid redirect target." });
break;
}
if (visited.has(key)) {
chain.push({ url: current.toString(), error: REDIRECT_LOOP_ERROR, code: "REDIRECT_LOOP" });
break;
}
visited.add(key);
const hop = await safeRequest(current, requester, deadline, index > 0);
chain.push(sanitizedHop(hop));
if ("error" in hop) break;
const status = Number(hop.status ?? 0);
const location = typeof hop.location === "string" ? hop.location : null;
if (status < 300 || status >= 400 || !location) break;
if (index === redirectLimit) {
chain.push({ url: current.toString(), error: "Redirect limit reached", code: "REDIRECT_LIMIT" });
break;
}
try {
const next = new URL(location, current);
validateProbeUrl(next);
if (current.protocol === "https:" && next.protocol === "http:") {
chain.push({ url: next.toString(), error: HTTPS_DOWNGRADE_ERROR, code: "HTTPS_DOWNGRADE", from: current.toString() });
break;
}
if (visited.has(normalizedUrlKey(next))) {
chain.push({ url: next.toString(), error: REDIRECT_LOOP_ERROR, code: "REDIRECT_LOOP", from: current.toString() });
break;
}
current = next;
} catch (error) {
chain.push({
url: safeUrl(location),
error: error instanceof Error ? error.message : "Invalid redirect target.",
code: error instanceof InspectionInputError || error instanceof InspectionPolicyError ? "REDIRECT_TARGET_BLOCKED" : "INVALID_REDIRECT",
});
break;
}
}
return chain;
}
function errorEvidence(hop: HttpHop, probe: string) {
return "error" in hop ? { probe, message: String(hop.error) } : null;
}
export async function collectHttp(host: string): Promise<Record<string, unknown>> {
const normalizedHost = normalizeHost(host);
const deadline = createDeadline(HTTP_TOTAL_TIMEOUT_MS);
const rawHttpResult = await safeRequest(new URL("http://" + normalizedHost + "/"), requestOnce, deadline);
const httpResult = sanitizedHop(rawHttpResult);
let httpRedirectsToHttps = false;
let httpRedirectTarget: HttpHop | null = null;
if (!("error" in rawHttpResult)) {
const status = Number(rawHttpResult.status ?? 0);
const location = typeof rawHttpResult.location === "string" ? rawHttpResult.location : null;
if (status >= 300 && status < 400 && location) {
try {
const target = new URL(location, new URL("http://" + normalizedHost + "/"));
validateProbeUrl(target);
const resolution = await resolvePublicHostDetailed(target.hostname, deadline);
httpRedirectsToHttps = target.protocol === "https:";
httpRedirectTarget = { url: target.toString(), addresses: resolution.addresses, resolution: resolution._meta, validated: true };
} catch (error) {
httpRedirectTarget = {
url: safeUrl(location),
error: error instanceof Error ? error.message : "Invalid redirect target.",
code: error instanceof InspectionInputError || error instanceof InspectionPolicyError ? "REDIRECT_TARGET_BLOCKED" : "INVALID_REDIRECT",
};
}
}
}
const httpsChain = await followRedirectChain(new URL("https://" + normalizedHost + "/"), requestOnce, DEFAULT_MAX_REDIRECTS, deadline);
const finalHop = httpsChain.at(-1) && !("error" in httpsChain.at(-1)!) ? httpsChain.at(-1)! : null;
const downgrade = httpsChain.some((hop) => hop.code === "HTTPS_DOWNGRADE");
const evidenceErrors = [
errorEvidence(httpResult, "HTTP"),
...(httpRedirectTarget && "error" in httpRedirectTarget
? [{ probe: "HTTP redirect", message: String(httpRedirectTarget.error) }]
: []),
...httpsChain.map((hop) => errorEvidence(hop, "HTTPS")),
].filter((item): item is { probe: string; message: string } => Boolean(item));
const hasSuccessfulObservation = ![httpResult, ...httpsChain].every((hop) => "error" in hop);
const status = evidenceErrors.length ? (hasSuccessfulObservation ? "partial" : "failed") : "ok";
return {
http: httpResult,
httpRedirectsToHttps,
httpRedirectTarget,
httpsChain,
httpsDowngrade: downgrade,
finalUrl: finalHop && typeof finalHop.url === "string" ? finalHop.url : null,
_meta: { status, errors: evidenceErrors },
};
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { buildCompleteness } from "./inspect";
describe("inspection completeness", () => {
it("counts complete collectors and keeps unique issue messages", () => {
const result = buildCompleteness(
{ _meta: { status: "ok", errors: [] } },
{ _meta: { status: "partial", errors: [{ probe: "TLS", message: "timeout" }, { probe: "TLS", message: "timeout" }] } },
{ _meta: { status: "failed", errors: [{ probe: "HTTPS", message: "failed" }] } },
);
expect(result).toEqual({
status: "partial",
complete: 1,
total: 3,
collectors: { dns: "ok", tls: "partial", http: "failed" },
issues: ["timeout", "failed"],
});
});
it("marks a report failed when no collector completed", () => {
expect(buildCompleteness({}, {}, {}).status).toBe("failed");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { collectDns } from "./dns";
import { evaluateDiagnostics } from "./diagnostics";
import { collectTls } from "./tls";
import { collectHttp } from "./http";
import { normalizeHost, validateResolvedAddresses } from "./policy";
import { score } from "./score";
import type { Category, CollectorStatus, InspectionResult } from "./types";
function collectorStatus(value: Record<string, unknown>): CollectorStatus {
const meta = value._meta;
if (!meta || typeof meta !== "object" || typeof (meta as Record<string, unknown>).status !== "string") return "failed";
const status = String((meta as Record<string, unknown>).status);
return status === "ok" || status === "partial" || status === "failed" ? status : "failed";
}
export function buildCompleteness(dns: Record<string, unknown>, tls: Record<string, unknown>, http: Record<string, unknown>) {
const collectors: Record<Category, CollectorStatus> = {
dns: collectorStatus(dns),
tls: collectorStatus(tls),
http: collectorStatus(http),
};
const statuses = Object.values(collectors);
const complete = statuses.filter((status) => status === "ok").length;
const issues = [dns, tls, http].flatMap((collector) => {
const errors = collector._meta && typeof collector._meta === "object" ? (collector._meta as Record<string, unknown>).errors : [];
return Array.isArray(errors) ? errors.map((error) => typeof error === "object" && error !== null && "message" in error ? String((error as { message?: unknown }).message) : String(error)) : [];
});
return {
status: complete === statuses.length ? "complete" as const : complete === 0 ? "failed" as const : "partial" as const,
complete,
total: statuses.length,
collectors,
issues: [...new Set(issues)],
};
}
export async function inspectHost(rawHost: string): Promise<InspectionResult> {
const host = normalizeHost(rawHost);
const dns = await collectDns(host);
const addresses = validateResolvedAddresses([
...(Array.isArray(dns.a) ? dns.a.filter((value): value is string => typeof value === "string") : []),
...(Array.isArray(dns.aaaa) ? dns.aaaa.filter((value): value is string => typeof value === "string") : []),
]);
const [tls, http] = await Promise.all([collectTls(host, addresses), collectHttp(host)]);
const findings = evaluateDiagnostics(dns, tls, http);
return { host, inspectedAt: new Date().toISOString(), addresses, completeness: buildCompleteness(dns, tls, http), dns, tls, http, findings, score: score(findings) };
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { assertPublicAddress, normalizeHost, validateResolvedAddresses } from "./policy";
describe("hostname policy",()=>{
it("normalizes public hostnames",()=>expect(normalizeHost("Example.COM.")).toBe("example.com"));
it("normalizes IDN input to its ASCII form",()=>expect(normalizeHost("münich.example")).toBe("xn--mnich-kva.example"));
it("rejects oversized input before IDN conversion",()=>expect(()=>normalizeHost("a".repeat(254)+".example")).toThrow());
it.each(["https://example.com","localhost","127.0.0.1","::1"])("rejects non-hostname input %s",(value)=>expect(()=>normalizeHost(value)).toThrow());
});
describe("address policy",()=>{
it.each(["127.0.0.1","10.0.0.1","192.168.1.5","169.254.1.1","100.64.0.1","224.0.0.1","::1","fc00::1","fe80::1","::ffff:127.0.0.1"])("rejects non-public address %s",(value)=>expect(()=>assertPublicAddress(value)).toThrow());
it.each(["1.1.1.1","8.8.8.8","2606:4700:4700::1111"])("accepts public unicast address %s",(value)=>expect(()=>assertPublicAddress(value)).not.toThrow());
it("validates every resolved address before returning any candidate", () => {
expect(() => validateResolvedAddresses(["1.1.1.1", "192.168.1.10"])).toThrow(/non-public address space/);
expect(validateResolvedAddresses(["1.1.1.1", "1.1.1.1"])).toEqual(["1.1.1.1"]);
});
});
+72
View File
@@ -0,0 +1,72 @@
import ipaddr from "ipaddr.js";
import { domainToASCII } from "node:url";
import { DNS_TIMEOUT_MS, resolveDnsFamily, type DnsFamilyResult } from "./dns";
import { createDeadline } from "./deadline";
const HOST_RE = /^(?=.{1,253}$)(?!-)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i;
export { DNS_TIMEOUT_MS } from "./dns";
export class InspectionInputError extends Error {
readonly kind = "input" as const;
}
export class InspectionPolicyError extends Error {
readonly kind = "policy" as const;
}
export class InspectionResolutionError extends Error {
readonly kind = "resolution" as const;
}
export function normalizeHost(input: string) {
if (typeof input !== "string") throw new InspectionInputError("Enter a public DNS hostname, not a URL or IP address.");
const trimmed = input.trim();
if (!trimmed || trimmed.length > 253) throw new InspectionInputError("Enter a public DNS hostname, not a URL or IP address.");
const host = domainToASCII(trimmed.toLowerCase().replace(/\.$/, ""));
if (!HOST_RE.test(host) || ipaddr.isValid(host)) {
throw new InspectionInputError("Enter a public DNS hostname, not a URL or IP address.");
}
return host;
}
export function assertPublicAddress(address: string) {
try {
const range = ipaddr.parse(address).range();
if (range !== "unicast") throw new InspectionPolicyError("Target resolved to non-public address space (" + range + ").");
} catch (error) {
if (error instanceof InspectionPolicyError) throw error;
throw new InspectionPolicyError("Target resolved to an invalid address.");
}
}
export function validateResolvedAddresses(addresses: string[]) {
if (!addresses.length) throw new InspectionResolutionError("Hostname did not resolve.");
const normalized = [...new Set(addresses)];
normalized.forEach(assertPublicAddress);
return normalized;
}
export interface PublicResolution {
addresses: string[];
families: { ipv4: DnsFamilyResult; ipv6: DnsFamilyResult };
_meta: { status: "ok" | "partial"; partial: boolean; errors: Array<{ probe: string; message: string }> };
}
export async function resolvePublicHostDetailed(host: string, deadline = createDeadline(DNS_TIMEOUT_MS)): Promise<PublicResolution> {
const normalized = normalizeHost(host);
const [ipv4, ipv6] = await Promise.all([
resolveDnsFamily(normalized, 4, deadline),
resolveDnsFamily(normalized, 6, deadline),
]);
const addresses = validateResolvedAddresses([...ipv4.addresses, ...ipv6.addresses]);
const errors = [ipv4.error, ipv6.error].filter((error): error is { probe: string; message: string } => Boolean(error));
return {
addresses,
families: { ipv4, ipv6 },
_meta: { status: errors.length ? "partial" : "ok", partial: errors.length > 0, errors },
};
}
export async function resolvePublicHost(host: string) {
return (await resolvePublicHostDetailed(host)).addresses;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { demoInspection } from "./demo";
import { inspectionToMarkdown } from "./report";
describe("report export", () => {
it("exports the health score, findings and normalized evidence", () => {
const report = inspectionToMarkdown(demoInspection);
expect(report).toContain("# CertScope report — portfolio-demo.invalid");
expect(report).toContain("Overall health: 98/100");
expect(report).toContain("Score formula: equal-weighted average");
expect(report).toContain("Completeness: complete (3/3 collectors complete)");
expect(report).toContain("- DNS: ok");
expect(report).toContain("Content-Security-Policy is missing");
expect(report).toContain("## DNS evidence");
expect(report).toContain("## TLS evidence");
expect(report).toContain("## HTTP evidence");
});
it("escapes finding text and keeps JSON evidence fences safe", () => {
const report = inspectionToMarkdown({
...demoInspection,
findings: [{
...demoInspection.findings[0],
title: "Injected *title* [value]",
summary: "line one\nline two",
}],
http: { ...demoInspection.http, injected: "```\nnot a markdown fence" },
});
expect(report).toContain("### Injected \\*title\\* \\[value\\]");
expect(report).toContain("- Summary: line one line two");
expect(report).toContain("injected");
expect(report.match(/^`{3,}(json)?$/gm)?.length).toBe(6);
});
});
+56
View File
@@ -0,0 +1,56 @@
import type { InspectionResult } from "./types";
function escapeMarkdown(value: unknown) {
return String(value ?? "")
.replace(/\\/g, "\\\\")
.replace(/[|*_\x60[\]<>]/g, "\\$&")
.replace(/\r?\n/g, " ");
}
function jsonFence(value: unknown) {
const json = JSON.stringify(value, null, 2);
const backticks = Math.max(3, Math.max(...(json.match(/\x60+/g) ?? [""]).map((match) => match.length)) + 1);
const fence = "\x60".repeat(backticks);
return [fence + "json", json, fence];
}
export function inspectionToMarkdown(result: InspectionResult): string {
const lines = [
"# CertScope report — " + escapeMarkdown(result.host),
"",
"- Inspected: " + escapeMarkdown(result.inspectedAt),
"- Addresses: " + escapeMarkdown(result.addresses.join(", ") || "none"),
"- Overall health: " + result.score.overall + "/100",
"- DNS: " + result.score.categories.dns + "/100",
"- TLS: " + result.score.categories.tls + "/100",
"- HTTP: " + result.score.categories.http + "/100",
"- Score formula: equal-weighted average of the DNS, TLS and HTTP category scores",
"- Completeness: " + result.completeness.status + " (" + result.completeness.complete + "/" + result.completeness.total + " collectors complete)",
"",
"## Collector status",
"",
];
for (const [category, status] of Object.entries(result.completeness.collectors)) {
lines.push("- " + category.toUpperCase() + ": " + escapeMarkdown(status));
}
if (result.completeness.issues.length) lines.push("- Collector issues: " + escapeMarkdown(result.completeness.issues.join("; ")));
lines.push("", "## Findings", "");
if (!result.findings.length) lines.push("No findings.", "");
for (const finding of result.findings) {
lines.push("### " + escapeMarkdown(finding.title));
lines.push("- Severity: " + escapeMarkdown(finding.severity));
lines.push("- Category: " + escapeMarkdown(finding.category));
lines.push("- Score impact: " + finding.scoreImpact);
lines.push("- Summary: " + escapeMarkdown(finding.summary));
lines.push("- Evidence: " + escapeMarkdown(finding.evidence.join(", ") || "none"));
if (finding.remediation) lines.push("- Remediation: " + escapeMarkdown(finding.remediation));
lines.push("");
}
for (const [title, evidence] of [["DNS", result.dns], ["TLS", result.tls], ["HTTP", result.http]] as const) {
lines.push("## " + title + " evidence", "", ...jsonFence(evidence), "");
}
return lines.join("\n");
}
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { score } from "./score";
import type { Finding } from "./types";
const finding = (impact: number, category: Finding["category"] = "http"): Finding => ({
id: `${category}-${impact}`,
category,
severity: "warning",
title: "test",
summary: "test",
evidence: [],
scoreImpact: impact,
});
describe("score", () => {
it("uses an equal-weighted aggregate of category scores", () => {
expect(score([finding(-18, "dns"), finding(-20, "tls"), finding(-22, "http")])).toEqual({
overall: 80,
deductions: 20,
categories: { dns: 82, tls: 80, http: 78 },
});
});
it("does not charge category deductions a second time globally", () => {
expect(score([finding(-8), finding(-6, "tls")])).toEqual({
overall: 95,
deductions: 5,
categories: { dns: 100, tls: 94, http: 92 },
});
});
it("rounds and clamps category and overall scores", () => {
expect(score([finding(-200, "dns"), finding(-200, "tls"), finding(-200, "http")])).toEqual({
overall: 0,
deductions: 100,
categories: { dns: 0, tls: 0, http: 0 },
});
});
it("ignores positive or non-finite score impacts", () => {
expect(score([finding(20, "dns"), finding(Number.NaN, "tls")]).overall).toBe(100);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { Category, Finding, InspectionScore } from "./types";
const CATEGORY_ORDER: Category[] = ["dns", "tls", "http"];
function clamp(value: number) {
return Math.max(0, Math.min(100, Math.round(value)));
}
/**
* Scores are calculated per category first, then averaged equally. This keeps
* one category's findings from being charged again against the global score.
*/
export function score(findings: Finding[]): InspectionScore {
const categories: Record<Category, number> = { dns: 100, tls: 100, http: 100 };
for (const finding of findings) {
const impact = Number.isFinite(finding.scoreImpact) ? Math.min(0, finding.scoreImpact) : 0;
categories[finding.category] = clamp(categories[finding.category] + impact);
}
const overall = clamp(CATEGORY_ORDER.reduce((sum, category) => sum + categories[category], 0) / CATEGORY_ORDER.length);
return { overall, deductions: 100 - overall, categories };
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import { EventEmitter } from "node:events";
import tls from "node:tls";
import { certificateDaysRemaining } from "./tls";
import { collectTls, type TlsConnector } from "./tls";
describe("TLS certificate date handling", () => {
it("returns whole days remaining from a supplied clock", () => {
const now = Date.parse("2026-01-01T12:00:00.000Z");
expect(certificateDaysRemaining("Jan 11 12:00:00 2026 GMT", now)).toBe(10);
expect(certificateDaysRemaining("Dec 31 12:00:00 2025 GMT", now)).toBe(-1);
});
it("does not turn an invalid certificate date into a score deduction", () => {
expect(certificateDaysRemaining("not-a-certificate-date", Date.now())).toBeNull();
expect(certificateDaysRemaining(null, Date.now())).toBeNull();
});
it("tries the next validated address after a failed TLS connection", async () => {
const attempted: string[] = [];
const connect: TlsConnector = ((options) => {
const address = String(options.host);
attempted.push(address);
const socket = new EventEmitter() as EventEmitter & {
authorized: boolean;
authorizationError: null;
getPeerCertificate: () => Record<string, unknown>;
getProtocol: () => string;
destroy: () => void;
};
socket.destroy = () => undefined;
if (address === "1.1.1.1") {
queueMicrotask(() => socket.emit("error", Object.assign(new Error("refused"), { code: "ECONNREFUSED" })));
} else {
socket.authorized = true;
socket.authorizationError = null;
socket.getProtocol = () => "TLSv1.3";
socket.getPeerCertificate = () => ({
subject: { CN: "example.com" },
issuer: { CN: "Example CA" },
subjectaltname: "DNS:example.com",
valid_from: "Jan 01 00:00:00 2026 GMT",
valid_to: "Jan 01 00:00:00 2027 GMT",
fingerprint256: "AA:BB",
});
queueMicrotask(() => socket.emit("secureConnect"));
}
return socket as unknown as ReturnType<typeof tls.connect>;
}) as TlsConnector;
const result = await collectTls("example.com", ["1.1.1.1", "2.2.2.2"], { connect, now: Date.parse("2026-06-01T00:00:00Z") });
expect(result).toMatchObject({ address: "2.2.2.2", protocol: "TLSv1.3", hostnameValid: true, authorized: true });
expect(attempted).toEqual(["1.1.1.1", "2.2.2.2"]);
expect(result.attempts).toEqual([{ address: "1.1.1.1", error: "TLS probe failed (ECONNREFUSED)." }]);
});
it("reports certificates that are not yet valid", async () => {
const connect: TlsConnector = (() => {
const socket = new EventEmitter() as EventEmitter & {
authorized: boolean;
authorizationError: null;
getPeerCertificate: () => Record<string, unknown>;
getProtocol: () => string;
destroy: () => void;
};
socket.authorized = true;
socket.authorizationError = null;
socket.getProtocol = () => "TLSv1.3";
socket.getPeerCertificate = () => ({
subjectaltname: "DNS:example.com",
valid_from: "Jul 01 00:00:00 2026 GMT",
valid_to: "Jan 01 00:00:00 2027 GMT",
});
socket.destroy = () => undefined;
queueMicrotask(() => socket.emit("secureConnect"));
return socket as unknown as ReturnType<typeof tls.connect>;
}) as TlsConnector;
const result = await collectTls("example.com", ["2.2.2.2"], { connect, now: Date.parse("2026-06-01T00:00:00Z") });
expect(result.notYetValid).toBe(true);
expect(result.daysRemaining).toBe(214);
});
it("marks malformed or inverted certificate validity dates", async () => {
const connect: TlsConnector = (() => {
const socket = new EventEmitter() as EventEmitter & {
authorized: boolean;
authorizationError: null;
getPeerCertificate: () => Record<string, unknown>;
getProtocol: () => string;
destroy: () => void;
};
socket.authorized = true;
socket.authorizationError = null;
socket.getProtocol = () => "TLSv1.3";
socket.getPeerCertificate = () => ({
subjectaltname: "DNS:example.com",
valid_from: "Jan 02 00:00:00 2026 GMT",
valid_to: "Jan 01 00:00:00 2026 GMT",
});
socket.destroy = () => undefined;
queueMicrotask(() => socket.emit("secureConnect"));
return socket as unknown as ReturnType<typeof tls.connect>;
}) as TlsConnector;
const result = await collectTls("example.com", ["2.2.2.2"], { connect, now: Date.parse("2026-01-01T00:00:00Z") });
expect(result).toMatchObject({ validityDatesValid: false, daysRemaining: 0 });
});
it("marks an exhausted address set as a failed collector", async () => {
const connect: TlsConnector = (() => {
const socket = new EventEmitter() as EventEmitter & { destroy: () => void };
socket.destroy = () => undefined;
queueMicrotask(() => socket.emit("error", Object.assign(new Error("unreachable"), { code: "ETIMEDOUT" })));
return socket as unknown as ReturnType<typeof tls.connect>;
}) as TlsConnector;
const result = await collectTls("example.com", ["2.2.2.2"], { connect, deadline: Date.now() + 1_000 });
expect(result._meta).toMatchObject({ status: "failed" });
});
});
+140
View File
@@ -0,0 +1,140 @@
import tls from "node:tls";
import { createDeadline, remainingMs, timeoutError } from "./deadline";
import { validateResolvedAddresses } from "./policy";
export const TLS_TIMEOUT_MS = 5_000;
export const TLS_TOTAL_TIMEOUT_MS = 8_000;
export const TLS_MAX_ADDRESS_ATTEMPTS = 6;
const DAY_MS = 86_400_000;
type TlsError = { probe: "TLS"; message: string };
type TlsSocket = ReturnType<typeof tls.connect>;
export type TlsConnectOptions = tls.ConnectionOptions;
export type TlsConnector = (options: TlsConnectOptions) => TlsSocket;
export interface TlsCollectionOptions {
connect?: TlsConnector;
now?: number;
deadline?: number;
}
function failure(message: string, attempts: Array<Record<string, unknown>> = []): Record<string, unknown> {
const errors: TlsError[] = [{ probe: "TLS", message }];
return { error: message, attempts, _meta: { status: "failed", errors } };
}
function errorMessage(error: unknown) {
const code = typeof error === "object" && error !== null && "code" in error
? String((error as { code?: unknown }).code ?? "")
: "";
return code ? "TLS probe failed (" + code + ")." : "TLS probe failed.";
}
export function certificateDaysRemaining(validTo: string | null | undefined, now = Date.now()) {
if (!validTo) return null;
const timestamp = Date.parse(validTo);
if (!Number.isFinite(timestamp)) return null;
return Math.floor((timestamp - now) / DAY_MS);
}
function certificateNotYetValid(validFrom: string | null | undefined, now: number) {
if (!validFrom) return null;
const timestamp = Date.parse(validFrom);
return Number.isFinite(timestamp) ? timestamp > now : null;
}
function certificateValidityDatesValid(validFrom: string | null | undefined, validTo: string | null | undefined) {
if (!validFrom || !validTo) return false;
const from = Date.parse(validFrom);
const to = Date.parse(validTo);
return Number.isFinite(from) && Number.isFinite(to) && from <= to;
}
function collectTlsCandidate(host: string, address: string, deadline: number, connect: TlsConnector, now: number): Promise<Record<string, unknown>> {
const budget = Math.min(TLS_TIMEOUT_MS, remainingMs(deadline));
if (!budget) return Promise.reject(timeoutError("TLS handshake timed out."));
return new Promise((resolve, reject) => {
let socket: TlsSocket | undefined;
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const done = (callback: () => void) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
socket?.destroy();
callback();
};
const fail = (error: unknown) => done(() => reject(error));
try {
socket = connect({
host: address,
port: 443,
servername: host,
rejectUnauthorized: false,
timeout: budget,
});
timer = setTimeout(() => fail(timeoutError("TLS handshake timed out.")), budget);
} catch (error) {
fail(error);
return;
}
socket.once("secureConnect", () => {
try {
const cert = socket!.getPeerCertificate(true);
if (!cert || !Object.keys(cert).length) {
fail(new Error("TLS peer certificate was unavailable."));
return;
}
const hostnameError = tls.checkServerIdentity(host, cert);
const validTo = cert.valid_to ?? null;
const validFrom = cert.valid_from ?? null;
const result = {
address,
protocol: socket!.getProtocol(),
authorized: socket!.authorized,
authorizationError: socket!.authorizationError ? String(socket!.authorizationError) : null,
hostnameValid: !hostnameError,
hostnameError: hostnameError?.message ?? null,
subject: cert.subject ?? null,
issuer: cert.issuer ?? null,
subjectaltname: cert.subjectaltname ?? null,
validFrom,
validTo,
validityDatesValid: certificateValidityDatesValid(validFrom, validTo),
daysRemaining: certificateDaysRemaining(validTo, now),
notYetValid: certificateNotYetValid(validFrom, now),
fingerprint256: cert.fingerprint256 ?? null,
_meta: { status: "ok", errors: [] },
};
done(() => resolve(result));
} catch (error) {
fail(new Error(error instanceof Error ? "TLS certificate could not be parsed." : "TLS probe failed."));
}
});
socket.once("timeout", () => fail(timeoutError("TLS handshake timed out.")));
socket.once("error", (error) => fail(error));
});
}
export async function collectTls(host: string, rawAddresses: string[] | string, options: TlsCollectionOptions = {}): Promise<Record<string, unknown>> {
const addresses = validateResolvedAddresses(Array.isArray(rawAddresses) ? rawAddresses : [rawAddresses]);
const deadline = options.deadline ?? createDeadline(TLS_TOTAL_TIMEOUT_MS);
const connect = options.connect ?? ((connectionOptions) => tls.connect(connectionOptions));
const now = options.now ?? Date.now();
const attempts: Array<Record<string, unknown>> = [];
for (const address of addresses.slice(0, TLS_MAX_ADDRESS_ATTEMPTS)) {
if (!remainingMs(deadline)) break;
try {
return { ...(await collectTlsCandidate(host, address, deadline, connect, now)), attempts };
} catch (error) {
attempts.push({ address, error: errorMessage(error) });
}
}
const message = remainingMs(deadline) ? "TLS probe failed for all resolved addresses." : "TLS probe deadline exhausted.";
return failure(message, attempts);
}
+21
View File
@@ -0,0 +1,21 @@
export type Category = "dns" | "tls" | "http";
export type Severity = "critical" | "warning" | "info" | "good";
export type CollectorStatus = "ok" | "partial" | "failed";
export type CompletenessStatus = "complete" | "partial" | "failed";
export interface ProbeError { probe: string; message: string; }
export interface CollectorMeta { status: CollectorStatus; errors: ProbeError[]; }
export interface Completeness {
status: CompletenessStatus;
complete: number;
total: number;
collectors: Record<Category, CollectorStatus>;
issues: string[];
}
export interface Finding { id:string; category:Category; severity:Severity; title:string; summary:string; evidence:string[]; remediation?:string; scoreImpact:number; }
export interface InspectionScore {
overall: number;
/** The aggregate loss from a perfect overall score, not the sum of category penalties. */
deductions: number;
categories: Record<Category, number>;
}
export interface InspectionResult { host:string; inspectedAt:string; addresses:string[]; completeness: Completeness; dns:Record<string,unknown>; tls:Record<string,unknown>; http:Record<string,unknown>; findings:Finding[]; score: InspectionScore; }
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { demoInspection } from "./demo";
import { isInspectionResult } from "./validation";
describe("inspection result runtime validation", () => {
it("accepts the normalized result contract", () => {
expect(isInspectionResult(demoInspection)).toBe(true);
});
it.each([
null,
{},
{ ...demoInspection, host: "" },
{ ...demoInspection, inspectedAt: "not-a-date" },
{ ...demoInspection, score: { overall: 100, categories: { dns: 100 } } },
{ ...demoInspection, completeness: { ...demoInspection.completeness, collectors: { dns: "unknown", tls: "ok", http: "ok" } } },
{ ...demoInspection, completeness: { ...demoInspection.completeness, complete: 4, total: 3 } },
{ ...demoInspection, findings: [{ id: "missing-fields" }] },
{ ...demoInspection, findings: [{ ...demoInspection.findings[0], category: "unknown" }] },
{ ...demoInspection, findings: [{ ...demoInspection.findings[0], evidence: [42] }] },
{ ...demoInspection, findings: [{ ...demoInspection.findings[0], remediation: { unexpected: true } }] },
])("rejects malformed payload %#", (payload) => {
expect(isInspectionResult(payload)).toBe(false);
});
});
+52
View File
@@ -0,0 +1,52 @@
import type { InspectionResult } from "./types";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function finiteScore(value: unknown) {
return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100;
}
function collectorStatus(value: unknown) {
return value === "ok" || value === "partial" || value === "failed";
}
function nonNegativeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
function isFinding(value: unknown) {
if (!isRecord(value)) return false;
const category = value.category;
const severity = value.severity;
return typeof value.id === "string" && typeof value.title === "string" && typeof value.summary === "string"
&& (category === "dns" || category === "tls" || category === "http")
&& (severity === "critical" || severity === "warning" || severity === "info" || severity === "good")
&& typeof value.scoreImpact === "number" && Number.isFinite(value.scoreImpact)
&& Array.isArray(value.evidence) && value.evidence.every((pointer) => typeof pointer === "string")
&& (value.remediation === undefined || typeof value.remediation === "string");
}
/**
* A deliberately small client boundary: it validates the shape ReportView
* depends on without duplicating server-side schema logic or adding a runtime
* validation dependency to this compact project.
*/
export function isInspectionResult(value: unknown): value is InspectionResult {
if (!isRecord(value) || typeof value.host !== "string" || !value.host || typeof value.inspectedAt !== "string" || !Number.isFinite(Date.parse(value.inspectedAt))) return false;
if (!Array.isArray(value.addresses) || !value.addresses.every((address) => typeof address === "string")) return false;
const score = value.score;
if (!isRecord(score) || !finiteScore(score.overall) || !isRecord(score.categories)) return false;
const categories = score.categories;
if (!["dns", "tls", "http"].every((category) => finiteScore(categories[category]))) return false;
const completeness = value.completeness;
if (!isRecord(completeness) || !["complete", "partial", "failed"].includes(String(completeness.status))) return false;
const complete = completeness.complete;
const total = completeness.total;
if (!nonNegativeInteger(complete) || !nonNegativeInteger(total) || complete > total || !isRecord(completeness.collectors) || !Array.isArray(completeness.issues)) return false;
const collectors = completeness.collectors;
if (!["dns", "tls", "http"].every((category) => collectorStatus(collectors[category])) || !completeness.issues.every((issue) => typeof issue === "string")) return false;
if (!Array.isArray(value.findings) || !value.findings.every(isFinding)) return false;
return isRecord(value.dns) && isRecord(value.tls) && isRecord(value.http);
}
+4
View File
@@ -0,0 +1,4 @@
import packageJson from "../package.json";
export const CERTSCOPE_VERSION = packageJson.version;
export const CERTSCOPE_REVISION = process.env.CERTSCOPE_BUILD_REVISION || "unknown";