Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/api/handlers_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)})
}
69 changes: 69 additions & 0 deletions internal/api/sanitize.go
Original file line number Diff line number Diff line change
@@ -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
}
95 changes: 95 additions & 0 deletions internal/api/sanitize_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
24 changes: 7 additions & 17 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion internal/vm/qemu_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Loading