Make log payloads opaque pass-through
This commit is contained in:
@@ -63,17 +63,18 @@ describe("Game Client Bridge safe projection schema", () => {
|
||||
{ sessionToken: "component-session-material" },
|
||||
{ componentSession: "component-session-material" },
|
||||
{ componentKey: "raw-component-key" },
|
||||
{ sourceSessionId: "component-session-1" },
|
||||
{ result: { ...safeCommand.result, payload: { dsn: "sqlite:///srv/scum/SCUM.db" } } },
|
||||
{ result: { ...safeCommand.result, payload: { endpoint: "tcp://127.0.0.1:9999" } } },
|
||||
{ result: { ...safeCommand.result, payload: { output: "/Users/operator/scum/config.yaml" } } }
|
||||
{ sourceSessionId: "component-session-1" }
|
||||
])("rejects forbidden command projection %#", (unsafe) => {
|
||||
expect(() => parseSafeGameClientBridgeCommand({ ...safeCommand, ...unsafe })).toThrow(/forbidden|sensitive/i);
|
||||
});
|
||||
|
||||
it("rejects credentials nested inside snapshot payloads", () => {
|
||||
const unsafe = structuredClone(safeSnapshotList);
|
||||
unsafe.items[0].payload = { players: [{ playerId: "player-1", credential: "raw-password" }] } as unknown as typeof unsafe.items[0]["payload"];
|
||||
expect(() => parseSafeGameClientBridgeSnapshotList(unsafe)).toThrow(/forbidden/i);
|
||||
it("preserves sensitive-looking command and snapshot payloads", () => {
|
||||
expect(parseSafeGameClientBridgeCommand({
|
||||
...safeCommand,
|
||||
result: { ...safeCommand.result, payload: { dsn: "sqlite:///srv/scum/SCUM.db", endpoint: "tcp://127.0.0.1:9999", output: "/Users/operator/scum/config.yaml" } }
|
||||
})).toMatchObject({ result: { payload: { dsn: "sqlite:///srv/scum/SCUM.db", endpoint: "tcp://127.0.0.1:9999", output: "/Users/operator/scum/config.yaml" } } });
|
||||
|
||||
const payload = { players: [{ playerId: "player-1", credential: "raw-password", note: "password=opaque" }] };
|
||||
expect(parseSafeGameClientBridgeSnapshotList({ ...safeSnapshotList, items: [{ ...safeSnapshotList.items[0], payload }] })).toMatchObject({ items: [{ payload }] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
|
||||
const commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired", "unknown"]);
|
||||
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled", "unknown"]);
|
||||
const forbiddenKeys = new Set([
|
||||
const forbiddenEnvelopeKeys = new Set([
|
||||
"apikey",
|
||||
"accesskey",
|
||||
"accesskeyid",
|
||||
@@ -47,19 +47,6 @@ const forbiddenKeys = new Set([
|
||||
"storagecredential",
|
||||
"token"
|
||||
]);
|
||||
const forbiddenFragments = [
|
||||
"bearer ",
|
||||
"password=",
|
||||
"secret://",
|
||||
"unix://",
|
||||
"tcp://",
|
||||
"mysql://",
|
||||
"postgres://",
|
||||
"sqlite://",
|
||||
"rcon://"
|
||||
];
|
||||
const forbiddenHostPath = /(?:^|[\s"'])(?:\/[Uu]sers\/|\/home\/|\/root\/|\/var\/|\/etc\/|\/opt\/|[a-z]:[\\/]|\\\\[^\\]+\\)/;
|
||||
|
||||
export function parseSafeGameClientBridgeStatus(value: unknown): GameClientBridgeStatusResponse {
|
||||
const record = safeObject(value, "Game Client Bridge status");
|
||||
return {
|
||||
@@ -191,32 +178,12 @@ function parseSnapshot(value: unknown): GameClientBridgeSnapshotResponse {
|
||||
|
||||
function safeObject(value: unknown, label: string): Record<string, unknown> {
|
||||
const record = object(value, label);
|
||||
rejectSensitiveProjection(record);
|
||||
for (const key of Object.keys(record)) {
|
||||
if (forbiddenEnvelopeKeys.has(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) throw new Error("Game Client Bridge response contains a forbidden field");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function rejectSensitiveProjection(value: unknown): void {
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.toLowerCase();
|
||||
if (forbiddenFragments.some((fragment) => normalized.includes(fragment)) || forbiddenHostPath.test(value)) {
|
||||
throw new Error("Game Client Bridge response contains sensitive connection or host data");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(rejectSensitiveProjection);
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (forbiddenKeys.has(key.toLowerCase().replace(/[^a-z0-9]/g, ""))) {
|
||||
throw new Error("Game Client Bridge response contains a forbidden field");
|
||||
}
|
||||
rejectSensitiveProjection(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jsonObject(value: unknown, label: string): GameClientBridgeJsonObject {
|
||||
const record = object(value, label);
|
||||
return Object.fromEntries(Object.entries(record).map(([key, child]) => [key, jsonValue(child, `${label}.${key}`)]));
|
||||
|
||||
@@ -27,14 +27,12 @@ describe("safe job projection schema", () => {
|
||||
expect(() => parseSafeJobResponse({ ...retryingJob, [field]: "forbidden" })).toThrow(/forbidden field/);
|
||||
});
|
||||
|
||||
it("accepts safe typed execution metadata without private content", () => {
|
||||
it("accepts typed execution metadata and preserves content", () => {
|
||||
const parsed = parseSafeJobResponse({
|
||||
...retryingJob,
|
||||
state: "succeeded",
|
||||
executionResult: { kind: "file.write", version: 2, checksum: "sha256:" + "a".repeat(64), sizeBytes: 18, summary: "atomic compare-and-swap file write" }
|
||||
executionResult: { kind: "file.read", version: 2, checksum: "sha256:" + "a".repeat(64), sizeBytes: 18, summary: "bounded regular-file read", content: "password=opaque\n/Users/operator/config.ini" }
|
||||
});
|
||||
expect(parsed.executionResult).toMatchObject({ kind: "file.write", version: 2, sizeBytes: 18 });
|
||||
expect(parsed.executionResult).not.toHaveProperty("content");
|
||||
expect(() => parseSafeJobResponse({ ...retryingJob, executionResult: { content: "private" } })).toThrow(/forbidden field/);
|
||||
expect(parsed.executionResult).toMatchObject({ kind: "file.read", version: 2, sizeBytes: 18, content: "password=opaque\n/Users/operator/config.ini" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { JobResponse, JobState } from "../api/types";
|
||||
|
||||
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
|
||||
const forbiddenProjectionKeys = new Set(["leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket", "credential", "content"]);
|
||||
const forbiddenProjectionKeys = new Set(["leaseToken", "leaseTokenHash", "leaseSessionGeneration", "sessionToken", "secretRef", "hostPath", "socket", "credential"]);
|
||||
|
||||
export function parseSafeJobResponse(value: unknown): JobResponse {
|
||||
if (!isRecord(value)) throw new Error("job projection must be an object");
|
||||
@@ -57,7 +57,8 @@ function optionalExecutionResult(value: Record<string, unknown>, key: string): J
|
||||
version: optionalNumber(field, "version"),
|
||||
checksum: optionalString(field, "checksum"),
|
||||
sizeBytes: optionalNumber(field, "sizeBytes"),
|
||||
summary: optionalString(field, "summary")
|
||||
summary: optionalString(field, "summary"),
|
||||
content: optionalString(field, "content")
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,8 @@ describe("safe dependency and Run update projections", () => {
|
||||
})).toThrow(/forbidden field/);
|
||||
});
|
||||
|
||||
it("rejects raw host paths or credentials hidden in safe-looking evidence", () => {
|
||||
expect(() => parseSafeDependencyCatalog({
|
||||
it("preserves raw host paths or credentials in evidence text", () => {
|
||||
expect(parseSafeDependencyCatalog({
|
||||
serverInstanceId: "server-1",
|
||||
pluginId: "game.runtime",
|
||||
pluginVersion: "1.0.0",
|
||||
@@ -68,6 +68,6 @@ describe("safe dependency and Run update projections", () => {
|
||||
probes: [{ key: "java", kind: "java.version", required: true, state: "present", evidence: "/Users/operator/private" }],
|
||||
plans: [],
|
||||
updatedAt: "2026-07-18T12:00:00Z"
|
||||
})).toThrow(/unsafe runtime details/);
|
||||
}).probes[0].evidence).toBe("/Users/operator/private");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,8 +31,6 @@ const forbiddenProjectionKeys = new Set([
|
||||
"bindings",
|
||||
"downloadref"
|
||||
]);
|
||||
const unsafeProjectionText = /(?:\/Users\/|\/home\/|\/var\/run\/|[A-Za-z]:\\|unix:\/\/|tcp:\/\/|Bearer\s+|password=|token=|sk-[A-Za-z0-9_-]+)/i;
|
||||
|
||||
export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse {
|
||||
const record = requiredRecordValue(value, "dependency catalog");
|
||||
rejectForbiddenProjection(record);
|
||||
@@ -72,9 +70,9 @@ function parseDependencyProbe(value: unknown): DependencyProbeViewResponse {
|
||||
key: requiredString(record, "key"),
|
||||
kind: requiredString(record, "kind"),
|
||||
required: requiredBoolean(record, "required"),
|
||||
minimumVersion: optionalSafeString(record, "minimumVersion"),
|
||||
minimumVersion: optionalString(record, "minimumVersion"),
|
||||
state,
|
||||
evidence: optionalSafeString(record, "evidence"),
|
||||
evidence: optionalString(record, "evidence"),
|
||||
installPlanKey: optionalString(record, "installPlanKey")
|
||||
};
|
||||
}
|
||||
@@ -125,7 +123,7 @@ function parseRunUpdate(record: Record<string, unknown>): RunUpdateJobResponse {
|
||||
idempotencyKey: optionalString(record, "idempotencyKey"),
|
||||
status,
|
||||
phase,
|
||||
message: optionalSafeString(record, "message"),
|
||||
message: optionalString(record, "message"),
|
||||
rollback: requiredBoolean(record, "rollback"),
|
||||
createdAt: requiredString(record, "createdAt"),
|
||||
updatedAt: requiredString(record, "updatedAt")
|
||||
@@ -168,12 +166,6 @@ function optionalString(value: Record<string, unknown>, key: string): string | u
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalSafeString(value: Record<string, unknown>, key: string): string | undefined {
|
||||
const field = optionalString(value, key);
|
||||
if (field && unsafeProjectionText.test(field)) throw new Error(`${key} contains unsafe runtime details`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function requiredChecksum(value: Record<string, unknown>, key: string): string {
|
||||
const field = requiredString(value, key);
|
||||
if (!/^sha256:[a-f0-9]{64}$/.test(field)) throw new Error(`${key} must be a SHA-256 checksum`);
|
||||
|
||||
Reference in New Issue
Block a user