From a0417d2150760d4bee55d06ae704691c88ee3db9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 20:57:01 +0000 Subject: [PATCH 1/2] Initial plan From 2c126cbd76fa9e4114718fa51641201792db5229 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:26:22 +0000 Subject: [PATCH 2/2] feat: harden seccomp profile with deny-by-default approach Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- AGENTS.md | 2 + containers/agent/seccomp-profile.json | 446 +++++++++++++++++++++++++- docs/architecture.md | 54 ++++ src/seccomp-validation.test.ts | 239 ++++++++++++++ 4 files changed, 728 insertions(+), 13 deletions(-) create mode 100644 src/seccomp-validation.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7c16761a..1753e73b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -219,6 +219,8 @@ The codebase follows a modular architecture with clear separation of concerns: - Mounts entire host filesystem at `/host` and user home directory for full access - `NET_ADMIN` capability required for iptables setup during initialization - **Security:** `NET_ADMIN` is dropped via `capsh --drop=cap_net_admin` before executing user commands, preventing malicious code from modifying iptables rules +- **Seccomp Profile:** Uses deny-by-default syscall filtering (`containers/agent/seccomp-profile.json`). Only syscalls required for normal operation (Node.js, curl, git, npm) are allowed; dangerous syscalls like `ptrace`, `mount`, `kexec_load`, and namespace manipulation are blocked. +- **Dropped Capabilities:** `NET_RAW`, `SYS_PTRACE`, `SYS_MODULE`, `SYS_RAWIO`, `MKNOD` to reduce attack surface - Two-stage entrypoint: 1. `setup-iptables.sh`: Configures iptables NAT rules to redirect HTTP/HTTPS traffic to Squid (agent container only) 2. `entrypoint.sh`: Drops NET_ADMIN capability, then executes user command as non-root user diff --git a/containers/agent/seccomp-profile.json b/containers/agent/seccomp-profile.json index 4c3cda60..131facbf 100644 --- a/containers/agent/seccomp-profile.json +++ b/containers/agent/seccomp-profile.json @@ -1,11 +1,367 @@ { - "defaultAction": "SCMP_ACT_ALLOW", + "defaultAction": "SCMP_ACT_ERRNO", + "defaultErrnoRet": 1, "architectures": [ "SCMP_ARCH_X86_64", "SCMP_ARCH_X86", "SCMP_ARCH_AARCH64" ], "syscalls": [ + { + "names": [ + "accept", + "accept4", + "access", + "alarm", + "bind", + "brk", + "capget", + "capset", + "chdir", + "chmod", + "chown", + "chown32", + "clock_getres", + "clock_getres_time64", + "clock_gettime", + "clock_gettime64", + "clock_nanosleep", + "clock_nanosleep_time64", + "clone", + "clone3", + "close", + "close_range", + "connect", + "copy_file_range", + "creat", + "dup", + "dup2", + "dup3", + "epoll_create", + "epoll_create1", + "epoll_ctl", + "epoll_ctl_old", + "epoll_pwait", + "epoll_pwait2", + "epoll_wait", + "epoll_wait_old", + "eventfd", + "eventfd2", + "execve", + "execveat", + "exit", + "exit_group", + "faccessat", + "faccessat2", + "fadvise64", + "fadvise64_64", + "fallocate", + "fchdir", + "fchmod", + "fchmodat", + "fchmodat2", + "fchown", + "fchown32", + "fchownat", + "fcntl", + "fcntl64", + "fdatasync", + "fgetxattr", + "flistxattr", + "flock", + "fork", + "fremovexattr", + "fsetxattr", + "fstat", + "fstat64", + "fstatat64", + "fstatfs", + "fstatfs64", + "fsync", + "ftruncate", + "ftruncate64", + "futex", + "futex_requeue", + "futex_time64", + "futex_wait", + "futex_waitv", + "futex_wake", + "futimesat", + "getcpu", + "getcwd", + "getdents", + "getdents64", + "getegid", + "getegid32", + "geteuid", + "geteuid32", + "getgid", + "getgid32", + "getgroups", + "getgroups32", + "getitimer", + "getpeername", + "getpgid", + "getpgrp", + "getpid", + "getppid", + "getpriority", + "getrandom", + "getresgid", + "getresgid32", + "getresuid", + "getresuid32", + "getrlimit", + "get_robust_list", + "getrusage", + "getsid", + "getsockname", + "getsockopt", + "get_thread_area", + "gettid", + "gettimeofday", + "getuid", + "getuid32", + "getxattr", + "inotify_add_watch", + "inotify_init", + "inotify_init1", + "inotify_rm_watch", + "io_cancel", + "ioctl", + "io_destroy", + "io_getevents", + "io_pgetevents", + "io_pgetevents_time64", + "ioprio_get", + "ioprio_set", + "io_setup", + "io_submit", + "io_uring_enter", + "io_uring_register", + "io_uring_setup", + "ipc", + "kill", + "lchown", + "lchown32", + "lgetxattr", + "link", + "linkat", + "listen", + "listxattr", + "llistxattr", + "_llseek", + "lremovexattr", + "lseek", + "lsetxattr", + "lstat", + "lstat64", + "madvise", + "membarrier", + "memfd_create", + "mincore", + "mkdir", + "mkdirat", + "mknod", + "mknodat", + "mlock", + "mlock2", + "mlockall", + "mmap", + "mmap2", + "mprotect", + "mq_getsetattr", + "mq_notify", + "mq_open", + "mq_timedreceive", + "mq_timedreceive_time64", + "mq_timedsend", + "mq_timedsend_time64", + "mq_unlink", + "mremap", + "msgctl", + "msgget", + "msgrcv", + "msgsnd", + "msync", + "munlock", + "munlockall", + "munmap", + "name_to_handle_at", + "nanosleep", + "newfstatat", + "_newselect", + "open", + "openat", + "openat2", + "pause", + "pidfd_open", + "pidfd_send_signal", + "pipe", + "pipe2", + "pkey_alloc", + "pkey_free", + "pkey_mprotect", + "poll", + "ppoll", + "ppoll_time64", + "prctl", + "pread64", + "preadv", + "preadv2", + "prlimit64", + "pselect6", + "pselect6_time64", + "pwrite64", + "pwritev", + "pwritev2", + "read", + "readahead", + "readlink", + "readlinkat", + "readv", + "recv", + "recvfrom", + "recvmmsg", + "recvmmsg_time64", + "recvmsg", + "remap_file_pages", + "removexattr", + "rename", + "renameat", + "renameat2", + "restart_syscall", + "rmdir", + "rseq", + "rt_sigaction", + "rt_sigpending", + "rt_sigprocmask", + "rt_sigqueueinfo", + "rt_sigreturn", + "rt_sigsuspend", + "rt_sigtimedwait", + "rt_sigtimedwait_time64", + "rt_tgsigqueueinfo", + "sched_getaffinity", + "sched_getattr", + "sched_getparam", + "sched_get_priority_max", + "sched_get_priority_min", + "sched_getscheduler", + "sched_rr_get_interval", + "sched_rr_get_interval_time64", + "sched_setaffinity", + "sched_setattr", + "sched_setparam", + "sched_setscheduler", + "sched_yield", + "seccomp", + "select", + "semctl", + "semget", + "semop", + "semtimedop", + "semtimedop_time64", + "send", + "sendfile", + "sendfile64", + "sendmmsg", + "sendmsg", + "sendto", + "setfsgid", + "setfsgid32", + "setfsuid", + "setfsuid32", + "setgid", + "setgid32", + "setgroups", + "setgroups32", + "setitimer", + "setpgid", + "setpriority", + "setregid", + "setregid32", + "setresgid", + "setresgid32", + "setresuid", + "setresuid32", + "setreuid", + "setreuid32", + "setrlimit", + "set_robust_list", + "setsid", + "setsockopt", + "set_thread_area", + "set_tid_address", + "setuid", + "setuid32", + "setxattr", + "shmat", + "shmctl", + "shmdt", + "shmget", + "shutdown", + "sigaltstack", + "signalfd", + "signalfd4", + "sigprocmask", + "sigreturn", + "socket", + "socketcall", + "socketpair", + "splice", + "stat", + "stat64", + "statfs", + "statfs64", + "statx", + "symlink", + "symlinkat", + "sync", + "sync_file_range", + "syncfs", + "sysinfo", + "tee", + "tgkill", + "time", + "timer_create", + "timer_delete", + "timer_getoverrun", + "timer_gettime", + "timer_gettime64", + "timer_settime", + "timer_settime64", + "timerfd_create", + "timerfd_gettime", + "timerfd_gettime64", + "timerfd_settime", + "timerfd_settime64", + "times", + "tkill", + "truncate", + "truncate64", + "ugetrlimit", + "umask", + "uname", + "unlink", + "unlinkat", + "utime", + "utimensat", + "utimensat_time64", + "utimes", + "vfork", + "vmsplice", + "wait4", + "waitid", + "waitpid", + "write", + "writev", + "arch_prctl", + "modify_ldt" + ], + "action": "SCMP_ACT_ALLOW", + "comment": "Allow syscalls required for normal container operation (Node.js, curl, git, npm)" + }, { "names": [ "ptrace", @@ -14,39 +370,103 @@ ], "action": "SCMP_ACT_ERRNO", "errnoRet": 1, - "comment": "Block process inspection/modification" + "comment": "Explicitly deny: Process inspection/modification - container escape vector" }, { "names": [ "kexec_load", "kexec_file_load", - "reboot", + "reboot" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Explicitly deny: System boot/kernel execution - host compromise" + }, + { + "names": [ "init_module", "finit_module", - "delete_module", - "acct", - "swapon", - "swapoff", + "delete_module" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Explicitly deny: Kernel module operations - rootkit installation" + }, + { + "names": [ "mount", "umount", "umount2", - "pivot_root", + "pivot_root" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Explicitly deny: Filesystem mount operations - container escape" + }, + { + "names": [ + "swapon", + "swapoff", + "acct", "syslog", - "add_key", - "request_key", - "keyctl", + "vhangup", "uselib", "personality", "ustat", "sysfs", - "vhangup", "get_kernel_syms", "query_module", "create_module", "nfsservctl" ], "action": "SCMP_ACT_ERRNO", - "errnoRet": 1 + "errnoRet": 1, + "comment": "Explicitly deny: Obsolete/dangerous system calls" + }, + { + "names": [ + "add_key", + "request_key", + "keyctl" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Explicitly deny: Kernel keyring operations - credential theft" + }, + { + "names": [ + "unshare", + "setns" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Explicitly deny: Namespace manipulation - container escape" + }, + { + "names": [ + "bpf", + "perf_event_open" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Explicitly deny: BPF/performance monitoring - kernel exploitation" + }, + { + "names": [ + "ioperm", + "iopl" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Explicitly deny: Raw I/O port access - hardware exploitation" + }, + { + "names": [ + "chroot" + ], + "action": "SCMP_ACT_ERRNO", + "errnoRet": 1, + "comment": "Explicitly deny: Change root directory - container escape" } ] } diff --git a/docs/architecture.md b/docs/architecture.md index 1f51721c..709ecabd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -216,3 +216,57 @@ Use `--keep-containers` to preserve containers and files after execution for deb - `execa`: Subprocess execution (docker-compose commands) - `js-yaml`: YAML generation for Docker Compose config - TypeScript 5.x, compiled to ES2020 CommonJS + +## Container Security Hardening + +The agent container implements multiple layers of security hardening to minimize the attack surface and prevent container escape attempts. + +### Seccomp Profile (Deny-by-Default) + +The agent container uses a seccomp (secure computing) profile that implements a **deny-by-default approach** for syscall filtering. This is defined in `containers/agent/seccomp-profile.json`. + +**Security Model:** +- **Default Action:** `SCMP_ACT_ERRNO` - All syscalls are denied by default +- **Explicit Allowlist:** Only syscalls required for normal operation (Node.js, curl, git, npm) are allowed +- **Explicit Denylist:** Dangerous syscalls are explicitly blocked with documented rationale + +**Allowed Syscall Categories:** +- **File I/O:** `read`, `write`, `open`, `openat`, `close`, `stat`, `fstat`, etc. +- **Networking:** `socket`, `connect`, `accept`, `bind`, `listen`, `send`, `recv`, etc. +- **Process Management:** `fork`, `clone`, `execve`, `wait4`, `exit`, `kill`, etc. +- **Memory Management:** `mmap`, `mprotect`, `munmap`, `brk`, etc. +- **Signal Handling:** `rt_sigaction`, `rt_sigprocmask`, `sigaltstack`, etc. +- **Async I/O:** `epoll_*`, `poll`, `select`, etc. +- **Time:** `clock_gettime`, `gettimeofday`, `nanosleep`, etc. + +**Explicitly Blocked Syscalls:** + +| Category | Syscalls | Risk | +|----------|----------|------| +| Process inspection | `ptrace`, `process_vm_readv`, `process_vm_writev` | Container escape vector | +| Kernel execution | `kexec_load`, `kexec_file_load`, `reboot` | Host compromise | +| Kernel modules | `init_module`, `finit_module`, `delete_module` | Rootkit installation | +| Mount operations | `mount`, `umount`, `umount2`, `pivot_root` | Container escape | +| Namespace manipulation | `unshare`, `setns` | Container escape | +| BPF/performance | `bpf`, `perf_event_open` | Kernel exploitation | +| Kernel keyring | `add_key`, `request_key`, `keyctl` | Credential theft | +| Raw I/O | `ioperm`, `iopl` | Hardware exploitation | +| Chroot | `chroot` | Container escape | + +### Dropped Capabilities + +The agent container drops the following Linux capabilities to reduce attack surface: + +- `NET_RAW` - Prevents raw socket creation (iptables bypass attempts) +- `SYS_PTRACE` - Prevents process inspection/debugging (container escape vector) +- `SYS_MODULE` - Prevents kernel module loading +- `SYS_RAWIO` - Prevents raw I/O access +- `MKNOD` - Prevents device node creation + +**Note:** `NET_ADMIN` capability is required for iptables setup but is dropped via `capsh --drop=cap_net_admin` before executing user commands. + +### Other Security Measures + +- **no-new-privileges:** Prevents privilege escalation via setuid binaries +- **Resource limits:** Memory limit (4GB), process limit (1000 PIDs) +- **Non-root user:** User commands execute as non-root `awfuser` diff --git a/src/seccomp-validation.test.ts b/src/seccomp-validation.test.ts new file mode 100644 index 00000000..a2d4cf0b --- /dev/null +++ b/src/seccomp-validation.test.ts @@ -0,0 +1,239 @@ +/** + * Seccomp Profile Validation Tests + * + * These tests verify the seccomp profile configuration: + * - Profile uses deny-by-default approach (SCMP_ACT_ERRNO as defaultAction) + * - Required syscalls for normal operation are allowed + * - Dangerous syscalls are explicitly blocked + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +describe('Seccomp Profile Validation', () => { + interface SeccompRule { + names: string[]; + action: string; + errnoRet?: number; + comment?: string; + } + + interface SeccompProfile { + defaultAction: string; + defaultErrnoRet?: number; + architectures: string[]; + syscalls: SeccompRule[]; + } + + let profile: SeccompProfile; + + beforeAll(() => { + const profilePath = path.join(__dirname, '../containers/agent/seccomp-profile.json'); + const content = fs.readFileSync(profilePath, 'utf-8'); + profile = JSON.parse(content); + }); + + describe('Profile Structure', () => { + test('should use deny-by-default approach', () => { + expect(profile.defaultAction).toBe('SCMP_ACT_ERRNO'); + expect(profile.defaultErrnoRet).toBe(1); + }); + + test('should support required architectures', () => { + expect(profile.architectures).toContain('SCMP_ARCH_X86_64'); + expect(profile.architectures).toContain('SCMP_ARCH_AARCH64'); + }); + + test('should have valid syscall rules', () => { + expect(profile.syscalls).toBeDefined(); + expect(Array.isArray(profile.syscalls)).toBe(true); + expect(profile.syscalls.length).toBeGreaterThan(0); + }); + }); + + describe('Allowed Syscalls', () => { + let allowedSyscalls: string[]; + + beforeAll(() => { + allowedSyscalls = profile.syscalls + .filter(rule => rule.action === 'SCMP_ACT_ALLOW') + .flatMap(rule => rule.names); + }); + + test('should allow basic file I/O syscalls', () => { + const fileIOSyscalls = [ + 'read', 'write', 'open', 'openat', 'close', 'stat', 'fstat', 'lstat', + 'lseek', 'mmap', 'mprotect', 'munmap', 'brk' + ]; + fileIOSyscalls.forEach(syscall => { + expect(allowedSyscalls).toContain(syscall); + }); + }); + + test('should allow networking syscalls for curl/git/npm', () => { + const networkSyscalls = [ + 'socket', 'connect', 'accept', 'accept4', 'bind', 'listen', + 'send', 'sendto', 'sendmsg', 'recv', 'recvfrom', 'recvmsg', + 'getsockopt', 'setsockopt', 'getsockname', 'getpeername' + ]; + networkSyscalls.forEach(syscall => { + expect(allowedSyscalls).toContain(syscall); + }); + }); + + test('should allow process management syscalls for Node.js', () => { + const processSyscalls = [ + 'fork', 'vfork', 'clone', 'clone3', 'execve', 'wait4', 'waitid', + 'exit', 'exit_group', 'kill', 'getpid', 'getppid' + ]; + processSyscalls.forEach(syscall => { + expect(allowedSyscalls).toContain(syscall); + }); + }); + + test('should allow signal handling syscalls', () => { + const signalSyscalls = [ + 'rt_sigaction', 'rt_sigprocmask', 'rt_sigreturn', 'sigaltstack' + ]; + signalSyscalls.forEach(syscall => { + expect(allowedSyscalls).toContain(syscall); + }); + }); + + test('should allow epoll/poll syscalls for async I/O', () => { + const pollSyscalls = [ + 'epoll_create', 'epoll_create1', 'epoll_ctl', 'epoll_wait', 'epoll_pwait', + 'poll', 'ppoll', 'select', 'pselect6' + ]; + pollSyscalls.forEach(syscall => { + expect(allowedSyscalls).toContain(syscall); + }); + }); + + test('should allow directory operations for git', () => { + const dirSyscalls = [ + 'mkdir', 'mkdirat', 'rmdir', 'rename', 'renameat', 'renameat2', + 'getcwd', 'chdir', 'getdents', 'getdents64' + ]; + dirSyscalls.forEach(syscall => { + expect(allowedSyscalls).toContain(syscall); + }); + }); + + test('should allow time-related syscalls', () => { + const timeSyscalls = [ + 'clock_gettime', 'clock_getres', 'gettimeofday', 'nanosleep', 'time' + ]; + timeSyscalls.forEach(syscall => { + expect(allowedSyscalls).toContain(syscall); + }); + }); + + test('should allow ioctl for terminal and device operations', () => { + expect(allowedSyscalls).toContain('ioctl'); + }); + }); + + describe('Denied Syscalls (Explicitly Blocked)', () => { + let deniedSyscalls: string[]; + + beforeAll(() => { + deniedSyscalls = profile.syscalls + .filter(rule => rule.action === 'SCMP_ACT_ERRNO') + .flatMap(rule => rule.names); + }); + + test('should explicitly block process inspection syscalls (container escape vector)', () => { + const ptraceSyscalls = ['ptrace', 'process_vm_readv', 'process_vm_writev']; + ptraceSyscalls.forEach(syscall => { + expect(deniedSyscalls).toContain(syscall); + }); + }); + + test('should explicitly block kernel execution syscalls (host compromise)', () => { + const kernelSyscalls = ['kexec_load', 'kexec_file_load', 'reboot']; + kernelSyscalls.forEach(syscall => { + expect(deniedSyscalls).toContain(syscall); + }); + }); + + test('should explicitly block kernel module syscalls (rootkit installation)', () => { + const moduleSyscalls = ['init_module', 'finit_module', 'delete_module']; + moduleSyscalls.forEach(syscall => { + expect(deniedSyscalls).toContain(syscall); + }); + }); + + test('should explicitly block mount syscalls (container escape)', () => { + const mountSyscalls = ['mount', 'umount', 'umount2', 'pivot_root']; + mountSyscalls.forEach(syscall => { + expect(deniedSyscalls).toContain(syscall); + }); + }); + + test('should explicitly block namespace manipulation syscalls (container escape)', () => { + const namespaceSyscalls = ['unshare', 'setns']; + namespaceSyscalls.forEach(syscall => { + expect(deniedSyscalls).toContain(syscall); + }); + }); + + test('should explicitly block BPF/perf syscalls (kernel exploitation)', () => { + const bpfSyscalls = ['bpf', 'perf_event_open']; + bpfSyscalls.forEach(syscall => { + expect(deniedSyscalls).toContain(syscall); + }); + }); + + test('should explicitly block kernel keyring syscalls (credential theft)', () => { + const keyringySyscalls = ['add_key', 'request_key', 'keyctl']; + keyringySyscalls.forEach(syscall => { + expect(deniedSyscalls).toContain(syscall); + }); + }); + + test('should explicitly block raw I/O syscalls (hardware exploitation)', () => { + const rawioSyscalls = ['ioperm', 'iopl']; + rawioSyscalls.forEach(syscall => { + expect(deniedSyscalls).toContain(syscall); + }); + }); + + test('should explicitly block chroot syscall (container escape)', () => { + expect(deniedSyscalls).toContain('chroot'); + }); + }); + + describe('Security Properties', () => { + test('should have more allowed syscalls than denied (comprehensive allowlist)', () => { + const allowedCount = profile.syscalls + .filter(rule => rule.action === 'SCMP_ACT_ALLOW') + .reduce((sum, rule) => sum + rule.names.length, 0); + + const deniedCount = profile.syscalls + .filter(rule => rule.action === 'SCMP_ACT_ERRNO') + .reduce((sum, rule) => sum + rule.names.length, 0); + + // With deny-by-default, we need more allowed syscalls than explicitly denied + expect(allowedCount).toBeGreaterThan(deniedCount); + // Ensure we have a reasonable number of allowed syscalls for functionality + expect(allowedCount).toBeGreaterThan(200); + }); + + test('all denied rules should have errnoRet set', () => { + const deniedRules = profile.syscalls.filter(rule => rule.action === 'SCMP_ACT_ERRNO'); + deniedRules.forEach(rule => { + expect(rule.errnoRet).toBeDefined(); + expect(rule.errnoRet).toBe(1); // EPERM + }); + }); + + test('all denied rules should have descriptive comments', () => { + const deniedRules = profile.syscalls.filter(rule => rule.action === 'SCMP_ACT_ERRNO'); + deniedRules.forEach(rule => { + expect(rule.comment).toBeDefined(); + expect(rule.comment!.length).toBeGreaterThan(10); + }); + }); + }); +});