mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* chore: Add a landlock test for path deny * fix: Make sure sandbox is available:
594 lines
16 KiB
Go
594 lines
16 KiB
Go
//go:build linux
|
|
|
|
package platform
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
llsyscall "github.com/landlock-lsm/go-landlock/landlock/syscall"
|
|
"github.com/safedep/dry/utils"
|
|
"github.com/safedep/pmg/sandbox"
|
|
)
|
|
|
|
func newTestPolicy() *sandbox.SandboxPolicy {
|
|
return &sandbox.SandboxPolicy{
|
|
Name: "test-policy",
|
|
PackageManagers: []string{"npm"},
|
|
}
|
|
}
|
|
|
|
func findRule(rules []landlockPathRule, path string) *landlockPathRule {
|
|
for i := range rules {
|
|
if rules[i].Path == path {
|
|
return &rules[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findDenyPath(entries []denyPathEntry, path string) *denyPathEntry {
|
|
for i := range entries {
|
|
if entries[i].Path == path {
|
|
return &entries[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_AllowRead(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
paths []string
|
|
wantPaths []string
|
|
}{
|
|
{
|
|
name: "single path",
|
|
paths: []string{"/usr/lib"},
|
|
wantPaths: []string{"/usr/lib"},
|
|
},
|
|
{
|
|
name: "multiple paths",
|
|
paths: []string{"/usr/lib", "/etc"},
|
|
wantPaths: []string{"/usr/lib", "/etc"},
|
|
},
|
|
}
|
|
|
|
abi := newLandlockABI(3)
|
|
expectedAccess := uint64(llsyscall.AccessFSReadFile | llsyscall.AccessFSReadDir | llsyscall.AccessFSExecute)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.AllowRead = tt.paths
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
for _, wantPath := range tt.wantPaths {
|
|
rule := findRule(ep.FilesystemRules, wantPath)
|
|
if rule == nil {
|
|
t.Errorf("expected rule for path %s, not found", wantPath)
|
|
continue
|
|
}
|
|
if rule.Access != expectedAccess {
|
|
t.Errorf("path %s: access = %x, want %x", wantPath, rule.Access, expectedAccess)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_AllowWrite(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
abiVersion int
|
|
wantRefer bool
|
|
wantTrunc bool
|
|
}{
|
|
{
|
|
name: "V1 - no Refer, no Truncate",
|
|
abiVersion: 1,
|
|
wantRefer: false,
|
|
wantTrunc: false,
|
|
},
|
|
{
|
|
name: "V2 - has Refer, no Truncate",
|
|
abiVersion: 2,
|
|
wantRefer: true,
|
|
wantTrunc: false,
|
|
},
|
|
{
|
|
name: "V3 - has Refer and Truncate",
|
|
abiVersion: 3,
|
|
wantRefer: true,
|
|
wantTrunc: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
abi := newLandlockABI(tt.abiVersion)
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.AllowWrite = []string{"/tmp/test"}
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
rule := findRule(ep.FilesystemRules, "/tmp/test")
|
|
if rule == nil {
|
|
t.Fatal("expected rule for /tmp/test, not found")
|
|
}
|
|
|
|
hasRefer := rule.Access&uint64(llsyscall.AccessFSRefer) != 0
|
|
hasTrunc := rule.Access&uint64(llsyscall.AccessFSTruncate) != 0
|
|
|
|
if hasRefer != tt.wantRefer {
|
|
t.Errorf("Refer flag: got %v, want %v", hasRefer, tt.wantRefer)
|
|
}
|
|
if hasTrunc != tt.wantTrunc {
|
|
t.Errorf("Truncate flag: got %v, want %v", hasTrunc, tt.wantTrunc)
|
|
}
|
|
|
|
// Verify base write flags are always present
|
|
if rule.Access&uint64(llsyscall.AccessFSWriteFile) == 0 {
|
|
t.Error("WriteFile flag should always be present")
|
|
}
|
|
if rule.Access&uint64(llsyscall.AccessFSMakeReg) == 0 {
|
|
t.Error("MakeReg flag should always be present")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_AllowExec(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Process.AllowExec = []string{"/usr/bin/node", "/usr/bin/npm"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Execute access includes ReadFile because the kernel must read the
|
|
// shebang line of script files to determine the interpreter.
|
|
expectedAccess := uint64(llsyscall.AccessFSExecute | llsyscall.AccessFSReadFile)
|
|
|
|
for _, path := range []string{"/usr/bin/node", "/usr/bin/npm"} {
|
|
rule := findRule(ep.FilesystemRules, path)
|
|
if rule == nil {
|
|
t.Errorf("expected rule for %s, not found", path)
|
|
continue
|
|
}
|
|
if rule.Access != expectedAccess {
|
|
t.Errorf("path %s: access = %x, want %x", path, rule.Access, expectedAccess)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_DenyRead(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.DenyRead = []string{"/etc/shadow", "/etc/passwd"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
for _, path := range []string{"/etc/shadow", "/etc/passwd"} {
|
|
entry := findDenyPath(ep.DenyPaths, path)
|
|
if entry == nil {
|
|
t.Errorf("expected deny entry for %s, not found", path)
|
|
continue
|
|
}
|
|
if entry.Mode != denyRead {
|
|
t.Errorf("path %s: mode = %d, want denyRead (%d)", path, entry.Mode, denyRead)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_DenyWrite(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
// DenyWrite is only effective within writable areas. Add /etc as writable
|
|
// so the deny rule for /etc/hosts is not pruned as redundant.
|
|
policy.Filesystem.AllowWrite = []string{"/etc/**"}
|
|
policy.Filesystem.DenyWrite = []string{"/etc/hosts"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
entry := findDenyPath(ep.DenyPaths, "/etc/hosts")
|
|
if entry == nil {
|
|
t.Fatal("expected deny entry for /etc/hosts, not found")
|
|
}
|
|
if entry.Mode != denyWrite {
|
|
t.Errorf("mode = %d, want denyWrite (%d)", entry.Mode, denyWrite)
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_DenyWriteSkippedWhenNotWritable(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
// No AllowWrite for /etc, so deny_write for /etc/hosts is redundant
|
|
// (Landlock already prevents writes).
|
|
policy.Filesystem.DenyWrite = []string{"/etc/hosts"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
entry := findDenyPath(ep.DenyPaths, "/etc/hosts")
|
|
if entry != nil {
|
|
t.Error("expected deny entry for /etc/hosts to be pruned (not in writable area)")
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_DenyExec(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Process.DenyExec = []string{"/usr/bin/curl", "/usr/bin/wget"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
for _, path := range []string{"/usr/bin/curl", "/usr/bin/wget"} {
|
|
found := false
|
|
for _, p := range ep.DenyExecPaths {
|
|
if p == path {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected %s in DenyExecPaths, not found", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_MandatoryDenies(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.AllowRead = []string{"/usr"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// Mandatory denies should always be present with denyBoth mode
|
|
if len(ep.DenyPaths) == 0 {
|
|
t.Fatal("expected mandatory deny paths, got none")
|
|
}
|
|
|
|
// Check that at least some mandatory denies have denyBoth mode
|
|
hasDenyBoth := false
|
|
for _, entry := range ep.DenyPaths {
|
|
if entry.Mode == denyBoth {
|
|
hasDenyBoth = true
|
|
break
|
|
}
|
|
}
|
|
if !hasDenyBoth {
|
|
t.Error("expected at least one mandatory deny with denyBoth mode")
|
|
}
|
|
|
|
// Check that .env is in the mandatory denies (it should always be there)
|
|
hasEnvDeny := false
|
|
for _, entry := range ep.DenyPaths {
|
|
if strings.HasSuffix(entry.Path, "/.env") && entry.Mode == denyBoth {
|
|
hasEnvDeny = true
|
|
break
|
|
}
|
|
}
|
|
if !hasEnvDeny {
|
|
t.Error("expected .env in mandatory deny paths with denyBoth mode")
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_AllowReadSuppression(t *testing.T) {
|
|
cwd, err := os.Getwd()
|
|
require.NoError(t, err)
|
|
cwdEnv := filepath.Clean(filepath.Join(cwd, ".env"))
|
|
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.AllowRead = []string{cwdEnv}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
require.NoError(t, err)
|
|
|
|
envEntry := findDenyPath(ep.DenyPaths, cwdEnv)
|
|
require.NotNil(t, envEntry)
|
|
assert.Equal(t, denyWrite, envEntry.Mode)
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_ImplicitRules(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.AllowRead = []string{"/usr"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
readAccess := uint64(llsyscall.AccessFSReadFile | llsyscall.AccessFSReadDir | llsyscall.AccessFSExecute)
|
|
|
|
// /proc must be present with read access
|
|
procRule := findRule(ep.FilesystemRules, "/proc")
|
|
if procRule == nil {
|
|
t.Error("expected implicit /proc rule")
|
|
} else if procRule.Access != readAccess {
|
|
t.Errorf("/proc access = %x, want read access %x", procRule.Access, readAccess)
|
|
}
|
|
|
|
// /dev/null, /dev/zero, /dev/random, /dev/urandom must be present with read+write
|
|
for _, dev := range []string{"/dev/null", "/dev/zero", "/dev/random", "/dev/urandom"} {
|
|
rule := findRule(ep.FilesystemRules, dev)
|
|
if rule == nil {
|
|
t.Errorf("expected implicit rule for %s", dev)
|
|
continue
|
|
}
|
|
// Should have both read and write access
|
|
if rule.Access&readAccess != readAccess {
|
|
t.Errorf("%s: missing read access flags", dev)
|
|
}
|
|
if rule.Access&uint64(llsyscall.AccessFSWriteFile) == 0 {
|
|
t.Errorf("%s: missing write access flags", dev)
|
|
}
|
|
}
|
|
|
|
// os.TempDir() must be present with write access
|
|
tmpDir := os.TempDir()
|
|
tmpRule := findRule(ep.FilesystemRules, tmpDir)
|
|
if tmpRule == nil {
|
|
t.Errorf("expected implicit rule for %s", tmpDir)
|
|
} else if tmpRule.Access&uint64(llsyscall.AccessFSWriteFile) == 0 {
|
|
t.Errorf("%s: missing write access flags", tmpDir)
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_AllowPTY_True(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.AllowPTY = utils.PtrTo(true)
|
|
policy.Filesystem.AllowRead = []string{"/usr"}
|
|
abi := newLandlockABI(5) // V5 has IoctlDev
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if !ep.AllowPTY {
|
|
t.Error("expected AllowPTY to be true")
|
|
}
|
|
|
|
// Check /dev/pts
|
|
ptsRule := findRule(ep.FilesystemRules, "/dev/pts")
|
|
if ptsRule == nil {
|
|
t.Fatal("expected rule for /dev/pts")
|
|
}
|
|
if ptsRule.Access&uint64(llsyscall.AccessFSReadFile) == 0 {
|
|
t.Error("/dev/pts: missing read access")
|
|
}
|
|
if ptsRule.Access&uint64(llsyscall.AccessFSWriteFile) == 0 {
|
|
t.Error("/dev/pts: missing write access")
|
|
}
|
|
// V5+ should have IoctlDev
|
|
if ptsRule.Access&uint64(llsyscall.AccessFSIoctlDev) == 0 {
|
|
t.Error("/dev/pts: missing IoctlDev access on V5+")
|
|
}
|
|
|
|
// Check /dev/ptmx
|
|
ptmxRule := findRule(ep.FilesystemRules, "/dev/ptmx")
|
|
if ptmxRule == nil {
|
|
t.Fatal("expected rule for /dev/ptmx")
|
|
}
|
|
if ptmxRule.Access&uint64(llsyscall.AccessFSIoctlDev) == 0 {
|
|
t.Error("/dev/ptmx: missing IoctlDev access on V5+")
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_AllowPTY_Nil(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.AllowPTY = nil // nil means false
|
|
policy.Filesystem.AllowRead = []string{"/usr"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if ep.AllowPTY {
|
|
t.Error("expected AllowPTY to be false when nil")
|
|
}
|
|
|
|
// /dev/pts and /dev/ptmx should NOT be in rules
|
|
if findRule(ep.FilesystemRules, "/dev/pts") != nil {
|
|
t.Error("unexpected rule for /dev/pts when AllowPTY is nil")
|
|
}
|
|
if findRule(ep.FilesystemRules, "/dev/ptmx") != nil {
|
|
t.Error("unexpected rule for /dev/ptmx when AllowPTY is nil")
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_ProcExplicitAllow(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.AllowRead = []string{"/proc/cpuinfo"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if !ep.SkipPIDNamespace {
|
|
t.Error("expected SkipPIDNamespace=true when /proc/cpuinfo is allowed")
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_ProcSelfOnly(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.AllowRead = []string{"/proc/self/status", "/proc/self/fd"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
if ep.SkipPIDNamespace {
|
|
t.Error("expected SkipPIDNamespace=false when only /proc/self paths are allowed")
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_ProcDenyDropped(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.DenyRead = []string{"/proc/kcore", "/etc/shadow"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// /proc/kcore should be dropped
|
|
for _, entry := range ep.DenyPaths {
|
|
if strings.HasPrefix(entry.Path, "/proc") {
|
|
t.Errorf("expected /proc deny entries to be dropped, found: %s", entry.Path)
|
|
}
|
|
}
|
|
|
|
// /etc/shadow should remain
|
|
found := false
|
|
for _, entry := range ep.DenyPaths {
|
|
if entry.Path == "/etc/shadow" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected /etc/shadow deny entry to remain")
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_AllowGitConfig_Nil(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.AllowGitConfig = nil // nil means false -> .git/config should be in mandatory denies
|
|
policy.Filesystem.AllowRead = []string{"/usr"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// .git/config should be in deny paths
|
|
hasGitConfigDeny := false
|
|
for _, entry := range ep.DenyPaths {
|
|
if strings.HasSuffix(entry.Path, ".git/config") {
|
|
hasGitConfigDeny = true
|
|
break
|
|
}
|
|
}
|
|
if !hasGitConfigDeny {
|
|
t.Error("expected .git/config in mandatory deny paths when AllowGitConfig is nil")
|
|
}
|
|
}
|
|
|
|
func TestLandlockTranslatePolicy_AllowGitConfig_True(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.AllowGitConfig = utils.PtrTo(true)
|
|
policy.Filesystem.AllowRead = []string{"/usr"}
|
|
abi := newLandlockABI(3)
|
|
|
|
ep, err := landlockTranslatePolicy(policy, abi)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
// .git/config should NOT be in deny paths
|
|
for _, entry := range ep.DenyPaths {
|
|
if strings.HasSuffix(entry.Path, ".git/config") {
|
|
t.Error("expected .git/config to NOT be in mandatory deny paths when AllowGitConfig is true")
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLandlockPolicyExplicitlyAllowsProc(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
readPaths []string
|
|
writePaths []string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "no proc paths",
|
|
readPaths: []string{"/usr/lib", "/etc"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "/proc/self only",
|
|
readPaths: []string{"/proc/self"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "/proc/self/status",
|
|
readPaths: []string{"/proc/self/status"},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "/proc/cpuinfo triggers",
|
|
readPaths: []string{"/proc/cpuinfo"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "/proc/1/status triggers",
|
|
readPaths: []string{"/proc/1/status"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "/proc in write paths triggers",
|
|
writePaths: []string{"/proc/sys/kernel/randomize_va_space"},
|
|
want: true,
|
|
},
|
|
{
|
|
name: "/proc alone triggers",
|
|
readPaths: []string{"/proc"},
|
|
want: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
policy := newTestPolicy()
|
|
policy.Filesystem.AllowRead = tt.readPaths
|
|
policy.Filesystem.AllowWrite = tt.writePaths
|
|
|
|
got := landlockPolicyExplicitlyAllowsProc(policy)
|
|
if got != tt.want {
|
|
t.Errorf("landlockPolicyExplicitlyAllowsProc() = %v, want %v", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|