mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
feat: Add support for per-project sandbox overlays (#294)
* feat: Add support for per-project sandbox overlays * chore: Maintain consistency with TUI experience * fix: Code review fixes
This commit is contained in:
@@ -0,0 +1,267 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/usefulerror"
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/errcodes"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowFactory bundles the dependencies needed by `pmg sandbox allow`. Tests
|
||||||
|
// inject stubs. Production wiring uses defaultAllowFactory.
|
||||||
|
type allowFactory struct {
|
||||||
|
overlayDir func() string
|
||||||
|
repoRoot func() (string, error)
|
||||||
|
cache func() *pmgsandbox.ViolationCache
|
||||||
|
locked func() bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultAllowFactory() allowFactory {
|
||||||
|
return allowFactory{
|
||||||
|
overlayDir: func() string { return config.Get().SandboxOverlayDir() },
|
||||||
|
repoRoot: resolveCurrentRepoRoot,
|
||||||
|
cache: func() *pmgsandbox.ViolationCache {
|
||||||
|
return pmgsandbox.NewViolationCache(config.Get().SandboxViolationCacheDir())
|
||||||
|
},
|
||||||
|
locked: func() bool { return config.Get().IsLocked() },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAllowCommand returns the `pmg sandbox allow` command.
|
||||||
|
func NewAllowCommand() *cobra.Command {
|
||||||
|
return newAllowCommand(defaultAllowFactory())
|
||||||
|
}
|
||||||
|
|
||||||
|
type allowOptions struct {
|
||||||
|
last bool
|
||||||
|
all bool
|
||||||
|
force bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAllowCommand(factory allowFactory) *cobra.Command {
|
||||||
|
opts := &allowOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "allow [type=value …]",
|
||||||
|
Short: "Persist sandbox allowances for the current repository",
|
||||||
|
Long: "Save allowances into the current repo's sandbox project overlay so future PMG runs in this repo apply them automatically.\n\n" +
|
||||||
|
"Use --last to promote the primary violation from the most recent cached report,\n" +
|
||||||
|
"or --last --all to promote every safe FS/exec violation from that report.\n" +
|
||||||
|
"Manual entries (type=value …) accept any allow type and persist as-is.",
|
||||||
|
Example: " pmg sandbox allow write=./.astro net-bind=localhost:4321\n" +
|
||||||
|
" pmg sandbox allow --last --all",
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
SilenceErrors: false,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := runAllow(cmd.OutOrStdout(), args, opts, factory); err != nil {
|
||||||
|
return sandboxErrorExit(cmd, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.last, "last", false, "Promote allowances from the most recent cached violation report")
|
||||||
|
cmd.Flags().BoolVar(&opts.all, "all", false, "With --last: promote every safe FS/exec violation (default: primary only)")
|
||||||
|
cmd.Flags().BoolVar(&opts.force, "force", false, "Allow saving entries that touch sensitive paths (.env, .npmrc, .ssh, ...)")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAllow(out io.Writer, args []string, opts *allowOptions, factory allowFactory) error {
|
||||||
|
// ApplySandbox ignores overlays under global_lockdown, so refuse up-front
|
||||||
|
// instead of letting the user think their allowances took effect.
|
||||||
|
if factory.locked != nil && factory.locked() {
|
||||||
|
return usefulerror.NewUsefulError().
|
||||||
|
WithCode(errcodes.PermissionDenied).
|
||||||
|
WithHumanError("sandbox overlays are disabled while global_lockdown is in force").
|
||||||
|
WithHelp("This machine's PMG configuration is locked. Contact your administrator to change sandbox policy.").
|
||||||
|
Wrap(errors.New("sandbox overlay refused under global_lockdown"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.last && len(args) == 0 {
|
||||||
|
return invalidArgumentError(
|
||||||
|
"nothing to save: pass type=value arguments or --last",
|
||||||
|
"Example: `pmg sandbox allow write=./.astro` or `pmg sandbox allow --last --all`.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if opts.all && !opts.last {
|
||||||
|
return invalidArgumentError(
|
||||||
|
"--all requires --last",
|
||||||
|
"Use `pmg sandbox allow --last --all` to promote every FS/exec violation from the most recent cached report.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, err := factory.repoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return wrapUseful(fmt.Errorf("resolve repo root: %w", err),
|
||||||
|
errcodes.Unknown,
|
||||||
|
"Could not determine the current repository root. Ensure the working directory is accessible.")
|
||||||
|
}
|
||||||
|
if repoRoot == "" {
|
||||||
|
return invalidArgumentError(
|
||||||
|
"could not determine current repository root",
|
||||||
|
"Change to a directory inside the repository, then retry.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayDir := factory.overlayDir()
|
||||||
|
if overlayDir == "" {
|
||||||
|
return invalidArgumentError(
|
||||||
|
"sandbox overlay directory is not configured",
|
||||||
|
"Ensure the PMG config directory is writable, then retry.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(overlayDir, repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return wrapUseful(err, errcodes.Unknown,
|
||||||
|
"Could not read the existing project overlay. Check the file under SandboxOverlayDir().")
|
||||||
|
}
|
||||||
|
if overlay == nil {
|
||||||
|
overlay = &pmgsandbox.Overlay{}
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := collectAllowEntries(args, opts, factory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(pending) == 0 {
|
||||||
|
return invalidArgumentError(
|
||||||
|
"no eligible allowances to save",
|
||||||
|
"Run a sandboxed command first to populate the violation cache, or pass explicit type=value arguments.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := guardSensitiveEntries(pending, opts.force); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
addedEntries := make([]pmgsandbox.OverlayAllow, 0, len(pending))
|
||||||
|
for _, entry := range pending {
|
||||||
|
if overlay.Add(entry) {
|
||||||
|
addedEntries = append(addedEntries, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(addedEntries) == 0 {
|
||||||
|
_, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No new allowances (%d already present).", len(pending))))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := pmgsandbox.SaveOverlay(overlayDir, repoRoot, overlay)
|
||||||
|
if err != nil {
|
||||||
|
return wrapUseful(err, errcodes.Unknown,
|
||||||
|
"Could not write the project overlay file. Check filesystem permissions for the overlay directory.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := fmt.Fprintf(out, "%s Saved %d allowance(s) for %s\n", ui.Colors.Green("✓"), len(addedEntries), repoRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, e := range addedEntries {
|
||||||
|
if _, err := fmt.Fprintf(out, " %s %s=%s\n", ui.Colors.Dim("•"), e.Type, e.Value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(out, " %s %s\n", ui.Colors.Dim("overlay:"), ui.Colors.Dim(path))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectAllowEntries(args []string, opts *allowOptions, factory allowFactory) ([]pmgsandbox.OverlayAllow, error) {
|
||||||
|
out := make([]pmgsandbox.OverlayAllow, 0, len(args)+1)
|
||||||
|
|
||||||
|
for _, raw := range args {
|
||||||
|
override, err := config.ParseSingleOverride(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, invalidArgumentError(
|
||||||
|
err.Error(),
|
||||||
|
"Each positional argument must be `type=value` (read, write, exec, net-connect, net-bind).",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
out = append(out, pmgsandbox.OverlayAllow{Type: override.Type, Value: override.Value})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.last {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestions, err := suggestionsFromCache(factory.cache(), opts.all)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, sugg := range suggestions {
|
||||||
|
typ := overrideTypeForKind(sugg.Kind)
|
||||||
|
if typ == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Normalize through the manual-entry validator so stored values match
|
||||||
|
// how applyRuntimeOverrides resolves them against the policy. Skip on
|
||||||
|
// rejection so one bad target does not block the rest of the report.
|
||||||
|
normalized, err := config.ParseSingleOverride(fmt.Sprintf("%s=%s", typ, sugg.Target))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, pmgsandbox.OverlayAllow{Type: normalized.Type, Value: normalized.Value})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestionsFromCache loads the latest cached violation report and returns
|
||||||
|
// the override suggestions to promote. When all is true, every safe FS/exec
|
||||||
|
// suggestion is returned, otherwise just the primary one (if any).
|
||||||
|
func suggestionsFromCache(cache *pmgsandbox.ViolationCache, all bool) ([]pmgsandbox.OverrideSuggestion, error) {
|
||||||
|
entry, err := cache.Latest()
|
||||||
|
if err != nil {
|
||||||
|
return nil, wrapUseful(err, errcodes.Unknown,
|
||||||
|
"Could not read the sandbox violation cache. Check the cache directory and retry.")
|
||||||
|
}
|
||||||
|
if entry == nil || entry.Record.Report == nil {
|
||||||
|
return nil, notFoundError(
|
||||||
|
"no cached violation report",
|
||||||
|
"Run a sandboxed command that hits a denial first, then retry `pmg sandbox allow --last`.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if all {
|
||||||
|
return pmgsandbox.BuildAllOverrides(entry.Record.Report), nil
|
||||||
|
}
|
||||||
|
if override := pmgsandbox.BuildExplanation(entry.Record.Report).Override; override != nil {
|
||||||
|
return []pmgsandbox.OverrideSuggestion{*override}, nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// overrideTypeForKind maps a ViolationKind to the matching SandboxAllowType.
|
||||||
|
// Only FS + exec are handled; network kinds are not classified by drivers and
|
||||||
|
// will never reach this function via BuildAllOverrides.
|
||||||
|
func overrideTypeForKind(kind pmgsandbox.ViolationKind) config.SandboxAllowType {
|
||||||
|
switch kind {
|
||||||
|
case pmgsandbox.ViolationKindFSRead:
|
||||||
|
return config.SandboxAllowRead
|
||||||
|
case pmgsandbox.ViolationKindFSWrite, pmgsandbox.ViolationKindFSDeleteOrRename:
|
||||||
|
return config.SandboxAllowWrite
|
||||||
|
case pmgsandbox.ViolationKindExec:
|
||||||
|
return config.SandboxAllowExec
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func guardSensitiveEntries(entries []pmgsandbox.OverlayAllow, force bool) error {
|
||||||
|
if force {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if pmgsandbox.IsSensitiveProjectTarget(e.Value) {
|
||||||
|
return usefulerror.NewUsefulError().
|
||||||
|
WithCode(errcodes.PermissionDenied).
|
||||||
|
WithHumanError(fmt.Sprintf("refusing to allow sensitive target: %s", e.Value)).
|
||||||
|
WithHelp("Re-run with --force to allow saving this entry, after verifying the path is intentional.").
|
||||||
|
Wrap(errors.New("sensitive target"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type allowDeps struct {
|
||||||
|
overlayDir string
|
||||||
|
repoRoot string
|
||||||
|
cache *pmgsandbox.ViolationCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAllowDeps(t *testing.T) *allowDeps {
|
||||||
|
t.Helper()
|
||||||
|
return &allowDeps{
|
||||||
|
overlayDir: t.TempDir(),
|
||||||
|
repoRoot: filepath.Clean(t.TempDir()),
|
||||||
|
cache: pmgsandbox.NewViolationCache(t.TempDir()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAllowCmd(t *testing.T, deps *allowDeps, args ...string) (string, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := newAllowCommand(allowFactory{
|
||||||
|
overlayDir: func() string { return deps.overlayDir },
|
||||||
|
repoRoot: func() (string, error) { return deps.repoRoot, nil },
|
||||||
|
cache: func() *pmgsandbox.ViolationCache { return deps.cache },
|
||||||
|
})
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return stdout.String(), stderr.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_PositionalSaved(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
stdout, stderr, err := runAllowCmd(t, deps, "net-bind=localhost:4321")
|
||||||
|
require.NoError(t, err, "stderr: %s", stderr)
|
||||||
|
assert.Contains(t, stdout, "Saved 1 allowance")
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, overlay)
|
||||||
|
require.Len(t, overlay.Allow, 1)
|
||||||
|
assert.Equal(t, config.SandboxAllowNetBind, overlay.Allow[0].Type)
|
||||||
|
assert.Equal(t, "localhost:4321", overlay.Allow[0].Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_LastAllPromotesFromCache(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
writeTarget := filepath.Join(deps.repoRoot, ".astro")
|
||||||
|
execTarget := "/usr/bin/sh"
|
||||||
|
report := &pmgsandbox.ViolationReport{
|
||||||
|
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||||
|
Violations: []pmgsandbox.Violation{
|
||||||
|
{Kind: pmgsandbox.ViolationKindFSWrite, Target: writeTarget},
|
||||||
|
{Kind: pmgsandbox.ViolationKindExec, Target: execTarget},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := deps.cache.Write(report)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, stderr, err := runAllowCmd(t, deps, "--last", "--all")
|
||||||
|
require.NoError(t, err, "stderr: %s", stderr)
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, overlay)
|
||||||
|
assert.ElementsMatch(t, []pmgsandbox.OverlayAllow{
|
||||||
|
{Type: config.SandboxAllowWrite, Value: writeTarget},
|
||||||
|
{Type: config.SandboxAllowExec, Value: execTarget},
|
||||||
|
}, overlay.Allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_LastPromotesPrimaryOnly(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
writeTarget := filepath.Join(deps.repoRoot, ".astro")
|
||||||
|
execTarget := "/usr/bin/sh"
|
||||||
|
report := &pmgsandbox.ViolationReport{
|
||||||
|
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||||
|
Violations: []pmgsandbox.Violation{
|
||||||
|
{Kind: pmgsandbox.ViolationKindFSWrite, Target: writeTarget},
|
||||||
|
{Kind: pmgsandbox.ViolationKindExec, Target: execTarget},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := deps.cache.Write(report)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, _, err = runAllowCmd(t, deps, "--last")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, overlay)
|
||||||
|
require.Len(t, overlay.Allow, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_LastNoCacheErrors(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
_, _, err := runAllowCmd(t, deps, "--last")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_RefusesSensitiveTargetWithoutForce(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
secret := filepath.Join(deps.repoRoot, ".env")
|
||||||
|
_, _, err := runAllowCmd(t, deps, "read="+secret)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "sensitive target")
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, overlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_SensitiveTargetWithForceSaves(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
secret := filepath.Join(deps.repoRoot, ".env")
|
||||||
|
_, _, err := runAllowCmd(t, deps, "--force", "read="+secret)
|
||||||
|
require.NoError(t, err)
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, overlay)
|
||||||
|
assert.Len(t, overlay.Allow, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_Dedupes(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
_, _, err := runAllowCmd(t, deps, "exec=/usr/bin/sh")
|
||||||
|
require.NoError(t, err)
|
||||||
|
stdout, _, err := runAllowCmd(t, deps, "exec=/usr/bin/sh")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, stdout, "No new allowances")
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, overlay)
|
||||||
|
assert.Len(t, overlay.Allow, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_NothingToSaveErrors(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
_, _, err := runAllowCmd(t, deps)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_AllWithoutLastErrors(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
_, _, err := runAllowCmd(t, deps, "--all", "exec=/usr/bin/sh")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_InvalidPositionalRejected(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
_, _, err := runAllowCmd(t, deps, "garbage")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_RefusedUnderLockdown(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
cmd := newAllowCommand(allowFactory{
|
||||||
|
overlayDir: func() string { return deps.overlayDir },
|
||||||
|
repoRoot: func() (string, error) { return deps.repoRoot, nil },
|
||||||
|
cache: func() *pmgsandbox.ViolationCache { return deps.cache },
|
||||||
|
locked: func() bool { return true },
|
||||||
|
})
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
cmd.SetArgs([]string{"exec=/usr/bin/sh"})
|
||||||
|
err := cmd.Execute()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "global_lockdown")
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, overlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAllow_LastNormalizesRelativeTargets(t *testing.T) {
|
||||||
|
deps := newAllowDeps(t)
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
relTarget := "./.astro"
|
||||||
|
wantAbs := filepath.Join(cwd, ".astro")
|
||||||
|
|
||||||
|
report := &pmgsandbox.ViolationReport{
|
||||||
|
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||||
|
Violations: []pmgsandbox.Violation{
|
||||||
|
{Kind: pmgsandbox.ViolationKindFSWrite, Target: relTarget},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err = deps.cache.Write(report)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, _, err = runAllowCmd(t, deps, "--last")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir, deps.repoRoot)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, overlay)
|
||||||
|
require.Len(t, overlay.Allow, 1)
|
||||||
|
assert.Equal(t, wantAbs, overlay.Allow[0].Value, "cache-derived target should be normalized to absolute")
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// projectDeps is the dependency surface for `pmg sandbox project` subcommands.
|
||||||
|
type projectDeps struct {
|
||||||
|
overlayDir func() string
|
||||||
|
repoRoot func() (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProjectDeps() projectDeps {
|
||||||
|
return projectDeps{
|
||||||
|
overlayDir: func() string { return config.Get().SandboxOverlayDir() },
|
||||||
|
repoRoot: resolveCurrentRepoRoot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCurrentRepoRoot returns the git toplevel for the current working
|
||||||
|
// directory, falling back to the cwd itself when not inside a git work tree.
|
||||||
|
func resolveCurrentRepoRoot() (string, error) {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return pmgsandbox.ResolveRepoRoot(cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderProjectSection prints the standard PMG section header
|
||||||
|
// (blank line, cyan title, dashed separator).
|
||||||
|
func renderProjectSection(out io.Writer, title string) error {
|
||||||
|
if _, err := fmt.Fprintln(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(out, ui.Colors.Cyan(title)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := fmt.Fprintln(out, ui.Colors.Normal("--------------------"))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderKeyValueBlock prints aligned "Key: value" pairs with bold keys. Empty
|
||||||
|
// values are skipped so optional fields do not produce empty lines.
|
||||||
|
func renderKeyValueBlock(out io.Writer, entries [][2]string) error {
|
||||||
|
width := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e[1] != "" && len(e[0]) > width {
|
||||||
|
width = len(e[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e[1] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
label := fmt.Sprintf("%-*s", width+1, e[0]+":")
|
||||||
|
if _, err := fmt.Fprintf(out, "%s %s\n", ui.Colors.Bold(label), e[1]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProjectCommand returns the `pmg sandbox project` parent command.
|
||||||
|
func NewProjectCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "project",
|
||||||
|
Short: "Inspect and manage the current repository's sandbox overlay",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
deps := defaultProjectDeps()
|
||||||
|
cmd.AddCommand(newProjectShowCommand(deps))
|
||||||
|
cmd.AddCommand(newProjectResetCommand(deps))
|
||||||
|
cmd.AddCommand(newProjectListCommand(deps))
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/errcodes"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectListOptions struct {
|
||||||
|
jsonOut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProjectListCommand(deps projectDeps) *cobra.Command {
|
||||||
|
opts := &projectListOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all known sandbox project overlays",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
SilenceErrors: false,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := runProjectList(cmd.OutOrStdout(), cmd.ErrOrStderr(), opts, deps); err != nil {
|
||||||
|
return sandboxErrorExit(cmd, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit entries as JSON")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectListJSONEntry struct {
|
||||||
|
RepoRoot string `json:"repo_root"`
|
||||||
|
Entries int `json:"entries"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectListJSONOutput struct {
|
||||||
|
Entries []projectListJSONEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runProjectList(out, _ io.Writer, opts *projectListOptions, deps projectDeps) error {
|
||||||
|
overlays, err := pmgsandbox.ListOverlays(deps.overlayDir())
|
||||||
|
if err != nil {
|
||||||
|
return wrapUseful(err, errcodes.Unknown, "Could not list project overlays.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.jsonOut {
|
||||||
|
payload := projectListJSONOutput{Entries: make([]projectListJSONEntry, 0, len(overlays))}
|
||||||
|
for _, e := range overlays {
|
||||||
|
item := projectListJSONEntry{
|
||||||
|
RepoRoot: e.Overlay.RepoRoot,
|
||||||
|
Entries: len(e.Overlay.Allow),
|
||||||
|
Path: e.Path,
|
||||||
|
}
|
||||||
|
if !e.Overlay.UpdatedAt.IsZero() {
|
||||||
|
item.UpdatedAt = e.Overlay.UpdatedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
payload.Entries = append(payload.Entries, item)
|
||||||
|
}
|
||||||
|
return writeJSONIndent(out, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := renderProjectSection(out, "Project Overlays"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(overlays) == 0 {
|
||||||
|
_, err := fmt.Fprintln(out, ui.Colors.Dim("No project overlays."))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := [][]string{{
|
||||||
|
ui.Colors.Bold("REPO"),
|
||||||
|
ui.Colors.Bold("ENTRIES"),
|
||||||
|
ui.Colors.Bold("UPDATED"),
|
||||||
|
}}
|
||||||
|
dash := ui.Colors.Dim("—")
|
||||||
|
for _, e := range overlays {
|
||||||
|
updated := dash
|
||||||
|
if !e.Overlay.UpdatedAt.IsZero() {
|
||||||
|
updated = e.Overlay.UpdatedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
rows = append(rows, []string{e.Overlay.RepoRoot, fmt.Sprintf("%d", len(e.Overlay.Allow)), updated})
|
||||||
|
}
|
||||||
|
return renderTable(out, rows, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func execProjectList(t *testing.T, overlayDir string, args ...string) (string, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := newProjectListCommand(projectDeps{
|
||||||
|
overlayDir: func() string { return overlayDir },
|
||||||
|
})
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return stdout.String(), stderr.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectList_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
stdout, _, err := execProjectList(t, dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, stdout, "No project overlays")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectList_Human(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, repo := range []string{"/repo/a", "/repo/b"} {
|
||||||
|
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||||
|
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, _, err := execProjectList(t, dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, stdout, "/repo/a")
|
||||||
|
assert.Contains(t, stdout, "/repo/b")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectList_JSON(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, repo := range []string{"/repo/a", "/repo/b"} {
|
||||||
|
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||||
|
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, _, err := execProjectList(t, dir, "--json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var payload struct {
|
||||||
|
Entries []struct {
|
||||||
|
RepoRoot string `json:"repo_root"`
|
||||||
|
Entries int `json:"entries"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
} `json:"entries"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
|
||||||
|
require.Len(t, payload.Entries, 2)
|
||||||
|
assert.Equal(t, "/repo/a", payload.Entries[0].RepoRoot)
|
||||||
|
assert.Equal(t, 1, payload.Entries[0].Entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectList_JSONEmpty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
stdout, _, err := execProjectList(t, dir, "--json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var payload struct {
|
||||||
|
Entries []any `json:"entries"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
|
||||||
|
assert.NotNil(t, payload.Entries)
|
||||||
|
assert.Empty(t, payload.Entries)
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/errcodes"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectResetOptions struct {
|
||||||
|
yes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProjectResetCommand(deps projectDeps) *cobra.Command {
|
||||||
|
opts := &projectResetOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "reset",
|
||||||
|
Short: "Delete the sandbox overlay for the current repository",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
SilenceErrors: false,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := runProjectReset(cmd.OutOrStdout(), opts, deps); err != nil {
|
||||||
|
return sandboxErrorExit(cmd, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.yes, "yes", false, "Confirm deletion without an interactive prompt")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runProjectReset(out io.Writer, opts *projectResetOptions, deps projectDeps) error {
|
||||||
|
if !opts.yes {
|
||||||
|
return invalidArgumentError(
|
||||||
|
"refusing to reset without --yes",
|
||||||
|
"Re-run with `pmg sandbox project reset --yes` to delete the overlay.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoRoot, err := deps.repoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return wrapUseful(err, errcodes.Unknown, "Could not determine the current repository root.")
|
||||||
|
}
|
||||||
|
overlay, path, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir(), repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return wrapUseful(err, errcodes.Unknown, "Could not read the project overlay file.")
|
||||||
|
}
|
||||||
|
if overlay == nil {
|
||||||
|
_, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No project overlay for %s", repoRoot)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := pmgsandbox.DeleteOverlayForRepo(deps.overlayDir(), repoRoot); err != nil {
|
||||||
|
return wrapUseful(err, errcodes.Unknown, "Could not delete the project overlay file.")
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(out, "%s Deleted overlay for %s\n", ui.Colors.Green("✓"), repoRoot); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(out, " %s\n", ui.Colors.Dim(path))
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func execProjectReset(t *testing.T, overlayDir, repoRoot string, args ...string) (string, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := newProjectResetCommand(projectDeps{
|
||||||
|
overlayDir: func() string { return overlayDir },
|
||||||
|
repoRoot: func() (string, error) { return repoRoot, nil },
|
||||||
|
})
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return stdout.String(), stderr.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectReset_RequiresYes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := filepath.Clean(t.TempDir())
|
||||||
|
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||||
|
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/x"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, _, err = execProjectReset(t, dir, repo)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(dir, repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, overlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectReset_WithYesDeletes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := filepath.Clean(t.TempDir())
|
||||||
|
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||||
|
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/x"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stdout, _, err := execProjectReset(t, dir, repo, "--yes")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, stdout, repo)
|
||||||
|
assert.Contains(t, stdout, "Deleted")
|
||||||
|
|
||||||
|
overlay, _, err := pmgsandbox.LoadOverlayForRepo(dir, repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, overlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectReset_MissingIsNoop(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := filepath.Clean(t.TempDir())
|
||||||
|
stdout, _, err := execProjectReset(t, dir, repo, "--yes")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, stdout, "No project overlay")
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/errcodes"
|
||||||
|
"github.com/safedep/pmg/internal/ui"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type projectShowOptions struct {
|
||||||
|
jsonOut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProjectShowCommand(deps projectDeps) *cobra.Command {
|
||||||
|
opts := &projectShowOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "show",
|
||||||
|
Short: "Show the saved sandbox overlay for the current repository",
|
||||||
|
SilenceErrors: false,
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := runProjectShow(cmd.OutOrStdout(), opts, deps); err != nil {
|
||||||
|
return sandboxErrorExit(cmd, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&opts.jsonOut, "json", false, "Emit overlay as JSON")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectShowJSONAllow struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectShowJSONPayload struct {
|
||||||
|
RepoRoot string `json:"repo_root"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
UpdatedAt string `json:"updated_at,omitempty"`
|
||||||
|
Allow []projectShowJSONAllow `json:"allow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func runProjectShow(out io.Writer, opts *projectShowOptions, deps projectDeps) error {
|
||||||
|
repoRoot, err := deps.repoRoot()
|
||||||
|
if err != nil {
|
||||||
|
return wrapUseful(err, errcodes.Unknown, "Could not determine the current repository root.")
|
||||||
|
}
|
||||||
|
overlay, path, err := pmgsandbox.LoadOverlayForRepo(deps.overlayDir(), repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return wrapUseful(err, errcodes.Unknown, "Could not read the project overlay file.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.jsonOut {
|
||||||
|
payload := projectShowJSONPayload{RepoRoot: repoRoot, Path: path, Allow: []projectShowJSONAllow{}}
|
||||||
|
if overlay != nil {
|
||||||
|
for _, a := range overlay.Allow {
|
||||||
|
payload.Allow = append(payload.Allow, projectShowJSONAllow{Type: string(a.Type), Value: a.Value})
|
||||||
|
}
|
||||||
|
if !overlay.UpdatedAt.IsZero() {
|
||||||
|
payload.UpdatedAt = overlay.UpdatedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return writeJSONIndent(out, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := renderProjectSection(out, "Project Overlay"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if overlay == nil || len(overlay.Allow) == 0 {
|
||||||
|
_, err := fmt.Fprintf(out, "%s\n", ui.Colors.Dim(fmt.Sprintf("No project overlay for %s", repoRoot)))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := ""
|
||||||
|
if !overlay.UpdatedAt.IsZero() {
|
||||||
|
updated = overlay.UpdatedAt.UTC().Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
if err := renderKeyValueBlock(out, [][2]string{
|
||||||
|
{"Repo", repoRoot},
|
||||||
|
{"Overlay", path},
|
||||||
|
{"Updated", updated},
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := make([][]string, 0, len(overlay.Allow)+1)
|
||||||
|
rows = append(rows, []string{
|
||||||
|
ui.Colors.Bold("TYPE"),
|
||||||
|
ui.Colors.Bold("VALUE"),
|
||||||
|
})
|
||||||
|
for _, a := range overlay.Allow {
|
||||||
|
rows = append(rows, []string{string(a.Type), a.Value})
|
||||||
|
}
|
||||||
|
return renderTable(out, rows, nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
pmgsandbox "github.com/safedep/pmg/sandbox"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func execProjectShow(t *testing.T, overlayDir, repoRoot string, args ...string) (string, string, error) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := newProjectShowCommand(projectDeps{
|
||||||
|
overlayDir: func() string { return overlayDir },
|
||||||
|
repoRoot: func() (string, error) { return repoRoot, nil },
|
||||||
|
})
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.SetOut(&stdout)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
cmd.SetArgs(args)
|
||||||
|
err := cmd.Execute()
|
||||||
|
return stdout.String(), stderr.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectShow_Empty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := filepath.Clean(t.TempDir())
|
||||||
|
stdout, _, err := execProjectShow(t, dir, repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, stdout, repo)
|
||||||
|
assert.Contains(t, stdout, "No project overlay")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectShow_WithEntries(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := filepath.Clean(t.TempDir())
|
||||||
|
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||||
|
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowWrite, Value: "/repo/.astro"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stdout, _, err := execProjectShow(t, dir, repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, stdout, repo)
|
||||||
|
assert.Contains(t, stdout, "write")
|
||||||
|
assert.Contains(t, stdout, "/repo/.astro")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectShow_JSON(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := filepath.Clean(t.TempDir())
|
||||||
|
_, err := pmgsandbox.SaveOverlay(dir, repo, &pmgsandbox.Overlay{
|
||||||
|
Allow: []pmgsandbox.OverlayAllow{{Type: config.SandboxAllowExec, Value: "/usr/bin/sh"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stdout, _, err := execProjectShow(t, dir, repo, "--json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var payload struct {
|
||||||
|
RepoRoot string `json:"repo_root"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Allow []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
} `json:"allow"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
|
||||||
|
assert.Equal(t, repo, payload.RepoRoot)
|
||||||
|
assert.NotEmpty(t, payload.Path)
|
||||||
|
require.Len(t, payload.Allow, 1)
|
||||||
|
assert.Equal(t, "exec", payload.Allow[0].Type)
|
||||||
|
assert.Equal(t, "/usr/bin/sh", payload.Allow[0].Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectShow_JSONEmpty(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := filepath.Clean(t.TempDir())
|
||||||
|
stdout, _, err := execProjectShow(t, dir, repo, "--json")
|
||||||
|
require.NoError(t, err)
|
||||||
|
var payload struct {
|
||||||
|
Allow []any `json:"allow"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(stdout), &payload))
|
||||||
|
assert.NotNil(t, payload.Allow)
|
||||||
|
assert.Empty(t, payload.Allow)
|
||||||
|
}
|
||||||
@@ -22,5 +22,7 @@ func NewCommand() *cobra.Command {
|
|||||||
cmd.AddCommand(NewProfileCommand())
|
cmd.AddCommand(NewProfileCommand())
|
||||||
cmd.AddCommand(NewExplainCommand())
|
cmd.AddCommand(NewExplainCommand())
|
||||||
cmd.AddCommand(NewViolationsCommand())
|
cmd.AddCommand(NewViolationsCommand())
|
||||||
|
cmd.AddCommand(NewAllowCommand())
|
||||||
|
cmd.AddCommand(NewProjectCommand())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ const (
|
|||||||
// Default sandbox profile directory is relative to the config directory.
|
// Default sandbox profile directory is relative to the config directory.
|
||||||
pmgDefaultSandboxProfileDir = "sandbox/profiles"
|
pmgDefaultSandboxProfileDir = "sandbox/profiles"
|
||||||
|
|
||||||
|
// Default sandbox overlay directory is relative to the config directory.
|
||||||
|
// Per-repo overlays persisted by `pmg sandbox allow` live here.
|
||||||
|
pmgDefaultSandboxOverlayDir = "sandbox/overlays"
|
||||||
|
|
||||||
// Default sandbox violation cache directory is relative to the cache root.
|
// Default sandbox violation cache directory is relative to the cache root.
|
||||||
pmgDefaultSandboxViolationCacheDir = "sandbox/violations"
|
pmgDefaultSandboxViolationCacheDir = "sandbox/violations"
|
||||||
|
|
||||||
@@ -205,6 +209,7 @@ type RuntimeConfig struct {
|
|||||||
configLocked bool // global file present and opted into lockdown (global_lockdown: true)
|
configLocked bool // global file present and opted into lockdown (global_lockdown: true)
|
||||||
eventLogDir string
|
eventLogDir string
|
||||||
sandboxProfileDir string
|
sandboxProfileDir string
|
||||||
|
sandboxOverlayDir string
|
||||||
sandboxViolationCacheDir string
|
sandboxViolationCacheDir string
|
||||||
viper *viper.Viper
|
viper *viper.Viper
|
||||||
}
|
}
|
||||||
@@ -270,6 +275,11 @@ func (r *RuntimeConfig) SandboxProfileDir() string {
|
|||||||
return r.sandboxProfileDir
|
return r.sandboxProfileDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SandboxOverlayDir returns the path to the per-repo sandbox overlay directory.
|
||||||
|
func (r *RuntimeConfig) SandboxOverlayDir() string {
|
||||||
|
return r.sandboxOverlayDir
|
||||||
|
}
|
||||||
|
|
||||||
// SandboxViolationCacheDir returns the path to the sandbox violation cache directory.
|
// SandboxViolationCacheDir returns the path to the sandbox violation cache directory.
|
||||||
func (r *RuntimeConfig) SandboxViolationCacheDir() string {
|
func (r *RuntimeConfig) SandboxViolationCacheDir() string {
|
||||||
return r.sandboxViolationCacheDir
|
return r.sandboxViolationCacheDir
|
||||||
@@ -399,11 +409,17 @@ func initConfig() {
|
|||||||
panic(fmt.Errorf("failed to get sandbox violation cache directory: %w", err))
|
panic(fmt.Errorf("failed to get sandbox violation cache directory: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sandboxOverlayDir, err := sandboxOverlayDir()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("failed to get sandbox overlay directory: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
globalConfig.configDir = configDir
|
globalConfig.configDir = configDir
|
||||||
globalConfig.configFilePath = activeConfigPath
|
globalConfig.configFilePath = activeConfigPath
|
||||||
globalConfig.userConfigFilePath = userConfigPath
|
globalConfig.userConfigFilePath = userConfigPath
|
||||||
globalConfig.eventLogDir = eventLogDir
|
globalConfig.eventLogDir = eventLogDir
|
||||||
globalConfig.sandboxProfileDir = sandboxProfileDir
|
globalConfig.sandboxProfileDir = sandboxProfileDir
|
||||||
|
globalConfig.sandboxOverlayDir = sandboxOverlayDir
|
||||||
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
|
globalConfig.sandboxViolationCacheDir = sandboxViolationCacheDir
|
||||||
|
|
||||||
// A globally managed config enforces lockdown only when it opts in via
|
// A globally managed config enforces lockdown only when it opts in via
|
||||||
@@ -581,6 +597,16 @@ func sandboxProfileDir() (string, error) {
|
|||||||
return filepath.Join(configDir, pmgDefaultSandboxProfileDir), nil
|
return filepath.Join(configDir, pmgDefaultSandboxProfileDir), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sandboxOverlayDir computes the path to the per-repo sandbox overlay directory.
|
||||||
|
func sandboxOverlayDir() (string, error) {
|
||||||
|
configDir, err := configDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get config directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(configDir, pmgDefaultSandboxOverlayDir), nil
|
||||||
|
}
|
||||||
|
|
||||||
// sandboxViolationCacheDir computes the path to the sandbox violation cache directory.
|
// sandboxViolationCacheDir computes the path to the sandbox violation cache directory.
|
||||||
func sandboxViolationCacheDir() (string, error) {
|
func sandboxViolationCacheDir() (string, error) {
|
||||||
cacheDir, err := cacheDir()
|
cacheDir, err := cacheDir()
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ func parseSandboxAllowOverrides(raw []string) ([]SandboxAllowOverride, error) {
|
|||||||
return overrides, nil
|
return overrides, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseSingleOverride is the exported entry point for callers outside this
|
||||||
|
// package (e.g. cmd handlers persisting overlay entries). It mirrors the
|
||||||
|
// validation used for --sandbox-allow flag values.
|
||||||
|
func ParseSingleOverride(raw string) (SandboxAllowOverride, error) {
|
||||||
|
return parseSingleOverride(raw)
|
||||||
|
}
|
||||||
|
|
||||||
// parseSingleOverride parses and validates a single "type=value" string.
|
// parseSingleOverride parses and validates a single "type=value" string.
|
||||||
func parseSingleOverride(raw string) (SandboxAllowOverride, error) {
|
func parseSingleOverride(raw string) (SandboxAllowOverride, error) {
|
||||||
// Split on first '=' only to handle values containing '='
|
// Split on first '=' only to handle values containing '='
|
||||||
|
|||||||
@@ -254,3 +254,16 @@ func TestParseSandboxAllowOverrides_FirstErrorStops(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "missing '=' separator")
|
assert.Contains(t, err.Error(), "missing '=' separator")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseSingleOverride_Exported(t *testing.T) {
|
||||||
|
got, err := ParseSingleOverride("net-bind=localhost:4321")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, SandboxAllowNetBind, got.Type)
|
||||||
|
assert.Equal(t, "localhost:4321", got.Value)
|
||||||
|
assert.Equal(t, "net-bind=localhost:4321", got.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSingleOverride_ExportedRejectsInvalid(t *testing.T) {
|
||||||
|
_, err := ParseSingleOverride("garbage")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,43 @@ func TestSandboxProfileDirRespectsXDGConfigHome(t *testing.T) {
|
|||||||
assert.Equal(t, expected, Get().SandboxProfileDir())
|
assert.Equal(t, expected, Get().SandboxProfileDir())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSandboxOverlayDir(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, pmgDefaultSandboxOverlayDir)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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", pmgDefaultSandboxOverlayDir)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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().SandboxOverlayDir())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSandboxViolationCacheDir(t *testing.T) {
|
func TestSandboxViolationCacheDir(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -179,6 +179,48 @@ mandatory deny for that credential file. PMG treats both channels as explicit us
|
|||||||
Suppression is exact-match only; broad paths or globs do not opt out. `.git/hooks` does not accept
|
Suppression is exact-match only; broad paths or globs do not opt out. `.git/hooks` does not accept
|
||||||
opt-outs.
|
opt-outs.
|
||||||
|
|
||||||
|
### Project Overlays
|
||||||
|
|
||||||
|
A project overlay is a per-repository allow-set that PMG applies automatically on every run in
|
||||||
|
that repo, so you do not have to retype the same `--sandbox-allow` flags. The overlay is local
|
||||||
|
only, keyed by the repo's git toplevel (CWD when not a git repo), and stored under
|
||||||
|
`<config_dir>/sandbox/overlays/`. It is purely additive on top of the resolved policy, never
|
||||||
|
weakens mandatory denies, and is ignored when `global_lockdown` is set.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Save manual allowances for the current repo
|
||||||
|
pmg sandbox allow write=./.astro net-bind=localhost:4321
|
||||||
|
|
||||||
|
# Promote the primary violation from the most recent cached report
|
||||||
|
pmg sandbox allow --last
|
||||||
|
|
||||||
|
# Promote every safe FS/exec violation from that report
|
||||||
|
pmg sandbox allow --last --all
|
||||||
|
|
||||||
|
# Allow a sensitive path (e.g. .env, .npmrc) explicitly
|
||||||
|
pmg sandbox allow --force read=./.env
|
||||||
|
|
||||||
|
# Inspect the current repo's overlay
|
||||||
|
pmg sandbox project show
|
||||||
|
pmg sandbox project show --json
|
||||||
|
|
||||||
|
# List overlays across all known repos
|
||||||
|
pmg sandbox project list
|
||||||
|
|
||||||
|
# Delete the current repo's overlay
|
||||||
|
pmg sandbox project reset --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- `--last`/`--last --all` only auto-promotes filesystem and exec denials. Network allowances
|
||||||
|
(`net-connect`, `net-bind`) must be passed manually as `type=value` because drivers do not
|
||||||
|
classify network denials yet.
|
||||||
|
- `pmg sandbox allow` refuses sensitive targets (`.env*`, `.npmrc`, `.ssh`, `.aws`, `.kube`,
|
||||||
|
`.gnupg`, ...) unless `--force` is given.
|
||||||
|
- Applied overlay entries are recorded in the audit event log with a `+overlay` source tag so
|
||||||
|
they can be distinguished from `--sandbox-allow` flags.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Custom policy overrides using Policy Templates</summary>
|
<summary>Custom policy overrides using Policy Templates</summary>
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ func RenderSandboxViolation(out io.Writer, rec *pmgsandbox.ViolationCacheRecord)
|
|||||||
if _, err := fmt.Fprintln(out); err != nil {
|
if _, err := fmt.Fprintln(out); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// `pmg sandbox allow` refuses sensitive targets without --force, so
|
||||||
|
// do not suggest a command that would immediately fail.
|
||||||
|
if !pmgsandbox.IsSensitiveProjectTarget(exp.Override.Target) {
|
||||||
|
if _, err := fmt.Fprintln(out, Colors.Dim("Remember for this project: pmg sandbox allow --last --all")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if exp.Primary != nil {
|
if exp.Primary != nil {
|
||||||
|
|||||||
@@ -120,3 +120,61 @@ func TestRenderSandboxViolationRejectsNilRecord(t *testing.T) {
|
|||||||
assert.Error(t, RenderSandboxViolation(&buf, nil))
|
assert.Error(t, RenderSandboxViolation(&buf, nil))
|
||||||
assert.Error(t, RenderSandboxViolation(&buf, &pmgsandbox.ViolationCacheRecord{}))
|
assert.Error(t, RenderSandboxViolation(&buf, &pmgsandbox.ViolationCacheRecord{}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderSandboxViolationIncludesRememberHint(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rec := &pmgsandbox.ViolationCacheRecord{
|
||||||
|
SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion,
|
||||||
|
Report: &pmgsandbox.ViolationReport{
|
||||||
|
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||||
|
PolicyName: "npm-restrictive",
|
||||||
|
Violations: []pmgsandbox.Violation{{
|
||||||
|
Kind: pmgsandbox.ViolationKindFSWrite,
|
||||||
|
Target: "/repo/.astro",
|
||||||
|
RuleLabel: "deny write",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, RenderSandboxViolation(&buf, rec))
|
||||||
|
assert.Contains(t, buf.String(), "pmg sandbox allow --last --all")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderSandboxViolationOmitsRememberHintWithoutOverride(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rec := &pmgsandbox.ViolationCacheRecord{
|
||||||
|
SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion,
|
||||||
|
Report: &pmgsandbox.ViolationReport{
|
||||||
|
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||||
|
PolicyName: "npm-restrictive",
|
||||||
|
Violations: []pmgsandbox.Violation{{
|
||||||
|
Kind: pmgsandbox.ViolationKindGenericDeny,
|
||||||
|
RuleLabel: "deny generic",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, RenderSandboxViolation(&buf, rec))
|
||||||
|
assert.NotContains(t, buf.String(), "pmg sandbox allow --last --all")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderSandboxViolationOmitsRememberHintForSensitiveTarget(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
rec := &pmgsandbox.ViolationCacheRecord{
|
||||||
|
SchemaVersion: pmgsandbox.ViolationCacheSchemaVersion,
|
||||||
|
Report: &pmgsandbox.ViolationReport{
|
||||||
|
SandboxName: pmgsandbox.DriverSeatbelt,
|
||||||
|
PolicyName: "npm-restrictive",
|
||||||
|
Violations: []pmgsandbox.Violation{{
|
||||||
|
Kind: pmgsandbox.ViolationKindFSRead,
|
||||||
|
Target: "/repo/.env",
|
||||||
|
RuleLabel: "deny read",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, RenderSandboxViolation(&buf, rec))
|
||||||
|
out := buf.String()
|
||||||
|
// The "Suggested override" line still shows so users see the manual fix.
|
||||||
|
assert.Contains(t, out, "--sandbox-allow read=")
|
||||||
|
// But the persistent-save hint is suppressed because `pmg sandbox allow`
|
||||||
|
// would refuse this target without --force.
|
||||||
|
assert.NotContains(t, out, "pmg sandbox allow --last --all")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
// BuildAllOverrides maps every safe FS/exec violation in report to an
|
||||||
|
// OverrideSuggestion. Network violations are intentionally skipped because
|
||||||
|
// drivers do not classify network denials yet. Returns nil when the report is
|
||||||
|
// empty. Duplicates (same Kind+Target) are collapsed so callers do not have to
|
||||||
|
// de-dup against the existing overlay before passing the result through.
|
||||||
|
func BuildAllOverrides(report *ViolationReport) []OverrideSuggestion {
|
||||||
|
if report == nil || len(report.Violations) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := make(map[OverrideSuggestion]struct{}, len(report.Violations))
|
||||||
|
out := make([]OverrideSuggestion, 0, len(report.Violations))
|
||||||
|
for i := range report.Violations {
|
||||||
|
sugg := overrideSuggestion(report.Violations[i])
|
||||||
|
if sugg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := seen[*sugg]; dup {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[*sugg] = struct{}{}
|
||||||
|
out = append(out, *sugg)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildAllOverridesMapsAllSafeFSAndExec(t *testing.T) {
|
||||||
|
report := &ViolationReport{
|
||||||
|
Violations: []Violation{
|
||||||
|
{Kind: ViolationKindFSWrite, Target: "/repo/.astro"},
|
||||||
|
{Kind: ViolationKindExec, Target: "/usr/bin/sh"},
|
||||||
|
{Kind: ViolationKindFSWrite, Target: "/repo/.astro"}, // duplicate collapsed
|
||||||
|
{Kind: ViolationKindGenericDeny, Target: "/something/else"}, // unsupported kind dropped
|
||||||
|
{Kind: ViolationKindFSRead, Target: "**/*.env"}, // unsafe target dropped
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := BuildAllOverrides(report)
|
||||||
|
assert.ElementsMatch(t, []OverrideSuggestion{
|
||||||
|
{Kind: ViolationKindFSWrite, Target: "/repo/.astro"},
|
||||||
|
{Kind: ViolationKindExec, Target: "/usr/bin/sh"},
|
||||||
|
}, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildAllOverridesNilOrEmpty(t *testing.T) {
|
||||||
|
assert.Empty(t, BuildAllOverrides(nil))
|
||||||
|
assert.Empty(t, BuildAllOverrides(&ViolationReport{}))
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package executor
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
@@ -117,6 +118,21 @@ func ApplySandbox(ctx context.Context, cmd *exec.Cmd, pmName string, opts ...app
|
|||||||
|
|
||||||
log.Debugf("Loaded sandbox policy %s", policy.Name)
|
log.Debugf("Loaded sandbox policy %s", policy.Name)
|
||||||
|
|
||||||
|
// Apply the per-repo project overlay (saved via `pmg sandbox allow`). When
|
||||||
|
// global_lockdown is set, overlays are ignored so the locked baseline is
|
||||||
|
// authoritative. An empty cwd is tolerated by ResolveRepoRoot's callers
|
||||||
|
// downstream, so swallow a Getwd error here.
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
if repoRoot, repoErr := sandbox.ResolveRepoRoot(cwd); repoErr != nil {
|
||||||
|
log.Warnf("Project overlay: resolve repo root: %v", repoErr)
|
||||||
|
} else if _, err := applyProjectOverlay(policy, cfg.SandboxOverlayDir(), repoRoot, cfg.IsLocked()); err != nil {
|
||||||
|
log.Warnf("Project overlay: apply: %v", err)
|
||||||
|
// A failed overlay load means the user's saved allowances were silently
|
||||||
|
// dropped. Echo to stderr so users at normal verbosity see why their
|
||||||
|
// sandbox is more restrictive than expected.
|
||||||
|
fmt.Fprintf(os.Stderr, "pmg: warning: project overlay could not be applied: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Apply runtime --sandbox-allow overrides to the policy before execution
|
// Apply runtime --sandbox-allow overrides to the policy before execution
|
||||||
if len(cfg.SandboxAllowOverrides) > 0 {
|
if len(cfg.SandboxAllowOverrides) > 0 {
|
||||||
applyRuntimeOverrides(policy, cfg.SandboxAllowOverrides)
|
applyRuntimeOverrides(policy, cfg.SandboxAllowOverrides)
|
||||||
@@ -210,6 +226,32 @@ func removeExactMatch(slice []string, value string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyProjectOverlay loads the per-repo overlay (when one exists) and feeds
|
||||||
|
// its entries through applyRuntimeOverrides. Returns the number of entries
|
||||||
|
// applied. A nil/missing overlay is a clean no-op. When locked, the overlay
|
||||||
|
// is ignored entirely.
|
||||||
|
func applyProjectOverlay(policy *sandbox.SandboxPolicy, overlayDir, repoRoot string, locked bool) (int, error) {
|
||||||
|
if locked {
|
||||||
|
log.Debugf("Project overlay: skipping under global_lockdown")
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay, _, err := sandbox.LoadOverlayForRepo(overlayDir, repoRoot)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("project overlay: load: %w", err)
|
||||||
|
}
|
||||||
|
if overlay == nil || len(overlay.Allow) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := overlay.ToAllowOverrides()
|
||||||
|
applyRuntimeOverrides(policy, entries)
|
||||||
|
// The "+overlay" suffix tags audit events as overlay-sourced.
|
||||||
|
logSandboxOverrides(policy.Name+"+overlay", entries)
|
||||||
|
log.Infof("Project overlay: applied %d saved allowance(s) for %s", len(entries), repoRoot)
|
||||||
|
return len(entries), nil
|
||||||
|
}
|
||||||
|
|
||||||
// logSandboxOverrides records sandbox allow overrides in the audit event log.
|
// logSandboxOverrides records sandbox allow overrides in the audit event log.
|
||||||
func logSandboxOverrides(profileName string, overrides []config.SandboxAllowOverride) {
|
func logSandboxOverrides(profileName string, overrides []config.SandboxAllowOverride) {
|
||||||
entries := make([]map[string]string, 0, len(overrides))
|
entries := make([]map[string]string, 0, len(overrides))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/safedep/pmg/config"
|
"github.com/safedep/pmg/config"
|
||||||
"github.com/safedep/pmg/sandbox"
|
"github.com/safedep/pmg/sandbox"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestApplyRuntimeOverrides_Read(t *testing.T) {
|
func TestApplyRuntimeOverrides_Read(t *testing.T) {
|
||||||
@@ -249,3 +250,57 @@ func TestApplyRuntimeOverrides_VariableDenyNotRemovedByAbsoluteOverride(t *testi
|
|||||||
assert.Equal(t, []string{"${CWD}/blocked.txt"}, policy.Filesystem.DenyWrite)
|
assert.Equal(t, []string{"${CWD}/blocked.txt"}, policy.Filesystem.DenyWrite)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestApplyProjectOverlayAppendsEntries(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := "/repo/example"
|
||||||
|
_, err := sandbox.SaveOverlay(dir, repo, &sandbox.Overlay{
|
||||||
|
Allow: []sandbox.OverlayAllow{
|
||||||
|
{Type: config.SandboxAllowWrite, Value: "/repo/example/.astro"},
|
||||||
|
{Type: config.SandboxAllowNetBind, Value: "localhost:4321"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
policy := &sandbox.SandboxPolicy{Name: "test"}
|
||||||
|
applied, err := applyProjectOverlay(policy, dir, repo, false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 2, applied)
|
||||||
|
assert.Contains(t, policy.Filesystem.AllowWrite, "/repo/example/.astro")
|
||||||
|
assert.Contains(t, policy.Network.AllowBind, "localhost:4321")
|
||||||
|
if assert.NotNil(t, policy.AllowNetworkBind) {
|
||||||
|
assert.True(t, *policy.AllowNetworkBind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyProjectOverlaySkippedWhenLocked(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := "/repo/example"
|
||||||
|
_, err := sandbox.SaveOverlay(dir, repo, &sandbox.Overlay{
|
||||||
|
Allow: []sandbox.OverlayAllow{{Type: config.SandboxAllowWrite, Value: "/repo/example/.astro"}},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
policy := &sandbox.SandboxPolicy{Name: "test"}
|
||||||
|
applied, err := applyProjectOverlay(policy, dir, repo, true)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, applied)
|
||||||
|
assert.Empty(t, policy.Filesystem.AllowWrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyProjectOverlayMissingFileIsNoop(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "no-such")
|
||||||
|
policy := &sandbox.SandboxPolicy{Name: "test"}
|
||||||
|
applied, err := applyProjectOverlay(policy, dir, "/repo/example", false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, applied)
|
||||||
|
_, statErr := os.Stat(dir)
|
||||||
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyProjectOverlayEmptyArgsNoop(t *testing.T) {
|
||||||
|
policy := &sandbox.SandboxPolicy{Name: "test"}
|
||||||
|
applied, err := applyProjectOverlay(policy, "", "", false)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, applied)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/safedep/dry/log"
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OverlaySchemaVersion is bumped when the on-disk YAML layout changes incompatibly.
|
||||||
|
const OverlaySchemaVersion = 1
|
||||||
|
|
||||||
|
const overlayFileSuffix = ".yml"
|
||||||
|
|
||||||
|
// Overlay is the on-disk representation of a per-repo allow-set. One file per
|
||||||
|
// repo, named by sha256(repo_root)[:16], lives under SandboxOverlayDir().
|
||||||
|
type Overlay struct {
|
||||||
|
SchemaVersion int `yaml:"schema_version"`
|
||||||
|
RepoRoot string `yaml:"repo_root"`
|
||||||
|
CreatedAt time.Time `yaml:"created_at,omitempty"`
|
||||||
|
UpdatedAt time.Time `yaml:"updated_at,omitempty"`
|
||||||
|
Allow []OverlayAllow `yaml:"allow"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverlayAllow is a single persisted allow entry. The field shape matches
|
||||||
|
// config.SandboxAllowOverride so conversion is mechanical.
|
||||||
|
type OverlayAllow struct {
|
||||||
|
Type config.SandboxAllowType `yaml:"type"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverlayListEntry pairs an Overlay with the file path it was loaded from,
|
||||||
|
// for `pmg sandbox project list` output.
|
||||||
|
type OverlayListEntry struct {
|
||||||
|
Path string
|
||||||
|
Overlay Overlay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add appends entry when no (Type, Value) duplicate already exists. Returns
|
||||||
|
// true when added, false when the entry was a duplicate.
|
||||||
|
func (o *Overlay) Add(entry OverlayAllow) bool {
|
||||||
|
for _, existing := range o.Allow {
|
||||||
|
if existing.Type == entry.Type && existing.Value == entry.Value {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.Allow = append(o.Allow, entry)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToAllowOverrides converts overlay entries to the runtime override shape used
|
||||||
|
// by sandbox/executor.applyRuntimeOverrides.
|
||||||
|
func (o *Overlay) ToAllowOverrides() []config.SandboxAllowOverride {
|
||||||
|
out := make([]config.SandboxAllowOverride, 0, len(o.Allow))
|
||||||
|
for _, a := range o.Allow {
|
||||||
|
out = append(out, config.SandboxAllowOverride{
|
||||||
|
Type: a.Type,
|
||||||
|
Value: a.Value,
|
||||||
|
Raw: fmt.Sprintf("%s=%s", a.Type, a.Value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRepoRoot returns the git toplevel for cwd, falling back to cwd itself
|
||||||
|
// when not inside a git work tree. The returned path is filepath.Clean'd and
|
||||||
|
// has its symlinks evaluated where possible so it forms a stable key.
|
||||||
|
func ResolveRepoRoot(cwd string) (string, error) {
|
||||||
|
if cwd == "" {
|
||||||
|
return "", errors.New("overlay: empty cwd")
|
||||||
|
}
|
||||||
|
|
||||||
|
root := cwd
|
||||||
|
cmd := exec.Command("git", "-C", cwd, "rev-parse", "--show-toplevel")
|
||||||
|
if out, err := cmd.Output(); err == nil {
|
||||||
|
if s := strings.TrimSpace(string(out)); s != "" {
|
||||||
|
root = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved, err := filepath.EvalSymlinks(root); err == nil {
|
||||||
|
root = resolved
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
// A genuine resolution failure (permissions, broken mid-path symlink)
|
||||||
|
// leaves us with a non-canonical key. Surface it so users can see why
|
||||||
|
// their overlay does not match across runs.
|
||||||
|
log.Warnf("overlay: eval symlinks for %s: %v", root, err)
|
||||||
|
}
|
||||||
|
return filepath.Clean(root), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlayFileFor returns the canonical absolute path of a repo's overlay file
|
||||||
|
// inside dir. The filename is sha256(repo_root)[:16] so we never have to
|
||||||
|
// path-escape the repo root.
|
||||||
|
func overlayFileFor(dir, repoRoot string) string {
|
||||||
|
sum := sha256.Sum256([]byte(repoRoot))
|
||||||
|
name := hex.EncodeToString(sum[:])[:16] + overlayFileSuffix
|
||||||
|
return filepath.Join(dir, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveOverlay writes overlay to dir, creating dir lazily. The schema version,
|
||||||
|
// repo root, and updated timestamp are normalized. CreatedAt is preserved from
|
||||||
|
// an existing file when present, otherwise set to now. Returns the path
|
||||||
|
// written.
|
||||||
|
func SaveOverlay(dir, repoRoot string, overlay *Overlay) (string, error) {
|
||||||
|
if dir == "" {
|
||||||
|
return "", errors.New("overlay: empty directory")
|
||||||
|
}
|
||||||
|
if repoRoot == "" {
|
||||||
|
return "", errors.New("overlay: empty repo root")
|
||||||
|
}
|
||||||
|
if overlay == nil {
|
||||||
|
return "", errors.New("overlay: nil overlay")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("overlay: create dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := overlayFileFor(dir, repoRoot)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
overlay.SchemaVersion = OverlaySchemaVersion
|
||||||
|
overlay.RepoRoot = repoRoot
|
||||||
|
if overlay.CreatedAt.IsZero() {
|
||||||
|
overlay.CreatedAt = now
|
||||||
|
if existing, err := loadOverlayFile(path); err == nil && existing != nil && !existing.CreatedAt.IsZero() {
|
||||||
|
overlay.CreatedAt = existing.CreatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
overlay.UpdatedAt = now
|
||||||
|
|
||||||
|
data, err := yaml.Marshal(overlay)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("overlay: marshal: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("overlay: write: %w", err)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadOverlayForRepo returns the overlay for repoRoot in dir, or (nil, "", nil)
|
||||||
|
// when the file is missing. Returns an error only for genuine IO or parse
|
||||||
|
// failures so callers can treat absence as "no overlay" without sniffing.
|
||||||
|
func LoadOverlayForRepo(dir, repoRoot string) (*Overlay, string, error) {
|
||||||
|
if dir == "" || repoRoot == "" {
|
||||||
|
return nil, "", nil
|
||||||
|
}
|
||||||
|
path := overlayFileFor(dir, repoRoot)
|
||||||
|
overlay, err := loadOverlayFile(path)
|
||||||
|
if err != nil || overlay == nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return overlay, path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadOverlayFile(path string) (*Overlay, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("overlay: read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var overlay Overlay
|
||||||
|
if err := yaml.Unmarshal(data, &overlay); err != nil {
|
||||||
|
return nil, fmt.Errorf("overlay: parse %s: %w", path, err)
|
||||||
|
}
|
||||||
|
if overlay.SchemaVersion != 0 && overlay.SchemaVersion != OverlaySchemaVersion {
|
||||||
|
return nil, fmt.Errorf("overlay: %s has unknown schema_version %d", path, overlay.SchemaVersion)
|
||||||
|
}
|
||||||
|
return &overlay, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOverlayForRepo removes the overlay file for repoRoot in dir. A missing
|
||||||
|
// file is not an error so callers can treat reset as idempotent.
|
||||||
|
func DeleteOverlayForRepo(dir, repoRoot string) error {
|
||||||
|
path := overlayFileFor(dir, repoRoot)
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("overlay: remove %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOverlays returns every readable overlay under dir, sorted by RepoRoot.
|
||||||
|
// A missing dir yields an empty slice with no error.
|
||||||
|
func ListOverlays(dir string) ([]OverlayListEntry, error) {
|
||||||
|
if dir == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
dirents, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("overlay: read dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]OverlayListEntry, 0, len(dirents))
|
||||||
|
for _, d := range dirents {
|
||||||
|
if d.IsDir() || !strings.HasSuffix(d.Name(), overlayFileSuffix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, d.Name())
|
||||||
|
overlay, err := loadOverlayFile(path)
|
||||||
|
if err != nil || overlay == nil {
|
||||||
|
// Skip corrupt files rather than failing the whole list, so users
|
||||||
|
// can still inspect/prune via the same command.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, OverlayListEntry{Path: path, Overlay: *overlay})
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Overlay.RepoRoot < out[j].Overlay.RepoRoot })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package sandbox
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/safedep/pmg/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func evalPath(t *testing.T, p string) string {
|
||||||
|
t.Helper()
|
||||||
|
v, err := filepath.EvalSymlinks(p)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayResolveRepoRootGit(t *testing.T) {
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
t.Skip("git not on PATH")
|
||||||
|
}
|
||||||
|
repo := t.TempDir()
|
||||||
|
require.NoError(t, exec.Command("git", "-C", repo, "init", "-q").Run())
|
||||||
|
sub := filepath.Join(repo, "pkg", "deep")
|
||||||
|
require.NoError(t, os.MkdirAll(sub, 0o755))
|
||||||
|
|
||||||
|
got, err := ResolveRepoRoot(sub)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, evalPath(t, repo), evalPath(t, got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayResolveRepoRootNonGitFallsBackToCWD(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
got, err := ResolveRepoRoot(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, evalPath(t, dir), evalPath(t, got))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayResolveRepoRootEmpty(t *testing.T) {
|
||||||
|
_, err := ResolveRepoRoot("")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlaySaveLoadRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := "/repo/example"
|
||||||
|
|
||||||
|
in := &Overlay{
|
||||||
|
Allow: []OverlayAllow{
|
||||||
|
{Type: config.SandboxAllowWrite, Value: "/repo/example/.astro"},
|
||||||
|
{Type: config.SandboxAllowNetBind, Value: "localhost:4321"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
path, err := SaveOverlay(dir, repo, in)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.FileExists(t, path)
|
||||||
|
assert.Equal(t, OverlaySchemaVersion, in.SchemaVersion)
|
||||||
|
assert.Equal(t, repo, in.RepoRoot)
|
||||||
|
assert.False(t, in.UpdatedAt.IsZero())
|
||||||
|
assert.False(t, in.CreatedAt.IsZero())
|
||||||
|
|
||||||
|
loaded, loadedPath, err := LoadOverlayForRepo(dir, repo)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, path, loadedPath)
|
||||||
|
require.NotNil(t, loaded)
|
||||||
|
assert.Equal(t, repo, loaded.RepoRoot)
|
||||||
|
assert.ElementsMatch(t, in.Allow, loaded.Allow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlaySavePreservesCreatedAt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := "/repo/example"
|
||||||
|
|
||||||
|
first := &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/sh"}}}
|
||||||
|
_, err := SaveOverlay(dir, repo, first)
|
||||||
|
require.NoError(t, err)
|
||||||
|
createdAt := first.CreatedAt
|
||||||
|
|
||||||
|
second := &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/zsh"}}}
|
||||||
|
_, err = SaveOverlay(dir, repo, second)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, createdAt, second.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOverlayForRepoMissingReturnsNil(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
overlay, path, err := LoadOverlayForRepo(dir, "/nope")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, overlay)
|
||||||
|
assert.Empty(t, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOverlayForRepoEmptyArgsReturnsNil(t *testing.T) {
|
||||||
|
overlay, _, err := LoadOverlayForRepo("", "/repo")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, overlay)
|
||||||
|
|
||||||
|
overlay, _, err = LoadOverlayForRepo(t.TempDir(), "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, overlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayAddDedupsByTypeAndValue(t *testing.T) {
|
||||||
|
o := &Overlay{}
|
||||||
|
assert.True(t, o.Add(OverlayAllow{Type: config.SandboxAllowWrite, Value: "/a"}))
|
||||||
|
assert.False(t, o.Add(OverlayAllow{Type: config.SandboxAllowWrite, Value: "/a"}))
|
||||||
|
assert.True(t, o.Add(OverlayAllow{Type: config.SandboxAllowRead, Value: "/a"}))
|
||||||
|
assert.Len(t, o.Allow, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayToAllowOverrides(t *testing.T) {
|
||||||
|
o := &Overlay{
|
||||||
|
Allow: []OverlayAllow{
|
||||||
|
{Type: config.SandboxAllowWrite, Value: "/a"},
|
||||||
|
{Type: config.SandboxAllowNetBind, Value: "localhost:1234"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got := o.ToAllowOverrides()
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
assert.Equal(t, config.SandboxAllowWrite, got[0].Type)
|
||||||
|
assert.Equal(t, "/a", got[0].Value)
|
||||||
|
assert.Equal(t, "write=/a", got[0].Raw)
|
||||||
|
assert.Equal(t, config.SandboxAllowNetBind, got[1].Type)
|
||||||
|
assert.Equal(t, "net-bind=localhost:1234", got[1].Raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteOverlayForRepoIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repo := "/repo/x"
|
||||||
|
_, err := SaveOverlay(dir, repo, &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, DeleteOverlayForRepo(dir, repo))
|
||||||
|
// Second call is a no-op.
|
||||||
|
require.NoError(t, DeleteOverlayForRepo(dir, repo))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOverlaysReturnsAllSorted(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
repos := []string{"/repo/c", "/repo/a", "/repo/b"}
|
||||||
|
for _, r := range repos {
|
||||||
|
_, err := SaveOverlay(dir, r, &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
listed, err := ListOverlays(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, listed, 3)
|
||||||
|
assert.Equal(t, "/repo/a", listed[0].Overlay.RepoRoot)
|
||||||
|
assert.Equal(t, "/repo/b", listed[1].Overlay.RepoRoot)
|
||||||
|
assert.Equal(t, "/repo/c", listed[2].Overlay.RepoRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOverlaysMissingDirIsEmpty(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "does", "not", "exist")
|
||||||
|
listed, err := ListOverlays(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, listed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListOverlaysSkipsCorruptFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := SaveOverlay(dir, "/repo/a", &Overlay{Allow: []OverlayAllow{{Type: config.SandboxAllowExec, Value: "/bin/x"}}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "garbage.yml"), []byte("{not valid yaml]"), 0o644))
|
||||||
|
|
||||||
|
listed, err := ListOverlays(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, listed, 1)
|
||||||
|
assert.Equal(t, "/repo/a", listed[0].Overlay.RepoRoot)
|
||||||
|
}
|
||||||
@@ -160,6 +160,13 @@ func isProjectPath(target, cwd string) bool {
|
|||||||
return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator))
|
return cleanTarget == cleanCwd || strings.HasPrefix(cleanTarget, cleanCwd+string(filepath.Separator))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSensitiveProjectTarget reports whether target points to a known sensitive
|
||||||
|
// file or directory (credential stores, dotfiles like .env/.npmrc). Used by
|
||||||
|
// the overlay save-time guard to refuse implicit credential allowances.
|
||||||
|
func IsSensitiveProjectTarget(target string) bool {
|
||||||
|
return isSensitiveProjectFile(target)
|
||||||
|
}
|
||||||
|
|
||||||
func isSensitiveProjectFile(target string) bool {
|
func isSensitiveProjectFile(target string) bool {
|
||||||
if target == "" || isParentRelativePath(target) {
|
if target == "" || isParentRelativePath(target) {
|
||||||
return false
|
return false
|
||||||
@@ -176,7 +183,8 @@ func isSensitiveProjectFile(target string) bool {
|
|||||||
default:
|
default:
|
||||||
return strings.Contains(target, string(filepath.Separator)+".ssh") ||
|
return strings.Contains(target, string(filepath.Separator)+".ssh") ||
|
||||||
strings.Contains(target, string(filepath.Separator)+".aws") ||
|
strings.Contains(target, string(filepath.Separator)+".aws") ||
|
||||||
strings.Contains(target, string(filepath.Separator)+".kube")
|
strings.Contains(target, string(filepath.Separator)+".kube") ||
|
||||||
|
strings.Contains(target, string(filepath.Separator)+".gnupg")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,3 +184,15 @@ func TestBuildExplanationEmptyReport(t *testing.T) {
|
|||||||
assert.Nil(t, exp.Override)
|
assert.Nil(t, exp.Override)
|
||||||
assert.Equal(t, 0, exp.AdditionalDenials)
|
assert.Equal(t, 0, exp.AdditionalDenials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsSensitiveProjectTargetExported(t *testing.T) {
|
||||||
|
assert.True(t, IsSensitiveProjectTarget("./.env"))
|
||||||
|
assert.True(t, IsSensitiveProjectTarget(filepath.Join("/repo", ".npmrc")))
|
||||||
|
assert.False(t, IsSensitiveProjectTarget(filepath.Join("/repo", ".astro")))
|
||||||
|
assert.False(t, IsSensitiveProjectTarget("../.env"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsSensitiveProjectTargetGNUPGFiles(t *testing.T) {
|
||||||
|
assert.True(t, IsSensitiveProjectTarget("/home/user/.gnupg/pubring.kbx"))
|
||||||
|
assert.True(t, IsSensitiveProjectTarget("/home/user/.gnupg/private-keys-v1.d/abc.key"))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user