171 lines
7.2 KiB
JavaScript
171 lines
7.2 KiB
JavaScript
import { readdir, readFile } from "node:fs/promises";
|
|
import { extname, relative, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
|
const rootDir = resolve(frontendDir, "..");
|
|
const sourceDir = resolve(frontendDir, "src");
|
|
const tauriDir = resolve(frontendDir, "src-tauri");
|
|
const failures = [];
|
|
|
|
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
|
|
|
const fail = (message) => failures.push(message);
|
|
const assert = (condition, message) => {
|
|
if (!condition) fail(message);
|
|
};
|
|
|
|
const walk = async (directory) => {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
return (
|
|
await Promise.all(
|
|
entries.map(async (entry) => {
|
|
const path = resolve(directory, entry.name);
|
|
return entry.isDirectory() ? walk(path) : path;
|
|
}),
|
|
)
|
|
).flat();
|
|
};
|
|
|
|
const permissionIdentifier = (permission) =>
|
|
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
|
|
|
|
const assertPathScope = (permission, expectedPrefix, description) => {
|
|
const allow = Array.isArray(permission.allow) ? permission.allow : [];
|
|
assert(allow.length > 0, `${description} must define an explicit allow list.`);
|
|
for (const item of allow) {
|
|
const path = item?.path;
|
|
assert(
|
|
typeof path === "string" && path.startsWith(expectedPrefix),
|
|
`${description} may only allow paths under ${expectedPrefix}; found ${path ?? "<missing>"}.`,
|
|
);
|
|
}
|
|
};
|
|
|
|
const verifyTauriConfig = async () => {
|
|
const tauriConfig = await readJson(resolve(tauriDir, "tauri.conf.json"));
|
|
const targets = tauriConfig.bundle?.targets;
|
|
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
|
|
const csp = tauriConfig.app?.security?.csp || "";
|
|
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
|
|
|
|
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
|
|
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
|
|
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
|
|
assert(
|
|
tauriConfig.bundle?.createUpdaterArtifacts === true,
|
|
"Tauri must create updater artifacts for signed desktop release builds.",
|
|
);
|
|
assert(
|
|
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
|
|
"Tauri updater public key must be configured.",
|
|
);
|
|
assert(csp.includes("default-src 'self'"), "Tauri CSP must keep default-src restricted to self.");
|
|
assert(csp.includes("object-src 'none'"), "Tauri CSP must disable object-src.");
|
|
assert(!csp.includes("'unsafe-eval'"), "Tauri CSP must not allow unsafe-eval.");
|
|
assert(
|
|
!cspTokens.some((token) => token === "*" || token.includes("://*")),
|
|
"Tauri CSP must not use wildcard sources.",
|
|
);
|
|
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
|
|
};
|
|
|
|
const verifyCapabilities = async () => {
|
|
const capabilitiesDir = resolve(tauriDir, "capabilities");
|
|
const files = (await readdir(capabilitiesDir)).filter((file) => file.endsWith(".json"));
|
|
assert(files.length > 0, "At least one Tauri capability file must exist.");
|
|
|
|
const bannedPermissions = new Set([
|
|
"shell:default",
|
|
"shell:allow-open",
|
|
"shell:allow-execute",
|
|
"fs:default",
|
|
"fs:allow-read-dir",
|
|
"fs:allow-read-text-file",
|
|
"fs:allow-write-text-file",
|
|
]);
|
|
|
|
for (const file of files) {
|
|
const capability = await readJson(resolve(capabilitiesDir, file));
|
|
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
|
|
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
|
|
|
|
for (const identifier of identifiers) {
|
|
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
|
|
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
|
|
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
|
|
}
|
|
|
|
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
|
|
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
|
|
if (fsScope && typeof fsScope !== "string") {
|
|
assertPathScope(fsScope, "$TEMP/ctms-desktop/", `${file}: fs:scope`);
|
|
}
|
|
|
|
const openerScope = permissions.find((permission) => permissionIdentifier(permission) === "opener:allow-open-path");
|
|
assert(Boolean(openerScope), `${file}: opener:allow-open-path must be explicitly scoped.`);
|
|
if (openerScope && typeof openerScope !== "string") {
|
|
assertPathScope(openerScope, "$TEMP/ctms-desktop/", `${file}: opener:allow-open-path`);
|
|
}
|
|
}
|
|
};
|
|
|
|
const verifyRustBoundary = async () => {
|
|
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
|
|
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
|
|
for (const token of forbiddenRust) {
|
|
assert(!libSource.includes(token), `Rust desktop boundary must not include ${token}.`);
|
|
}
|
|
|
|
const singleInstanceIndex = libSource.indexOf("tauri_plugin_single_instance::init");
|
|
const dialogIndex = libSource.indexOf("tauri_plugin_dialog::init");
|
|
assert(singleInstanceIndex >= 0, "Single-instance plugin must be registered.");
|
|
assert(
|
|
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
|
|
"Single-instance plugin must be registered before other desktop plugins.",
|
|
);
|
|
|
|
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
|
|
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
|
|
const allowedCommands = [
|
|
"credentials::credential_get",
|
|
"credentials::credential_set",
|
|
"credentials::credential_delete",
|
|
"updates::desktop_update_check",
|
|
"updates::desktop_update_install",
|
|
];
|
|
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
|
|
const missing = allowedCommands.filter((command) => !commands.includes(command));
|
|
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
|
|
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
|
|
};
|
|
|
|
const verifySourceSafety = async () => {
|
|
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
|
|
const files = [
|
|
...(await walk(sourceDir)),
|
|
...(await walk(resolve(tauriDir, "src"))),
|
|
].filter((path) => sourceExtensions.has(extname(path)));
|
|
|
|
for (const path of files) {
|
|
const source = await readFile(path, "utf8");
|
|
const file = relative(rootDir, path);
|
|
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
|
|
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
|
|
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
|
|
}
|
|
}
|
|
};
|
|
|
|
await verifyTauriConfig();
|
|
await verifyCapabilities();
|
|
await verifyRustBoundary();
|
|
await verifySourceSafety();
|
|
|
|
if (failures.length > 0) {
|
|
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log("Desktop release gate passed.");
|
|
}
|