Skip to content

Commit a9d3e91

Browse files
wagnerioclaude
andcommitted
Add gVisor (runsc) runtime isolation support
Introduce --runtime-isolation flag (auto|bubblewrap|podman|podman-gvisor) for selecting the script execution sandbox backend. When podman-gvisor is selected, Podman runs with --runtime=/path/to/runsc for userspace syscall interception via gVisor's Sentry. Auto-detection probes SCURL_RUNSC_PATH, /usr/local/bin/runsc, and /usr/bin/runsc. Includes rootless Podman warning, runtime_used field in container results and audit logs, and refactored Podman arg construction to eliminate duplication. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 752d2b7 commit a9d3e91

File tree

4 files changed

+282
-49
lines changed

4 files changed

+282
-49
lines changed

src/audit.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,9 @@ pub(crate) fn write_audit_log(
8181
// Append container observation fields if present
8282
if let Some(cr) = container_result {
8383
entry.push_str(&format!(
84-
",\"container_id\":\"{}\",\"runtime_duration_ms\":{},\"container_timed_out\":{},\"container_exit_code\":{},\"killed_by_monitor\":{},\"filesystem_changes\":{}",
84+
",\"container_id\":\"{}\",\"runtime_used\":\"{}\",\"runtime_duration_ms\":{},\"container_timed_out\":{},\"container_exit_code\":{},\"killed_by_monitor\":{},\"filesystem_changes\":{}",
8585
cr.container_id.replace('"', "\\\""),
86+
cr.runtime_used.replace('"', "\\\""),
8687
cr.duration_ms,
8788
cr.timed_out,
8889
cr.exit_code.map(|c| c.to_string()).unwrap_or_else(|| "null".to_string()),

src/container.rs

Lines changed: 128 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ pub(crate) struct ContainerResult {
2424
pub(crate) filesystem_diff: Vec<String>,
2525
pub(crate) timed_out: bool,
2626
pub(crate) killed_by_monitor: bool,
27+
/// Which runtime was used: "podman-gvisor", "podman"
28+
pub(crate) runtime_used: String,
2729
}
2830

2931
/// A security alert from Falco runtime monitoring.
@@ -80,6 +82,25 @@ pub(crate) fn falco_log_path() -> Option<String> {
8082
None
8183
}
8284

85+
/// Detect gVisor's runsc binary. Checks SCURL_RUNSC_PATH env var first,
86+
/// then common installation paths. Returns the absolute path if found.
87+
pub(crate) fn detect_runsc() -> Option<String> {
88+
// Environment override
89+
if let Ok(path) = std::env::var("SCURL_RUNSC_PATH") {
90+
let p = Path::new(&path);
91+
if p.is_file() {
92+
return Some(path);
93+
}
94+
}
95+
// Common install locations
96+
for candidate in &["/usr/local/bin/runsc", "/usr/bin/runsc"] {
97+
if Path::new(candidate).is_file() {
98+
return Some(candidate.to_string());
99+
}
100+
}
101+
None
102+
}
103+
83104
/// Classify a Falco alert into a severity bucket for snipe decisions.
84105
pub(crate) fn classify_alert(alert: &FalcoAlert) -> AlertSeverity {
85106
let rule_lower = alert.rule.to_lowercase();
@@ -236,14 +257,66 @@ pub(crate) async fn monitor_falco(
236257
let _ = child.kill().await;
237258
}
238259

260+
/// Configure common Podman run arguments on an async command.
261+
/// When `runsc_path` is Some, adds `--runtime=<path>` for gVisor isolation.
262+
fn configure_podman_run(
263+
cmd: &mut tokio::process::Command,
264+
container_name: &str,
265+
host_path: &str,
266+
runsc_path: Option<&str>,
267+
) {
268+
cmd.arg("run")
269+
.arg("--name")
270+
.arg(container_name);
271+
272+
if let Some(path) = runsc_path {
273+
cmd.arg(format!("--runtime={}", path));
274+
}
275+
276+
cmd.arg("--network=none")
277+
.arg("--read-only")
278+
.arg("--tmpfs")
279+
.arg("/tmp:rw,noexec,nosuid,size=64m")
280+
.arg("--cap-drop=ALL")
281+
.arg("--security-opt=no-new-privileges")
282+
.arg("--memory=256m")
283+
.arg("--pids-limit=256")
284+
.arg("-v")
285+
.arg(format!("{}:/install.sh:ro", host_path))
286+
.arg("docker.io/library/alpine:latest")
287+
.arg("/bin/sh")
288+
.arg("/install.sh");
289+
}
290+
291+
/// Warn if running rootless Podman with gVisor (known compatibility issues).
292+
fn warn_rootless_gvisor() {
293+
let is_root = std::process::Command::new("id")
294+
.arg("-u")
295+
.output()
296+
.ok()
297+
.and_then(|o| String::from_utf8(o.stdout).ok())
298+
.map(|s| s.trim() == "0")
299+
.unwrap_or(false);
300+
301+
if !is_root {
302+
eprintln!(
303+
" {} Rootless Podman + gVisor may have issues (cgroups, network namespaces).\n \
304+
Consider running with sudo for reliable gVisor isolation.\n \
305+
See: https://github.com/google/gvisor/issues/311",
306+
"⚠".yellow()
307+
);
308+
}
309+
}
310+
239311
/// Execute a script in a Podman container with optional Falco monitoring.
240-
/// Returns the container result, any Falco alerts collected, and whether
241-
/// the container was killed by the monitor.
312+
/// When `runsc_path` is Some, uses gVisor (runsc) as the OCI runtime.
313+
/// Returns the container result and any Falco alerts collected.
242314
pub(crate) async fn execute_in_container(
243315
script: &str,
244316
timeout_secs: u64,
245317
monitor_level: &MonitorLevel,
246318
enable_monitor: bool,
319+
runsc_path: Option<&str>,
247320
) -> Result<(ContainerResult, Vec<FalcoAlert>)> {
248321
use tokio::process::Command as AsyncCommand;
249322

@@ -254,6 +327,10 @@ pub(crate) async fn execute_in_container(
254327
);
255328
}
256329

330+
if runsc_path.is_some() {
331+
warn_rootless_gvisor();
332+
}
333+
257334
let start = std::time::Instant::now();
258335
let run_id = SystemTime::now()
259336
.duration_since(SystemTime::UNIX_EPOCH)
@@ -297,23 +374,9 @@ pub(crate) async fn execute_in_container(
297374
let (timed_out, killed_by_monitor, stdout, stderr, exit_code, alerts) = if falco_available {
298375
use tokio::io::AsyncReadExt;
299376

300-
let mut child = AsyncCommand::new("podman")
301-
.arg("run")
302-
.arg("--name")
303-
.arg(&container_name)
304-
.arg("--network=none")
305-
.arg("--read-only")
306-
.arg("--tmpfs")
307-
.arg("/tmp:rw,noexec,nosuid,size=64m")
308-
.arg("--cap-drop=ALL")
309-
.arg("--security-opt=no-new-privileges")
310-
.arg("--memory=256m")
311-
.arg("--pids-limit=256")
312-
.arg("-v")
313-
.arg(format!("{}:/install.sh:ro", host_path))
314-
.arg("docker.io/library/alpine:latest")
315-
.arg("/bin/sh")
316-
.arg("/install.sh")
377+
let mut child_cmd = AsyncCommand::new("podman");
378+
configure_podman_run(&mut child_cmd, &container_name, host_path, runsc_path);
379+
let mut child = child_cmd
317380
.stdout(std::process::Stdio::piped())
318381
.stderr(std::process::Stdio::piped())
319382
.spawn()?;
@@ -425,27 +488,12 @@ pub(crate) async fn execute_in_container(
425488

426489
(timed, sniped, so, se, code, collected)
427490
} else {
428-
// ── Unmonitored path (simple output, same as Day 1) ──────────────
491+
// ── Unmonitored path (simple output) ────────────────────────────
492+
let mut run_cmd = AsyncCommand::new("podman");
493+
configure_podman_run(&mut run_cmd, &container_name, host_path, runsc_path);
429494
let run_result = tokio::time::timeout(
430495
Duration::from_secs(timeout_secs),
431-
AsyncCommand::new("podman")
432-
.arg("run")
433-
.arg("--name")
434-
.arg(&container_name)
435-
.arg("--network=none")
436-
.arg("--read-only")
437-
.arg("--tmpfs")
438-
.arg("/tmp:rw,noexec,nosuid,size=64m")
439-
.arg("--cap-drop=ALL")
440-
.arg("--security-opt=no-new-privileges")
441-
.arg("--memory=256m")
442-
.arg("--pids-limit=256")
443-
.arg("-v")
444-
.arg(format!("{}:/install.sh:ro", host_path))
445-
.arg("docker.io/library/alpine:latest")
446-
.arg("/bin/sh")
447-
.arg("/install.sh")
448-
.output(),
496+
run_cmd.output(),
449497
)
450498
.await;
451499

@@ -530,6 +578,12 @@ pub(crate) async fn execute_in_container(
530578
// Cleanup
531579
cleanup_container(&container_name).await;
532580

581+
let runtime_used = if runsc_path.is_some() {
582+
"podman-gvisor".to_string()
583+
} else {
584+
"podman".to_string()
585+
};
586+
533587
let result = ContainerResult {
534588
container_id,
535589
exit_code,
@@ -539,6 +593,7 @@ pub(crate) async fn execute_in_container(
539593
filesystem_diff,
540594
timed_out,
541595
killed_by_monitor,
596+
runtime_used,
542597
};
543598

544599
Ok((result, alerts))
@@ -557,6 +612,7 @@ pub(crate) fn display_container_result(result: &ContainerResult, alerts: &[Falco
557612
println!("{}", "─".repeat(50));
558613

559614
println!("{} {}", "Container ID:".bold(), result.container_id);
615+
println!("{} {}", "Runtime:".bold(), result.runtime_used);
560616
println!("{} {}ms", "Duration:".bold(), result.duration_ms);
561617

562618
if result.killed_by_monitor {
@@ -668,6 +724,7 @@ mod tests {
668724
],
669725
timed_out,
670726
killed_by_monitor,
727+
runtime_used: "podman".to_string(),
671728
}
672729
}
673730

@@ -726,6 +783,7 @@ mod tests {
726783
10,
727784
&MonitorLevel::Medium,
728785
false,
786+
None,
729787
)
730788
.await;
731789
assert!(result.is_err());
@@ -957,4 +1015,36 @@ mod tests {
9571015
}
9581016
}
9591017
}
1018+
1019+
// ── gVisor / runsc detection tests ──
1020+
1021+
#[test]
1022+
fn test_detect_runsc_returns_option() {
1023+
// Just verify it doesn't panic; actual result depends on system
1024+
let _result = detect_runsc();
1025+
}
1026+
1027+
#[test]
1028+
fn test_detect_runsc_respects_env() {
1029+
// With a nonexistent path in env, should return None
1030+
std::env::set_var("SCURL_RUNSC_PATH", "/nonexistent/runsc");
1031+
let result = detect_runsc();
1032+
assert!(result.is_none());
1033+
std::env::remove_var("SCURL_RUNSC_PATH");
1034+
}
1035+
1036+
#[test]
1037+
fn test_container_result_runtime_used_field() {
1038+
let result = make_container_result(false, false);
1039+
assert_eq!(result.runtime_used, "podman");
1040+
}
1041+
1042+
#[test]
1043+
fn test_display_container_result_shows_runtime() {
1044+
let mut result = make_container_result(false, false);
1045+
result.runtime_used = "podman-gvisor".to_string();
1046+
// Should not panic; verify runtime field is present
1047+
display_container_result(&result, &[]);
1048+
assert_eq!(result.runtime_used, "podman-gvisor");
1049+
}
9601050
}

0 commit comments

Comments
 (0)