first commit
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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("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")
|
||||
) {
|
||||
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")
|
||||
) {
|
||||
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");
|
||||
}
|
||||
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 -- <manifest-path>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const errors = validateManifestFile(target);
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`validated ${target}`);
|
||||
}
|
||||
Reference in New Issue
Block a user