From b4b83a873d8b7182813ac46b062ec30b040201a1 Mon Sep 17 00:00:00 2001 From: ErenAri Date: Fri, 19 Jun 2026 14:49:21 +0300 Subject: [PATCH 1/2] Redact absolute trace_path from public runtime decisions The /api/runtime/decisions listing returned each decision's trace_path -- an absolute host path to the per-decision audit file (e.g. /var/lib/bpfcompat-demo/runtime-audit/decisions/.json). That path is not retrievable via the API and discloses the server's workdir layout to anonymous readers on the public demo. Strip trace_path from the listing when BPFCOMPAT_API_REDACT_RUNTIME_DETAILS is set (the demo posture), consistent with the report sanitizer; operators with shell access can still read the file directly. Make the field omitempty so it drops cleanly. Adds a regression test. Co-Authored-By: Claude Opus 4.8 --- internal/api/server.go | 9 +++++ internal/api/server_security_test.go | 55 ++++++++++++++++++++++++++++ internal/runtime/audit.go | 2 +- 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/internal/api/server.go b/internal/api/server.go index a1614fc1..377a5292 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -748,6 +748,15 @@ func (s *Server) handleRuntimeDecisions(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, fmt.Sprintf("read runtime decision history: %v", err)) return } + // trace_path is an absolute host path to the per-decision audit file. It is + // not retrievable via the API and leaks the server's workdir layout, so on + // the public/redacted posture strip it from the listing (consistent with the + // report sanitizer). Operators with shell access can read the file directly. + if redactRuntimeDetailsEnabled() { + for i := range events { + events[i].TracePath = "" + } + } writeJSON(w, http.StatusOK, map[string]any{"records": events}) } diff --git a/internal/api/server_security_test.go b/internal/api/server_security_test.go index 5a302ea8..4d3b9419 100644 --- a/internal/api/server_security_test.go +++ b/internal/api/server_security_test.go @@ -866,6 +866,61 @@ func TestReadEndpointsRejectUnauthenticated(t *testing.T) { } } +// TestRuntimeDecisionsRedactTracePath confirms the public decisions listing +// strips the absolute host trace_path when redaction is on (demo posture), and +// keeps it when redaction is off. trace_path leaks the server workdir layout and +// is not retrievable via the API. +func TestRuntimeDecisionsRedactTracePath(t *testing.T) { + t.Setenv(envAllowAnonymousRead, "true") + workDir := t.TempDir() + if _, err := runtime.PersistDecisionTrace(workDir, runtime.DecisionTrace{ + Source: "api", + Operation: "select", + ArtifactName: "aegis", + Status: "success", + }); err != nil { + t.Fatalf("persist decision trace: %v", err) + } + s := &Server{cfg: Config{WorkDir: workDir}} + + decode := func() []runtime.DecisionEvent { + req := httptest.NewRequest(http.MethodGet, "/api/runtime/decisions", nil) + rec := httptest.NewRecorder() + s.handleRuntimeDecisions(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var body struct { + Records []runtime.DecisionEvent `json:"records"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("decode decisions: %v", err) + } + if len(body.Records) == 0 { + t.Fatalf("expected at least one decision record") + } + return body.Records + } + + t.Setenv(envRedactRuntime, "true") + for _, rec := range decode() { + if rec.TracePath != "" { + t.Fatalf("expected trace_path redacted, got %q", rec.TracePath) + } + } + + t.Setenv(envRedactRuntime, "false") + sawPath := false + for _, rec := range decode() { + if rec.TracePath != "" { + sawPath = true + } + } + if !sawPath { + t.Fatalf("expected trace_path present when redaction is disabled") + } +} + // TestReadEndpointsAllowAuthenticated confirms that supplying the configured // API key restores access to the read surface after H-1b's auth gate. func TestReadEndpointsAllowAuthenticated(t *testing.T) { diff --git a/internal/runtime/audit.go b/internal/runtime/audit.go index 9e4d9952..8b8a31e2 100644 --- a/internal/runtime/audit.go +++ b/internal/runtime/audit.go @@ -142,7 +142,7 @@ type DecisionEvent struct { RequestedVersion string `json:"requested_version,omitempty"` SelectedVersion string `json:"selected_version,omitempty"` ExecutionStatus string `json:"execution_status,omitempty"` - TracePath string `json:"trace_path"` + TracePath string `json:"trace_path,omitempty"` } type DecisionPersistResult struct { From fb644c42fe3c93f36d3e2292feb840bbed0ee1f6 Mon Sep 17 00:00:00 2001 From: ErenAri Date: Fri, 19 Jun 2026 15:12:38 +0300 Subject: [PATCH 2/2] Use http.NoBody in decisions redaction test (gocritic) Co-Authored-By: Claude Opus 4.8 --- internal/api/server_security_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/server_security_test.go b/internal/api/server_security_test.go index 4d3b9419..5bdd6213 100644 --- a/internal/api/server_security_test.go +++ b/internal/api/server_security_test.go @@ -884,7 +884,7 @@ func TestRuntimeDecisionsRedactTracePath(t *testing.T) { s := &Server{cfg: Config{WorkDir: workDir}} decode := func() []runtime.DecisionEvent { - req := httptest.NewRequest(http.MethodGet, "/api/runtime/decisions", nil) + req := httptest.NewRequest(http.MethodGet, "/api/runtime/decisions", http.NoBody) rec := httptest.NewRecorder() s.handleRuntimeDecisions(rec, req) if rec.Code != http.StatusOK {