safe_cargo/
lib.rs

1use seatbelt::{Filter, Operation, Profile, allow, deny};
2use std::{env, fs, io, path::Path};
3
4pub fn prepare_profile(workspace: &Path, sandbox: &Path) -> Result<Profile, io::Error> {
5    use Filter::*;
6    use Operation::*;
7
8    let path_items = if let Ok(path) = env::var("PATH") {
9        path.split(":")
10            .map(|p| Prefix(p.to_owned()))
11            .collect::<Vec<_>>()
12    } else {
13        vec![]
14    };
15
16    let mut rules = vec![
17        deny(Default, None),
18        allow(ProcessAll, None),
19        allow(SysctlRead, None),
20        allow(MachLookup, None),
21        allow(IpcPosixShmReadData, None),
22        allow(UserPreferenceRead, None),
23        allow(FileReadMetadata, vec![Prefix("/".into())]),
24        allow(FileIoctl, vec![Literal("/dev/dtracehelper".into())]),
25        allow(
26            FileWriteAll,
27            vec![
28                Literal("/dev/dtracehelper".into()),
29                Literal("/dev/null".into()),
30                Literal("/dev/tty".into()),
31            ],
32        ),
33        // allowing to read binaries from PATH
34        allow(FileReadAll, path_items),
35        allow(
36            FileReadAll,
37            vec![
38                // System directories
39                Literal("/".into()), // <-- This should be Literal, not Prefix
40                Literal("/dev/autofs_nowait".into()),
41                Literal("/dev/urandom".into()),
42                Literal("/dev/random".into()),
43                Literal("/dev/null".into()),
44                Literal("/dev/tty".into()),
45                Literal("/dev/dtracehelper".into()),
46                Prefix("/private/etc/".into()),
47                Prefix("/private/var/db/timezone/".into()),
48                Prefix("/Applications/Xcode.app/Contents/Developer".into()),
49                Prefix("/usr/lib/".into()),
50                Prefix("/private/var/db/dyld/".into()),
51                Prefix("/System/Library/".into()),
52                Prefix("/System/Volumes/Preboot/Cryptexes/OS".into()),
53                Prefix("/System/Cryptexes/OS/".into()),
54                Prefix("/Library/Preferences/".into()),
55                Regex("/.CFUserTextEncoding$".into()),
56                Regex("/Cargo.(lock|toml)$".into()),
57                Regex("/.cargo/config$".into()),
58            ],
59        ),
60        // Outbound Network Access
61        allow(
62            NetworkOutbound,
63            vec![
64                RemoteIp("*:80".into()),
65                RemoteIp("*:443".into()),
66                RemoteUnixSocket("/private/var/run/mDNSResponder".into()),
67            ],
68        ),
69    ];
70
71    // RO Cargo Workspace directory
72    if let Some(workspace_dir) = workspace.to_str().map(|s| s.to_owned()) {
73        // Allowing to write Cargo.lock
74        rules.push(allow(
75            FileWriteCreate,
76            vec![Literal(format!("{}/Cargo.lock", &workspace_dir))],
77        ));
78        rules.push(allow(FileReadAll, vec![Prefix(workspace_dir)]));
79    }
80
81    // RO Rustup directory
82    if let Ok(home_dir) = env::var("HOME") {
83        rules.push(allow(
84            FileReadAll,
85            vec![Prefix(format!("{}/.rustup/", home_dir))],
86        ));
87        rules.push(deny(
88            FileReadMetadata,
89            vec![Prefix(format!("{}/.ssh/", home_dir))],
90        ));
91    }
92
93    // RW Temp directory
94    let tmp_dir = env::temp_dir();
95    let real_tmp_path = fs::canonicalize(tmp_dir)?;
96    if let Some(tmp_dir) = real_tmp_path.to_str() {
97        rules.push(allow(FileReadAll, vec![Prefix(tmp_dir.to_owned())]));
98        rules.push(allow(FileWriteAll, vec![Prefix(tmp_dir.to_owned())]));
99    }
100
101    // RW Sandbox path
102    if let Some(sandbox_path) = sandbox.to_str().map(|p| p.to_owned()) {
103        rules.push(allow(
104            FileWriteAll,
105            vec![
106                Prefix(format!("{}/cargo", sandbox_path)),
107                Prefix(format!("{}/target", sandbox_path)),
108            ],
109        ));
110    }
111
112    Ok(Profile(rules))
113}
114
115/// This module describe types that can be used with macOS seatbelt sandboxing mechanism
116/// See: https://reverse.put.as/wp-content/uploads/2011/09/Apple-Sandbox-Guide-v1.0.pdf
117mod seatbelt {
118    use std::fmt;
119
120    pub struct Profile(pub Vec<Rule>);
121
122    impl fmt::Display for Profile {
123        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124            writeln!(f, "(version 1)")?;
125            for rule in &self.0 {
126                writeln!(f, "{}", rule)?;
127            }
128            Ok(())
129        }
130    }
131
132    pub struct Rule(pub Action, pub Operation, pub Vec<Filter>);
133
134    impl fmt::Display for Rule {
135        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136            let Rule(action, op, filters) = &self;
137            if filters.is_empty() {
138                write!(f, "({} {})", action.as_str(), op.as_str())
139            } else {
140                writeln!(f, "({} {}", action.as_str(), op.as_str())?;
141                for filter in filters {
142                    writeln!(f, "  ({})", filter)?;
143                }
144                write!(f, ")")
145            }
146        }
147    }
148
149    pub enum Action {
150        Allow,
151        Deny,
152    }
153
154    impl Action {
155        pub fn as_str(&self) -> &'static str {
156            match self {
157                Action::Allow => "allow",
158                Action::Deny => "deny",
159            }
160        }
161    }
162
163    #[allow(unused)]
164    pub enum Operation {
165        Default,
166        FileAll,
167        FileWriteAll,
168        FileWriteCreate,
169        FileReadAll,
170        FileIoctl,
171        IpcPosixShmReadData,
172        UserPreferenceRead,
173        FileReadMetadata,
174        NetworkOutbound,
175        MachLookup,
176        IpcAll,
177        MachAll,
178        NetworkAll,
179        ProcessAll,
180        ProcessFork,
181        Signal,
182        SysctlAll,
183        SysctlRead,
184        SystemAll,
185    }
186
187    impl Operation {
188        pub fn as_str(&self) -> &'static str {
189            match self {
190                Operation::Default => "default",
191                Operation::FileAll => "file*",
192                Operation::FileWriteAll => "file-write*",
193                Operation::FileWriteCreate => "file-write-create",
194                Operation::FileReadAll => "file-read*",
195                Operation::FileIoctl => "file-ioctl",
196                Operation::IpcPosixShmReadData => "ipc-posix-shm-read-data",
197                Operation::UserPreferenceRead => "user-preference-read",
198                Operation::FileReadMetadata => "file-read-metadata",
199                Operation::NetworkOutbound => "network-outbound",
200                Operation::MachLookup => "mach-lookup",
201                Operation::IpcAll => "ipc*",
202                Operation::MachAll => "mach*",
203                Operation::NetworkAll => "network*",
204                Operation::ProcessAll => "process*",
205                Operation::ProcessFork => "process-fork",
206                Operation::Signal => "signal",
207                Operation::SysctlAll => "sysctl*",
208                Operation::SysctlRead => "sysctl-read",
209                Operation::SystemAll => "system*",
210            }
211        }
212    }
213
214    pub enum Filter {
215        Literal(String),
216        Prefix(String),
217        Regex(String),
218        RemoteIp(String),
219        RemoteUnixSocket(String),
220    }
221
222    impl fmt::Display for Filter {
223        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
224            match self {
225                Filter::Literal(p) => write!(f, "literal \"{p}\""),
226                Filter::Prefix(p) => write!(f, r#"prefix "{p}""#),
227                Filter::Regex(r) => write!(f, r#"regex #"{r}""#),
228                Filter::RemoteIp(a) => write!(f, r#"remote ip "{a}""#),
229                Filter::RemoteUnixSocket(p) => {
230                    write!(f, r#"remote unix-socket (path-literal "{p}")"#)
231                }
232            }
233        }
234    }
235
236    pub fn deny(op: Operation, filters: impl IntoIterator<Item = Filter>) -> Rule {
237        Rule(Action::Deny, op, filters.into_iter().collect())
238    }
239
240    pub fn allow(op: Operation, filters: impl IntoIterator<Item = Filter>) -> Rule {
241        Rule(Action::Allow, op, filters.into_iter().collect())
242    }
243
244    #[cfg(test)]
245    mod tests {
246        use super::*;
247        use Filter::*;
248        use Operation::*;
249        use indoc::indoc;
250
251        #[test]
252        fn check_file_prefix_filter() {
253            let profile = Profile(vec![allow(
254                FileAll,
255                vec![Prefix("/home".into()), Prefix("/bin".into())],
256            )]);
257
258            let expected_str = indoc! {r#"
259                (version 1)
260                (allow file*
261                  (prefix "/home")
262                  (prefix "/bin")
263                )
264            "#};
265            assert_eq!(profile.to_string(), expected_str);
266        }
267
268        #[test]
269        fn check_network_outbound() {
270            let profile = Profile(vec![deny(
271                NetworkOutbound,
272                vec![RemoteIp("192.168.1.1".into())],
273            )]);
274
275            let expected_str = indoc! {r#"
276                (version 1)
277                (deny network-outbound
278                  (remote ip "192.168.1.1")
279                )
280            "#};
281            assert_eq!(profile.to_string(), expected_str);
282        }
283
284        #[test]
285        fn check_multiple_rules() {
286            let profile = Profile(vec![
287                allow(FileReadMetadata, vec![Prefix("/etc".into())]),
288                deny(ProcessFork, None),
289            ]);
290
291            let expected_str = indoc! {r#"
292                (version 1)
293                (allow file-read-metadata
294                  (prefix "/etc")
295                )
296                (deny process-fork)
297            "#};
298            assert_eq!(profile.to_string(), expected_str);
299        }
300
301        #[test]
302        fn check_complex_filters() {
303            let profile = Profile(vec![allow(
304                IpcAll,
305                vec![
306                    Regex(".*\\.shm".into()),
307                    RemoteUnixSocket("/var/run/socket".into()),
308                ],
309            )]);
310
311            let expected_str = indoc! {r#"
312                (version 1)
313                (allow ipc*
314                  (regex #".*\.shm")
315                  (remote unix-socket (path-literal "/var/run/socket"))
316                )
317            "#};
318            assert_eq!(profile.to_string(), expected_str);
319        }
320    }
321}