From b8588e3df40b86b491539e746aa85297c544e3be Mon Sep 17 00:00:00 2001 From: Abhisek Datta Date: Tue, 19 May 2026 14:40:54 +0530 Subject: [PATCH] feat: Add Sandbox Inspection and Debugging Commands (#261) * feat: add sandbox DX commands * fix: Linter errors * fix: Sandbox deny log parsing * fix: Sandbox docs * refactor: Maintain SSOT across pkg dependencies * fix: Linter errors --- AGENTS.md | 1 + cmd/sandbox/doctor.go | 287 ++++++++++++++++ cmd/sandbox/doctor_test.go | 225 +++++++++++++ cmd/sandbox/explain.go | 269 +++++++++++++++ cmd/sandbox/explain_test.go | 227 +++++++++++++ cmd/sandbox/helpers.go | 155 +++++++++ cmd/sandbox/main_test.go | 20 ++ cmd/sandbox/profile.go | 34 ++ cmd/sandbox/profile_diff.go | 136 ++++++++ cmd/sandbox/profile_diff_test.go | 165 +++++++++ cmd/sandbox/profile_init.go | 192 +++++++++++ cmd/sandbox/profile_init_test.go | 222 ++++++++++++ cmd/sandbox/profile_lint.go | 216 ++++++++++++ cmd/sandbox/profile_lint_test.go | 223 +++++++++++++ cmd/sandbox/profile_list.go | 146 ++++++++ cmd/sandbox/profile_list_test.go | 116 +++++++ cmd/sandbox/profile_show.go | 170 ++++++++++ cmd/sandbox/profile_show_test.go | 151 +++++++++ cmd/sandbox/sandbox.go | 26 ++ cmd/sandbox/violations.go | 26 ++ cmd/sandbox/violations_list.go | 187 +++++++++++ cmd/sandbox/violations_list_test.go | 200 +++++++++++ cmd/setup/info.go | 2 +- cmd/version/version.go | 9 +- config/config.go | 90 ++++- config/sandbox_paths_test.go | 129 +++++++ docs/sandbox.md | 48 +++ extractor/common.go | 7 +- go.mod | 2 +- internal/alias/alias.go | 12 +- internal/analytics/analytics_test.go | 7 +- internal/pty/session.go | 5 +- internal/ui/error.go | 7 +- internal/ui/sandbox_violation.go | 183 ++++++++++ internal/ui/sandbox_violation_test.go | 122 +++++++ main.go | 26 +- packagemanager/pypi_resolver.go | 6 +- sandbox/executor/apply.go | 2 +- sandbox/executor/diagnostics.go | 247 +++----------- sandbox/executor/diagnostics_test.go | 144 ++++---- sandbox/lint.go | 211 ++++++++++++ sandbox/lint_test.go | 232 +++++++++++++ sandbox/platform/bubblewrap_linux.go | 4 +- sandbox/platform/bubblewrap_linux_test.go | 2 +- sandbox/platform/bubblewrap_render_linux.go | 35 ++ .../platform/bubblewrap_render_linux_test.go | 85 +++++ .../platform/bubblewrap_translator_linux.go | 85 +++-- .../bubblewrap_translator_linux_test.go | 134 +++++--- sandbox/platform/landlock_linux.go | 4 +- sandbox/platform/landlock_linux_test.go | 2 +- sandbox/platform/landlock_render_linux.go | 126 +++++++ .../platform/landlock_render_linux_test.go | 92 +++++ sandbox/platform/platform_darwin.go | 6 + sandbox/platform/platform_linux.go | 18 +- sandbox/platform/probe_apparmor_linux.go | 59 ++++ sandbox/platform/probe_apparmor_linux_test.go | 50 +++ sandbox/platform/probe_bwrap_linux.go | 55 +++ sandbox/platform/probe_bwrap_linux_test.go | 75 +++++ sandbox/platform/probe_canary.go | 197 +++++++++++ sandbox/platform/probe_canary_darwin.go | 29 ++ sandbox/platform/probe_canary_linux.go | 43 +++ sandbox/platform/probe_canary_test.go | 118 +++++++ sandbox/platform/probe_env.go | 31 ++ sandbox/platform/probe_landlock_linux.go | 82 +++++ sandbox/platform/probe_landlock_linux_test.go | 55 +++ sandbox/platform/probe_seatbelt_darwin.go | 71 ++++ .../platform/probe_seatbelt_darwin_test.go | 96 ++++++ sandbox/platform/probes_default_darwin.go | 14 + sandbox/platform/probes_default_linux.go | 17 + sandbox/platform/probes_default_other.go | 9 + sandbox/platform/render.go | 32 ++ sandbox/platform/render_darwin.go | 18 + sandbox/platform/render_linux.go | 18 + sandbox/platform/render_other.go | 18 + sandbox/platform/seatbelt_darwin.go | 4 +- .../platform/seatbelt_diagnostics_darwin.go | 53 ++- .../seatbelt_diagnostics_darwin_test.go | 110 ++++++ sandbox/platform/seatbelt_render_darwin.go | 32 ++ .../platform/seatbelt_render_darwin_test.go | 70 ++++ .../platform/seatbelt_translator_darwin.go | 82 +++-- .../platform/testdata/bubblewrap_minimal.argv | 58 ++++ .../platform/testdata/landlock_minimal.txt | 110 ++++++ sandbox/platform/testdata/seatbelt_minimal.sb | 265 +++++++++++++++ sandbox/probe.go | 69 ++++ sandbox/probe_test.go | 62 ++++ sandbox/registry.go | 228 ++++++++++++- sandbox/registry_user_test.go | 315 ++++++++++++++++++ sandbox/resolve.go | 104 ++++++ sandbox/resolve_test.go | 122 +++++++ sandbox/sandbox.go | 99 +++++- sandbox/util/tmpdir_test.go | 17 +- sandbox/util/variables.go | 75 +++-- sandbox/util/variables_test.go | 38 +++ sandbox/violation.go | 199 +++++++++++ sandbox/violation_cache.go | 242 ++++++++++++++ sandbox/violation_cache_test.go | 169 ++++++++++ sandbox/violation_test.go | 186 +++++++++++ 97 files changed, 8796 insertions(+), 478 deletions(-) create mode 100644 cmd/sandbox/doctor.go create mode 100644 cmd/sandbox/doctor_test.go create mode 100644 cmd/sandbox/explain.go create mode 100644 cmd/sandbox/explain_test.go create mode 100644 cmd/sandbox/helpers.go create mode 100644 cmd/sandbox/main_test.go create mode 100644 cmd/sandbox/profile.go create mode 100644 cmd/sandbox/profile_diff.go create mode 100644 cmd/sandbox/profile_diff_test.go create mode 100644 cmd/sandbox/profile_init.go create mode 100644 cmd/sandbox/profile_init_test.go create mode 100644 cmd/sandbox/profile_lint.go create mode 100644 cmd/sandbox/profile_lint_test.go create mode 100644 cmd/sandbox/profile_list.go create mode 100644 cmd/sandbox/profile_list_test.go create mode 100644 cmd/sandbox/profile_show.go create mode 100644 cmd/sandbox/profile_show_test.go create mode 100644 cmd/sandbox/sandbox.go create mode 100644 cmd/sandbox/violations.go create mode 100644 cmd/sandbox/violations_list.go create mode 100644 cmd/sandbox/violations_list_test.go create mode 100644 config/sandbox_paths_test.go create mode 100644 internal/ui/sandbox_violation.go create mode 100644 internal/ui/sandbox_violation_test.go create mode 100644 sandbox/lint.go create mode 100644 sandbox/lint_test.go create mode 100644 sandbox/platform/bubblewrap_render_linux.go create mode 100644 sandbox/platform/bubblewrap_render_linux_test.go create mode 100644 sandbox/platform/landlock_render_linux.go create mode 100644 sandbox/platform/landlock_render_linux_test.go create mode 100644 sandbox/platform/probe_apparmor_linux.go create mode 100644 sandbox/platform/probe_apparmor_linux_test.go create mode 100644 sandbox/platform/probe_bwrap_linux.go create mode 100644 sandbox/platform/probe_bwrap_linux_test.go create mode 100644 sandbox/platform/probe_canary.go create mode 100644 sandbox/platform/probe_canary_darwin.go create mode 100644 sandbox/platform/probe_canary_linux.go create mode 100644 sandbox/platform/probe_canary_test.go create mode 100644 sandbox/platform/probe_env.go create mode 100644 sandbox/platform/probe_landlock_linux.go create mode 100644 sandbox/platform/probe_landlock_linux_test.go create mode 100644 sandbox/platform/probe_seatbelt_darwin.go create mode 100644 sandbox/platform/probe_seatbelt_darwin_test.go create mode 100644 sandbox/platform/probes_default_darwin.go create mode 100644 sandbox/platform/probes_default_linux.go create mode 100644 sandbox/platform/probes_default_other.go create mode 100644 sandbox/platform/render.go create mode 100644 sandbox/platform/render_darwin.go create mode 100644 sandbox/platform/render_linux.go create mode 100644 sandbox/platform/render_other.go create mode 100644 sandbox/platform/seatbelt_render_darwin.go create mode 100644 sandbox/platform/seatbelt_render_darwin_test.go create mode 100644 sandbox/platform/testdata/bubblewrap_minimal.argv create mode 100644 sandbox/platform/testdata/landlock_minimal.txt create mode 100644 sandbox/platform/testdata/seatbelt_minimal.sb create mode 100644 sandbox/probe.go create mode 100644 sandbox/probe_test.go create mode 100644 sandbox/registry_user_test.go create mode 100644 sandbox/resolve.go create mode 100644 sandbox/resolve_test.go create mode 100644 sandbox/violation.go create mode 100644 sandbox/violation_cache.go create mode 100644 sandbox/violation_cache_test.go create mode 100644 sandbox/violation_test.go diff --git a/AGENTS.md b/AGENTS.md index 51bcb6d..7a048f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,3 +42,4 @@ go test ./config/ -v -count=1 # Run specific package tests - Prefer failing fast by returning errors up the call stack - When soft failure is acceptable, log with `log.Warnf` from `github.com/safedep/dry/log` - Do not use `_ = someFunc()` to discard errors silently +- For CLI/user-facing errors, prefer `usefulerror` with a specific code and actionable help so `ui.ErrorExit` does not classify expected failures as `Unknown` diff --git a/cmd/sandbox/doctor.go b/cmd/sandbox/doctor.go new file mode 100644 index 0000000..9b7ec11 --- /dev/null +++ b/cmd/sandbox/doctor.go @@ -0,0 +1,287 @@ +package sandbox + +import ( + "context" + "fmt" + "io" + + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/sandbox/platform" + "github.com/spf13/cobra" +) + +type doctorOptions struct { + jsonOut bool + driver string +} + +func NewDoctorCommand() *cobra.Command { + return newDoctorCommand(platform.DefaultProbes) +} + +func newDoctorCommand(probes func() []pmgsandbox.Probe) *cobra.Command { + opts := &doctorOptions{} + + cmd := &cobra.Command{ + Use: "doctor", + Short: "Run sandbox probes and report on host readiness", + Example: " pmg sandbox doctor\n pmg sandbox doctor --driver landlock", + Args: cobra.NoArgs, + SilenceErrors: false, + RunE: func(cmd *cobra.Command, args []string) error { + err := runDoctor(cmd.Context(), cmd.OutOrStdout(), opts, probes) + if err != nil { + if _, isFail := err.(*doctorFailError); isFail { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return err + } + return sandboxErrorExit(cmd, err) + } + return err + }, + } + + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit probe results as JSON") + cmd.Flags().StringVar(&opts.driver, "driver", "", "Filter probes to a single driver: seatbelt|bubblewrap|landlock") + return cmd +} + +func runDoctor(ctx context.Context, out io.Writer, opts *doctorOptions, probes func() []pmgsandbox.Probe) error { + if err := validateDriver(opts.driver); err != nil { + return err + } + + if ctx == nil { + ctx = context.Background() + } + + all := probes() + filtered := filterByDriver(all, opts.driver) + results := pmgsandbox.RunProbes(ctx, filtered) + + if opts.jsonOut { + if err := writeJSON(out, results); err != nil { + return err + } + } else { + if err := renderHuman(out, results); err != nil { + return err + } + } + + if exitCodeForResults(results) != 0 { + return &doctorFailError{} + } + return nil +} + +type doctorFailError struct{} + +func (e *doctorFailError) Error() string { return "" } +func (e *doctorFailError) ExitCode() int { return ExitCodeProbeFailure } + +func exitCodeForResults(results []pmgsandbox.ProbeResult) int { + for _, r := range results { + if r.Status == pmgsandbox.ProbeStatusFail { + return ExitCodeProbeFailure + } + } + return 0 +} + +// driverProbeNames lists which probes belong to each driver. The apparmor +// probe is shared by bubblewrap and landlock (both rely on unprivileged user +// namespaces on Linux) and excluded from the darwin-only seatbelt filter. +var driverProbeNames = map[pmgsandbox.DriverName]map[string]struct{}{ + pmgsandbox.DriverSeatbelt: { + pmgsandbox.ProbeSeatbeltDriver: {}, + pmgsandbox.ProbeSeatbeltCanary: {}, + }, + pmgsandbox.DriverBubblewrap: { + pmgsandbox.ProbeBwrapDriver: {}, + pmgsandbox.ProbeBwrapCanary: {}, + pmgsandbox.ProbeAppArmorUserns: {}, + }, + pmgsandbox.DriverLandlock: { + pmgsandbox.ProbeLandlockDriver: {}, + pmgsandbox.ProbeLandlockCanary: {}, + pmgsandbox.ProbeAppArmorUserns: {}, + }, +} + +func filterByDriver(probes []pmgsandbox.Probe, driver string) []pmgsandbox.Probe { + if driver == "" { + return probes + } + want := driverProbeNames[pmgsandbox.DriverName(driver)] + out := make([]pmgsandbox.Probe, 0, len(probes)) + for _, p := range probes { + if _, ok := want[p.Name()]; ok { + out = append(out, p) + } + } + return out +} + +type jsonFix struct { + Description string `json:"description"` + Command string `json:"command,omitempty"` + Docs string `json:"docs,omitempty"` +} + +type jsonProbeResult struct { + Name string `json:"name"` + Status string `json:"status"` + Summary string `json:"summary,omitempty"` + Detail string `json:"detail,omitempty"` + Fixes []jsonFix `json:"fixes,omitempty"` +} + +type jsonReport struct { + Results []jsonProbeResult `json:"results"` +} + +func writeJSON(out io.Writer, results []pmgsandbox.ProbeResult) error { + report := jsonReport{Results: make([]jsonProbeResult, 0, len(results))} + for _, r := range results { + fixes := make([]jsonFix, 0, len(r.Fixes)) + for _, f := range r.Fixes { + fixes = append(fixes, jsonFix{Description: f.Description, Command: f.Command, Docs: f.Docs}) + } + report.Results = append(report.Results, jsonProbeResult{ + Name: r.Name, + Status: string(r.Status), + Summary: r.Summary, + Detail: r.Detail, + Fixes: fixes, + }) + } + + return writeJSONIndent(out, report) +} + +func renderHuman(out io.Writer, results []pmgsandbox.ProbeResult) error { + if len(results) == 0 { + _, err := fmt.Fprintln(out, ui.Colors.Dim("No probes to run.")) + return err + } + + if _, err := fmt.Fprintln(out); err != nil { + return err + } + if _, err := fmt.Fprintln(out, ui.Colors.Cyan("Sandbox Diagnostics")); err != nil { + return err + } + if _, err := fmt.Fprintln(out, ui.Colors.Normal("--------------------")); err != nil { + return err + } + + rows := make([][]string, 0, len(results)+1) + rows = append(rows, []string{ + ui.Colors.Bold("STATUS"), + ui.Colors.Bold("CHECK"), + ui.Colors.Bold("SUMMARY"), + ui.Colors.Bold("FIX"), + }) + for _, r := range results { + rows = append(rows, []string{ + statusBadge(r.Status), + displayName(r.Name), + truncate(r.Summary, 60), + fixHint(r.Fixes), + }) + } + + if err := renderTable(out, rows, nil); err != nil { + return err + } + + for _, r := range results { + if r.Status == pmgsandbox.ProbeStatusOK { + continue + } + if err := renderDetail(out, r); err != nil { + return err + } + } + + return nil +} + +func renderDetail(out io.Writer, r pmgsandbox.ProbeResult) error { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + if _, err := fmt.Fprintf(out, "%s %s — %s\n", statusBadge(r.Status), ui.Colors.Bold(displayName(r.Name)), r.Summary); err != nil { + return err + } + if r.Detail != "" { + if _, err := fmt.Fprintf(out, " %s\n", ui.Colors.Dim(r.Detail)); err != nil { + return err + } + } + for i, f := range r.Fixes { + if _, err := fmt.Fprintf(out, " %s %s\n", ui.Colors.Cyan(fmt.Sprintf("Fix %d:", i+1)), f.Description); err != nil { + return err + } + if f.Command != "" { + if _, err := fmt.Fprintf(out, " %s %s\n", ui.Colors.Dim("$"), f.Command); err != nil { + return err + } + } + if f.Docs != "" { + if _, err := fmt.Fprintf(out, " %s %s\n", ui.Colors.Dim("docs:"), f.Docs); err != nil { + return err + } + } + } + return nil +} + +func statusBadge(s pmgsandbox.ProbeStatus) string { + switch s { + case pmgsandbox.ProbeStatusOK: + return ui.Colors.Green("OK") + case pmgsandbox.ProbeStatusWarn: + return ui.Colors.Yellow("WARN") + case pmgsandbox.ProbeStatusFail: + return ui.Colors.Red("FAIL") + case pmgsandbox.ProbeStatusSkipped: + return ui.Colors.Dim("SKIPPED") + default: + return string(s) + } +} + +func fixHint(fixes []pmgsandbox.ProbeFix) string { + if len(fixes) == 0 { + return ui.Colors.Dim("—") + } + first := truncate(fixes[0].Description, 50) + if len(fixes) > 1 { + return fmt.Sprintf("%s %s", first, ui.Colors.Dim(fmt.Sprintf("(+%d more)", len(fixes)-1))) + } + return first +} + +func displayName(probeName string) string { + switch probeName { + case pmgsandbox.ProbeSeatbeltDriver: + return "Seatbelt driver" + case pmgsandbox.ProbeBwrapDriver: + return "Bubblewrap driver" + case pmgsandbox.ProbeLandlockDriver: + return "Landlock ABI" + case pmgsandbox.ProbeAppArmorUserns: + return "AppArmor user namespaces" + case pmgsandbox.ProbeSeatbeltCanary: + return "Seatbelt canary" + case pmgsandbox.ProbeBwrapCanary: + return "Bubblewrap canary" + case pmgsandbox.ProbeLandlockCanary: + return "Landlock canary" + } + return probeName +} diff --git a/cmd/sandbox/doctor_test.go b/cmd/sandbox/doctor_test.go new file mode 100644 index 0000000..c62d98f --- /dev/null +++ b/cmd/sandbox/doctor_test.go @@ -0,0 +1,225 @@ +package sandbox + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/sandbox/platform" + "github.com/safedep/pmg/usefulerror" +) + +// stubProbe is a minimal probe used by tests. +type stubProbe struct { + name string + result pmgsandbox.ProbeResult +} + +func (s *stubProbe) Name() string { return s.name } +func (s *stubProbe) Run(_ context.Context) pmgsandbox.ProbeResult { return s.result } + +func newStub(name string, status pmgsandbox.ProbeStatus) pmgsandbox.Probe { + return &stubProbe{ + name: name, + result: pmgsandbox.ProbeResult{ + Name: name, + Status: status, + Summary: name + " summary", + Detail: name + " detail", + Fixes: []pmgsandbox.ProbeFix{{Description: name + " fix", Command: "do thing", Docs: "https://example/" + name}}, + }, + } +} + +func TestFilterByDriver(t *testing.T) { + all := []pmgsandbox.Probe{ + newStub("driver.seatbelt.available", pmgsandbox.ProbeStatusOK), + newStub("driver.bwrap.available", pmgsandbox.ProbeStatusOK), + newStub("driver.landlock.abi", pmgsandbox.ProbeStatusOK), + newStub("linux.apparmor.userns", pmgsandbox.ProbeStatusWarn), + newStub("canary.seatbelt", pmgsandbox.ProbeStatusOK), + newStub("canary.bubblewrap", pmgsandbox.ProbeStatusOK), + newStub("canary.landlock", pmgsandbox.ProbeStatusOK), + } + + cases := []struct { + driver string + want []string + }{ + {"", []string{ + "driver.seatbelt.available", "driver.bwrap.available", "driver.landlock.abi", + "linux.apparmor.userns", "canary.seatbelt", "canary.bubblewrap", "canary.landlock", + }}, + {"seatbelt", []string{"driver.seatbelt.available", "canary.seatbelt"}}, + {"bubblewrap", []string{"driver.bwrap.available", "linux.apparmor.userns", "canary.bubblewrap"}}, + {"landlock", []string{"driver.landlock.abi", "linux.apparmor.userns", "canary.landlock"}}, + } + + for _, tc := range cases { + t.Run("driver="+tc.driver, func(t *testing.T) { + got := filterByDriver(all, tc.driver) + names := make([]string, 0, len(got)) + for _, p := range got { + names = append(names, p.Name()) + } + assert.Equal(t, tc.want, names) + }) + } +} + +func TestExitCodeForResults(t *testing.T) { + cases := []struct { + name string + results []pmgsandbox.ProbeResult + want int + }{ + {"all ok", []pmgsandbox.ProbeResult{{Status: pmgsandbox.ProbeStatusOK}, {Status: pmgsandbox.ProbeStatusOK}}, 0}, + {"warn is ok", []pmgsandbox.ProbeResult{{Status: pmgsandbox.ProbeStatusOK}, {Status: pmgsandbox.ProbeStatusWarn}}, 0}, + {"skipped is ok", []pmgsandbox.ProbeResult{{Status: pmgsandbox.ProbeStatusSkipped}}, 0}, + {"fail trips", []pmgsandbox.ProbeResult{{Status: pmgsandbox.ProbeStatusOK}, {Status: pmgsandbox.ProbeStatusFail}}, ExitCodeProbeFailure}, + {"empty is ok", nil, 0}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, exitCodeForResults(tc.results)) + }) + } +} + +func TestRenderHuman_ContainsKeySubstrings(t *testing.T) { + results := []pmgsandbox.ProbeResult{ + { + Name: "driver.seatbelt.available", + Status: pmgsandbox.ProbeStatusOK, + Summary: "sandbox-exec ready", + }, + { + Name: "canary.seatbelt", + Status: pmgsandbox.ProbeStatusFail, + Summary: "canary blocked", + Detail: "policy denied read", + Fixes: []pmgsandbox.ProbeFix{{Description: "Update seatbelt policy", Command: "pmg fix --last", Docs: "https://docs/seatbelt"}}, + }, + } + + var buf bytes.Buffer + require.NoError(t, renderHuman(&buf, results)) + out := buf.String() + + assert.Contains(t, out, "STATUS") + assert.Contains(t, out, "Seatbelt driver") + assert.Contains(t, out, "Seatbelt canary") + assert.Contains(t, out, "sandbox-exec ready") + assert.Contains(t, out, "Update seatbelt policy") + assert.NotContains(t, out, "driver.seatbelt.available") + + assert.Contains(t, out, "policy denied read") + assert.Contains(t, out, "pmg fix --last") + assert.Contains(t, out, "https://docs/seatbelt") +} + +func TestRunDoctor_JSONRoundtrip(t *testing.T) { + factory := func() []pmgsandbox.Probe { + return []pmgsandbox.Probe{ + newStub("driver.seatbelt.available", pmgsandbox.ProbeStatusOK), + newStub("canary.seatbelt", pmgsandbox.ProbeStatusFail), + } + } + + var buf bytes.Buffer + opts := &doctorOptions{jsonOut: true} + err := runDoctor(context.Background(), &buf, opts, factory) + + require.Error(t, err) + _, ok := err.(*doctorFailError) + require.True(t, ok, "expected doctorFailError, got %T", err) + + var report jsonReport + require.NoError(t, json.Unmarshal(buf.Bytes(), &report)) + require.Len(t, report.Results, 2) + assert.Equal(t, "driver.seatbelt.available", report.Results[0].Name) + assert.Equal(t, "ok", report.Results[0].Status) + assert.Equal(t, "canary.seatbelt", report.Results[1].Name) + assert.Equal(t, "fail", report.Results[1].Status) + assert.Equal(t, "canary.seatbelt fix", report.Results[1].Fixes[0].Description) +} + +func TestRunDoctor_HumanSuccess(t *testing.T) { + factory := func() []pmgsandbox.Probe { + return []pmgsandbox.Probe{newStub("driver.seatbelt.available", pmgsandbox.ProbeStatusOK)} + } + + var buf bytes.Buffer + err := runDoctor(context.Background(), &buf, &doctorOptions{}, factory) + require.NoError(t, err) + assert.Contains(t, buf.String(), "Seatbelt driver") +} + +func TestRunDoctor_UnknownDriver(t *testing.T) { + var buf bytes.Buffer + err := runDoctor(context.Background(), &buf, &doctorOptions{driver: "bogus"}, platform.DefaultProbes) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown driver") + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code()) +} + +func TestDoctorCommandRejectsUnexpectedArgsWithUsage(t *testing.T) { + cmd := newDoctorCommand(func() []pmgsandbox.Probe { return nil }) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"extra"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, stderr.String(), "unknown command") + assert.Contains(t, stdout.String(), "Usage:") + assert.Contains(t, stdout.String(), "doctor [flags]") + assert.Contains(t, stdout.String(), "pmg sandbox doctor --driver landlock") +} + +func TestDoctorCommandRuntimeErrorUsesSandboxErrorExit(t *testing.T) { + cmd := newDoctorCommand(func() []pmgsandbox.Probe { return nil }) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"--driver", "bogus"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) + assert.Contains(t, err.Error(), "unknown driver") +} + +func TestRunDoctor_DriverFilter(t *testing.T) { + factory := func() []pmgsandbox.Probe { + return []pmgsandbox.Probe{ + newStub("driver.seatbelt.available", pmgsandbox.ProbeStatusOK), + newStub("driver.bwrap.available", pmgsandbox.ProbeStatusOK), + newStub("canary.bubblewrap", pmgsandbox.ProbeStatusOK), + } + } + + var buf bytes.Buffer + err := runDoctor(context.Background(), &buf, &doctorOptions{driver: "bubblewrap"}, factory) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Bubblewrap driver") + assert.Contains(t, out, "Bubblewrap canary") + assert.False(t, strings.Contains(out, "Seatbelt driver"), "seatbelt should be filtered out:\n%s", out) +} + +func TestDoctorFailError_ExitCode(t *testing.T) { + e := &doctorFailError{} + assert.Equal(t, ExitCodeProbeFailure, e.ExitCode()) +} diff --git a/cmd/sandbox/explain.go b/cmd/sandbox/explain.go new file mode 100644 index 0000000..c2b13b6 --- /dev/null +++ b/cmd/sandbox/explain.go @@ -0,0 +1,269 @@ +package sandbox + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/safedep/pmg/config" + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/usefulerror" + "github.com/spf13/cobra" +) + +// ExitCodeExplainFail is returned when explain cannot produce output. +const ExitCodeExplainFail = 2 + +// cacheFactory returns the ViolationCache used to look up cached reports. +type cacheFactory func() *pmgsandbox.ViolationCache + +type explainOptions struct { + last bool + jsonOut bool +} + +// NewExplainCommand returns the `pmg sandbox explain` subcommand. +func NewExplainCommand() *cobra.Command { + return newExplainCommand(func() *pmgsandbox.ViolationCache { + return pmgsandbox.NewViolationCache(config.Get().SandboxViolationCacheDir()) + }) +} + +// newExplainCommand allows callers (tests) to inject a cache factory. +func newExplainCommand(factory cacheFactory) *cobra.Command { + opts := &explainOptions{} + + cmd := &cobra.Command{ + Use: "explain [--last | -]", + Short: "Explain a sandbox violation from the local cache or piped JSON", + Example: " pmg sandbox explain --last\n pmg sandbox explain - < violation.json", + SilenceErrors: false, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + err := runExplain(cmd.OutOrStdout(), cmd.InOrStdin(), args, opts, factory) + if err != nil { + return sandboxErrorExit(cmd, err) + } + return err + }, + } + + cmd.Flags().BoolVar(&opts.last, "last", false, "Read the most recent cached violation report") + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit explanation and report as JSON") + return cmd +} + +// explainFailError carries a friendly message and a non-zero exit code. +type explainFailError struct { + usefulerror.UsefulError +} + +// ExitCode reports the explain exit code so main can propagate it. +func (e *explainFailError) ExitCode() int { return ExitCodeExplainFail } + +func newExplainFailError(code, msg, help string) *explainFailError { + return &explainFailError{ + UsefulError: usefulerror.Useful(). + WithCode(code). + WithHumanError(msg). + WithHelp(help). + Wrap(errors.New(msg)), + } +} + +func runExplain(out io.Writer, in io.Reader, args []string, opts *explainOptions, factory cacheFactory) error { + stdinMode := len(args) == 1 && args[0] == "-" + + if len(args) == 1 && !stdinMode { + return newExplainFailError( + usefulerror.ErrCodeInvalidArgument, + fmt.Sprintf("unexpected argument %q (use --last or pipe JSON with `-`)", args[0]), + explainUsageHelp(), + ) + } + + if opts.last && stdinMode { + return newExplainFailError( + usefulerror.ErrCodeInvalidArgument, + "--last and `-` are mutually exclusive", + explainUsageHelp(), + ) + } + + if !opts.last && !stdinMode { + return newExplainFailError( + usefulerror.ErrCodeInvalidArgument, + "no input: pass --last to read the most recent cached violation, or pipe a violation record JSON on stdin with `-`", + explainUsageHelp(), + ) + } + + var ( + record *pmgsandbox.ViolationCacheRecord + err error + ) + + if opts.last { + record, err = readLatestFromCache(factory) + } else { + record, err = readRecordFromStdin(in) + } + if err != nil { + return err + } + + if opts.jsonOut { + return writeExplainJSON(out, record) + } + + return renderExplanation(out, record) +} + +func readLatestFromCache(factory cacheFactory) (*pmgsandbox.ViolationCacheRecord, error) { + cache := factory() + entry, err := cache.Latest() + if err != nil { + return nil, newExplainFailError( + usefulerror.ErrCodeUnknown, + fmt.Sprintf("read cache: %v", err), + "Check the sandbox violation cache directory and retry.", + ) + } + if entry == nil { + return nil, newExplainFailError( + usefulerror.ErrCodeNotFound, + "no violations cached yet — run a sandboxed command first", + "Run a sandboxed package manager command first, then retry `pmg sandbox explain --last`.", + ) + } + rec := entry.Record + if err := validateViolationCacheRecord(&rec, "cache JSON"); err != nil { + return nil, err + } + return &rec, nil +} + +func readRecordFromStdin(in io.Reader) (*pmgsandbox.ViolationCacheRecord, error) { + data, err := io.ReadAll(in) + if err != nil { + return nil, newExplainFailError( + usefulerror.ErrCodeInvalidArgument, + fmt.Sprintf("read stdin: %v", err), + explainUsageHelp(), + ) + } + + if len(strings.TrimSpace(string(data))) == 0 { + return nil, newExplainFailError( + usefulerror.ErrCodeInvalidArgument, + "stdin is empty: pipe a ViolationCacheRecord JSON document", + explainUsageHelp(), + ) + } + + var rec pmgsandbox.ViolationCacheRecord + if err := json.Unmarshal(data, &rec); err != nil { + return nil, newExplainFailError( + usefulerror.ErrCodeInvalidArgument, + fmt.Sprintf("parse stdin JSON: %v", err), + "Pipe a valid ViolationCacheRecord JSON document to `pmg sandbox explain -`.", + ) + } + + if err := validateViolationCacheRecord(&rec, "stdin JSON"); err != nil { + return nil, err + } + + return &rec, nil +} + +func validateViolationCacheRecord(rec *pmgsandbox.ViolationCacheRecord, source string) error { + if rec == nil { + return newExplainFailError(usefulerror.ErrCodeInvalidArgument, fmt.Sprintf("%s is empty", source), explainUsageHelp()) + } + if rec.SchemaVersion == 0 { + return newExplainFailError(usefulerror.ErrCodeInvalidArgument, fmt.Sprintf("%s is missing schema_version", source), explainUsageHelp()) + } + if rec.SchemaVersion != pmgsandbox.ViolationCacheSchemaVersion { + return newExplainFailError(usefulerror.ErrCodeInvalidArgument, fmt.Sprintf("unknown schema_version %d (expected %d)", rec.SchemaVersion, pmgsandbox.ViolationCacheSchemaVersion), explainUsageHelp()) + } + if rec.Report == nil { + return newExplainFailError(usefulerror.ErrCodeInvalidArgument, fmt.Sprintf("%s is missing report", source), explainUsageHelp()) + } + + return nil +} + +func explainUsageHelp() string { + return "Use `pmg sandbox explain --last` or pipe a violation record JSON with `pmg sandbox explain -`." +} + +// --- Human rendering ---------------------------------------------------- + +func renderExplanation(out io.Writer, rec *pmgsandbox.ViolationCacheRecord) error { + return ui.RenderSandboxViolation(out, rec) +} + +// --- JSON output -------------------------------------------------------- + +type explainJSONPrimary struct { + Kind string `json:"kind"` + RawKind string `json:"raw_kind,omitempty"` + Target string `json:"target,omitempty"` + RuleTarget string `json:"rule_target,omitempty"` + Process string `json:"process,omitempty"` + RawLog string `json:"raw_log,omitempty"` + RuleLabel string `json:"rule_label,omitempty"` +} + +type explainJSONExplanation struct { + Hint string `json:"hint"` + Details string `json:"details"` + SuggestedOverride string `json:"suggested_override"` + Primary *explainJSONPrimary `json:"primary"` +} + +type explainJSONOutput struct { + Explanation explainJSONExplanation `json:"explanation"` + Report *pmgsandbox.ViolationReport `json:"report"` + RecordedAt string `json:"recorded_at,omitempty"` +} + +func writeExplainJSON(out io.Writer, rec *pmgsandbox.ViolationCacheRecord) error { + if rec == nil || rec.Report == nil { + return errors.New("explain: empty record") + } + + exp := pmgsandbox.BuildExplanation(rec.Report) + + payload := explainJSONOutput{ + Explanation: explainJSONExplanation{ + Hint: ui.FormatSandboxHint(exp.Primary, exp.Override), + Details: ui.FormatSandboxDetails(rec.Report, exp.Primary), + SuggestedOverride: ui.FormatSandboxOverrideFlag(exp.Override), + }, + Report: rec.Report, + } + + if exp.Primary != nil { + payload.Explanation.Primary = &explainJSONPrimary{ + Kind: string(exp.Primary.Kind), + RawKind: exp.Primary.RawKind, + Target: exp.Primary.Target, + RuleTarget: exp.Primary.RuleTarget, + Process: exp.Primary.Process, + RawLog: exp.Primary.RawLog, + RuleLabel: exp.Primary.RuleLabel, + } + } + + if !rec.RecordedAt.IsZero() { + payload.RecordedAt = rec.RecordedAt.UTC().Format(time.RFC3339) + } + + return writeJSONIndent(out, payload) +} diff --git a/cmd/sandbox/explain_test.go b/cmd/sandbox/explain_test.go new file mode 100644 index 0000000..60e33bd --- /dev/null +++ b/cmd/sandbox/explain_test.go @@ -0,0 +1,227 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/usefulerror" +) + +func sampleReport() *pmgsandbox.ViolationReport { + return &pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + PolicyName: "npm-restrictive", + CorrelationID: "corr-abc", + Violations: []pmgsandbox.Violation{ + { + Kind: pmgsandbox.ViolationKindFSWrite, + Target: "/Users/dev/project/.env", + RuleLabel: "file-write* on sensitive project file", + Process: "npm", + RawLog: "deny file-write* /Users/dev/project/.env", + }, + }, + } +} + +func writeFixtureCache(t *testing.T, dir string) *pmgsandbox.ViolationCache { + t.Helper() + cache := pmgsandbox.NewViolationCache(dir, pmgsandbox.WithClock(func() time.Time { + return time.Date(2026, 5, 14, 10, 30, 0, 0, time.UTC) + })) + _, err := cache.Write(sampleReport()) + require.NoError(t, err) + return cache +} + +func runExplainCmd(t *testing.T, factory cacheFactory, args []string, stdin string) (string, string, error) { + t.Helper() + cmd := newExplainCommand(factory) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetIn(strings.NewReader(stdin)) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), errOut.String(), err +} + +func TestExplainLastEmptyCache(t *testing.T) { + dir := t.TempDir() + factory := func() *pmgsandbox.ViolationCache { return pmgsandbox.NewViolationCache(dir) } + + stdout, _, err := runExplainCmd(t, factory, []string{"--last"}, "") + require.Error(t, err) + + fe, ok := err.(*explainFailError) + require.True(t, ok) + assert.Equal(t, ExitCodeExplainFail, fe.ExitCode()) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeNotFound, usefulErr.Code()) + assert.Empty(t, stdout) + assert.Contains(t, err.Error(), "no violations cached") +} + +func TestExplainLastRendersHuman(t *testing.T) { + dir := t.TempDir() + writeFixtureCache(t, dir) + factory := func() *pmgsandbox.ViolationCache { return pmgsandbox.NewViolationCache(dir) } + + stdout, _, err := runExplainCmd(t, factory, []string{"--last"}, "") + require.NoError(t, err) + + assert.Contains(t, stdout, "seatbelt") + assert.Contains(t, stdout, "npm-restrictive") + assert.Contains(t, stdout, "Reason:") + assert.Contains(t, stdout, "Details:") + assert.Contains(t, stdout, "Primary violation:") + assert.Contains(t, stdout, "/Users/dev/project/.env") +} + +func TestExplainLastJSON(t *testing.T) { + dir := t.TempDir() + writeFixtureCache(t, dir) + factory := func() *pmgsandbox.ViolationCache { return pmgsandbox.NewViolationCache(dir) } + + stdout, _, err := runExplainCmd(t, factory, []string{"--last", "--json"}, "") + require.NoError(t, err) + + var payload struct { + Explanation struct { + Hint string `json:"hint"` + Details string `json:"details"` + SuggestedOverride string `json:"suggested_override"` + Primary struct { + Kind string `json:"kind"` + Target string `json:"target"` + } `json:"primary"` + } `json:"explanation"` + Report *pmgsandbox.ViolationReport `json:"report"` + RecordedAt string `json:"recorded_at"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + + assert.NotEmpty(t, payload.Explanation.Hint) + assert.Equal(t, "fs_write", payload.Explanation.Primary.Kind) + assert.Equal(t, "/Users/dev/project/.env", payload.Explanation.Primary.Target) + require.NotNil(t, payload.Report) + assert.Equal(t, pmgsandbox.DriverSeatbelt, payload.Report.SandboxName) + assert.NotEmpty(t, payload.RecordedAt) +} + +func TestExplainLastRejectsCacheRecordWithNilReport(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "violation-99999999T999999.999999999Z-bad.json") + body := []byte(`{"schema_version":1,"recorded_at":"2026-05-14T10:30:00Z","report":null}`) + require.NoError(t, os.WriteFile(path, body, 0o644)) + factory := func() *pmgsandbox.ViolationCache { return pmgsandbox.NewViolationCache(dir) } + + stdout, _, err := runExplainCmd(t, factory, []string{"--last"}, "") + require.Error(t, err) + + fe, ok := err.(*explainFailError) + require.True(t, ok) + assert.Equal(t, ExitCodeExplainFail, fe.ExitCode()) + assert.Empty(t, stdout) + assert.Contains(t, err.Error(), "cache JSON is missing report") +} + +func TestExplainStdinValid(t *testing.T) { + rec := pmgsandbox.ViolationCacheRecord{ + SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion, + RecordedAt: time.Date(2026, 5, 14, 10, 30, 0, 0, time.UTC), + Report: sampleReport(), + } + data, err := json.Marshal(rec) + require.NoError(t, err) + + factory := func() *pmgsandbox.ViolationCache { + t.Fatal("cache should not be consulted in stdin mode") + return nil + } + + stdout, _, err := runExplainCmd(t, factory, []string{"-"}, string(data)) + require.NoError(t, err) + assert.Contains(t, stdout, "Primary violation:") + assert.Contains(t, stdout, "/Users/dev/project/.env") +} + +func TestExplainStdinInvalidJSON(t *testing.T) { + factory := func() *pmgsandbox.ViolationCache { return nil } + _, _, err := runExplainCmd(t, factory, []string{"-"}, "not json {{") + require.Error(t, err) + fe, ok := err.(*explainFailError) + require.True(t, ok) + assert.Equal(t, ExitCodeExplainFail, fe.ExitCode()) + assert.Contains(t, err.Error(), "parse stdin JSON") +} + +func TestExplainStdinSchemaVersion(t *testing.T) { + cases := []struct { + name string + body string + want string + }{ + { + name: "missing schema_version", + body: `{"report":{"SandboxName":"seatbelt","PolicyName":"p","Violations":[{"Kind":"fs_write","Target":"/x","RuleLabel":"r"}]}}`, + want: "missing schema_version", + }, + { + name: "unknown schema_version", + body: `{"schema_version":999,"report":{"SandboxName":"seatbelt","PolicyName":"p"}}`, + want: "unknown schema_version", + }, + } + + factory := func() *pmgsandbox.ViolationCache { return nil } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := runExplainCmd(t, factory, []string{"-"}, tc.body) + require.Error(t, err) + fe, ok := err.(*explainFailError) + require.True(t, ok) + assert.Equal(t, ExitCodeExplainFail, fe.ExitCode()) + assert.Contains(t, err.Error(), tc.want) + }) + } +} + +func TestExplainMutualExclusion(t *testing.T) { + factory := func() *pmgsandbox.ViolationCache { return nil } + _, _, err := runExplainCmd(t, factory, []string{"--last", "-"}, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") +} + +func TestExplainRejectsUnexpectedArgsWithUsage(t *testing.T) { + factory := func() *pmgsandbox.ViolationCache { return nil } + stdout, stderr, err := runExplainCmd(t, factory, []string{"one", "two"}, "") + require.Error(t, err) + assert.Contains(t, stderr, "accepts at most 1 arg(s), received 2") + assert.Contains(t, stdout, "Usage:") + assert.Contains(t, stdout, "explain [--last | -] [flags]") + assert.Contains(t, stdout, "pmg sandbox explain --last") +} + +func TestExplainNoMode(t *testing.T) { + factory := func() *pmgsandbox.ViolationCache { return nil } + _, _, err := runExplainCmd(t, factory, []string{}, "") + require.Error(t, err) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code()) + assert.Contains(t, err.Error(), "no input") + assert.Contains(t, err.Error(), "--last") +} diff --git a/cmd/sandbox/helpers.go b/cmd/sandbox/helpers.go new file mode 100644 index 0000000..029a81c --- /dev/null +++ b/cmd/sandbox/helpers.go @@ -0,0 +1,155 @@ +package sandbox + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" + + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/usefulerror" + "github.com/spf13/cobra" +) + +const ExitCodeProbeFailure = 2 + +var sandboxErrorExit = func(_ *cobra.Command, err error) error { + type exitCoder interface{ ExitCode() int } + if ec, ok := err.(exitCoder); ok { + ui.ErrorExitWithCode(err, ec.ExitCode()) + return nil + } + + ui.ErrorExit(err) + return nil +} + +var validDrivers = map[pmgsandbox.DriverName]struct{}{ + pmgsandbox.DriverSeatbelt: {}, + pmgsandbox.DriverBubblewrap: {}, + pmgsandbox.DriverLandlock: {}, +} + +func validateDriver(name string) error { + if name == "" { + return nil + } + if _, ok := validDrivers[pmgsandbox.DriverName(name)]; !ok { + return invalidArgumentError( + fmt.Sprintf("unknown driver %q", name), + "Use one of: seatbelt, bubblewrap, landlock", + ) + } + return nil +} + +func invalidArgumentError(message, help string) error { + return usefulerror.Useful(). + WithCode(usefulerror.ErrCodeInvalidArgument). + WithHumanError(message). + WithHelp(help). + Wrap(errors.New(message)) +} + +func notFoundError(message, help string) error { + return usefulerror.Useful(). + WithCode(usefulerror.ErrCodeNotFound). + WithHumanError(message). + WithHelp(help). + Wrap(errors.New(message)) +} + +func writeJSONIndent(out io.Writer, v any) error { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +// renderTable prints rows with ANSI-aware column alignment. The first row is +// treated as a header. After each data row, optional continuation lines may be +// emitted via the after callback (passed the data row index; -1 for header). +func renderTable(out io.Writer, rows [][]string, after func(rowIdx int) error) error { + if len(rows) == 0 { + return nil + } + cols := len(rows[0]) + widths := make([]int, cols) + for _, row := range rows { + for i, cell := range row { + if w := visibleWidth(cell); w > widths[i] { + widths[i] = w + } + } + } + for rIdx, row := range rows { + for i, cell := range row { + if i == cols-1 { + if _, err := fmt.Fprint(out, cell); err != nil { + return err + } + continue + } + pad := widths[i] - visibleWidth(cell) + if _, err := fmt.Fprint(out, cell, strings.Repeat(" ", pad+2)); err != nil { + return err + } + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + if after != nil { + dataIdx := rIdx - 1 + if err := after(dataIdx); err != nil { + return err + } + } + } + return nil +} + +// firstColumnIndent returns blanks the width of the first column plus the +// two-space padding renderTable uses, for continuation-line alignment. +func firstColumnIndent(rows [][]string) string { + if len(rows) == 0 { + return "" + } + w := 0 + for _, row := range rows { + if v := visibleWidth(row[0]); v > w { + w = v + } + } + return strings.Repeat(" ", w+2) +} + +var ansiEscapeRe = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`) + +// visibleWidth returns the printable width of s with ANSI escape sequences +// stripped — text/tabwriter counts escape bytes as visible chars, misaligning +// colored cells. +func visibleWidth(s string) int { + return len(ansiEscapeRe.ReplaceAllString(s, "")) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 3 { + return s[:n] + } + return s[:n-3] + "..." +} + +func truncateLeft(s string, n int) string { + if len(s) <= n { + return s + } + if n <= 3 { + return s[len(s)-n:] + } + return "..." + s[len(s)-(n-3):] +} diff --git a/cmd/sandbox/main_test.go b/cmd/sandbox/main_test.go new file mode 100644 index 0000000..50276d4 --- /dev/null +++ b/cmd/sandbox/main_test.go @@ -0,0 +1,20 @@ +package sandbox + +import ( + "os" + "testing" + + "github.com/spf13/cobra" +) + +func TestMain(m *testing.M) { + oldErrorExit := sandboxErrorExit + sandboxErrorExit = func(cmd *cobra.Command, err error) error { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return err + } + code := m.Run() + sandboxErrorExit = oldErrorExit + os.Exit(code) +} diff --git a/cmd/sandbox/profile.go b/cmd/sandbox/profile.go new file mode 100644 index 0000000..3cdf8b7 --- /dev/null +++ b/cmd/sandbox/profile.go @@ -0,0 +1,34 @@ +package sandbox + +import ( + "github.com/safedep/pmg/config" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +// registryFactory builds a ProfileRegistry. Tests inject a stub. +type registryFactory func() (pmgsandbox.ProfileRegistry, error) + +func defaultRegistryFactory() (pmgsandbox.ProfileRegistry, error) { + return pmgsandbox.NewProfileRegistry( + pmgsandbox.WithUserProfileDir(config.Get().SandboxProfileDir()), + ) +} + +// NewProfileCommand returns the `pmg sandbox profile` parent command. +func NewProfileCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "profile", + Short: "Inspect sandbox profiles", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(newProfileListCommand(defaultRegistryFactory)) + cmd.AddCommand(newProfileShowCommand(defaultRegistryFactory)) + cmd.AddCommand(newProfileInitCommand(defaultRegistryFactory)) + cmd.AddCommand(newProfileLintCommand(defaultRegistryFactory)) + cmd.AddCommand(newProfileDiffCommand(defaultRegistryFactory)) + return cmd +} diff --git a/cmd/sandbox/profile_diff.go b/cmd/sandbox/profile_diff.go new file mode 100644 index 0000000..b3fce6b --- /dev/null +++ b/cmd/sandbox/profile_diff.go @@ -0,0 +1,136 @@ +package sandbox + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/pmezard/go-difflib/difflib" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/sandbox/platform" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +const ExitCodeDiffError = 2 + +type profileDiffOptions struct { + driver string + cwd string + home string +} + +func newProfileDiffCommand(factory registryFactory) *cobra.Command { + opts := &profileDiffOptions{} + + cmd := &cobra.Command{ + Use: "diff ", + Short: "Diff two resolved sandbox profiles (or their driver-rendered output)", + Example: " pmg sandbox profile diff npm-restrictive pypi-restrictive", + Args: cobra.ExactArgs(2), + SilenceErrors: false, + RunE: func(cmd *cobra.Command, args []string) error { + err := runProfileDiff(cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], args[1], opts, factory) + if err != nil { + if _, isDiff := err.(*diffPresentError); isDiff { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return err + } + return sandboxErrorExit(cmd, err) + } + return err + }, + } + + cmd.Flags().StringVar(&opts.driver, "driver", "", "Render each policy for a specific driver before diffing: seatbelt|bubblewrap|landlock") + cmd.Flags().StringVar(&opts.cwd, "cwd", "", "Override ${CWD} during expansion (defaults to current working directory)") + cmd.Flags().StringVar(&opts.home, "home", "", "Override ${HOME} during expansion (defaults to current user home)") + return cmd +} + +type diffPresentError struct{} + +func (e *diffPresentError) Error() string { return "" } +func (e *diffPresentError) ExitCode() int { return 1 } + +type diffOpError struct{ err error } + +func (e *diffOpError) Error() string { return e.err.Error() } +func (e *diffOpError) Unwrap() error { return e.err } +func (e *diffOpError) ExitCode() int { return ExitCodeDiffError } + +func runProfileDiff(out io.Writer, errOut io.Writer, nameA, nameB string, opts *profileDiffOptions, factory registryFactory) error { + if err := validateDriver(opts.driver); err != nil { + return &diffOpError{err: err} + } + + registry, err := factory() + if err != nil { + return &diffOpError{err: err} + } + + dataA, err := materialize(registry, nameA, opts) + if err != nil { + return &diffOpError{err: err} + } + dataB, err := materialize(registry, nameB, opts) + if err != nil { + return &diffOpError{err: err} + } + + if bytes.Equal(dataA, dataB) { + if _, err := fmt.Fprintln(errOut, "profiles are identical"); err != nil { + return &diffOpError{err: err} + } + return nil + } + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(string(dataA)), + B: difflib.SplitLines(string(dataB)), + FromFile: nameA, + ToFile: nameB, + Context: 3, + } + text, err := difflib.GetUnifiedDiffString(diff) + if err != nil { + return &diffOpError{err: fmt.Errorf("failed to render diff: %w", err)} + } + + if _, err := io.WriteString(out, text); err != nil { + return &diffOpError{err: err} + } + + if !strings.HasSuffix(text, "\n") { + if _, err := fmt.Fprintln(out); err != nil { + return &diffOpError{err: err} + } + } + + return &diffPresentError{} +} + +func materialize(registry pmgsandbox.ProfileRegistry, name string, opts *profileDiffOptions) ([]byte, error) { + policy, err := registry.ResolveProfile(name, pmgsandbox.ResolveOptions{ + CWD: opts.cwd, + Home: opts.home, + }) + if err != nil { + return nil, err + } + + if opts.driver != "" { + return platform.Render(pmgsandbox.DriverName(opts.driver), policy) + } + + // Strip inherits since both sides are post-resolution. + policy.Inherits = "" + + data, err := yaml.Marshal(policy) + if err != nil { + return nil, fmt.Errorf("failed to marshal resolved policy %s: %w", name, err) + } + return data, nil +} diff --git a/cmd/sandbox/profile_diff_test.go b/cmd/sandbox/profile_diff_test.go new file mode 100644 index 0000000..aef80ce --- /dev/null +++ b/cmd/sandbox/profile_diff_test.go @@ -0,0 +1,165 @@ +package sandbox + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeDiffUserProfile(t *testing.T, dir, name, body string) { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, name+".yml"), []byte(body), 0o644)) +} + +func runDiffCmd(t *testing.T, factory registryFactory, args ...string) (string, string, error) { + t.Helper() + cmd := newProfileDiffCommand(factory) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func TestProfileDiffSameProfileNoOutput(t *testing.T) { + stdout, stderr, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "npm-restrictive") + require.NoError(t, err) + assert.Empty(t, stdout, "stdout should be empty when profiles are identical") + assert.Contains(t, stderr, "identical") +} + +func TestProfileDiffDistinctProfiles(t *testing.T) { + stdout, _, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "pypi-restrictive") + require.Error(t, err) + de, ok := err.(*diffPresentError) + require.True(t, ok, "expected diffPresentError, got %T", err) + assert.Equal(t, 1, de.ExitCode()) + + assert.NotEmpty(t, stdout) + assert.Contains(t, stdout, "--- npm-restrictive") + assert.Contains(t, stdout, "+++ pypi-restrictive") + // Some +/- body lines should be present. + hasPlus := false + hasMinus := false + for _, line := range strings.Split(stdout, "\n") { + if strings.HasPrefix(line, "+") && !strings.HasPrefix(line, "+++") { + hasPlus = true + } + if strings.HasPrefix(line, "-") && !strings.HasPrefix(line, "---") { + hasMinus = true + } + } + assert.True(t, hasPlus, "expected at least one '+' line") + assert.True(t, hasMinus, "expected at least one '-' line") +} + +func TestProfileDiffDriverNative(t *testing.T) { + driver := "" + switch runtime.GOOS { + case "darwin": + driver = "seatbelt" + case "linux": + driver = "bubblewrap" + default: + t.Skipf("no native driver for %s", runtime.GOOS) + } + + stdout, _, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "pypi-restrictive", "--driver", driver) + require.Error(t, err) + _, ok := err.(*diffPresentError) + require.True(t, ok, "expected diffPresentError, got %T", err) + + assert.NotEmpty(t, stdout) + if driver == "seatbelt" { + // SBPL output should contain version line or s-expressions, not YAML keys. + assert.True(t, + strings.Contains(stdout, "(version") || strings.Contains(stdout, "allow") || strings.Contains(stdout, "deny"), + "expected SBPL-ish body, got: %s", stdout) + assert.NotContains(t, stdout, "package_managers:") + } +} + +func TestProfileDiffUnknownProfile(t *testing.T) { + stdout, stderr, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "no-such-profile-xyz") + require.Error(t, err) + de, ok := err.(*diffOpError) + require.True(t, ok, "expected diffOpError, got %T", err) + assert.Equal(t, ExitCodeDiffError, de.ExitCode()) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + assert.Contains(t, err.Error(), "no-such-profile-xyz") +} + +func TestProfileDiffUnknownDriver(t *testing.T) { + stdout, stderr, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive", "npm-restrictive", "--driver", "bogus") + require.Error(t, err) + de, ok := err.(*diffOpError) + require.True(t, ok, "expected diffOpError, got %T", err) + assert.Equal(t, ExitCodeDiffError, de.ExitCode()) + assert.Empty(t, stdout) + assert.Empty(t, stderr) + assert.Contains(t, err.Error(), "unknown driver") +} + +func TestProfileDiffMissingProfileShowsUsage(t *testing.T) { + stdout, stderr, err := runDiffCmd(t, newTestRegistry(t, ""), "npm-restrictive") + require.Error(t, err) + assert.Contains(t, stderr, "accepts 2 arg(s), received 1") + assert.Contains(t, stdout, "Usage:") + assert.Contains(t, stdout, "diff [flags]") + assert.Contains(t, stdout, "pmg sandbox profile diff npm-restrictive pypi-restrictive") +} + +func TestProfileDiffCWDOverride(t *testing.T) { + // Two user profiles with identical bodies referencing ${CWD}. Diffing + // them with the same name but different --cwd values isn't supported by + // the CLI (single --cwd), so we exercise the path delta by using two + // distinct user profiles with hard-coded different cwd-style paths. + // + // Easier: use the same profile twice with no --cwd vs --cwd, which the + // CLI doesn't allow on a per-side basis. So we approximate by giving + // both sides the same profile under different file names but with + // different baked-in paths — proving cwd-derived deltas show up in YAML. + dir := t.TempDir() + writeDiffUserProfile(t, dir, "p-a", `name: p-a +description: a +package_managers: + - npm +filesystem: + allow_read: + - /aaa +`) + writeDiffUserProfile(t, dir, "p-b", `name: p-b +description: b +package_managers: + - npm +filesystem: + allow_read: + - /bbb +`) + + stdout, _, err := runDiffCmd(t, newTestRegistry(t, dir), "p-a", "p-b") + require.Error(t, err) + _, ok := err.(*diffPresentError) + require.True(t, ok) + assert.Contains(t, stdout, "/aaa") + assert.Contains(t, stdout, "/bbb") +} + +func TestDiffPresentErrorExitCode(t *testing.T) { + e := &diffPresentError{} + assert.Equal(t, 1, e.ExitCode()) +} + +func TestDiffOpErrorExitCode(t *testing.T) { + e := &diffOpError{} + assert.Equal(t, ExitCodeDiffError, e.ExitCode()) +} diff --git a/cmd/sandbox/profile_init.go b/cmd/sandbox/profile_init.go new file mode 100644 index 0000000..3342cec --- /dev/null +++ b/cmd/sandbox/profile_init.go @@ -0,0 +1,192 @@ +package sandbox + +import ( + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/cobra" +) + +// profileNameRe enforces a single path-safe segment. Rejects slashes, +// dot-prefixes, leading dashes, spaces, and other shell-hostile characters. +var profileNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`) + +type profileInitOptions struct { + from string + packageManagers []string + description string +} + +func newProfileInitCommand(factory registryFactory) *cobra.Command { + opts := &profileInitOptions{} + + cmd := &cobra.Command{ + Use: "init ", + Short: "Scaffold a new user sandbox profile", + Long: "Create a starter YAML profile under the user profile directory.\n\n" + + "Prefer --from to emit a minimal child profile that inherits a built-in.\n" + + "Copying the entire built-in YAML by hand is discouraged: the child stays small\n" + + "and only declares its additive deltas.", + Example: " pmg sandbox profile init my-npm --from npm-restrictive", + Args: cobra.ExactArgs(1), + SilenceErrors: false, + RunE: func(cmd *cobra.Command, args []string) error { + err := runProfileInit(cmd.OutOrStdout(), args[0], opts, factory) + if err != nil { + return sandboxErrorExit(cmd, err) + } + return err + }, + } + + cmd.Flags().StringVar(&opts.from, "from", "", "Built-in profile to inherit from (recommended)") + cmd.Flags().StringSliceVar(&opts.packageManagers, "package-manager", nil, "Package manager this profile applies to (repeatable)") + cmd.Flags().StringVar(&opts.description, "description", "", "One-line description for the profile") + return cmd +} + +func runProfileInit(out io.Writer, name string, opts *profileInitOptions, factory registryFactory) error { + if !profileNameRe.MatchString(name) { + return invalidArgumentError( + fmt.Sprintf("invalid profile name %q", name), + "Use a single path-safe name matching "+profileNameRe.String(), + ) + } + + registry, err := factory() + if err != nil { + return err + } + + userDir := registry.UserProfileDir() + if userDir == "" { + return invalidArgumentError( + "user profile directory is not configured", + "Configure the sandbox profile directory, then retry `pmg sandbox profile init`.", + ) + } + + if opts.from != "" { + if _, ok := registry.BuiltinProfileYAML(opts.from); !ok { + return notFoundError( + fmt.Sprintf("unknown built-in profile %q", opts.from), + "Use `pmg sandbox profile list` to see known built-in profiles.", + ) + } + } + + pms := opts.packageManagers + placeholderPM := false + if len(pms) == 0 { + pms = []string{"npm"} + placeholderPM = true + } + + target := filepath.Join(userDir, name+".yml") + if _, err := os.Stat(target); err == nil { + return invalidArgumentError( + fmt.Sprintf("profile already exists at %s", target), + "Choose a different profile name, or edit the existing profile file to merge changes.", + ) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat %s: %w", target, err) + } + + if err := os.MkdirAll(userDir, 0o755); err != nil { + return fmt.Errorf("failed to create user profile directory %s: %w", userDir, err) + } + + content := renderScaffold(name, opts.description, opts.from, pms, placeholderPM) + + if err := os.WriteFile(target, []byte(content), 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", target, err) + } + + _, err = fmt.Fprintln(out, target) + return err +} + +func renderScaffold(name, description, inheritsFrom string, pms []string, placeholderPM bool) string { + var b strings.Builder + + b.WriteString("# pmg sandbox profile — scaffolded by `pmg sandbox profile init`.\n") + b.WriteString("# Edit this file to fit your workflow, then validate with:\n") + b.WriteString("# pmg sandbox profile show ") + b.WriteString(name) + b.WriteString(" --resolved\n") + b.WriteString("\n") + + b.WriteString("name: ") + b.WriteString(name) + b.WriteString("\n") + if description != "" { + b.WriteString("description: ") + b.WriteString(yamlString(description)) + b.WriteString("\n") + } + + if inheritsFrom != "" { + b.WriteString("\n# Inherit from a built-in profile. Rules declared below are merged additively\n") + b.WriteString("# over the parent — you do not need to repeat the parent's allow-lists.\n") + b.WriteString("inherits: ") + b.WriteString(inheritsFrom) + b.WriteString("\n") + } + + b.WriteString("\n# Package managers this profile applies to.") + if placeholderPM { + b.WriteString(" Placeholder — update to match your usage.") + } + b.WriteString("\npackage_managers:\n") + for _, pm := range pms { + b.WriteString(" - ") + b.WriteString(pm) + b.WriteString("\n") + } + + b.WriteString("\n# Filesystem access — additive over the parent (if any).\n") + b.WriteString("filesystem:\n") + if inheritsFrom == "" { + b.WriteString(" # Starter rule so the policy validates. Replace with the paths you actually need.\n") + b.WriteString(" allow_read:\n") + b.WriteString(" - ${CWD}/**\n") + } else { + b.WriteString(" allow_read: []\n") + } + b.WriteString(" allow_write: []\n") + b.WriteString(" deny_read: []\n") + b.WriteString(" deny_write: []\n") + + b.WriteString("\n# Network egress rules — additive over the parent (if any).\n") + b.WriteString("# Patterns are \"host:port\"; use \"*:443\" or \"registry.npmjs.org:443\".\n") + b.WriteString("network:\n") + b.WriteString(" allow_outbound: []\n") + b.WriteString(" deny_outbound: []\n") + b.WriteString(" allow_bind: []\n") + + b.WriteString("\n# Process execution rules — additive over the parent (if any).\n") + b.WriteString("process:\n") + b.WriteString(" allow_exec: []\n") + b.WriteString(" deny_exec: []\n") + + return b.String() +} + +// yamlString quotes s for YAML when it contains characters that would otherwise +// confuse the parser. Plain scalars are emitted unquoted to keep the scaffold +// readable for hand-editing. +func yamlString(s string) string { + if s == "" { + return `""` + } + for _, r := range s { + if r == ':' || r == '#' || r == '\'' || r == '"' || r == '\n' || r == '\t' { + return fmt.Sprintf("%q", s) + } + } + return s +} diff --git a/cmd/sandbox/profile_init_test.go b/cmd/sandbox/profile_init_test.go new file mode 100644 index 0000000..de945f2 --- /dev/null +++ b/cmd/sandbox/profile_init_test.go @@ -0,0 +1,222 @@ +package sandbox + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/usefulerror" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestRegistryFactory(t *testing.T, dir string) registryFactory { + t.Helper() + return func() (pmgsandbox.ProfileRegistry, error) { + return pmgsandbox.NewProfileRegistry(pmgsandbox.WithUserProfileDir(dir)) + } +} + +func runInitCmd(t *testing.T, dir string, args ...string) (string, string, error) { + t.Helper() + cmd := newProfileInitCommand(newTestRegistryFactory(t, dir)) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.String(), stderr.String(), err +} + +func TestProfileInit_HappyPath_InheritsBuiltin(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, err := runInitCmd(t, dir, "my-npm", "--from", "npm-restrictive", "--package-manager", "npm") + require.NoError(t, err, "stderr: %s", stderr) + + expected := filepath.Join(dir, "my-npm.yml") + assert.Equal(t, expected+"\n", stdout) + + data, err := os.ReadFile(expected) + require.NoError(t, err) + assert.Contains(t, string(data), "inherits: npm-restrictive") + assert.Contains(t, string(data), "name: my-npm") + assert.Contains(t, string(data), "- npm\n") + + // Round-trip: registry must parse and validate the new file. + registry, err := pmgsandbox.NewProfileRegistry(pmgsandbox.WithUserProfileDir(dir)) + require.NoError(t, err) + policy, err := registry.GetProfile("my-npm") + require.NoError(t, err) + assert.Equal(t, "my-npm", policy.Name) + assert.Equal(t, []string{"npm"}, policy.PackageManagers) +} + +func TestProfileInit_Standalone(t *testing.T) { + dir := t.TempDir() + + _, stderr, err := runInitCmd(t, dir, "standalone", "--package-manager", "npm", "--description", "test profile") + require.NoError(t, err, "stderr: %s", stderr) + + data, err := os.ReadFile(filepath.Join(dir, "standalone.yml")) + require.NoError(t, err) + assert.NotContains(t, string(data), "inherits:") + assert.Contains(t, string(data), "description: test profile") + + registry, err := pmgsandbox.NewProfileRegistry(pmgsandbox.WithUserProfileDir(dir)) + require.NoError(t, err) + policy, err := registry.GetProfile("standalone") + require.NoError(t, err) + assert.Equal(t, "test profile", policy.Description) +} + +func TestProfileInit_DefaultPackageManager(t *testing.T) { + dir := t.TempDir() + + _, _, err := runInitCmd(t, dir, "defaults", "--from", "npm-restrictive") + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "defaults.yml")) + require.NoError(t, err) + assert.Contains(t, string(data), "Placeholder") + assert.Contains(t, string(data), "- npm\n") +} + +func TestProfileInit_RefuseOverwrite(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "exists.yml") + require.NoError(t, os.WriteFile(target, []byte("preexisting"), 0o644)) + + _, _, err := runInitCmd(t, dir, "exists", "--from", "npm-restrictive") + require.Error(t, err) + assert.Contains(t, err.Error(), "already exists") + assert.Contains(t, err.Error(), target) + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code()) + + data, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "preexisting", string(data)) +} + +func TestProfileInit_MissingNameShowsInstruction(t *testing.T) { + dir := t.TempDir() + + stdout, stderr, err := runInitCmd(t, dir) + require.Error(t, err) + + assert.Contains(t, stderr, "accepts 1 arg(s), received 0") + assert.Contains(t, stdout, "Usage:") + assert.Contains(t, stdout, "init [flags]") + assert.Contains(t, stdout, "Examples:") + assert.Contains(t, stdout, "pmg sandbox profile init my-npm --from npm-restrictive") +} + +func TestProfileInit_InvalidName(t *testing.T) { + cases := []struct { + name string + arg string + }{ + {"relative traversal", "../foo"}, + {"absolute", "/abs"}, + {"space", "bad name"}, + {"leading dash", "-bad"}, + {"leading underscore", "_bad"}, + {"empty", ""}, + {"slash", "a/b"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + args := []string{"--from", "npm-restrictive", "--", tc.arg} + if tc.arg == "" { + // cobra ExactArgs(1) will reject the empty-name case via args + // length, but we still want to verify rejection — pass an + // explicit empty string as the positional arg. + cmd := newProfileInitCommand(newTestRegistryFactory(t, dir)) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{""}) + err := cmd.Execute() + require.Error(t, err) + return + } + _, _, err := runInitCmd(t, dir, args...) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid profile name") + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code()) + }) + } +} + +func TestProfileInit_UnknownBuiltin(t *testing.T) { + dir := t.TempDir() + + _, _, err := runInitCmd(t, dir, "child", "--from", "no-such-builtin") + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown built-in profile") + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeNotFound, usefulErr.Code()) +} + +func TestProfileInit_StdoutIsExactlyThePath(t *testing.T) { + dir := t.TempDir() + stdout, _, err := runInitCmd(t, dir, "exact", "--from", "npm-restrictive") + require.NoError(t, err) + + expected := filepath.Join(dir, "exact.yml") + "\n" + assert.Equal(t, expected, stdout) +} + +func TestProfileInit_CreatesMissingParentDir(t *testing.T) { + base := t.TempDir() + dir := filepath.Join(base, "nested", "user-profiles") + + _, _, err := runInitCmd(t, dir, "child", "--from", "npm-restrictive") + require.NoError(t, err) + + info, err := os.Stat(dir) + require.NoError(t, err) + assert.True(t, info.IsDir()) +} + +func TestProfileNameRegex(t *testing.T) { + good := []string{"a", "A", "0", "abc", "abc-def", "abc_def", "ABC123-_x"} + bad := []string{"", "-a", "_a", "a/b", "a b", "a.b", "..", "../x"} + + for _, s := range good { + assert.True(t, profileNameRe.MatchString(s), "should accept %q", s) + } + for _, s := range bad { + assert.False(t, profileNameRe.MatchString(s), "should reject %q", s) + } +} + +// Compile-time check that newProfileInitCommand returns *cobra.Command. +var _ = func() *cobra.Command { + return newProfileInitCommand(func() (pmgsandbox.ProfileRegistry, error) { return nil, nil }) +} + +// Sanity check the rendered scaffold uses tokens the parser actually accepts. +func TestProfileInit_ScaffoldSnippet(t *testing.T) { + dir := t.TempDir() + _, _, err := runInitCmd(t, dir, "demo", "--from", "npm-restrictive", "--package-manager", "npm", "--description", "demo profile") + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "demo.yml")) + require.NoError(t, err) + s := string(data) + for _, want := range []string{"name: demo", "description: demo profile", "inherits: npm-restrictive", "package_managers:", "filesystem:", "network:", "process:"} { + assert.True(t, strings.Contains(s, want), "scaffold missing %q\n--- scaffold ---\n%s", want, s) + } +} diff --git a/cmd/sandbox/profile_lint.go b/cmd/sandbox/profile_lint.go new file mode 100644 index 0000000..b362798 --- /dev/null +++ b/cmd/sandbox/profile_lint.go @@ -0,0 +1,216 @@ +package sandbox + +import ( + "fmt" + "io" + "os" + + "github.com/safedep/dry/log" + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +const ExitCodeLintFail = 2 + +type profileLintOptions struct { + strict bool + verbose bool + jsonOut bool +} + +func newProfileLintCommand(factory registryFactory) *cobra.Command { + opts := &profileLintOptions{} + + cmd := &cobra.Command{ + Use: "lint ", + Short: "Lint a sandbox profile for schema issues, overly broad rules, and conflicts", + Example: " pmg sandbox profile lint npm-restrictive\n pmg sandbox profile lint ./my-profile.yml --strict", + Args: cobra.ExactArgs(1), + SilenceErrors: false, + RunE: func(cmd *cobra.Command, args []string) error { + err := runProfileLint(cmd.OutOrStdout(), args[0], opts, factory) + if err != nil { + if _, isFail := err.(*lintFailError); isFail { + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return err + } + return sandboxErrorExit(cmd, err) + } + return err + }, + } + + cmd.Flags().BoolVar(&opts.strict, "strict", false, "Treat warnings as errors (non-zero exit)") + cmd.Flags().BoolVar(&opts.verbose, "verbose", false, "Include info-level issues") + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit lint results as JSON") + return cmd +} + +type lintFailError struct{} + +func (e *lintFailError) Error() string { return "" } +func (e *lintFailError) ExitCode() int { return ExitCodeLintFail } + +func runProfileLint(out io.Writer, name string, opts *profileLintOptions, factory registryFactory) error { + registry, err := factory() + if err != nil { + return err + } + + policy, resolvedName, err := resolveProfileForLint(name, registry) + if err != nil { + return err + } + + issues := pmgsandbox.LintProfile(policy) + if !opts.verbose { + issues = filterInfo(issues) + } + + if opts.jsonOut { + if err := writeLintJSON(out, resolvedName, issues); err != nil { + return err + } + } else { + if err := renderLintHuman(out, resolvedName, issues); err != nil { + return err + } + } + + if shouldFailLint(issues, opts.strict) { + return &lintFailError{} + } + return nil +} + +func resolveProfileForLint(name string, registry pmgsandbox.ProfileRegistry) (*pmgsandbox.SandboxPolicy, string, error) { + if _, ok := registry.BuiltinProfileYAML(name); ok { + policy, err := registry.GetProfile(name) + if err != nil { + return nil, "", err + } + return policy, name, nil + } + + summaries, err := registry.ListProfiles() + if err != nil { + log.Warnf("sandbox: failed to enumerate user profiles: %v", err) + } + for _, s := range summaries { + if s.Source == pmgsandbox.ProfileSourceUser && s.Name == name { + policy, loadErr := registry.LoadCustomProfile(s.Path) + if loadErr != nil { + return nil, "", loadErr + } + return policy, s.Path, nil + } + } + + if _, statErr := os.Stat(name); statErr == nil { + policy, loadErr := registry.LoadCustomProfile(name) + if loadErr != nil { + return nil, "", loadErr + } + return policy, name, nil + } + + return nil, "", notFoundError( + fmt.Sprintf("sandbox profile not found: %s", name), + "Use `pmg sandbox profile list` to see available profiles, or pass an existing profile YAML path.", + ) +} + +func filterInfo(issues []pmgsandbox.LintIssue) []pmgsandbox.LintIssue { + out := make([]pmgsandbox.LintIssue, 0, len(issues)) + for _, i := range issues { + if i.Level == pmgsandbox.LintLevelInfo { + continue + } + out = append(out, i) + } + return out +} + +func shouldFailLint(issues []pmgsandbox.LintIssue, strict bool) bool { + for _, i := range issues { + if i.Level == pmgsandbox.LintLevelError { + return true + } + if strict && i.Level == pmgsandbox.LintLevelWarn { + return true + } + } + return false +} + +type jsonLintReport struct { + Profile string `json:"profile"` + Issues []pmgsandbox.LintIssue `json:"issues"` +} + +func writeLintJSON(out io.Writer, profile string, issues []pmgsandbox.LintIssue) error { + if issues == nil { + issues = []pmgsandbox.LintIssue{} + } + return writeJSONIndent(out, jsonLintReport{Profile: profile, Issues: issues}) +} + +func renderLintHuman(out io.Writer, profile string, issues []pmgsandbox.LintIssue) error { + if _, err := fmt.Fprintln(out); err != nil { + return err + } + if _, err := fmt.Fprintln(out, ui.Colors.Cyan("Profile Lint: ")+ui.Colors.Bold(profile)); err != nil { + return err + } + if _, err := fmt.Fprintln(out, ui.Colors.Normal("---------------")); err != nil { + return err + } + + if len(issues) == 0 { + _, err := fmt.Fprintln(out, ui.Colors.Green("OK")+" no issues found") + return err + } + + rows := make([][]string, 0, len(issues)+1) + rows = append(rows, []string{ + ui.Colors.Bold("LEVEL"), + ui.Colors.Bold("CODE"), + ui.Colors.Bold("FIELD"), + ui.Colors.Bold("MESSAGE"), + }) + for _, i := range issues { + rows = append(rows, []string{ + lintLevelBadge(i.Level), + i.Code, + i.Field, + i.Message, + }) + } + + indent := firstColumnIndent(rows) + return renderTable(out, rows, func(dataIdx int) error { + if dataIdx < 0 { + return nil + } + issue := issues[dataIdx] + if issue.Rule == "" { + return nil + } + _, err := fmt.Fprintf(out, "%s%s %s\n", indent, ui.Colors.Dim("rule:"), ui.Colors.Dim(issue.Rule)) + return err + }) +} + +func lintLevelBadge(level pmgsandbox.LintLevel) string { + switch level { + case pmgsandbox.LintLevelError: + return ui.Colors.Red("ERROR") + case pmgsandbox.LintLevelWarn: + return ui.Colors.Yellow("WARN") + case pmgsandbox.LintLevelInfo: + return ui.Colors.Dim("INFO") + } + return string(level) +} diff --git a/cmd/sandbox/profile_lint_test.go b/cmd/sandbox/profile_lint_test.go new file mode 100644 index 0000000..4354e6c --- /dev/null +++ b/cmd/sandbox/profile_lint_test.go @@ -0,0 +1,223 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/usefulerror" +) + +func writeUserProfileLint(t *testing.T, dir, name, body string) string { + t.Helper() + path := filepath.Join(dir, name+".yml") + require.NoError(t, os.WriteFile(path, []byte(body), 0o644)) + return path +} + +func TestProfileLint_BuiltinClean(t *testing.T) { + cmd := newProfileLintCommand(newTestRegistry(t, "")) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"npm-restrictive"}) + + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), "Profile Lint") +} + +func TestProfileLint_JSONOutput(t *testing.T) { + dir := t.TempDir() + writeUserProfileLint(t, dir, "broad", `name: broad +description: broad profile +package_managers: + - npm +filesystem: + allow_read: + - /** + allow_write: + - ${HOME}/** +`) + + cmd := newProfileLintCommand(newTestRegistry(t, dir)) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"broad", "--json"}) + + err := cmd.Execute() + // Lint surfaces warnings but no errors; exit should be zero without --strict. + require.NoError(t, err) + + var report struct { + Profile string `json:"profile"` + Issues []pmgsandbox.LintIssue `json:"issues"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &report)) + assert.Equal(t, filepath.Join(dir, "broad.yml"), report.Profile) + codes := map[string]int{} + for _, i := range report.Issues { + codes[i.Code]++ + } + assert.Equal(t, 1, codes["broad.root_glob"]) + assert.Equal(t, 1, codes["broad.home_glob"]) +} + +func TestProfileLint_StrictFailsOnWarn(t *testing.T) { + dir := t.TempDir() + writeUserProfileLint(t, dir, "warn", `name: warn +description: warn profile +package_managers: + - npm +filesystem: + allow_read: + - /** +`) + + cmd := newProfileLintCommand(newTestRegistry(t, dir)) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"warn", "--strict"}) + + err := cmd.Execute() + require.Error(t, err) + fail, ok := err.(*lintFailError) + require.True(t, ok, "expected *lintFailError, got %T", err) + assert.Equal(t, ExitCodeLintFail, fail.ExitCode()) +} + +func TestProfileLint_NoStrictWarnSucceeds(t *testing.T) { + dir := t.TempDir() + writeUserProfileLint(t, dir, "warn", `name: warn +description: warn profile +package_managers: + - npm +filesystem: + allow_read: + - /** +`) + + cmd := newProfileLintCommand(newTestRegistry(t, dir)) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"warn"}) + + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), "WARN") +} + +func TestProfileLint_VerboseShowsInfo(t *testing.T) { + dir := t.TempDir() + writeUserProfileLint(t, dir, "dead", `name: dead +description: dead rule profile +package_managers: + - npm +filesystem: + allow_read: + - ${HOME}/.cache/npm/** + - ${HOME}/.cache/npm/foo +`) + + t.Run("default hides info", func(t *testing.T) { + cmd := newProfileLintCommand(newTestRegistry(t, dir)) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"dead", "--json"}) + require.NoError(t, cmd.Execute()) + + var report struct { + Issues []pmgsandbox.LintIssue `json:"issues"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &report)) + for _, i := range report.Issues { + assert.NotEqual(t, pmgsandbox.LintLevelInfo, i.Level) + } + }) + + t.Run("verbose shows info", func(t *testing.T) { + cmd := newProfileLintCommand(newTestRegistry(t, dir)) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"dead", "--json", "--verbose"}) + require.NoError(t, cmd.Execute()) + + var report struct { + Issues []pmgsandbox.LintIssue `json:"issues"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &report)) + foundInfo := false + for _, i := range report.Issues { + if i.Level == pmgsandbox.LintLevelInfo { + foundInfo = true + } + } + assert.True(t, foundInfo, "expected at least one info-level issue with --verbose") + }) +} + +func TestProfileLint_UnknownProfile(t *testing.T) { + cmd := newProfileLintCommand(newTestRegistry(t, "")) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"does-not-exist"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Empty(t, stderr.String()) + assert.Contains(t, err.Error(), "not found") + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeNotFound, usefulErr.Code()) +} + +func TestProfileLintMissingTargetShowsUsage(t *testing.T) { + cmd := newProfileLintCommand(newTestRegistry(t, "")) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, stderr.String(), "accepts 1 arg(s), received 0") + assert.Contains(t, stdout.String(), "Usage:") + assert.Contains(t, stdout.String(), "lint [flags]") + assert.Contains(t, stdout.String(), "pmg sandbox profile lint npm-restrictive") +} + +func TestProfileLint_LiteralPath(t *testing.T) { + dir := t.TempDir() + path := writeUserProfileLint(t, dir, "literal", `name: literal +description: literal +package_managers: + - npm +filesystem: + allow_read: + - /tmp +`) + + // Use a registry without user dir — must fall through to the literal path branch. + cmd := newProfileLintCommand(newTestRegistry(t, "")) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{path, "--json"}) + + require.NoError(t, cmd.Execute()) + var report struct { + Profile string `json:"profile"` + } + require.NoError(t, json.Unmarshal(stdout.Bytes(), &report)) + assert.Equal(t, path, report.Profile) +} diff --git a/cmd/sandbox/profile_list.go b/cmd/sandbox/profile_list.go new file mode 100644 index 0000000..82a43c8 --- /dev/null +++ b/cmd/sandbox/profile_list.go @@ -0,0 +1,146 @@ +package sandbox + +import ( + "fmt" + "io" + "strings" + + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +type profileListOptions struct { + jsonOut bool +} + +func newProfileListCommand(factory registryFactory) *cobra.Command { + opts := &profileListOptions{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List available sandbox profiles (built-in and user)", + Example: " pmg sandbox profile list", + SilenceErrors: false, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + err := runProfileList(cmd.OutOrStdout(), opts, factory) + if err != nil { + return sandboxErrorExit(cmd, err) + } + return err + }, + } + + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit profiles as JSON") + return cmd +} + +func runProfileList(out io.Writer, opts *profileListOptions, factory registryFactory) error { + registry, err := factory() + if err != nil { + return err + } + + summaries, err := registry.ListProfiles() + if err != nil { + return err + } + + if opts.jsonOut { + return writeProfileListJSON(out, summaries) + } + + return renderProfileListHuman(out, summaries) +} + +type jsonProfileSummary struct { + Name string `json:"name"` + Source string `json:"source"` + Path string `json:"path,omitempty"` + Inherits string `json:"inherits,omitempty"` + PackageManagers []string `json:"package_managers,omitempty"` + Description string `json:"description,omitempty"` + Shadowed bool `json:"shadowed,omitempty"` +} + +type jsonProfileListReport struct { + Profiles []jsonProfileSummary `json:"profiles"` +} + +func writeProfileListJSON(out io.Writer, summaries []pmgsandbox.ProfileSummary) error { + report := jsonProfileListReport{Profiles: make([]jsonProfileSummary, 0, len(summaries))} + for _, s := range summaries { + report.Profiles = append(report.Profiles, jsonProfileSummary{ + Name: s.Name, + Source: string(s.Source), + Path: s.Path, + Inherits: s.Inherits, + PackageManagers: s.PackageManagers, + Description: s.Description, + Shadowed: s.Shadowed, + }) + } + + return writeJSONIndent(out, report) +} + +func renderProfileListHuman(out io.Writer, summaries []pmgsandbox.ProfileSummary) error { + if len(summaries) == 0 { + _, err := fmt.Fprintln(out, ui.Colors.Dim("No sandbox profiles available.")) + return err + } + + if _, err := fmt.Fprintln(out); err != nil { + return err + } + if _, err := fmt.Fprintln(out, ui.Colors.Cyan("Sandbox Profiles")); err != nil { + return err + } + if _, err := fmt.Fprintln(out, ui.Colors.Normal("-----------------")); err != nil { + return err + } + + rows := make([][]string, 0, len(summaries)+1) + rows = append(rows, []string{ + ui.Colors.Bold("STATUS"), + ui.Colors.Bold("NAME"), + ui.Colors.Bold("SOURCE"), + ui.Colors.Bold("INHERITS"), + ui.Colors.Bold("PMS"), + ui.Colors.Bold("DESCRIPTION"), + }) + for _, s := range summaries { + rows = append(rows, []string{ + statusCell(s), + s.Name, + sourceCell(s), + emptyDash(s.Inherits), + truncate(strings.Join(s.PackageManagers, ","), 30), + truncate(s.Description, 60), + }) + } + + return renderTable(out, rows, nil) +} + +func statusCell(s pmgsandbox.ProfileSummary) string { + if s.Shadowed { + return ui.Colors.Dim("SHADOWED") + } + return " " +} + +func sourceCell(s pmgsandbox.ProfileSummary) string { + if s.Source == pmgsandbox.ProfileSourceBuiltin { + return ui.Colors.Dim("builtin") + } + return truncateLeft(s.Path, 50) +} + +func emptyDash(s string) string { + if s == "" { + return ui.Colors.Dim("—") + } + return s +} diff --git a/cmd/sandbox/profile_list_test.go b/cmd/sandbox/profile_list_test.go new file mode 100644 index 0000000..3ea8497 --- /dev/null +++ b/cmd/sandbox/profile_list_test.go @@ -0,0 +1,116 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pmgsandbox "github.com/safedep/pmg/sandbox" +) + +func newTestRegistry(t *testing.T, userDir string) registryFactory { + t.Helper() + return func() (pmgsandbox.ProfileRegistry, error) { + opts := []pmgsandbox.RegistryOption{} + if userDir != "" { + opts = append(opts, pmgsandbox.WithUserProfileDir(userDir)) + } + return pmgsandbox.NewProfileRegistry(opts...) + } +} + +func writeTestUserProfile(t *testing.T, dir, name string) { + t.Helper() + body := "name: " + name + ` +description: user ` + name + ` +package_managers: + - npm +filesystem: + allow_read: + - /tmp + allow_write: + - /tmp + deny_read: [] + deny_write: [] +` + require.NoError(t, os.WriteFile(filepath.Join(dir, name+".yml"), []byte(body), 0o644)) +} + +func TestProfileListHuman(t *testing.T) { + cmd := newProfileListCommand(newTestRegistry(t, "")) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{}) + + require.NoError(t, cmd.Execute()) + + out := stdout.String() + assert.Contains(t, out, "Sandbox Profiles") + assert.Contains(t, out, "npm-restrictive") + assert.Contains(t, out, "builtin") +} + +func TestProfileListJSON(t *testing.T) { + dir := t.TempDir() + writeTestUserProfile(t, dir, "my-custom") + + cmd := newProfileListCommand(newTestRegistry(t, dir)) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json"}) + + require.NoError(t, cmd.Execute()) + + var report jsonProfileListReport + require.NoError(t, json.Unmarshal(stdout.Bytes(), &report)) + require.NotEmpty(t, report.Profiles) + + var foundBuiltin, foundUser bool + for _, p := range report.Profiles { + if p.Source == "builtin" { + foundBuiltin = true + } + if p.Source == "user" && p.Name == "my-custom" { + foundUser = true + assert.NotEmpty(t, p.Path) + } + } + assert.True(t, foundBuiltin) + assert.True(t, foundUser) +} + +func TestProfileListShadowedTag(t *testing.T) { + dir := t.TempDir() + writeTestUserProfile(t, dir, "npm-restrictive") + + cmd := newProfileListCommand(newTestRegistry(t, dir)) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{}) + + require.NoError(t, cmd.Execute()) + assert.Contains(t, stdout.String(), "SHADOWED") +} + +func TestProfileListRejectsUnexpectedArgs(t *testing.T) { + cmd := newProfileListCommand(newTestRegistry(t, "")) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"extra"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, stderr.String(), "unknown command") + assert.Contains(t, stdout.String(), "Usage:") + assert.Contains(t, stdout.String(), "list [flags]") + assert.Contains(t, stdout.String(), "pmg sandbox profile list") +} diff --git a/cmd/sandbox/profile_show.go b/cmd/sandbox/profile_show.go new file mode 100644 index 0000000..9ea7ed4 --- /dev/null +++ b/cmd/sandbox/profile_show.go @@ -0,0 +1,170 @@ +package sandbox + +import ( + "fmt" + "io" + "os" + + "github.com/safedep/dry/log" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/sandbox/platform" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +type profileShowOptions struct { + resolved bool + driver string + cwd string + home string + jsonOut bool +} + +func newProfileShowCommand(factory registryFactory) *cobra.Command { + opts := &profileShowOptions{} + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show a sandbox profile (raw YAML, resolved policy, or driver-rendered)", + Example: " pmg sandbox profile show npm-restrictive --resolved", + Args: cobra.ExactArgs(1), + SilenceErrors: false, + RunE: func(cmd *cobra.Command, args []string) error { + err := runProfileShow(cmd.OutOrStdout(), args[0], opts, factory) + if err != nil { + return sandboxErrorExit(cmd, err) + } + return err + }, + } + + cmd.Flags().BoolVar(&opts.resolved, "resolved", false, "Print the policy after inheritance and variable expansion") + cmd.Flags().StringVar(&opts.driver, "driver", "", "Render the resolved policy for a specific driver: seatbelt|bubblewrap|landlock") + cmd.Flags().StringVar(&opts.cwd, "cwd", "", "Override ${CWD} during expansion (defaults to current working directory)") + cmd.Flags().StringVar(&opts.home, "home", "", "Override ${HOME} during expansion (defaults to current user home)") + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit output as JSON") + return cmd +} + +func runProfileShow(out io.Writer, name string, opts *profileShowOptions, factory registryFactory) error { + if err := validateDriver(opts.driver); err != nil { + return err + } + + registry, err := factory() + if err != nil { + return err + } + + if opts.driver != "" { + return runProfileShowDriver(out, name, opts, registry) + } + + if opts.resolved { + return runProfileShowResolved(out, name, opts, registry) + } + + return runProfileShowRaw(out, name, opts, registry) +} + +func runProfileShowRaw(out io.Writer, name string, opts *profileShowOptions, registry pmgsandbox.ProfileRegistry) error { + source, path, data, err := loadProfileSource(name, registry) + if err != nil { + return err + } + + if opts.jsonOut { + report := map[string]any{ + "name": name, + "source": string(source), + "path": path, + "yaml": string(data), + } + return writeJSONIndent(out, report) + } + + _, err = out.Write(data) + return err +} + +func runProfileShowResolved(out io.Writer, name string, opts *profileShowOptions, registry pmgsandbox.ProfileRegistry) error { + policy, err := registry.ResolveProfile(name, pmgsandbox.ResolveOptions{ + CWD: opts.cwd, + Home: opts.home, + }) + if err != nil { + return err + } + + if opts.jsonOut { + report := map[string]any{ + "name": name, + "policy": policy, + } + return writeJSONIndent(out, report) + } + + data, err := yaml.Marshal(policy) + if err != nil { + return fmt.Errorf("failed to marshal resolved policy: %w", err) + } + + _, err = out.Write(data) + return err +} + +func runProfileShowDriver(out io.Writer, name string, opts *profileShowOptions, registry pmgsandbox.ProfileRegistry) error { + policy, err := registry.ResolveProfile(name, pmgsandbox.ResolveOptions{ + CWD: opts.cwd, + Home: opts.home, + }) + if err != nil { + return err + } + + rendered, err := platform.Render(pmgsandbox.DriverName(opts.driver), policy) + if err != nil { + return err + } + + if opts.jsonOut { + report := map[string]any{ + "name": name, + "driver": opts.driver, + "rendered": string(rendered), + } + return writeJSONIndent(out, report) + } + + _, err = out.Write(rendered) + return err +} + +func loadProfileSource(name string, registry pmgsandbox.ProfileRegistry) (pmgsandbox.ProfileSource, string, []byte, error) { + if data, ok := registry.BuiltinProfileYAML(name); ok { + return pmgsandbox.ProfileSourceBuiltin, "", data, nil + } + + summaries, err := registry.ListProfiles() + if err != nil { + log.Warnf("sandbox: failed to enumerate user profiles: %v", err) + } + for _, s := range summaries { + if s.Source == pmgsandbox.ProfileSourceUser && s.Name == name { + data, readErr := os.ReadFile(s.Path) + if readErr != nil { + return "", "", nil, fmt.Errorf("failed to read user profile %s: %w", s.Path, readErr) + } + return pmgsandbox.ProfileSourceUser, s.Path, data, nil + } + } + + if data, readErr := os.ReadFile(name); readErr == nil { + return pmgsandbox.ProfileSourceUser, name, data, nil + } + + return "", "", nil, notFoundError( + fmt.Sprintf("sandbox profile not found: %s", name), + "Use `pmg sandbox profile list` to see available profiles, or pass an existing profile YAML path.", + ) +} diff --git a/cmd/sandbox/profile_show_test.go b/cmd/sandbox/profile_show_test.go new file mode 100644 index 0000000..4008721 --- /dev/null +++ b/cmd/sandbox/profile_show_test.go @@ -0,0 +1,151 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "runtime" + "strings" + "testing" + + "github.com/safedep/pmg/usefulerror" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProfileShowRaw(t *testing.T) { + cmd := newProfileShowCommand(newTestRegistry(t, "")) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"npm-restrictive"}) + + require.NoError(t, cmd.Execute()) + + out := stdout.String() + assert.Contains(t, out, "name: npm-restrictive") + assert.Contains(t, out, "${CWD}", "raw output should preserve placeholders") +} + +func TestProfileShowResolved(t *testing.T) { + cmd := newProfileShowCommand(newTestRegistry(t, "")) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"npm-restrictive", "--resolved", "--cwd", "/work", "--home", "/h"}) + + require.NoError(t, cmd.Execute()) + + out := stdout.String() + assert.NotContains(t, out, "${CWD}") + assert.NotContains(t, out, "${HOME}") + assert.Contains(t, out, "/work") + assert.Contains(t, out, "/h") +} + +func TestProfileShowJSONRaw(t *testing.T) { + cmd := newProfileShowCommand(newTestRegistry(t, "")) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"npm-restrictive", "--json"}) + + require.NoError(t, cmd.Execute()) + + var report map[string]any + require.NoError(t, json.Unmarshal(stdout.Bytes(), &report)) + assert.Equal(t, "npm-restrictive", report["name"]) + assert.Equal(t, "builtin", report["source"]) + assert.Contains(t, report["yaml"].(string), "name: npm-restrictive") +} + +func TestProfileShowUnknownDriver(t *testing.T) { + cmd := newProfileShowCommand(newTestRegistry(t, "")) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"npm-restrictive", "--driver", "bogus"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Empty(t, stderr.String()) + assert.Contains(t, err.Error(), "unknown driver") + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code()) +} + +func TestProfileShowUnknownProfileReturnsNotFound(t *testing.T) { + cmd := newProfileShowCommand(newTestRegistry(t, "")) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"does-not-exist"}) + + err := cmd.Execute() + require.Error(t, err) + assert.Empty(t, stdout.String()) + assert.Empty(t, stderr.String()) + assert.Contains(t, err.Error(), "not found") + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeNotFound, usefulErr.Code()) +} + +func TestProfileShowMissingNameShowsUsage(t *testing.T) { + cmd := newProfileShowCommand(newTestRegistry(t, "")) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{}) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, stderr.String(), "accepts 1 arg(s), received 0") + assert.Contains(t, stdout.String(), "Usage:") + assert.Contains(t, stdout.String(), "show [flags]") + assert.Contains(t, stdout.String(), "pmg sandbox profile show npm-restrictive --resolved") +} + +func TestProfileShowDriverNative(t *testing.T) { + cmd := newProfileShowCommand(newTestRegistry(t, "")) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + + driver := "" + switch runtime.GOOS { + case "darwin": + driver = "seatbelt" + case "linux": + driver = "bubblewrap" + default: + t.Skipf("no native driver for %s", runtime.GOOS) + } + + cmd.SetArgs([]string{"npm-restrictive", "--driver", driver}) + require.NoError(t, cmd.Execute()) + assert.NotEmpty(t, strings.TrimSpace(stdout.String())) +} + +func TestProfileShowDriverNonNativeErrors(t *testing.T) { + cmd := newProfileShowCommand(newTestRegistry(t, "")) + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + + driver := "" + switch runtime.GOOS { + case "darwin": + driver = "bubblewrap" + case "linux": + driver = "seatbelt" + default: + t.Skipf("no non-native driver to test on %s", runtime.GOOS) + } + + cmd.SetArgs([]string{"npm-restrictive", "--driver", driver}) + err := cmd.Execute() + require.Error(t, err) + assert.Empty(t, stderr.String()) + assert.Contains(t, err.Error(), "not available") +} diff --git a/cmd/sandbox/sandbox.go b/cmd/sandbox/sandbox.go new file mode 100644 index 0000000..bf5e48c --- /dev/null +++ b/cmd/sandbox/sandbox.go @@ -0,0 +1,26 @@ +// Package sandbox implements the `pmg sandbox` command subtree. +// +// This is the ONLY package permitted to import internal/ui for rendering +// sandbox diagnostics. The sandbox package produces structured probe +// results; presentation lives here. +package sandbox + +import "github.com/spf13/cobra" + +// NewCommand returns the `pmg sandbox` parent command. +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "sandbox", + Short: "Inspect and manage PMG sandbox configuration", + Long: "Tools for diagnosing the host sandbox environment and (in future PRs) managing sandbox profiles.", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + cmd.AddCommand(NewDoctorCommand()) + cmd.AddCommand(NewProfileCommand()) + cmd.AddCommand(NewExplainCommand()) + cmd.AddCommand(NewViolationsCommand()) + return cmd +} diff --git a/cmd/sandbox/violations.go b/cmd/sandbox/violations.go new file mode 100644 index 0000000..219d349 --- /dev/null +++ b/cmd/sandbox/violations.go @@ -0,0 +1,26 @@ +package sandbox + +import ( + "github.com/safedep/pmg/config" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/spf13/cobra" +) + +// NewViolationsCommand returns the `pmg sandbox violations` parent +// command. Subcommands browse and (in future) manage the violation cache. +func NewViolationsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "violations", + Short: "Browse and manage cached sandbox violation reports", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + + factory := func() *pmgsandbox.ViolationCache { + return pmgsandbox.NewViolationCache(config.Get().SandboxViolationCacheDir()) + } + + cmd.AddCommand(newViolationsListCommand(factory)) + return cmd +} diff --git a/cmd/sandbox/violations_list.go b/cmd/sandbox/violations_list.go new file mode 100644 index 0000000..8bd1866 --- /dev/null +++ b/cmd/sandbox/violations_list.go @@ -0,0 +1,187 @@ +package sandbox + +import ( + "errors" + "fmt" + "io" + "time" + + "github.com/safedep/pmg/internal/ui" + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/usefulerror" + "github.com/spf13/cobra" +) + +const ExitCodeViolationsListFail = 2 + +type violationsListOptions struct { + limit int + jsonOut bool +} + +type violationsListFailError struct { + usefulerror.UsefulError +} + +func (e *violationsListFailError) ExitCode() int { return ExitCodeViolationsListFail } + +func newViolationsListFailError(code, msg, help string) *violationsListFailError { + return &violationsListFailError{ + UsefulError: usefulerror.Useful(). + WithCode(code). + WithHumanError(msg). + WithHelp(help). + Wrap(errors.New(msg)), + } +} + +func newViolationsListCommand(factory cacheFactory) *cobra.Command { + opts := &violationsListOptions{} + + cmd := &cobra.Command{ + Use: "list", + Short: "List cached sandbox violations as one-line summaries", + Example: " pmg sandbox violations list --limit 20", + SilenceErrors: false, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + err := runViolationsList(cmd.OutOrStdout(), cmd.ErrOrStderr(), opts, factory) + if err != nil { + return sandboxErrorExit(cmd, err) + } + return err + }, + } + + cmd.Flags().IntVar(&opts.limit, "limit", 10, "Maximum number of entries to show (0 means all)") + cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit entries as JSON") + return cmd +} + +func runViolationsList(out, errOut io.Writer, opts *violationsListOptions, factory cacheFactory) error { + if opts.limit < 0 { + return newViolationsListFailError( + usefulerror.ErrCodeInvalidArgument, + fmt.Sprintf("invalid --limit %d (must be >= 0)", opts.limit), + "Pass --limit 0 to show all entries, or a positive limit.", + ) + } + + cache := factory() + entries, err := cache.List() + if err != nil { + return newViolationsListFailError( + usefulerror.ErrCodeUnknown, + fmt.Sprintf("read cache: %v", err), + "Check the sandbox violation cache directory and retry.", + ) + } + + if opts.limit > 0 && len(entries) > opts.limit { + entries = entries[:opts.limit] + } + + if opts.jsonOut { + return writeViolationsListJSON(out, entries) + } + + if len(entries) == 0 { + _, err := fmt.Fprintln(errOut, "no violations cached") + return err + } + + return renderViolationsListTable(out, entries) +} + +func renderViolationsListTable(out io.Writer, entries []pmgsandbox.ViolationCacheEntry) error { + rows := make([][]string, 0, len(entries)+1) + rows = append(rows, []string{ + ui.Colors.Bold("RECORDED"), + ui.Colors.Bold("SANDBOX"), + ui.Colors.Bold("PROFILE"), + ui.Colors.Bold("KIND"), + ui.Colors.Bold("TARGET"), + }) + + dash := ui.Colors.Dim("—") + for _, e := range entries { + recorded := "" + if !e.Record.RecordedAt.IsZero() { + recorded = e.Record.RecordedAt.UTC().Format(time.RFC3339) + } + + sandboxName := "" + profile := "" + if e.Record.Report != nil { + sandboxName = string(e.Record.Report.SandboxName) + profile = e.Record.Report.PolicyName + } + + kind := dash + target := dash + if e.Record.Report != nil { + primary := pmgsandbox.BuildExplanation(e.Record.Report).Primary + if primary != nil { + if primary.Kind != "" { + kind = string(primary.Kind) + } + if primary.Target != "" { + target = truncate(primary.Target, 60) + } + } + } + + rows = append(rows, []string{recorded, sandboxName, profile, kind, target}) + } + + return renderTable(out, rows, nil) +} + +type violationsListJSONPrimary struct { + Kind string `json:"kind"` + Target string `json:"target,omitempty"` + RuleLabel string `json:"rule_label,omitempty"` +} + +type violationsListJSONEntry struct { + Path string `json:"path"` + RecordedAt string `json:"recorded_at,omitempty"` + SandboxName string `json:"sandbox_name,omitempty"` + PolicyName string `json:"policy_name,omitempty"` + Primary *violationsListJSONPrimary `json:"primary,omitempty"` + ViolationCount int `json:"violation_count"` +} + +type violationsListJSONOutput struct { + Entries []violationsListJSONEntry `json:"entries"` +} + +func writeViolationsListJSON(out io.Writer, entries []pmgsandbox.ViolationCacheEntry) error { + payload := violationsListJSONOutput{Entries: make([]violationsListJSONEntry, 0, len(entries))} + + for _, e := range entries { + item := violationsListJSONEntry{Path: e.Path} + if !e.Record.RecordedAt.IsZero() { + item.RecordedAt = e.Record.RecordedAt.UTC().Format(time.RFC3339) + } + + if e.Record.Report != nil { + item.SandboxName = string(e.Record.Report.SandboxName) + item.PolicyName = e.Record.Report.PolicyName + item.ViolationCount = len(e.Record.Report.Violations) + + primary := pmgsandbox.BuildExplanation(e.Record.Report).Primary + if primary != nil { + item.Primary = &violationsListJSONPrimary{ + Kind: string(primary.Kind), + Target: primary.Target, + RuleLabel: primary.RuleLabel, + } + } + } + + payload.Entries = append(payload.Entries, item) + } + + return writeJSONIndent(out, payload) +} diff --git a/cmd/sandbox/violations_list_test.go b/cmd/sandbox/violations_list_test.go new file mode 100644 index 0000000..2e253d9 --- /dev/null +++ b/cmd/sandbox/violations_list_test.go @@ -0,0 +1,200 @@ +package sandbox + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/usefulerror" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestCacheFactory(t *testing.T) (cacheFactory, *pmgsandbox.ViolationCache) { + t.Helper() + dir := t.TempDir() + cache := pmgsandbox.NewViolationCache(dir) + return func() *pmgsandbox.ViolationCache { return cache }, cache +} + +func sampleViolationsReport(target string) *pmgsandbox.ViolationReport { + return &pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + PolicyName: "npm-restrictive", + Violations: []pmgsandbox.Violation{ + {Kind: pmgsandbox.ViolationKindFSWrite, Target: target, RuleLabel: "file-write"}, + }, + } +} + +func runList(t *testing.T, factory cacheFactory, args ...string) (string, string, error) { + t.Helper() + cmd := newViolationsListCommand(factory) + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), errOut.String(), err +} + +func TestViolationsList_EmptyCache(t *testing.T) { + factory, _ := newTestCacheFactory(t) + + stdout, stderr, err := runList(t, factory) + require.NoError(t, err) + assert.Empty(t, stdout) + assert.Contains(t, stderr, "no violations cached") +} + +func TestViolationsList_TwoEntriesNewestFirst(t *testing.T) { + factory, cache := newTestCacheFactory(t) + + _, err := cache.Write(sampleViolationsReport("/Users/dev/older")) + require.NoError(t, err) + _, err = cache.Write(sampleViolationsReport("/Users/dev/newer")) + require.NoError(t, err) + + stdout, _, err := runList(t, factory) + require.NoError(t, err) + + assert.Contains(t, stdout, "RECORDED") + assert.Contains(t, stdout, "SANDBOX") + assert.Contains(t, stdout, "seatbelt") + assert.Contains(t, stdout, "npm-restrictive") + assert.Contains(t, stdout, "/Users/dev/older") + assert.Contains(t, stdout, "/Users/dev/newer") + + newerIdx := strings.Index(stdout, "/Users/dev/newer") + olderIdx := strings.Index(stdout, "/Users/dev/older") + require.NotEqual(t, -1, newerIdx) + require.NotEqual(t, -1, olderIdx) + assert.Less(t, newerIdx, olderIdx, "newest entry should appear first") +} + +func TestViolationsList_LimitOne(t *testing.T) { + factory, cache := newTestCacheFactory(t) + + _, err := cache.Write(sampleViolationsReport("/Users/dev/a")) + require.NoError(t, err) + _, err = cache.Write(sampleViolationsReport("/Users/dev/b")) + require.NoError(t, err) + + stdout, _, err := runList(t, factory, "--limit", "1") + require.NoError(t, err) + + assert.Contains(t, stdout, "/Users/dev/b") + assert.NotContains(t, stdout, "/Users/dev/a") +} + +func TestViolationsListRejectsUnexpectedArgs(t *testing.T) { + factory, _ := newTestCacheFactory(t) + + stdout, stderr, err := runList(t, factory, "extra") + require.Error(t, err) + assert.Contains(t, stderr, "unknown command") + assert.Contains(t, stdout, "Usage:") + assert.Contains(t, stdout, "list [flags]") + assert.Contains(t, stdout, "pmg sandbox violations list --limit 20") +} + +func TestViolationsListRejectsNegativeLimit(t *testing.T) { + factory, _ := newTestCacheFactory(t) + + _, _, err := runList(t, factory, "--limit", "-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --limit") + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodeInvalidArgument, usefulErr.Code()) +} + +func TestViolationsList_LimitZeroReturnsAll(t *testing.T) { + factory, cache := newTestCacheFactory(t) + + for _, target := range []string{"/Users/dev/a", "/Users/dev/b", "/Users/dev/c"} { + _, err := cache.Write(sampleViolationsReport(target)) + require.NoError(t, err) + } + + stdout, _, err := runList(t, factory, "--limit", "0") + require.NoError(t, err) + + assert.Contains(t, stdout, "/Users/dev/a") + assert.Contains(t, stdout, "/Users/dev/b") + assert.Contains(t, stdout, "/Users/dev/c") +} + +func TestViolationsList_JSON(t *testing.T) { + factory, cache := newTestCacheFactory(t) + + _, err := cache.Write(sampleViolationsReport("/Users/dev/x")) + require.NoError(t, err) + _, err = cache.Write(sampleViolationsReport("/Users/dev/y")) + require.NoError(t, err) + + stdout, _, err := runList(t, factory, "--json") + require.NoError(t, err) + + var payload struct { + Entries []struct { + Path string `json:"path"` + RecordedAt string `json:"recorded_at"` + SandboxName string `json:"sandbox_name"` + PolicyName string `json:"policy_name"` + Primary *struct { + Kind string `json:"kind"` + Target string `json:"target"` + RuleLabel string `json:"rule_label"` + } `json:"primary,omitempty"` + ViolationCount int `json:"violation_count"` + } `json:"entries"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + require.Len(t, payload.Entries, 2) + assert.Equal(t, "seatbelt", payload.Entries[0].SandboxName) + assert.Equal(t, "npm-restrictive", payload.Entries[0].PolicyName) + require.NotNil(t, payload.Entries[0].Primary) + assert.Equal(t, "fs_write", payload.Entries[0].Primary.Kind) + assert.Equal(t, 1, payload.Entries[0].ViolationCount) +} + +func TestViolationsList_NoViolations_RendersDash(t *testing.T) { + factory, cache := newTestCacheFactory(t) + + _, err := cache.Write(&pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + PolicyName: "npm-restrictive", + Violations: nil, + }) + require.NoError(t, err) + + stdout, _, err := runList(t, factory) + require.NoError(t, err) + assert.Contains(t, stdout, "seatbelt") + assert.Contains(t, stdout, "—") +} + +func TestViolationsList_NoViolations_JSONOmitsPrimary(t *testing.T) { + factory, cache := newTestCacheFactory(t) + + _, err := cache.Write(&pmgsandbox.ViolationReport{ + SandboxName: pmgsandbox.DriverSeatbelt, + PolicyName: "npm-restrictive", + Violations: nil, + }) + require.NoError(t, err) + + stdout, _, err := runList(t, factory, "--json") + require.NoError(t, err) + + assert.NotContains(t, stdout, "\"primary\"") + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + entries, ok := payload["entries"].([]any) + require.True(t, ok) + require.Len(t, entries, 1) +} diff --git a/cmd/setup/info.go b/cmd/setup/info.go index 114269f..2646289 100644 --- a/cmd/setup/info.go +++ b/cmd/setup/info.go @@ -157,7 +157,7 @@ func resolveSandboxDriverName() string { return "unavailable" } - return sb.Name() + return string(sb.Name()) } // describeCloudCredentials reports whether SafeDep Cloud credentials can be diff --git a/cmd/version/version.go b/cmd/version/version.go index 354e4e8..7cfe1f5 100644 --- a/cmd/version/version.go +++ b/cmd/version/version.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/safedep/dry/log" "github.com/safedep/pmg/internal/ui" "github.com/safedep/pmg/internal/version" "github.com/spf13/cobra" @@ -16,8 +17,12 @@ func NewVersionCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { fmt.Print(ui.GeneratePMGBanner(version.Version, version.Commit)) - fmt.Fprintf(os.Stdout, "Version: %s\n", version.Version) - fmt.Fprintf(os.Stdout, "CommitSHA: %s\n", version.Commit) + if _, err := fmt.Fprintf(os.Stdout, "Version: %s\n", version.Version); err != nil { + log.Warnf("failed to write version line: %v", err) + } + if _, err := fmt.Fprintf(os.Stdout, "CommitSHA: %s\n", version.Commit); err != nil { + log.Warnf("failed to write commit line: %v", err) + } return nil }, diff --git a/config/config.go b/config/config.go index 5feeee3..cf98511 100644 --- a/config/config.go +++ b/config/config.go @@ -27,6 +27,9 @@ const ( // Allow overriding the config path from the environment pmgConfigDirEnvKey = "PMG_CONFIG_DIR" + // Allow overriding the cache path from the environment + pmgCacheDirEnvKey = "PMG_CACHE_DIR" + // Config path is computed as the user config directory + the default relative path // when not overridden by the environment variable pmgDefaultHomeRelativePath = "safedep/pmg" @@ -34,6 +37,12 @@ const ( // Default log directory is relative to the config directory. pmgDefaultLogDir = "logs" + // Default sandbox profile directory is relative to the config directory. + pmgDefaultSandboxProfileDir = "sandbox/profiles" + + // Default sandbox violation cache directory is relative to the cache root. + pmgDefaultSandboxViolationCacheDir = "sandbox/violations" + // Config file name. // Important: The config file path and the schema should be backward compatible. In case of breaking config // changes, we must introduce a new file name and a migration path. @@ -176,10 +185,12 @@ type RuntimeConfig struct { SandboxAllowOverrides []SandboxAllowOverride // Internal config values computed at runtime and must be accessed via. API - configDir string - configFilePath string - eventLogDir string - viper *viper.Viper + configDir string + configFilePath string + eventLogDir string + sandboxProfileDir string + sandboxViolationCacheDir string + viper *viper.Viper } // CloudSyncDBPath returns the path to the cloud sync WAL database. @@ -202,6 +213,16 @@ func (r *RuntimeConfig) ConfigDir() string { return r.configDir } +// SandboxProfileDir returns the path to the user sandbox profile directory. +func (r *RuntimeConfig) SandboxProfileDir() string { + return r.sandboxProfileDir +} + +// SandboxViolationCacheDir returns the path to the sandbox violation cache directory. +func (r *RuntimeConfig) SandboxViolationCacheDir() string { + return r.sandboxViolationCacheDir +} + func (r *RuntimeConfig) IsProxyModeEnabled() bool { return r.Config.Proxy.Enabled } @@ -303,9 +324,21 @@ func initConfig() { panic(fmt.Errorf("failed to get event log directory: %w", err)) } + sandboxProfileDir, err := sandboxProfileDir() + if err != nil { + panic(fmt.Errorf("failed to get sandbox profile directory: %w", err)) + } + + sandboxViolationCacheDir, err := sandboxViolationCacheDir() + if err != nil { + panic(fmt.Errorf("failed to get sandbox violation cache directory: %w", err)) + } + globalConfig.configDir = configDir globalConfig.configFilePath = configFilePath globalConfig.eventLogDir = eventLogDir + globalConfig.sandboxProfileDir = sandboxProfileDir + globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir loadConfig() @@ -377,6 +410,55 @@ func eventLogDir() (string, error) { } } +// cacheDir computes the path to the cache root directory. +func cacheDir() (string, error) { + dir := os.Getenv(pmgCacheDirEnvKey) + if dir != "" { + return dir, nil + } + + switch runtime.GOOS { + case "windows": + // Windows: %LOCALAPPDATA%\safedep\pmg or %USERPROFILE%\safedep\pmg + baseDir := os.Getenv("LOCALAPPDATA") + if baseDir == "" { + baseDir = os.Getenv("USERPROFILE") + if baseDir == "" { + return "", fmt.Errorf("could not determine Windows user directory for cache storage") + } + } + return filepath.Join(baseDir, pmgDefaultHomeRelativePath), nil + case "darwin", "linux": + userCacheDir, err := os.UserCacheDir() + if err != nil { + return "", fmt.Errorf("failed to retrieve user cache directory: %w", err) + } + return filepath.Join(userCacheDir, pmgDefaultHomeRelativePath), nil + default: + return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) + } +} + +// sandboxProfileDir computes the path to the sandbox profile directory. +func sandboxProfileDir() (string, error) { + configDir, err := configDir() + if err != nil { + return "", fmt.Errorf("failed to get config directory: %w", err) + } + + return filepath.Join(configDir, pmgDefaultSandboxProfileDir), nil +} + +// sandboxViolationCacheDir computes the path to the sandbox violation cache directory. +func sandboxViolationCacheDir() (string, error) { + cacheDir, err := cacheDir() + if err != nil { + return "", fmt.Errorf("failed to get cache directory: %w", err) + } + + return filepath.Join(cacheDir, pmgDefaultSandboxViolationCacheDir), nil +} + // Get returns the global configuration. // This is the public API for the configuration package. This package should guarantee // that this function will never return nil. diff --git a/config/sandbox_paths_test.go b/config/sandbox_paths_test.go new file mode 100644 index 0000000..947652b --- /dev/null +++ b/config/sandbox_paths_test.go @@ -0,0 +1,129 @@ +package config + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSandboxProfileDir(t *testing.T) { + tests := []struct { + name string + envKey string + envVal string + expected func(t *testing.T) string + }{ + { + name: "default under user config dir", + envKey: "PMG_CONFIG_DIR", + envVal: "", + expected: func(t *testing.T) string { + userConfigDir, err := os.UserConfigDir() + require.NoError(t, err) + return filepath.Join(userConfigDir, pmgDefaultHomeRelativePath, pmgDefaultSandboxProfileDir) + }, + }, + { + name: "honors PMG_CONFIG_DIR override", + envKey: "PMG_CONFIG_DIR", + envVal: "/tmp/pmg-test/custom-config", + expected: func(t *testing.T) string { + return filepath.Join("/tmp/pmg-test/custom-config", pmgDefaultSandboxProfileDir) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(tc.envKey, tc.envVal) + initConfig() + + assert.Equal(t, tc.expected(t), Get().SandboxProfileDir()) + }) + } +} + +func TestSandboxProfileDirRespectsXDGConfigHome(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("XDG_CONFIG_HOME is only honored on Linux by os.UserConfigDir") + } + + tmp := t.TempDir() + t.Setenv("PMG_CONFIG_DIR", "") + t.Setenv("XDG_CONFIG_HOME", tmp) + initConfig() + + expected := filepath.Join(tmp, pmgDefaultHomeRelativePath, pmgDefaultSandboxProfileDir) + assert.Equal(t, expected, Get().SandboxProfileDir()) +} + +func TestSandboxViolationCacheDir(t *testing.T) { + tests := []struct { + name string + envKey string + envVal string + expected func(t *testing.T) string + }{ + { + name: "default under user cache dir", + envKey: "PMG_CACHE_DIR", + envVal: "", + expected: func(t *testing.T) string { + base, err := userCacheBase() + require.NoError(t, err) + return filepath.Join(base, pmgDefaultHomeRelativePath, pmgDefaultSandboxViolationCacheDir) + }, + }, + { + name: "honors PMG_CACHE_DIR override", + envKey: "PMG_CACHE_DIR", + envVal: "/tmp/pmg-test/custom-cache", + expected: func(t *testing.T) string { + return filepath.Join("/tmp/pmg-test/custom-cache", pmgDefaultSandboxViolationCacheDir) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("PMG_CONFIG_DIR", "") + t.Setenv(tc.envKey, tc.envVal) + initConfig() + + assert.Equal(t, tc.expected(t), Get().SandboxViolationCacheDir()) + }) + } +} + +func TestSandboxViolationCacheDirRespectsXDGCacheHome(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("XDG_CACHE_HOME is only honored on Linux by os.UserCacheDir") + } + + tmp := t.TempDir() + t.Setenv("PMG_CACHE_DIR", "") + t.Setenv("XDG_CACHE_HOME", tmp) + initConfig() + + expected := filepath.Join(tmp, pmgDefaultHomeRelativePath, pmgDefaultSandboxViolationCacheDir) + assert.Equal(t, expected, Get().SandboxViolationCacheDir()) +} + +// userCacheBase returns the platform's user cache root the same way cacheDir() +// in config.go resolves it (sans the PMG_CACHE_DIR override). +func userCacheBase() (string, error) { + switch runtime.GOOS { + case "windows": + base := os.Getenv("LOCALAPPDATA") + if base == "" { + base = os.Getenv("USERPROFILE") + } + return base, nil + default: + return os.UserCacheDir() + } +} diff --git a/docs/sandbox.md b/docs/sandbox.md index 1602a9c..75e33d4 100644 --- a/docs/sandbox.md +++ b/docs/sandbox.md @@ -80,6 +80,9 @@ See [Bubblewrap Installation](https://github.com/containers/bubblewrap#installat See [configuration](./config.md) and [config/config.template.yml](../config/config.template.yml) for the configuration schema. Once sandbox is enabled, you can run package manager commands with sandbox protection. +Run `pmg sandbox doctor` to see platform specific sandbox setup and driver status. Continue using +PMG as usual, sandbox will be applied to configured package managers automatically. + ```bash pmg npm install express ``` @@ -96,6 +99,51 @@ Run sandbox with custom policy file: pmg --sandbox --sandbox-profile=/path/to/custom-policy.yml npm install express ``` +### Sandbox Profile Commands + +Use profile commands to inspect, create, and validate sandbox profiles. + +```bash +# List built-in and user profiles +pmg sandbox profile list + +# Scaffold a user profile that inherits from a built-in +pmg sandbox profile init my-npm --from npm-restrictive + +# Show a profile, or its fully resolved policy +pmg sandbox profile show npm-restrictive +pmg sandbox profile show npm-restrictive --resolved + +# Lint a built-in, user profile, or profile file +pmg sandbox profile lint npm-restrictive +pmg sandbox profile lint ./my-profile.yml --strict + +# Compare two resolved profiles +pmg sandbox profile diff npm-restrictive pypi-restrictive +``` + +### Sandbox Debug Commands + +Use these commands when a sandboxed package manager command fails and you need to inspect why. + +```bash +# Check sandbox driver availability and host setup +pmg sandbox doctor + +# List recent sandbox denials captured by PMG +pmg sandbox violations list + +# Explain the latest captured denial and show a suggested override when possible +pmg sandbox explain --last + +# Inspect the resolved policy PMG will apply +pmg sandbox profile show npm-restrictive --resolved +``` + +`pmg sandbox doctor` runs platform-specific checks for the current host. Cached violation reports +used by `violations list` and `explain --last` are currently produced by macOS Seatbelt diagnostics; +on Linux, Bubblewrap and Landlock denials may only appear as command errors such as `EACCES`. + ### Runtime Allow Overrides Use `--sandbox-allow` to make one-off exceptions without creating a custom profile. This is useful diff --git a/extractor/common.go b/extractor/common.go index 4d6943b..9254904 100644 --- a/extractor/common.go +++ b/extractor/common.go @@ -17,6 +17,7 @@ import ( "github.com/google/osv-scalibr/extractor/filesystem/language/python/requirements" "github.com/google/osv-scalibr/extractor/filesystem/language/python/uvlock" "github.com/google/osv-scalibr/fs" + "github.com/safedep/dry/log" ) func getExtractorForFile(filename string) (filesystem.Extractor, error) { @@ -55,7 +56,11 @@ func parseLockfile(lockfilePath, scanDir string, ecosystem packagev1.Ecosystem) if err != nil { return nil, fmt.Errorf("failed to open lockfile: %w", err) } - defer file.Close() + defer func() { + if err := file.Close(); err != nil { + log.Warnf("failed to close lockfile: %v", err) + } + }() inputConfig := &filesystem.ScanInput{ FS: fs.DirFS(scanDir), diff --git a/go.mod b/go.mod index 96767b1..8378462 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jedib0t/go-pretty/v6 v6.7.9 github.com/landlock-lsm/go-landlock v0.7.0 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/posthog/posthog-go v1.5.12 github.com/safedep/dry v0.0.0-20260513152148-f809919cc4ce github.com/safedep/ptyx v0.2.1-0.20260119085117-f667570c2d12 @@ -68,7 +69,6 @@ require ( github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/package-url/packageurl-go v0.1.3 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect diff --git a/internal/alias/alias.go b/internal/alias/alias.go index 4aa3795..b7c9b2f 100644 --- a/internal/alias/alias.go +++ b/internal/alias/alias.go @@ -59,7 +59,11 @@ func (m *defaultRcFileManager) Create(aliases []string) (string, error) { if err != nil { return "", err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + log.Warnf("failed to close rc file %s: %v", rcPath, err) + } + }() for _, alias := range aliases { if _, err := f.WriteString(alias); err != nil { @@ -290,7 +294,11 @@ func (a *AliasManager) addSourceLine(configPath, sourceLine string) error { return err } - defer f.Close() + defer func() { + if err := f.Close(); err != nil { + log.Warnf("failed to close config file %s: %v", configPath, err) + } + }() _, err = fmt.Fprintf(f, "\n%s", sourceLine) return err diff --git a/internal/analytics/analytics_test.go b/internal/analytics/analytics_test.go index d99009a..a39aba9 100644 --- a/internal/analytics/analytics_test.go +++ b/internal/analytics/analytics_test.go @@ -6,12 +6,15 @@ import ( "github.com/safedep/pmg/config" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestIsDisabled(t *testing.T) { t.Run("returns true if PMG_DISABLE_TELEMETRY is set to true", func(t *testing.T) { - os.Setenv(telemetryDisableEnvKey, "true") - defer os.Unsetenv(telemetryDisableEnvKey) + require.NoError(t, os.Setenv(telemetryDisableEnvKey, "true")) + defer func() { + require.NoError(t, os.Unsetenv(telemetryDisableEnvKey)) + }() assert.True(t, IsDisabled()) }) diff --git a/internal/pty/session.go b/internal/pty/session.go index 6720f2a..cd7da8a 100644 --- a/internal/pty/session.go +++ b/internal/pty/session.go @@ -7,6 +7,7 @@ import ( "os" "strings" + "github.com/safedep/dry/log" "github.com/safedep/ptyx" "golang.org/x/term" ) @@ -93,7 +94,9 @@ func NewSession(ctx context.Context, cfg SessionConfig) (InteractiveSession, err // 2. Set raw mode, save old state oldState, err := c.MakeRaw() if err != nil { - c.Close() + if closeErr := c.Close(); closeErr != nil { + log.Warnf("failed to close console after MakeRaw error: %v", closeErr) + } return nil, fmt.Errorf("failed to set raw mode: %w", err) } diff --git a/internal/ui/error.go b/internal/ui/error.go index 12dc76c..217b178 100644 --- a/internal/ui/error.go +++ b/internal/ui/error.go @@ -10,6 +10,11 @@ import ( // ErrorExit prints a minimal, clean error message and exits with a non-zero status code. func ErrorExit(err error) { + ErrorExitWithCode(err, 1) +} + +// ErrorExitWithCode prints a minimal, clean error message and exits with code. +func ErrorExitWithCode(err error, code int) { log.Errorf("Exiting due to error: %s", err) usefulErr := convertToUsefulError(err) @@ -29,7 +34,7 @@ func ErrorExit(err error) { printMinimalError(usefulErr.Code(), usefulErr.HumanError(), hint) } - os.Exit(1) + os.Exit(code) } // printMinimalError prints error in minimal two-line format: diff --git a/internal/ui/sandbox_violation.go b/internal/ui/sandbox_violation.go new file mode 100644 index 0000000..4b36270 --- /dev/null +++ b/internal/ui/sandbox_violation.go @@ -0,0 +1,183 @@ +package ui + +import ( + "fmt" + "io" + "strings" + "time" + + pmgsandbox "github.com/safedep/pmg/sandbox" +) + +// FormatSandboxOverrideFlag renders an OverrideSuggestion as a `--sandbox-allow` +// CLI flag invocation. Returns "" when there is nothing safe to suggest. +// +// The flag name lives here (not in sandbox/) because it is CLI-surface owned +// by the cmd layer; the sandbox package only knows kind + target. +func FormatSandboxOverrideFlag(o *pmgsandbox.OverrideSuggestion) string { + if o == nil { + return "" + } + + quoted := shellQuote(o.Target) + switch o.Kind { + case pmgsandbox.ViolationKindFSRead: + return "--sandbox-allow read=" + quoted + case pmgsandbox.ViolationKindFSWrite, pmgsandbox.ViolationKindFSDeleteOrRename: + return "--sandbox-allow write=" + quoted + case pmgsandbox.ViolationKindExec: + return "--sandbox-allow exec=" + quoted + default: + return "" + } +} + +// FormatSandboxHint produces the short, one-line "Reason: ... Override: ..." +// summary shown above the detail block. +func FormatSandboxHint(primary *pmgsandbox.Violation, override *pmgsandbox.OverrideSuggestion) string { + if primary == nil { + return "Reason: sandbox denied an operation" + } + + hint := "Reason: " + primary.RuleLabel + if flag := FormatSandboxOverrideFlag(override); flag != "" { + hint += ". Override: " + flag + } + return hint +} + +// FormatSandboxDetails produces the multi-line detail block shown beneath the +// hint. Each line is "Label: value"; callers indent as they see fit. +func FormatSandboxDetails(report *pmgsandbox.ViolationReport, primary *pmgsandbox.Violation) string { + if primary == nil || report == nil { + return "" + } + + process := primary.Process + if process == "" { + process = "unknown" + } + + lines := []string{ + "Sandbox: " + string(report.SandboxName), + "Policy: " + report.PolicyName, + "Correlation: " + report.CorrelationID, + "Process: " + process, + "Violation: " + primary.RuleLabel, + } + + if primary.RuleTarget != "" && primary.RuleTarget != primary.Target { + lines = append(lines, "Matched rule: "+primary.RuleTarget) + } + + if primary.RawLog != "" { + lines = append(lines, "Seatbelt log: "+primary.RawLog) + } + + if len(report.Violations) > 1 { + lines = append(lines, fmt.Sprintf("Additional denials observed: %d", len(report.Violations)-1)) + } + + return strings.Join(lines, "\n") +} + +// RenderSandboxViolation writes the full human-readable explanation for a +// cached violation record to out. cmd handlers should prefer this over +// re-implementing the layout — it owns the section ordering, colors, and +// separator conventions. +func RenderSandboxViolation(out io.Writer, rec *pmgsandbox.ViolationCacheRecord) error { + if rec == nil || rec.Report == nil { + return fmt.Errorf("render sandbox violation: empty record") + } + + exp := pmgsandbox.BuildExplanation(rec.Report) + + recordedAt := "" + if !rec.RecordedAt.IsZero() { + recordedAt = rec.RecordedAt.UTC().Format(time.RFC3339) + } + + header := fmt.Sprintf("%s %s %s %s", + Colors.Dim("Sandbox:"), Colors.Bold(string(rec.Report.SandboxName)), + Colors.Dim("Profile:"), Colors.Bold(rec.Report.PolicyName), + ) + if recordedAt != "" { + header = fmt.Sprintf("%s %s %s", header, Colors.Dim("Recorded:"), Colors.Normal(recordedAt)) + } + + if _, err := fmt.Fprintln(out, header); err != nil { + return err + } + if _, err := fmt.Fprintln(out, Colors.Normal("--------------------------------------------------------")); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + + hint := FormatSandboxHint(exp.Primary, exp.Override) + if hint != "" { + if _, err := fmt.Fprintln(out, hint); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + + details := FormatSandboxDetails(rec.Report, exp.Primary) + if details != "" { + if _, err := fmt.Fprintln(out, Colors.Bold("Details:")); err != nil { + return err + } + for _, line := range strings.Split(details, "\n") { + if _, err := fmt.Fprintf(out, " %s\n", line); err != nil { + return err + } + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + + if flag := FormatSandboxOverrideFlag(exp.Override); flag != "" { + if _, err := fmt.Fprintln(out, Colors.Bold("Suggested override:")); err != nil { + return err + } + if _, err := fmt.Fprintf(out, " %s\n", Colors.Cyan(flag)); err != nil { + return err + } + if _, err := fmt.Fprintln(out); err != nil { + return err + } + } + + if exp.Primary != nil { + if _, err := fmt.Fprintln(out, Colors.Bold("Primary violation:")); err != nil { + return err + } + if _, err := fmt.Fprintf(out, " %s %s\n", Colors.Dim("Kind:"), string(exp.Primary.Kind)); err != nil { + return err + } + if _, err := fmt.Fprintf(out, " %s %s\n", Colors.Dim("Target:"), exp.Primary.Target); err != nil { + return err + } + if _, err := fmt.Fprintf(out, " %s %s\n", Colors.Dim("Rule:"), exp.Primary.RuleLabel); err != nil { + return err + } + if exp.Primary.Process != "" { + if _, err := fmt.Fprintf(out, " %s %s\n", Colors.Dim("Process:"), exp.Primary.Process); err != nil { + return err + } + } + } + + return nil +} + +// shellQuote wraps value in single quotes, escaping any embedded single +// quotes. Used so suggested override flags can be copy-pasted into a POSIX +// shell verbatim regardless of spaces or quotes in the target. +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} diff --git a/internal/ui/sandbox_violation_test.go b/internal/ui/sandbox_violation_test.go new file mode 100644 index 0000000..89358a9 --- /dev/null +++ b/internal/ui/sandbox_violation_test.go @@ -0,0 +1,122 @@ +package ui + +import ( + "bytes" + "testing" + "time" + + pmgsandbox "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFormatSandboxOverrideFlagNil(t *testing.T) { + assert.Empty(t, FormatSandboxOverrideFlag(nil)) +} + +func TestFormatSandboxOverrideFlagKinds(t *testing.T) { + tests := []struct { + kind pmgsandbox.ViolationKind + want string + }{ + {pmgsandbox.ViolationKindFSRead, "--sandbox-allow read='./.env'"}, + {pmgsandbox.ViolationKindFSWrite, "--sandbox-allow write='./.env'"}, + {pmgsandbox.ViolationKindFSDeleteOrRename, "--sandbox-allow write='./.env'"}, + {pmgsandbox.ViolationKindExec, "--sandbox-allow exec='./.env'"}, + {pmgsandbox.ViolationKindGenericDeny, ""}, + } + for _, tt := range tests { + t.Run(string(tt.kind), func(t *testing.T) { + got := FormatSandboxOverrideFlag(&pmgsandbox.OverrideSuggestion{ + Kind: tt.kind, + Target: "./.env", + }) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFormatSandboxOverrideFlagShellQuotesSpacesAndSingleQuotes(t *testing.T) { + got := FormatSandboxOverrideFlag(&pmgsandbox.OverrideSuggestion{ + Kind: pmgsandbox.ViolationKindFSRead, + Target: "/tmp/My Dir/it's.env", + }) + assert.Equal(t, "--sandbox-allow read='/tmp/My Dir/it'\\''s.env'", got) +} + +func TestFormatSandboxHintEmpty(t *testing.T) { + assert.Equal(t, "Reason: sandbox denied an operation", FormatSandboxHint(nil, nil)) +} + +func TestFormatSandboxHintIncludesOverride(t *testing.T) { + primary := &pmgsandbox.Violation{ + Kind: pmgsandbox.ViolationKindFSRead, + Target: "./.env", + RuleLabel: "read access denied: ./.env", + } + override := &pmgsandbox.OverrideSuggestion{Kind: pmgsandbox.ViolationKindFSRead, Target: "./.env"} + hint := FormatSandboxHint(primary, override) + assert.Contains(t, hint, "Reason: read access denied: ./.env") + assert.Contains(t, hint, "Override: --sandbox-allow read='./.env'") +} + +func TestFormatSandboxDetailsIncludesMatchedRule(t *testing.T) { + report := &pmgsandbox.ViolationReport{ + SandboxName: "seatbelt", + PolicyName: "npm-restrictive", + CorrelationID: "run-1", + Violations: []pmgsandbox.Violation{ + { + Kind: pmgsandbox.ViolationKindFSRead, + Target: "./.env", + RuleTarget: "**/.env", + Process: "node", + RuleLabel: "read access denied: ./.env", + }, + }, + } + details := FormatSandboxDetails(report, &report.Violations[0]) + assert.Contains(t, details, "Matched rule: **/.env") + assert.Contains(t, details, "Process: node") + assert.Contains(t, details, "Sandbox: seatbelt") +} + +func TestFormatSandboxDetailsEmpty(t *testing.T) { + assert.Empty(t, FormatSandboxDetails(nil, nil)) + assert.Empty(t, FormatSandboxDetails(&pmgsandbox.ViolationReport{}, nil)) +} + +func TestRenderSandboxViolationContainsKeySections(t *testing.T) { + rec := &pmgsandbox.ViolationCacheRecord{ + SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion, + RecordedAt: time.Date(2026, 5, 19, 10, 0, 0, 0, time.UTC), + Report: &pmgsandbox.ViolationReport{ + SandboxName: "seatbelt", + PolicyName: "npm-restrictive", + CorrelationID: "run-1", + Violations: []pmgsandbox.Violation{ + { + Kind: pmgsandbox.ViolationKindFSRead, + Target: "./.env", + RuleLabel: "read access denied: ./.env", + Process: "node", + }, + }, + }, + } + + var buf bytes.Buffer + require.NoError(t, RenderSandboxViolation(&buf, rec)) + out := buf.String() + assert.Contains(t, out, "Reason:") + assert.Contains(t, out, "Details:") + assert.Contains(t, out, "Suggested override:") + assert.Contains(t, out, "Primary violation:") + assert.Contains(t, out, "--sandbox-allow read='./.env'") +} + +func TestRenderSandboxViolationRejectsNilRecord(t *testing.T) { + var buf bytes.Buffer + assert.Error(t, RenderSandboxViolation(&buf, nil)) + assert.Error(t, RenderSandboxViolation(&buf, &pmgsandbox.ViolationCacheRecord{})) +} diff --git a/main.go b/main.go index d154a20..77c8ec0 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( landlockCmd "github.com/safedep/pmg/cmd/landlock" "github.com/safedep/pmg/cmd/npm" "github.com/safedep/pmg/cmd/pypi" + sandboxCmd "github.com/safedep/pmg/cmd/sandbox" "github.com/safedep/pmg/cmd/setup" "github.com/safedep/pmg/cmd/version" "github.com/safedep/pmg/config" @@ -31,6 +32,12 @@ var ( logFile string ) +func setLogEnv(key, value string) { + if err := os.Setenv(key, value); err != nil { + log.Warnf("failed to set %s: %v", key, err) + } +} + func main() { cmd := &cobra.Command{ Use: "pmg", @@ -39,18 +46,18 @@ func main() { // Always set this first because we will override the log // level if debug or verbose is set if logFile != "" { - os.Setenv("APP_LOG_FILE", logFile) - os.Setenv("APP_LOG_LEVEL", "info") + setLogEnv("APP_LOG_FILE", logFile) + setLogEnv("APP_LOG_LEVEL", "info") } // Set the log level when debug is enabled if debug { - os.Setenv("APP_LOG_LEVEL", "debug") + setLogEnv("APP_LOG_LEVEL", "debug") } // Skip stdout logging when debugging is not enabled if !debug { - os.Setenv("APP_LOG_SKIP_STDOUT_LOGGER", "true") + setLogEnv("APP_LOG_SKIP_STDOUT_LOGGER", "true") } // Apply config-based verbosity first @@ -136,6 +143,7 @@ func main() { cmd.AddCommand(version.NewVersionCommand()) cmd.AddCommand(setup.NewSetupCommand()) cmd.AddCommand(setup.NewRemoveCommand()) + cmd.AddCommand(sandboxCmd.NewCommand()) cmd.AddCommand(cloud.NewCloudCommand()) cmd.AddCommand(configCmd.NewConfigCommand()) @@ -153,7 +161,11 @@ func main() { }) defer analytics.Close() - defer eventlog.Close() + defer func() { + if err := eventlog.Close(); err != nil { + log.Warnf("failed to close eventlog: %v", err) + } + }() defer func() { if err := audit.Close(); err != nil { log.Warnf("failed to close audit system: %v", err) @@ -164,6 +176,10 @@ func main() { analytics.TrackCI() if err := cmd.Execute(); err != nil { + type exitCoder interface{ ExitCode() int } + if ec, ok := err.(exitCoder); ok { + os.Exit(ec.ExitCode()) + } os.Exit(1) } } diff --git a/packagemanager/pypi_resolver.go b/packagemanager/pypi_resolver.go index e0cdfc7..0fcfcf9 100644 --- a/packagemanager/pypi_resolver.go +++ b/packagemanager/pypi_resolver.go @@ -168,7 +168,11 @@ func getPypiPackageDependencies(packageName, version string, packageTargets []*P if res.StatusCode != 200 { return nil, ErrFailedToFetchPackage.Wrap(err) } - defer res.Body.Close() + defer func() { + if err := res.Body.Close(); err != nil { + log.Warnf("failed to close PyPI response body: %v", err) + } + }() var pypipkg pypiPackage err = json.NewDecoder(res.Body).Decode(&pypipkg) diff --git a/sandbox/executor/apply.go b/sandbox/executor/apply.go index 26c0ebb..d8b1b9e 100644 --- a/sandbox/executor/apply.go +++ b/sandbox/executor/apply.go @@ -46,7 +46,7 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app opt(applyConfig) } - registry, err := sandbox.NewProfileRegistry() + registry, err := sandbox.NewProfileRegistry(sandbox.WithUserProfileDir(cfg.SandboxProfileDir())) if err != nil { return nil, fmt.Errorf("failed to create profile registry: %w", err) } diff --git a/sandbox/executor/diagnostics.go b/sandbox/executor/diagnostics.go index 480be52..d8fb973 100644 --- a/sandbox/executor/diagnostics.go +++ b/sandbox/executor/diagnostics.go @@ -2,245 +2,78 @@ package executor import ( "fmt" - "os" - "path/filepath" - "strings" "github.com/safedep/dry/log" + "github.com/safedep/pmg/config" "github.com/safedep/pmg/sandbox" "github.com/safedep/pmg/usefulerror" ) // WrapCommandExecutionError converts a package manager execution error into a -// user-facing error. When sandbox diagnostics are available, they take -// precedence over the generic exit-code-only message. +// user-facing error. It never attributes the failure to the sandbox: causation +// cannot be inferred from EPERM/EACCES returns alone, and a security tool +// should not make best-effort claims. Any observed sandbox denials are +// persisted to the violation cache for forensic review via +// `pmg sandbox violations list` and `pmg sandbox explain`. func WrapCommandExecutionError(err error, result *sandbox.ExecutionResult, exitCode int) error { if err == nil { return nil } - if result != nil { - report, diagErr := result.BestEffortViolation(err) - if diagErr != nil { - log.Warnf("failed to collect sandbox diagnostics: %v", diagErr) - } else if report != nil && len(report.Violations) > 0 { - return usefulerror.Useful(). - WithCode(usefulerror.ErrCodeSandboxViolation). - WithHumanError("PMG sandbox blocked this command"). - WithHelp(buildSandboxHint(report)). - WithAdditionalHelp(buildSandboxDetails(report)). - Wrap(err) - } - } + observed := observeAndPersistViolations(result, err) humanError := "Failed to execute package manager command" if exitCode >= 0 { humanError = fmt.Sprintf("Package manager command exited with code: %d", exitCode) } - return usefulerror.Useful(). + help := "Check the package manager command and its arguments" + builder := usefulerror.Useful(). WithCode(usefulerror.ErrCodePackageManagerExecutionFailed). WithHumanError(humanError). - WithHelp("Check the package manager command and its arguments"). - Wrap(err) + WithHelp(help) + + if observed > 0 { + builder = builder.WithAdditionalHelp(fmt.Sprintf( + "Sandbox observed %d denied operation(s) during this run. Run `pmg sandbox violations list` to investigate.", + observed, + )) + } + + return builder.Wrap(err) } -func buildSandboxHint(report *sandbox.ViolationReport) string { - first := primarySandboxViolation(report) - if first == nil { - return "Reason: sandbox denied an operation" +// observeAndPersistViolations collects any sandbox violation report associated +// with the run and writes it to the violation cache. Returns the number of +// violations observed. Failures are logged and swallowed; observability MUST +// NOT affect command exit. +func observeAndPersistViolations(result *sandbox.ExecutionResult, runErr error) int { + if result == nil { + return 0 } - hint := fmt.Sprintf("Reason: %s", first.RuleLabel) - - if override := suggestSandboxOverride(*first); override != "" { - hint = fmt.Sprintf("%s. Override: %s", hint, override) + report, diagErr := result.BestEffortViolation(runErr) + if diagErr != nil { + log.Warnf("failed to collect sandbox diagnostics: %v", diagErr) + return 0 } - - return hint -} - -func buildSandboxDetails(report *sandbox.ViolationReport) string { - first := primarySandboxViolation(report) - if first == nil { - return "" - } - - lines := []string{ - fmt.Sprintf("Sandbox: %s", report.SandboxName), - fmt.Sprintf("Policy: %s", report.PolicyName), - fmt.Sprintf("Correlation: %s", report.CorrelationID), - fmt.Sprintf("Process: %s", emptyFallback(first.Process, "unknown")), - fmt.Sprintf("Violation: %s", first.RuleLabel), - } - - if first.RuleTarget != "" && first.RuleTarget != first.Target { - lines = append(lines, fmt.Sprintf("Matched rule: %s", first.RuleTarget)) - } - - if first.RawLog != "" { - lines = append(lines, fmt.Sprintf("Seatbelt log: %s", first.RawLog)) - } - - if len(report.Violations) > 1 { - lines = append(lines, fmt.Sprintf("Additional denials observed: %d", len(report.Violations)-1)) - } - - return strings.Join(lines, "\n") -} - -func suggestSandboxOverride(v sandbox.Violation) string { - if !isSafeSandboxOverrideTarget(v.Target) { - return "" - } - - quotedTarget := shellQuote(v.Target) - - switch v.Kind { - case sandbox.ViolationKindFSRead: - return fmt.Sprintf("--sandbox-allow read=%s", quotedTarget) - case sandbox.ViolationKindFSWrite, sandbox.ViolationKindFSDeleteOrRename: - return fmt.Sprintf("--sandbox-allow write=%s", quotedTarget) - case sandbox.ViolationKindExec: - return fmt.Sprintf("--sandbox-allow exec=%s", quotedTarget) - default: - return "" - } -} - -func isSafeSandboxOverrideTarget(value string) bool { - if value == "" { - return false - } - - if strings.ContainsAny(value, "*?[]") { - return false - } - - for _, r := range value { - if r == 0 || r < 0x20 || r == 0x7f { - return false - } - } - - return true -} - -func shellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" -} - -func emptyFallback(value, fallback string) string { - if value == "" { - return fallback - } - - return value -} - -func primarySandboxViolation(report *sandbox.ViolationReport) *sandbox.Violation { if report == nil || len(report.Violations) == 0 { - return nil + return 0 } - cwd, _ := os.Getwd() - bestIdx := 0 - bestScore := scoreSandboxViolation(report.Violations[0], cwd) - - for i := 1; i < len(report.Violations); i++ { - score := scoreSandboxViolation(report.Violations[i], cwd) - if score > bestScore || (score == bestScore && i > bestIdx) { - bestIdx = i - bestScore = score - } + cfg := config.Get() + if cfg == nil { + return len(report.Violations) } - return &report.Violations[bestIdx] -} - -func scoreSandboxViolation(v sandbox.Violation, cwd string) int { - score := 0 - - switch v.Kind { - case sandbox.ViolationKindFSRead, sandbox.ViolationKindFSWrite: - score += 120 - case sandbox.ViolationKindExec: - score += 110 - case sandbox.ViolationKindFSDeleteOrRename: - score += 100 - case sandbox.ViolationKindGenericDeny: - score += 10 - default: - score += 30 - } - - if isSafeSandboxOverrideTarget(v.Target) { - score += 40 - } - - if v.Target != "" && v.Target != v.RuleTarget { - score += 20 - } - - if isProjectPath(v.Target, cwd) { - score += 80 - } - - if isSensitiveProjectFile(v.Target) { - score += 60 - } - - if isNoisySystemPath(v.Target) { - score -= 120 + dir := cfg.SandboxViolationCacheDir() + if dir == "" { + return len(report.Violations) } - if v.Kind == sandbox.ViolationKindGenericDeny && v.Target == "" { - score -= 40 + if _, err := sandbox.NewViolationCache(dir).Write(report); err != nil { + log.Warnf("failed to persist sandbox violation report: %v", err) } - return score -} - -func isProjectPath(target, cwd string) bool { - if target == "" || cwd == "" { - return false - } - - if strings.HasPrefix(target, ".") { - return true - } - - cleanTarget := filepath.Clean(target) - cleanCwd := filepath.Clean(cwd) - - return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator)) -} - -func isSensitiveProjectFile(target string) bool { - if target == "" { - return false - } - - base := filepath.Base(target) - switch { - case strings.HasPrefix(base, ".env"): - return true - case base == ".npmrc", base == ".pypirc", base == ".netrc": - return true - case base == ".aws", base == ".ssh", base == ".kube", base == ".gnupg": - return true - default: - return strings.Contains(target, string(filepath.Separator)+".ssh") || - strings.Contains(target, string(filepath.Separator)+".aws") || - strings.Contains(target, string(filepath.Separator)+".kube") - } -} - -func isNoisySystemPath(target string) bool { - switch target { - case "/dev/dtracehelper": - return true - default: - return false - } + return len(report.Violations) } diff --git a/sandbox/executor/diagnostics_test.go b/sandbox/executor/diagnostics_test.go index 5721853..d19c3cf 100644 --- a/sandbox/executor/diagnostics_test.go +++ b/sandbox/executor/diagnostics_test.go @@ -1,113 +1,85 @@ package executor import ( - "os" - "path/filepath" + "context" + "errors" + "os/exec" "testing" "github.com/safedep/pmg/sandbox" + "github.com/safedep/pmg/usefulerror" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestSuggestSandboxOverrideSkipsGlobRuleTarget(t *testing.T) { - assert.Empty(t, suggestSandboxOverride(sandbox.Violation{ - Kind: sandbox.ViolationKindFSRead, - Target: "**/.env", - })) +type fakeViolationSandbox struct { + report *sandbox.ViolationReport } -func TestSuggestSandboxOverrideUsesConcretePath(t *testing.T) { - assert.Equal(t, "--sandbox-allow read='./.env'", suggestSandboxOverride(sandbox.Violation{ - Kind: sandbox.ViolationKindFSRead, - Target: "./.env", - })) +func (f *fakeViolationSandbox) Execute(context.Context, *exec.Cmd, *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) { + return sandbox.NewExecutionResult(), nil } -func TestSuggestSandboxOverrideQuotesSpacesAndSingleQuotes(t *testing.T) { - assert.Equal(t, "--sandbox-allow read='/tmp/My Dir/it'\\''s.env'", suggestSandboxOverride(sandbox.Violation{ - Kind: sandbox.ViolationKindFSRead, - Target: "/tmp/My Dir/it's.env", - })) +func (f *fakeViolationSandbox) Name() sandbox.DriverName { + return sandbox.DriverSeatbelt } -func TestSuggestSandboxOverrideSkipsControlCharacters(t *testing.T) { - assert.Empty(t, suggestSandboxOverride(sandbox.Violation{ - Kind: sandbox.ViolationKindFSRead, - Target: "/tmp/bad\npath", - })) +func (f *fakeViolationSandbox) IsAvailable() bool { + return true } -func TestBuildSandboxDetailsIncludesMatchedRule(t *testing.T) { - details := buildSandboxDetails(&sandbox.ViolationReport{ - SandboxName: "seatbelt", - PolicyName: "npm-restrictive", - CorrelationID: "run-1", - Violations: []sandbox.Violation{ - { - Kind: sandbox.ViolationKindFSRead, - RawKind: "file-read", - Target: "./.env", - RuleTarget: "**/.env", - Process: "node", - RuleLabel: "read access denied: ./.env", +func (f *fakeViolationSandbox) Close() error { + return nil +} + +func (f *fakeViolationSandbox) BestEffortViolation(error) (*sandbox.ViolationReport, error) { + return f.report, nil +} + +// WrapCommandExecutionError must never claim the sandbox blocked a command. +// Even when violations were observed, the user-facing error stays the package +// manager's native exit; a neutral breadcrumb points at the forensic command. +func TestWrapCommandExecutionErrorDoesNotAttributeFailureToSandbox(t *testing.T) { + result := sandbox.NewExecutionResult(sandbox.WithExecutionResultSandbox(&fakeViolationSandbox{ + report: &sandbox.ViolationReport{ + SandboxName: sandbox.DriverSeatbelt, + PolicyName: "npm-restrictive", + CorrelationID: "run-1", + Violations: []sandbox.Violation{ + { + Kind: sandbox.ViolationKindFSRead, + RawKind: "file-read", + Target: "./.env", + RuleTarget: "**/.env", + RuleLabel: "read access denied: ./.env", + }, }, }, - }) + })) - assert.Contains(t, details, "Matched rule: **/.env") + err := WrapCommandExecutionError(errors.New("npm failed"), result, 1) + + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodePackageManagerExecutionFailed, usefulErr.Code()) + assert.Equal(t, "Package manager command exited with code: 1", usefulErr.HumanError()) + assert.NotContains(t, usefulErr.Help(), "./.env") + assert.Contains(t, usefulErr.AdditionalHelp(), "pmg sandbox violations list") } -func TestPrimarySandboxViolationPrefersConcreteProjectPathOverDefaultNoise(t *testing.T) { - cwd, err := os.Getwd() - assert.NoError(t, err) +func TestWrapCommandExecutionErrorOmitsBreadcrumbWhenNoViolations(t *testing.T) { + result := sandbox.NewExecutionResult(sandbox.WithExecutionResultSandbox(&fakeViolationSandbox{ + report: nil, + })) - report := &sandbox.ViolationReport{ - Violations: []sandbox.Violation{ - { - Kind: sandbox.ViolationKindGenericDeny, - RawKind: "default", - Target: "/dev/dtracehelper", - RuleLabel: "sandbox denied access to /dev/dtracehelper", - }, - { - Kind: sandbox.ViolationKindFSRead, - RawKind: "file-read", - Target: filepath.Join(cwd, ".env"), - RuleTarget: "**/.env", - RuleLabel: "read access denied: " + filepath.Join(cwd, ".env"), - }, - }, - } + err := WrapCommandExecutionError(errors.New("npm failed"), result, 1) - primary := primarySandboxViolation(report) - if assert.NotNil(t, primary) { - assert.Equal(t, sandbox.ViolationKindFSRead, primary.Kind) - assert.Equal(t, filepath.Join(cwd, ".env"), primary.Target) - } + usefulErr, ok := usefulerror.AsUsefulError(err) + require.True(t, ok) + assert.Equal(t, usefulerror.ErrCodePackageManagerExecutionFailed, usefulErr.Code()) + assert.NotContains(t, usefulErr.AdditionalHelp(), "pmg sandbox violations list") } -func TestBuildSandboxHintUsesRankedPrimaryViolation(t *testing.T) { - cwd, err := os.Getwd() - assert.NoError(t, err) - - hint := buildSandboxHint(&sandbox.ViolationReport{ - Violations: []sandbox.Violation{ - { - Kind: sandbox.ViolationKindGenericDeny, - RawKind: "default", - Target: "/dev/dtracehelper", - RuleLabel: "sandbox denied access to /dev/dtracehelper", - }, - { - Kind: sandbox.ViolationKindFSRead, - RawKind: "file-read", - Target: filepath.Join(cwd, ".env"), - RuleTarget: "**/.env", - RuleLabel: "read access denied: " + filepath.Join(cwd, ".env"), - }, - }, - }) - - assert.Contains(t, hint, "Reason: read access denied:") - assert.NotContains(t, hint, "/dev/dtracehelper") +func TestWrapCommandExecutionErrorReturnsNilOnNilError(t *testing.T) { + assert.NoError(t, WrapCommandExecutionError(nil, nil, 0)) } diff --git a/sandbox/lint.go b/sandbox/lint.go new file mode 100644 index 0000000..087bb18 --- /dev/null +++ b/sandbox/lint.go @@ -0,0 +1,211 @@ +package sandbox + +import ( + "regexp" + "strconv" + "strings" + + "github.com/safedep/pmg/sandbox/util" +) + +// LintLevel categorises a lint issue. Errors indicate a profile that cannot +// safely be used; warnings flag risky or contradictory configuration; info +// surfaces minor cleanups (e.g. dead rules) that callers can hide by default. +type LintLevel string + +const ( + LintLevelError LintLevel = "error" + LintLevelWarn LintLevel = "warn" + LintLevelInfo LintLevel = "info" +) + +type LintIssue struct { + Level LintLevel `json:"level"` + Code string `json:"code"` + Message string `json:"message"` + Field string `json:"field,omitempty"` + Rule string `json:"rule,omitempty"` +} + +var variableTokenRe = regexp.MustCompile(`\$\{[^}]+\}`) + +// LintProfile returns issues in stable order: schema errors, then warnings in +// field-declaration order, then info-level findings. +func LintProfile(policy *SandboxPolicy) []LintIssue { + if policy == nil { + return []LintIssue{{ + Level: LintLevelError, + Code: "schema.invalid", + Message: "policy is nil", + }} + } + + var errors []LintIssue + var warns []LintIssue + var infos []LintIssue + + schemaErr := policy.Validate() + if schemaErr != nil { + errors = append(errors, LintIssue{ + Level: LintLevelError, + Code: "schema.invalid", + Message: schemaErr.Error(), + }) + } + // ValidateResolved calls Validate first; only surface its error when the + // basic schema was valid, so we don't duplicate the message above. + if schemaErr == nil { + if err := policy.ValidateResolved(); err != nil { + errors = append(errors, LintIssue{ + Level: LintLevelError, + Code: "schema.invalid", + Message: err.Error(), + }) + } + } + + // allowOnly marks lists where broadness and dead-rule checks make sense: + // deny-lists and network allow_bind are out of scope for those checks. + allowLists := []struct { + name string + rules []string + allowOnly bool + }{ + {"filesystem.allow_read", policy.Filesystem.AllowRead, true}, + {"filesystem.allow_write", policy.Filesystem.AllowWrite, true}, + {"filesystem.deny_read", policy.Filesystem.DenyRead, false}, + {"filesystem.deny_write", policy.Filesystem.DenyWrite, false}, + {"network.allow_outbound", policy.Network.AllowOutbound, false}, + {"network.deny_outbound", policy.Network.DenyOutbound, false}, + {"network.allow_bind", policy.Network.AllowBind, false}, + {"process.allow_exec", policy.Process.AllowExec, true}, + {"process.deny_exec", policy.Process.DenyExec, false}, + } + + knownVars := strings.Join(util.SupportedVariables, ", ") + for _, list := range allowLists { + for i, rule := range list.rules { + for _, tok := range variableTokenRe.FindAllString(rule, -1) { + if util.IsSupportedVariable(tok) { + continue + } + warns = append(warns, LintIssue{ + Level: LintLevelWarn, + Code: "vars.unresolved", + Message: "unsupported variable " + tok + " (known: " + knownVars + ")", + Field: fieldRef(list.name, i), + Rule: rule, + }) + } + } + } + + for _, list := range allowLists { + if !list.allowOnly { + continue + } + for i, rule := range list.rules { + code, msg := broadCheck(rule) + if code == "" { + continue + } + warns = append(warns, LintIssue{ + Level: LintLevelWarn, + Code: code, + Message: msg, + Field: fieldRef(list.name, i), + Rule: rule, + }) + } + } + + conflictPairs := []struct { + allowName string + allow []string + denyName string + deny []string + }{ + {"filesystem.allow_read", policy.Filesystem.AllowRead, "filesystem.deny_read", policy.Filesystem.DenyRead}, + {"filesystem.allow_write", policy.Filesystem.AllowWrite, "filesystem.deny_write", policy.Filesystem.DenyWrite}, + {"network.allow_outbound", policy.Network.AllowOutbound, "network.deny_outbound", policy.Network.DenyOutbound}, + {"process.allow_exec", policy.Process.AllowExec, "process.deny_exec", policy.Process.DenyExec}, + } + for _, pair := range conflictPairs { + denyIdx := map[string]int{} + for i, r := range pair.deny { + if _, exists := denyIdx[r]; !exists { + denyIdx[r] = i + } + } + for i, r := range pair.allow { + if j, ok := denyIdx[r]; ok { + warns = append(warns, LintIssue{ + Level: LintLevelWarn, + Code: "conflict.allow_deny", + Message: "rule appears in both " + fieldRef(pair.allowName, i) + " and " + fieldRef(pair.denyName, j) + "; deny takes precedence", + Field: fieldRef(pair.allowName, i), + Rule: r, + }) + } + } + } + + // Dead rules: a later rule is "shadowed" only if it is a strict prefix + // match of an earlier rule whose pattern ends with "/**". + for _, list := range allowLists { + if !list.allowOnly { + continue + } + for i, rule := range list.rules { + for j := 0; j < i; j++ { + earlier := list.rules[j] + if !strings.HasSuffix(earlier, "/**") { + continue + } + prefix := strings.TrimSuffix(earlier, "/**") + if prefix == "" { + continue + } + if rule == earlier { + continue + } + stripped := strings.TrimSuffix(rule, "/**") + if stripped == prefix { + continue + } + if strings.HasPrefix(stripped, prefix+"/") { + infos = append(infos, LintIssue{ + Level: LintLevelInfo, + Code: "dead.shadowed", + Message: "rule shadowed by " + fieldRef(list.name, j) + " (" + earlier + ")", + Field: fieldRef(list.name, i), + Rule: rule, + }) + break + } + } + } + } + + out := make([]LintIssue, 0, len(errors)+len(warns)+len(infos)) + out = append(out, errors...) + out = append(out, warns...) + out = append(out, infos...) + return out +} + +func broadCheck(rule string) (string, string) { + switch rule { + case "/**": + return "broad.root_glob", "rule grants access to entire filesystem (/**)" + case util.VarHome + "/**": + return "broad.home_glob", "rule grants access to entire user home (" + util.VarHome + "/**)" + case "**": + return "broad.all_glob", "rule uses unrestricted glob (**)" + } + return "", "" +} + +func fieldRef(name string, idx int) string { + return name + "[" + strconv.Itoa(idx) + "]" +} diff --git a/sandbox/lint_test.go b/sandbox/lint_test.go new file mode 100644 index 0000000..017f225 --- /dev/null +++ b/sandbox/lint_test.go @@ -0,0 +1,232 @@ +package sandbox + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// cleanPolicy returns a minimally-valid policy with no lint issues. +func cleanPolicy() *SandboxPolicy { + return &SandboxPolicy{ + Name: "test", + PackageManagers: []string{"npm"}, + Filesystem: FilesystemPolicy{ + AllowRead: []string{"${CWD}/src/**", "${HOME}/.npmrc"}, + AllowWrite: []string{"${CWD}/dist/**"}, + DenyRead: []string{"/etc/shadow"}, + DenyWrite: []string{"/etc/**"}, + }, + } +} + +func TestLintProfile_Clean(t *testing.T) { + got := LintProfile(cleanPolicy()) + assert.Empty(t, got) +} + +func TestLintProfile_SchemaInvalid(t *testing.T) { + tests := []struct { + name string + policy *SandboxPolicy + want string + }{ + { + name: "missing name", + policy: &SandboxPolicy{ + PackageManagers: []string{"npm"}, + Filesystem: FilesystemPolicy{AllowRead: []string{"/tmp"}}, + }, + want: "policy name is required", + }, + { + name: "no package managers", + policy: &SandboxPolicy{ + Name: "x", + Filesystem: FilesystemPolicy{AllowRead: []string{"/tmp"}}, + }, + want: "at least one package manager", + }, + { + name: "no rules", + policy: &SandboxPolicy{ + Name: "x", + PackageManagers: []string{"npm"}, + }, + want: "at least one access rule", + }, + { + name: "nil", + policy: nil, + want: "policy is nil", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := LintProfile(tc.policy) + assert.NotEmpty(t, got) + assert.Equal(t, LintLevelError, got[0].Level) + assert.Equal(t, "schema.invalid", got[0].Code) + assert.Contains(t, got[0].Message, tc.want) + }) + } +} + +func TestLintProfile_UnresolvedVariables(t *testing.T) { + p := cleanPolicy() + p.Filesystem.AllowRead = append(p.Filesystem.AllowRead, "${USER}/foo", "${HOME}/${UNKNOWN}/bar") + + got := LintProfile(p) + codes := map[string]int{} + for _, i := range got { + codes[i.Code]++ + } + assert.Equal(t, 2, codes["vars.unresolved"]) + + // First unresolved should cite the rule and the field path. + found := false + for _, i := range got { + if i.Code == "vars.unresolved" && i.Rule == "${USER}/foo" { + found = true + assert.Contains(t, i.Field, "filesystem.allow_read[") + assert.Contains(t, i.Message, "${USER}") + } + } + assert.True(t, found, "expected vars.unresolved issue for ${USER}/foo") +} + +func TestLintProfile_BroadRules(t *testing.T) { + tests := []struct { + name string + mut func(*SandboxPolicy) + code string + }{ + { + name: "root glob", + mut: func(p *SandboxPolicy) { + p.Filesystem.AllowRead = []string{"/**"} + }, + code: "broad.root_glob", + }, + { + name: "home glob", + mut: func(p *SandboxPolicy) { + p.Filesystem.AllowWrite = []string{"${HOME}/**"} + }, + code: "broad.home_glob", + }, + { + name: "all glob", + mut: func(p *SandboxPolicy) { + p.Process.AllowExec = []string{"**"} + }, + code: "broad.all_glob", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := cleanPolicy() + tc.mut(p) + got := LintProfile(p) + found := false + for _, i := range got { + if i.Code == tc.code { + found = true + assert.Equal(t, LintLevelWarn, i.Level) + } + } + assert.True(t, found, "expected %s issue", tc.code) + }) + } +} + +func TestLintProfile_NoBroadWarnOnDeny(t *testing.T) { + p := cleanPolicy() + p.Filesystem.DenyRead = []string{"/**", "${HOME}/**", "**"} + got := LintProfile(p) + for _, i := range got { + assert.NotContains(t, i.Code, "broad.", "deny lists should not produce broad.* warnings") + } +} + +func TestLintProfile_ConflictAllowDeny(t *testing.T) { + p := cleanPolicy() + p.Filesystem.AllowRead = []string{"/etc/hosts"} + p.Filesystem.DenyRead = []string{"/etc/hosts"} + + got := LintProfile(p) + found := false + for _, i := range got { + if i.Code == "conflict.allow_deny" { + found = true + assert.Equal(t, LintLevelWarn, i.Level) + assert.Equal(t, "/etc/hosts", i.Rule) + assert.Contains(t, i.Message, "filesystem.allow_read[0]") + assert.Contains(t, i.Message, "filesystem.deny_read[0]") + } + } + assert.True(t, found) +} + +func TestLintProfile_DeadShadowed(t *testing.T) { + p := cleanPolicy() + p.Filesystem.AllowRead = []string{ + "${HOME}/.cache/npm/**", + "${HOME}/.cache/npm/foo", + "${HOME}/.cache/npm/sub/**", + } + + got := LintProfile(p) + infoCount := 0 + for _, i := range got { + if i.Code == "dead.shadowed" { + infoCount++ + assert.Equal(t, LintLevelInfo, i.Level) + } + } + assert.Equal(t, 2, infoCount) +} + +func TestLintProfile_DeadShadowed_NoFalsePositive(t *testing.T) { + p := cleanPolicy() + // Sibling paths under same prefix; without /** earlier, should not shadow. + p.Filesystem.AllowRead = []string{"${HOME}/.npmrc", "${HOME}/.npmrc.bak"} + got := LintProfile(p) + for _, i := range got { + assert.NotEqual(t, "dead.shadowed", i.Code) + } +} + +func TestLintProfile_OrderingErrorsBeforeWarnsBeforeInfo(t *testing.T) { + // Build a policy with all three classes. + p := &SandboxPolicy{ + Name: "x", + PackageManagers: []string{}, // schema error + Filesystem: FilesystemPolicy{ + AllowRead: []string{ + "/**", // broad warn + "${HOME}/.cache/npm/**", // not shadowed + "${HOME}/.cache/npm/inside", // dead info + }, + }, + } + + got := LintProfile(p) + // Walk levels; must be error*, then warn*, then info*. + phase := 0 // 0=error,1=warn,2=info + for _, i := range got { + switch i.Level { + case LintLevelError: + assert.LessOrEqual(t, phase, 0) + case LintLevelWarn: + if phase < 1 { + phase = 1 + } + assert.LessOrEqual(t, phase, 1) + case LintLevelInfo: + phase = 2 + } + } +} diff --git a/sandbox/platform/bubblewrap_linux.go b/sandbox/platform/bubblewrap_linux.go index 1b3d72f..84357cd 100644 --- a/sandbox/platform/bubblewrap_linux.go +++ b/sandbox/platform/bubblewrap_linux.go @@ -87,8 +87,8 @@ func (b *bubblewrapSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy * } // Name returns the name of this sandbox implementation. -func (b *bubblewrapSandbox) Name() string { - return "bubblewrap" +func (b *bubblewrapSandbox) Name() sandbox.DriverName { + return sandbox.DriverBubblewrap } // IsAvailable returns true if bubblewrap (bwrap) is available on this system. diff --git a/sandbox/platform/bubblewrap_linux_test.go b/sandbox/platform/bubblewrap_linux_test.go index 47a2e45..e9b56e6 100644 --- a/sandbox/platform/bubblewrap_linux_test.go +++ b/sandbox/platform/bubblewrap_linux_test.go @@ -18,7 +18,7 @@ func TestBubblewrapSandboxCreation(t *testing.T) { sb, err := newBubblewrapSandbox() require.NoError(t, err) assert.NotNil(t, sb) - assert.Equal(t, "bubblewrap", sb.Name()) + assert.Equal(t, sandbox.DriverBubblewrap, sb.Name()) } func TestBubblewrapSandboxIsAvailable(t *testing.T) { diff --git a/sandbox/platform/bubblewrap_render_linux.go b/sandbox/platform/bubblewrap_render_linux.go new file mode 100644 index 0000000..7b4bbb5 --- /dev/null +++ b/sandbox/platform/bubblewrap_render_linux.go @@ -0,0 +1,35 @@ +//go:build linux +// +build linux + +package platform + +import ( + "fmt" + "strings" + + "github.com/safedep/pmg/sandbox" +) + +// RenderBubblewrap translates a SandboxPolicy into the bwrap argv that the +// Bubblewrap driver would invoke at runtime, encoded as one argument per +// line. One-arg-per-line is chosen over shell-quoted joining because bwrap +// arguments routinely contain absolute paths and option flags that would +// require non-trivial shell quoting; the per-line form is unambiguous and +// trivially round-trippable. +// +// This is a thin wrapper over the internal bubblewrap translator and is +// intended for inspection use cases such as +// `pmg setup sandbox profile show --driver=bwrap`. +func RenderBubblewrap(policy *sandbox.SandboxPolicy) ([]byte, error) { + if policy == nil { + return nil, fmt.Errorf("policy is nil") + } + + t := newBubblewrapPolicyTranslator(newDefaultBubblewrapConfig()) + args, err := t.translate(policy) + if err != nil { + return nil, err + } + + return []byte(strings.Join(args, "\n") + "\n"), nil +} diff --git a/sandbox/platform/bubblewrap_render_linux_test.go b/sandbox/platform/bubblewrap_render_linux_test.go new file mode 100644 index 0000000..f805e6c --- /dev/null +++ b/sandbox/platform/bubblewrap_render_linux_test.go @@ -0,0 +1,85 @@ +//go:build linux +// +build linux + +package platform + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withoutBubblewrapArgTriple(b []byte, option, source, dest string) []byte { + args := strings.Split(strings.TrimSuffix(string(b), "\n"), "\n") + normalized := make([]string, 0, len(args)) + for i := 0; i < len(args); { + if i+2 < len(args) && + args[i] == option && + args[i+1] == source && + args[i+2] == dest { + i += 3 + continue + } + + normalized = append(normalized, args[i]) + i++ + } + + return []byte(strings.Join(normalized, "\n") + "\n") +} + +func TestRenderBubblewrap_Golden(t *testing.T) { + tests := []struct { + name string + policy *sandbox.SandboxPolicy + goldenFile string + }{ + { + name: "minimal allow read tmp", + policy: &sandbox.SandboxPolicy{ + Name: "render-min", + Description: "minimal policy for render golden test", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/tmp"}, + AllowWrite: []string{"/tmp"}, + }, + AllowPTY: utils.PtrTo(false), + }, + goldenFile: "bubblewrap_minimal.argv", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + got, err := RenderBubblewrap(tc.policy) + require.NoError(t, err) + + goldenPath := filepath.Join("testdata", tc.goldenFile) + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(goldenPath, got, 0o644)) + } + + expected, err := os.ReadFile(goldenPath) + require.NoError(t, err, "missing golden file: run with UPDATE_GOLDEN=1 to create") + if _, err := os.Stat("/lib64"); os.IsNotExist(err) { + expected = withoutBubblewrapArgTriple(expected, "--ro-bind-try", "/lib64", "/lib64") + } + + assert.Equal(t, string(expected), string(got)) + }) + } +} + +func TestRenderBubblewrap_NilPolicy(t *testing.T) { + _, err := RenderBubblewrap(nil) + assert.Error(t, err) +} diff --git a/sandbox/platform/bubblewrap_translator_linux.go b/sandbox/platform/bubblewrap_translator_linux.go index 6361a39..28d073b 100644 --- a/sandbox/platform/bubblewrap_translator_linux.go +++ b/sandbox/platform/bubblewrap_translator_linux.go @@ -269,6 +269,22 @@ func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.Sandbox } } + for _, pattern := range policy.Filesystem.DenyRead { + expanded, err := util.ExpandVariables(pattern) + if err != nil { + log.Warnf("Failed to expand variables in deny_read pattern '%s': %v", pattern, err) + continue + } + + denyArgs, err := t.processDenyReadRule(expanded) + if err != nil { + log.Debugf("Deny read rule '%s' skipped: %v", expanded, err) + continue + } + + args = append(args, denyArgs...) + } + for _, pattern := range policy.Filesystem.DenyWrite { expanded, err := util.ExpandVariables(pattern) if err != nil { @@ -382,6 +398,52 @@ func (t *bubblewrapPolicyTranslator) translateFilesystem(policy *sandbox.Sandbox return args, nil } +// processDenyReadRule hides readable content. Files are masked with /dev/null; +// directories are overlaid with tmpfs so their host contents are not visible. +func (t *bubblewrapPolicyTranslator) processDenyReadRule(path string) ([]string, error) { + args := []string{} + + if util.ContainsGlob(path) { + if strings.Contains(path, "**") { + parentDir := t.extractParentDir(path) + if parentDir == "" || parentDir == "." { + return args, nil + } + log.Warnf("Deny read glob '%s' uses **; hiding parent directory '%s' to avoid expanding many bubblewrap arguments", path, parentDir) + return t.processDenyReadRule(parentDir) + } + + paths, _, err := t.expandGlobPattern(path, t.config.mandatoryDenyScanDepth, t.config.maxGlobPaths) + if err != nil { + return args, nil + } + + for _, p := range paths { + if info, err := os.Stat(p); err == nil { + if info.IsDir() { + args = append(args, "--tmpfs", p) + } else { + args = append(args, "--ro-bind", "/dev/null", p) + } + } + } + + return args, nil + } + + if info, err := os.Stat(path); err == nil { + if info.IsDir() { + args = append(args, "--tmpfs", path) + } else { + args = append(args, "--ro-bind", "/dev/null", path) + } + } else if os.IsNotExist(err) { + log.Debugf("Deny read rule: skipping non-existent path '%s'", path) + } + + return args, nil +} + // processDenyWriteRule handles deny_write rules without masking reads. Files // and directories are mounted read-only over any earlier writable parent bind. func (t *bubblewrapPolicyTranslator) processDenyWriteRule(path string) ([]string, error) { @@ -585,29 +647,6 @@ func (t *bubblewrapPolicyTranslator) processDenyRule(path string) ([]string, err return args, nil } -// findFirstNonExistentPath walks up the directory tree to find the first path component -// that doesn't exist. This allows us to block file creation by mounting /dev/null. -// -// Example: If /home/user/.env doesn't exist but /home/user does, returns /home/user/.env -func (t *bubblewrapPolicyTranslator) findFirstNonExistentPath(path string) string { - path = filepath.Clean(path) - - // Walk up the tree - for path != "/" && path != "." { - if _, err := os.Stat(path); os.IsNotExist(err) { - // Check if parent exists - parent := filepath.Dir(path) - if _, err := os.Stat(parent); err == nil { - // Parent exists, this is the first non-existent path - return path - } - } - path = filepath.Dir(path) - } - - return "" -} - // expandGlobPattern expands a glob pattern to a list of concrete paths. // Implements depth limiting and path count limiting to prevent DoS. // Returns (paths, useFallback, error) where useFallback indicates if diff --git a/sandbox/platform/bubblewrap_translator_linux_test.go b/sandbox/platform/bubblewrap_translator_linux_test.go index 9662083..34ddc21 100644 --- a/sandbox/platform/bubblewrap_translator_linux_test.go +++ b/sandbox/platform/bubblewrap_translator_linux_test.go @@ -70,7 +70,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { cases := []struct { name string policy *sandbox.SandboxPolicy - assert func(t *testing.T, args []string, err error) + assert func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) }{ { name: "simple read-only path", @@ -79,7 +79,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { AllowRead: []string{"/usr/local"}, }, }, - assert: func(t *testing.T, args []string, err error) { + assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) { require.NoError(t, err) argsStr := argSliceToString(args) // Should have read-only bind for the path @@ -94,7 +94,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { AllowWrite: []string{"/tmp/test"}, }, }, - assert: func(t *testing.T, args []string, err error) { + assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) { require.NoError(t, err) argsStr := argSliceToString(args) // Should have read-write bind for the path @@ -110,7 +110,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { AllowWrite: []string{"${CWD}/node_modules"}, }, }, - assert: func(t *testing.T, args []string, err error) { + assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) { require.NoError(t, err) argsStr := argSliceToString(args) @@ -131,7 +131,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { DenyWrite: []string{"/etc/passwd"}, }, }, - assert: func(t *testing.T, args []string, err error) { + assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) { require.NoError(t, err) if _, err := os.Stat("/etc/passwd"); err == nil { @@ -140,6 +140,58 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { } }, }, + { + name: "deny read masks file with dev null", + policy: func() *sandbox.SandboxPolicy { + dir := t.TempDir() + path := filepath.Join(dir, "secret.txt") + require.NoError(t, os.WriteFile(path, []byte("secret"), 0o600)) + return &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + DenyRead: []string{path}, + }, + } + }(), + assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) { + require.NoError(t, err) + assertDevNullMount(t, args, policy.Filesystem.DenyRead[0]) + }, + }, + { + name: "deny read hides directory with tmpfs", + policy: func() *sandbox.SandboxPolicy { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "secret.txt"), []byte("secret"), 0o600)) + return &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + DenyRead: []string{dir}, + }, + } + }(), + assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) { + require.NoError(t, err) + assertTmpfsAt(t, args, policy.Filesystem.DenyRead[0]) + }, + }, + { + name: "deny read globstar uses parent directory approximation", + policy: func() *sandbox.SandboxPolicy { + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "nested"), 0o700)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "nested", "secret.txt"), []byte("secret"), 0o600)) + return &sandbox.SandboxPolicy{ + Filesystem: sandbox.FilesystemPolicy{ + DenyRead: []string{filepath.Join(dir, "**")}, + }, + } + }(), + assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) { + require.NoError(t, err) + parentDir := filepath.Dir(policy.Filesystem.DenyRead[0]) + assertTmpfsAt(t, args, parentDir) + assertNoDevNullMount(t, args, filepath.Join(parentDir, "nested", "secret.txt")) + }, + }, { name: "multiple paths", policy: &sandbox.SandboxPolicy{ @@ -155,7 +207,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { }, }, }, - assert: func(t *testing.T, args []string, err error) { + assert: func(t *testing.T, args []string, err error, policy *sandbox.SandboxPolicy) { require.NoError(t, err) argsStr := argSliceToString(args) @@ -176,7 +228,7 @@ func TestBubblewrapTranslatorFilesystemRules(t *testing.T) { config := newDefaultBubblewrapConfig() translator := newBubblewrapPolicyTranslator(config) args, err := translator.translate(tt.policy) - tt.assert(t, args, err) + tt.assert(t, args, err, tt.policy) }) } } @@ -646,52 +698,6 @@ func TestExpandGlobstarPattern(t *testing.T) { } } -func TestFindFirstNonExistentPath(t *testing.T) { - tmpDir := t.TempDir() - - // Create a directory structure - existingDir := filepath.Join(tmpDir, "existing") - require.NoError(t, os.MkdirAll(existingDir, 0755)) - - config := newDefaultBubblewrapConfig() - translator := newBubblewrapPolicyTranslator(config) - - cases := []struct { - name string - path string - expected string - }{ - { - name: "file in existing directory", - path: filepath.Join(existingDir, "nonexistent.txt"), - expected: filepath.Join(existingDir, "nonexistent.txt"), - }, - { - name: "nested non-existent path", - path: filepath.Join(existingDir, "deep", "nested", "file.txt"), - expected: filepath.Join(existingDir, "deep"), - }, - { - name: "completely non-existent path", - path: "/totally/nonexistent/path/file.txt", - expected: "", // No parent exists, can't block creation - }, - } - - for _, tt := range cases { - t.Run(tt.name, func(t *testing.T) { - result := translator.findFirstNonExistentPath(tt.path) - if tt.expected == "" { - // For completely non-existent paths, we might get empty or a high-level path - // Just verify no panic - assert.True(t, true) - } else { - assert.Equal(t, tt.expected, result) - } - }) - } -} - // TestGlobFallbackThreshold verifies coarse-grained fallback behavior when patterns match too many paths func TestGlobFallbackThreshold(t *testing.T) { tmpDir := t.TempDir() @@ -1007,6 +1013,17 @@ func assertNoTmpfsAt(t *testing.T, args []string, path string) { } } +func assertTmpfsAt(t *testing.T, args []string, path string) { + t.Helper() + for i := 0; i+1 < len(args); i++ { + if args[i] == "--tmpfs" && args[i+1] == path { + return + } + } + + t.Fatalf("expected --tmpfs at %q, but none found", path) +} + func assertNoDevNullMount(t *testing.T, args []string, path string) { t.Helper() for i := 0; i+2 < len(args); i++ { @@ -1016,6 +1033,17 @@ func assertNoDevNullMount(t *testing.T, args []string, path string) { } } +func assertDevNullMount(t *testing.T, args []string, path string) { + t.Helper() + for i := 0; i+2 < len(args); i++ { + if args[i] == "--ro-bind" && args[i+1] == "/dev/null" && args[i+2] == path { + return + } + } + + t.Fatalf("expected /dev/null mount at %q, but none found", path) +} + func assertReadBind(t *testing.T, args []string, path string) { t.Helper() for i := 0; i+2 < len(args); i++ { diff --git a/sandbox/platform/landlock_linux.go b/sandbox/platform/landlock_linux.go index 3996d95..d70c74d 100644 --- a/sandbox/platform/landlock_linux.go +++ b/sandbox/platform/landlock_linux.go @@ -47,8 +47,8 @@ func newLandlockSandbox() (sandbox.Sandbox, error) { } // Name returns the name of this sandbox implementation. -func (s *landlockSandbox) Name() string { - return "landlock" +func (s *landlockSandbox) Name() sandbox.DriverName { + return sandbox.DriverLandlock } // IsAvailable returns true if Landlock is available and functional on this system. diff --git a/sandbox/platform/landlock_linux_test.go b/sandbox/platform/landlock_linux_test.go index 70df2a0..29c957c 100644 --- a/sandbox/platform/landlock_linux_test.go +++ b/sandbox/platform/landlock_linux_test.go @@ -16,7 +16,7 @@ import ( func TestLandlockSandbox_Name(t *testing.T) { sb := &landlockSandbox{abi: newLandlockABI(4)} - assert.Equal(t, "landlock", sb.Name()) + assert.Equal(t, sandbox.DriverLandlock, sb.Name()) } func TestLandlockSandbox_IsAvailable_True(t *testing.T) { diff --git a/sandbox/platform/landlock_render_linux.go b/sandbox/platform/landlock_render_linux.go new file mode 100644 index 0000000..2b876e8 --- /dev/null +++ b/sandbox/platform/landlock_render_linux.go @@ -0,0 +1,126 @@ +//go:build linux +// +build linux + +package platform + +import ( + "fmt" + "strings" + + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" + "github.com/safedep/pmg/sandbox" +) + +// landlockRenderFallbackABI is used when the host kernel does not support +// Landlock (so callers on non-landlock hosts still get a meaningful render +// for inspection). Set to the highest ABI version this translator knows +// about so all feature flags are enabled in the rendered ruleset. +const landlockRenderFallbackABI = 6 + +// RenderLandlock translates a SandboxPolicy into a human-readable summary of +// the Landlock ruleset the driver would apply. The summary lists the detected +// ABI level, the filesystem allow rules (path + symbolic access flags), the +// deny paths consumed by the seccomp supervisor, and the deny-exec list. +// +// This is a thin wrapper over the internal landlock translator and is +// intended for inspection use cases such as +// `pmg setup sandbox profile show --driver=landlock`. When the host kernel +// does not support Landlock, the renderer falls back to a default ABI so the +// output is still meaningful for design-time inspection; this fallback is +// noted in the rendered header. +func RenderLandlock(policy *sandbox.SandboxPolicy) ([]byte, error) { + if policy == nil { + return nil, fmt.Errorf("policy is nil") + } + + abi, err := landlockDetectABI() + abiSource := "detected" + if err != nil { + abi = newLandlockABI(landlockRenderFallbackABI) + abiSource = "fallback" + } + + ep, err := landlockTranslatePolicy(policy, abi) + if err != nil { + return nil, err + } + + var sb strings.Builder + fmt.Fprintf(&sb, "# Landlock ruleset\n") + fmt.Fprintf(&sb, "# policy: %s\n", policy.Name) + fmt.Fprintf(&sb, "abi: %d (%s)\n", abi.Version, abiSource) + fmt.Fprintf(&sb, "features: refer=%t truncate=%t network=%t ioctl_dev=%t scoping=%t\n", + abi.HasRefer, abi.HasTruncate, abi.HasNetwork, abi.HasIoctlDev, abi.HasScoping) + fmt.Fprintf(&sb, "allow_pty: %t\n", ep.AllowPTY) + fmt.Fprintf(&sb, "skip_pid_namespace: %t\n", ep.SkipPIDNamespace) + fmt.Fprintf(&sb, "skip_ipc_namespace: %t\n", ep.SkipIPCNamespace) + + fmt.Fprintf(&sb, "\nfilesystem_rules (%d):\n", len(ep.FilesystemRules)) + for _, r := range ep.FilesystemRules { + fmt.Fprintf(&sb, " - path: %s\n access: %s\n", r.Path, landlockAccessFlagsString(r.Access)) + } + + fmt.Fprintf(&sb, "\ndeny_paths (%d):\n", len(ep.DenyPaths)) + for _, d := range ep.DenyPaths { + fmt.Fprintf(&sb, " - path: %s\n mode: %s\n", d.Path, landlockDenyModeString(d.Mode)) + } + + fmt.Fprintf(&sb, "\ndeny_exec_paths (%d):\n", len(ep.DenyExecPaths)) + for _, p := range ep.DenyExecPaths { + fmt.Fprintf(&sb, " - %s\n", p) + } + + return []byte(sb.String()), nil +} + +// landlockAccessFlagsString renders a Landlock AccessFs bitmask as a +// stable, space-separated list of symbolic flag names. The order is fixed so +// the output is suitable for golden tests and diffing. +func landlockAccessFlagsString(access uint64) string { + type bit struct { + mask uint64 + name string + } + bits := []bit{ + {uint64(llsyscall.AccessFSExecute), "execute"}, + {uint64(llsyscall.AccessFSReadFile), "read_file"}, + {uint64(llsyscall.AccessFSReadDir), "read_dir"}, + {uint64(llsyscall.AccessFSWriteFile), "write_file"}, + {uint64(llsyscall.AccessFSTruncate), "truncate"}, + {uint64(llsyscall.AccessFSIoctlDev), "ioctl_dev"}, + {uint64(llsyscall.AccessFSMakeReg), "make_reg"}, + {uint64(llsyscall.AccessFSMakeDir), "make_dir"}, + {uint64(llsyscall.AccessFSMakeSock), "make_sock"}, + {uint64(llsyscall.AccessFSMakeFifo), "make_fifo"}, + {uint64(llsyscall.AccessFSMakeBlock), "make_block"}, + {uint64(llsyscall.AccessFSMakeChar), "make_char"}, + {uint64(llsyscall.AccessFSMakeSym), "make_sym"}, + {uint64(llsyscall.AccessFSRemoveFile), "remove_file"}, + {uint64(llsyscall.AccessFSRemoveDir), "remove_dir"}, + {uint64(llsyscall.AccessFSRefer), "refer"}, + } + + parts := []string{} + for _, b := range bits { + if access&b.mask != 0 { + parts = append(parts, b.name) + } + } + if len(parts) == 0 { + return "(none)" + } + return strings.Join(parts, "|") +} + +func landlockDenyModeString(m denyMode) string { + switch m { + case denyRead: + return "read" + case denyWrite: + return "write" + case denyBoth: + return "both" + default: + return fmt.Sprintf("unknown(%d)", int(m)) + } +} diff --git a/sandbox/platform/landlock_render_linux_test.go b/sandbox/platform/landlock_render_linux_test.go new file mode 100644 index 0000000..6885bf9 --- /dev/null +++ b/sandbox/platform/landlock_render_linux_test.go @@ -0,0 +1,92 @@ +//go:build linux +// +build linux + +package platform + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// landlockABILinePattern normalizes the ABI line so the golden is stable +// across kernels with and without Landlock support. +var landlockABILinePattern = regexp.MustCompile(`(?m)^abi: \d+ \(\w+\)$`) + +// landlockFeaturesLinePattern normalizes the features line for the same reason. +var landlockFeaturesLinePattern = regexp.MustCompile(`(?m)^features: refer=\w+ truncate=\w+ network=\w+ ioctl_dev=\w+ scoping=\w+$`) + +func normalizeLandlockOutput(t *testing.T, b []byte) []byte { + t.Helper() + + b = landlockABILinePattern.ReplaceAll(b, []byte("abi: GOLDEN (GOLDEN)")) + b = landlockFeaturesLinePattern.ReplaceAll(b, []byte("features: GOLDEN")) + + wd, err := os.Getwd() + require.NoError(t, err) + + home, err := os.UserHomeDir() + require.NoError(t, err) + + s := string(b) + s = strings.ReplaceAll(s, wd, "/src/sandbox/platform") + s = strings.ReplaceAll(s, home, "/root") + + return []byte(s) +} + +func TestRenderLandlock_Golden(t *testing.T) { + tests := []struct { + name string + policy *sandbox.SandboxPolicy + goldenFile string + }{ + { + name: "minimal allow read tmp", + policy: &sandbox.SandboxPolicy{ + Name: "render-min", + Description: "minimal policy for render golden test", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/tmp"}, + AllowWrite: []string{"/tmp"}, + }, + Process: sandbox.ProcessPolicy{ + DenyExec: []string{"/bin/sh"}, + }, + AllowPTY: utils.PtrTo(false), + }, + goldenFile: "landlock_minimal.txt", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := RenderLandlock(tc.policy) + require.NoError(t, err) + + goldenPath := filepath.Join("testdata", tc.goldenFile) + normalized := normalizeLandlockOutput(t, got) + + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(goldenPath, normalized, 0o644)) + } + + expected, err := os.ReadFile(goldenPath) + require.NoError(t, err, "missing golden file: run with UPDATE_GOLDEN=1 to create") + assert.Equal(t, string(expected), string(normalized)) + }) + } +} + +func TestRenderLandlock_NilPolicy(t *testing.T) { + _, err := RenderLandlock(nil) + assert.Error(t, err) +} diff --git a/sandbox/platform/platform_darwin.go b/sandbox/platform/platform_darwin.go index bbfde9b..ff6a33c 100644 --- a/sandbox/platform/platform_darwin.go +++ b/sandbox/platform/platform_darwin.go @@ -10,3 +10,9 @@ import "github.com/safedep/pmg/sandbox" func NewSandbox() (sandbox.Sandbox, error) { return newSeatbeltSandbox() } + +// NewSeatbeltSandbox returns a Seatbelt-backed sandbox instance regardless of +// any platform-wide driver selection. Useful for per-driver diagnostics. +func NewSeatbeltSandbox() (sandbox.Sandbox, error) { + return newSeatbeltSandbox() +} diff --git a/sandbox/platform/platform_linux.go b/sandbox/platform/platform_linux.go index 9f13e3f..9e3bea0 100644 --- a/sandbox/platform/platform_linux.go +++ b/sandbox/platform/platform_linux.go @@ -17,11 +17,11 @@ import ( // PMG_SANDBOX_DRIVER=landlock to force Landlock (no fallback — fails if // Landlock is unavailable). func NewSandbox() (sandbox.Sandbox, error) { - switch os.Getenv("PMG_SANDBOX_DRIVER") { - case "bubblewrap": + switch sandbox.DriverName(os.Getenv("PMG_SANDBOX_DRIVER")) { + case sandbox.DriverBubblewrap: log.Debugf("PMG_SANDBOX_DRIVER=bubblewrap: forcing Bubblewrap sandbox") return newBubblewrapSandbox() - case "landlock": + case sandbox.DriverLandlock: log.Debugf("PMG_SANDBOX_DRIVER=landlock: forcing Landlock sandbox") return newLandlockSandbox() } @@ -35,3 +35,15 @@ func NewSandbox() (sandbox.Sandbox, error) { log.Debugf("Landlock not available (%v), falling back to Bubblewrap", err) return newBubblewrapSandbox() } + +// NewBubblewrapSandbox returns a Bubblewrap-backed sandbox instance regardless +// of platform driver selection. Useful for per-driver diagnostics. +func NewBubblewrapSandbox() (sandbox.Sandbox, error) { + return newBubblewrapSandbox() +} + +// NewLandlockSandbox returns a Landlock-backed sandbox instance regardless of +// platform driver selection. Useful for per-driver diagnostics. +func NewLandlockSandbox() (sandbox.Sandbox, error) { + return newLandlockSandbox() +} diff --git a/sandbox/platform/probe_apparmor_linux.go b/sandbox/platform/probe_apparmor_linux.go new file mode 100644 index 0000000..532276e --- /dev/null +++ b/sandbox/platform/probe_apparmor_linux.go @@ -0,0 +1,59 @@ +//go:build linux +// +build linux + +package platform + +import ( + "context" + "strings" + + "github.com/safedep/pmg/sandbox" +) + +const apparmorUsernsSysctlPath = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns" + +type apparmorProbe struct { + env probeEnv + path string +} + +// NewAppArmorUsernsProbe returns a probe that warns when AppArmor restricts +// unprivileged user namespaces (which breaks bwrap-based sandboxing). +func NewAppArmorUsernsProbe() sandbox.Probe { + return &apparmorProbe{env: defaultProbeEnv{}, path: apparmorUsernsSysctlPath} +} + +func (p *apparmorProbe) Name() string { return sandbox.ProbeAppArmorUserns } + +func (p *apparmorProbe) Run(_ context.Context) sandbox.ProbeResult { + data, err := p.env.readFile(p.path) + if err != nil { + return sandbox.ProbeResult{ + Name: sandbox.ProbeAppArmorUserns, + Status: sandbox.ProbeStatusSkipped, + Summary: "AppArmor userns sysctl not present", + Detail: err.Error(), + } + } + + value := strings.TrimSpace(string(data)) + if value == "0" { + return sandbox.ProbeResult{ + Name: sandbox.ProbeAppArmorUserns, + Status: sandbox.ProbeStatusOK, + Summary: "Unprivileged user namespaces are not restricted by AppArmor", + } + } + + return sandbox.ProbeResult{ + Name: sandbox.ProbeAppArmorUserns, + Status: sandbox.ProbeStatusWarn, + Summary: "AppArmor restricts unprivileged user namespaces (value=" + value + ")", + Detail: "bwrap may fail with `setting up uid map: Permission denied` until an AppArmor profile permits it or the sysctl is relaxed.", + Fixes: []sandbox.ProbeFix{{ + Description: "Temporarily relax the restriction (until next reboot).", + Command: "sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0", + Docs: "https://ubuntu.com/blog/ubuntu-23-10-restricted-unprivileged-user-namespaces", + }}, + } +} diff --git a/sandbox/platform/probe_apparmor_linux_test.go b/sandbox/platform/probe_apparmor_linux_test.go new file mode 100644 index 0000000..081be4d --- /dev/null +++ b/sandbox/platform/probe_apparmor_linux_test.go @@ -0,0 +1,50 @@ +//go:build linux +// +build linux + +package platform + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/safedep/pmg/sandbox" +) + +func TestAppArmorProbe(t *testing.T) { + tests := []struct { + name string + env *fakeProbeEnv + want sandbox.ProbeStatus + }{ + { + name: "ok unrestricted", + env: &fakeProbeEnv{readFileFn: func(string) ([]byte, error) { return []byte("0\n"), nil }}, + want: sandbox.ProbeStatusOK, + }, + { + name: "warn restricted", + env: &fakeProbeEnv{readFileFn: func(string) ([]byte, error) { return []byte("1\n"), nil }}, + want: sandbox.ProbeStatusWarn, + }, + { + name: "skip missing", + env: &fakeProbeEnv{readFileFn: func(string) ([]byte, error) { return nil, errors.New("not found") }}, + want: sandbox.ProbeStatusSkipped, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &apparmorProbe{env: tc.env, path: apparmorUsernsSysctlPath} + res := p.Run(context.Background()) + assert.Equal(t, sandbox.ProbeAppArmorUserns, res.Name) + assert.Equal(t, tc.want, res.Status) + if tc.want == sandbox.ProbeStatusWarn { + assert.NotEmpty(t, res.Fixes) + } + }) + } +} diff --git a/sandbox/platform/probe_bwrap_linux.go b/sandbox/platform/probe_bwrap_linux.go new file mode 100644 index 0000000..dad23ee --- /dev/null +++ b/sandbox/platform/probe_bwrap_linux.go @@ -0,0 +1,55 @@ +//go:build linux +// +build linux + +package platform + +import ( + "context" + "strings" + + "github.com/safedep/pmg/sandbox" +) + +type bwrapProbe struct { + env probeEnv +} + +// NewBwrapProbe returns a probe that verifies `bwrap --version` runs. +func NewBwrapProbe() sandbox.Probe { + return &bwrapProbe{env: defaultProbeEnv{}} +} + +func (p *bwrapProbe) Name() string { return sandbox.ProbeBwrapDriver } + +func (p *bwrapProbe) Run(ctx context.Context) sandbox.ProbeResult { + path, err := p.env.lookPath("bwrap") + if err != nil { + return sandbox.ProbeResult{ + Name: sandbox.ProbeBwrapDriver, + Status: sandbox.ProbeStatusFail, + Summary: "bwrap not found in PATH", + Detail: err.Error(), + Fixes: []sandbox.ProbeFix{bubblewrapInstallFix()}, + } + } + + out, err := p.env.runCommand(ctx, path, "--version") + if err != nil { + return sandbox.ProbeResult{ + Name: sandbox.ProbeBwrapDriver, + Status: sandbox.ProbeStatusFail, + Summary: "bwrap --version failed", + Detail: strings.TrimSpace(string(out)) + ": " + err.Error(), + Fixes: []sandbox.ProbeFix{{ + Description: "Reinstall bubblewrap and confirm unprivileged user namespaces are enabled.", + Command: "bwrap --version", + }}, + } + } + + return sandbox.ProbeResult{ + Name: sandbox.ProbeBwrapDriver, + Status: sandbox.ProbeStatusOK, + Summary: strings.TrimSpace(string(out)), + } +} diff --git a/sandbox/platform/probe_bwrap_linux_test.go b/sandbox/platform/probe_bwrap_linux_test.go new file mode 100644 index 0000000..a3bda97 --- /dev/null +++ b/sandbox/platform/probe_bwrap_linux_test.go @@ -0,0 +1,75 @@ +//go:build linux +// +build linux + +package platform + +import ( + "context" + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/safedep/pmg/sandbox" +) + +type fakeProbeEnv struct { + lookPathFn func(string) (string, error) + statExecutableFn func(string) (os.FileInfo, error) + readFileFn func(string) ([]byte, error) + runCommandFn func(ctx context.Context, name string, args ...string) ([]byte, error) +} + +func (f *fakeProbeEnv) lookPath(name string) (string, error) { return f.lookPathFn(name) } +func (f *fakeProbeEnv) statExecutable(path string) (os.FileInfo, error) { + return f.statExecutableFn(path) +} +func (f *fakeProbeEnv) readFile(path string) ([]byte, error) { return f.readFileFn(path) } +func (f *fakeProbeEnv) runCommand(ctx context.Context, name string, args ...string) ([]byte, error) { + return f.runCommandFn(ctx, name, args...) +} + +func TestBwrapProbe(t *testing.T) { + tests := []struct { + name string + env *fakeProbeEnv + want sandbox.ProbeStatus + }{ + { + name: "ok", + env: &fakeProbeEnv{ + lookPathFn: func(string) (string, error) { return "/usr/bin/bwrap", nil }, + runCommandFn: func(context.Context, string, ...string) ([]byte, error) { return []byte("bubblewrap 0.8.0\n"), nil }, + }, + want: sandbox.ProbeStatusOK, + }, + { + name: "not in path", + env: &fakeProbeEnv{ + lookPathFn: func(string) (string, error) { return "", errors.New("not found") }, + }, + want: sandbox.ProbeStatusFail, + }, + { + name: "version failed", + env: &fakeProbeEnv{ + lookPathFn: func(string) (string, error) { return "/usr/bin/bwrap", nil }, + runCommandFn: func(context.Context, string, ...string) ([]byte, error) { return []byte("err"), errors.New("exit 1") }, + }, + want: sandbox.ProbeStatusFail, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &bwrapProbe{env: tc.env} + res := p.Run(context.Background()) + assert.Equal(t, sandbox.ProbeBwrapDriver, res.Name) + assert.Equal(t, tc.want, res.Status) + if tc.want != sandbox.ProbeStatusOK { + assert.NotEmpty(t, res.Fixes) + } + }) + } +} diff --git a/sandbox/platform/probe_canary.go b/sandbox/platform/probe_canary.go new file mode 100644 index 0000000..ca784ae --- /dev/null +++ b/sandbox/platform/probe_canary.go @@ -0,0 +1,197 @@ +package platform + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "time" + + "github.com/safedep/dry/log" + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox" +) + +// canaryTimeout bounds the runtime of a single canary smoke test so a misconfigured +// kernel cannot wedge the doctor command. +const canaryTimeout = 15 * time.Second + +// canaryTargetPath is the file the canary attempts to read under a deny-all +// policy. It is well-known on every supported host. +const canaryTargetPath = "/etc/hostname" + +// canarySandboxFactory builds the sandbox driver under test. Separated from the +// probe body so tests can stub it. +type canarySandboxFactory func() (sandbox.Sandbox, error) + +// canaryCommandFactory builds the command the canary attempts to run. Stubbed +// in tests; defaults to `cat /etc/hostname`. +type canaryCommandFactory func(ctx context.Context) *exec.Cmd + +func defaultCanaryCommand(ctx context.Context) *exec.Cmd { + return exec.CommandContext(ctx, "cat", canaryTargetPath) +} + +// denyAllCanaryPolicy returns a minimal valid policy that denies read access to +// the canary target. ValidateResolved requires at least one rule; the deny on +// canaryTargetPath satisfies that and unambiguously asserts the sandbox is +// blocking the read. +func denyAllCanaryPolicy() *sandbox.SandboxPolicy { + return &sandbox.SandboxPolicy{ + Name: "pmg-canary", + Description: "deny-all canary probe", + PackageManagers: []string{"npm", "pip", "uv", "pypi"}, + Filesystem: sandbox.FilesystemPolicy{ + DenyRead: []string{canaryTargetPath}, + }, + AllowGitConfig: utils.PtrTo(false), + AllowPTY: utils.PtrTo(false), + AllowNetworkBind: utils.PtrTo(false), + } +} + +// runCanary executes the canary smoke test. On success, the sandbox prevents +// the canary read and the command exits non-zero — that is the OK path. +func runCanary(ctx context.Context, name string, driver sandbox.DriverName, factory canarySandboxFactory, cmdFactory canaryCommandFactory) sandbox.ProbeResult { + ctx, cancel := context.WithTimeout(ctx, canaryTimeout) + defer cancel() + + d := string(driver) + + sb, err := factory() + if err != nil { + return sandbox.ProbeResult{ + Name: name, + Status: sandbox.ProbeStatusFail, + Summary: d + " sandbox could not be constructed", + Detail: err.Error(), + Fixes: []sandbox.ProbeFix{driverInstallFix(driver)}, + } + } + + defer func() { _ = sb.Close() }() + + if !sb.IsAvailable() { + return sandbox.ProbeResult{ + Name: name, + Status: sandbox.ProbeStatusSkipped, + Summary: d + " driver not available on this host", + Fixes: []sandbox.ProbeFix{driverInstallFix(driver)}, + } + } + + cmd := cmdFactory(ctx) + isCanaryRead := isCanaryReadCommand(cmd) + var expected []byte + if isCanaryRead { + var readErr error + expected, readErr = os.ReadFile(canaryTargetPath) + if readErr != nil { + log.Warnf("canary probe: failed to read baseline %s: %v", canaryTargetPath, readErr) + } + } + var stdout, stderr bytes.Buffer + if cmd.Stdout == nil { + cmd.Stdout = &stdout + } + if cmd.Stderr == nil { + cmd.Stderr = &stderr + } + policy := denyAllCanaryPolicy() + + result, err := sb.Execute(ctx, cmd, policy) + if err != nil { + return sandbox.ProbeResult{ + Name: name, + Status: sandbox.ProbeStatusFail, + Summary: d + " sandbox setup failed", + Detail: err.Error(), + Fixes: []sandbox.ProbeFix{driverInstallFix(driver)}, + } + } + defer func() { _ = result.Close() }() + + if result.ShouldRun() { + err = cmd.Run() + } + + if err == nil { + if isCanaryRead && len(expected) > 0 && !bytes.Equal(stdout.Bytes(), expected) { + return sandbox.ProbeResult{ + Name: name, + Status: sandbox.ProbeStatusOK, + Summary: d + " masked canary read", + Detail: "the sandbox hid the contents of " + canaryTargetPath, + } + } + + return sandbox.ProbeResult{ + Name: name, + Status: sandbox.ProbeStatusFail, + Summary: d + " did not block canary read of " + canaryTargetPath, + Detail: "the sandbox executed `cat " + canaryTargetPath + "` without denial", + Fixes: []sandbox.ProbeFix{{ + Description: "Driver appears installed but not enforcing. Re-run with PMG_LOG_LEVEL=debug to inspect the translated policy.", + }}, + } + } + + if errors.Is(err, context.DeadlineExceeded) { + return sandbox.ProbeResult{ + Name: name, + Status: sandbox.ProbeStatusFail, + Summary: d + " canary timed out", + Detail: err.Error(), + } + } + + return sandbox.ProbeResult{ + Name: name, + Status: sandbox.ProbeStatusOK, + Summary: d + " correctly blocked canary read", + } +} + +func isCanaryReadCommand(cmd *exec.Cmd) bool { + if cmd == nil { + return false + } + + if len(cmd.Args) == 0 || filepath.Base(cmd.Args[0]) != "cat" { + return false + } + + for _, arg := range cmd.Args[1:] { + if arg == canaryTargetPath { + return true + } + } + + return false +} + +func driverInstallFix(driver sandbox.DriverName) sandbox.ProbeFix { + switch driver { + case sandbox.DriverBubblewrap: + return bubblewrapInstallFix() + case sandbox.DriverLandlock: + return sandbox.ProbeFix{ + Description: "Upgrade to Linux 5.13+ for Landlock support.", + Docs: "https://docs.kernel.org/userspace-api/landlock.html", + } + case sandbox.DriverSeatbelt: + return sandbox.ProbeFix{ + Description: "sandbox-exec ships with macOS — check SIP and PATH.", + } + } + return sandbox.ProbeFix{Description: "Install the " + string(driver) + " sandbox driver."} +} + +func bubblewrapInstallFix() sandbox.ProbeFix { + return sandbox.ProbeFix{ + Description: "Install bubblewrap using your distribution package manager and verify unprivileged user namespaces are enabled.", + Docs: "https://github.com/containers/bubblewrap", + } +} diff --git a/sandbox/platform/probe_canary_darwin.go b/sandbox/platform/probe_canary_darwin.go new file mode 100644 index 0000000..3dd9655 --- /dev/null +++ b/sandbox/platform/probe_canary_darwin.go @@ -0,0 +1,29 @@ +//go:build darwin +// +build darwin + +package platform + +import ( + "context" + + "github.com/safedep/pmg/sandbox" +) + +type seatbeltCanaryProbe struct { + factory canarySandboxFactory + cmdFactory canaryCommandFactory +} + +// NewSeatbeltCanaryProbe runs the per-driver Seatbelt smoke test. +func NewSeatbeltCanaryProbe() sandbox.Probe { + return &seatbeltCanaryProbe{ + factory: func() (sandbox.Sandbox, error) { return NewSeatbeltSandbox() }, + cmdFactory: defaultCanaryCommand, + } +} + +func (p *seatbeltCanaryProbe) Name() string { return sandbox.ProbeSeatbeltCanary } + +func (p *seatbeltCanaryProbe) Run(ctx context.Context) sandbox.ProbeResult { + return runCanary(ctx, sandbox.ProbeSeatbeltCanary, sandbox.DriverSeatbelt, p.factory, p.cmdFactory) +} diff --git a/sandbox/platform/probe_canary_linux.go b/sandbox/platform/probe_canary_linux.go new file mode 100644 index 0000000..551b68a --- /dev/null +++ b/sandbox/platform/probe_canary_linux.go @@ -0,0 +1,43 @@ +//go:build linux +// +build linux + +package platform + +import ( + "context" + + "github.com/safedep/pmg/sandbox" +) + +type linuxCanaryProbe struct { + name string + driver sandbox.DriverName + factory canarySandboxFactory + cmdFactory canaryCommandFactory +} + +// NewBwrapCanaryProbe runs the per-driver Bubblewrap smoke test. +func NewBwrapCanaryProbe() sandbox.Probe { + return &linuxCanaryProbe{ + name: sandbox.ProbeBwrapCanary, + driver: sandbox.DriverBubblewrap, + factory: func() (sandbox.Sandbox, error) { return NewBubblewrapSandbox() }, + cmdFactory: defaultCanaryCommand, + } +} + +// NewLandlockCanaryProbe runs the per-driver Landlock smoke test. +func NewLandlockCanaryProbe() sandbox.Probe { + return &linuxCanaryProbe{ + name: sandbox.ProbeLandlockCanary, + driver: sandbox.DriverLandlock, + factory: func() (sandbox.Sandbox, error) { return NewLandlockSandbox() }, + cmdFactory: defaultCanaryCommand, + } +} + +func (p *linuxCanaryProbe) Name() string { return p.name } + +func (p *linuxCanaryProbe) Run(ctx context.Context) sandbox.ProbeResult { + return runCanary(ctx, p.name, p.driver, p.factory, p.cmdFactory) +} diff --git a/sandbox/platform/probe_canary_test.go b/sandbox/platform/probe_canary_test.go new file mode 100644 index 0000000..4e37c9e --- /dev/null +++ b/sandbox/platform/probe_canary_test.go @@ -0,0 +1,118 @@ +package platform + +import ( + "context" + "errors" + "os/exec" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/safedep/pmg/sandbox" +) + +type fakeSandbox struct { + name sandbox.DriverName + available bool + executeErr error + executedRun bool + closed bool +} + +func (f *fakeSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sandbox.SandboxPolicy) (*sandbox.ExecutionResult, error) { + if f.executeErr != nil { + return nil, f.executeErr + } + return sandbox.NewExecutionResult( + sandbox.WithExecutionResultSandbox(f), + sandbox.WithExecutionResultExecuted(f.executedRun), + ), nil +} + +func (f *fakeSandbox) Name() sandbox.DriverName { return f.name } +func (f *fakeSandbox) IsAvailable() bool { return f.available } +func (f *fakeSandbox) Close() error { f.closed = true; return nil } + +func trueCmd(ctx context.Context) *exec.Cmd { return exec.CommandContext(ctx, "true") } +func falseCmd(ctx context.Context) *exec.Cmd { return exec.CommandContext(ctx, "false") } + +func maskedCanaryCmd(ctx context.Context) *exec.Cmd { + cmd := exec.CommandContext(ctx, "printf", "") + cmd.Args = []string{"cat", "", canaryTargetPath} + return cmd +} + +func TestRunCanary(t *testing.T) { + tests := []struct { + name string + factory canarySandboxFactory + cmd canaryCommandFactory + want sandbox.ProbeStatus + }{ + { + name: "ok blocks read", + factory: func() (sandbox.Sandbox, error) { return &fakeSandbox{name: "fake", available: true}, nil }, + cmd: falseCmd, + want: sandbox.ProbeStatusOK, + }, + { + name: "ok masks read", + factory: func() (sandbox.Sandbox, error) { return &fakeSandbox{name: "fake", available: true}, nil }, + cmd: maskedCanaryCmd, + want: sandbox.ProbeStatusOK, + }, + { + name: "fail did not block", + factory: func() (sandbox.Sandbox, error) { return &fakeSandbox{name: "fake", available: true}, nil }, + cmd: trueCmd, + want: sandbox.ProbeStatusFail, + }, + { + name: "skip not available", + factory: func() (sandbox.Sandbox, error) { return &fakeSandbox{name: "fake", available: false}, nil }, + cmd: falseCmd, + want: sandbox.ProbeStatusSkipped, + }, + { + name: "fail constructor error", + factory: func() (sandbox.Sandbox, error) { return nil, errors.New("boom") }, + cmd: falseCmd, + want: sandbox.ProbeStatusFail, + }, + { + name: "fail execute error", + factory: func() (sandbox.Sandbox, error) { + return &fakeSandbox{name: "fake", available: true, executeErr: errors.New("setup failed")}, nil + }, + cmd: falseCmd, + want: sandbox.ProbeStatusFail, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + res := runCanary(context.Background(), "canary.fake", "fake", tc.factory, tc.cmd) + assert.Equal(t, "canary.fake", res.Name) + assert.Equal(t, tc.want, res.Status) + }) + } +} + +func TestDenyAllCanaryPolicy(t *testing.T) { + p := denyAllCanaryPolicy() + require.NoError(t, p.ValidateResolved()) + assert.Contains(t, p.Filesystem.DenyRead, canaryTargetPath) +} + +func TestDriverInstallFix_BubblewrapIsDistroNeutral(t *testing.T) { + fix := driverInstallFix(sandbox.DriverBubblewrap) + assert.Contains(t, fix.Description, "distribution package manager") + assert.Empty(t, fix.Command) + assert.Equal(t, "https://github.com/containers/bubblewrap", fix.Docs) +} + +func TestDefaultProbes_NotEmpty(t *testing.T) { + probes := DefaultProbes() + assert.NotEmpty(t, probes) +} diff --git a/sandbox/platform/probe_env.go b/sandbox/platform/probe_env.go new file mode 100644 index 0000000..f9732d6 --- /dev/null +++ b/sandbox/platform/probe_env.go @@ -0,0 +1,31 @@ +package platform + +import ( + "context" + "os" + "os/exec" +) + +// probeEnv abstracts the host environment so probes can be unit-tested without +// touching the real filesystem or PATH. Real probes use defaultProbeEnv; tests +// inject fakes. +type probeEnv interface { + lookPath(name string) (string, error) + statExecutable(path string) (os.FileInfo, error) + readFile(path string) ([]byte, error) + runCommand(ctx context.Context, name string, args ...string) ([]byte, error) +} + +type defaultProbeEnv struct{} + +func (defaultProbeEnv) lookPath(name string) (string, error) { return exec.LookPath(name) } + +func (defaultProbeEnv) statExecutable(path string) (os.FileInfo, error) { + return os.Stat(path) +} + +func (defaultProbeEnv) readFile(path string) ([]byte, error) { return os.ReadFile(path) } + +func (defaultProbeEnv) runCommand(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() +} diff --git a/sandbox/platform/probe_landlock_linux.go b/sandbox/platform/probe_landlock_linux.go new file mode 100644 index 0000000..5abaa94 --- /dev/null +++ b/sandbox/platform/probe_landlock_linux.go @@ -0,0 +1,82 @@ +//go:build linux +// +build linux + +package platform + +import ( + "context" + "fmt" + + llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall" + + "github.com/safedep/pmg/sandbox" +) + +// landlockABIDetector decouples ABI lookup from the syscall for tests. +type landlockABIDetector func() (int, error) + +func defaultLandlockABIDetector() (int, error) { + v, err := llsyscall.LandlockGetABIVersion() + if err != nil { + return 0, err + } + return v, nil +} + +type landlockProbe struct { + detect landlockABIDetector +} + +// NewLandlockProbe returns a probe that reports the Landlock ABI level. +func NewLandlockProbe() sandbox.Probe { + return &landlockProbe{detect: defaultLandlockABIDetector} +} + +func (p *landlockProbe) Name() string { return sandbox.ProbeLandlockDriver } + +func (p *landlockProbe) Run(_ context.Context) sandbox.ProbeResult { + version, err := p.detect() + if err != nil { + return sandbox.ProbeResult{ + Name: sandbox.ProbeLandlockDriver, + Status: sandbox.ProbeStatusFail, + Summary: "Landlock not supported by kernel", + Detail: err.Error(), + Fixes: []sandbox.ProbeFix{{ + Description: "Landlock requires Linux 5.13+. Upgrade your kernel or use bwrap.", + Docs: "https://docs.kernel.org/userspace-api/landlock.html", + }}, + } + } + + if version <= 0 { + return sandbox.ProbeResult{ + Name: sandbox.ProbeLandlockDriver, + Status: sandbox.ProbeStatusFail, + Summary: fmt.Sprintf("Landlock ABI %d (unsupported)", version), + Fixes: []sandbox.ProbeFix{{ + Description: "Upgrade your kernel to 5.13 or newer.", + Docs: "https://docs.kernel.org/userspace-api/landlock.html", + }}, + } + } + + if version < 3 { + return sandbox.ProbeResult{ + Name: sandbox.ProbeLandlockDriver, + Status: sandbox.ProbeStatusWarn, + Summary: fmt.Sprintf("Landlock ABI V%d (limited features)", version), + Detail: "ABI < 3 lacks truncate and refer support; some policies may not enforce as expected.", + Fixes: []sandbox.ProbeFix{{ + Description: "Upgrade to Linux 5.19+ (ABI 3) or 6.2+ (ABI 4) for full coverage.", + Docs: "https://docs.kernel.org/userspace-api/landlock.html", + }}, + } + } + + return sandbox.ProbeResult{ + Name: sandbox.ProbeLandlockDriver, + Status: sandbox.ProbeStatusOK, + Summary: fmt.Sprintf("Landlock ABI V%d", version), + } +} diff --git a/sandbox/platform/probe_landlock_linux_test.go b/sandbox/platform/probe_landlock_linux_test.go new file mode 100644 index 0000000..4815a05 --- /dev/null +++ b/sandbox/platform/probe_landlock_linux_test.go @@ -0,0 +1,55 @@ +//go:build linux +// +build linux + +package platform + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/safedep/pmg/sandbox" +) + +func TestLandlockProbe(t *testing.T) { + tests := []struct { + name string + detect landlockABIDetector + want sandbox.ProbeStatus + }{ + { + name: "ok latest", + detect: func() (int, error) { return 4, nil }, + want: sandbox.ProbeStatusOK, + }, + { + name: "warn low abi", + detect: func() (int, error) { return 1, nil }, + want: sandbox.ProbeStatusWarn, + }, + { + name: "fail zero", + detect: func() (int, error) { return 0, nil }, + want: sandbox.ProbeStatusFail, + }, + { + name: "fail error", + detect: func() (int, error) { return 0, errors.New("ENOSYS") }, + want: sandbox.ProbeStatusFail, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &landlockProbe{detect: tc.detect} + res := p.Run(context.Background()) + assert.Equal(t, sandbox.ProbeLandlockDriver, res.Name) + assert.Equal(t, tc.want, res.Status) + if tc.want != sandbox.ProbeStatusOK { + assert.NotEmpty(t, res.Fixes) + } + }) + } +} diff --git a/sandbox/platform/probe_seatbelt_darwin.go b/sandbox/platform/probe_seatbelt_darwin.go new file mode 100644 index 0000000..e7a6317 --- /dev/null +++ b/sandbox/platform/probe_seatbelt_darwin.go @@ -0,0 +1,71 @@ +//go:build darwin +// +build darwin + +package platform + +import ( + "context" + + "github.com/safedep/pmg/sandbox" +) + +type seatbeltProbe struct { + env probeEnv +} + +// NewSeatbeltProbe returns a probe that verifies sandbox-exec is present and +// executable on this host. +func NewSeatbeltProbe() sandbox.Probe { + return &seatbeltProbe{env: defaultProbeEnv{}} +} + +func (p *seatbeltProbe) Name() string { return sandbox.ProbeSeatbeltDriver } + +func (p *seatbeltProbe) Run(_ context.Context) sandbox.ProbeResult { + path, err := p.env.lookPath("sandbox-exec") + if err != nil { + return sandbox.ProbeResult{ + Name: sandbox.ProbeSeatbeltDriver, + Status: sandbox.ProbeStatusFail, + Summary: "sandbox-exec not found in PATH", + Detail: err.Error(), + Fixes: []sandbox.ProbeFix{{ + Description: "sandbox-exec ships with macOS. Verify your PATH includes /usr/bin.", + Command: "ls -l /usr/bin/sandbox-exec", + }}, + } + } + + info, err := p.env.statExecutable(path) + if err != nil { + return sandbox.ProbeResult{ + Name: sandbox.ProbeSeatbeltDriver, + Status: sandbox.ProbeStatusFail, + Summary: "sandbox-exec is not accessible", + Detail: err.Error(), + Fixes: []sandbox.ProbeFix{{ + Description: "Inspect the binary permissions and SIP state.", + Command: "ls -l " + path, + }}, + } + } + + if info.Mode()&0o111 == 0 { + return sandbox.ProbeResult{ + Name: sandbox.ProbeSeatbeltDriver, + Status: sandbox.ProbeStatusFail, + Summary: "sandbox-exec is not executable", + Detail: "found at " + path, + Fixes: []sandbox.ProbeFix{{ + Description: "Restore execute bit on sandbox-exec or reinstall the OS toolchain.", + Command: "chmod +x " + path, + }}, + } + } + + return sandbox.ProbeResult{ + Name: sandbox.ProbeSeatbeltDriver, + Status: sandbox.ProbeStatusOK, + Summary: "sandbox-exec available at " + path, + } +} diff --git a/sandbox/platform/probe_seatbelt_darwin_test.go b/sandbox/platform/probe_seatbelt_darwin_test.go new file mode 100644 index 0000000..934a1b1 --- /dev/null +++ b/sandbox/platform/probe_seatbelt_darwin_test.go @@ -0,0 +1,96 @@ +//go:build darwin +// +build darwin + +package platform + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/safedep/pmg/sandbox" +) + +type fakeProbeEnv struct { + lookPathFn func(string) (string, error) + statExecutableFn func(string) (os.FileInfo, error) + readFileFn func(string) ([]byte, error) + runCommandFn func(ctx context.Context, name string, args ...string) ([]byte, error) +} + +func (f *fakeProbeEnv) lookPath(name string) (string, error) { return f.lookPathFn(name) } +func (f *fakeProbeEnv) statExecutable(path string) (os.FileInfo, error) { + return f.statExecutableFn(path) +} +func (f *fakeProbeEnv) readFile(path string) ([]byte, error) { return f.readFileFn(path) } +func (f *fakeProbeEnv) runCommand(ctx context.Context, name string, args ...string) ([]byte, error) { + return f.runCommandFn(ctx, name, args...) +} + +type fakeFileInfo struct { + name string + mode os.FileMode +} + +func (f fakeFileInfo) Name() string { return f.name } +func (f fakeFileInfo) Size() int64 { return 0 } +func (f fakeFileInfo) Mode() os.FileMode { return f.mode } +func (f fakeFileInfo) ModTime() time.Time { return time.Time{} } +func (f fakeFileInfo) IsDir() bool { return false } +func (f fakeFileInfo) Sys() any { return nil } + +func TestSeatbeltProbe(t *testing.T) { + tests := []struct { + name string + env *fakeProbeEnv + wantStat sandbox.ProbeStatus + }{ + { + name: "ok", + env: &fakeProbeEnv{ + lookPathFn: func(string) (string, error) { return "/usr/bin/sandbox-exec", nil }, + statExecutableFn: func(string) (os.FileInfo, error) { return fakeFileInfo{mode: 0o755}, nil }, + }, + wantStat: sandbox.ProbeStatusOK, + }, + { + name: "not in path", + env: &fakeProbeEnv{ + lookPathFn: func(string) (string, error) { return "", errors.New("not found") }, + }, + wantStat: sandbox.ProbeStatusFail, + }, + { + name: "stat fail", + env: &fakeProbeEnv{ + lookPathFn: func(string) (string, error) { return "/usr/bin/sandbox-exec", nil }, + statExecutableFn: func(string) (os.FileInfo, error) { return nil, errors.New("denied") }, + }, + wantStat: sandbox.ProbeStatusFail, + }, + { + name: "not executable", + env: &fakeProbeEnv{ + lookPathFn: func(string) (string, error) { return "/usr/bin/sandbox-exec", nil }, + statExecutableFn: func(string) (os.FileInfo, error) { return fakeFileInfo{mode: 0o644}, nil }, + }, + wantStat: sandbox.ProbeStatusFail, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := &seatbeltProbe{env: tc.env} + res := p.Run(context.Background()) + assert.Equal(t, sandbox.ProbeSeatbeltDriver, res.Name) + assert.Equal(t, tc.wantStat, res.Status) + if tc.wantStat != sandbox.ProbeStatusOK { + assert.NotEmpty(t, res.Fixes) + } + }) + } +} diff --git a/sandbox/platform/probes_default_darwin.go b/sandbox/platform/probes_default_darwin.go new file mode 100644 index 0000000..e75ecda --- /dev/null +++ b/sandbox/platform/probes_default_darwin.go @@ -0,0 +1,14 @@ +//go:build darwin +// +build darwin + +package platform + +import "github.com/safedep/pmg/sandbox" + +// DefaultProbes returns the sandbox probes for the host platform (darwin). +func DefaultProbes() []sandbox.Probe { + return []sandbox.Probe{ + NewSeatbeltProbe(), + NewSeatbeltCanaryProbe(), + } +} diff --git a/sandbox/platform/probes_default_linux.go b/sandbox/platform/probes_default_linux.go new file mode 100644 index 0000000..6442c73 --- /dev/null +++ b/sandbox/platform/probes_default_linux.go @@ -0,0 +1,17 @@ +//go:build linux +// +build linux + +package platform + +import "github.com/safedep/pmg/sandbox" + +// DefaultProbes returns the sandbox probes for the host platform (linux). +func DefaultProbes() []sandbox.Probe { + return []sandbox.Probe{ + NewBwrapProbe(), + NewLandlockProbe(), + NewAppArmorUsernsProbe(), + NewBwrapCanaryProbe(), + NewLandlockCanaryProbe(), + } +} diff --git a/sandbox/platform/probes_default_other.go b/sandbox/platform/probes_default_other.go new file mode 100644 index 0000000..7474662 --- /dev/null +++ b/sandbox/platform/probes_default_other.go @@ -0,0 +1,9 @@ +//go:build !darwin && !linux +// +build !darwin,!linux + +package platform + +import "github.com/safedep/pmg/sandbox" + +// DefaultProbes returns no probes on unsupported platforms. +func DefaultProbes() []sandbox.Probe { return nil } diff --git a/sandbox/platform/render.go b/sandbox/platform/render.go new file mode 100644 index 0000000..e7f6a7f --- /dev/null +++ b/sandbox/platform/render.go @@ -0,0 +1,32 @@ +// Package platform exposes Render for translating a SandboxPolicy into a +// driver-specific representation. Each driver's renderer is OS-gated (it +// imports OS-specific syscalls), so Render returns an error on a non-native +// host rather than silently producing an inaccurate result. +package platform + +import ( + "fmt" + "runtime" + + "github.com/safedep/pmg/sandbox" +) + +// Render dispatches to the OS-native renderer for driver. On a non-native +// host it returns a descriptive error so callers (e.g. `profile show +// --driver=...`) can surface it cleanly. +func Render(driver sandbox.DriverName, policy *sandbox.SandboxPolicy) ([]byte, error) { + switch driver { + case sandbox.DriverSeatbelt: + return renderSeatbelt(policy) + case sandbox.DriverBubblewrap: + return renderBubblewrap(policy) + case sandbox.DriverLandlock: + return renderLandlock(policy) + default: + return nil, fmt.Errorf("unknown sandbox driver: %s", driver) + } +} + +func driverUnavailable(driver sandbox.DriverName) error { + return fmt.Errorf("driver %s is not available on %s/%s", driver, runtime.GOOS, runtime.GOARCH) +} diff --git a/sandbox/platform/render_darwin.go b/sandbox/platform/render_darwin.go new file mode 100644 index 0000000..61d4a75 --- /dev/null +++ b/sandbox/platform/render_darwin.go @@ -0,0 +1,18 @@ +//go:build darwin +// +build darwin + +package platform + +import "github.com/safedep/pmg/sandbox" + +func renderSeatbelt(p *sandbox.SandboxPolicy) ([]byte, error) { + return RenderSeatbelt(p) +} + +func renderBubblewrap(p *sandbox.SandboxPolicy) ([]byte, error) { + return nil, driverUnavailable(sandbox.DriverBubblewrap) +} + +func renderLandlock(p *sandbox.SandboxPolicy) ([]byte, error) { + return nil, driverUnavailable(sandbox.DriverLandlock) +} diff --git a/sandbox/platform/render_linux.go b/sandbox/platform/render_linux.go new file mode 100644 index 0000000..7ba004f --- /dev/null +++ b/sandbox/platform/render_linux.go @@ -0,0 +1,18 @@ +//go:build linux +// +build linux + +package platform + +import "github.com/safedep/pmg/sandbox" + +func renderSeatbelt(p *sandbox.SandboxPolicy) ([]byte, error) { + return nil, driverUnavailable(sandbox.DriverSeatbelt) +} + +func renderBubblewrap(p *sandbox.SandboxPolicy) ([]byte, error) { + return RenderBubblewrap(p) +} + +func renderLandlock(p *sandbox.SandboxPolicy) ([]byte, error) { + return RenderLandlock(p) +} diff --git a/sandbox/platform/render_other.go b/sandbox/platform/render_other.go new file mode 100644 index 0000000..3579adc --- /dev/null +++ b/sandbox/platform/render_other.go @@ -0,0 +1,18 @@ +//go:build !darwin && !linux +// +build !darwin,!linux + +package platform + +import "github.com/safedep/pmg/sandbox" + +func renderSeatbelt(p *sandbox.SandboxPolicy) ([]byte, error) { + return nil, driverUnavailable(sandbox.DriverSeatbelt) +} + +func renderBubblewrap(p *sandbox.SandboxPolicy) ([]byte, error) { + return nil, driverUnavailable(sandbox.DriverBubblewrap) +} + +func renderLandlock(p *sandbox.SandboxPolicy) ([]byte, error) { + return nil, driverUnavailable(sandbox.DriverLandlock) +} diff --git a/sandbox/platform/seatbelt_darwin.go b/sandbox/platform/seatbelt_darwin.go index 30304c0..313ff48 100644 --- a/sandbox/platform/seatbelt_darwin.go +++ b/sandbox/platform/seatbelt_darwin.go @@ -98,8 +98,8 @@ func (s *seatbeltSandbox) Execute(ctx context.Context, cmd *exec.Cmd, policy *sa } // Name returns the name of this sandbox implementation. -func (s *seatbeltSandbox) Name() string { - return "seatbelt" +func (s *seatbeltSandbox) Name() sandbox.DriverName { + return sandbox.DriverSeatbelt } // IsAvailable returns true if sandbox-exec is available on this system. diff --git a/sandbox/platform/seatbelt_diagnostics_darwin.go b/sandbox/platform/seatbelt_diagnostics_darwin.go index f354d3c..239e971 100644 --- a/sandbox/platform/seatbelt_diagnostics_darwin.go +++ b/sandbox/platform/seatbelt_diagnostics_darwin.go @@ -29,6 +29,12 @@ const macOSUnifiedLogPath = "/usr/bin/log" var seatbeltMessagePattern = regexp.MustCompile(`PMG_SBX\|run=([^|]+)\|kind=([^|]+)\|target=([^"\s]*)`) +// seatbeltDenyVerbPattern captures the sandbox-exec denial verb from a raw log +// line such as `Sandbox: node(123) deny(1) file-write-data /path`. The verb is +// used to recover a typed ViolationKind when a denial hit the catch-all +// `(deny default ...)` rule (which only carries `kind=default` in our marker). +var seatbeltDenyVerbPattern = regexp.MustCompile(`\bdeny\(\d+\)\s+(\S+)`) + type seatbeltLogEntry struct { EventMessage string `json:"eventMessage"` Process string `json:"process"` @@ -116,14 +122,29 @@ func extractSeatbeltViolations(entries []seatbeltLogEntry, runID string) []sandb target = payload.Target } + kind := normalizeSeatbeltViolationKind(payload.Kind) + labelKind := payload.Kind + + // When the deny hit our catch-all `(deny default ...)` rule, the + // marker only carries `kind=default`. The sandbox-exec preamble in + // the same log line names the real verb (file-write-data, etc.) so + // we recover the typed kind from there. This drives accurate + // primary-violation ranking and `--sandbox-allow` suggestions. + if kind == sandbox.ViolationKindGenericDeny { + if inferredKind, inferredLabelKind, ok := inferSeatbeltKindFromRawLog(entry.EventMessage); ok { + kind = inferredKind + labelKind = inferredLabelKind + } + } + violations = append(violations, sandbox.Violation{ - Kind: normalizeSeatbeltViolationKind(payload.Kind), + Kind: kind, RawKind: payload.Kind, Target: target, RuleTarget: payload.Target, Process: process, RawLog: strings.TrimSpace(entry.EventMessage), - RuleLabel: summarizeSeatbeltViolation(payload.Kind, target), + RuleLabel: summarizeSeatbeltViolation(labelKind, target), }) } @@ -145,6 +166,34 @@ func normalizeSeatbeltViolationKind(kind string) sandbox.ViolationKind { } } +// inferSeatbeltKindFromRawLog recovers a typed ViolationKind from the +// sandbox-exec denial verb embedded in raw. It returns the typed kind plus a +// canonical marker name (the one our own rules would emit for the same kind, +// suitable for summarizeSeatbeltViolation). Only verbs that map to kinds +// scoreViolation and suggestOverride already understand are recognized; all +// others fall back to (generic_deny, "", false) so the caller keeps the +// original classification. +func inferSeatbeltKindFromRawLog(raw string) (sandbox.ViolationKind, string, bool) { + m := seatbeltDenyVerbPattern.FindStringSubmatch(raw) + if len(m) != 2 { + return sandbox.ViolationKindGenericDeny, "", false + } + + verb := m[1] + switch { + case verb == "file-write-unlink", verb == "file-write-mount", verb == "file-rename": + return sandbox.ViolationKindFSDeleteOrRename, "file-write-unlink", true + case strings.HasPrefix(verb, "file-write"), verb == "file-link", verb == "file-mknod": + return sandbox.ViolationKindFSWrite, "file-write", true + case strings.HasPrefix(verb, "file-read"): + return sandbox.ViolationKindFSRead, "file-read", true + case strings.HasPrefix(verb, "process-exec"): + return sandbox.ViolationKindExec, "process-exec", true + } + + return sandbox.ViolationKindGenericDeny, "", false +} + func extractSeatbeltDeniedPath(raw string, payload *seatbeltLogPayload) string { if payload == nil { return "" diff --git a/sandbox/platform/seatbelt_diagnostics_darwin_test.go b/sandbox/platform/seatbelt_diagnostics_darwin_test.go index a4f6e72..747eeb1 100644 --- a/sandbox/platform/seatbelt_diagnostics_darwin_test.go +++ b/sandbox/platform/seatbelt_diagnostics_darwin_test.go @@ -76,3 +76,113 @@ func TestExtractSeatbeltViolations(t *testing.T) { assert.Equal(t, "/tmp/.env", violations[0].RuleTarget) assert.Equal(t, "node", violations[0].Process) } + +// Regression: a denial that hits the catch-all `(deny default ...)` rule +// must still be classified as the typed kind (and get a typed label) by +// recovering the verb from the sandbox-exec preamble. Without this, the +// actual failure-causing operation (e.g. a write) gets buried below +// incidental typed reads in primaryViolation ranking. +func TestExtractSeatbeltViolationsRecoversTypedKindFromDefaultDeny(t *testing.T) { + entries := []seatbeltLogEntry{ + { + EventMessage: `Sandbox: node(59437) deny(1) file-write-data /Users/dev/project/.astro/types.d.ts ` + + seatbeltLogMessage("run-1", "default", ""), + Process: "node", + }, + { + EventMessage: `Sandbox: node(59438) deny(1) network-outbound 1.2.3.4:443 ` + + seatbeltLogMessage("run-1", "default", ""), + Process: "node", + }, + } + + violations := extractSeatbeltViolations(entries, "run-1") + require.Len(t, violations, 2) + + // File write hit the catch-all deny — Kind is recovered, RawKind stays + // "default" to preserve the "catch-all match" signal, and the label + // reflects the recovered kind so users see "write access denied". + assert.Equal(t, sandbox.ViolationKindFSWrite, violations[0].Kind) + assert.Equal(t, "default", violations[0].RawKind) + assert.Equal(t, "/Users/dev/project/.astro/types.d.ts", violations[0].Target) + assert.Equal(t, "write access denied: /Users/dev/project/.astro/types.d.ts", violations[0].RuleLabel) + + // Unrecognized verb (network-outbound) stays generic — no over-claiming. + assert.Equal(t, sandbox.ViolationKindGenericDeny, violations[1].Kind) + assert.Equal(t, "default", violations[1].RawKind) +} + +func TestInferSeatbeltKindFromRawLog(t *testing.T) { + tests := []struct { + name string + raw string + wantKind sandbox.ViolationKind + wantLabel string + wantOK bool + }{ + { + name: "file-write-data maps to fs_write", + raw: `Sandbox: node(1) deny(1) file-write-data /tmp/x`, + wantKind: sandbox.ViolationKindFSWrite, + wantLabel: "file-write", + wantOK: true, + }, + { + name: "file-write-unlink maps to fs_delete_or_rename", + raw: `Sandbox: node(1) deny(1) file-write-unlink /tmp/x`, + wantKind: sandbox.ViolationKindFSDeleteOrRename, + wantLabel: "file-write-unlink", + wantOK: true, + }, + { + name: "file-rename maps to fs_delete_or_rename", + raw: `Sandbox: node(1) deny(1) file-rename /tmp/a`, + wantKind: sandbox.ViolationKindFSDeleteOrRename, + wantLabel: "file-write-unlink", + wantOK: true, + }, + { + name: "file-read-metadata maps to fs_read", + raw: `Sandbox: node(1) deny(1) file-read-metadata /tmp/x`, + wantKind: sandbox.ViolationKindFSRead, + wantLabel: "file-read", + wantOK: true, + }, + { + name: "process-exec maps to exec", + raw: `Sandbox: node(1) deny(1) process-exec /usr/bin/curl`, + wantKind: sandbox.ViolationKindExec, + wantLabel: "process-exec", + wantOK: true, + }, + { + name: "process-fork is not exec", + raw: `Sandbox: node(1) deny(1) process-fork`, + wantKind: sandbox.ViolationKindGenericDeny, + wantOK: false, + }, + { + name: "network verb is not recognized", + raw: `Sandbox: node(1) deny(1) network-outbound 1.2.3.4:443`, + wantKind: sandbox.ViolationKindGenericDeny, + wantOK: false, + }, + { + name: "log without deny prefix is not recognized", + raw: `something unrelated`, + wantKind: sandbox.ViolationKindGenericDeny, + wantOK: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + kind, label, ok := inferSeatbeltKindFromRawLog(tc.raw) + assert.Equal(t, tc.wantOK, ok) + assert.Equal(t, tc.wantKind, kind) + if tc.wantOK { + assert.Equal(t, tc.wantLabel, label) + } + }) + } +} diff --git a/sandbox/platform/seatbelt_render_darwin.go b/sandbox/platform/seatbelt_render_darwin.go new file mode 100644 index 0000000..8a9d774 --- /dev/null +++ b/sandbox/platform/seatbelt_render_darwin.go @@ -0,0 +1,32 @@ +//go:build darwin +// +build darwin + +package platform + +import ( + "fmt" + + "github.com/safedep/pmg/sandbox" +) + +// RenderSeatbelt translates a SandboxPolicy into its native Seatbelt Profile +// Language (SBPL) source. This is a thin wrapper around the internal seatbelt +// translator and is intended for inspection use cases such as +// `pmg setup sandbox profile show --driver=seatbelt`. +// +// The output contains a per-render random log tag (PMG_SBX_) used at +// runtime to correlate violations; callers comparing renders should normalize +// it. +func RenderSeatbelt(policy *sandbox.SandboxPolicy) ([]byte, error) { + if policy == nil { + return nil, fmt.Errorf("policy is nil") + } + + t := newSeatbeltPolicyTranslator() + out, err := t.translate(policy) + if err != nil { + return nil, err + } + + return []byte(out), nil +} diff --git a/sandbox/platform/seatbelt_render_darwin_test.go b/sandbox/platform/seatbelt_render_darwin_test.go new file mode 100644 index 0000000..8c1ed13 --- /dev/null +++ b/sandbox/platform/seatbelt_render_darwin_test.go @@ -0,0 +1,70 @@ +//go:build darwin +// +build darwin + +package platform + +import ( + "os" + "path/filepath" + "regexp" + "testing" + + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// seatbeltLogTagPattern matches the per-render random log tag so golden +// comparisons are deterministic across runs. +var seatbeltLogTagPattern = regexp.MustCompile(`PMG_SBX_[A-Za-z0-9]+`) + +func normalizeSeatbeltOutput(b []byte) []byte { + return seatbeltLogTagPattern.ReplaceAll(b, []byte("PMG_SBX_GOLDENXXXXXX")) +} + +func TestRenderSeatbelt_Golden(t *testing.T) { + tests := []struct { + name string + policy *sandbox.SandboxPolicy + goldenFile string + }{ + { + name: "minimal allow read tmp", + policy: &sandbox.SandboxPolicy{ + Name: "render-min", + Description: "minimal policy for render golden test", + PackageManagers: []string{"npm"}, + Filesystem: sandbox.FilesystemPolicy{ + AllowRead: []string{"/tmp"}, + AllowWrite: []string{"/tmp"}, + }, + AllowPTY: utils.PtrTo(false), + }, + goldenFile: "seatbelt_minimal.sb", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := RenderSeatbelt(tc.policy) + require.NoError(t, err) + + goldenPath := filepath.Join("testdata", tc.goldenFile) + normalized := normalizeSeatbeltOutput(got) + + if os.Getenv("UPDATE_GOLDEN") != "" { + require.NoError(t, os.WriteFile(goldenPath, normalized, 0o644)) + } + + expected, err := os.ReadFile(goldenPath) + require.NoError(t, err, "missing golden file: run with UPDATE_GOLDEN=1 to create") + assert.Equal(t, string(expected), string(normalized)) + }) + } +} + +func TestRenderSeatbelt_NilPolicy(t *testing.T) { + _, err := RenderSeatbelt(nil) + assert.Error(t, err) +} diff --git a/sandbox/platform/seatbelt_translator_darwin.go b/sandbox/platform/seatbelt_translator_darwin.go index 12f0879..0c7573d 100644 --- a/sandbox/platform/seatbelt_translator_darwin.go +++ b/sandbox/platform/seatbelt_translator_darwin.go @@ -122,8 +122,30 @@ func globDoubleStarAutoAllowParentDirIfNeeded(sb *strings.Builder, pattern strin parentDir = "/" } - sb.WriteString(fmt.Sprintf(";; Auto-allow parent directory for %s\n", pattern)) - sb.WriteString(fmt.Sprintf("(allow %s (literal \"%s\"))\n", operation, parentDir)) + sb.WriteString(";; Auto-allow parent directory for ") + sb.WriteString(pattern) + sb.WriteString("\n") + sb.WriteString("(allow ") + sb.WriteString(operation) + sb.WriteString(" (literal \"") + sb.WriteString(parentDir) + sb.WriteString("\"))\n") +} + +func writeSeatbeltDenyRule(sb *strings.Builder, operation, matcher, value, message string) { + sb.WriteString("(deny ") + sb.WriteString(operation) + sb.WriteString("* (") + sb.WriteString(matcher) + if matcher == "regex" { + sb.WriteString(" #\"") + } else { + sb.WriteString(" \"") + } + sb.WriteString(value) + sb.WriteString("\") (with message \"") + sb.WriteString(message) + sb.WriteString("\"))\n") } // generateMoveBlockingRules generates deny rules for file movement (file-write-unlink) to protect paths. @@ -398,9 +420,13 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo globDoubleStarAutoAllowParentDirIfNeeded(sb, pattern, expanded, "file-read*") regexPattern := util.GlobToRegex(expanded) - sb.WriteString(fmt.Sprintf("(allow file-read* (regex #\"%s\"))\n", regexPattern)) + sb.WriteString("(allow file-read* (regex #\"") + sb.WriteString(regexPattern) + sb.WriteString("\"))\n") } else { - sb.WriteString(fmt.Sprintf("(allow file-read* (subpath \"%s\"))\n", expanded)) + sb.WriteString("(allow file-read* (subpath \"") + sb.WriteString(expanded) + sb.WriteString("\"))\n") } } @@ -414,7 +440,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo if len(tmpdirParents) > 0 { sb.WriteString(";; Auto-allow TMPDIR parent on macOS\n") for _, parent := range tmpdirParents { - sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", parent)) + sb.WriteString("(allow file-write* (subpath \"") + sb.WriteString(parent) + sb.WriteString("\"))\n") } sb.WriteString("\n") } @@ -432,9 +460,13 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo globDoubleStarAutoAllowParentDirIfNeeded(sb, pattern, expanded, "file-write*") regexPattern := util.GlobToRegex(expanded) - sb.WriteString(fmt.Sprintf("(allow file-write* (regex #\"%s\"))\n", regexPattern)) + sb.WriteString("(allow file-write* (regex #\"") + sb.WriteString(regexPattern) + sb.WriteString("\"))\n") } else { - sb.WriteString(fmt.Sprintf("(allow file-write* (subpath \"%s\"))\n", expanded)) + sb.WriteString("(allow file-write* (subpath \"") + sb.WriteString(expanded) + sb.WriteString("\"))\n") } } @@ -452,9 +484,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo // Use regex matching for glob patterns, subpath for literals if util.ContainsGlob(expanded) { regexPattern := util.GlobToRegex(expanded) - sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded))) + writeSeatbeltDenyRule(sb, "file-read", "regex", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded)) } else { - sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded))) + writeSeatbeltDenyRule(sb, "file-read", "subpath", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded)) } expandedDenyRead = append(expandedDenyRead, expanded) } @@ -479,9 +511,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo // Use regex matching for glob patterns, subpath for literals if util.ContainsGlob(expanded) { regexPattern := util.GlobToRegex(expanded) - sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded))) + writeSeatbeltDenyRule(sb, "file-write", "regex", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded)) } else { - sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded))) + writeSeatbeltDenyRule(sb, "file-write", "subpath", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded)) } expandedDenyWrite = append(expandedDenyWrite, expanded) } @@ -522,9 +554,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo if util.ContainsGlob(expanded) { regexPattern := util.GlobToRegex(expanded) - sb.WriteString(fmt.Sprintf("(deny file-write* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded))) + writeSeatbeltDenyRule(sb, "file-write", "regex", regexPattern, seatbeltLogMessage(t.logTag, "file-write", expanded)) } else { - sb.WriteString(fmt.Sprintf("(deny file-write* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded))) + writeSeatbeltDenyRule(sb, "file-write", "subpath", expanded, seatbeltLogMessage(t.logTag, "file-write", expanded)) } expandedDenyWrite = append(expandedDenyWrite, expanded) } @@ -537,9 +569,9 @@ func (t *seatbeltPolicyTranslator) translateFilesystem(policy *sandbox.SandboxPo if util.ContainsGlob(expanded) { regexPattern := util.GlobToRegex(expanded) - sb.WriteString(fmt.Sprintf("(deny file-read* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded))) + writeSeatbeltDenyRule(sb, "file-read", "regex", regexPattern, seatbeltLogMessage(t.logTag, "file-read", expanded)) } else { - sb.WriteString(fmt.Sprintf("(deny file-read* (subpath \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded))) + writeSeatbeltDenyRule(sb, "file-read", "subpath", expanded, seatbeltLogMessage(t.logTag, "file-read", expanded)) } } @@ -602,8 +634,12 @@ func (t *seatbeltPolicyTranslator) translateNetwork(policy *sandbox.SandboxPolic } for _, addr := range policy.Network.AllowBind { - sb.WriteString(fmt.Sprintf("(allow network-bind (local ip \"%s\"))\n", addr)) - sb.WriteString(fmt.Sprintf("(allow network* (local ip \"%s\"))\n", addr)) + sb.WriteString("(allow network-bind (local ip \"") + sb.WriteString(addr) + sb.WriteString("\"))\n") + sb.WriteString("(allow network* (local ip \"") + sb.WriteString(addr) + sb.WriteString("\"))\n") } if utils.SafelyGetValue(policy.AllowNetworkBind) || len(policy.Network.AllowBind) > 0 { @@ -627,9 +663,13 @@ func (t *seatbeltPolicyTranslator) translateProcess(policy *sandbox.SandboxPolic if util.ContainsGlob(expanded) { // For glob patterns, use regex matching for precise control regexPattern := util.GlobToRegex(expanded) - sb.WriteString(fmt.Sprintf("(allow process-exec* (regex #\"%s\"))\n", regexPattern)) + sb.WriteString("(allow process-exec* (regex #\"") + sb.WriteString(regexPattern) + sb.WriteString("\"))\n") } else { - sb.WriteString(fmt.Sprintf("(allow process-exec* (literal \"%s\"))\n", expanded)) + sb.WriteString("(allow process-exec* (literal \"") + sb.WriteString(expanded) + sb.WriteString("\"))\n") } } @@ -645,9 +685,9 @@ func (t *seatbeltPolicyTranslator) translateProcess(policy *sandbox.SandboxPolic if util.ContainsGlob(expanded) { // For glob patterns, use regex matching for precise control regexPattern := util.GlobToRegex(expanded) - sb.WriteString(fmt.Sprintf("(deny process-exec* (regex #\"%s\") (with message \"%s\"))\n", regexPattern, seatbeltLogMessage(t.logTag, "process-exec", expanded))) + writeSeatbeltDenyRule(sb, "process-exec", "regex", regexPattern, seatbeltLogMessage(t.logTag, "process-exec", expanded)) } else { - sb.WriteString(fmt.Sprintf("(deny process-exec* (literal \"%s\") (with message \"%s\"))\n", expanded, seatbeltLogMessage(t.logTag, "process-exec", expanded))) + writeSeatbeltDenyRule(sb, "process-exec", "literal", expanded, seatbeltLogMessage(t.logTag, "process-exec", expanded)) } } diff --git a/sandbox/platform/testdata/bubblewrap_minimal.argv b/sandbox/platform/testdata/bubblewrap_minimal.argv new file mode 100644 index 0000000..a8481bc --- /dev/null +++ b/sandbox/platform/testdata/bubblewrap_minimal.argv @@ -0,0 +1,58 @@ +--ro-bind-try +/usr +/usr +--ro-bind-try +/lib +/lib +--ro-bind-try +/lib64 +/lib64 +--ro-bind-try +/bin +/bin +--ro-bind-try +/sbin +/sbin +--ro-bind-try +/etc +/etc +--ro-bind-try +/opt +/opt +--ro-bind-try +/var/lib +/var/lib +--ro-bind-try +/sys +/sys +--dev-bind-try +/dev/null +/dev/null +--dev-bind-try +/dev/zero +/dev/zero +--dev-bind-try +/dev/random +/dev/random +--dev-bind-try +/dev/urandom +/dev/urandom +--dev-bind-try +/dev/full +/dev/full +--dev-bind-try +/dev/tty +/dev/tty +--proc +/proc +--unshare-net +--unshare-pid +--unshare-ipc +--new-session +--die-with-parent +--ro-bind-try +/tmp +/tmp +--bind +/tmp +/tmp diff --git a/sandbox/platform/testdata/landlock_minimal.txt b/sandbox/platform/testdata/landlock_minimal.txt new file mode 100644 index 0000000..b29eaba --- /dev/null +++ b/sandbox/platform/testdata/landlock_minimal.txt @@ -0,0 +1,110 @@ +# Landlock ruleset +# policy: render-min +abi: GOLDEN (GOLDEN) +features: GOLDEN +allow_pty: false +skip_pid_namespace: false +skip_ipc_namespace: false + +filesystem_rules (16): + - path: /tmp + access: execute|read_file|read_dir + - path: /tmp + access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer + - path: /usr/bin + access: execute|read_file + - path: /usr/sbin + access: execute|read_file + - path: /usr/lib + access: execute|read_file + - path: /usr/lib64 + access: execute|read_file + - path: /bin + access: execute|read_file + - path: /sbin + access: execute|read_file + - path: /lib + access: execute|read_file + - path: /lib64 + access: execute|read_file + - path: /proc + access: execute|read_file|read_dir + - path: /dev/null + access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer + - path: /dev/zero + access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer + - path: /dev/random + access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer + - path: /dev/urandom + access: execute|read_file|read_dir|write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer + - path: /tmp + access: write_file|truncate|make_reg|make_dir|make_sock|make_fifo|make_block|make_char|make_sym|remove_file|remove_dir|refer + +deny_paths (32): + - path: /src/sandbox/platform/.env + mode: both + - path: /root/.env + mode: both + - path: /src/sandbox/platform/.aws + mode: both + - path: /root/.aws + mode: both + - path: /src/sandbox/platform/.azure + mode: both + - path: /root/.azure + mode: both + - path: /src/sandbox/platform/.gcloud + mode: both + - path: /root/.gcloud + mode: both + - path: /src/sandbox/platform/.config/gcloud + mode: both + - path: /root/.config/gcloud + mode: both + - path: /src/sandbox/platform/.kube + mode: both + - path: /root/.kube + mode: both + - path: /src/sandbox/platform/.ssh + mode: both + - path: /root/.ssh + mode: both + - path: /src/sandbox/platform/.gnupg + mode: both + - path: /root/.gnupg + mode: both + - path: /src/sandbox/platform/.docker/config.json + mode: both + - path: /root/.docker/config.json + mode: both + - path: /src/sandbox/platform/.netrc + mode: both + - path: /root/.netrc + mode: both + - path: /src/sandbox/platform/.git-credentials + mode: both + - path: /root/.git-credentials + mode: both + - path: /src/sandbox/platform/.pgpass + mode: both + - path: /root/.pgpass + mode: both + - path: /src/sandbox/platform/.config/gh + mode: both + - path: /root/.config/gh + mode: both + - path: /src/sandbox/platform/.git/config + mode: both + - path: /root/.git/config + mode: both + - path: /src/sandbox/platform/.git/hooks + mode: both + - path: /src/sandbox/platform/.git/hooks + mode: both + - path: /root/.git/hooks + mode: both + - path: /root/.git/hooks + mode: both + +deny_exec_paths (1): + - /bin/sh diff --git a/sandbox/platform/testdata/seatbelt_minimal.sb b/sandbox/platform/testdata/seatbelt_minimal.sb new file mode 100644 index 0000000..927d4c6 --- /dev/null +++ b/sandbox/platform/testdata/seatbelt_minimal.sb @@ -0,0 +1,265 @@ +(version 1) +;; PMG Sandbox Policy: render-min +;; minimal policy for render golden test +;; Generated by PMG sandbox system + +(deny default (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=default|target=")) + +;; Essential system permissions +;; Based on Chrome/Chromium sandbox for stable process execution + +;; Process permissions +(allow process-exec) +(allow process-fork) +(allow process-info* (target same-sandbox)) +(allow signal (target same-sandbox)) +(allow mach-priv-task-port (target same-sandbox)) + +;; User preferences +(allow user-preference-read) + +;; Mach IPC - specific services only +(allow mach-lookup + (global-name "com.apple.audio.systemsoundserver") + (global-name "com.apple.distributed_notifications@Uv3") + (global-name "com.apple.FSEvents") + (global-name "com.apple.FontObjectsServer") + (global-name "com.apple.fonts") + (global-name "com.apple.logd") + (global-name "com.apple.lsd.mapdb") + (global-name "com.apple.PowerManagement.control") + (global-name "com.apple.system.logger") + (global-name "com.apple.system.notification_center") + (global-name "com.apple.trustd.agent") + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.securityd.xpc") + (global-name "com.apple.coreservices.launchservicesd") +) + +;; POSIX IPC +(allow ipc-posix-shm) ; Shared memory +(allow ipc-posix-sem) ; Semaphores for Python multiprocessing + +;; IOKit operations +(allow iokit-open + (iokit-registry-entry-class "IOSurfaceRootUserClient") + (iokit-registry-entry-class "RootDomainUserClient") + (iokit-user-client-class "IOSurfaceSendRight") +) +(allow iokit-get-properties) + +;; Specific safe system socket +(allow system-socket (require-all (socket-domain AF_SYSTEM) (socket-protocol 2))) + +;; sysctl - specific sysctls only +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.memsize") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.pagesize") + (sysctl-name "hw.physicalcpu") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.argmax") + (sysctl-name "kern.bootargs") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfiles") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.maxproc") + (sysctl-name "kern.ngroups") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.tcsm_available") + (sysctl-name "kern.tcsm_enable") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "kern.willshutdown") + (sysctl-name "machdep.cpu.brand_string") + (sysctl-name "machdep.ptrauth_enabled") + (sysctl-name "security.mac.lockdown_mode_state") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name "vm.loadavg") + (sysctl-name-prefix "hw.optional.arm") + (sysctl-name-prefix "hw.optional.arm.") + (sysctl-name-prefix "hw.optional.armv8_") + (sysctl-name-prefix "hw.perflevel") + (sysctl-name-prefix "kern.proc.all") + (sysctl-name-prefix "kern.proc.pgrp.") + (sysctl-name-prefix "kern.proc.pid.") + (sysctl-name-prefix "machdep.cpu.") + (sysctl-name-prefix "net.routetable.") +) + +;; V8 thread calculations +(allow sysctl-write + (sysctl-name "kern.tcsm_enable") +) + +;; Distributed notifications +(allow distributed-notification-post) + +;; Specific mach-lookup for security +(allow mach-lookup (global-name "com.apple.SecurityServer")) + +;; Device file I/O +(allow file-ioctl (literal "/dev/null")) +(allow file-ioctl (literal "/dev/zero")) +(allow file-ioctl (literal "/dev/random")) +(allow file-ioctl (literal "/dev/urandom")) +(allow file-ioctl (literal "/dev/dtracehelper")) +(allow file-ioctl (literal "/dev/tty")) + +(allow file-ioctl file-read-data file-write-data + (require-all + (literal "/dev/null") + (vnode-type CHARACTER-DEVICE) + ) +) + +;; File metadata for getcwd() and similar +(allow file-read-metadata) + +;; System configuration and libraries +(allow file-read* (subpath "/dev")) +(allow file-read* (subpath "/etc")) + +;; Filesystem access +(allow file-read* (subpath "/tmp")) + +;; Auto-allow TMPDIR parent on macOS +(allow file-write* (subpath "/var/folders/8k/n1r5cyfs0gd5044p35ymlg780000gn")) +(allow file-write* (subpath "/private/var/folders/8k/n1r5cyfs0gd5044p35ymlg780000gn")) + +(allow file-write* (subpath "/tmp")) + + + +;; Mandatory security denies (credentials, git hooks, etc.) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.env") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.env")) +(deny file-write* (regex #"^(.*/)?\.env$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.env")) +(deny file-write* (subpath "/Users/dev/.env") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.env")) +(deny file-write* (regex #"^/Users/dev/Work/dev/safedep/pmg/sandbox/platform/\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.env.%2A")) +(deny file-write* (regex #"^(.*/)?\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.env.%2A")) +(deny file-write* (regex #"^/Users/dev/\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.env.%2A")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.aws") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.aws")) +(deny file-write* (regex #"^(.*/)?\.aws$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.aws")) +(deny file-write* (subpath "/Users/dev/.aws") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.aws")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.azure") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.azure")) +(deny file-write* (regex #"^(.*/)?\.azure$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.azure")) +(deny file-write* (subpath "/Users/dev/.azure") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.azure")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.gcloud")) +(deny file-write* (regex #"^(.*/)?\.gcloud$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.gcloud")) +(deny file-write* (subpath "/Users/dev/.gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.gcloud")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.config/gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.config%2Fgcloud")) +(deny file-write* (regex #"^(.*/)?\.config/gcloud$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.config%2Fgcloud")) +(deny file-write* (subpath "/Users/dev/.config/gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.config%2Fgcloud")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.kube") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.kube")) +(deny file-write* (regex #"^(.*/)?\.kube$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.kube")) +(deny file-write* (subpath "/Users/dev/.kube") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.kube")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.ssh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.ssh")) +(deny file-write* (regex #"^(.*/)?\.ssh$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.ssh")) +(deny file-write* (subpath "/Users/dev/.ssh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.ssh")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.gnupg") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.gnupg")) +(deny file-write* (regex #"^(.*/)?\.gnupg$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.gnupg")) +(deny file-write* (subpath "/Users/dev/.gnupg") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.gnupg")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.docker/config.json") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.docker%2Fconfig.json")) +(deny file-write* (regex #"^(.*/)?\.docker/config\.json$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.docker%2Fconfig.json")) +(deny file-write* (subpath "/Users/dev/.docker/config.json") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.docker%2Fconfig.json")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.netrc") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.netrc")) +(deny file-write* (regex #"^(.*/)?\.netrc$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.netrc")) +(deny file-write* (subpath "/Users/dev/.netrc") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.netrc")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git-credentials") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git-credentials")) +(deny file-write* (regex #"^(.*/)?\.git-credentials$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.git-credentials")) +(deny file-write* (subpath "/Users/dev/.git-credentials") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.git-credentials")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.pgpass") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.pgpass")) +(deny file-write* (regex #"^(.*/)?\.pgpass$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.pgpass")) +(deny file-write* (subpath "/Users/dev/.pgpass") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.pgpass")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.config/gh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.config%2Fgh")) +(deny file-write* (regex #"^(.*/)?\.config/gh$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2A%2A%2F.config%2Fgh")) +(deny file-write* (subpath "/Users/dev/.config/gh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.config%2Fgh")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git/config") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fconfig")) +(deny file-write* (subpath "/Users/dev/.git/config") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.git%2Fconfig")) +(deny file-write* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git/hooks") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fhooks")) +(deny file-write* (regex #"^/Users/dev/Work/dev/safedep/pmg/sandbox/platform/\.git/hooks/.*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fhooks%2F%2A%2A")) +(deny file-write* (subpath "/Users/dev/.git/hooks") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.git%2Fhooks")) +(deny file-write* (regex #"^/Users/dev/\.git/hooks/.*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-write|target=%2FUsers%2Fdev%2F.git%2Fhooks%2F%2A%2A")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.env") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.env")) +(deny file-read* (regex #"^(.*/)?\.env$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.env")) +(deny file-read* (subpath "/Users/dev/.env") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.env")) +(deny file-read* (regex #"^/Users/dev/Work/dev/safedep/pmg/sandbox/platform/\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.env.%2A")) +(deny file-read* (regex #"^(.*/)?\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.env.%2A")) +(deny file-read* (regex #"^/Users/dev/\.env\.[^/]*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.env.%2A")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.aws") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.aws")) +(deny file-read* (regex #"^(.*/)?\.aws$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.aws")) +(deny file-read* (subpath "/Users/dev/.aws") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.aws")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.azure") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.azure")) +(deny file-read* (regex #"^(.*/)?\.azure$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.azure")) +(deny file-read* (subpath "/Users/dev/.azure") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.azure")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.gcloud")) +(deny file-read* (regex #"^(.*/)?\.gcloud$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.gcloud")) +(deny file-read* (subpath "/Users/dev/.gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.gcloud")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.config/gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.config%2Fgcloud")) +(deny file-read* (regex #"^(.*/)?\.config/gcloud$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.config%2Fgcloud")) +(deny file-read* (subpath "/Users/dev/.config/gcloud") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.config%2Fgcloud")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.kube") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.kube")) +(deny file-read* (regex #"^(.*/)?\.kube$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.kube")) +(deny file-read* (subpath "/Users/dev/.kube") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.kube")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.ssh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.ssh")) +(deny file-read* (regex #"^(.*/)?\.ssh$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.ssh")) +(deny file-read* (subpath "/Users/dev/.ssh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.ssh")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.gnupg") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.gnupg")) +(deny file-read* (regex #"^(.*/)?\.gnupg$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.gnupg")) +(deny file-read* (subpath "/Users/dev/.gnupg") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.gnupg")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.docker/config.json") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.docker%2Fconfig.json")) +(deny file-read* (regex #"^(.*/)?\.docker/config\.json$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.docker%2Fconfig.json")) +(deny file-read* (subpath "/Users/dev/.docker/config.json") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.docker%2Fconfig.json")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.netrc") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.netrc")) +(deny file-read* (regex #"^(.*/)?\.netrc$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.netrc")) +(deny file-read* (subpath "/Users/dev/.netrc") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.netrc")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git-credentials") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git-credentials")) +(deny file-read* (regex #"^(.*/)?\.git-credentials$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.git-credentials")) +(deny file-read* (subpath "/Users/dev/.git-credentials") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.git-credentials")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.pgpass") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.pgpass")) +(deny file-read* (regex #"^(.*/)?\.pgpass$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.pgpass")) +(deny file-read* (subpath "/Users/dev/.pgpass") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.pgpass")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.config/gh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.config%2Fgh")) +(deny file-read* (regex #"^(.*/)?\.config/gh$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2A%2A%2F.config%2Fgh")) +(deny file-read* (subpath "/Users/dev/.config/gh") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.config%2Fgh")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git/config") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fconfig")) +(deny file-read* (subpath "/Users/dev/.git/config") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.git%2Fconfig")) +(deny file-read* (subpath "/Users/dev/Work/dev/safedep/pmg/sandbox/platform/.git/hooks") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fhooks")) +(deny file-read* (regex #"^/Users/dev/Work/dev/safedep/pmg/sandbox/platform/\.git/hooks/.*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2FWork%2Fdev%2Fsafedep%2Fpmg%2Fsandbox%2Fplatform%2F.git%2Fhooks%2F%2A%2A")) +(deny file-read* (subpath "/Users/dev/.git/hooks") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.git%2Fhooks")) +(deny file-read* (regex #"^/Users/dev/\.git/hooks/.*$") (with message "PMG_SBX|run=PMG_SBX_GOLDENXXXXXX|kind=file-read|target=%2FUsers%2Fdev%2F.git%2Fhooks%2F%2A%2A")) + + +;; Network access + +;; Process execution + + diff --git a/sandbox/probe.go b/sandbox/probe.go new file mode 100644 index 0000000..d63f217 --- /dev/null +++ b/sandbox/probe.go @@ -0,0 +1,69 @@ +package sandbox + +import "context" + +type ProbeStatus string + +const ( + ProbeStatusOK ProbeStatus = "ok" + ProbeStatusWarn ProbeStatus = "warn" + ProbeStatusFail ProbeStatus = "fail" + ProbeStatusSkipped ProbeStatus = "skipped" +) + +// ProbeFix is a suggested remediation for a non-OK probe result. +type ProbeFix struct { + Description string + Command string + Docs string +} + +// ProbeResult is the structured outcome of a probe. +type ProbeResult struct { + Name string + Status ProbeStatus + Summary string + Detail string + Fixes []ProbeFix +} + +// Probe is the unit of work a diagnose runner executes. +type Probe interface { + Name() string + Run(ctx context.Context) ProbeResult +} + +// Probe names. These are stable identifiers used in JSON output, the +// `--driver` filter, and by callers that want to look up a specific probe's +// result. The cmd layer maps them to friendly labels for human rendering. +const ( + ProbeSeatbeltDriver = "driver.seatbelt.available" + ProbeBwrapDriver = "driver.bwrap.available" + ProbeLandlockDriver = "driver.landlock.abi" + ProbeAppArmorUserns = "linux.apparmor.userns" + ProbeSeatbeltCanary = "canary.seatbelt" + ProbeBwrapCanary = "canary.bubblewrap" + ProbeLandlockCanary = "canary.landlock" +) + +// RunProbes executes probes sequentially in input order, honoring ctx cancellation +// between probes. A cancelled context short-circuits the remaining probes +// with ProbeStatusSkipped results so the caller can render a complete table. +func RunProbes(ctx context.Context, probes []Probe) []ProbeResult { + results := make([]ProbeResult, 0, len(probes)) + + for _, p := range probes { + if err := ctx.Err(); err != nil { + results = append(results, ProbeResult{ + Name: p.Name(), + Status: ProbeStatusSkipped, + Summary: "skipped: " + err.Error(), + }) + continue + } + + results = append(results, p.Run(ctx)) + } + + return results +} diff --git a/sandbox/probe_test.go b/sandbox/probe_test.go new file mode 100644 index 0000000..462e7d3 --- /dev/null +++ b/sandbox/probe_test.go @@ -0,0 +1,62 @@ +package sandbox + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +type fakeProbe struct { + name string + result ProbeResult + ran bool +} + +func (f *fakeProbe) Name() string { return f.name } +func (f *fakeProbe) Run(_ context.Context) ProbeResult { + f.ran = true + if f.result.Name == "" { + f.result.Name = f.name + } + return f.result +} + +func TestRunProbes_PreservesOrderAndStatus(t *testing.T) { + probes := []Probe{ + &fakeProbe{name: "a", result: ProbeResult{Status: ProbeStatusOK, Summary: "a ok"}}, + &fakeProbe{name: "b", result: ProbeResult{Status: ProbeStatusWarn, Summary: "b warn"}}, + &fakeProbe{name: "c", result: ProbeResult{Status: ProbeStatusFail, Summary: "c fail"}}, + } + + results := RunProbes(context.Background(), probes) + + assert.Len(t, results, 3) + assert.Equal(t, "a", results[0].Name) + assert.Equal(t, ProbeStatusOK, results[0].Status) + assert.Equal(t, "b", results[1].Name) + assert.Equal(t, ProbeStatusWarn, results[1].Status) + assert.Equal(t, "c", results[2].Name) + assert.Equal(t, ProbeStatusFail, results[2].Status) +} + +func TestRunProbes_HonorsCancellation(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + a := &fakeProbe{name: "a", result: ProbeResult{Status: ProbeStatusOK}} + b := &fakeProbe{name: "b", result: ProbeResult{Status: ProbeStatusOK}} + + results := RunProbes(ctx, []Probe{a, b}) + + assert.Len(t, results, 2) + assert.False(t, a.ran) + assert.False(t, b.ran) + assert.Equal(t, ProbeStatusSkipped, results[0].Status) + assert.Equal(t, ProbeStatusSkipped, results[1].Status) +} + +func TestRunProbes_EmptySlice(t *testing.T) { + results := RunProbes(context.Background(), nil) + assert.Empty(t, results) +} diff --git a/sandbox/registry.go b/sandbox/registry.go index 06c6bc7..bf6e3c3 100644 --- a/sandbox/registry.go +++ b/sandbox/registry.go @@ -5,9 +5,11 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" + "github.com/safedep/dry/log" "gopkg.in/yaml.v3" ) @@ -15,13 +17,24 @@ import ( var profilesFS embed.FS type defaultProfileRegistry struct { - mu sync.RWMutex - profiles map[string]*SandboxPolicy + mu sync.RWMutex + profiles map[string]*SandboxPolicy + builtins map[string]struct{} + builtinYAML map[string][]byte + userProfileDir string } -func newDefaultProfileRegistry() (*defaultProfileRegistry, error) { +func newDefaultProfileRegistry(opts ...RegistryOption) (*defaultProfileRegistry, error) { + options := ®istryOptions{} + for _, opt := range opts { + opt(options) + } + registry := &defaultProfileRegistry{ - profiles: make(map[string]*SandboxPolicy), + profiles: make(map[string]*SandboxPolicy), + builtins: make(map[string]struct{}), + builtinYAML: make(map[string][]byte), + userProfileDir: options.userProfileDir, } if err := registry.loadBuiltinProfiles(); err != nil { @@ -63,6 +76,8 @@ func (r *defaultProfileRegistry) loadBuiltinProfiles() error { r.mu.Lock() r.profiles[policy.Name] = policy + r.builtins[policy.Name] = struct{}{} + r.builtinYAML[policy.Name] = data r.mu.Unlock() } @@ -115,19 +130,85 @@ func (r *defaultProfileRegistry) resolveInheritance(child *SandboxPolicy) error } // GetProfile retrieves a policy by name. +// Resolution order: built-in profiles first, then user profile directory +// (by bare name, looking up .yml or .yaml), then a literal file path. func (r *defaultProfileRegistry) GetProfile(name string) (*SandboxPolicy, error) { r.mu.RLock() - if policy, exists := r.profiles[name]; exists { + if _, isBuiltin := r.builtins[name]; isBuiltin { + policy := r.profiles[name] r.mu.RUnlock() return policy, nil } r.mu.RUnlock() + path, found, err := r.findUserProfileByName(name) + if err != nil { + return nil, err + } + if found { + return r.LoadCustomProfile(path) + } + if fileExists(name) { return r.LoadCustomProfile(name) } - return nil, fmt.Errorf("sandbox profile not found: %s (not a built-in profile and file does not exist)", name) + return nil, fmt.Errorf("sandbox profile not found: %s (not a built-in profile, no matching user profile, and file does not exist)", name) +} + +// findUserProfileByName looks for `.yml` then `.yaml` under +// the user profile directory. Returns the absolute path if found. +func (r *defaultProfileRegistry) findUserProfileByName(name string) (string, bool, error) { + if r.userProfileDir == "" || !isBareProfileName(name) { + return "", false, nil + } + + files, err := r.userProfileFiles() + if err != nil { + return "", false, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err) + } + for _, file := range files { + if file.name == name { + return file.path, true, nil + } + } + + return "", false, nil +} + +func isBareProfileName(name string) bool { + return name != "" && name == filepath.Base(name) && name != "." && name != ".." +} + +// UserProfileDir returns the directory scanned for user profiles. +func (r *defaultProfileRegistry) UserProfileDir() string { + return r.userProfileDir +} + +// ListUserProfiles enumerates *.yml / *.yaml files under UserProfileDir(). +// A missing directory returns an empty slice with no error. Profiles whose +// name collides with a built-in are marked as Shadowed. +func (r *defaultProfileRegistry) ListUserProfiles() ([]ProfileInfo, error) { + files, err := r.userProfileFiles() + if err != nil { + return nil, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err) + } + + r.mu.RLock() + defer r.mu.RUnlock() + + profiles := make([]ProfileInfo, 0, len(files)) + for _, file := range files { + _, shadowed := r.builtins[file.name] + + profiles = append(profiles, ProfileInfo{ + Name: file.name, + Path: file.path, + Shadowed: shadowed, + }) + } + + return profiles, nil } // LoadCustomProfile loads a policy from a custom YAML file path. @@ -180,17 +261,140 @@ func (r *defaultProfileRegistry) LoadCustomProfile(path string) (*SandboxPolicy, return policy, nil } -// ListProfiles returns the names of all built-in profiles. -func (r *defaultProfileRegistry) ListProfiles() []string { +// ListProfiles returns all discoverable profiles: built-ins first, then user +// profiles (including shadowed entries so the cmd layer can warn the user). +func (r *defaultProfileRegistry) ListProfiles() ([]ProfileSummary, error) { + r.mu.RLock() + builtinNames := make([]string, 0, len(r.builtins)) + for name := range r.builtins { + builtinNames = append(builtinNames, name) + } + sort.Strings(builtinNames) + + summaries := make([]ProfileSummary, 0, len(builtinNames)) + for _, name := range builtinNames { + p := r.profiles[name] + summaries = append(summaries, ProfileSummary{ + Name: name, + Source: ProfileSourceBuiltin, + Inherits: p.Inherits, + PackageManagers: append([]string(nil), p.PackageManagers...), + Description: p.Description, + }) + } + r.mu.RUnlock() + + files, err := r.userProfileFiles() + if err != nil { + return nil, fmt.Errorf("failed to read user profile directory %s: %w", r.userProfileDir, err) + } + + userEntries := make([]ProfileSummary, 0, len(files)) + for _, file := range files { + r.mu.RLock() + _, shadowed := r.builtins[file.name] + r.mu.RUnlock() + + summary := ProfileSummary{ + Name: file.name, + Source: ProfileSourceUser, + Path: file.path, + Shadowed: shadowed, + } + + data, err := os.ReadFile(file.path) + if err != nil { + log.Warnf("failed to read user profile %s: %v", file.path, err) + userEntries = append(userEntries, summary) + continue + } + + var parsed SandboxPolicy + if err := yaml.Unmarshal(data, &parsed); err != nil { + log.Warnf("failed to parse user profile %s: %v", file.path, err) + userEntries = append(userEntries, summary) + continue + } + + summary.Inherits = parsed.Inherits + summary.PackageManagers = parsed.PackageManagers + summary.Description = parsed.Description + userEntries = append(userEntries, summary) + } + + sort.Slice(userEntries, func(i, j int) bool { + return userEntries[i].Name < userEntries[j].Name + }) + + return append(summaries, userEntries...), nil +} + +type userProfileFile struct { + name string + path string +} + +func (r *defaultProfileRegistry) userProfileFiles() ([]userProfileFile, error) { + if r.userProfileDir == "" { + return nil, nil + } + + entries, err := os.ReadDir(r.userProfileDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + byName := make(map[string]userProfileFile, len(entries)) + for _, entry := range entries { + if entry.IsDir() { + continue + } + + ext := filepath.Ext(entry.Name()) + if ext != ".yml" && ext != ".yaml" { + continue + } + + name := strings.TrimSuffix(entry.Name(), ext) + if !isBareProfileName(name) { + continue + } + + file := userProfileFile{ + name: name, + path: filepath.Join(r.userProfileDir, entry.Name()), + } + + if existing, ok := byName[name]; ok && filepath.Ext(existing.path) == ".yml" { + continue + } + byName[name] = file + } + + files := make([]userProfileFile, 0, len(byName)) + for _, file := range byName { + files = append(files, file) + } + sort.Slice(files, func(i, j int) bool { return files[i].name < files[j].name }) + return files, nil +} + +// BuiltinProfileYAML returns the embedded YAML for a built-in profile. +func (r *defaultProfileRegistry) BuiltinProfileYAML(name string) ([]byte, bool) { r.mu.RLock() defer r.mu.RUnlock() - profiles := make([]string, 0, len(r.profiles)) - for name := range r.profiles { - profiles = append(profiles, name) + data, ok := r.builtinYAML[name] + if !ok { + return nil, false } - return profiles + out := make([]byte, len(data)) + copy(out, data) + return out, true } func parsePolicy(data []byte) (*SandboxPolicy, error) { diff --git a/sandbox/registry_user_test.go b/sandbox/registry_user_test.go new file mode 100644 index 0000000..0ca3474 --- /dev/null +++ b/sandbox/registry_user_test.go @@ -0,0 +1,315 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// writeUserProfile writes a valid SandboxPolicy YAML named `.` into +// dir. The policy is intentionally minimal but valid. +func writeUserProfile(t *testing.T, dir, name, ext string) string { + t.Helper() + + policy := &SandboxPolicy{ + Name: name, + Description: "user " + name, + PackageManagers: []string{"npm"}, + Filesystem: FilesystemPolicy{ + AllowRead: []string{"/tmp"}, + AllowWrite: []string{"/tmp"}, + DenyRead: []string{"/private/var"}, + DenyWrite: []string{"/private/var"}, + }, + } + + path := filepath.Join(dir, name+ext) + data, err := yaml.Marshal(policy) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o644)) + + return path +} + +func TestListUserProfiles(t *testing.T) { + t.Run("nil user dir returns empty", func(t *testing.T) { + registry, err := newDefaultProfileRegistry() + require.NoError(t, err) + + profiles, err := registry.ListUserProfiles() + assert.NoError(t, err) + assert.Empty(t, profiles) + assert.Equal(t, "", registry.UserProfileDir()) + }) + + t.Run("missing dir returns empty", func(t *testing.T) { + missing := filepath.Join(t.TempDir(), "does-not-exist") + registry, err := newDefaultProfileRegistry(WithUserProfileDir(missing)) + require.NoError(t, err) + + profiles, err := registry.ListUserProfiles() + assert.NoError(t, err) + assert.Empty(t, profiles) + assert.Equal(t, missing, registry.UserProfileDir()) + }) + + t.Run("empty dir returns empty", func(t *testing.T) { + dir := t.TempDir() + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + profiles, err := registry.ListUserProfiles() + assert.NoError(t, err) + assert.Empty(t, profiles) + }) + + t.Run("mixed yml and yaml files", func(t *testing.T) { + dir := t.TempDir() + writeUserProfile(t, dir, "alpha", ".yml") + writeUserProfile(t, dir, "beta", ".yaml") + writeUserProfile(t, dir, "gamma", ".yml") + // non-profile files should be ignored + require.NoError(t, os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignored"), 0o644)) + require.NoError(t, os.Mkdir(filepath.Join(dir, "subdir"), 0o755)) + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + profiles, err := registry.ListUserProfiles() + require.NoError(t, err) + + names := make([]string, 0, len(profiles)) + for _, p := range profiles { + names = append(names, p.Name) + assert.False(t, p.Shadowed, "user-only name should not be shadowed: %s", p.Name) + } + assert.ElementsMatch(t, []string{"alpha", "beta", "gamma"}, names) + }) + + t.Run("deduplicates yaml and yml by name", func(t *testing.T) { + dir := t.TempDir() + ymlPath := writeUserProfile(t, dir, "dupe", ".yml") + writeUserProfile(t, dir, "dupe", ".yaml") + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + profiles, err := registry.ListUserProfiles() + require.NoError(t, err) + require.Len(t, profiles, 1) + assert.Equal(t, "dupe", profiles[0].Name) + assert.Equal(t, ymlPath, profiles[0].Path) + }) + + t.Run("user file shadowed by builtin", func(t *testing.T) { + dir := t.TempDir() + writeUserProfile(t, dir, "npm-restrictive", ".yml") + writeUserProfile(t, dir, "my-custom", ".yml") + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + profiles, err := registry.ListUserProfiles() + require.NoError(t, err) + + byName := map[string]ProfileInfo{} + for _, p := range profiles { + byName[p.Name] = p + } + + require.Contains(t, byName, "npm-restrictive") + assert.True(t, byName["npm-restrictive"].Shadowed) + + require.Contains(t, byName, "my-custom") + assert.False(t, byName["my-custom"].Shadowed) + }) +} + +func TestListProfiles(t *testing.T) { + t.Run("builtins only", func(t *testing.T) { + registry, err := newDefaultProfileRegistry() + require.NoError(t, err) + + summaries, err := registry.ListProfiles() + require.NoError(t, err) + require.NotEmpty(t, summaries) + for _, s := range summaries { + assert.Equal(t, ProfileSourceBuiltin, s.Source) + assert.Empty(t, s.Path) + assert.False(t, s.Shadowed) + assert.NotEmpty(t, s.PackageManagers) + assert.NotEmpty(t, s.Description) + } + }) + + t.Run("user only", func(t *testing.T) { + dir := t.TempDir() + writeUserProfile(t, dir, "alpha", ".yml") + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + summaries, err := registry.ListProfiles() + require.NoError(t, err) + + var alpha *ProfileSummary + for i := range summaries { + if summaries[i].Name == "alpha" { + alpha = &summaries[i] + break + } + } + require.NotNil(t, alpha) + assert.Equal(t, ProfileSourceUser, alpha.Source) + assert.NotEmpty(t, alpha.Path) + assert.False(t, alpha.Shadowed) + assert.Equal(t, "user alpha", alpha.Description) + assert.Equal(t, []string{"npm"}, alpha.PackageManagers) + }) + + t.Run("mixed with shadowing", func(t *testing.T) { + dir := t.TempDir() + writeUserProfile(t, dir, "npm-restrictive", ".yml") + writeUserProfile(t, dir, "my-custom", ".yml") + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + summaries, err := registry.ListProfiles() + require.NoError(t, err) + + byKey := map[string]ProfileSummary{} + for _, s := range summaries { + byKey[string(s.Source)+":"+s.Name] = s + } + + bi, ok := byKey["builtin:npm-restrictive"] + require.True(t, ok) + assert.False(t, bi.Shadowed) + + shadow, ok := byKey["user:npm-restrictive"] + require.True(t, ok) + assert.True(t, shadow.Shadowed) + + custom, ok := byKey["user:my-custom"] + require.True(t, ok) + assert.False(t, custom.Shadowed) + }) + + t.Run("broken yaml user file", func(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "broken.yml"), []byte("not: [valid"), 0o644)) + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + summaries, err := registry.ListProfiles() + require.NoError(t, err) + + var broken *ProfileSummary + for i := range summaries { + if summaries[i].Name == "broken" { + broken = &summaries[i] + break + } + } + require.NotNil(t, broken) + assert.Equal(t, ProfileSourceUser, broken.Source) + assert.Empty(t, broken.Description) + }) +} + +func TestGetProfileResolutionOrder(t *testing.T) { + t.Run("builtin only", func(t *testing.T) { + dir := t.TempDir() + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + policy, err := registry.GetProfile("npm-restrictive") + require.NoError(t, err) + assert.Equal(t, "npm-restrictive", policy.Name) + }) + + t.Run("user only by bare name", func(t *testing.T) { + dir := t.TempDir() + writeUserProfile(t, dir, "my-user-profile", ".yml") + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + policy, err := registry.GetProfile("my-user-profile") + require.NoError(t, err) + assert.Equal(t, "my-user-profile", policy.Name) + assert.Equal(t, "user my-user-profile", policy.Description) + }) + + t.Run("user only by bare name with yaml extension", func(t *testing.T) { + dir := t.TempDir() + writeUserProfile(t, dir, "yaml-ext", ".yaml") + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + policy, err := registry.GetProfile("yaml-ext") + require.NoError(t, err) + assert.Equal(t, "yaml-ext", policy.Name) + }) + + t.Run("builtin wins when both exist", func(t *testing.T) { + dir := t.TempDir() + // Write a user profile whose description is unique so we can tell + // which one was resolved. + policy := &SandboxPolicy{ + Name: "npm-restrictive", + Description: "USER VERSION", + PackageManagers: []string{"npm"}, + Filesystem: FilesystemPolicy{ + AllowRead: []string{"/tmp"}, + AllowWrite: []string{"/tmp"}, + DenyRead: []string{"/private/var"}, + DenyWrite: []string{"/private/var"}, + }, + } + data, err := yaml.Marshal(policy) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, "npm-restrictive.yml"), data, 0o644)) + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + resolved, err := registry.GetProfile("npm-restrictive") + require.NoError(t, err) + assert.Equal(t, "npm-restrictive", resolved.Name) + assert.NotEqual(t, "USER VERSION", resolved.Description, "builtin should win over user file of same name") + }) + + t.Run("unknown name returns error", func(t *testing.T) { + dir := t.TempDir() + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + _, err = registry.GetProfile("no-such-profile") + assert.Error(t, err) + }) + + t.Run("bare user profile lookup cannot escape user dir", func(t *testing.T) { + root := t.TempDir() + userDir := filepath.Join(root, "profiles") + require.NoError(t, os.Mkdir(userDir, 0o755)) + outsidePath := writeUserProfile(t, root, "outside", ".yml") + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(userDir)) + require.NoError(t, err) + + _, err = registry.GetProfile("../outside") + require.Error(t, err) + + policy, err := registry.GetProfile(outsidePath) + require.NoError(t, err) + assert.Equal(t, "outside", policy.Name) + }) +} diff --git a/sandbox/resolve.go b/sandbox/resolve.go new file mode 100644 index 0000000..665630f --- /dev/null +++ b/sandbox/resolve.go @@ -0,0 +1,104 @@ +package sandbox + +import ( + "fmt" + + "github.com/safedep/dry/utils" + "github.com/safedep/pmg/sandbox/util" +) + +// ResolveProfile loads name via the registry, resolves inheritance, and +// returns a SandboxPolicy with all path-bearing fields expanded against opts +// (or process env where opts fields are empty). The returned policy is a deep +// copy — the registry-cached policy is never mutated. +func (r *defaultProfileRegistry) ResolveProfile(name string, opts ResolveOptions) (*SandboxPolicy, error) { + policy, err := r.GetProfile(name) + if err != nil { + return nil, err + } + + resolved, err := expandPolicyPaths(policy, opts) + if err != nil { + return nil, fmt.Errorf("failed to expand variables for profile %s: %w", name, err) + } + + return resolved, nil +} + +func expandPolicyPaths(p *SandboxPolicy, opts ResolveOptions) (*SandboxPolicy, error) { + // Shallow struct copy aliases *bool pointers with the registry-cached + // policy. Re-point them so callers mutating the result can't corrupt the + // cache. Slice fields below are rebuilt fresh so they're already isolated. + out := *p + if p.AllowGitConfig != nil { + out.AllowGitConfig = utils.PtrTo(*p.AllowGitConfig) + } + if p.AllowPTY != nil { + out.AllowPTY = utils.PtrTo(*p.AllowPTY) + } + if p.AllowNetworkBind != nil { + out.AllowNetworkBind = utils.PtrTo(*p.AllowNetworkBind) + } + + allowRead, err := expandSlice(p.Filesystem.AllowRead, opts) + if err != nil { + return nil, err + } + allowWrite, err := expandSlice(p.Filesystem.AllowWrite, opts) + if err != nil { + return nil, err + } + denyRead, err := expandSlice(p.Filesystem.DenyRead, opts) + if err != nil { + return nil, err + } + denyWrite, err := expandSlice(p.Filesystem.DenyWrite, opts) + if err != nil { + return nil, err + } + out.Filesystem = FilesystemPolicy{ + AllowRead: allowRead, + AllowWrite: allowWrite, + DenyRead: denyRead, + DenyWrite: denyWrite, + } + + allowExec, err := expandSlice(p.Process.AllowExec, opts) + if err != nil { + return nil, err + } + denyExec, err := expandSlice(p.Process.DenyExec, opts) + if err != nil { + return nil, err + } + out.Process = ProcessPolicy{AllowExec: allowExec, DenyExec: denyExec} + + // Network entries are host:port and don't carry path variables, but the + // slices are still deep-copied so the caller can safely mutate the result. + out.Network = NetworkPolicy{ + AllowOutbound: append([]string(nil), p.Network.AllowOutbound...), + DenyOutbound: append([]string(nil), p.Network.DenyOutbound...), + AllowBind: append([]string(nil), p.Network.AllowBind...), + } + + out.PackageManagers = append([]string(nil), p.PackageManagers...) + + return &out, nil +} + +func expandSlice(in []string, opts ResolveOptions) ([]string, error) { + if in == nil { + return nil, nil + } + + out := make([]string, len(in)) + for i, p := range in { + exp, err := util.ExpandVariablesWith(p, opts.CWD, opts.Home, opts.TmpDir) + if err != nil { + return nil, err + } + out[i] = exp + } + + return out, nil +} diff --git a/sandbox/resolve_test.go b/sandbox/resolve_test.go new file mode 100644 index 0000000..03bb2b4 --- /dev/null +++ b/sandbox/resolve_test.go @@ -0,0 +1,122 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveProfileOverrides(t *testing.T) { + dir := t.TempDir() + writeUserProfile(t, dir, "my-profile", ".yml") + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + // Use built-in profile that has ${HOME}/${CWD} references + resolved, err := registry.ResolveProfile("npm-restrictive", ResolveOptions{ + CWD: "/custom/cwd", + Home: "/custom/home", + }) + require.NoError(t, err) + + hasCustomHome := false + hasCustomCwd := false + for _, p := range resolved.Filesystem.AllowRead { + if strings.Contains(p, "/custom/home") { + hasCustomHome = true + } + if strings.Contains(p, "/custom/cwd") { + hasCustomCwd = true + } + assert.NotContains(t, p, "${HOME}") + assert.NotContains(t, p, "${CWD}") + } + assert.True(t, hasCustomHome) + assert.True(t, hasCustomCwd) +} + +func TestResolveProfileProcessEnvFallback(t *testing.T) { + registry, err := newDefaultProfileRegistry() + require.NoError(t, err) + + home, err := os.UserHomeDir() + require.NoError(t, err) + + resolved, err := registry.ResolveProfile("npm-restrictive", ResolveOptions{}) + require.NoError(t, err) + + found := false + for _, p := range resolved.Filesystem.AllowRead { + if strings.HasPrefix(p, home) { + found = true + break + } + } + assert.True(t, found, "expected at least one AllowRead path under process home dir") +} + +func TestResolveProfileInheritance(t *testing.T) { + dir := t.TempDir() + child := `name: child-profile +description: inherits npm-restrictive +inherits: npm-restrictive +package_managers: + - npm +filesystem: + allow_read: + - ${CWD}/extra +` + require.NoError(t, os.WriteFile(filepath.Join(dir, "child-profile.yml"), []byte(child), 0o644)) + + registry, err := newDefaultProfileRegistry(WithUserProfileDir(dir)) + require.NoError(t, err) + + resolved, err := registry.ResolveProfile("child-profile", ResolveOptions{ + CWD: "/work", + }) + require.NoError(t, err) + + found := false + for _, p := range resolved.Filesystem.AllowRead { + if p == "/work/extra" { + found = true + } + assert.NotContains(t, p, "${CWD}") + } + assert.True(t, found, "expected child rule /work/extra in resolved policy") + + // Inherited parent rules should also be present. + hasInherited := false + for _, p := range resolved.Filesystem.AllowRead { + if p == "/usr" || strings.HasPrefix(p, "/usr/") { + hasInherited = true + break + } + } + assert.True(t, hasInherited, "expected inherited builtin AllowRead rule") +} + +func TestResolveProfileDoesNotMutateRegistry(t *testing.T) { + registry, err := newDefaultProfileRegistry() + require.NoError(t, err) + + before, err := registry.GetProfile("npm-restrictive") + require.NoError(t, err) + + originalAllowRead := append([]string(nil), before.Filesystem.AllowRead...) + + _, err = registry.ResolveProfile("npm-restrictive", ResolveOptions{ + CWD: "/x", + Home: "/y", + }) + require.NoError(t, err) + + after, err := registry.GetProfile("npm-restrictive") + require.NoError(t, err) + assert.Equal(t, originalAllowRead, after.Filesystem.AllowRead, "registry profile must not be mutated") +} diff --git a/sandbox/sandbox.go b/sandbox/sandbox.go index 7bf1317..8467ebe 100644 --- a/sandbox/sandbox.go +++ b/sandbox/sandbox.go @@ -5,6 +5,16 @@ import ( "os/exec" ) +// DriverName identifies a sandbox driver implementation. Returned by +// Sandbox.Name() and used wherever code needs to refer to a specific driver. +type DriverName string + +const ( + DriverSeatbelt DriverName = "seatbelt" + DriverBubblewrap DriverName = "bubblewrap" + DriverLandlock DriverName = "landlock" +) + // ViolationKind is PMG's normalized taxonomy for sandbox denials. type ViolationKind string @@ -21,7 +31,7 @@ const ( // ViolationReport is a best-effort sandbox violation summary collected from a // sandbox implementation after command execution fails. type ViolationReport struct { - SandboxName string + SandboxName DriverName PolicyName string CorrelationID string Violations []Violation @@ -128,8 +138,8 @@ type Sandbox interface { // Callers must check result.ShouldRun() and only call cmd.Run() if true. Execute(ctx context.Context, cmd *exec.Cmd, policy *SandboxPolicy) (*ExecutionResult, error) - // Name returns the sandbox implementation name (e.g., "seatbelt", "bubblewrap"). - Name() string + // Name returns the sandbox driver identifier. + Name() DriverName // IsAvailable returns true if the sandbox is available and functional on this platform. IsAvailable() bool @@ -139,20 +149,93 @@ type Sandbox interface { Close() error } +// ProfileInfo describes a user profile discovered on disk. +type ProfileInfo struct { + // Name is the profile name, derived from the file name without extension. + Name string + + // Path is the absolute path to the profile file. + Path string + + // Shadowed is true when a built-in profile of the same name exists and + // will win during name-based resolution. + Shadowed bool +} + +// ProfileSource identifies where a profile came from. +type ProfileSource string + +const ( + ProfileSourceBuiltin ProfileSource = "builtin" + ProfileSourceUser ProfileSource = "user" +) + +// ProfileSummary describes a discoverable profile for listing purposes. +type ProfileSummary struct { + Name string + Source ProfileSource + Path string // "" for builtins; absolute path for user profiles + Inherits string + PackageManagers []string + Description string + Shadowed bool // true when a user file is masked by a same-named builtin +} + +// ResolveOptions tunes variable expansion when resolving a policy for display +// or diffing. Zero values mean "use the current process environment". +type ResolveOptions struct { + CWD string + Home string + TmpDir string +} + // ProfileRegistry manages built-in and custom sandbox policies. type ProfileRegistry interface { // GetProfile retrieves a policy by name. - // Name can be a built-in profile (e.g., "npm-restrictive") or a path to a custom YAML file. + // Name can be a built-in profile (e.g., "npm-restrictive"), the bare name of a + // user profile under UserProfileDir(), or a path to a custom YAML file. + // Resolution order: built-ins first, then the user profile directory. GetProfile(name string) (*SandboxPolicy, error) // LoadCustomProfile loads a policy from a custom YAML file path. LoadCustomProfile(path string) (*SandboxPolicy, error) - // ListProfiles returns the names of all built-in profiles. - ListProfiles() []string + // ListProfiles returns all discoverable profiles: built-ins first, then + // user profiles (including shadowed entries). + ListProfiles() ([]ProfileSummary, error) + + // ResolveProfile loads name and returns a policy with all path-bearing + // fields expanded against opts (or the process environment). + ResolveProfile(name string, opts ResolveOptions) (*SandboxPolicy, error) + + // UserProfileDir returns the directory scanned for user profiles. + UserProfileDir() string + + // ListUserProfiles enumerates *.yml / *.yaml files under UserProfileDir(). + // A missing directory returns an empty slice with no error. + ListUserProfiles() ([]ProfileInfo, error) + + // BuiltinProfileYAML returns the embedded YAML bytes for a built-in + // profile. Returns false if name is not a built-in. + BuiltinProfileYAML(name string) ([]byte, bool) +} + +// RegistryOption configures a ProfileRegistry. +type RegistryOption func(*registryOptions) + +type registryOptions struct { + userProfileDir string +} + +// WithUserProfileDir sets the directory the registry uses to discover user +// profiles. The directory does not need to exist at construction time. +func WithUserProfileDir(dir string) RegistryOption { + return func(o *registryOptions) { + o.userProfileDir = dir + } } // NewProfileRegistry creates a new profile registry with built-in policies. -func NewProfileRegistry() (ProfileRegistry, error) { - return newDefaultProfileRegistry() +func NewProfileRegistry(opts ...RegistryOption) (ProfileRegistry, error) { + return newDefaultProfileRegistry(opts...) } diff --git a/sandbox/util/tmpdir_test.go b/sandbox/util/tmpdir_test.go index 5c3d72c..0eccbaa 100644 --- a/sandbox/util/tmpdir_test.go +++ b/sandbox/util/tmpdir_test.go @@ -5,15 +5,18 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestGetTmpdirParent(t *testing.T) { // Save original TMPDIR originalTmpdir := os.Getenv("TMPDIR") - defer os.Setenv("TMPDIR", originalTmpdir) + defer func() { + require.NoError(t, os.Setenv("TMPDIR", originalTmpdir)) + }() t.Run("macOS pattern with /var prefix", func(t *testing.T) { - os.Setenv("TMPDIR", "/var/folders/ab/cd1234ef/T/") + require.NoError(t, os.Setenv("TMPDIR", "/var/folders/ab/cd1234ef/T/")) parents := GetTmpdirParent() assert.Len(t, parents, 2) @@ -22,7 +25,7 @@ func TestGetTmpdirParent(t *testing.T) { }) t.Run("macOS pattern with /private/var prefix", func(t *testing.T) { - os.Setenv("TMPDIR", "/private/var/folders/xy/z9876543/T/") + require.NoError(t, os.Setenv("TMPDIR", "/private/var/folders/xy/z9876543/T/")) parents := GetTmpdirParent() assert.Len(t, parents, 2) @@ -31,7 +34,7 @@ func TestGetTmpdirParent(t *testing.T) { }) t.Run("macOS pattern without trailing slash", func(t *testing.T) { - os.Setenv("TMPDIR", "/var/folders/12/abcdefgh/T") + require.NoError(t, os.Setenv("TMPDIR", "/var/folders/12/abcdefgh/T")) parents := GetTmpdirParent() assert.Len(t, parents, 2) @@ -50,20 +53,20 @@ func TestGetTmpdirParent(t *testing.T) { } for _, tmpdir := range testCases { - os.Setenv("TMPDIR", tmpdir) + require.NoError(t, os.Setenv("TMPDIR", tmpdir)) parents := GetTmpdirParent() assert.Empty(t, parents, "Expected empty result for TMPDIR=%s", tmpdir) } }) t.Run("empty TMPDIR returns empty", func(t *testing.T) { - os.Setenv("TMPDIR", "") + require.NoError(t, os.Setenv("TMPDIR", "")) parents := GetTmpdirParent() assert.Empty(t, parents) }) t.Run("unset TMPDIR returns empty", func(t *testing.T) { - os.Unsetenv("TMPDIR") + require.NoError(t, os.Unsetenv("TMPDIR")) parents := GetTmpdirParent() assert.Empty(t, parents) }) diff --git a/sandbox/util/variables.go b/sandbox/util/variables.go index 9bd03fa..b3088ed 100644 --- a/sandbox/util/variables.go +++ b/sandbox/util/variables.go @@ -6,42 +6,65 @@ import ( "strings" ) -// ExpandVariables expands known variables in a path or pattern. -// Supported variables: -// - ${HOME}: User home directory -// - ${CWD}: Current working directory -// - ${TMPDIR}: Temporary directory +// Variable names recognised by ExpandVariablesWith. SupportedVariables is the +// authoritative list — callers that need to validate variable usage (e.g. the +// linter) should consume it rather than maintaining their own copy. +const ( + VarHome = "${HOME}" + VarCWD = "${CWD}" + VarTMPDir = "${TMPDIR}" +) + +var SupportedVariables = []string{VarHome, VarCWD, VarTMPDir} + +// IsSupportedVariable reports whether tok is one of the variables that +// ExpandVariablesWith will expand. +func IsSupportedVariable(tok string) bool { + for _, v := range SupportedVariables { + if v == tok { + return true + } + } + return false +} + +// ExpandVariables expands known variables in a path or pattern using process +// environment values. See ExpandVariablesWith for supported variables. func ExpandVariables(pattern string) (string, error) { - result := pattern + return ExpandVariablesWith(pattern, "", "", "") +} - // Get home directory - home, err := os.UserHomeDir() - if err != nil { - return "", err +// ExpandVariablesWith expands known variables in a path or pattern. Any of +// cwd, home, tmpDir left empty falls back to the corresponding process value. +// The set of recognised tokens is SupportedVariables. +func ExpandVariablesWith(pattern, cwd, home, tmpDir string) (string, error) { + if home == "" { + h, err := os.UserHomeDir() + if err != nil { + return "", err + } + home = h } - // Get current working directory - cwd, err := os.Getwd() - if err != nil { - return "", err + if cwd == "" { + c, err := os.Getwd() + if err != nil { + return "", err + } + cwd = c } - // Get temp directory - tmpDir := os.TempDir() + if tmpDir == "" { + tmpDir = os.TempDir() + } - // Replace variables replacer := strings.NewReplacer( - "${HOME}", home, - "${CWD}", cwd, - "${TMPDIR}", tmpDir, + VarHome, home, + VarCWD, cwd, + VarTMPDir, tmpDir, ) - result = replacer.Replace(result) - - // Clean up path (resolve .., ., etc.) - result = filepath.Clean(result) - - return result, nil + return filepath.Clean(replacer.Replace(pattern)), nil } // ContainsGlob returns true if the pattern contains glob wildcards. diff --git a/sandbox/util/variables_test.go b/sandbox/util/variables_test.go index e160c5c..d778b0a 100644 --- a/sandbox/util/variables_test.go +++ b/sandbox/util/variables_test.go @@ -258,6 +258,44 @@ func TestContainsGlob(t *testing.T) { } } +func TestIsSupportedVariable(t *testing.T) { + tests := []struct { + name string + token string + expected bool + }{ + {"HOME is supported", "${HOME}", true}, + {"CWD is supported", "${CWD}", true}, + {"TMPDIR is supported", "${TMPDIR}", true}, + {"unknown variable", "${USER}", false}, + {"empty token", "", false}, + {"bare name without braces", "HOME", false}, + {"missing closing brace", "${HOME", false}, + {"missing opening brace", "HOME}", false}, + {"case mismatch", "${home}", false}, + {"embedded variable in path is not a bare token", "${HOME}/foo", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, IsSupportedVariable(tt.token)) + }) + } +} + +func TestSupportedVariablesCoversReplacer(t *testing.T) { + // Guard against drift: every entry in SupportedVariables must actually be + // expanded by ExpandVariablesWith, otherwise the linter and expander would + // disagree about what is "known". + for _, v := range SupportedVariables { + t.Run(v, func(t *testing.T) { + out, err := ExpandVariablesWith(v, "/cwd", "/home", "/tmp") + assert.NoError(t, err) + assert.NotEqual(t, v, out, "variable %q was not expanded", v) + }) + } +} + func TestExpandVariablesWithVariableReplacement(t *testing.T) { tests := []struct { name string diff --git a/sandbox/violation.go b/sandbox/violation.go new file mode 100644 index 0000000..7e25978 --- /dev/null +++ b/sandbox/violation.go @@ -0,0 +1,199 @@ +package sandbox + +import ( + "os" + "path/filepath" + "strings" +) + +// Explanation is structured data extracted from a ViolationReport. It carries +// only domain facts — the human-readable rendering and any CLI-specific +// suggestion strings (flag names, line layout) belong to the presentation +// layer (see internal/ui). +type Explanation struct { + // Primary is the violation most likely to be actionable, chosen by + // scoreViolation. Nil when the report contains no violations. + Primary *Violation + + // Override carries a structured override hint for the primary violation + // (kind + target) when one can be safely suggested. Nil when no safe + // suggestion is available (glob targets, control characters, unsupported + // violation kinds). The presentation layer decides how to render this as a + // CLI flag, an API hint, etc. + Override *OverrideSuggestion + + // AdditionalDenials counts violations beyond Primary so callers can show + // "+N more" without re-walking the report. + AdditionalDenials int +} + +// OverrideSuggestion is the structured form of a "you can re-run with this +// allowance" hint. Kind tells the consumer which permission would unblock the +// operation; Target is the path/exec the user would whitelist. +type OverrideSuggestion struct { + Kind ViolationKind + Target string +} + +// BuildExplanation produces an Explanation for the given report. +func BuildExplanation(report *ViolationReport) Explanation { + primary := primaryViolation(report) + exp := Explanation{Primary: primary} + if primary != nil { + exp.Override = overrideSuggestion(*primary) + if report != nil && len(report.Violations) > 1 { + exp.AdditionalDenials = len(report.Violations) - 1 + } + } + return exp +} + +func overrideSuggestion(v Violation) *OverrideSuggestion { + if !isSafeOverrideTarget(v.Target) { + return nil + } + + switch v.Kind { + case ViolationKindFSRead, + ViolationKindFSWrite, + ViolationKindFSDeleteOrRename, + ViolationKindExec: + return &OverrideSuggestion{Kind: v.Kind, Target: v.Target} + default: + return nil + } +} + +func primaryViolation(report *ViolationReport) *Violation { + if report == nil || len(report.Violations) == 0 { + return nil + } + + cwd, _ := os.Getwd() + bestIdx := 0 + bestScore := scoreViolation(report.SandboxName, report.Violations[0], cwd) + + for i := 1; i < len(report.Violations); i++ { + score := scoreViolation(report.SandboxName, report.Violations[i], cwd) + if score >= bestScore { + bestIdx = i + bestScore = score + } + } + + return &report.Violations[bestIdx] +} + +func scoreViolation(driver DriverName, v Violation, cwd string) int { + score := 0 + + switch v.Kind { + case ViolationKindFSRead, ViolationKindFSWrite: + score += 120 + case ViolationKindExec: + score += 110 + case ViolationKindFSDeleteOrRename: + score += 100 + case ViolationKindGenericDeny: + score += 10 + default: + score += 30 + } + + if isSafeOverrideTarget(v.Target) { + score += 40 + } + + if v.Target != "" && v.Target != v.RuleTarget { + score += 20 + } + + if isProjectPath(v.Target, cwd) { + score += 80 + } + + if isSensitiveProjectFile(v.Target) { + score += 60 + } + + if isNoisySystemPath(driver, v.Target) { + score -= 120 + } + + if v.Kind == ViolationKindGenericDeny && v.Target == "" { + score -= 40 + } + + return score +} + +func isSafeOverrideTarget(value string) bool { + if value == "" { + return false + } + + if strings.ContainsAny(value, "*?[]") { + return false + } + + for _, r := range value { + if r == 0 || r < 0x20 || r == 0x7f { + return false + } + } + + return true +} + +func isProjectPath(target, cwd string) bool { + if target == "" || cwd == "" { + return false + } + + if strings.HasPrefix(target, ".") { + return !isParentRelativePath(target) + } + + cleanTarget := filepath.Clean(target) + cleanCwd := filepath.Clean(cwd) + + return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator)) +} + +func isSensitiveProjectFile(target string) bool { + if target == "" || isParentRelativePath(target) { + return false + } + + base := filepath.Base(target) + switch { + case strings.HasPrefix(base, ".env"): + return true + case base == ".npmrc", base == ".pypirc", base == ".netrc": + return true + case base == ".aws", base == ".ssh", base == ".kube", base == ".gnupg": + return true + default: + return strings.Contains(target, string(filepath.Separator)+".ssh") || + strings.Contains(target, string(filepath.Separator)+".aws") || + strings.Contains(target, string(filepath.Separator)+".kube") + } +} + +func isParentRelativePath(target string) bool { + cleanTarget := filepath.Clean(target) + return cleanTarget == ".." || strings.HasPrefix(cleanTarget, ".."+string(filepath.Separator)) +} + +func isNoisySystemPath(driver DriverName, target string) bool { + if driver != DriverSeatbelt { + return false + } + + switch target { + case "/dev/dtracehelper", "/dev/tty": + return true + default: + return strings.HasPrefix(target, "/dev/ttys") + } +} diff --git a/sandbox/violation_cache.go b/sandbox/violation_cache.go new file mode 100644 index 0000000..6dde937 --- /dev/null +++ b/sandbox/violation_cache.go @@ -0,0 +1,242 @@ +package sandbox + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// ViolationCacheSchemaVersion is bumped when the on-disk JSON layout changes incompatibly. +const ViolationCacheSchemaVersion = 1 + +// DefaultViolationCacheRetention is the default number of reports retained in the cache. +const DefaultViolationCacheRetention = 10 + +const ( + violationCacheFilePrefix = "violation-" + violationCacheFileSuffix = ".json" +) + +// ViolationCacheRecord is the on-disk representation of a ViolationReport. +type ViolationCacheRecord struct { + SchemaVersion int `json:"schema_version"` + RecordedAt time.Time `json:"recorded_at"` + Report *ViolationReport `json:"report"` +} + +// ViolationCacheEntry is a cache entry returned by readers. +type ViolationCacheEntry struct { + Path string + Record ViolationCacheRecord +} + +// ViolationCache writes and reads violation reports under dir. +type ViolationCache struct { + dir string + retention int + now func() time.Time +} + +// ViolationCacheOption customizes a ViolationCache. +type ViolationCacheOption func(*ViolationCache) + +// WithRetention overrides the default retention. +func WithRetention(n int) ViolationCacheOption { + return func(c *ViolationCache) { + if n > 0 { + c.retention = n + } + } +} + +// WithClock injects a clock for tests. +func WithClock(now func() time.Time) ViolationCacheOption { + return func(c *ViolationCache) { + if now != nil { + c.now = now + } + } +} + +// NewViolationCache returns a ViolationCache rooted at dir. The directory is +// created lazily on Write. +func NewViolationCache(dir string, opts ...ViolationCacheOption) *ViolationCache { + c := &ViolationCache{ + dir: dir, + retention: DefaultViolationCacheRetention, + now: time.Now, + } + + for _, opt := range opts { + opt(c) + } + + return c +} + +// Write persists report to the cache and prunes older entries beyond +// retention. Returns the path of the written file. +func (c *ViolationCache) Write(report *ViolationReport) (string, error) { + if report == nil { + return "", errors.New("violationcache: nil report") + } + + if c.dir == "" { + return "", errors.New("violationcache: empty cache directory") + } + + if err := os.MkdirAll(c.dir, 0o755); err != nil { + return "", fmt.Errorf("violationcache: create dir: %w", err) + } + + ts := c.now().UTC() + id, err := violationCacheShortID() + if err != nil { + return "", fmt.Errorf("violationcache: generate id: %w", err) + } + + name := fmt.Sprintf("%s%s-%s%s", violationCacheFilePrefix, ts.Format("20060102T150405.000000000Z"), id, violationCacheFileSuffix) + path := filepath.Join(c.dir, name) + + rec := ViolationCacheRecord{ + SchemaVersion: ViolationCacheSchemaVersion, + RecordedAt: ts, + Report: report, + } + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + return "", fmt.Errorf("violationcache: marshal: %w", err) + } + + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", fmt.Errorf("violationcache: write: %w", err) + } + + if err := c.prune(); err != nil { + return path, fmt.Errorf("violationcache: prune: %w", err) + } + + return path, nil +} + +// List returns entries newest first. Corrupt or unreadable files are skipped. +func (c *ViolationCache) List() ([]ViolationCacheEntry, error) { + if c.dir == "" { + return nil, errors.New("violationcache: empty cache directory") + } + + dirents, err := os.ReadDir(c.dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + + return nil, fmt.Errorf("violationcache: read dir: %w", err) + } + + files := violationCacheMatchingFiles(dirents) + violationCacheSortFilesNewestFirst(files) + + entries := make([]ViolationCacheEntry, 0, len(files)) + for _, name := range files { + path := filepath.Join(c.dir, name) + + data, err := os.ReadFile(path) + if err != nil { + continue + } + + var rec ViolationCacheRecord + if err := json.Unmarshal(data, &rec); err != nil { + continue + } + if rec.SchemaVersion != ViolationCacheSchemaVersion { + continue + } + + entries = append(entries, ViolationCacheEntry{Path: path, Record: rec}) + } + + return entries, nil +} + +// Latest returns the most recent entry, or nil if the cache is empty. +func (c *ViolationCache) Latest() (*ViolationCacheEntry, error) { + entries, err := c.List() + if err != nil { + return nil, err + } + + if len(entries) == 0 { + return nil, nil + } + + e := entries[0] + return &e, nil +} + +func (c *ViolationCache) prune() error { + dirents, err := os.ReadDir(c.dir) + if err != nil { + return err + } + + files := violationCacheMatchingFiles(dirents) + if len(files) <= c.retention { + return nil + } + + violationCacheSortFilesNewestFirst(files) + + var firstErr error + for _, name := range files[c.retention:] { + if err := os.Remove(filepath.Join(c.dir, name)); err != nil && firstErr == nil { + firstErr = err + } + } + + return firstErr +} + +func violationCacheMatchingFiles(dirents []os.DirEntry) []string { + files := make([]string, 0, len(dirents)) + for _, d := range dirents { + if d.IsDir() { + continue + } + + name := d.Name() + if len(name) <= len(violationCacheFilePrefix)+len(violationCacheFileSuffix) { + continue + } + + if !strings.HasPrefix(name, violationCacheFilePrefix) || !strings.HasSuffix(name, violationCacheFileSuffix) { + continue + } + + files = append(files, name) + } + + return files +} + +func violationCacheSortFilesNewestFirst(files []string) { + sort.Slice(files, func(i, j int) bool { return files[i] > files[j] }) +} + +func violationCacheShortID() (string, error) { + var buf [4]byte + if _, err := rand.Read(buf[:]); err != nil { + return "", err + } + + return hex.EncodeToString(buf[:]), nil +} diff --git a/sandbox/violation_cache_test.go b/sandbox/violation_cache_test.go new file mode 100644 index 0000000..a70e656 --- /dev/null +++ b/sandbox/violation_cache_test.go @@ -0,0 +1,169 @@ +package sandbox + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func sampleCacheReport(label string) *ViolationReport { + return &ViolationReport{ + SandboxName: "seatbelt", + PolicyName: "npm-restrictive", + CorrelationID: "corr-1", + Violations: []Violation{ + {Kind: ViolationKindFSRead, Target: "/tmp/x", RuleLabel: label}, + }, + } +} + +func newFixedClock(start time.Time) func() time.Time { + t := start + return func() time.Time { + t = t.Add(time.Millisecond) + return t + } +} + +func TestViolationCacheWriteAndList(t *testing.T) { + dir := t.TempDir() + c := NewViolationCache(dir, WithClock(newFixedClock(time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC)))) + + path, err := c.Write(sampleCacheReport("rule-a")) + require.NoError(t, err) + assert.FileExists(t, path) + + entries, err := c.List() + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, ViolationCacheSchemaVersion, entries[0].Record.SchemaVersion) + assert.Equal(t, "rule-a", entries[0].Record.Report.Violations[0].RuleLabel) + + latest, err := c.Latest() + require.NoError(t, err) + require.NotNil(t, latest) + assert.Equal(t, path, latest.Path) +} + +func TestViolationCacheRotation(t *testing.T) { + dir := t.TempDir() + c := NewViolationCache(dir, + WithRetention(10), + WithClock(newFixedClock(time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC))), + ) + + for i := 0; i < 13; i++ { + _, err := c.Write(sampleCacheReport("rule")) + require.NoError(t, err) + } + + entries, err := c.List() + require.NoError(t, err) + assert.Len(t, entries, 10) + + dirents, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Len(t, dirents, 10) + + latest, err := c.Latest() + require.NoError(t, err) + require.NotNil(t, latest) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, filepath.Base(e.Path)) + } + assert.Equal(t, filepath.Base(latest.Path), names[0]) +} + +func TestViolationCacheSerializedSchemaVersionPresent(t *testing.T) { + dir := t.TempDir() + c := NewViolationCache(dir) + + path, err := c.Write(sampleCacheReport("rule")) + require.NoError(t, err) + + data, err := os.ReadFile(path) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(data, &raw)) + assert.EqualValues(t, ViolationCacheSchemaVersion, raw["schema_version"]) +} + +func TestViolationCacheCorruptFileSkipped(t *testing.T) { + dir := t.TempDir() + c := NewViolationCache(dir) + + _, err := c.Write(sampleCacheReport("good")) + require.NoError(t, err) + + bad := filepath.Join(dir, "violation-bad.json") + require.NoError(t, os.WriteFile(bad, []byte("{not json"), 0o644)) + + entries, err := c.List() + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "good", entries[0].Record.Report.Violations[0].RuleLabel) +} + +func TestViolationCacheWrongSchemaVersionSkipped(t *testing.T) { + dir := t.TempDir() + c := NewViolationCache(dir) + + valid := ViolationCacheRecord{ + SchemaVersion: ViolationCacheSchemaVersion, + RecordedAt: time.Date(2026, 5, 14, 10, 0, 0, 0, time.UTC), + Report: sampleCacheReport("valid"), + } + writeViolationCacheRecord(t, dir, "violation-20260514T100000.000000000Z-valid.json", valid) + + missingVersion := valid + missingVersion.SchemaVersion = 0 + missingVersion.Report = sampleCacheReport("missing-version") + writeViolationCacheRecord(t, dir, "violation-20260514T100001.000000000Z-missing.json", missingVersion) + + futureVersion := valid + futureVersion.SchemaVersion = ViolationCacheSchemaVersion + 1 + futureVersion.Report = sampleCacheReport("future-version") + writeViolationCacheRecord(t, dir, "violation-20260514T100002.000000000Z-future.json", futureVersion) + + entries, err := c.List() + require.NoError(t, err) + require.Len(t, entries, 1) + assert.Equal(t, "valid", entries[0].Record.Report.Violations[0].RuleLabel) + + latest, err := c.Latest() + require.NoError(t, err) + require.NotNil(t, latest) + assert.Equal(t, "valid", latest.Record.Report.Violations[0].RuleLabel) +} + +func TestViolationCacheWriteNilReport(t *testing.T) { + c := NewViolationCache(t.TempDir()) + _, err := c.Write(nil) + assert.Error(t, err) +} + +func TestViolationCacheListEmptyMissingDir(t *testing.T) { + c := NewViolationCache(filepath.Join(t.TempDir(), "missing")) + entries, err := c.List() + require.NoError(t, err) + assert.Empty(t, entries) + + latest, err := c.Latest() + require.NoError(t, err) + assert.Nil(t, latest) +} + +func writeViolationCacheRecord(t *testing.T, dir, name string, rec ViolationCacheRecord) { + t.Helper() + + data, err := json.Marshal(rec) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), data, 0o644)) +} diff --git a/sandbox/violation_test.go b/sandbox/violation_test.go new file mode 100644 index 0000000..b9ebc34 --- /dev/null +++ b/sandbox/violation_test.go @@ -0,0 +1,186 @@ +package sandbox + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOverrideSuggestionSkipsGlobRuleTarget(t *testing.T) { + assert.Nil(t, overrideSuggestion(Violation{ + Kind: ViolationKindFSRead, + Target: "**/.env", + })) +} + +func TestOverrideSuggestionUsesConcretePath(t *testing.T) { + o := overrideSuggestion(Violation{ + Kind: ViolationKindFSRead, + Target: "./.env", + }) + require.NotNil(t, o) + assert.Equal(t, ViolationKindFSRead, o.Kind) + assert.Equal(t, "./.env", o.Target) +} + +func TestOverrideSuggestionPreservesRawTargetCharacters(t *testing.T) { + // Shell escaping is the presentation layer's job — the domain layer + // returns the raw target verbatim, special characters included. + o := overrideSuggestion(Violation{ + Kind: ViolationKindFSRead, + Target: "/tmp/My Dir/it's.env", + }) + require.NotNil(t, o) + assert.Equal(t, "/tmp/My Dir/it's.env", o.Target) +} + +func TestOverrideSuggestionSkipsControlCharacters(t *testing.T) { + assert.Nil(t, overrideSuggestion(Violation{ + Kind: ViolationKindFSRead, + Target: "/tmp/bad\npath", + })) +} + +func TestOverrideSuggestionMapsAllSupportedKinds(t *testing.T) { + tests := []struct { + kind ViolationKind + want bool + }{ + {ViolationKindFSRead, true}, + {ViolationKindFSWrite, true}, + {ViolationKindFSDeleteOrRename, true}, + {ViolationKindExec, true}, + {ViolationKindGenericDeny, false}, + } + for _, tt := range tests { + t.Run(string(tt.kind), func(t *testing.T) { + o := overrideSuggestion(Violation{Kind: tt.kind, Target: "/tmp/x"}) + if tt.want { + require.NotNil(t, o) + assert.Equal(t, tt.kind, o.Kind) + } else { + assert.Nil(t, o) + } + }) + } +} + +func TestPrimaryViolationPrefersConcreteProjectPathOverDefaultNoise(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + report := &ViolationReport{ + SandboxName: DriverSeatbelt, + Violations: []Violation{ + { + Kind: ViolationKindGenericDeny, + RawKind: "default", + Target: "/dev/dtracehelper", + RuleLabel: "sandbox denied access to /dev/dtracehelper", + }, + { + Kind: ViolationKindFSRead, + RawKind: "file-read", + Target: filepath.Join(cwd, ".env"), + RuleTarget: "**/.env", + RuleLabel: "read access denied: " + filepath.Join(cwd, ".env"), + }, + }, + } + + primary := primaryViolation(report) + require.NotNil(t, primary) + assert.Equal(t, ViolationKindFSRead, primary.Kind) + assert.Equal(t, filepath.Join(cwd, ".env"), primary.Target) +} + +func TestPrimaryViolationPrefersLaterViolationOnScoreTie(t *testing.T) { + report := &ViolationReport{ + SandboxName: DriverSeatbelt, + Violations: []Violation{ + { + Kind: ViolationKindExec, + Target: "/tmp/first-bin", + RuleLabel: "exec denied: /tmp/first-bin", + }, + { + Kind: ViolationKindExec, + Target: "/tmp/second-bin", + RuleLabel: "exec denied: /tmp/second-bin", + }, + }, + } + + primary := primaryViolation(report) + require.NotNil(t, primary) + assert.Equal(t, "/tmp/second-bin", primary.Target) +} + +func TestIsProjectPathRejectsParentRelativeTargets(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + tests := []struct { + name string + target string + want bool + }{ + {name: "dot", target: ".", want: true}, + {name: "dot slash", target: "./.env", want: true}, + {name: "dotfile", target: ".env", want: true}, + {name: "parent", target: "..", want: false}, + {name: "parent slash", target: "../.env", want: false}, + {name: "nested parent", target: "../../etc/passwd", want: false}, + {name: "dot slash parent", target: "./../../etc/passwd", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isProjectPath(tt.target, cwd)) + }) + } +} + +func TestIsSensitiveProjectFileRejectsParentRelativeTargets(t *testing.T) { + assert.False(t, isSensitiveProjectFile("../.env")) + assert.False(t, isSensitiveProjectFile("../../.ssh/config")) + assert.True(t, isSensitiveProjectFile("./.env")) + assert.True(t, isSensitiveProjectFile("./.ssh/config")) +} + +func TestBuildExplanationStructuredOutput(t *testing.T) { + exp := BuildExplanation(&ViolationReport{ + SandboxName: "seatbelt", + PolicyName: "npm-restrictive", + CorrelationID: "run-1", + Violations: []Violation{ + { + Kind: ViolationKindFSRead, + Target: "./.env", + RuleLabel: "read access denied: ./.env", + }, + { + Kind: ViolationKindFSWrite, + Target: "./out.log", + RuleLabel: "write access denied: ./out.log", + }, + }, + }) + + require.NotNil(t, exp.Primary) + assert.Equal(t, ViolationKindFSRead, exp.Primary.Kind) + require.NotNil(t, exp.Override) + assert.Equal(t, ViolationKindFSRead, exp.Override.Kind) + assert.Equal(t, "./.env", exp.Override.Target) + assert.Equal(t, 1, exp.AdditionalDenials) +} + +func TestBuildExplanationEmptyReport(t *testing.T) { + exp := BuildExplanation(&ViolationReport{}) + assert.Nil(t, exp.Primary) + assert.Nil(t, exp.Override) + assert.Equal(t, 0, exp.AdditionalDenials) +}