312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
#!/usr/bin/env tsx
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const REPORT_PATH = "docs/security/tenant-isolation-report.md";
|
|
export const S04_SUMMARY_PATH = "docs/security/tenant-isolation-s04-summary.md";
|
|
export const REQUIRED_ISOLATION_TEST_PATHS = [
|
|
"apps/web/app/test/isolation/admin-routes.test.ts",
|
|
"apps/web/app/test/isolation/fixtures/user-session-isolation.test.ts",
|
|
] as const;
|
|
export const ISOLATION_TEST_ROOTS = ["apps/web/app/test/isolation", "apps/api/src/isolation"] as const;
|
|
|
|
type EvidenceSource = {
|
|
path: string;
|
|
content: string;
|
|
};
|
|
|
|
export type IsolationTestEvidence = {
|
|
path: string;
|
|
testCount: number;
|
|
boundaries: string[];
|
|
denyPathEvidence: string[];
|
|
};
|
|
|
|
export type TenantIsolationEvidence = {
|
|
generatedFrom: string[];
|
|
tests: IsolationTestEvidence[];
|
|
s04: {
|
|
path: string;
|
|
verificationStatus: string;
|
|
summary: string;
|
|
patterns: string[];
|
|
};
|
|
};
|
|
|
|
export type CheckTenantIsolationReportResult = {
|
|
ok: boolean;
|
|
reportPath: string;
|
|
failures: string[];
|
|
};
|
|
|
|
type CliOptions = {
|
|
rootDir: string;
|
|
check: boolean;
|
|
};
|
|
|
|
const TEST_CASE_PATTERN = /\b(?:it|test)\(\s*(["'`])([\s\S]*?)\1\s*,/g;
|
|
const TENANT_ID_PATTERN = /\b(?:tenant|row|relationship|api[_-]?key|log|user|session|token|bearer)[_-](?:session[_-])?(?:iso[_-])?[a-z0-9_-]+\b/gi;
|
|
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
const UUID_PATTERN = /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/gi;
|
|
const TOKEN_ASSIGNMENT_PATTERN = /\b(?:token|apiKey|api_key|secret|password|bearer)\s*[:=]\s*["'`][^"'`]+["'`]/gi;
|
|
const LONG_SECRET_PATTERN = /\b[A-Za-z0-9_-]{24,}\b/g;
|
|
const SOURCE_SNIPPET_PATTERN = /[`{};]/g;
|
|
|
|
const BOUNDARY_RULES: readonly { pattern: RegExp; label: string }[] = [
|
|
{ pattern: /admin permission|admin auth|adminOnly|verifyUserHasPermission/i, label: "admin permission gate" },
|
|
{ pattern: /resolver tenant|tenant, user, and entity|tenantId|userId/i, label: "tenant and user scoped resolver" },
|
|
{ pattern: /relationship resolver|parent and child|authorized rows/i, label: "relationship parent/child authorization" },
|
|
{ pattern: /row logs|API-key log|setApiKeyLogStatus|status log/i, label: "API-key status logging boundary" },
|
|
{ pattern: /reportUsage|usage/i, label: "tenant-scoped usage reporting" },
|
|
];
|
|
|
|
const DENY_RULES: readonly { pattern: RegExp; label: string }[] = [
|
|
{ pattern: /permission is denied|auth is denied|denied/i, label: "denied credentials stop downstream access" },
|
|
{ pattern: /returns 403|status: 403|, 403/i, label: "403 response for unauthorized tenant access" },
|
|
{ pattern: /not\.toHaveBeenCalled|skips|not called/i, label: "denied path skips data mutation or lookup" },
|
|
{ pattern: /foreign|tenant B|cross-tenant/i, label: "foreign tenant resource denied" },
|
|
];
|
|
|
|
function normalizeRelativePath(relativePath: string): string {
|
|
return relativePath.split(path.sep).join("/");
|
|
}
|
|
|
|
function uniqueSorted(values: Iterable<string>): string[] {
|
|
return [...new Set(values)].sort((a, b) => a.localeCompare(b));
|
|
}
|
|
|
|
function readRequiredFile(rootDir: string, relativePath: string): EvidenceSource {
|
|
const normalizedPath = normalizeRelativePath(relativePath);
|
|
const absolutePath = path.join(rootDir, normalizedPath);
|
|
|
|
if (!fs.existsSync(absolutePath)) {
|
|
throw new Error(`missing expected isolation evidence file: ${normalizedPath}`);
|
|
}
|
|
|
|
return { path: normalizedPath, content: fs.readFileSync(absolutePath, "utf8") };
|
|
}
|
|
|
|
function walkTestFiles(rootDir: string, relativeRoot: string): string[] {
|
|
const absoluteRoot = path.join(rootDir, relativeRoot);
|
|
if (!fs.existsSync(absoluteRoot)) return [];
|
|
|
|
const discovered: string[] = [];
|
|
const walk = (absoluteDir: string) => {
|
|
for (const entry of fs.readdirSync(absoluteDir, { withFileTypes: true })) {
|
|
const absolutePath = path.join(absoluteDir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(absolutePath);
|
|
} else if (entry.isFile() && /\.test\.[cm]?[tj]sx?$/.test(entry.name)) {
|
|
discovered.push(normalizeRelativePath(path.relative(rootDir, absolutePath)));
|
|
}
|
|
}
|
|
};
|
|
|
|
walk(absoluteRoot);
|
|
return discovered;
|
|
}
|
|
|
|
export function discoverIsolationTestPaths(rootDir = process.cwd()): string[] {
|
|
return uniqueSorted([
|
|
...REQUIRED_ISOLATION_TEST_PATHS,
|
|
...ISOLATION_TEST_ROOTS.flatMap((testRoot) => walkTestFiles(rootDir, testRoot)),
|
|
]);
|
|
}
|
|
|
|
function collectLabels(content: string, rules: readonly { pattern: RegExp; label: string }[]): string[] {
|
|
return uniqueSorted(rules.filter((rule) => rule.pattern.test(content)).map((rule) => rule.label));
|
|
}
|
|
|
|
function extractTestNames(content: string): string[] {
|
|
return [...content.matchAll(TEST_CASE_PATTERN)].map((match) => match[2].replace(/\s+/g, " ").trim()).filter(Boolean);
|
|
}
|
|
|
|
function parseS04Summary(source: EvidenceSource): TenantIsolationEvidence["s04"] {
|
|
const verificationStatus = source.content.match(/^verification_result:\s*(.+)$/m)?.[1]?.trim() ?? "unknown";
|
|
const headingSummary = source.content.match(/^\*\*(.+?)\*\*/m)?.[1]?.trim() ?? "S04 summary available.";
|
|
const patternBlock = source.content.match(/^patterns_established:\n([\s\S]*?)(?:\n[a-z_]+:|\n---)/m)?.[1] ?? "";
|
|
const patterns = uniqueSorted([...patternBlock.matchAll(/^\s*-\s+(.+)$/gm)].map((match) => sanitizeSummaryText(match[1])));
|
|
|
|
return {
|
|
path: source.path,
|
|
verificationStatus: sanitizeSummaryText(verificationStatus),
|
|
summary: sanitizeSummaryText(headingSummary),
|
|
patterns,
|
|
};
|
|
}
|
|
|
|
function sanitizeSummaryText(value: string): string {
|
|
return value
|
|
.replace(EMAIL_PATTERN, "[redacted email]")
|
|
.replace(TOKEN_ASSIGNMENT_PATTERN, "[redacted credential]")
|
|
.replace(UUID_PATTERN, "[redacted id]")
|
|
.replace(TENANT_ID_PATTERN, (match) => categoricalLabelFor(match))
|
|
.replace(LONG_SECRET_PATTERN, "[redacted token]")
|
|
.replace(SOURCE_SNIPPET_PATTERN, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function categoricalLabelFor(value: string): string {
|
|
const lower = value.toLowerCase();
|
|
if (lower.includes("tenant") && /(?:_a|tenanta|tenant-a| a\b)/i.test(value)) return "tenant A";
|
|
if (lower.includes("tenant") && /(?:_b|tenantb|tenant-b| b\b)/i.test(value)) return "tenant B";
|
|
if (lower.includes("admin")) return "admin";
|
|
if (lower.includes("relationship")) return "relationship record";
|
|
if (lower.includes("row")) return "row record";
|
|
if (lower.includes("api")) return "API credential";
|
|
if (lower.includes("user")) return "user session";
|
|
return "[redacted id]";
|
|
}
|
|
|
|
function summarizeIsolationTest(source: EvidenceSource): IsolationTestEvidence {
|
|
const testNames = extractTestNames(source.content).map(sanitizeSummaryText);
|
|
return {
|
|
path: source.path,
|
|
testCount: testNames.length,
|
|
boundaries: collectLabels(source.content, BOUNDARY_RULES),
|
|
denyPathEvidence: uniqueSorted([...testNames.filter((name) => /denied|403|foreign|skips/i.test(name)), ...collectLabels(source.content, DENY_RULES)]),
|
|
};
|
|
}
|
|
|
|
export function collectTenantIsolationEvidence(rootDir = process.cwd()): TenantIsolationEvidence {
|
|
const tests = discoverIsolationTestPaths(rootDir).map((testPath) => summarizeIsolationTest(readRequiredFile(rootDir, testPath))).sort((a, b) =>
|
|
a.path.localeCompare(b.path),
|
|
);
|
|
const s04Source = readRequiredFile(rootDir, S04_SUMMARY_PATH);
|
|
const generatedFrom = uniqueSorted([...tests.map((test) => test.path), s04Source.path]);
|
|
|
|
return { generatedFrom, tests, s04: parseS04Summary(s04Source) };
|
|
}
|
|
|
|
function renderList(values: string[]): string {
|
|
if (values.length === 0) return "- none observed";
|
|
return values.map((value) => `- ${value}`).join("\n");
|
|
}
|
|
|
|
export function renderTenantIsolationReport(evidence: TenantIsolationEvidence): string {
|
|
const testRows = evidence.tests.map((test) => {
|
|
return `| ${test.path} | ${test.testCount} | ${test.boundaries.join("; ") || "none observed"} | ${test.denyPathEvidence.join("; ") || "none observed"} |`;
|
|
});
|
|
|
|
return [
|
|
"# Tenant Isolation Report",
|
|
"",
|
|
"<!-- Generated by scripts/tenant-isolation-report.ts. Do not edit by hand. -->",
|
|
"",
|
|
"## Scope",
|
|
"",
|
|
"This report summarizes committed Tenant Isolation tenant-isolation tests and S04 evidence using categorical labels only. Fixture identifiers, credentials, row IDs, relationship IDs, tenant IDs, emails, tokens, and source snippets are intentionally redacted from this document.",
|
|
"",
|
|
"## Source Evidence",
|
|
"",
|
|
renderList(evidence.generatedFrom),
|
|
"",
|
|
"## Boundary Matrix",
|
|
"",
|
|
"| Source | Test count | Checked access boundaries | Deny-path evidence |",
|
|
"| --- | ---: | --- | --- |",
|
|
...testRows,
|
|
"",
|
|
"## Prior Slice Evidence",
|
|
"",
|
|
`- Source: ${evidence.s04.path}`,
|
|
`- Verification status: ${evidence.s04.verificationStatus}`,
|
|
`- Summary: ${evidence.s04.summary}`,
|
|
"- Established patterns:",
|
|
...evidence.s04.patterns.map((pattern) => ` - ${pattern}`),
|
|
"",
|
|
"## Deny-Path Coverage",
|
|
"",
|
|
"The boundary matrix records denial outcomes for unauthorized credentials, foreign tenant resources, skipped downstream mutations/lookups, and 403 responses. These entries are derived from test names and assertion patterns rather than concrete fixture values.",
|
|
"",
|
|
"## Redaction Rules",
|
|
"",
|
|
"The renderer reports tenant A, tenant B, admin, user session, API credential, row record, and relationship record categories instead of concrete fixture values.",
|
|
"",
|
|
"## Verification Commands",
|
|
"",
|
|
"```sh",
|
|
"pnpm exec eslint scripts/tenant-isolation-report.ts scripts/tenant-isolation-report.test.ts --max-warnings=0",
|
|
"pnpm exec vitest run scripts/tenant-isolation-report.test.ts",
|
|
"pnpm exec tsx scripts/tenant-isolation-report.ts --check",
|
|
"pnpm exec tsx scripts/docs-link-check.ts",
|
|
"```",
|
|
"",
|
|
"## Redaction Contract",
|
|
"",
|
|
"The renderer reports tenant A, tenant B, admin, user session, API credential, row record, and relationship record categories instead of concrete fixture values.",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function writeTenantIsolationReport(rootDir = process.cwd()): string {
|
|
const reportPath = path.join(rootDir, REPORT_PATH);
|
|
const report = renderTenantIsolationReport(collectTenantIsolationEvidence(rootDir));
|
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
|
fs.writeFileSync(reportPath, report, "utf8");
|
|
return reportPath;
|
|
}
|
|
|
|
export function checkTenantIsolationReport(rootDir = process.cwd()): CheckTenantIsolationReportResult {
|
|
const reportPath = path.join(rootDir, REPORT_PATH);
|
|
const expected = renderTenantIsolationReport(collectTenantIsolationEvidence(rootDir));
|
|
|
|
if (!fs.existsSync(reportPath)) {
|
|
return { ok: false, reportPath, failures: [`missing generated report: ${REPORT_PATH}`] };
|
|
}
|
|
|
|
const actual = fs.readFileSync(reportPath, "utf8");
|
|
if (actual !== expected) {
|
|
return { ok: false, reportPath, failures: [`stale generated report: ${REPORT_PATH}`] };
|
|
}
|
|
|
|
return { ok: true, reportPath, failures: [] };
|
|
}
|
|
|
|
function parseCliOptions(args: string[]): CliOptions {
|
|
const options: CliOptions = { rootDir: process.cwd(), check: false };
|
|
|
|
for (const arg of args) {
|
|
if (arg === "--check") {
|
|
options.check = true;
|
|
} else if (arg.startsWith("--root=")) {
|
|
options.rootDir = path.resolve(arg.slice("--root=".length));
|
|
} else {
|
|
throw new Error(`unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function formatCliResult(status: "PASS" | "FAIL", reportPath: string, details: string[]): string {
|
|
const relativeReportPath = normalizeRelativePath(path.relative(process.cwd(), reportPath));
|
|
return [`${status} tenant-isolation-report ${relativeReportPath}`, ...details].join("\n") + "\n";
|
|
}
|
|
|
|
export function runTenantIsolationReportCli(args = process.argv.slice(2)): number {
|
|
try {
|
|
const options = parseCliOptions(args);
|
|
if (options.check) {
|
|
const result = checkTenantIsolationReport(options.rootDir);
|
|
const output = formatCliResult(result.ok ? "PASS" : "FAIL", result.reportPath, result.failures.map((failure) => `FAIL ${failure}`));
|
|
(result.ok ? process.stdout : process.stderr).write(output);
|
|
return result.ok ? 0 : 1;
|
|
}
|
|
|
|
const reportPath = writeTenantIsolationReport(options.rootDir);
|
|
process.stdout.write(formatCliResult("PASS", reportPath, ["wrote generated report"]));
|
|
return 0;
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : "unknown tenant isolation report failure";
|
|
process.stderr.write(`FAIL tenant-isolation-report ${message}\n`);
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
process.exitCode = runTenantIsolationReportCli();
|
|
}
|