import fs from "node:fs"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { Ajv2020, type AnySchema, type ErrorObject } from "ajv/dist/2020.js"; const rootDir = fileURLToPath(new URL("..", import.meta.url)); const manifestSchemaPath = path.join(rootDir, "manifests", "game-plugin.manifest.schema.json"); const createFormSchemaPath = path.join(rootDir, "schemas", "create-form.schema.json"); function readJson(filePath: string): unknown { return JSON.parse(fs.readFileSync(filePath, "utf8")); } function formatErrors(prefix: string, errors: ErrorObject[] | null | undefined): string[] { return (errors ?? []).map((error) => `${prefix}${error.instancePath}: ${error.message}`); } function unsafeFieldReason(fieldName: string): string | undefined { const compact = fieldName.toLowerCase().replace(/[^a-z0-9]/g, ""); if (compact.includes("rawapikey") || compact.includes("apikey") || compact.includes("providerkey")) { return "raw AI/provider key field is not allowed"; } if (compact.includes("runcredential") || compact.includes("runsocket") || compact.includes("directrun")) { return "direct run access field is not allowed"; } if (compact.includes("password") || compact.includes("dsn") || compact.includes("rawkey") || compact.includes("secretkey") || compact.includes("credential")) { return "raw credential field is not allowed"; } if (compact.includes("hostpath") || compact.includes("rawpath")) { return "raw host path field is not allowed"; } return undefined; } function unsafeStringReasons(value: string): string[] { const trimmed = value.trim(); const lowered = trimmed.toLowerCase(); if (trimmed === "") { return []; } const reasons: string[] = []; if ( lowered.startsWith("sk-") || lowered.startsWith("sk_") || lowered.includes("bearer ") || lowered.includes("api_key=") || lowered.includes("apikey=") || lowered.includes("raw api key") || lowered.includes("raw credential") || lowered.includes("provider key") || lowered.includes("ai key") || lowered.includes("password=") || lowered.includes("rcon password") || lowered.includes("ftp password") || lowered.startsWith("mysql://") || lowered.startsWith("sqlite://") || lowered.includes("database dsn") ) { reasons.push("raw credential or AI/provider key content is not allowed"); } if ( lowered.includes("direct run") || lowered.includes("run socket") || lowered.includes("run credential") || lowered.includes("run token") || lowered.includes("direct socket") || lowered.startsWith("tcp://") || lowered.startsWith("unix://") ) { reasons.push("direct run access request is not allowed"); } if ( lowered.startsWith("file://") || lowered.startsWith("\\\\") || /^[a-z]:[\\/]/i.test(trimmed) || /^\/(users|etc|var|tmp|home|root|private|volumes|opt)\//i.test(trimmed) || lowered.includes("host path") || lowered.includes("raw host path") ) { reasons.push("raw host path access is not allowed"); } if ( lowered.includes("shell script") || lowered.includes("bash -c") || lowered.includes("powershell -") || lowered.includes("cmd.exe") || lowered.includes("curl |") ) { reasons.push("arbitrary shell content is not allowed"); } return reasons; } function scanUnsafeValues(value: unknown, location: string): string[] { if (typeof value === "string") { return unsafeStringReasons(value).map((reason) => `${location}: ${reason}`); } if (Array.isArray(value)) { return value.flatMap((item, index) => scanUnsafeValues(item, `${location}[${index}]`)); } if (typeof value === "object" && value !== null) { return Object.entries(value).flatMap(([key, child]) => { const keyReason = unsafeFieldReason(key); const keyErrors = keyReason ? [`${location}.${key}: ${keyReason}`] : []; return [...keyErrors, ...scanUnsafeValues(child, `${location}.${key}`)]; }); } return []; } function isSafeRelativeJsonRef(value: string): boolean { return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value); } export function validateManifestFile(manifestPath: string): string[] { const absoluteManifestPath = path.resolve(rootDir, manifestPath); const manifest = readJson(absoluteManifestPath); const manifestDir = path.dirname(absoluteManifestPath); const ajv = new Ajv2020({ allErrors: true }); const validateManifest = ajv.compile(readJson(manifestSchemaPath) as AnySchema); const validateCreateForm = ajv.compile(readJson(createFormSchemaPath) as AnySchema); const errors: string[] = []; if (!validateManifest(manifest)) { errors.push(...formatErrors("manifest", validateManifest.errors)); } errors.push(...scanUnsafeValues(manifest, "manifest")); if (typeof manifest === "object" && manifest !== null && "server" in manifest) { const server = (manifest as { server?: { createFormSchema?: string } }).server; if (server?.createFormSchema) { if (!isSafeRelativeJsonRef(server.createFormSchema)) { errors.push("createFormSchema: unsafe file reference"); return errors; } const createFormPath = path.resolve(manifestDir, server.createFormSchema); if (!fs.existsSync(createFormPath)) { errors.push(`createFormSchema: missing file ${server.createFormSchema}`); } else { const createForm = readJson(createFormPath); if (!validateCreateForm(createForm)) { errors.push(...formatErrors("createForm", validateCreateForm.errors)); } errors.push(...scanUnsafeValues(createForm, "createForm")); } } } return errors; } if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { const target = process.argv[2]; if (!target) { console.error("usage: npm run validate:manifest -- "); process.exit(2); } const errors = validateManifestFile(target); if (errors.length > 0) { console.error(errors.join("\n")); process.exit(1); } console.log(`validated ${target}`); }