forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.6 KiB
1//! The seccomp profile for job processes, compiled with the pure-Rust `seccompiler` crate.
2//!
3//! The profile is default-allow: [`DENIED_SYSCALLS`] fail with EPERM and everything else runs.
4//! seccompiler resolves syscall names only through its JSON frontend, and that frontend rejects
5//! names unknown on the target arch. So each name is probed on its own, and the unknown ones are
6//! skipped. The filter is built in the helper before libcontainer forks, and installed by
7//! [`super::exec::FilteredExecutor`] just before the job execs.
8
9use seccompiler::{BpfProgram, TargetArch};
10use serde_json::json;
11
12use super::SandboxError;
13
14/// The errno returned for denied syscalls.
15pub const EPERM: u32 = 1;
16
17/// Syscalls denied with EPERM. Everything else is allowed.
18pub const DENIED_SYSCALLS: &[&str] = &[
19 "keyctl",
20 "add_key",
21 "request_key",
22 "bpf",
23 "perf_event_open",
24 "ptrace",
25 "mount",
26 "umount2",
27 "pivot_root",
28 "swapon",
29 "swapoff",
30 "reboot",
31 "kexec_load",
32 "kexec_file_load",
33 "init_module",
34 "finit_module",
35 "delete_module",
36 "unshare",
37 "setns",
38 "open_by_handle_at",
39 "userfaultfd",
40 "acct",
41 "settimeofday",
42 "clock_settime",
43 "syslog",
44 "iopl",
45 "ioperm",
46 "quotactl",
47 "name_to_handle_at",
48 "lookup_dcookie",
49];
50
51/// The seccompiler arch for the CPU this binary runs on.
52pub fn native_arch() -> Result<TargetArch, SandboxError> {
53 if cfg!(target_arch = "x86_64") {
54 Ok(TargetArch::x86_64)
55 } else if cfg!(target_arch = "aarch64") {
56 Ok(TargetArch::aarch64)
57 } else if cfg!(target_arch = "riscv64") {
58 Ok(TargetArch::riscv64)
59 } else {
60 Err(SandboxError::Setup(
61 "seccomp: unsupported CPU architecture".to_owned(),
62 ))
63 }
64}
65
66/// The denied syscalls that seccompiler knows on `arch`, in the order of [`DENIED_SYSCALLS`].
67pub fn resolved_names(arch: TargetArch) -> Vec<&'static str> {
68 DENIED_SYSCALLS
69 .iter()
70 .copied()
71 .filter(|name| compile(&[name], arch).is_ok())
72 .collect()
73}
74
75/// Builds the BPF program that denies [`DENIED_SYSCALLS`] with EPERM on this CPU.
76pub fn build_filter() -> Result<BpfProgram, SandboxError> {
77 let arch = native_arch()?;
78 let names = resolved_names(arch);
79 if names.is_empty() {
80 return Err(SandboxError::Setup(
81 "seccomp: no denied syscall is known on this arch".to_owned(),
82 ));
83 }
84 compile(&names, arch)
85}
86
87/// Compiles one filter that denies `names` with EPERM and allows everything else.
88fn compile(names: &[&str], arch: TargetArch) -> Result<BpfProgram, SandboxError> {
89 const FILTER: &str = "forge_job";
90 let rules: Vec<serde_json::Value> = names
91 .iter()
92 .map(|name| json!({ "syscall": name }))
93 .collect();
94 let document = json!({
95 FILTER: {
96 "mismatch_action": "allow",
97 "match_action": { "errno": EPERM },
98 "filter": rules,
99 }
100 });
101 let text = document.to_string();
102 let mut programs = seccompiler::compile_from_json(text.as_bytes(), arch)
103 .map_err(|e| SandboxError::Setup(format!("seccomp: cannot compile filter: {e}")))?;
104 programs
105 .remove(FILTER)
106 .ok_or_else(|| SandboxError::Setup("seccomp: compiled filter is missing".to_owned()))
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn native_arch_resolves_on_supported_cpus() {
115 // Only the CPUs this crate builds for are expected to resolve.
116 if cfg!(any(
117 target_arch = "x86_64",
118 target_arch = "aarch64",
119 target_arch = "riscv64"
120 )) {
121 assert!(native_arch().is_ok());
122 }
123 }
124
125 #[test]
126 fn the_whole_denied_list_is_known_on_x86_64() {
127 // Every name in the contract resolves on x86_64, so none is silently dropped there.
128 assert_eq!(resolved_names(TargetArch::x86_64), DENIED_SYSCALLS);
129 }
130
131 #[test]
132 fn unknown_names_are_skipped_and_known_ones_kept() {
133 let arch = native_arch().expect("arch");
134 let known = resolved_names(arch);
135 for name in DENIED_SYSCALLS {
136 // Names that resolve on this arch are kept, and the rest are dropped.
137 assert_eq!(
138 known.contains(name),
139 compile(&[name], arch).is_ok(),
140 "{name}"
141 );
142 }
143 assert!(compile(&["no_such_syscall_forge"], arch).is_err());
144 }
145
146 #[test]
147 fn filter_builds_and_is_a_bpf_program() {
148 let program = build_filter().expect("filter");
149 // A BPF program is a sequence of 8-byte instructions. It is never empty for a deny list.
150 assert!(!program.is_empty());
151 }
152
153 #[test]
154 fn denied_list_covers_the_contract() {
155 for expected in [
156 "keyctl",
157 "add_key",
158 "request_key",
159 "bpf",
160 "perf_event_open",
161 "ptrace",
162 "mount",
163 "umount2",
164 "pivot_root",
165 "swapon",
166 "swapoff",
167 "reboot",
168 "kexec_load",
169 "kexec_file_load",
170 "init_module",
171 "finit_module",
172 "delete_module",
173 "unshare",
174 "setns",
175 "open_by_handle_at",
176 "userfaultfd",
177 "acct",
178 "settimeofday",
179 "clock_settime",
180 "syslog",
181 "iopl",
182 "ioperm",
183 "quotactl",
184 "name_to_handle_at",
185 "lookup_dcookie",
186 ] {
187 assert!(DENIED_SYSCALLS.contains(&expected), "{expected} not denied");
188 }
189 assert_eq!(DENIED_SYSCALLS.len(), 30);
190 }
191}