package sandbox import ( "testing" "github.com/safedep/dry/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // 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 } } } func TestLintProfile_AllowDirectDNSWithoutLockdown(t *testing.T) { tests := []struct { name string allowDirectDNS *bool networkViaProxyOnly *bool wantWarn bool }{ { name: "allow_direct_dns without lockdown warns", allowDirectDNS: utils.PtrTo(true), wantWarn: true, }, { name: "allow_direct_dns with lockdown is clean", allowDirectDNS: utils.PtrTo(true), networkViaProxyOnly: utils.PtrTo(true), wantWarn: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { policy := cleanPolicy() policy.AllowDirectDNS = tc.allowDirectDNS policy.NetworkViaProxyOnly = tc.networkViaProxyOnly var found *LintIssue for _, i := range LintProfile(policy) { if i.Code == "allow-direct-dns-without-lockdown" { issue := i found = &issue break } } if !tc.wantWarn { assert.Nil(t, found) return } require.NotNil(t, found) assert.Equal(t, LintLevelWarn, found.Level) assert.Equal(t, "allow_direct_dns", found.Field) assert.Equal(t, "allow_direct_dns has no effect unless network_via_proxy_only is true", found.Message) }) } }