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 lifecycleActionSchemaPath = path.join(rootDir, "manifests", "lifecycle-action.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 validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] { const action = readJson(path.resolve(rootDir, actionPath)); const ajv = new Ajv2020({ allErrors: true }); ajv.addSchema(readJson(manifestSchemaPath) as AnySchema); const validateAction = ajv.compile(readJson(lifecycleActionSchemaPath) as AnySchema); const errors = validateAction(action) ? [] : formatErrors("lifecycleAction", validateAction.errors); errors.push(...scanUnsafeValues(action, "lifecycleAction")); if (typeof action !== "object" || action === null) { return errors; } const declaration = action as { action?: string; mode?: string; executableKey?: string }; if (expectedAction && declaration.action !== expectedAction) { errors.push(`lifecycleAction.action: expected ${expectedAction}`); } if ((declaration.action === "install" || declaration.action === "start") && !declaration.executableKey) { errors.push("lifecycleAction.executableKey: required for install/start"); } if (declaration.action === "start" && declaration.mode !== "supervised") { errors.push("lifecycleAction.mode: start must be supervised"); } if ((declaration.action === "stop" || declaration.action === "status") && declaration.mode !== "control") { errors.push(`lifecycleAction.mode: ${declaration.action} must be control`); } return errors; } function referencedLifecycleActions(manifest: unknown): Array<{ action: string; ref: string }> { if (typeof manifest !== "object" || manifest === null) { return []; } const record = manifest as { actions?: Record; runtimeProfiles?: { lifecycleProfiles?: Array<{ actionRefs?: Record }> }; }; const refs = new Map(); for (const [action, ref] of Object.entries(record.actions ?? {})) { refs.set(`${action}:${ref}`, { action, ref }); } for (const profile of record.runtimeProfiles?.lifecycleProfiles ?? []) { for (const [action, ref] of Object.entries(profile.actionRefs ?? {})) { refs.set(`${action}:${ref}`, { action, ref }); } } return [...refs.values()]; } function validateDependencyPlans(manifest: unknown): string[] { if (typeof manifest !== "object" || manifest === null) { return []; } const plans = (manifest as { runtimeProfiles?: { installPlans?: Array<{ key?: string; steps?: Array<{ type?: string; downloadRef?: string }> }> } }).runtimeProfiles?.installPlans ?? []; const errors: string[] = []; for (const [planIndex, plan] of plans.entries()) { for (const [stepIndex, step] of (plan.steps ?? []).entries()) { if (step.type !== "verified-download" || !step.downloadRef) { continue; } try { const parsed = new URL(step.downloadRef); const host = parsed.hostname.toLowerCase(); const privateIPv4 = /^(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host); if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.hash || host === "localhost" || host.endsWith(".localhost") || privateIPv4 || host === "::1") { errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: host is not approved for dependency download`); } } catch { errors.push(`manifest.runtimeProfiles.installPlans[${planIndex}].steps[${stepIndex}].downloadRef: URL is invalid`); } } } return errors; } function validateClientManagerProfiles(manifest: unknown): string[] { if (typeof manifest !== "object" || manifest === null) { return []; } type ClientManagerProfile = { key?: string; version?: string; repository?: { revisionPolicy?: string; branch?: string; tag?: string; revision?: string }; outputArtifacts?: string[]; deployment?: { executableRef?: string; requiredRunCapabilities?: string[] }; lifecycle?: { actions?: string[]; startupTimeoutSeconds?: number; stopTimeoutSeconds?: number }; health?: { mode?: string; intervalSeconds?: number; degradedAfterSeconds?: number; offlineAfterSeconds?: number; requiredCapabilities?: string[] }; compatibility?: { minimumVersion?: string; maximumVersion?: string }; updatePolicy?: { healthConfirmationSeconds?: number }; }; const profiles = (manifest as { runtimeProfiles?: { clientManagers?: ClientManagerProfile[] } }).runtimeProfiles?.clientManagers ?? []; const errors: string[] = []; const parseVersion = (value: string | undefined): number[] | undefined => { const match = value?.match(/^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/); return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : undefined; }; const compareVersions = (left: number[], right: number[]): number => { for (let index = 0; index < 3; index += 1) { if (left[index] !== right[index]) { return left[index] - right[index]; } } return 0; }; for (const [profileIndex, profile] of profiles.entries()) { const location = `manifest.runtimeProfiles.clientManagers[${profileIndex}]`; const policy = profile.repository?.revisionPolicy; if (policy === "pinned" && !profile.repository?.revision) { errors.push(`${location}.repository.revision: required for pinned revision policy`); } if (policy === "branch" && !profile.repository?.branch) { errors.push(`${location}.repository.branch: required for branch revision policy`); } if (policy === "tag" && !profile.repository?.tag) { errors.push(`${location}.repository.tag: required for tag revision policy`); } if (!profile.deployment) { continue; } if (!profile.version || !parseVersion(profile.version)) { errors.push(`${location}.version: lifecycle deployment requires a semantic version`); } if (!profile.outputArtifacts?.includes(profile.deployment.executableRef ?? "")) { errors.push(`${location}.deployment.executableRef: must name one declared output artifact`); } const actions = new Set(profile.lifecycle?.actions ?? []); const runCapabilities = new Set(profile.deployment.requiredRunCapabilities ?? []); if (!runCapabilities.has("client-manager.deploy")) { errors.push(`${location}.deployment.requiredRunCapabilities: client-manager.deploy is required`); } if (["start", "stop", "restart", "status"].some((action) => actions.has(action)) && !runCapabilities.has("client-manager.control")) { errors.push(`${location}.deployment.requiredRunCapabilities: lifecycle control actions require client-manager.control`); } if (actions.has("update") && !runCapabilities.has("client-manager.update")) { errors.push(`${location}.deployment.requiredRunCapabilities: update requires client-manager.update`); } if (actions.has("rollback") && !runCapabilities.has("client-manager.rollback")) { errors.push(`${location}.deployment.requiredRunCapabilities: rollback requires client-manager.rollback`); } if (actions.has("uninstall") && !runCapabilities.has("client-manager.uninstall")) { errors.push(`${location}.deployment.requiredRunCapabilities: uninstall requires client-manager.uninstall`); } const interval = profile.health?.intervalSeconds ?? 0; const degraded = profile.health?.degradedAfterSeconds ?? 0; const offline = profile.health?.offlineAfterSeconds ?? 0; if (degraded < interval * 2 || offline <= degraded) { errors.push(`${location}.health: degraded threshold must allow two heartbeats and offline threshold must be later`); } if (profile.health?.mode === "component-heartbeat") { const required = new Set(profile.health.requiredCapabilities ?? []); for (const capability of ["component.register", "component.heartbeat", "component.health"]) { if (!required.has(capability)) { errors.push(`${location}.health.requiredCapabilities: ${capability} is required for component-heartbeat mode`); } } } const version = parseVersion(profile.version); const minimum = parseVersion(profile.compatibility?.minimumVersion); const maximum = parseVersion(profile.compatibility?.maximumVersion); if (minimum && maximum && compareVersions(minimum, maximum) > 0) { errors.push(`${location}.compatibility: minimumVersion must not exceed maximumVersion`); } if (version && minimum && compareVersions(version, minimum) < 0) { errors.push(`${location}.compatibility: profile version is below minimumVersion`); } if (version && maximum && compareVersions(version, maximum) > 0) { errors.push(`${location}.compatibility: profile version exceeds maximumVersion`); } if ((profile.updatePolicy?.healthConfirmationSeconds ?? 0) < interval) { errors.push(`${location}.updatePolicy.healthConfirmationSeconds: must cover at least one health interval`); } } return errors; } 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")); errors.push(...validateDependencyPlans(manifest)); errors.push(...validateClientManagerProfiles(manifest)); for (const declaration of referencedLifecycleActions(manifest)) { if (!isSafeRelativeJsonRef(declaration.ref)) { errors.push(`lifecycleAction.${declaration.action}: unsafe file reference`); continue; } const actionPath = path.resolve(manifestDir, declaration.ref); if (!fs.existsSync(actionPath)) { errors.push(`lifecycleAction.${declaration.action}: missing file ${declaration.ref}`); continue; } errors.push(...validateLifecycleActionFile(path.relative(rootDir, actionPath), declaration.action)); } 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}`); }