Skip to main content

qk_executor/
recording.rs

1//! Recording what a task's commands do to files, with eBPF or strace.
2
3use std::path::{Path, PathBuf};
4
5use anyhow::Context;
6use qk_input_analysis::Access;
7use qk_input_analysis::strace::Limits;
8
9/// The recorder `QK_RECORDER` names.
10#[derive(Clone, Copy, Debug, PartialEq, Eq)]
11pub enum Choice {
12    /// eBPF where its helper can load it, else strace: unset or `auto`.
13    Auto,
14    Ebpf,
15    Strace,
16}
17
18impl Choice {
19    pub fn from_env() -> anyhow::Result<Self> {
20        Self::set(std::env::var_os("QK_RECORDER").as_deref())
21    }
22
23    /// The choice `QK_RECORDER`'s value makes; unset, `auto`.
24    fn set(value: Option<&std::ffi::OsStr>) -> anyhow::Result<Self> {
25        match value.map(std::ffi::OsStr::to_str) {
26            None => Ok(Self::Auto),
27            Some(Some(name)) => Self::named(name),
28            Some(None) => anyhow::bail!(
29                "unknown QK_RECORDER {value:?}, which is not UTF-8; use auto, ebpf or strace",
30                value = value.unwrap_or_default()
31            ),
32        }
33    }
34
35    fn named(name: &str) -> anyhow::Result<Self> {
36        match name {
37            "" | "auto" => Ok(Self::Auto),
38            "ebpf" => Ok(Self::Ebpf),
39            "strace" => Ok(Self::Strace),
40            other => anyhow::bail!("unknown QK_RECORDER {other:?}; use auto, ebpf or strace"),
41        }
42    }
43}
44
45/// Whether a recorder can record here.
46#[derive(Debug)]
47pub enum Probed<R = Recording> {
48    Ready(R),
49    /// Nothing records on this system.
50    Unsupported,
51    /// No recorder works here, and none was named: why the last one tried
52    /// cannot record.
53    Unavailable(anyhow::Error),
54}
55
56/// The recorder `choice` gives, of eBPF's and strace's: one named must
57/// record, and `auto` takes eBPF where it can, else strace. Only Linux
58/// records; elsewhere `auto` records nothing, and a recorder named fails.
59fn decide<R>(
60    choice: Choice,
61    linux: bool,
62    ebpf: impl FnOnce() -> Result<R, String>,
63    strace: impl FnOnce() -> anyhow::Result<R>,
64) -> anyhow::Result<Probed<R>> {
65    if !linux {
66        return match choice {
67            Choice::Auto => Ok(Probed::Unsupported),
68            Choice::Ebpf => anyhow::bail!(
69                "recording with eBPF needs Linux; unset QK_RECORDER or set it to auto"
70            ),
71            Choice::Strace => anyhow::bail!(
72                "recording with strace needs Linux; unset QK_RECORDER or set it to auto"
73            ),
74        };
75    }
76    match choice {
77        Choice::Ebpf => match ebpf() {
78            Ok(recording) => Ok(Probed::Ready(recording)),
79            Err(error) => anyhow::bail!("cannot record with eBPF: {error}"),
80        },
81        Choice::Strace => strace().map(Probed::Ready),
82        Choice::Auto => match ebpf() {
83            Ok(recording) => Ok(Probed::Ready(recording)),
84            Err(_) => Ok(match strace() {
85                Ok(recording) => Probed::Ready(recording),
86                Err(error) => Probed::Unavailable(error),
87            }),
88        },
89    }
90}
91
92/// An external syscall recorder wrapping each command, with separate trace files.
93#[derive(Clone, Debug)]
94pub struct Recording {
95    /// The recorder's program: strace, or qk itself, whose helper loads the
96    /// eBPF recorder.
97    pub program: PathBuf,
98    pub directory: PathBuf,
99    /// Whether strace filters the traced calls in the kernel, with seccomp,
100    /// so untraced calls run at full speed. strace 5.3 and later can.
101    pub seccomp: bool,
102    /// Whether it may key the task's result for `qk:ultracache`, as
103    /// `--record` gives it. A recording made only to audit the task does not.
104    keys: bool,
105    /// The eBPF recorder, and the recordings of this task's commands it has
106    /// begun; `None` when strace records.
107    #[cfg(target_os = "linux")]
108    ebpf: Option<Ebpf>,
109}
110
111#[cfg(target_os = "linux")]
112#[derive(Clone, Debug)]
113struct Ebpf {
114    recorder: std::sync::Arc<qk_trace::Recorder>,
115    sessions: std::sync::Arc<std::sync::Mutex<Vec<qk_trace::Session>>>,
116    /// Why settling failed, kept so that a later settle, as the cache's and
117    /// then an audit's of one recording, does not find it whole.
118    failure: std::sync::Arc<std::sync::Mutex<Option<String>>>,
119    /// Whether an opened file's path is recorded with what its links lead
120    /// to, as strace records it, which costs a lookup an open.
121    resolve: bool,
122}
123
124/// The eBPF recorder, its helper started once a process: as qk itself when
125/// qk runs as root, else through `sudo -n`, so that it is never asked for a
126/// password.
127#[cfg(target_os = "linux")]
128fn ebpf_recorder() -> Result<std::sync::Arc<qk_trace::Recorder>, String> {
129    static RECORDER: std::sync::OnceLock<Result<std::sync::Arc<qk_trace::Recorder>, String>> =
130        std::sync::OnceLock::new();
131    RECORDER
132        .get_or_init(|| {
133            let qk = std::env::current_exe().map_err(|error| error.to_string())?;
134            // SAFETY: geteuid has no preconditions.
135            let helper = if unsafe { libc::geteuid() } == 0 {
136                let mut helper = std::process::Command::new(qk);
137                helper.arg("__trace-helper");
138                helper
139            } else {
140                let mut helper = std::process::Command::new("sudo");
141                helper.args(["-n", "--"]).arg(qk).arg("__trace-helper");
142                helper
143            };
144            qk_trace::Recorder::start(helper).map_err(|error| format!("{error:#}"))
145        })
146        .clone()
147}
148
149impl Recording {
150    /// The recorder `QK_RECORDER` picks: by default eBPF, where its helper
151    /// can load it, else strace on `PATH`, checked to record child
152    /// processes, with seccomp filtering where it supports it. Its trial
153    /// traces go in `directory`. Err: `QK_RECORDER` misnamed, or the
154    /// recorder it names cannot record.
155    pub fn probe(directory: &Path) -> anyhow::Result<Probed> {
156        decide(
157            Choice::from_env()?,
158            cfg!(target_os = "linux"),
159            || Self::ebpf(directory),
160            || Self::strace(directory),
161        )
162    }
163
164    /// The recorder [`Self::probe`] picks, for what cannot go on without
165    /// one: the sandbox's audit, input analysis.
166    pub fn required(directory: &Path) -> anyhow::Result<Self> {
167        match Self::probe(directory)? {
168            Probed::Ready(recording) => Ok(recording),
169            Probed::Unavailable(error) => Err(error),
170            Probed::Unsupported => anyhow::bail!("recording needs Linux"),
171        }
172    }
173
174    #[cfg(target_os = "linux")]
175    fn ebpf(directory: &Path) -> Result<Self, String> {
176        let recorder = ebpf_recorder()?;
177        Ok(Self {
178            program: std::env::current_exe().map_err(|error| error.to_string())?,
179            directory: directory.to_owned(),
180            seccomp: false,
181            keys: false,
182            ebpf: Some(Ebpf {
183                recorder,
184                sessions: Default::default(),
185                failure: Default::default(),
186                resolve: false,
187            }),
188        })
189    }
190
191    #[cfg(not(target_os = "linux"))]
192    fn ebpf(_: &Path) -> Result<Self, String> {
193        Err("the eBPF recorder records only on Linux".into())
194    }
195
196    fn strace(directory: &Path) -> anyhow::Result<Self> {
197        let path = std::env::var_os("PATH").context("recording needs strace on PATH")?;
198        let program = std::env::split_paths(&path)
199            .map(|directory| directory.join("strace"))
200            .find(|candidate| candidate.is_file())
201            .context("recording needs strace on PATH; install strace and retry")?
202            .canonicalize()?;
203        let mut failure = String::new();
204        for seccomp in [true, false] {
205            let recording = Self {
206                program: program.clone(),
207                directory: directory.to_owned(),
208                seccomp,
209                keys: false,
210                #[cfg(target_os = "linux")]
211                ebpf: None,
212            };
213            let probe = recording
214                .command(0, "exec /bin/true", Path::new("/"))
215                .output()
216                .context("cannot probe strace")?;
217            if probe.status.success() {
218                return Ok(recording);
219            }
220            failure = String::from_utf8_lossy(&probe.stderr).into_owned();
221        }
222        anyhow::bail!("strace cannot record child processes: {failure}")
223    }
224
225    /// The recorder's name, as reports give it: `ebpf` or `strace`.
226    pub fn backend(&self) -> &'static str {
227        #[cfg(target_os = "linux")]
228        if self.ebpf.is_some() {
229            return "ebpf";
230        }
231        "strace"
232    }
233
234    /// The same settings, with traces in `directory`.
235    pub fn in_directory(&self, directory: PathBuf) -> Self {
236        Self {
237            directory,
238            #[cfg(target_os = "linux")]
239            ebpf: self.ebpf.as_ref().map(|ebpf| Ebpf {
240                recorder: ebpf.recorder.clone(),
241                sessions: Default::default(),
242                failure: Default::default(),
243                resolve: ebpf.resolve,
244            }),
245            ..self.clone()
246        }
247    }
248
249    /// The same, recording an opened file's path with what its links lead
250    /// to, as a report of what a task read shows it. strace always does.
251    pub fn resolving(self) -> Self {
252        #[cfg(target_os = "linux")]
253        {
254            let mut recording = self;
255            if let Some(ebpf) = &mut recording.ebpf {
256                ebpf.resolve = true;
257            }
258            recording
259        }
260        #[cfg(not(target_os = "linux"))]
261        self
262    }
263
264    /// The same, keying the task's result: see [`Self::keys`].
265    pub fn keying(self) -> Self {
266        Self { keys: true, ..self }
267    }
268
269    /// Whether it may key the task's result for `qk:ultracache`, as
270    /// `--record` gives it. A recording made only to audit the task does not.
271    pub fn keys(&self) -> bool {
272        self.keys
273    }
274
275    /// Ends the recordings of the commands that have run, once they have
276    /// finished, so that their traces are whole. strace's are as soon as it
277    /// exits; the eBPF recorder's once its helper has sent every event.
278    pub fn settle(&self) -> anyhow::Result<()> {
279        #[cfg(target_os = "linux")]
280        if let Some(ebpf) = &self.ebpf {
281            if let Some(failure) = ebpf.failure.lock().unwrap().as_ref() {
282                anyhow::bail!("{failure}");
283            }
284            let sessions = std::mem::take(&mut *ebpf.sessions.lock().unwrap());
285            let mut sessions = sessions.into_iter();
286            while let Some(session) = sessions.next() {
287                if let Err(error) = ebpf.recorder.finish(session) {
288                    *ebpf.failure.lock().unwrap() = Some(format!("{error:#}"));
289                    // The rest without waiting again: a helper that did not
290                    // answer once will not now.
291                    sessions
292                        .by_ref()
293                        .for_each(|rest| ebpf.recorder.abandon(rest));
294                    return Err(error);
295                }
296            }
297        }
298        Ok(())
299    }
300
301    /// What the commands that have run did, settled, within `limits`, with
302    /// what kept it from being complete; their paths relative to `cwd`,
303    /// where they started.
304    pub fn accesses(
305        &self,
306        cwd: &Path,
307        limits: &Limits,
308    ) -> anyhow::Result<(Vec<Access>, Vec<String>)> {
309        self.settle()?;
310        qk_input_analysis::strace::collect_within(&self.directory, cwd, limits)
311    }
312
313    /// The same, whole: what kept it from being complete, the first issue,
314    /// is the error.
315    pub fn whole_accesses(&self, cwd: &Path, limits: &Limits) -> Result<Vec<Access>, String> {
316        let (accesses, issues) = self
317            .accesses(cwd, limits)
318            .map_err(|error| format!("{error:#}"))?;
319        match issues.into_iter().next() {
320            Some(issue) => Err(issue),
321            None => Ok(accesses),
322        }
323    }
324
325    /// The same invocation is used by preflight and actual task commands,
326    /// which start in `cwd`.
327    pub fn command(&self, index: usize, text: &str, cwd: &Path) -> std::process::Command {
328        #[cfg(target_os = "linux")]
329        if let Some(ebpf) = &self.ebpf {
330            use std::os::unix::process::CommandExt;
331            let session = ebpf.recorder.begin(
332                &self.directory,
333                &format!("command-{index}"),
334                cwd,
335                ebpf.resolve,
336            );
337            ebpf.sessions.lock().unwrap().push(session);
338            let mut command = crate::process::shell(text);
339            // SAFETY: the hook makes one system call.
340            unsafe { command.pre_exec(ebpf.recorder.join(session)) };
341            return command;
342        }
343        let _ = cwd;
344        let mut command = std::process::Command::new(&self.program);
345        // No read/write buffers or exec argument/environment arrays.
346        if self.seccomp {
347            command.arg("--seccomp-bpf");
348        }
349        command
350            .args([
351                "-ff",
352                "-ttt",
353                "-yy",
354                "-qq",
355                "-s",
356                "4096",
357                "-e",
358                "trace=%file,%process,getdents64,fchdir,close,dup,dup2,dup3,getcwd",
359                "-e",
360                "raw=execve,execveat",
361                "-o",
362            ])
363            .arg(self.directory.join(format!("command-{index}")))
364            .args(["--", "/bin/sh", "-c", text]);
365        command
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn decided(
374        choice: Choice,
375        ebpf: Result<&'static str, &'static str>,
376        strace: Result<&'static str, &'static str>,
377    ) -> Result<Probed<&'static str>, String> {
378        decide(
379            choice,
380            true,
381            || ebpf.map_err(str::to_owned),
382            || strace.map_err(|error| anyhow::anyhow!(error)),
383        )
384        .map_err(|error| format!("{error:#}"))
385    }
386
387    fn ready(probed: Result<Probed<&'static str>, String>) -> Option<&'static str> {
388        match probed {
389            Ok(Probed::Ready(recording)) => Some(recording),
390            _ => None,
391        }
392    }
393
394    #[test]
395    fn auto_takes_ebpf_then_strace_and_goes_on_without_either() {
396        assert_eq!(
397            ready(decided(Choice::Auto, Ok("ebpf"), Ok("strace"))),
398            Some("ebpf")
399        );
400        assert_eq!(
401            ready(decided(Choice::Auto, Err("no"), Ok("strace"))),
402            Some("strace")
403        );
404        match decided(Choice::Auto, Err("no eBPF"), Err("no strace")) {
405            Ok(Probed::Unavailable(error)) => assert_eq!(error.to_string(), "no strace"),
406            other => panic!("{other:?}"),
407        }
408    }
409
410    #[test]
411    fn a_named_recorder_records_or_fails() {
412        assert_eq!(
413            ready(decided(Choice::Ebpf, Ok("ebpf"), Ok("strace"))),
414            Some("ebpf")
415        );
416        assert_eq!(
417            decided(Choice::Ebpf, Err("no helper"), Ok("strace")).unwrap_err(),
418            "cannot record with eBPF: no helper"
419        );
420        assert_eq!(
421            ready(decided(Choice::Strace, Ok("ebpf"), Ok("strace"))),
422            Some("strace")
423        );
424        assert_eq!(
425            decided(Choice::Strace, Ok("ebpf"), Err("no strace")).unwrap_err(),
426            "no strace"
427        );
428    }
429
430    #[test]
431    fn strace_named_is_never_ebpf() {
432        let probed = decide(
433            Choice::Strace,
434            true,
435            || -> Result<&str, String> { panic!("eBPF probed") },
436            || Ok("strace"),
437        );
438        assert_eq!(
439            ready(probed.map_err(|error| error.to_string())),
440            Some("strace")
441        );
442    }
443
444    #[test]
445    fn off_linux_auto_records_nothing_and_a_recorder_named_fails() {
446        let off = |choice| {
447            decide(
448                choice,
449                false,
450                || -> Result<&str, String> { panic!("eBPF probed") },
451                || panic!("strace probed"),
452            )
453        };
454        assert!(matches!(off(Choice::Auto), Ok(Probed::Unsupported)));
455        assert_eq!(
456            off(Choice::Ebpf).unwrap_err().to_string(),
457            "recording with eBPF needs Linux; unset QK_RECORDER or set it to auto"
458        );
459        assert_eq!(
460            off(Choice::Strace).unwrap_err().to_string(),
461            "recording with strace needs Linux; unset QK_RECORDER or set it to auto"
462        );
463    }
464
465    #[cfg(unix)]
466    #[test]
467    fn qk_recorder_unset_is_auto_and_not_utf8_fails() {
468        use std::os::unix::ffi::OsStrExt;
469        assert_eq!(Choice::set(None).unwrap(), Choice::Auto);
470        assert_eq!(
471            Choice::set(Some("strace".as_ref())).unwrap(),
472            Choice::Strace
473        );
474        let error = Choice::set(Some(std::ffi::OsStr::from_bytes(b"str\xffce")))
475            .unwrap_err()
476            .to_string();
477        assert!(error.starts_with("unknown QK_RECORDER "), "{error}");
478        assert!(
479            error.ends_with(", which is not UTF-8; use auto, ebpf or strace"),
480            "{error}"
481        );
482    }
483
484    #[test]
485    fn qk_recorder_names_one_of_three() {
486        assert_eq!(Choice::named("").unwrap(), Choice::Auto);
487        assert_eq!(Choice::named("auto").unwrap(), Choice::Auto);
488        assert_eq!(Choice::named("ebpf").unwrap(), Choice::Ebpf);
489        assert_eq!(Choice::named("strace").unwrap(), Choice::Strace);
490        assert_eq!(
491            Choice::named("strase").unwrap_err().to_string(),
492            "unknown QK_RECORDER \"strase\"; use auto, ebpf or strace"
493        );
494    }
495}