功能修改
This commit is contained in:
@@ -115,6 +115,179 @@ function isSafeRelativeJsonRef(value: string): boolean {
|
||||
return /^(?!\/)(?![A-Za-z]:)(?!.*:\/\/)(?!.*\.\.)[a-zA-Z0-9_./-]+\.json$/.test(value);
|
||||
}
|
||||
|
||||
function identifierTokens(value: string): string[] {
|
||||
return value
|
||||
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
||||
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function unsafeGameClientBridgeCommandTypeReason(value: string): string | undefined {
|
||||
const tokens = identifierTokens(value);
|
||||
const tokenSet = new Set(tokens);
|
||||
if (
|
||||
tokenSet.has("sql") ||
|
||||
((tokenSet.has("database") || tokenSet.has("db")) && tokens.some((token) => ["execute", "exec", "eval", "run", "query", "statement"].includes(token))) ||
|
||||
(tokenSet.has("query") && tokens.some((token) => ["execute", "exec", "eval", "raw", "statement"].includes(token)))
|
||||
) {
|
||||
return "arbitrary SQL or database execution command declarations are not allowed";
|
||||
}
|
||||
if (
|
||||
tokens.some((token) => token === "shell" || token === "powershell" || token === "script" || token === "terminal") ||
|
||||
tokens.some((token) => token === "execute" || token === "exec" || token === "eval") ||
|
||||
((tokenSet.has("command") || tokenSet.has("cmd") || tokenSet.has("process") || tokenSet.has("system") || tokenSet.has("os") || tokenSet.has("executor")) && tokenSet.has("run"))
|
||||
) {
|
||||
return "arbitrary shell, script, terminal, or generic execution command declarations are not allowed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unsafeBridgeSchemaFieldReason(fieldName: string): string | undefined {
|
||||
const tokens = identifierTokens(fieldName);
|
||||
const compact = tokens.join("");
|
||||
const generalReason = unsafeFieldReason(fieldName);
|
||||
if (generalReason) {
|
||||
return generalReason;
|
||||
}
|
||||
if ((tokens.includes("sql") || tokens.includes("query")) && tokens.includes("template") && (tokens.includes("key") || tokens.includes("ref"))) {
|
||||
return undefined;
|
||||
}
|
||||
if (["sql", "rawsql", "sqltext", "sqlquery", "sqlstatement", "rawquery", "statement"].includes(compact)) {
|
||||
return "arbitrary SQL field is not allowed";
|
||||
}
|
||||
if (["shell", "shellcommand", "shellscript", "script", "scriptbody", "terminalcommand", "commandline", "powershell"].includes(compact)) {
|
||||
return "arbitrary shell or script field is not allowed";
|
||||
}
|
||||
if (["hostpath", "rawpath", "absolutepath", "filesystempath"].includes(compact)) {
|
||||
return "raw host path field is not allowed";
|
||||
}
|
||||
if (["runcapability", "executorcapability", "runendpoint", "runsocket", "directrun"].includes(compact)) {
|
||||
return "unsafe executor capability or direct Run field is not allowed";
|
||||
}
|
||||
if (tokens.some((token) => ["socket", "password", "credential", "secret", "token", "dsn"].includes(token))) {
|
||||
return "direct socket or raw credential field is not allowed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unsafeBridgeSchemaStringReasons(value: string): string[] {
|
||||
const reasons = [...unsafeStringReasons(value)];
|
||||
const trimmed = value.trim();
|
||||
const fieldReason = unsafeBridgeSchemaFieldReason(trimmed);
|
||||
if (fieldReason) {
|
||||
reasons.push(fieldReason);
|
||||
}
|
||||
if (/\bselect\b[\s\S]{0,240}\bfrom\b/i.test(trimmed) || /\b(?:insert\s+into|update\s+[a-z0-9_.]+\s+set|delete\s+from|drop\s+table|alter\s+table|create\s+table|attach\s+database|pragma\s+[a-z0-9_]+)/i.test(trimmed)) {
|
||||
reasons.push("arbitrary SQL content is not allowed");
|
||||
}
|
||||
if (/^\s*(?:sh|bash|zsh|powershell|pwsh)\s+-[a-z]*c\b/i.test(trimmed) || /^\s*cmd(?:\.exe)?\s+\/c\b/i.test(trimmed)) {
|
||||
reasons.push("arbitrary shell content is not allowed");
|
||||
}
|
||||
if (/^(?:run|executor|shell|script|terminal)\.(?:socket|endpoint|exec|execute|command)$/i.test(trimmed)) {
|
||||
reasons.push("unsafe executor capability is not allowed");
|
||||
}
|
||||
return [...new Set(reasons)];
|
||||
}
|
||||
|
||||
function scanUnsafeBridgeSchema(value: unknown, location: string): string[] {
|
||||
if (typeof value === "string") {
|
||||
return unsafeBridgeSchemaStringReasons(value).map((reason) => `${location}: ${reason}`);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item, index) => scanUnsafeBridgeSchema(item, `${location}[${index}]`));
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return Object.entries(value).flatMap(([key, child]) => {
|
||||
const keyReason = unsafeBridgeSchemaFieldReason(key);
|
||||
const keyErrors = keyReason ? [`${location}.${key}: ${keyReason}`] : [];
|
||||
return [...keyErrors, ...scanUnsafeBridgeSchema(child, `${location}.${key}`)];
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function validateBoundedBridgeSchema(value: unknown, location: string): string[] {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return [`${location}: bridge schema root must be an object schema`];
|
||||
}
|
||||
const root = value as Record<string, unknown>;
|
||||
const errors: string[] = [];
|
||||
if (root.type !== "object") {
|
||||
errors.push(`${location}: bridge schema root type must be object`);
|
||||
}
|
||||
const visit = (node: unknown, nodeLocation: string): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item, index) => visit(item, `${nodeLocation}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object" || node === null) {
|
||||
return;
|
||||
}
|
||||
const record = node as Record<string, unknown>;
|
||||
if ((record.type === "object" || Object.hasOwn(record, "properties")) && record.additionalProperties !== false) {
|
||||
errors.push(`${nodeLocation}.additionalProperties: bounded object schemas must set additionalProperties to false`);
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
visit(child, `${nodeLocation}.${key}`);
|
||||
}
|
||||
};
|
||||
visit(root, location);
|
||||
return errors;
|
||||
}
|
||||
|
||||
function unsafeSemanticLogEventTypeReason(value: string): string | undefined {
|
||||
const tokens = identifierTokens(value);
|
||||
const tokenSet = new Set(tokens);
|
||||
if (
|
||||
tokens.some((token) => ["shell", "powershell", "script", "terminal", "execute", "exec", "eval"].includes(token)) ||
|
||||
tokens.some((token) => ["credential", "password", "secret", "socket"].includes(token)) ||
|
||||
(tokenSet.has("run") && (tokenSet.has("direct") || tokenSet.has("socket"))) ||
|
||||
(tokenSet.has("path") && (tokenSet.has("host") || tokenSet.has("raw"))) ||
|
||||
(tokenSet.has("sql") && tokens.some((token) => ["query", "statement", "raw", "execute", "exec"].includes(token)))
|
||||
) {
|
||||
return "unsafe SQL, shell, path, credential, or socket event types are not allowed";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function validateBoundedSemanticLogSchema(value: unknown, location: string): string[] {
|
||||
const errors = validateBoundedBridgeSchema(value, location);
|
||||
const visit = (node: unknown, nodeLocation: string): void => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach((item, index) => visit(item, `${nodeLocation}[${index}]`));
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object" || node === null) {
|
||||
return;
|
||||
}
|
||||
const record = node as Record<string, unknown>;
|
||||
if (record.type === "array") {
|
||||
if (!Number.isInteger(record.maxItems) || (record.maxItems as number) < 1 || (record.maxItems as number) > 1000) {
|
||||
errors.push(`${nodeLocation}.maxItems: bounded event arrays must set maxItems between 1 and 1000`);
|
||||
}
|
||||
}
|
||||
if (record.type === "string" && !Object.hasOwn(record, "enum") && !Object.hasOwn(record, "const")) {
|
||||
if (!Number.isInteger(record.maxLength) || (record.maxLength as number) < 1 || (record.maxLength as number) > 4096) {
|
||||
errors.push(`${nodeLocation}.maxLength: bounded event strings must set maxLength between 1 and 4096`);
|
||||
}
|
||||
}
|
||||
if (record.type === "integer" || record.type === "number") {
|
||||
if (typeof record.minimum !== "number" || !Number.isFinite(record.minimum) || typeof record.maximum !== "number" || !Number.isFinite(record.maximum)) {
|
||||
errors.push(`${nodeLocation}: bounded event numbers must set finite minimum and maximum values`);
|
||||
} else if (record.minimum > record.maximum) {
|
||||
errors.push(`${nodeLocation}: event number minimum must not exceed maximum`);
|
||||
}
|
||||
}
|
||||
for (const [key, child] of Object.entries(record)) {
|
||||
visit(child, `${nodeLocation}.${key}`);
|
||||
}
|
||||
};
|
||||
visit(value, location);
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateLifecycleActionFile(actionPath: string, expectedAction?: string): string[] {
|
||||
const action = readJson(path.resolve(rootDir, actionPath));
|
||||
const ajv = new Ajv2020({ allErrors: true });
|
||||
@@ -288,6 +461,539 @@ function validateClientManagerProfiles(manifest: unknown): string[] {
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateGameClientBridgeCatalog(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type BridgeCommand = { type?: string; approvalLevel?: string; payloadSchemaRef?: string; resultSchemaRef?: string };
|
||||
type BridgeQueryTemplate = {
|
||||
key?: string;
|
||||
permission?: string;
|
||||
engine?: string;
|
||||
transportKey?: string;
|
||||
targetKey?: string;
|
||||
parameterSchemaRef?: string;
|
||||
resultSchemaRef?: string;
|
||||
maxRows?: number;
|
||||
timeoutSeconds?: number;
|
||||
};
|
||||
type BridgePage = { pageKey?: string; commandTypes?: string[]; snapshotTypes?: string[]; queryTemplateKeys?: string[] };
|
||||
type BridgeCompanion = {
|
||||
profileKey?: string;
|
||||
configTemplateKey?: string;
|
||||
configSchemaRef?: string;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
commandPollIntervalSeconds?: number;
|
||||
requestTimeoutSeconds?: number;
|
||||
registrationProof?: string;
|
||||
proofMaterialSource?: string;
|
||||
proofMaterialEnv?: string;
|
||||
sessionMode?: string;
|
||||
tlsPolicy?: string;
|
||||
};
|
||||
type PluginPage = { key?: string; permissions?: string[]; bridgeActions?: string[] };
|
||||
type RuntimeTransportProfile = { key?: string; kind?: string; targetKey?: string; capabilities?: string[] };
|
||||
type RuntimeClientManager = { key?: string; configTemplates?: Array<{ key?: string; outputRef?: string }>; health?: { intervalSeconds?: number; requiredCapabilities?: string[] } };
|
||||
const declaration = manifest as {
|
||||
capabilities?: string[];
|
||||
permissions?: string[];
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
pages?: PluginPage[];
|
||||
runtimeProfiles?: { transportProfiles?: RuntimeTransportProfile[]; clientManagers?: RuntimeClientManager[] };
|
||||
gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: Array<{ type?: string }>; queryTemplates?: BridgeQueryTemplate[]; pages?: BridgePage[]; companion?: BridgeCompanion };
|
||||
};
|
||||
const bridge = declaration.gameClientBridge;
|
||||
if (!bridge) {
|
||||
return [];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
const commands = new Set<string>();
|
||||
const snapshots = new Set((bridge.snapshots ?? []).map((snapshot) => snapshot.type ?? ""));
|
||||
const queryTemplates = new Map<string, BridgeQueryTemplate>();
|
||||
const declaredPermissions = new Set(declaration.permissions ?? []);
|
||||
const declaredCapabilities = new Set(declaration.capabilities ?? []);
|
||||
const remoteCapabilities = new Set(declaration.remoteAccess?.runCapabilities ?? []);
|
||||
const remoteDatabaseEngines = new Set(declaration.remoteAccess?.databaseEngines ?? []);
|
||||
const transportProfiles = declaration.runtimeProfiles?.transportProfiles ?? [];
|
||||
const companion = bridge.companion;
|
||||
if (companion) {
|
||||
const location = "manifest.gameClientBridge.companion";
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
||||
if (!manager) {
|
||||
errors.push(`${location}.profileKey: must reference a declared Client Manager profile`);
|
||||
} else {
|
||||
const template = manager.configTemplates?.find((candidate) => candidate.key === companion.configTemplateKey);
|
||||
if (!template) {
|
||||
errors.push(`${location}.configTemplateKey: must reference the Client Manager profile`);
|
||||
} else if (template.outputRef !== "config.yaml") {
|
||||
errors.push(`${location}.configTemplateKey: config template must materialize config.yaml`);
|
||||
}
|
||||
if (manager.health?.intervalSeconds !== companion.heartbeatIntervalSeconds) {
|
||||
errors.push(`${location}.heartbeatIntervalSeconds: must match the Client Manager health interval`);
|
||||
}
|
||||
for (const capability of ["component.register", "component.heartbeat", "component.health", "game-client.bridge"]) {
|
||||
if (!manager.health?.requiredCapabilities?.includes(capability)) {
|
||||
errors.push(`${location}: Client Manager health must require ${capability}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!companion.configSchemaRef || !isSafeRelativeJsonRef(companion.configSchemaRef)) {
|
||||
errors.push(`${location}.configSchemaRef: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
if (companion.registrationProof !== "hmac-sha256" || companion.proofMaterialSource !== "component-package" || companion.sessionMode !== "component-session" || companion.tlsPolicy !== "verify-system-roots") {
|
||||
errors.push(`${location}: secure component registration/session/TLS policy is required`);
|
||||
}
|
||||
const reservedProofEnvironments = new Set(["COMSPEC", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", "HOME", "LD_LIBRARY_PATH", "LD_PRELOAD", "PATH", "PATHEXT", "SHELL", "SYSTEMROOT", "TEMP", "TMP", "USERPROFILE", "WINDIR"]);
|
||||
if (!/^[A-Z][A-Z0-9_]{2,63}$/.test(companion.proofMaterialEnv ?? "") || reservedProofEnvironments.has(companion.proofMaterialEnv ?? "")) {
|
||||
errors.push(`${location}.proofMaterialEnv: must be a bounded environment variable name`);
|
||||
}
|
||||
if (!Number.isInteger(companion.commandPollIntervalSeconds) || (companion.commandPollIntervalSeconds ?? 0) < 1 || (companion.commandPollIntervalSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.commandPollIntervalSeconds: must be between 1 and 60`);
|
||||
}
|
||||
if (!Number.isInteger(companion.requestTimeoutSeconds) || (companion.requestTimeoutSeconds ?? 0) < 1 || (companion.requestTimeoutSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.requestTimeoutSeconds: must be between 1 and 60`);
|
||||
}
|
||||
}
|
||||
for (const [index, command] of (bridge.commands ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.commands[${index}]`;
|
||||
const type = command.type ?? "";
|
||||
const unsafeTypeReason = unsafeGameClientBridgeCommandTypeReason(type);
|
||||
if (unsafeTypeReason) {
|
||||
errors.push(`${location}.type: ${unsafeTypeReason}`);
|
||||
}
|
||||
if (!command.approvalLevel) {
|
||||
errors.push(`${location}.approvalLevel: approval metadata is required`);
|
||||
}
|
||||
for (const [field, ref] of [["payloadSchemaRef", command.payloadSchemaRef], ["resultSchemaRef", command.resultSchemaRef]] as const) {
|
||||
if (ref && !isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
}
|
||||
commands.add(type);
|
||||
}
|
||||
for (const [index, queryTemplate] of (bridge.queryTemplates ?? []).entries()) {
|
||||
const location = `manifest.gameClientBridge.queryTemplates[${index}]`;
|
||||
const key = queryTemplate.key ?? "";
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/.test(key)) {
|
||||
errors.push(`${location}.key: query template key is unsafe`);
|
||||
}
|
||||
if (queryTemplates.has(key)) {
|
||||
errors.push(`${location}.key: duplicate query template ${key}`);
|
||||
}
|
||||
queryTemplates.set(key, queryTemplate);
|
||||
if (!queryTemplate.permission || !declaredPermissions.has(queryTemplate.permission)) {
|
||||
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
||||
}
|
||||
if (queryTemplate.engine !== "sqlite") {
|
||||
errors.push(`${location}.engine: only sqlite read-only query templates are allowed`);
|
||||
}
|
||||
for (const [field, ref] of [["parameterSchemaRef", queryTemplate.parameterSchemaRef], ["resultSchemaRef", queryTemplate.resultSchemaRef]] as const) {
|
||||
if (!ref || !isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}.${field}: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
}
|
||||
if (!Number.isInteger(queryTemplate.maxRows) || (queryTemplate.maxRows ?? 0) < 1 || (queryTemplate.maxRows ?? 0) > 500) {
|
||||
errors.push(`${location}.maxRows: must be an integer between 1 and 500`);
|
||||
}
|
||||
if (!Number.isInteger(queryTemplate.timeoutSeconds) || (queryTemplate.timeoutSeconds ?? 0) < 1 || (queryTemplate.timeoutSeconds ?? 0) > 60) {
|
||||
errors.push(`${location}.timeoutSeconds: must be an integer between 1 and 60`);
|
||||
}
|
||||
const transportProfile = transportProfiles.find((profile) => profile.key === queryTemplate.transportKey);
|
||||
if (!transportProfile) {
|
||||
errors.push(`${location}.transportKey: undeclared transport profile ${queryTemplate.transportKey ?? ""}`);
|
||||
continue;
|
||||
}
|
||||
if (transportProfile.kind !== "sqlite") {
|
||||
errors.push(`${location}.transportKey: transport profile must use sqlite`);
|
||||
}
|
||||
if (!queryTemplate.targetKey || transportProfile.targetKey !== queryTemplate.targetKey) {
|
||||
errors.push(`${location}.targetKey: must match the declared sqlite transport target`);
|
||||
}
|
||||
if (!transportProfile.capabilities?.includes("remote.run.db.sqlite.query")) {
|
||||
errors.push(`${location}.transportKey: sqlite transport must declare remote.run.db.sqlite.query`);
|
||||
}
|
||||
if (!declaredCapabilities.has("remote.run.db.sqlite.query") || !remoteCapabilities.has("remote.run.db.sqlite.query") || !remoteDatabaseEngines.has("sqlite")) {
|
||||
errors.push(`${location}: sqlite query templates require the plugin and remote-access sqlite query capability`);
|
||||
}
|
||||
}
|
||||
for (const [index, page] of (bridge.pages ?? []).entries()) {
|
||||
for (const commandType of page.commandTypes ?? []) {
|
||||
if (!commands.has(commandType)) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].commandTypes: undeclared command ${commandType}`);
|
||||
}
|
||||
}
|
||||
for (const snapshotType of page.snapshotTypes ?? []) {
|
||||
if (!snapshots.has(snapshotType)) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].snapshotTypes: undeclared snapshot ${snapshotType}`);
|
||||
}
|
||||
}
|
||||
for (const queryTemplateKey of page.queryTemplateKeys ?? []) {
|
||||
const queryTemplate = queryTemplates.get(queryTemplateKey);
|
||||
if (!queryTemplate) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: undeclared query template ${queryTemplateKey}`);
|
||||
continue;
|
||||
}
|
||||
const pluginPage = declaration.pages?.find((candidate) => candidate.key === page.pageKey);
|
||||
if (!pluginPage?.permissions?.includes(queryTemplate.permission ?? "")) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare query template permission ${queryTemplate.permission ?? ""}`);
|
||||
}
|
||||
if (!pluginPage?.bridgeActions?.includes("remote.access.request")) {
|
||||
errors.push(`manifest.gameClientBridge.pages[${index}].queryTemplateKeys: page must declare remote.access.request`);
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateRuntimeLogEventCatalog(manifest: unknown): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type RuntimeLogSource = { key?: string; retentionDays?: number };
|
||||
type RuntimeLogEvent = {
|
||||
key?: string;
|
||||
sourceKey?: string;
|
||||
eventType?: string;
|
||||
permission?: string;
|
||||
schemaRef?: string;
|
||||
retentionDays?: number;
|
||||
severity?: string;
|
||||
};
|
||||
const declaration = manifest as {
|
||||
permissions?: string[];
|
||||
runtimeProfiles?: { logSources?: RuntimeLogSource[]; logEvents?: RuntimeLogEvent[] };
|
||||
};
|
||||
const logEvents = declaration.runtimeProfiles?.logEvents ?? [];
|
||||
const logSources = new Map((declaration.runtimeProfiles?.logSources ?? []).map((source) => [source.key ?? "", source]));
|
||||
const permissions = new Set(declaration.permissions ?? []);
|
||||
const keys = new Set<string>();
|
||||
const eventTypes = new Set<string>();
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const [index, event] of logEvents.entries()) {
|
||||
const location = `manifest.runtimeProfiles.logEvents[${index}]`;
|
||||
const key = event.key ?? "";
|
||||
const eventType = event.eventType ?? "";
|
||||
if (keys.has(key)) {
|
||||
errors.push(`${location}.key: duplicate semantic log event key ${key}`);
|
||||
}
|
||||
keys.add(key);
|
||||
if (eventTypes.has(eventType)) {
|
||||
errors.push(`${location}.eventType: duplicate semantic log event type ${eventType}`);
|
||||
}
|
||||
eventTypes.add(eventType);
|
||||
const unsafeTypeReason = unsafeSemanticLogEventTypeReason(eventType);
|
||||
if (unsafeTypeReason) {
|
||||
errors.push(`${location}.eventType: ${unsafeTypeReason}`);
|
||||
}
|
||||
const source = logSources.get(event.sourceKey ?? "");
|
||||
if (!source) {
|
||||
errors.push(`${location}.sourceKey: undeclared log source ${event.sourceKey ?? ""}`);
|
||||
}
|
||||
if (!event.permission || !permissions.has(event.permission)) {
|
||||
errors.push(`${location}.permission: permission must be declared by the plugin manifest`);
|
||||
}
|
||||
if (!event.schemaRef || !isSafeRelativeJsonRef(event.schemaRef)) {
|
||||
errors.push(`${location}.schemaRef: raw host paths and unsafe schema references are not allowed`);
|
||||
}
|
||||
if (!Number.isInteger(event.retentionDays) || (event.retentionDays ?? 0) < 1 || (event.retentionDays ?? 0) > 365) {
|
||||
errors.push(`${location}.retentionDays: must be an integer between 1 and 365`);
|
||||
}
|
||||
if (source?.retentionDays && (event.retentionDays ?? 0) > source.retentionDays) {
|
||||
errors.push(`${location}.retentionDays: must not exceed source retentionDays`);
|
||||
}
|
||||
if (!event.severity || !["info", "notice", "warning", "critical"].includes(event.severity)) {
|
||||
errors.push(`${location}.severity: must be info, notice, warning, or critical`);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
type GameClientBridgeSchemaReference = {
|
||||
location: string;
|
||||
ref: string;
|
||||
};
|
||||
|
||||
function referencedGameClientBridgeSchemas(manifest: unknown): GameClientBridgeSchemaReference[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
type BridgeCommand = { payloadSchemaRef?: string; resultSchemaRef?: string };
|
||||
type BridgeSnapshot = { schemaRef?: string };
|
||||
type BridgeQueryTemplate = { parameterSchemaRef?: string; resultSchemaRef?: string };
|
||||
const bridge = (manifest as { gameClientBridge?: { commands?: BridgeCommand[]; snapshots?: BridgeSnapshot[]; queryTemplates?: BridgeQueryTemplate[] } }).gameClientBridge;
|
||||
if (!bridge) {
|
||||
return [];
|
||||
}
|
||||
const refs: GameClientBridgeSchemaReference[] = [];
|
||||
for (const [index, command] of (bridge.commands ?? []).entries()) {
|
||||
if (command.payloadSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.commands[${index}].payloadSchemaRef`, ref: command.payloadSchemaRef });
|
||||
}
|
||||
if (command.resultSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.commands[${index}].resultSchemaRef`, ref: command.resultSchemaRef });
|
||||
}
|
||||
}
|
||||
for (const [index, snapshot] of (bridge.snapshots ?? []).entries()) {
|
||||
if (snapshot.schemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.snapshots[${index}].schemaRef`, ref: snapshot.schemaRef });
|
||||
}
|
||||
}
|
||||
for (const [index, queryTemplate] of (bridge.queryTemplates ?? []).entries()) {
|
||||
if (queryTemplate.parameterSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].parameterSchemaRef`, ref: queryTemplate.parameterSchemaRef });
|
||||
}
|
||||
if (queryTemplate.resultSchemaRef) {
|
||||
refs.push({ location: `manifest.gameClientBridge.queryTemplates[${index}].resultSchemaRef`, ref: queryTemplate.resultSchemaRef });
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeSchemaFiles(manifest: unknown, manifestDir: string): string[] {
|
||||
const errors: string[] = [];
|
||||
for (const declaration of referencedGameClientBridgeSchemas(manifest)) {
|
||||
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
||||
errors.push(`${declaration.location}: raw host paths and unsafe schema references are not allowed`);
|
||||
continue;
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, declaration.ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
errors.push(`${declaration.location}: missing bridge schema file ${declaration.ref}`);
|
||||
continue;
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
errors.push(`${declaration.location}: bridge schema must remain inside the plugin manifest directory`);
|
||||
continue;
|
||||
}
|
||||
let schema: unknown;
|
||||
try {
|
||||
schema = readJson(schemaPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON";
|
||||
errors.push(`${declaration.location}: bridge schema is not valid JSON: ${message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const schemaAjv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!schemaAjv.validateSchema(schema as AnySchema)) {
|
||||
errors.push(...formatErrors(`${declaration.location}.schema`, schemaAjv.errors));
|
||||
} else {
|
||||
schemaAjv.compile(schema as AnySchema);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
||||
errors.push(`${declaration.location}: bridge schema is invalid: ${message}`);
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${declaration.location}.schema`));
|
||||
errors.push(...validateBoundedBridgeSchema(schema, `${declaration.location}.schema`));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateRuntimeLogEventSchemaFiles(manifest: unknown, manifestDir: string): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const logEvents = (manifest as { runtimeProfiles?: { logEvents?: Array<{ schemaRef?: string }> } }).runtimeProfiles?.logEvents ?? [];
|
||||
const errors: string[] = [];
|
||||
for (const [index, event] of logEvents.entries()) {
|
||||
const location = `manifest.runtimeProfiles.logEvents[${index}].schemaRef`;
|
||||
const ref = event.schemaRef;
|
||||
if (!ref || !isSafeRelativeJsonRef(ref)) {
|
||||
errors.push(`${location}: raw host paths and unsafe schema references are not allowed`);
|
||||
continue;
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
errors.push(`${location}: missing semantic log event schema file ${ref}`);
|
||||
continue;
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
errors.push(`${location}: semantic log event schema must remain inside the plugin manifest directory`);
|
||||
continue;
|
||||
}
|
||||
let schema: unknown;
|
||||
try {
|
||||
schema = readJson(schemaPath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON";
|
||||
errors.push(`${location}: semantic log event schema is not valid JSON: ${message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const schemaAjv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!schemaAjv.validateSchema(schema as AnySchema)) {
|
||||
errors.push(...formatErrors(`${location}.schema`, schemaAjv.errors));
|
||||
} else {
|
||||
schemaAjv.compile(schema as AnySchema);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "invalid JSON Schema";
|
||||
errors.push(`${location}: semantic log event schema is invalid: ${message}`);
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
||||
errors.push(...validateBoundedSemanticLogSchema(schema, `${location}.schema`));
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
type CompanionConfigDeclaration = {
|
||||
profileKey?: string;
|
||||
configSchemaRef?: string;
|
||||
registrationProof?: string;
|
||||
proofMaterialEnv?: string;
|
||||
sessionMode?: string;
|
||||
tlsPolicy?: string;
|
||||
heartbeatIntervalSeconds?: number;
|
||||
commandPollIntervalSeconds?: number;
|
||||
requestTimeoutSeconds?: number;
|
||||
};
|
||||
|
||||
type CompanionRuntimeProfile = {
|
||||
key?: string;
|
||||
version?: string;
|
||||
supportedTargets?: Array<{ os?: string; arch?: string }>;
|
||||
health?: { requiredCapabilities?: string[] };
|
||||
};
|
||||
|
||||
function recordValue(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
function sameStringSet(left: unknown, right: string[]): boolean {
|
||||
if (!Array.isArray(left) || !left.every((item) => typeof item === "string") || left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
const leftSet = new Set(left);
|
||||
const rightSet = new Set(right);
|
||||
return leftSet.size === left.length && rightSet.size === right.length && [...leftSet].every((item) => rightSet.has(item));
|
||||
}
|
||||
|
||||
function isSafeHTTPSBaseURL(value: unknown): boolean {
|
||||
if (typeof value !== "string" || value.length === 0 || /\s/.test(value)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" && parsed.hostname !== "" && parsed.username === "" && parsed.password === "" && parsed.search === "" && parsed.hash === "" && (parsed.pathname === "" || parsed.pathname === "/");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function validateGeneratedCompanionConfig(manifest: unknown, companion: CompanionConfigDeclaration, example: unknown, location: string): string[] {
|
||||
const declaration = manifest as {
|
||||
id?: string;
|
||||
runtimeProfiles?: { clientManagers?: CompanionRuntimeProfile[] };
|
||||
};
|
||||
const manager = declaration.runtimeProfiles?.clientManagers?.find((candidate) => candidate.key === companion.profileKey);
|
||||
const root = recordValue(example);
|
||||
const platform = recordValue(root?.platform);
|
||||
const component = recordValue(root?.component);
|
||||
const proof = recordValue(root?.proof);
|
||||
const session = recordValue(root?.session);
|
||||
const timing = recordValue(root?.timing);
|
||||
const tls = recordValue(root?.tls);
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!isSafeHTTPSBaseURL(platform?.baseUrl)) {
|
||||
errors.push(`${location}.example.platform.baseUrl: must be an HTTPS origin without userinfo, path, query, or fragment`);
|
||||
}
|
||||
if (component?.pluginId !== declaration.id) {
|
||||
errors.push(`${location}.example.component.pluginId: must match the plugin manifest id`);
|
||||
}
|
||||
if (component?.profileKey !== companion.profileKey) {
|
||||
errors.push(`${location}.example.component.profileKey: must match the companion profileKey`);
|
||||
}
|
||||
if (manager?.version && component?.version !== manager.version) {
|
||||
errors.push(`${location}.example.component.version: must match the Client Manager profile version`);
|
||||
}
|
||||
if (manager && !manager.supportedTargets?.some((target) => target.os === component?.targetOs && target.arch === component?.targetArch)) {
|
||||
errors.push(`${location}.example.component: targetOs and targetArch must match a supported Client Manager target`);
|
||||
}
|
||||
if (proof?.mode !== companion.registrationProof) {
|
||||
errors.push(`${location}.example.proof.mode: must match the companion registrationProof`);
|
||||
}
|
||||
if (proof?.materialEnv !== companion.proofMaterialEnv) {
|
||||
errors.push(`${location}.example.proof.materialEnv: must match the companion proofMaterialEnv`);
|
||||
}
|
||||
if (session?.mode !== companion.sessionMode) {
|
||||
errors.push(`${location}.example.session.mode: must match the companion sessionMode`);
|
||||
}
|
||||
if (tls?.policy !== companion.tlsPolicy) {
|
||||
errors.push(`${location}.example.tls.policy: must match the companion tlsPolicy`);
|
||||
}
|
||||
for (const [field, expected] of [
|
||||
["heartbeatIntervalSeconds", companion.heartbeatIntervalSeconds],
|
||||
["commandPollIntervalSeconds", companion.commandPollIntervalSeconds],
|
||||
["requestTimeoutSeconds", companion.requestTimeoutSeconds]
|
||||
] as const) {
|
||||
if (timing?.[field] !== expected) {
|
||||
errors.push(`${location}.example.timing.${field}: must match the companion declaration`);
|
||||
}
|
||||
}
|
||||
if (!manager || !sameStringSet(root?.capabilities, manager.health?.requiredCapabilities ?? [])) {
|
||||
errors.push(`${location}.example.capabilities: must exactly match the Client Manager requiredCapabilities`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateGameClientBridgeCompanionConfig(manifest: unknown, manifestDir: string): string[] {
|
||||
if (typeof manifest !== "object" || manifest === null) {
|
||||
return [];
|
||||
}
|
||||
const companion = (manifest as { gameClientBridge?: { companion?: CompanionConfigDeclaration } }).gameClientBridge?.companion;
|
||||
if (!companion) {
|
||||
return [];
|
||||
}
|
||||
const location = "manifest.gameClientBridge.companion.configSchemaRef";
|
||||
const ref = companion.configSchemaRef ?? "";
|
||||
if (!isSafeRelativeJsonRef(ref)) {
|
||||
return [`${location}: raw host paths and unsafe schema references are not allowed`];
|
||||
}
|
||||
const schemaPath = path.resolve(manifestDir, ref);
|
||||
if (!fs.existsSync(schemaPath) || !fs.statSync(schemaPath).isFile()) {
|
||||
return [`${location}: missing companion config schema file ${ref}`];
|
||||
}
|
||||
const relativeRealPath = path.relative(fs.realpathSync(manifestDir), fs.realpathSync(schemaPath));
|
||||
if (relativeRealPath === ".." || relativeRealPath.startsWith(`..${path.sep}`) || path.isAbsolute(relativeRealPath)) {
|
||||
return [`${location}: companion config schema must remain inside the plugin manifest directory`];
|
||||
}
|
||||
const errors: string[] = [];
|
||||
try {
|
||||
const schema = readJson(schemaPath) as AnySchema;
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false });
|
||||
if (!ajv.validateSchema(schema)) {
|
||||
errors.push(...formatErrors(`${location}.schema`, ajv.errors));
|
||||
return errors;
|
||||
}
|
||||
errors.push(...scanUnsafeBridgeSchema(schema, `${location}.schema`));
|
||||
errors.push(...validateBoundedBridgeSchema(schema, `${location}.schema`));
|
||||
const validate = ajv.compile(schema);
|
||||
const examplePath = schemaPath.replace(/\.schema\.json$/, ".generated.example.json");
|
||||
if (!fs.existsSync(examplePath)) {
|
||||
errors.push(`${location}: missing generated companion config example`);
|
||||
return errors;
|
||||
}
|
||||
const example = readJson(examplePath);
|
||||
if (!validate(example)) {
|
||||
errors.push(...formatErrors(`${location}.example`, validate.errors));
|
||||
}
|
||||
errors.push(...scanUnsafeValues(example, `${location}.example`));
|
||||
errors.push(...validateGeneratedCompanionConfig(manifest, companion, example, location));
|
||||
const serialized = `${JSON.stringify(schema)}\n${JSON.stringify(example)}`;
|
||||
if (/\/api\/v1\/scum-clients\//i.test(serialized) || /InsecureSkipVerify/i.test(serialized) || /"(?:authKey|componentKey|credential|password|sessionToken|secret)"\s*:/i.test(serialized)) {
|
||||
errors.push(`${location}: companion config must not contain legacy endpoints, insecure TLS, or inline proof/session material`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push(`${location}: companion config schema or example is invalid: ${error instanceof Error ? error.message : "invalid JSON"}`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateManifestFile(manifestPath: string): string[] {
|
||||
const absoluteManifestPath = path.resolve(rootDir, manifestPath);
|
||||
const manifest = readJson(absoluteManifestPath);
|
||||
@@ -305,6 +1011,11 @@ export function validateManifestFile(manifestPath: string): string[] {
|
||||
errors.push(...scanUnsafeValues(manifest, "manifest"));
|
||||
errors.push(...validateDependencyPlans(manifest));
|
||||
errors.push(...validateClientManagerProfiles(manifest));
|
||||
errors.push(...validateGameClientBridgeCatalog(manifest));
|
||||
errors.push(...validateGameClientBridgeSchemaFiles(manifest, manifestDir));
|
||||
errors.push(...validateGameClientBridgeCompanionConfig(manifest, manifestDir));
|
||||
errors.push(...validateRuntimeLogEventCatalog(manifest));
|
||||
errors.push(...validateRuntimeLogEventSchemaFiles(manifest, manifestDir));
|
||||
|
||||
for (const declaration of referencedLifecycleActions(manifest)) {
|
||||
if (!isSafeRelativeJsonRef(declaration.ref)) {
|
||||
|
||||
Reference in New Issue
Block a user