Add SCUM query asset boundary tests
This commit is contained in:
@@ -80,6 +80,68 @@ function writeFixtureJSON(fixtureDir: string, relativePath: string, value: unkno
|
||||
fs.writeFileSync(target, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
||||
}
|
||||
|
||||
const currentSCUMSchemaFingerprint = "sha256:ebd477d6c6ead9c34c41169af489236d762a76186d45dedd753d50f1b81e26f0";
|
||||
const scumSQLiteQueryAssetVersion = "scum-sqlite-query-v1";
|
||||
const scumPaginatedQueryKeys = new Set(["scum-players-read", "scum-squads-read", "scum-squad-members-read", "scum-vehicles-read", "scum-flags-read", "scum-positions-read"]);
|
||||
const unsafeSCUMSQLiteReadStatementPattern = /;|\b(INSERT|UPDATE|DELETE|REPLACE|DROP|ALTER|CREATE|VACUUM|ATTACH|DETACH|ANALYZE|REINDEX)\b|\bload_extension\s*\(|\bPRAGMA\s+(?!(?:table_info|foreign_key_list|index_list|index_info|schema_version|data_version)\b)/i;
|
||||
|
||||
type SCUMSQLiteQueryAssetParameter = {
|
||||
name: string;
|
||||
binding: string;
|
||||
type?: string;
|
||||
required?: boolean;
|
||||
nullable?: boolean;
|
||||
minimum?: number;
|
||||
maximum?: number;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
enum?: string[];
|
||||
};
|
||||
|
||||
type SCUMSQLiteQueryAsset = {
|
||||
key?: string;
|
||||
adapterVersion?: string;
|
||||
queryVersion?: string;
|
||||
capability?: string;
|
||||
requiredSchemaFingerprint?: string;
|
||||
statementType?: string;
|
||||
statement?: string;
|
||||
parameters?: SCUMSQLiteQueryAssetParameter[];
|
||||
safety?: {
|
||||
queryOnly?: boolean;
|
||||
readOnlyConnection?: boolean;
|
||||
forbidMultipleStatements?: boolean;
|
||||
forbidAttach?: boolean;
|
||||
forbidWritePragmas?: boolean;
|
||||
forbidExtensionLoading?: boolean;
|
||||
maxRows?: number;
|
||||
timeoutMs?: number;
|
||||
maxResultBytes?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type JSONSchemaObject = {
|
||||
type?: unknown;
|
||||
additionalProperties?: boolean;
|
||||
required?: string[];
|
||||
properties?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
|
||||
function sortedValues(values: Iterable<string>): string[] {
|
||||
return [...values].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function extractSQLiteParameterBindings(statement: string): string[] {
|
||||
return [...statement.matchAll(/:[A-Za-z_][A-Za-z0-9_]*/g)].map((match) => match[0]);
|
||||
}
|
||||
|
||||
function expectSCUMSQLiteReadStatementBoundary(statementType: string | undefined, statement: string | undefined): void {
|
||||
expect(statementType).toBe("single-select-or-cte");
|
||||
const trimmed = statement?.trim() ?? "";
|
||||
expect(trimmed).toMatch(/^(WITH|SELECT)\b/i);
|
||||
expect(trimmed).not.toMatch(unsafeSCUMSQLiteReadStatementPattern);
|
||||
}
|
||||
|
||||
function sha256FixtureDigest(fixtureDir: string, relativePath: string): string {
|
||||
return `sha256:${crypto.createHash("sha256").update(fs.readFileSync(path.join(fixtureDir, relativePath))).digest("hex")}`;
|
||||
}
|
||||
@@ -223,6 +285,7 @@ describe("plugin manifest validation", () => {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as GamePluginManifest & { scumLiveData: SCUMLiveDataManifestDeclaration };
|
||||
const assetPaths = new Set(manifest.assetFiles?.map((file) => file.path) ?? []);
|
||||
const queries = manifest.scumLiveData.sqliteQueries ?? [];
|
||||
expect(manifest.scumLiveData.schemaVersion).toBe("1");
|
||||
expect(queries.map((query) => query.key)).toEqual([
|
||||
"scum-players-read",
|
||||
"scum-player-details-read",
|
||||
@@ -240,11 +303,21 @@ describe("plugin manifest validation", () => {
|
||||
const assetPath = path.join(pluginDir, query.assetPath);
|
||||
const actualDigest = `sha256:${crypto.createHash("sha256").update(fs.readFileSync(assetPath)).digest("hex")}`;
|
||||
expect(query.digest).toBe(actualDigest);
|
||||
const asset = JSON.parse(fs.readFileSync(assetPath, "utf8")) as { requiredSchemaFingerprint?: string; statement?: string; safety?: { queryOnly?: boolean; readOnlyConnection?: boolean } };
|
||||
const asset = JSON.parse(fs.readFileSync(assetPath, "utf8")) as SCUMSQLiteQueryAsset;
|
||||
expect(asset).toMatchObject({ key: `${query.key}-v1`, adapterVersion: query.adapterVersion, queryVersion: scumSQLiteQueryAssetVersion, capability: query.capability });
|
||||
expect(query.requiredSchemaFingerprint).toBe(currentSCUMSchemaFingerprint);
|
||||
expect(asset.requiredSchemaFingerprint).toBe(query.requiredSchemaFingerprint);
|
||||
expect(asset.safety).toMatchObject({ queryOnly: true, readOnlyConnection: true });
|
||||
expect(asset.statement).toMatch(/^(WITH|SELECT)\b/i);
|
||||
expect(asset.statement).not.toMatch(/;|\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|ATTACH)\b/i);
|
||||
expectSCUMSQLiteReadStatementBoundary(asset.statementType, asset.statement);
|
||||
expect(query.maxRows).toBeGreaterThan(0);
|
||||
expect(query.maxRows).toBeLessThanOrEqual(500);
|
||||
expect(query.timeoutMs).toBeGreaterThan(0);
|
||||
expect(query.timeoutMs).toBeLessThanOrEqual(60000);
|
||||
expect(query.maxResultBytes).toBeGreaterThan(0);
|
||||
expect(query.maxResultBytes).toBeLessThanOrEqual(1048576);
|
||||
expect(asset.safety).toMatchObject({ queryOnly: true, readOnlyConnection: true, forbidMultipleStatements: true, forbidAttach: true, forbidWritePragmas: true, forbidExtensionLoading: true });
|
||||
expect(asset.safety?.maxRows).toBeLessThanOrEqual(query.maxRows);
|
||||
expect(asset.safety?.timeoutMs).toBeLessThanOrEqual(query.timeoutMs);
|
||||
expect(asset.safety?.maxResultBytes).toBeLessThanOrEqual(query.maxResultBytes);
|
||||
if (["scum-squads-read", "scum-squad-members-read", "scum-vehicles-read", "scum-flags-read"].includes(query.key)) {
|
||||
expect(asset.statement).toContain("CAST(NULL");
|
||||
}
|
||||
@@ -252,11 +325,65 @@ describe("plugin manifest validation", () => {
|
||||
for (const schemaRef of [query.parameterSchemaRef, query.resultSchemaRef]) {
|
||||
expect(fs.existsSync(path.join(pluginDir, schemaRef))).toBe(true);
|
||||
}
|
||||
const parameterSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.parameterSchemaRef), "utf8")) as JSONSchemaObject;
|
||||
const parameters = asset.parameters ?? [];
|
||||
const parameterNames = parameters.map((parameter) => parameter.name);
|
||||
const parameterBindings = new Set(extractSQLiteParameterBindings(asset.statement ?? ""));
|
||||
expect(parameterSchema).toMatchObject({ type: "object", additionalProperties: false });
|
||||
expect(new Set(parameterNames).size).toBe(parameterNames.length);
|
||||
expect(sortedValues(parameterSchema.required ?? [])).toEqual(sortedValues(parameterNames));
|
||||
expect(sortedValues(Object.keys(parameterSchema.properties ?? {}))).toEqual(sortedValues(parameterNames));
|
||||
for (const parameter of parameters) {
|
||||
expect(parameter.required).toBe(true);
|
||||
expect(parameter.binding).toBe(`:${parameter.name}`);
|
||||
expect(parameterBindings.has(parameter.binding)).toBe(true);
|
||||
const schemaProperty = parameterSchema.properties?.[parameter.name] ?? {};
|
||||
if (parameter.enum !== undefined) {
|
||||
expect(schemaProperty.enum).toEqual(parameter.enum);
|
||||
if (schemaProperty.type !== undefined) expect(schemaProperty.type).toBe(parameter.type);
|
||||
} else if (parameter.type !== undefined) {
|
||||
expect(schemaProperty.type).toEqual(parameter.nullable ? [parameter.type, "null"] : parameter.type);
|
||||
}
|
||||
for (const bound of ["minimum", "maximum", "minLength", "maxLength"] as const) {
|
||||
if (parameter[bound] !== undefined) expect(schemaProperty[bound]).toBe(parameter[bound]);
|
||||
}
|
||||
}
|
||||
expect(sortedValues(parameterBindings)).toEqual(sortedValues(parameters.map((parameter) => parameter.binding)));
|
||||
if (scumPaginatedQueryKeys.has(query.key)) {
|
||||
const limit = parameters.find((parameter) => parameter.name === "limit");
|
||||
const offset = parameters.find((parameter) => parameter.name === "offset");
|
||||
expect(limit).toMatchObject({ binding: ":limit", type: "integer", required: true, minimum: 1 });
|
||||
expect(limit?.maximum).toBeLessThanOrEqual(query.maxRows);
|
||||
expect(offset).toMatchObject({ binding: ":offset", type: "integer", required: true, minimum: 0 });
|
||||
expect(offset?.maximum).toBeLessThanOrEqual(1000000);
|
||||
expect(asset.statement).toMatch(/\bLIMIT\s+:limit\b/i);
|
||||
expect(asset.statement).toMatch(/\bOFFSET\s+:offset\b/i);
|
||||
}
|
||||
const resultSchema = JSON.parse(fs.readFileSync(path.join(pluginDir, query.resultSchemaRef), "utf8")) as { properties?: { rows?: { maxItems?: number } } };
|
||||
expect(resultSchema.properties?.rows?.maxItems).toBeLessThanOrEqual(query.maxRows);
|
||||
expect(resultSchema.properties?.rows?.maxItems).toBeLessThanOrEqual(asset.safety?.maxRows ?? query.maxRows);
|
||||
}
|
||||
});
|
||||
|
||||
it("guards SCUM live query asset SQL boundary rules against unsafe input classes", () => {
|
||||
const rejectedStatements = [
|
||||
"SELECT 1; SELECT 2",
|
||||
"INSERT INTO players VALUES (1)",
|
||||
"UPDATE players SET name = 'x'",
|
||||
"DELETE FROM players",
|
||||
"DROP TABLE players",
|
||||
"ALTER TABLE players ADD COLUMN x INTEGER",
|
||||
"CREATE TABLE unsafe (id INTEGER)",
|
||||
"ATTACH DATABASE 'other.db' AS other",
|
||||
"DETACH DATABASE other",
|
||||
"SELECT load_extension('unsafe')",
|
||||
"PRAGMA journal_mode = WAL",
|
||||
"PRAGMA writable_schema = ON"
|
||||
];
|
||||
const allowedReadBoundaries = ["SELECT 1", "WITH one AS (SELECT 1) SELECT * FROM one", "PRAGMA table_info('players')", "PRAGMA foreign_key_list('players')"];
|
||||
for (const statement of rejectedStatements) expect(statement).toMatch(unsafeSCUMSQLiteReadStatementPattern);
|
||||
for (const statement of allowedReadBoundaries) expect(statement).not.toMatch(unsafeSCUMSQLiteReadStatementPattern);
|
||||
});
|
||||
|
||||
it("requires SCUM schema probe targets to have generated Run workspace data targets", () => {
|
||||
const errors = validateTemporaryScumCompanionManifest((manifest) => {
|
||||
manifest.runtimeProfiles.dataTargets = [];
|
||||
|
||||
Reference in New Issue
Block a user