Initial curated CertScope source from fe2bbe3ca5c98bf64be26a09c6c58ee805a7f87e
This commit is contained in:
commit
7348faf6c0
65 files changed
+12509
No files matched your search
@@ -0,0 +1,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;
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user