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,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) };
|
||||
}
|
||||
Reference in new issue
Block a user