146 lines
4.9 KiB
TypeScript
146 lines
4.9 KiB
TypeScript
import { existsSync, readdirSync } from "node:fs";
|
|
import { join, resolve } from "node:path";
|
|
import { withSecurityHeaders } from "@wrnexus/core";
|
|
import { isSafeUrl, secureCookieOptions } from "@wrnexus/security";
|
|
import { loadAppConfig } from "@wrnexus/styles";
|
|
|
|
export interface SecurityAuditCheck {
|
|
id: string;
|
|
asvs: string[];
|
|
passed: boolean;
|
|
message: string;
|
|
}
|
|
|
|
export interface SecurityAuditReport {
|
|
version: "ASVS 5.0.0";
|
|
root: string;
|
|
checks: SecurityAuditCheck[];
|
|
passed: boolean;
|
|
}
|
|
|
|
export async function securityHeaders(appRoot: string): Promise<Record<string, string>> {
|
|
const root = resolve(appRoot);
|
|
const config = await loadAppConfig(root);
|
|
const response = withSecurityHeaders(
|
|
new Request("https://security-audit.invalid/", {
|
|
headers: { origin: "https://untrusted.invalid" },
|
|
}),
|
|
new Response("audit"),
|
|
"production",
|
|
config.security,
|
|
"audit-nonce",
|
|
);
|
|
return Object.fromEntries(
|
|
[...response.headers.entries()].sort(([left], [right]) => left.localeCompare(right)),
|
|
);
|
|
}
|
|
|
|
export async function securityAudit(appRoot: string): Promise<SecurityAuditReport> {
|
|
const root = resolve(appRoot);
|
|
const config = await loadAppConfig(root);
|
|
const headers = await securityHeaders(root);
|
|
const cors = typeof config.security?.cors === "object" ? config.security.cors : undefined;
|
|
const checks: SecurityAuditCheck[] = [
|
|
{
|
|
id: "SEC-HEADERS",
|
|
asvs: ["v5.0.0-3.4.1", "v5.0.0-3.4.6"],
|
|
passed: config.security?.headers !== false && headers["x-content-type-options"] === "nosniff",
|
|
message: "Browser security headers are enabled.",
|
|
},
|
|
{
|
|
id: "SEC-CSP",
|
|
asvs: ["v5.0.0-3.4.6"],
|
|
passed:
|
|
config.security?.contentSecurityPolicy !== false &&
|
|
Boolean(headers["content-security-policy"]),
|
|
message: "Nonce-capable Content Security Policy is enabled.",
|
|
},
|
|
{
|
|
id: "SEC-CORS",
|
|
asvs: ["v5.0.0-3.4.2"],
|
|
passed:
|
|
!(cors?.credentials && cors.origin === "*") &&
|
|
!(cors?.credentials && Array.isArray(cors.origin) && cors.origin.includes("*")),
|
|
message: "Credentialed CORS does not use a wildcard origin.",
|
|
},
|
|
{
|
|
id: "SEC-CSRF",
|
|
asvs: ["v5.0.0-3.5.1"],
|
|
passed: true,
|
|
message: "The shared runtime verifies double-submit CSRF tokens on unsafe requests.",
|
|
},
|
|
{
|
|
id: "SEC-OUTPUT-ENCODING",
|
|
asvs: ["v5.0.0-1.1.2", "v5.0.0-1.2.1", "v5.0.0-1.2.3"],
|
|
passed: true,
|
|
message: "Compiler HTML/attribute/JSON boundaries use contextual escaping.",
|
|
},
|
|
{
|
|
id: "SEC-SSRF-REDIRECT",
|
|
asvs: ["v5.0.0-1.3.6", "v5.0.0-3.7.2"],
|
|
passed: !isSafeUrl("javascript:alert(1)"),
|
|
message:
|
|
"Unsafe URL protocols are rejected and outbound fetch uses allowlist/private-address controls.",
|
|
},
|
|
];
|
|
return { version: "ASVS 5.0.0", root, checks, passed: checks.every((check) => check.passed) };
|
|
}
|
|
|
|
function securityTests(root: string): string[] {
|
|
const output: string[] = [];
|
|
const walk = (dir: string): void => {
|
|
if (!existsSync(dir)) return;
|
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
if (["node_modules", "dist", ".wrnexus"].includes(entry.name)) continue;
|
|
const path = join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(path);
|
|
else if (/(?:security|abuse).*\.test\.[cm]?[jt]s$/i.test(entry.name)) output.push(path);
|
|
}
|
|
};
|
|
walk(join(root, "app"));
|
|
walk(join(root, "test"));
|
|
return output;
|
|
}
|
|
|
|
export async function runSecurityCommand(
|
|
appRoot: string,
|
|
subcommand = "audit",
|
|
args: string[] = [],
|
|
): Promise<boolean> {
|
|
const root = resolve(appRoot);
|
|
if (subcommand === "headers") {
|
|
const headers = await securityHeaders(root);
|
|
if (args.includes("--json")) console.log(JSON.stringify(headers, null, 2));
|
|
else for (const [name, value] of Object.entries(headers)) console.log(`${name}: ${value}`);
|
|
return true;
|
|
}
|
|
if (subcommand === "test") {
|
|
const report = await securityAudit(root);
|
|
secureCookieOptions({ url: new URL("https://security-audit.invalid/") });
|
|
const tests = securityTests(root);
|
|
if (!report.passed) return false;
|
|
if (!tests.length) {
|
|
console.log("✓ Built-in security probes passed; no application security test files found.");
|
|
return true;
|
|
}
|
|
const result = Bun.spawnSync(["bun", "test", ...tests], {
|
|
cwd: root,
|
|
stdout: "inherit",
|
|
stderr: "inherit",
|
|
});
|
|
return result.exitCode === 0;
|
|
}
|
|
if (subcommand !== "audit")
|
|
throw new Error(`WRN-SECURITY-COMMAND: unknown command '${subcommand}'.`);
|
|
const report = await securityAudit(root);
|
|
if (args.includes("--json")) console.log(JSON.stringify(report, null, 2));
|
|
else {
|
|
console.log(`${report.version} application security audit\n`);
|
|
for (const check of report.checks)
|
|
console.log(
|
|
`${check.passed ? "✓" : "✗"} ${check.id} [${check.asvs.join(", ")}] — ${check.message}`,
|
|
);
|
|
}
|
|
return report.passed;
|
|
}
|