mirror of
https://github.com/storytold/gridcraft.git
synced 2026-11-12 08:53:03 -06:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3810dac673 | ||
|
|
b0bd6f8660 | ||
|
|
5d7f5d7fa1 |
@@ -27,10 +27,6 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -78,6 +78,8 @@ fn the_self_test_finds_every_denial() {
|
||||
] {
|
||||
assert!(out.contains(line), "missing `{line}` in:\n{out}");
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
assert!(out.contains("denied: switch a descriptor's notifications on"), "missing the notification switch in:\n{out}");
|
||||
std::fs::remove_dir_all(home).unwrap();
|
||||
}
|
||||
|
||||
@@ -181,7 +183,9 @@ fn system_python() -> Option<(String, String)> {
|
||||
/// Confined, a program can't signal another process (this test, signal 0 delivering nothing), but can
|
||||
/// still signal itself and its own threads. On Linux, where the filter names them, it also can't
|
||||
/// change another process's limits or priority, or type into a terminal: no call here reaches a real
|
||||
/// terminal, since its standard input isn't one.
|
||||
/// terminal, since its standard input isn't one. Linux also refuses the `fcntl` commands that would
|
||||
/// have the kernel signal a process for it: naming the owner, choosing the signal, or switching the
|
||||
/// notification on.
|
||||
#[test]
|
||||
fn a_confined_program_leaves_other_processes_alone() {
|
||||
if !confines() {
|
||||
@@ -226,6 +230,13 @@ if sys.platform == "linux":
|
||||
assert refused(os.setpriority, os.PRIO_USER, 0, 0), "every process's priority"
|
||||
assert refused(os.sched_setaffinity, other, {0}), "another process's processors"
|
||||
assert refused(os.pidfd_open, other), "a handle on another process"
|
||||
read, write = os.pipe()
|
||||
assert refused(fcntl.fcntl, write, fcntl.F_SETOWN, other), "giving a descriptor's notifications to another process"
|
||||
# 15 is F_SETOWN_EX and 10 is F_SETSIG, which Python's fcntl doesn't name; the filter refuses
|
||||
# them whatever owner or signal they name, so the argument doesn't matter.
|
||||
assert refused(fcntl.fcntl, write, 15), "naming a descriptor's owner the extended way"
|
||||
assert refused(fcntl.fcntl, write, 10), "choosing a descriptor's notification signal"
|
||||
assert refused(fcntl.fcntl, write, fcntl.F_SETFL, fcntl.fcntl(write, fcntl.F_GETFL) | os.O_ASYNC), "switching a descriptor's notifications on"
|
||||
resource.prlimit(0, resource.RLIMIT_NOFILE)
|
||||
resource.prlimit(os.getpid(), resource.RLIMIT_NOFILE)
|
||||
os.setpriority(os.PRIO_PROCESS, 0, os.getpriority(os.PRIO_PROCESS, 0))
|
||||
@@ -240,6 +251,47 @@ print("left alone")
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("left alone"), "{}", text(&o));
|
||||
}
|
||||
|
||||
/// A socket the parent made, which the program inherited as a standard stream, can't be pointed at
|
||||
/// another process through the ioctls that do what `fcntl`'s notification commands do: `FIOSETOWN`
|
||||
/// (0x8901) and `FIOASYNC` (0x5452), which Python's `fcntl` passes straight through. Linux only:
|
||||
/// macOS's profile has no rule for them, and its standard streams here are pipes.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn a_confined_program_cant_point_an_inherited_socket_at_another_process() {
|
||||
if !confines() {
|
||||
return;
|
||||
}
|
||||
let Some((python, prefix)) = system_python() else {
|
||||
return;
|
||||
};
|
||||
let code = r#"
|
||||
import fcntl, sys
|
||||
other = int(sys.argv[1])
|
||||
def refused(call, *args):
|
||||
try:
|
||||
call(*args)
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError as e:
|
||||
print("other error", e)
|
||||
return False
|
||||
assert refused(fcntl.ioctl, 0, 0x8901, other), "giving a socket's notifications to another process"
|
||||
assert refused(fcntl.ioctl, 0, 0x5452, 1), "switching a socket's notifications on"
|
||||
print("socket quiet")
|
||||
"#;
|
||||
let (mine, _) = std::os::unix::net::UnixStream::pair().unwrap();
|
||||
let socket: std::os::fd::OwnedFd = mine.into();
|
||||
let me = std::process::id().to_string();
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
|
||||
.args(["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me])
|
||||
.stdin(std::process::Stdio::from(socket))
|
||||
.output()
|
||||
.unwrap();
|
||||
let out = text(&o);
|
||||
assert!(o.status.success(), "{out}");
|
||||
assert!(String::from_utf8_lossy(&o.stdout).contains("socket quiet"), "{out}");
|
||||
}
|
||||
|
||||
/// Confined, a program changes no file, whether the policy lets it read that file or not, by path or
|
||||
/// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags.
|
||||
/// Skipped when there is no `python3`.
|
||||
|
||||
@@ -467,19 +467,11 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
|
||||
if let Some(t0) = t0 {
|
||||
eprintln!("commit: + rows {:?}", since(t0));
|
||||
}
|
||||
// A grant to edit a protected range belongs to the sheet and range it was given for: a sheet that
|
||||
// was renamed, deleted or replaced by another of the same name no longer carries it.
|
||||
purge_unlock_grants(&mut d.unlocked_ranges, &wb);
|
||||
d.wb = std::sync::Arc::new(wb);
|
||||
d.selection = sel;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Drops the grants to edit protected ranges whose sheet name or range title `wb` no longer has.
|
||||
pub(crate) fn purge_unlock_grants(grants: &mut Vec<(String, String)>, wb: &Workbook) {
|
||||
grants.retain(|(sheet, title)| wb.sheets.iter().any(|sh| sh.name == *sheet && sh.edit_ranges.iter().any(|r| r.title == *title)));
|
||||
}
|
||||
|
||||
/// "Set precision as displayed": a number typed, pasted or reformatted is stored as its format
|
||||
/// shows it (Excel rounds a constant when it is entered and when its cell's format changes,
|
||||
/// observed through COM). Only cells the edit touched are visited, so this costs nothing for
|
||||
|
||||
@@ -209,7 +209,6 @@ fn undo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
crate::push_history(&mut d.redo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
crate::cmd::purge_unlock_grants(&mut d.unlocked_ranges, &d.wb);
|
||||
d.calc.rebuild(&d.wb);
|
||||
d.revision += 1;
|
||||
Ok(json!({"undone": label}))
|
||||
@@ -231,7 +230,6 @@ fn redo(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
crate::push_history(&mut d.undo, label.clone(), cur_wb, &d.wb, cur_selection);
|
||||
}
|
||||
super::extra::keep_watch_list(&now, &mut d.wb);
|
||||
crate::cmd::purge_unlock_grants(&mut d.unlocked_ranges, &d.wb);
|
||||
d.calc.rebuild(&d.wb);
|
||||
d.revision += 1;
|
||||
Ok(json!({"redone": label}))
|
||||
|
||||
@@ -75,9 +75,6 @@ fn denied_script_step(id: &str, params: &Json) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Commands that read the session's clipboard, which a script may use only for a copy made in its own workbook.
|
||||
const CLIPBOARD_READERS: [&str; 3] = ["edit.paste", "edit.pasteSpecial", "home.insertCopiedCells"];
|
||||
|
||||
pub(super) fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
// Only a missing `trusted` means "trust"; `"false"`, `0` or `null` are mistakes, not consent.
|
||||
let trusted = match p.get("trusted") {
|
||||
@@ -89,23 +86,6 @@ pub(super) fn trust_scripts(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
Ok(json!({"trusted": trusted}))
|
||||
}
|
||||
|
||||
/// Whether step `id` with `params` takes cells from the session clipboard: not `edit.paste` of explicit
|
||||
/// text that differs from it (that pastes the text, see `edit::clipboard::paste`).
|
||||
fn reads_clipboard(s: &Session, id: &str, params: &Json) -> bool {
|
||||
if !CLIPBOARD_READERS.contains(&id) {
|
||||
return false;
|
||||
}
|
||||
let trim = |t: &str| t.trim_end_matches(['\r', '\n']).to_string();
|
||||
match (id, str_param(params, "text")) {
|
||||
("edit.paste", Some(text)) => s.clipboard.as_ref().is_some_and(|c| trim(&c.text) == trim(text)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn d_uid(s: &Session) -> Result<u64> {
|
||||
Ok(s.doc()?.uid)
|
||||
}
|
||||
|
||||
/// Runs a stored script. Scripts in a workbook opened from a file run only once the workbook is
|
||||
/// trusted for this session (`automate.trustScripts`, or `trusted: true` on this call); steps that
|
||||
/// reach outside the workbook ([`denied_script_step`]) are refused before any step runs.
|
||||
@@ -128,7 +108,6 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
}
|
||||
}
|
||||
let _sandbox = crate::io::ScriptSandbox::enter();
|
||||
let home = d_uid(s)?;
|
||||
let mut n = 0;
|
||||
// What ran, for a recorder in progress: the recording keeps these steps with the run, so
|
||||
// editing or deleting the script before the recording stops does not change what it saved.
|
||||
@@ -138,25 +117,9 @@ pub(super) fn run_script(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if id.starts_with("automate.") {
|
||||
continue;
|
||||
}
|
||||
if reads_clipboard(s, id, &st["params"]) && s.clipboard.as_ref().is_some_and(|c| c.doc_uid != home) {
|
||||
return Err(bad("automate.runScript", format!("scripts can't run `{id}` on cells copied from another workbook")));
|
||||
}
|
||||
let before = s.ui_requests.len();
|
||||
let wb_before = s.doc().ok().map(|d| (d.uid, d.wb.clone()));
|
||||
// A stored step is not the person: an `approve` it carries is not consent to run a macro.
|
||||
let mut params = st["params"].clone();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("approve");
|
||||
}
|
||||
let ran = s.execute(id, params);
|
||||
// A step (a nested macro, an alias of a window command) that switched windows would point the
|
||||
// rest of the script at another workbook: put the script's own back and stop.
|
||||
if s.doc().map(|d| d.uid).ok() != Some(home) {
|
||||
if let Some(i) = s.docs.iter().position(|d| d.uid == home) {
|
||||
s.set_active(i);
|
||||
}
|
||||
return Err(bad("automate.runScript", format!("`{id}` switched to another workbook; scripts stay in their own")));
|
||||
}
|
||||
let ran = s.execute(id, st["params"].clone());
|
||||
// A step that asks the host to open a URL leaves the app: drop the request and stop.
|
||||
if s.ui_requests.get(before..).is_some_and(|new| new.iter().any(|r| matches!(r, crate::UiRequest::OpenUrl(_)))) {
|
||||
let mut i = 0;
|
||||
|
||||
@@ -6480,34 +6480,6 @@ fn clearing_links_and_notes_keeps_allow_edit_ranges() {
|
||||
assert!(sh.hyperlinks.is_empty() && sh.comments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_range_grant_does_not_outlive_its_sheet_name() {
|
||||
let mut s = s();
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Kept", "range": "C5", "password": "example"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Kept", "password": "example"})).unwrap();
|
||||
assert!(!s.doc().unwrap().unlocked_ranges.is_empty());
|
||||
// The sheet is renamed and another takes its name, with a range of the same title and password.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
let old = s.doc().unwrap().wb.active().unwrap().name.clone();
|
||||
s.execute("sheet.rename", json!({"name": "Renamed"})).unwrap();
|
||||
assert!(s.doc().unwrap().unlocked_ranges.is_empty(), "the grant went with the old name");
|
||||
s.execute("home.insertSheet", json!({"name": old})).unwrap();
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Kept", "range": "C5", "password": "other"})).unwrap();
|
||||
s.execute("review.protectSheet", json!({})).unwrap();
|
||||
assert!(s.execute("cell.set", json!({"cell": "C5", "input": "1"})).is_err(), "the new sheet's range is locked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_drops_the_grant_of_a_range_it_removes() {
|
||||
let mut s = s();
|
||||
s.execute("review.allowEditRanges", json!({"action": "add", "title": "Kept", "range": "C5", "password": "example"})).unwrap();
|
||||
s.execute("review.allowEditRanges", json!({"action": "unlock", "title": "Kept", "password": "example"})).unwrap();
|
||||
assert!(!s.doc().unwrap().unlocked_ranges.is_empty());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().unlocked_ranges.is_empty(), "the range is gone, so is its grant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formulatext_does_not_reveal_a_hidden_formula() {
|
||||
let mut s = s();
|
||||
|
||||
@@ -2514,40 +2514,3 @@ fn a_step_that_deletes_a_form_control_after_a_partial_footprint_is_caught() {
|
||||
s.execute("macro.run", json!({"name": "Drop", "inputs": "A1", "overwrite": true})).unwrap();
|
||||
assert!(s.doc().unwrap().wb.active().unwrap().controls.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_cannot_paste_what_another_workbook_copied() {
|
||||
let mut s = s();
|
||||
let steps = json!([{"command": "edit.paste", "params": {"at": "B2"}}]);
|
||||
s.execute("automate.newScript", json!({"name": "Paste", "commands": steps})).unwrap();
|
||||
// A copy made in this workbook may be pasted by its script.
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "mine"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "Paste"})).unwrap();
|
||||
// A copy from another workbook is not read.
|
||||
s.execute("file.new", json!({})).unwrap();
|
||||
s.execute("cell.set", json!({"cell": "A1", "input": "secret"})).unwrap();
|
||||
s.execute("edit.copy", json!({"range": "A1"})).unwrap();
|
||||
s.set_active(0);
|
||||
let err = s.execute("automate.runScript", json!({"name": "Paste"})).unwrap_err().to_string();
|
||||
assert!(err.contains("another workbook"), "{err}");
|
||||
// Explicit text that is not the clipboard's does not read it.
|
||||
let own = json!([{"command": "edit.paste", "params": {"at": "C3", "text": "typed"}}]);
|
||||
s.execute("automate.newScript", json!({"name": "Typed", "commands": own})).unwrap();
|
||||
s.execute("automate.runScript", json!({"name": "Typed"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_script_step_cannot_approve_a_macro_for_the_person() {
|
||||
let mut s = s();
|
||||
let mut wb = gridcraft_model::Workbook::new();
|
||||
wb.macros.push(crate::macros::parse_macro(&json!({"name": "Theirs", "output": {"onConflict": "overwrite"}, "steps": [{"command": "cell.set", "params": {"cell": "A1", "input": "ran"}}]})).unwrap());
|
||||
let bytes = serde_json::to_vec(&wb).unwrap();
|
||||
s.execute("file.open", json!({"name": "theirs.json", "base64": crate::io::base64_encode(&bytes)})).unwrap();
|
||||
let steps = json!([{"command": "macro.run", "params": {"name": "Theirs", "approve": true}}]);
|
||||
s.execute("automate.newScript", json!({"name": "Run", "commands": steps})).unwrap();
|
||||
let r = s.execute("automate.runScript", json!({"name": "Run", "trusted": true}));
|
||||
assert!(matches!(r, Err(EngineError::Approve(_))), "{r:?}");
|
||||
assert!(s.macro_approvals.is_empty(), "the step's flag approved nothing");
|
||||
assert!(v(&s, "A1").is_empty());
|
||||
}
|
||||
|
||||
@@ -268,12 +268,38 @@ const TIOCLINUX: u64 = libc::TIOCLINUX as u64;
|
||||
const IOC_WRITE: u64 = 0x4000_0000;
|
||||
/// Changes a directory, though its number says it only reads, as the kernel's headers have it.
|
||||
const FS_IOC_SET_ENCRYPTION_POLICY: u64 = 0x800C_6613;
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
const F_SETOWN: u64 = libc::F_SETOWN as u64;
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
const F_SETFL: u64 = libc::F_SETFL as u64;
|
||||
#[allow(clippy::unnecessary_cast)]
|
||||
const O_ASYNC: u64 = libc::O_ASYNC as u64;
|
||||
/// The two `fcntl` commands of the same family that the kernel's headers name and `libc` doesn't;
|
||||
/// the numbers are the same on every architecture (`asm-generic/fcntl.h`).
|
||||
const F_SETOWN_EX: u64 = 15;
|
||||
const F_SETSIG: u64 = 10;
|
||||
/// Socket ioctls that do what those commands do: `FIOSETOWN` and `SIOCSPGRP` name the process to
|
||||
/// notify, and `FIOASYNC` switches the notification on. Their requests carry no data-direction
|
||||
/// bit, so the [`IOC_WRITE`] rule doesn't name them. `libc` doesn't name them; the numbers are
|
||||
/// the kernel's, the same on every architecture (`asm-generic/sockios.h`).
|
||||
const FIOSETOWN: u64 = 0x8901;
|
||||
const SIOCSPGRP: u64 = 0x8902;
|
||||
const FIOASYNC: u64 = 0x5452;
|
||||
|
||||
/// What the main filter refuses with `EPERM`: the calls above, and these when their arguments match.
|
||||
/// - `clone` without `CLONE_THREAD`: a new process.
|
||||
/// - A signal to any process but `me`, whose ID `exec` keeps. Landlock's signal scope needs a newer
|
||||
/// kernel than the rest (ABI 6), and `kill(-1, 9)` must not get through on an older one.
|
||||
/// - A change to the limits, priority or scheduling of any process but `me`.
|
||||
/// - The `fcntl` commands that arrange asynchronous notification: naming the process to notify
|
||||
/// (`F_SETOWN`, `F_SETOWN_EX`), choosing its signal (`F_SETSIG`), and `F_SETFL` when it sets
|
||||
/// `O_ASYNC`, the switch without which nothing is sent. None is a signal call, and the only rule
|
||||
/// that would cover them is Landlock's signal scope, which needs ABI 6, newer than a launch
|
||||
/// requires; without these a confined program could have the kernel signal any process of its
|
||||
/// user. `F_SETFL` itself still works, as `O_NONBLOCK` comes from it. A socket answers the same
|
||||
/// arrangement through ioctls — `FIOSETOWN` and `SIOCSPGRP` to name the process, `FIOASYNC` to
|
||||
/// switch the notification on — and those requests carry no data-direction bit, so they are
|
||||
/// named alongside the others.
|
||||
/// - `TIOCSTI` and `TIOCLINUX`, which type into a terminal the program inherited, as when
|
||||
/// `gridcraft-cli sandbox-check` runs a program from a shell.
|
||||
/// - An `ioctl` that hands the kernel data ([`IOC_WRITE`]), as those that change a file's flags or
|
||||
@@ -307,10 +333,22 @@ fn main_rules(me: u32) -> Result<BTreeMap<i64, Vec<SeccompRule>>> {
|
||||
vec![
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, TIOCSTI)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, TIOCLINUX)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, FIOSETOWN)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, SIOCSPGRP)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, FIOASYNC)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::MaskedEq(IOC_WRITE), IOC_WRITE)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, FS_IOC_SET_ENCRYPTION_POLICY)?])?,
|
||||
],
|
||||
);
|
||||
rules.insert(
|
||||
libc::SYS_fcntl,
|
||||
vec![
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, F_SETOWN_EX)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, F_SETSIG)?])?,
|
||||
rule(vec![word(1, SeccompCmpOp::Eq, F_SETFL)?, word(2, SeccompCmpOp::MaskedEq(O_ASYNC), O_ASYNC)?])?,
|
||||
],
|
||||
);
|
||||
Ok(rules)
|
||||
}
|
||||
|
||||
@@ -378,6 +416,9 @@ mod tests {
|
||||
#[test]
|
||||
fn the_calls_that_reach_another_process_have_rules() {
|
||||
let rules = main_rules(4242).unwrap();
|
||||
// `fcntl`'s notification commands: naming the owner to notify, both ways, choosing its
|
||||
// signal, and the switch itself.
|
||||
assert_eq!(rules.get(&libc::SYS_fcntl).map(Vec::len), Some(4), "the asynchronous-notification commands");
|
||||
for nr in [
|
||||
libc::SYS_kill,
|
||||
libc::SYS_tkill,
|
||||
@@ -415,7 +456,9 @@ mod tests {
|
||||
] {
|
||||
assert_eq!(rules.get(&nr).map(Vec::len), Some(0), "{nr} is refused outright");
|
||||
}
|
||||
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(4));
|
||||
// The terminal rules, the socket notification ioctls (`FIOSETOWN`, `SIOCSPGRP`,
|
||||
// `FIOASYNC`), the data-carrying rule and the encryption rule.
|
||||
assert_eq!(rules.get(&libc::SYS_ioctl).map(Vec::len), Some(7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -44,6 +44,8 @@ pub fn enforcement(canary: &Path, exe: &Path, target: u32) -> Vec<Check> {
|
||||
Check { name: "TCP connection", outcome: denied(tcp_probe()) },
|
||||
Check { name: "start a process", outcome: spawn_probe(exe) },
|
||||
Check { name: "signal another process", outcome: signal_probe(target) },
|
||||
#[cfg(target_os = "linux")]
|
||||
Check { name: "switch a descriptor's notifications on", outcome: async_probe() },
|
||||
]
|
||||
}
|
||||
|
||||
@@ -128,6 +130,20 @@ fn write_probe(dir: &Path) -> Outcome {
|
||||
denied(r.map(drop))
|
||||
}
|
||||
|
||||
/// `O_ASYNC` on a descriptor of the program's own: the switch that has the kernel signal the
|
||||
/// descriptor's owner when its state changes. The owner may be a process the program didn't name, a
|
||||
/// standard stream the parent made, so on Linux the filter refuses setting it, the same way it
|
||||
/// refuses the commands that name another owner. Naming one can't be asked here without `fcntl`'s
|
||||
/// variable-argument form, which no safe API wraps; the rules refuse those commands, and the
|
||||
/// confined-program test covers them.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn async_probe() -> Outcome {
|
||||
let r = nix::unistd::pipe().map_err(std::io::Error::from).and_then(|(_, write)| {
|
||||
nix::fcntl::fcntl(write, nix::fcntl::FcntlArg::F_SETFL(nix::fcntl::OFlag::O_ASYNC)).map(drop).map_err(std::io::Error::from)
|
||||
});
|
||||
denied(r)
|
||||
}
|
||||
|
||||
fn tcp_probe() -> std::io::Result<()> {
|
||||
TcpStream::connect_timeout(&SocketAddr::from(TCP_TARGET), Duration::from_secs(2)).map(drop)
|
||||
}
|
||||
@@ -216,6 +232,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unconfined, the notification probe reports what the system allows; confined, the launch
|
||||
/// check needs it `Denied`. Either way it comes out of the same three calls, so this proves
|
||||
/// the probe runs and classifies rather than failing outright.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn the_notification_probe_classifies() {
|
||||
assert!(matches!(async_probe(), Outcome::Allowed(_)), "unconfined the switch can be set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_checks_on_the_canary_change_nothing_where_they_are_allowed() {
|
||||
let canary = crate::Canary::create().unwrap();
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# The sandbox for helper processes
|
||||
|
||||
> **Last updated:** 2026-11-07 · **Change:** minor (no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`)
|
||||
> **Last updated:** 2026-11-12 · **Change:** minor (no signals to another process on Linux, not even indirectly: `fcntl`'s notification commands and the socket ioctls that do the same are refused; earlier: no file changes by any route on Linux: Landlock's truncation right is required, and seccomp refuses what Landlock has no right for; earlier: more is refused on Linux: other processes, System V and POSIX queues, the terminal; earlier: first version: the `sandbox` crate and `gridcraft-cli sandbox-check`)
|
||||
|
||||
The sandbox is the layer GridCraft is to run code it doesn't trust in, such as a workbook's
|
||||
Python cells: a separate process that confines itself before the code starts. Here only
|
||||
@@ -24,7 +24,7 @@ other programs. The sandbox's job is that it can do none of these:
|
||||
- **Network:** no sockets, no connections and no listeners. The exception is a standard stream the
|
||||
parent made a socket (see below).
|
||||
- **Other processes and the system:** no debugging or reading another process, no System V shared
|
||||
memory, message queues or semaphores, no POSIX message queues, no signals to another process, no typing into a terminal it inherited, no mounts or namespaces, no kernel
|
||||
memory, message queues or semaphores, no POSIX message queues, no signals to another process — on Linux not even indirectly, as the `fcntl` commands and socket ioctls that have the kernel send one are refused — no typing into a terminal it inherited, no mounts or namespaces, no kernel
|
||||
modules, BPF, keyrings or performance counters. On Linux it also can't change another process's
|
||||
limits, priority or scheduling.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user