mirror of
https://github.com/safedep/pmg.git
synced 2026-08-03 07:24:09 +02:00
* feat: Initial implementation of landlock based sandbox driver * fix: Handle seccom probe failure * fix: Remove unnecessary seccomp probe * fix: Use file based policy load * fix: Keep bpf filter in memory * fix: Use TSYNC for seccom filter * fix: Use TSYNC for seccom filter * fix: Update landlock translator * fix: Landlock sandbox implementation * fix: Landlock + seccomp based sandboxing on Linux * fix: Misc fixes * fix: Cleanup sandbox files * fix: Handle mandatory deny API change post merge * fix: Landlock write access translation * chore: Fix linter issues * ci: Use /tmp for npm cache for landlock
532 lines
14 KiB
Go
532 lines
14 KiB
Go
//go:build linux
|
|
|
|
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"unsafe"
|
|
|
|
"golang.org/x/sys/unix"
|
|
)
|
|
|
|
func TestSeccompStructSizes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
got uintptr
|
|
expected uintptr
|
|
}{
|
|
{"seccompData", unsafe.Sizeof(seccompData{}), 64},
|
|
{"seccompNotification", unsafe.Sizeof(seccompNotification{}), 80},
|
|
{"seccompNotifResp", unsafe.Sizeof(seccompNotifResp{}), 24},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.got != tt.expected {
|
|
t.Errorf("sizeof(%s) = %d, want %d", tt.name, tt.got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildSeccompBPFFilter(t *testing.T) {
|
|
prog, err := landlockBuildBPFFilter()
|
|
if err != nil {
|
|
t.Fatalf("landlockBuildBPFFilter() returned error: %v", err)
|
|
}
|
|
|
|
if prog == nil {
|
|
t.Fatal("landlockBuildBPFFilter() returned nil")
|
|
}
|
|
|
|
if prog.Filter == nil {
|
|
t.Fatal("landlockBuildBPFFilter() returned nil Filter")
|
|
}
|
|
|
|
// Expect 7 instructions: 1 load + 4 comparisons + 1 allow + 1 notify
|
|
if prog.Len != 7 {
|
|
t.Errorf("expected 7 instructions, got %d", prog.Len)
|
|
}
|
|
}
|
|
|
|
func TestBuildSeccompBPFFilter_InstructionTypes(t *testing.T) {
|
|
prog, err := landlockBuildBPFFilter()
|
|
if err != nil {
|
|
t.Fatalf("landlockBuildBPFFilter() returned error: %v", err)
|
|
}
|
|
|
|
// Access the filter instructions as a slice
|
|
instructions := unsafe.Slice(prog.Filter, prog.Len)
|
|
|
|
// First instruction should be BPF_LD | BPF_W | BPF_ABS
|
|
firstCode := instructions[0].Code
|
|
expectedFirst := uint16(unix.BPF_LD | unix.BPF_W | unix.BPF_ABS)
|
|
if firstCode != expectedFirst {
|
|
t.Errorf("first instruction code = 0x%x, want 0x%x (BPF_LD|BPF_W|BPF_ABS)", firstCode, expectedFirst)
|
|
}
|
|
|
|
// Second-to-last instruction should be BPF_RET (allow)
|
|
secondToLast := instructions[prog.Len-2]
|
|
expectedRet := uint16(unix.BPF_RET | unix.BPF_K)
|
|
if secondToLast.Code != expectedRet {
|
|
t.Errorf("second-to-last instruction code = 0x%x, want 0x%x (BPF_RET|BPF_K)", secondToLast.Code, expectedRet)
|
|
}
|
|
if secondToLast.K != unix.SECCOMP_RET_ALLOW {
|
|
t.Errorf("second-to-last instruction K = 0x%x, want 0x%x (SECCOMP_RET_ALLOW)", secondToLast.K, unix.SECCOMP_RET_ALLOW)
|
|
}
|
|
|
|
// Last instruction should be BPF_RET (notify)
|
|
last := instructions[prog.Len-1]
|
|
if last.Code != expectedRet {
|
|
t.Errorf("last instruction code = 0x%x, want 0x%x (BPF_RET|BPF_K)", last.Code, expectedRet)
|
|
}
|
|
if last.K != unix.SECCOMP_RET_USER_NOTIF {
|
|
t.Errorf("last instruction K = 0x%x, want 0x%x (SECCOMP_RET_USER_NOTIF)", last.K, unix.SECCOMP_RET_USER_NOTIF)
|
|
}
|
|
}
|
|
|
|
func TestDenyMode_Values(t *testing.T) {
|
|
if denyRead == denyWrite {
|
|
t.Error("denyRead and denyWrite should be distinct")
|
|
}
|
|
if denyRead == denyBoth {
|
|
t.Error("denyRead and denyBoth should be distinct")
|
|
}
|
|
if denyWrite == denyBoth {
|
|
t.Error("denyWrite and denyBoth should be distinct")
|
|
}
|
|
|
|
// Verify iota ordering
|
|
if denyRead != 0 {
|
|
t.Errorf("denyRead = %d, want 0", denyRead)
|
|
}
|
|
if denyWrite != 1 {
|
|
t.Errorf("denyWrite = %d, want 1", denyWrite)
|
|
}
|
|
if denyBoth != 2 {
|
|
t.Errorf("denyBoth = %d, want 2", denyBoth)
|
|
}
|
|
}
|
|
|
|
func TestWriteAuditEvent(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
|
|
evt := auditEvent{
|
|
Type: auditSeccompDeny,
|
|
Syscall: "openat",
|
|
Path: "/etc/passwd",
|
|
PID: 1234,
|
|
Message: "blocked",
|
|
Ts: 1700000000,
|
|
}
|
|
|
|
err := landlockWriteAuditEvent(&buf, evt)
|
|
if err != nil {
|
|
t.Fatalf("landlockWriteAuditEvent() returned error: %v", err)
|
|
}
|
|
|
|
output := buf.String()
|
|
|
|
// Should end with newline
|
|
if output[len(output)-1] != '\n' {
|
|
t.Error("output should end with newline")
|
|
}
|
|
|
|
// Should be valid JSON
|
|
var decoded auditEvent
|
|
if err := json.Unmarshal([]byte(output), &decoded); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
|
|
if decoded.Type != auditSeccompDeny {
|
|
t.Errorf("type = %q, want %q", decoded.Type, auditSeccompDeny)
|
|
}
|
|
if decoded.Syscall != "openat" {
|
|
t.Errorf("syscall = %q, want %q", decoded.Syscall, "openat")
|
|
}
|
|
if decoded.Path != "/etc/passwd" {
|
|
t.Errorf("path = %q, want %q", decoded.Path, "/etc/passwd")
|
|
}
|
|
if decoded.PID != 1234 {
|
|
t.Errorf("pid = %d, want %d", decoded.PID, 1234)
|
|
}
|
|
if decoded.Ts != 1700000000 {
|
|
t.Errorf("ts = %d, want %d", decoded.Ts, 1700000000)
|
|
}
|
|
}
|
|
|
|
func TestWriteAuditEvent_Omitempty(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
|
|
evt := auditEvent{
|
|
Type: auditNamespaceUnavailable,
|
|
Ts: 1700000000,
|
|
}
|
|
|
|
err := landlockWriteAuditEvent(&buf, evt)
|
|
if err != nil {
|
|
t.Fatalf("landlockWriteAuditEvent() returned error: %v", err)
|
|
}
|
|
|
|
output := buf.String()
|
|
|
|
// Parse as raw JSON to check which fields are present
|
|
var raw map[string]interface{}
|
|
if err := json.Unmarshal([]byte(output), &raw); err != nil {
|
|
t.Fatalf("output is not valid JSON: %v", err)
|
|
}
|
|
|
|
// These fields should be omitted due to omitempty
|
|
omittedFields := []string{"syscall", "path", "pid", "message", "error"}
|
|
for _, field := range omittedFields {
|
|
if _, ok := raw[field]; ok {
|
|
t.Errorf("field %q should be omitted when empty, but was present in output", field)
|
|
}
|
|
}
|
|
|
|
// These fields should be present
|
|
requiredFields := []string{"type", "ts"}
|
|
for _, field := range requiredFields {
|
|
if _, ok := raw[field]; !ok {
|
|
t.Errorf("field %q should be present in output", field)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsPathDenied_DenyRead(t *testing.T) {
|
|
deny := []denyPathEntry{
|
|
{Path: "/home/user/.env", Mode: denyRead},
|
|
}
|
|
|
|
// DenyRead should block O_RDONLY
|
|
if !isPathDenied("/home/user/.env", unix.O_RDONLY, deny) {
|
|
t.Error("denyRead should block O_RDONLY")
|
|
}
|
|
|
|
// DenyRead should block O_RDWR
|
|
if !isPathDenied("/home/user/.env", unix.O_RDWR, deny) {
|
|
t.Error("denyRead should block O_RDWR")
|
|
}
|
|
|
|
// DenyRead should allow O_WRONLY
|
|
if isPathDenied("/home/user/.env", unix.O_WRONLY, deny) {
|
|
t.Error("denyRead should allow O_WRONLY")
|
|
}
|
|
}
|
|
|
|
func TestIsPathDenied_DenyWrite(t *testing.T) {
|
|
deny := []denyPathEntry{
|
|
{Path: "/home/user/.env", Mode: denyWrite},
|
|
}
|
|
|
|
// DenyWrite should block O_WRONLY
|
|
if !isPathDenied("/home/user/.env", unix.O_WRONLY, deny) {
|
|
t.Error("denyWrite should block O_WRONLY")
|
|
}
|
|
|
|
// DenyWrite should block O_RDWR
|
|
if !isPathDenied("/home/user/.env", unix.O_RDWR, deny) {
|
|
t.Error("denyWrite should block O_RDWR")
|
|
}
|
|
|
|
// DenyWrite should allow O_RDONLY
|
|
if isPathDenied("/home/user/.env", unix.O_RDONLY, deny) {
|
|
t.Error("denyWrite should allow O_RDONLY")
|
|
}
|
|
}
|
|
|
|
func TestIsPathDenied_DenyBoth(t *testing.T) {
|
|
deny := []denyPathEntry{
|
|
{Path: "/home/user/.env", Mode: denyBoth},
|
|
}
|
|
|
|
if !isPathDenied("/home/user/.env", unix.O_RDONLY, deny) {
|
|
t.Error("denyBoth should block O_RDONLY")
|
|
}
|
|
if !isPathDenied("/home/user/.env", unix.O_WRONLY, deny) {
|
|
t.Error("denyBoth should block O_WRONLY")
|
|
}
|
|
if !isPathDenied("/home/user/.env", unix.O_RDWR, deny) {
|
|
t.Error("denyBoth should block O_RDWR")
|
|
}
|
|
}
|
|
|
|
func TestIsPathDenied_ExactMatch(t *testing.T) {
|
|
deny := []denyPathEntry{
|
|
{Path: "/home/user/.env", Mode: denyBoth},
|
|
}
|
|
|
|
if !isPathDenied("/home/user/.env", unix.O_RDONLY, deny) {
|
|
t.Error("exact match should be denied")
|
|
}
|
|
}
|
|
|
|
func TestIsPathDenied_NoPartialMatch(t *testing.T) {
|
|
deny := []denyPathEntry{
|
|
{Path: "/home/user/.env", Mode: denyBoth},
|
|
}
|
|
|
|
if isPathDenied("/home/user/.envrc", unix.O_RDONLY, deny) {
|
|
t.Error("/home/user/.envrc should NOT match deny /home/user/.env (no partial match)")
|
|
}
|
|
}
|
|
|
|
func TestIsPathDenied_DirectoryPrefix(t *testing.T) {
|
|
deny := []denyPathEntry{
|
|
{Path: "/home/user/.ssh/", Mode: denyBoth},
|
|
}
|
|
|
|
if !isPathDenied("/home/user/.ssh/id_rsa", unix.O_RDONLY, deny) {
|
|
t.Error("/home/user/.ssh/id_rsa should match deny /home/user/.ssh/")
|
|
}
|
|
|
|
if !isPathDenied("/home/user/.ssh/config", unix.O_WRONLY, deny) {
|
|
t.Error("/home/user/.ssh/config should match deny /home/user/.ssh/")
|
|
}
|
|
}
|
|
|
|
// Deny entries without a trailing slash are treated as "this path or anything
|
|
// beneath it" — matching how GetMandatoryDenyPatterns emits entries like
|
|
// "/home/user/.ssh" (no slash) that must cover "~/.ssh/id_rsa" too.
|
|
func TestIsPathDenied_DirectoryWithoutTrailingSlash(t *testing.T) {
|
|
deny := []denyPathEntry{
|
|
{Path: "/home/user/.ssh", Mode: denyBoth},
|
|
}
|
|
|
|
if !isPathDenied("/home/user/.ssh", unix.O_RDONLY, deny) {
|
|
t.Error("exact match on /home/user/.ssh should be denied")
|
|
}
|
|
if !isPathDenied("/home/user/.ssh/id_rsa", unix.O_RDONLY, deny) {
|
|
t.Error("/home/user/.ssh/id_rsa should match deny /home/user/.ssh (no trailing slash)")
|
|
}
|
|
// Must not false-match on similarly-prefixed siblings.
|
|
if isPathDenied("/home/user/.ssh2/id_rsa", unix.O_RDONLY, deny) {
|
|
t.Error("/home/user/.ssh2/id_rsa must NOT match deny /home/user/.ssh (no trailing slash)")
|
|
}
|
|
if isPathDenied("/home/user/.sshfoo", unix.O_RDONLY, deny) {
|
|
t.Error("/home/user/.sshfoo must NOT match deny /home/user/.ssh")
|
|
}
|
|
}
|
|
|
|
func TestIsPathDenied_NoMatch(t *testing.T) {
|
|
deny := []denyPathEntry{
|
|
{Path: "/home/user/.env", Mode: denyBoth},
|
|
{Path: "/home/user/.ssh/", Mode: denyBoth},
|
|
}
|
|
|
|
if isPathDenied("/home/user/safe.txt", unix.O_RDONLY, deny) {
|
|
t.Error("/home/user/safe.txt should not match any deny entry")
|
|
}
|
|
}
|
|
|
|
func TestIsExecDenied_Match(t *testing.T) {
|
|
denyExec := []string{"/usr/bin/curl", "/usr/bin/wget"}
|
|
|
|
if !isExecDenied("/usr/bin/curl", denyExec) {
|
|
t.Error("/usr/bin/curl should be denied")
|
|
}
|
|
}
|
|
|
|
func TestIsExecDenied_NoMatch(t *testing.T) {
|
|
denyExec := []string{"/usr/bin/curl", "/usr/bin/wget"}
|
|
|
|
if isExecDenied("/usr/bin/node", denyExec) {
|
|
t.Error("/usr/bin/node should not be denied")
|
|
}
|
|
}
|
|
|
|
func TestIsExecDenied_DirectoryPrefix(t *testing.T) {
|
|
denyExec := []string{"/usr/bin/"}
|
|
|
|
if !isExecDenied("/usr/bin/curl", denyExec) {
|
|
t.Error("/usr/bin/curl should match deny /usr/bin/")
|
|
}
|
|
if !isExecDenied("/usr/bin/node", denyExec) {
|
|
t.Error("/usr/bin/node should match deny /usr/bin/")
|
|
}
|
|
if isExecDenied("/usr/local/bin/node", denyExec) {
|
|
t.Error("/usr/local/bin/node should NOT match deny /usr/bin/")
|
|
}
|
|
}
|
|
|
|
func TestReadPathFromMem(t *testing.T) {
|
|
// Create a temporary file with a null-terminated path string to simulate
|
|
// process memory.
|
|
tmpFile, err := os.CreateTemp("", "test-mem-*")
|
|
if err != nil {
|
|
t.Fatalf("create temp file: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Remove(tmpFile.Name()); err != nil {
|
|
t.Logf("remove temp file: %v", err)
|
|
}
|
|
}()
|
|
defer func() {
|
|
if err := tmpFile.Close(); err != nil {
|
|
t.Logf("close temp file: %v", err)
|
|
}
|
|
}()
|
|
|
|
testPath := "/home/user/.env"
|
|
data := append([]byte(testPath), 0) // null-terminated
|
|
// Add some extra bytes after the null to simulate memory contents.
|
|
data = append(data, []byte("garbage data after null")...)
|
|
|
|
if _, err := tmpFile.Write(data); err != nil {
|
|
t.Fatalf("write temp file: %v", err)
|
|
}
|
|
|
|
// Re-open for reading.
|
|
memFd, err := os.Open(tmpFile.Name())
|
|
if err != nil {
|
|
t.Fatalf("open temp file: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := memFd.Close(); err != nil {
|
|
t.Logf("close mem fd: %v", err)
|
|
}
|
|
}()
|
|
|
|
result, err := readPathFromMem(memFd, 0)
|
|
if err != nil {
|
|
t.Fatalf("readPathFromMem() error: %v", err)
|
|
}
|
|
|
|
if result != testPath {
|
|
t.Errorf("readPathFromMem() = %q, want %q", result, testPath)
|
|
}
|
|
}
|
|
|
|
func TestReadPathFromMem_Offset(t *testing.T) {
|
|
tmpFile, err := os.CreateTemp("", "test-mem-offset-*")
|
|
if err != nil {
|
|
t.Fatalf("create temp file: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Remove(tmpFile.Name()); err != nil {
|
|
t.Logf("remove temp file: %v", err)
|
|
}
|
|
}()
|
|
defer func() {
|
|
if err := tmpFile.Close(); err != nil {
|
|
t.Logf("close temp file: %v", err)
|
|
}
|
|
}()
|
|
|
|
// Write padding, then a null-terminated path at a known offset.
|
|
padding := make([]byte, 100)
|
|
testPath := "/etc/passwd"
|
|
data := append(padding, append([]byte(testPath), 0)...)
|
|
if _, err := tmpFile.Write(data); err != nil {
|
|
t.Fatalf("write temp file: %v", err)
|
|
}
|
|
|
|
memFd, err := os.Open(tmpFile.Name())
|
|
if err != nil {
|
|
t.Fatalf("open temp file: %v", err)
|
|
}
|
|
defer func() {
|
|
if err := memFd.Close(); err != nil {
|
|
t.Logf("close mem fd: %v", err)
|
|
}
|
|
}()
|
|
|
|
result, err := readPathFromMem(memFd, 100)
|
|
if err != nil {
|
|
t.Fatalf("readPathFromMem() error: %v", err)
|
|
}
|
|
|
|
if result != testPath {
|
|
t.Errorf("readPathFromMem() = %q, want %q", result, testPath)
|
|
}
|
|
}
|
|
|
|
func TestResolveNotifPath_Absolute(t *testing.T) {
|
|
// Absolute paths should be returned cleaned, regardless of dirfd/pid.
|
|
result, err := resolveNotifPath(1, -100, "/home/user/.env")
|
|
if err != nil {
|
|
t.Fatalf("resolveNotifPath() error: %v", err)
|
|
}
|
|
if result != "/home/user/.env" {
|
|
t.Errorf("resolveNotifPath() = %q, want %q", result, "/home/user/.env")
|
|
}
|
|
|
|
// With .. components that should be cleaned.
|
|
result, err = resolveNotifPath(1, -100, "/home/user/../user/.env")
|
|
if err != nil {
|
|
t.Fatalf("resolveNotifPath() error: %v", err)
|
|
}
|
|
if result != "/home/user/.env" {
|
|
t.Errorf("resolveNotifPath() = %q, want %q", result, "/home/user/.env")
|
|
}
|
|
}
|
|
|
|
func TestResolveNotifPath_AT_FDCWD(t *testing.T) {
|
|
// This test requires /proc/<self>/cwd to be readable.
|
|
pid := os.Getpid()
|
|
cwdLink := fmt.Sprintf("/proc/%d/cwd", pid)
|
|
if _, err := os.Readlink(cwdLink); err != nil {
|
|
t.Skipf("cannot read %s: %v (skipping /proc-dependent test)", cwdLink, err)
|
|
}
|
|
|
|
cwd, _ := os.Getwd()
|
|
result, err := resolveNotifPath(uint32(pid), -100, "relative/path")
|
|
if err != nil {
|
|
t.Fatalf("resolveNotifPath() error: %v", err)
|
|
}
|
|
|
|
expected := cwd + "/relative/path"
|
|
if result != expected {
|
|
t.Errorf("resolveNotifPath() = %q, want %q", result, expected)
|
|
}
|
|
}
|
|
|
|
func TestDirfdFromArgs(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
val uint64
|
|
want int
|
|
}{
|
|
{"AT_FDCWD 32-bit", _AT_FDCWD_32, -100},
|
|
{"AT_FDCWD 64-bit", _AT_FDCWD_64, -100},
|
|
{"regular fd 3", 3, 3},
|
|
{"regular fd 0", 0, 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := dirfdFromArgs(tt.val)
|
|
if got != tt.want {
|
|
t.Errorf("dirfdFromArgs(0x%x) = %d, want %d", tt.val, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestClassifyOpenFlags_Openat(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
flags uint64
|
|
expected int
|
|
}{
|
|
{"O_RDONLY", uint64(unix.O_RDONLY), unix.O_RDONLY},
|
|
{"O_WRONLY", uint64(unix.O_WRONLY), unix.O_WRONLY},
|
|
{"O_RDWR", uint64(unix.O_RDWR), unix.O_RDWR},
|
|
{"O_WRONLY|O_CREAT", uint64(unix.O_WRONLY | unix.O_CREAT), unix.O_WRONLY},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
args := [6]uint64{0, 0, tt.flags, 0, 0, 0}
|
|
got := classifyOpenFlags(int32(unix.SYS_OPENAT), args, nil)
|
|
if got != tt.expected {
|
|
t.Errorf("classifyOpenFlags() = %d, want %d", got, tt.expected)
|
|
}
|
|
})
|
|
}
|
|
}
|