Make log payloads opaque pass-through
This commit is contained in:
@@ -293,10 +293,7 @@ func sameLogBatchRecord(left domain.LogBatchRecord, right domain.LogBatchRecord)
|
|||||||
if left.FirstSeq != right.FirstSeq || left.LastSeq != right.LastSeq {
|
if left.FirstSeq != right.FirstSeq || left.LastSeq != right.LastSeq {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if left.Checksum == right.Checksum {
|
return left.Checksum == right.Checksum
|
||||||
return true
|
|
||||||
}
|
|
||||||
return len(left.Entries) == 1 && right.Checksum == validator.LogLineChecksum(left.Entries[0].Line)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func readLogSegment(path string) (domain.LogBatchRecord, error) {
|
func readLogSegment(path string) (domain.LogBatchRecord, error) {
|
||||||
|
|||||||
@@ -177,13 +177,7 @@ func jobIDFromLogBatch(batch domain.LogBatchIngest) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIngest) bool {
|
func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIngest) bool {
|
||||||
if record.Checksum == batch.Checksum {
|
return record.Checksum == batch.Checksum
|
||||||
return true
|
|
||||||
}
|
|
||||||
if len(record.Entries) == 1 && len(batch.Entries) == 1 {
|
|
||||||
return batch.Checksum == validator.LogLineChecksum(record.Entries[0].Line)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) {
|
func (svc *CoreService) QueryLogStream(query domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
|
|
||||||
func TestLogIngestPreservesOpaqueFields(t *testing.T) {
|
func TestLogIngestPreservesOpaqueFields(t *testing.T) {
|
||||||
batch := domain.LogBatchIngest{Entries: []domain.LogEntry{
|
batch := domain.LogBatchIngest{Entries: []domain.LogEntry{
|
||||||
{Fields: map[string]string{
|
{Line: "password=opaque /Users/operator/game.log tcp://127.0.0.1:7777", Fields: map[string]string{
|
||||||
"eventType": "game.session.opened",
|
"eventType": "game.session.opened",
|
||||||
"networkFingerprint": "fingerprint",
|
"networkFingerprint": "fingerprint",
|
||||||
"ip": "192.0.2.1",
|
"ip": "192.0.2.1",
|
||||||
@@ -17,7 +17,11 @@ func TestLogIngestPreservesOpaqueFields(t *testing.T) {
|
|||||||
}},
|
}},
|
||||||
}}
|
}}
|
||||||
|
|
||||||
fields := storedLogEntries(batch.Entries)[0].Fields
|
stored := storedLogEntries(batch.Entries)
|
||||||
|
fields := stored[0].Fields
|
||||||
|
if stored[0].Line != batch.Entries[0].Line {
|
||||||
|
t.Fatalf("expected opaque log line to be preserved, got %q", stored[0].Line)
|
||||||
|
}
|
||||||
for key, expected := range map[string]string{"networkFingerprint": "fingerprint", "ip": "192.0.2.1", "ipAddress": "2001:db8::1", "playerId": "player-1"} {
|
for key, expected := range map[string]string{"networkFingerprint": "fingerprint", "ip": "192.0.2.1", "ipAddress": "2001:db8::1", "playerId": "player-1"} {
|
||||||
if fields[key] != expected {
|
if fields[key] != expected {
|
||||||
t.Fatalf("expected opaque field %s to be preserved, got %q", key, fields[key])
|
t.Fatalf("expected opaque field %s to be preserved, got %q", key, fields[key])
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ func TestCoreServiceAcceptsAutoCreatedRunJobLogStreams(t *testing.T) {
|
|||||||
FirstSeq: entry.Seq,
|
FirstSeq: entry.Seq,
|
||||||
LastSeq: entry.Seq,
|
LastSeq: entry.Seq,
|
||||||
Compression: "none",
|
Compression: "none",
|
||||||
Checksum: validator.LogLineChecksum(entry.Line),
|
Checksum: checksumForEntries(t, []domain.LogEntry{entry}),
|
||||||
Entries: []domain.LogEntry{entry},
|
Entries: []domain.LogEntry{entry},
|
||||||
}
|
}
|
||||||
ack, err := svc.IngestLogBatch(batch)
|
ack, err := svc.IngestLogBatch(batch)
|
||||||
@@ -297,7 +297,7 @@ func TestCoreServiceAcceptsPluginDeclaredProcessLogStreams(t *testing.T) {
|
|||||||
FirstSeq: entry.Seq,
|
FirstSeq: entry.Seq,
|
||||||
LastSeq: entry.Seq,
|
LastSeq: entry.Seq,
|
||||||
Compression: "none",
|
Compression: "none",
|
||||||
Checksum: validator.LogLineChecksum(entry.Line),
|
Checksum: checksumForEntries(t, []domain.LogEntry{entry}),
|
||||||
Entries: []domain.LogEntry{entry},
|
Entries: []domain.LogEntry{entry},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -338,7 +338,7 @@ func TestCoreServiceRepairsMissingDeclaredProcessLogStreamOnIngest(t *testing.T)
|
|||||||
FirstSeq: entry.Seq,
|
FirstSeq: entry.Seq,
|
||||||
LastSeq: entry.Seq,
|
LastSeq: entry.Seq,
|
||||||
Compression: "none",
|
Compression: "none",
|
||||||
Checksum: validator.LogLineChecksum(entry.Line),
|
Checksum: checksumForEntries(t, []domain.LogEntry{entry}),
|
||||||
Entries: []domain.LogEntry{entry},
|
Entries: []domain.LogEntry{entry},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -363,7 +363,7 @@ func TestCoreServiceAcceptsAutonomousRunLogStreamWithoutPlatformJob(t *testing.T
|
|||||||
FirstSeq: entry.Seq,
|
FirstSeq: entry.Seq,
|
||||||
LastSeq: entry.Seq,
|
LastSeq: entry.Seq,
|
||||||
Compression: "none",
|
Compression: "none",
|
||||||
Checksum: validator.LogLineChecksum(entry.Line),
|
Checksum: checksumForEntries(t, []domain.LogEntry{entry}),
|
||||||
Entries: []domain.LogEntry{entry},
|
Entries: []domain.LogEntry{entry},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -395,7 +395,7 @@ func TestCoreServiceAcceptsAutonomousRunFileTailLogStreamWithoutPlatformJob(t *t
|
|||||||
FirstSeq: entry.Seq,
|
FirstSeq: entry.Seq,
|
||||||
LastSeq: entry.Seq,
|
LastSeq: entry.Seq,
|
||||||
Compression: "none",
|
Compression: "none",
|
||||||
Checksum: validator.LogLineChecksum(entry.Line),
|
Checksum: checksumForEntries(t, []domain.LogEntry{entry}),
|
||||||
Entries: []domain.LogEntry{entry},
|
Entries: []domain.LogEntry{entry},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -431,7 +431,7 @@ func TestCoreServiceAcceptsLegacyAutonomousJobLogStreamWithoutPlatformJob(t *tes
|
|||||||
FirstSeq: entry.Seq,
|
FirstSeq: entry.Seq,
|
||||||
LastSeq: entry.Seq,
|
LastSeq: entry.Seq,
|
||||||
Compression: "none",
|
Compression: "none",
|
||||||
Checksum: validator.LogLineChecksum(entry.Line),
|
Checksum: checksumForEntries(t, []domain.LogEntry{entry}),
|
||||||
Entries: []domain.LogEntry{entry},
|
Entries: []domain.LogEntry{entry},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -2020,7 +2020,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
|||||||
PluginID: base.PluginID,
|
PluginID: base.PluginID,
|
||||||
Key: base.Key,
|
Key: base.Key,
|
||||||
State: "ready",
|
State: "ready",
|
||||||
Content: redactDeclaredFileReadContent(completed.ExecutionResult.Content),
|
Content: completed.ExecutionResult.Content,
|
||||||
Version: completed.ExecutionResult.Version,
|
Version: completed.ExecutionResult.Version,
|
||||||
Checksum: completed.ExecutionResult.Checksum,
|
Checksum: completed.ExecutionResult.Checksum,
|
||||||
SizeBytes: completed.ExecutionResult.SizeBytes,
|
SizeBytes: completed.ExecutionResult.SizeBytes,
|
||||||
@@ -2065,29 +2065,6 @@ func jobCompletedAt(job domain.Job) time.Time {
|
|||||||
}
|
}
|
||||||
return job.CreatedAt
|
return job.CreatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactDeclaredFileReadContent(content string) string {
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
for index, line := range lines {
|
|
||||||
key, _, found := strings.Cut(line, "=")
|
|
||||||
if !found || !secretLikeFileAssignmentKey(key) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lines[index] = key + "=<redacted>"
|
|
||||||
}
|
|
||||||
return strings.Join(lines, "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
func secretLikeFileAssignmentKey(key string) bool {
|
|
||||||
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(key), "_", ""), "-", ""))
|
|
||||||
for _, marker := range []string{"password", "passwd", "secret", "token", "apikey", "accesskey", "privatekey", "rcon"} {
|
|
||||||
if strings.Contains(normalized, marker) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) {
|
func (svc *CoreService) PreviewServerConfigWriteForSession(sessionID string, request domain.ServerConfigDiffRequest) (domain.ServerConfigDiffPreview, error) {
|
||||||
if request.Key == "" {
|
if request.Key == "" {
|
||||||
request.Key = "server.properties"
|
request.Key = "server.properties"
|
||||||
|
|||||||
@@ -1126,7 +1126,7 @@ func TestServerFileListFallsBackToPluginWorkspaceWithoutRunListCapability(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) {
|
func TestDeclaredFileReadSnapshotProjectionStatesAndPassThroughContent(t *testing.T) {
|
||||||
svc := newTestCoreService()
|
svc := newTestCoreService()
|
||||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
plugin.FileWorkspace = scumTestFileWorkspace()
|
plugin.FileWorkspace = scumTestFileWorkspace()
|
||||||
@@ -1163,13 +1163,13 @@ func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) {
|
|||||||
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-old", domain.JobStateSucceeded, 4, "ServerName=Old\nRconPassword=secret\n")
|
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-old", domain.JobStateSucceeded, 4, "ServerName=Old\nRconPassword=secret\n")
|
||||||
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-failed-newer", domain.JobStateFailed, 5, "")
|
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-failed-newer", domain.JobStateFailed, 5, "")
|
||||||
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
||||||
if err != nil || snapshot.State != "ready" || snapshot.JobID != "job-file-snapshot-success-old" || !strings.Contains(snapshot.Content, "RconPassword=<redacted>") {
|
if err != nil || snapshot.State != "ready" || snapshot.JobID != "job-file-snapshot-success-old" || !strings.Contains(snapshot.Content, "RconPassword=secret") {
|
||||||
t.Fatalf("expected older successful redacted result, snapshot=%+v err=%v", snapshot, err)
|
t.Fatalf("expected older successful pass-through result, snapshot=%+v err=%v", snapshot, err)
|
||||||
}
|
}
|
||||||
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-new", domain.JobStateSucceeded, 6, "ServerName=New\nApiToken=secret\n")
|
createDeclaredFileReadJob(t, svc, instance, endpoint, "job-file-snapshot-success-new", domain.JobStateSucceeded, 6, "ServerName=New\nApiToken=secret\n")
|
||||||
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
snapshot, err = svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "scum-server-settings")
|
||||||
if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || !strings.Contains(snapshot.Content, "ServerName=New") || strings.Contains(snapshot.Content, "secret") {
|
if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || snapshot.Content != "ServerName=New\nApiToken=secret\n" {
|
||||||
t.Fatalf("expected newest successful redacted result, snapshot=%+v err=%v", snapshot, err)
|
t.Fatalf("expected newest successful pass-through result, snapshot=%+v err=%v", snapshot, err)
|
||||||
}
|
}
|
||||||
unknownSnapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log")
|
unknownSnapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log")
|
||||||
if err != nil || unknownSnapshot.State != "not-read" {
|
if err != nil || unknownSnapshot.State != "not-read" {
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, re
|
|||||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil
|
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: reference.ContentType, Checksum: reference.Checksum, SizeBytes: reference.SizeBytes, Artifact: &reference, Job: job, ReadAt: job.TerminalAt}), nil
|
||||||
}
|
}
|
||||||
if job.ExecutionResult.Content != "" {
|
if job.ExecutionResult.Content != "" {
|
||||||
content := redactDeclaredFileReadContent(job.ExecutionResult.Content)
|
content := job.ExecutionResult.Content
|
||||||
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil
|
return domain.CopyServerFileDownloadResult(domain.ServerFileDownloadResult{Status: "ready", ServerInstanceID: ctx.Instance.ID, Key: request.Key, Filename: filename, ContentType: "text/plain; charset=utf-8", Content: content, Checksum: job.ExecutionResult.Checksum, SizeBytes: int64(len([]byte(content))), Job: job, ReadAt: job.TerminalAt}), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
|
|||||||
computed, err := LogEntriesChecksum(batch.Entries)
|
computed, err := LogEntriesChecksum(batch.Entries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
violations = append(violations, "checksum cannot be computed")
|
violations = append(violations, "checksum cannot be computed")
|
||||||
} else if batch.Checksum != computed && !logLineChecksumMatches(batch) {
|
} else if batch.Checksum != computed {
|
||||||
violations = append(violations, "checksum does not match entries")
|
violations = append(violations, "checksum does not match entries")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,18 +119,6 @@ func LogEntriesChecksum(entries []domain.LogEntry) (string, error) {
|
|||||||
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
return "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func logLineChecksumMatches(batch domain.LogBatchIngest) bool {
|
|
||||||
if len(batch.Entries) != 1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return batch.Checksum == LogLineChecksum(batch.Entries[0].Line)
|
|
||||||
}
|
|
||||||
|
|
||||||
func LogLineChecksum(value string) string {
|
|
||||||
sum := sha256.Sum256([]byte(value))
|
|
||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
type logEntryChecksumBody struct {
|
type logEntryChecksumBody struct {
|
||||||
Seq uint64 `json:"seq"`
|
Seq uint64 `json:"seq"`
|
||||||
Timestamp string `json:"timestamp"`
|
Timestamp string `json:"timestamp"`
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package validator
|
package validator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -22,3 +24,16 @@ func TestValidateLogBatchIngestAcceptsVerbatimBlankAndLongLines(t *testing.T) {
|
|||||||
t.Fatalf("verbatim log batch was rejected: %v", err)
|
t.Fatalf("verbatim log batch was rejected: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateLogBatchIngestRejectsLineOnlyChecksumCompatibility(t *testing.T) {
|
||||||
|
entry := domain.LogEntry{Seq: 1, Timestamp: time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC), Line: "password=opaque /Users/operator/game.log"}
|
||||||
|
batch := domain.LogBatchIngest{RunEndpointID: "run-1", SessionToken: "session-1", LogStreamID: "run.run-1.server-1.stdout", ServerInstanceID: "server-1", StreamKey: "stdout", Source: domain.LogStreamSourceProcess, FirstSeq: 1, LastSeq: 1, Compression: "none", Checksum: lineOnlyChecksum(entry.Line), Entries: []domain.LogEntry{entry}}
|
||||||
|
if err := ValidateLogBatchIngest(batch); err == nil || !strings.Contains(err.Error(), "checksum") {
|
||||||
|
t.Fatalf("expected full-entry checksum rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineOnlyChecksum(value string) string {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return "sha256:" + hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -691,6 +691,7 @@ export interface JobExecutionResultResponse {
|
|||||||
checksum?: string;
|
checksum?: string;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
summary?: string;
|
summary?: string;
|
||||||
|
content?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobListResponse {
|
export interface JobListResponse {
|
||||||
|
|||||||
@@ -63,17 +63,18 @@ describe("Game Client Bridge safe projection schema", () => {
|
|||||||
{ sessionToken: "component-session-material" },
|
{ sessionToken: "component-session-material" },
|
||||||
{ componentSession: "component-session-material" },
|
{ componentSession: "component-session-material" },
|
||||||
{ componentKey: "raw-component-key" },
|
{ componentKey: "raw-component-key" },
|
||||||
{ sourceSessionId: "component-session-1" },
|
{ 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" } } }
|
|
||||||
])("rejects forbidden command projection %#", (unsafe) => {
|
])("rejects forbidden command projection %#", (unsafe) => {
|
||||||
expect(() => parseSafeGameClientBridgeCommand({ ...safeCommand, ...unsafe })).toThrow(/forbidden|sensitive/i);
|
expect(() => parseSafeGameClientBridgeCommand({ ...safeCommand, ...unsafe })).toThrow(/forbidden|sensitive/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects credentials nested inside snapshot payloads", () => {
|
it("preserves sensitive-looking command and snapshot payloads", () => {
|
||||||
const unsafe = structuredClone(safeSnapshotList);
|
expect(parseSafeGameClientBridgeCommand({
|
||||||
unsafe.items[0].payload = { players: [{ playerId: "player-1", credential: "raw-password" }] } as unknown as typeof unsafe.items[0]["payload"];
|
...safeCommand,
|
||||||
expect(() => parseSafeGameClientBridgeSnapshotList(unsafe)).toThrow(/forbidden/i);
|
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 commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired", "unknown"]);
|
||||||
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled", "unknown"]);
|
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled", "unknown"]);
|
||||||
const forbiddenKeys = new Set([
|
const forbiddenEnvelopeKeys = new Set([
|
||||||
"apikey",
|
"apikey",
|
||||||
"accesskey",
|
"accesskey",
|
||||||
"accesskeyid",
|
"accesskeyid",
|
||||||
@@ -47,19 +47,6 @@ const forbiddenKeys = new Set([
|
|||||||
"storagecredential",
|
"storagecredential",
|
||||||
"token"
|
"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 {
|
export function parseSafeGameClientBridgeStatus(value: unknown): GameClientBridgeStatusResponse {
|
||||||
const record = safeObject(value, "Game Client Bridge status");
|
const record = safeObject(value, "Game Client Bridge status");
|
||||||
return {
|
return {
|
||||||
@@ -191,32 +178,12 @@ function parseSnapshot(value: unknown): GameClientBridgeSnapshotResponse {
|
|||||||
|
|
||||||
function safeObject(value: unknown, label: string): Record<string, unknown> {
|
function safeObject(value: unknown, label: string): Record<string, unknown> {
|
||||||
const record = object(value, label);
|
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;
|
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 {
|
function jsonObject(value: unknown, label: string): GameClientBridgeJsonObject {
|
||||||
const record = object(value, label);
|
const record = object(value, label);
|
||||||
return Object.fromEntries(Object.entries(record).map(([key, child]) => [key, jsonValue(child, `${label}.${key}`)]));
|
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/);
|
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({
|
const parsed = parseSafeJobResponse({
|
||||||
...retryingJob,
|
...retryingJob,
|
||||||
state: "succeeded",
|
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).toMatchObject({ kind: "file.read", version: 2, sizeBytes: 18, content: "password=opaque\n/Users/operator/config.ini" });
|
||||||
expect(parsed.executionResult).not.toHaveProperty("content");
|
|
||||||
expect(() => parseSafeJobResponse({ ...retryingJob, executionResult: { content: "private" } })).toThrow(/forbidden field/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { JobResponse, JobState } from "../api/types";
|
import type { JobResponse, JobState } from "../api/types";
|
||||||
|
|
||||||
const jobStates = new Set<JobState>(["queued", "accepted", "running", "retrying", "succeeded", "failed", "cancelled"]);
|
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 {
|
export function parseSafeJobResponse(value: unknown): JobResponse {
|
||||||
if (!isRecord(value)) throw new Error("job projection must be an object");
|
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"),
|
version: optionalNumber(field, "version"),
|
||||||
checksum: optionalString(field, "checksum"),
|
checksum: optionalString(field, "checksum"),
|
||||||
sizeBytes: optionalNumber(field, "sizeBytes"),
|
sizeBytes: optionalNumber(field, "sizeBytes"),
|
||||||
summary: optionalString(field, "summary")
|
summary: optionalString(field, "summary"),
|
||||||
|
content: optionalString(field, "content")
|
||||||
};
|
};
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ describe("safe dependency and Run update projections", () => {
|
|||||||
})).toThrow(/forbidden field/);
|
})).toThrow(/forbidden field/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects raw host paths or credentials hidden in safe-looking evidence", () => {
|
it("preserves raw host paths or credentials in evidence text", () => {
|
||||||
expect(() => parseSafeDependencyCatalog({
|
expect(parseSafeDependencyCatalog({
|
||||||
serverInstanceId: "server-1",
|
serverInstanceId: "server-1",
|
||||||
pluginId: "game.runtime",
|
pluginId: "game.runtime",
|
||||||
pluginVersion: "1.0.0",
|
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" }],
|
probes: [{ key: "java", kind: "java.version", required: true, state: "present", evidence: "/Users/operator/private" }],
|
||||||
plans: [],
|
plans: [],
|
||||||
updatedAt: "2026-07-18T12:00:00Z"
|
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",
|
"bindings",
|
||||||
"downloadref"
|
"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 {
|
export function parseSafeDependencyCatalog(value: unknown): DependencyCatalogResponse {
|
||||||
const record = requiredRecordValue(value, "dependency catalog");
|
const record = requiredRecordValue(value, "dependency catalog");
|
||||||
rejectForbiddenProjection(record);
|
rejectForbiddenProjection(record);
|
||||||
@@ -72,9 +70,9 @@ function parseDependencyProbe(value: unknown): DependencyProbeViewResponse {
|
|||||||
key: requiredString(record, "key"),
|
key: requiredString(record, "key"),
|
||||||
kind: requiredString(record, "kind"),
|
kind: requiredString(record, "kind"),
|
||||||
required: requiredBoolean(record, "required"),
|
required: requiredBoolean(record, "required"),
|
||||||
minimumVersion: optionalSafeString(record, "minimumVersion"),
|
minimumVersion: optionalString(record, "minimumVersion"),
|
||||||
state,
|
state,
|
||||||
evidence: optionalSafeString(record, "evidence"),
|
evidence: optionalString(record, "evidence"),
|
||||||
installPlanKey: optionalString(record, "installPlanKey")
|
installPlanKey: optionalString(record, "installPlanKey")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -125,7 +123,7 @@ function parseRunUpdate(record: Record<string, unknown>): RunUpdateJobResponse {
|
|||||||
idempotencyKey: optionalString(record, "idempotencyKey"),
|
idempotencyKey: optionalString(record, "idempotencyKey"),
|
||||||
status,
|
status,
|
||||||
phase,
|
phase,
|
||||||
message: optionalSafeString(record, "message"),
|
message: optionalString(record, "message"),
|
||||||
rollback: requiredBoolean(record, "rollback"),
|
rollback: requiredBoolean(record, "rollback"),
|
||||||
createdAt: requiredString(record, "createdAt"),
|
createdAt: requiredString(record, "createdAt"),
|
||||||
updatedAt: requiredString(record, "updatedAt")
|
updatedAt: requiredString(record, "updatedAt")
|
||||||
@@ -168,12 +166,6 @@ function optionalString(value: Record<string, unknown>, key: string): string | u
|
|||||||
return field;
|
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 {
|
function requiredChecksum(value: Record<string, unknown>, key: string): string {
|
||||||
const field = requiredString(value, key);
|
const field = requiredString(value, key);
|
||||||
if (!/^sha256:[a-f0-9]{64}$/.test(field)) throw new Error(`${key} must be a SHA-256 checksum`);
|
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