diff --git a/internal/api/handlers_history.go b/internal/api/handlers_history.go index e9e4729..f728114 100644 --- a/internal/api/handlers_history.go +++ b/internal/api/handlers_history.go @@ -88,5 +88,5 @@ func (s *Server) handleRunReport(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, fmt.Sprintf("parse report: %v", err)) return } - writeJSON(w, http.StatusOK, map[string]any{"run_id": runID, "report": report}) + writeJSON(w, http.StatusOK, map[string]any{"run_id": runID, "report": sanitizeReportMapForPublic(report)}) } diff --git a/internal/api/sanitize.go b/internal/api/sanitize.go new file mode 100644 index 0000000..24c2cf6 --- /dev/null +++ b/internal/api/sanitize.go @@ -0,0 +1,69 @@ +package api + +import ( + "github.com/kernel-guard/bpfcompat/internal/runner" + "github.com/kernel-guard/bpfcompat/pkg/schema" +) + +// The web validate/history endpoints are reachable by anonymous visitors on the +// public demo. The full report carries operator-only, host-internal details that +// should never leave the host over HTTP: +// - absolute filesystem paths (report/markdown/artifact paths, run_dir) +// - the per-target VM run directory, which also holds that run's SSH private key +// - the per-target QEMU command line and serial-log path +// +// The compatibility evidence a viewer actually wants (load/attach/verifier/BTF/ +// functional results, kernel info, classification, timings) is preserved. + +// sanitizeReportForPublic returns a copy of the typed report with the +// host-internal fields cleared. The original on-disk report is untouched. +func sanitizeReportForPublic(r schema.ReportV01) schema.ReportV01 { + r.Paths = schema.Paths{} + r.Artifact.Path = "" + r.Matrix.Path = "" + if len(r.Targets) > 0 { + targets := make([]schema.Target, len(r.Targets)) + copy(targets, r.Targets) + for i := range targets { + targets[i].VMRunDir = "" + targets[i].QEMUCommand = "" + targets[i].SerialLog = "" + } + r.Targets = targets + } + return r +} + +// publicValidateResponse builds a validateResponse safe to return over HTTP: +// host paths are dropped and the embedded report is sanitized. +func publicValidateResponse(result runner.RunResult) *validateResponse { + return &validateResponse{ + ExitCode: result.ExitCode, + Report: sanitizeReportForPublic(result.Report), + } +} + +// sanitizeReportMapForPublic strips the same host-internal fields from a report +// decoded from disk as a generic map (the history run-report endpoint). +func sanitizeReportMapForPublic(report map[string]any) map[string]any { + if report == nil { + return nil + } + delete(report, "paths") + if a, ok := report["artifact"].(map[string]any); ok { + delete(a, "path") + } + if m, ok := report["matrix"].(map[string]any); ok { + delete(m, "path") + } + if targets, ok := report["targets"].([]any); ok { + for _, t := range targets { + if tm, ok := t.(map[string]any); ok { + delete(tm, "vm_run_dir") + delete(tm, "qemu_command") + delete(tm, "serial_log") + } + } + } + return report +} diff --git a/internal/api/sanitize_test.go b/internal/api/sanitize_test.go new file mode 100644 index 0000000..d381629 --- /dev/null +++ b/internal/api/sanitize_test.go @@ -0,0 +1,95 @@ +package api + +import ( + "testing" + + "github.com/kernel-guard/bpfcompat/internal/runner" + "github.com/kernel-guard/bpfcompat/pkg/schema" +) + +func sampleInternalReport() schema.ReportV01 { + return schema.ReportV01{ + SchemaVersion: "0.1", + Artifact: schema.Artifact{Path: "/home/azureuser/secret/aegis.bpf.o", BaseName: "aegis", SHA256: "abc"}, + Matrix: schema.MatrixInfo{Path: "/home/azureuser/matrices/mvp.yaml", Name: "mvp"}, + Targets: []schema.Target{{ + ProfileID: "debian-12-6.1", + Status: "pass", + VMRunDir: ".bpfcompat/runs/20260616-abc/vm", + QEMUCommand: "qemu-system-x86_64 -drive file=.bpfcompat/runs/20260616-abc/id_ed25519", + SerialLog: ".bpfcompat/runs/20260616-abc/serial.log", + Validation: &schema.Validation{LoadStatus: "ok"}, + }}, + Paths: schema.Paths{RunDir: ".bpfcompat/runs/20260616-abc", JSON: "/home/azureuser/reports/x.json", Markdown: "/home/azureuser/reports/x.md"}, + } +} + +func TestSanitizeReportForPublicStripsInternalFields(t *testing.T) { + got := sanitizeReportForPublic(sampleInternalReport()) + + if got.Paths != (schema.Paths{}) { + t.Errorf("expected paths cleared, got %+v", got.Paths) + } + if got.Artifact.Path != "" { + t.Errorf("expected artifact path cleared, got %q", got.Artifact.Path) + } + if got.Matrix.Path != "" { + t.Errorf("expected matrix path cleared, got %q", got.Matrix.Path) + } + tg := got.Targets[0] + if tg.VMRunDir != "" || tg.QEMUCommand != "" || tg.SerialLog != "" { + t.Errorf("expected target internals cleared, got vm_run_dir=%q qemu=%q serial=%q", tg.VMRunDir, tg.QEMUCommand, tg.SerialLog) + } + // Compatibility evidence must survive. + if tg.Status != "pass" || tg.Validation == nil || tg.Validation.LoadStatus != "ok" { + t.Errorf("sanitizer dropped compatibility evidence: %+v", tg) + } + if got.Artifact.SHA256 != "abc" || got.Artifact.BaseName != "aegis" { + t.Errorf("sanitizer dropped artifact identity: %+v", got.Artifact) + } +} + +func TestSanitizeReportForPublicDoesNotMutateOriginal(t *testing.T) { + orig := sampleInternalReport() + _ = sanitizeReportForPublic(orig) + if orig.Targets[0].SerialLog == "" || orig.Paths.JSON == "" { + t.Fatal("sanitizer mutated the original report") + } +} + +func TestPublicValidateResponseDropsHostPaths(t *testing.T) { + resp := publicValidateResponse(runner.RunResult{ + ExitCode: 0, + RunDir: ".bpfcompat/runs/20260616-abc", + Report: sampleInternalReport(), + }) + if resp.RunDir != "" || resp.ReportJSONPath != "" || resp.ReportMarkdownPath != "" { + t.Errorf("expected host paths dropped, got run_dir=%q json=%q md=%q", resp.RunDir, resp.ReportJSONPath, resp.ReportMarkdownPath) + } +} + +func TestSanitizeReportMapForPublic(t *testing.T) { + report := map[string]any{ + "paths": map[string]any{"json": "/home/azureuser/x.json"}, + "artifact": map[string]any{"path": "/home/azureuser/a.bpf.o", "sha256": "abc"}, + "matrix": map[string]any{"path": "/home/azureuser/m.yaml", "name": "mvp"}, + "targets": []any{ + map[string]any{"profile_id": "debian-12-6.1", "status": "pass", "vm_run_dir": "x", "qemu_command": "y", "serial_log": "z"}, + }, + } + got := sanitizeReportMapForPublic(report) + + if _, ok := got["paths"]; ok { + t.Error("expected paths removed") + } + if a := got["artifact"].(map[string]any); a["path"] != nil || a["sha256"] != "abc" { + t.Errorf("artifact not sanitized correctly: %+v", a) + } + tg := got["targets"].([]any)[0].(map[string]any) + if tg["vm_run_dir"] != nil || tg["qemu_command"] != nil || tg["serial_log"] != nil { + t.Errorf("target internals not removed: %+v", tg) + } + if tg["status"] != "pass" { + t.Errorf("target status dropped: %+v", tg) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 219f061..a1614fc 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -338,8 +338,8 @@ type agentDecisionRequest struct { type validateResponse struct { ExitCode int `json:"exit_code"` - RunDir string `json:"run_dir"` - ReportJSONPath string `json:"report_json_path"` + RunDir string `json:"run_dir,omitempty"` + ReportJSONPath string `json:"report_json_path,omitempty"` ReportMarkdownPath string `json:"report_markdown_path,omitempty"` Report interface{} `json:"report"` } @@ -2308,14 +2308,9 @@ func (s *Server) handleValidate(w http.ResponseWriter, r *http.Request) { s.log().Warn("auto-sync registry warning", slog.String("error", err.Error())) } - response := validateResponse{ - ExitCode: result.ExitCode, - RunDir: result.RunDir, - ReportJSONPath: result.Report.Paths.JSON, - ReportMarkdownPath: result.Report.Paths.Markdown, - Report: result.Report, - } - writeJSON(w, http.StatusOK, response) + // Strip host-internal fields (absolute paths, VM run dir, QEMU command, + // serial-log pointers) before returning over HTTP. See sanitize.go. + writeJSON(w, http.StatusOK, publicValidateResponse(result)) } func (s *Server) handleValidateStart(w http.ResponseWriter, r *http.Request) { @@ -2484,13 +2479,8 @@ func (s *Server) runValidateJob(jobID string, cfg runner.Config) { s.log().Warn("auto-sync registry warning", slog.String("error", err.Error())) } - response := &validateResponse{ - ExitCode: result.ExitCode, - RunDir: result.RunDir, - ReportJSONPath: result.Report.Paths.JSON, - ReportMarkdownPath: result.Report.Paths.Markdown, - Report: result.Report, - } + // Strip host-internal fields before this response leaves the host. See sanitize.go. + response := publicValidateResponse(result) s.updateValidateJob(jobID, func(job *validateJob) { job.State = "completed" job.Stage = string(runner.ProgressStageCompleted) diff --git a/internal/vm/qemu_test.go b/internal/vm/qemu_test.go index 995e40e..af975e3 100644 --- a/internal/vm/qemu_test.go +++ b/internal/vm/qemu_test.go @@ -52,7 +52,10 @@ func TestQEMUSystemBinaryForARM64(t *testing.T) { args := buildQEMUArgs(profile, "/tmp/overlay.qcow2", "/tmp/serial.log", 2222, seedDeliveryNoCloudNet, "http://127.0.0.1:8080/", "", "") joined := strings.Join(args, " ") - if !strings.Contains(joined, "-machine virt,accel=kvm") { + // arm64 always uses the "-machine virt,accel=..." form. The accel mode + // (kvm vs tcg) depends on whether the host exposes /dev/kvm, so don't pin + // it here — explicit kvm/tcg coverage lives in TestMachineArgsForAccelFallback. + if !strings.Contains(joined, "-machine virt,accel=") { t.Fatalf("expected arm64 virt machine args: %s", joined) } if strings.Contains(joined, "-enable-kvm") {