mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
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
This commit is contained in:
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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):]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <a> <b>",
|
||||
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
|
||||
}
|
||||
@@ -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 <a> <b> [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())
|
||||
}
|
||||
@@ -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 <name>",
|
||||
Short: "Scaffold a new user sandbox profile",
|
||||
Long: "Create a starter YAML profile under the user profile directory.\n\n" +
|
||||
"Prefer --from <builtin> 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
|
||||
}
|
||||
@@ -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 <name> [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)
|
||||
}
|
||||
}
|
||||
@@ -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 <path|name>",
|
||||
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)
|
||||
}
|
||||
@@ -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 <path|name> [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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 <name>",
|
||||
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.",
|
||||
)
|
||||
}
|
||||
@@ -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 <name> [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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+1
-1
@@ -157,7 +157,7 @@ func resolveSandboxDriverName() string {
|
||||
return "unavailable"
|
||||
}
|
||||
|
||||
return sb.Name()
|
||||
return string(sb.Name())
|
||||
}
|
||||
|
||||
// describeCloudCredentials reports whether SafeDep Cloud credentials can be
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
@@ -179,6 +188,8 @@ type RuntimeConfig struct {
|
||||
configDir string
|
||||
configFilePath string
|
||||
eventLogDir string
|
||||
sandboxProfileDir string
|
||||
sandboxViolationCacheDir string
|
||||
viper *viper.Viper
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+6
-1
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
-2
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -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{}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+38
-205
@@ -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,
|
||||
))
|
||||
}
|
||||
|
||||
func buildSandboxHint(report *sandbox.ViolationReport) string {
|
||||
first := primarySandboxViolation(report)
|
||||
if first == nil {
|
||||
return "Reason: sandbox denied an operation"
|
||||
return builder.Wrap(err)
|
||||
}
|
||||
|
||||
hint := fmt.Sprintf("Reason: %s", first.RuleLabel)
|
||||
|
||||
if override := suggestSandboxOverride(*first); override != "" {
|
||||
hint = fmt.Sprintf("%s. Override: %s", hint, override)
|
||||
// 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
|
||||
}
|
||||
|
||||
return hint
|
||||
report, diagErr := result.BestEffortViolation(runErr)
|
||||
if diagErr != nil {
|
||||
log.Warnf("failed to collect sandbox diagnostics: %v", diagErr)
|
||||
return 0
|
||||
}
|
||||
|
||||
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]
|
||||
dir := cfg.SandboxViolationCacheDir()
|
||||
if dir == "" {
|
||||
return len(report.Violations)
|
||||
}
|
||||
|
||||
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 _, err := sandbox.NewViolationCache(dir).Write(report); err != nil {
|
||||
log.Warnf("failed to persist sandbox violation report: %v", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
if v.Kind == sandbox.ViolationKindGenericDeny && v.Target == "" {
|
||||
score -= 40
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
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",
|
||||
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{
|
||||
@@ -48,66 +51,35 @@ func TestBuildSandboxDetailsIncludesMatchedRule(t *testing.T) {
|
||||
RawKind: "file-read",
|
||||
Target: "./.env",
|
||||
RuleTarget: "**/.env",
|
||||
Process: "node",
|
||||
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)
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
primary := primarySandboxViolation(report)
|
||||
if assert.NotNil(t, primary) {
|
||||
assert.Equal(t, sandbox.ViolationKindFSRead, primary.Kind)
|
||||
assert.Equal(t, filepath.Join(cwd, ".env"), primary.Target)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
+211
@@ -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) + "]"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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++ {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_<random>) 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
+110
@@ -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
|
||||
+265
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+213
-9
@@ -5,9 +5,11 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/safedep/dry/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -17,11 +19,22 @@ var profilesFS embed.FS
|
||||
type defaultProfileRegistry struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]*SandboxPolicy
|
||||
builtins map[string]struct{}
|
||||
builtinYAML map[string][]byte
|
||||
userProfileDir string
|
||||
}
|
||||
|
||||
func newDefaultProfileRegistry(opts ...RegistryOption) (*defaultProfileRegistry, error) {
|
||||
options := ®istryOptions{}
|
||||
for _, opt := range opts {
|
||||
opt(options)
|
||||
}
|
||||
|
||||
func newDefaultProfileRegistry() (*defaultProfileRegistry, error) {
|
||||
registry := &defaultProfileRegistry{
|
||||
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 <name>.yml or <name>.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 `<name>.yml` then `<name>.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) {
|
||||
|
||||
@@ -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 `<name>.<ext>` 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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+91
-8
@@ -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...)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
+55
-32
@@ -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
|
||||
func ExpandVariables(pattern string) (string, error) {
|
||||
result := pattern
|
||||
|
||||
// Get home directory
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get current working directory
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Get temp directory
|
||||
tmpDir := os.TempDir()
|
||||
|
||||
// Replace variables
|
||||
replacer := strings.NewReplacer(
|
||||
"${HOME}", home,
|
||||
"${CWD}", cwd,
|
||||
"${TMPDIR}", tmpDir,
|
||||
// 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}"
|
||||
)
|
||||
|
||||
result = replacer.Replace(result)
|
||||
var SupportedVariables = []string{VarHome, VarCWD, VarTMPDir}
|
||||
|
||||
// Clean up path (resolve .., ., etc.)
|
||||
result = filepath.Clean(result)
|
||||
// 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
|
||||
}
|
||||
|
||||
return result, nil
|
||||
// ExpandVariables expands known variables in a path or pattern using process
|
||||
// environment values. See ExpandVariablesWith for supported variables.
|
||||
func ExpandVariables(pattern string) (string, error) {
|
||||
return ExpandVariablesWith(pattern, "", "", "")
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
if cwd == "" {
|
||||
c, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cwd = c
|
||||
}
|
||||
|
||||
if tmpDir == "" {
|
||||
tmpDir = os.TempDir()
|
||||
}
|
||||
|
||||
replacer := strings.NewReplacer(
|
||||
VarHome, home,
|
||||
VarCWD, cwd,
|
||||
VarTMPDir, tmpDir,
|
||||
)
|
||||
|
||||
return filepath.Clean(replacer.Replace(pattern)), nil
|
||||
}
|
||||
|
||||
// ContainsGlob returns true if the pattern contains glob wildcards.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user