use std::path::{Path, PathBuf};
use boxlite::runtime::options::VolumeSpec;
use boxlite::{
BoxCommand, BoxOptions, BoxliteOptions, BoxliteRuntime, LiteBox, NetworkSpec, RootfsSpec,
};
use futures::StreamExt as _;
use rto_graph::{Isolation, RunnerKind};
use crate::adapter::{Adapter, AssetPaths, Invocation, NativeContext};
use crate::assets;
use crate::clock::rfc3339_utc;
use crate::ingest::assemble;
use crate::runner::{AnalysisRequest, AnalysisResponse, AnalyzerRunner, ExecError, check_request};
use crate::snippet::WorktreeSnippets;
pub const GUEST_WORKTREE: &str = "/work";
pub const GUEST_ASSETS: &str = "/assets";
pub const MAX_OUTPUT_BYTES: usize = 256 << 20;
pub const GUEST_INIT: &[&str] = &["sh", "-c", "while : ; do sleep 86400 ; done"];
pub const GUEST_MEMORY_MIB: u32 = 4096;
pub const GUEST_CPUS: u8 = 2;
pub const EXEC_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(30);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SandboxImage {
pub analyzer: &'static str,
pub reference: &'static str,
pub digest: &'static str,
pub analyzer_version: &'static str,
}
pub static SANDBOX_IMAGES: &[SandboxImage] = &[SandboxImage {
analyzer: crate::adapter::semgrep::ANALYZER,
reference: "docker.io/semgrep/semgrep@sha256:67319956da3dcb58baf5b322899c15458e3963e7018a86aeeb5cd224e69cb77a",
digest: "sha256:67319956da3dcb58baf5b322899c15458e3963e7018a86aeeb5cd224e69cb77a",
analyzer_version: "1.173.0",
}];
#[must_use]
pub fn image_for(analyzer: &str) -> Option<&'static SandboxImage> {
SANDBOX_IMAGES.iter().find(|i| i.analyzer == analyzer)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SandboxProbe {
Available,
Unavailable(String),
}
impl SandboxProbe {
#[must_use]
pub fn is_available(&self) -> bool {
matches!(self, Self::Available)
}
#[must_use]
pub fn reason(&self) -> Option<&str> {
match self {
Self::Available => None,
Self::Unavailable(why) => Some(why),
}
}
}
#[must_use]
pub fn sandbox_probe() -> SandboxProbe {
match boxlite::system_check::SystemCheck::run() {
Ok(_) => SandboxProbe::Available,
Err(e) => SandboxProbe::Unavailable(probe_reason(&e.to_string())),
}
}
fn probe_reason(raw: &str) -> String {
if raw.trim().is_empty() {
return "the hypervisor probe failed without giving a reason".to_owned();
}
raw.trim().to_owned()
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SandboxError {
#[error(
"no sandbox is available on this host: {reason}\n \
run the analyzer with `--allow-unsandboxed` to accept an unisolated run \
(its evidence will record isolation=none), or ingest a report produced elsewhere."
)]
Unavailable {
reason: String,
},
#[error(
"no pinned sandbox image for analyzer {requested:?} in this build (available: {known})"
)]
NoImage {
requested: String,
known: String,
},
#[error(
"assets-unavailable-offline: the pinned image for {analyzer} is not in the local store\n \
image: {reference}\n \
fetch it with: roteiro security prefetch --allow-download\n \
(roteiro never pulls an image during a run, so a scan can never depend on \
a registry being reachable)"
)]
ImageNotProvisioned {
analyzer: String,
reference: &'static str,
},
#[error("sandbox {stage}: {message}")]
Runtime {
stage: &'static str,
message: String,
},
#[error(
"`{program}` exited with status {status} inside the sandbox, which it does not use for a \
completed scan (expected one of: {expected}). A scan that failed is not a clean result, \
so nothing was stored.{stderr}"
)]
UnexpectedStatus {
program: String,
status: i32,
expected: String,
stderr: String,
},
#[error(
"`{program}` was killed by signal {signal} inside the sandbox and wrote nothing to \
stderr.\n The usual cause is the guest running out of memory: it is given \
{memory_mib} MiB, and a large tree can need more.\n This is not a finding — nothing \
was stored, because a scan that was killed is not a clean result."
)]
Killed {
program: String,
signal: i32,
memory_mib: u32,
},
#[error(
"`{program}` produced more than {max} bytes of output in the sandbox; refusing to read it"
)]
OutputTooLarge {
program: String,
max: usize,
},
}
pub struct BoxliteRunner {
adapter: &'static dyn Adapter,
image: &'static SandboxImage,
assets: Vec<(&'static str, PathBuf)>,
guest_assets: Vec<(&'static str, PathBuf)>,
assets_root: PathBuf,
runtime: tokio::runtime::Runtime,
}
impl std::fmt::Debug for BoxliteRunner {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BoxliteRunner")
.field("adapter", &self.adapter)
.field("image", &self.image)
.field("assets_root", &self.assets_root)
.finish_non_exhaustive()
}
}
impl BoxliteRunner {
pub fn new(analyzer: &str, assets_root: &Path) -> Result<Self, ExecError> {
let adapter =
crate::adapter::adapter_for(analyzer).ok_or_else(|| ExecError::UnknownAnalyzer {
requested: analyzer.to_owned(),
known: crate::adapter::known_analyzers().join(", "),
})?;
if let SandboxProbe::Unavailable(reason) = sandbox_probe() {
return Err(SandboxError::Unavailable { reason }.into());
}
let image = image_for(analyzer).ok_or_else(|| SandboxError::NoImage {
requested: analyzer.to_owned(),
known: SANDBOX_IMAGES
.iter()
.map(|i| i.analyzer)
.collect::<Vec<_>>()
.join(", "),
})?;
let assets = assets::resolve(assets_root, analyzer)?;
let guest_assets = assets
.iter()
.map(|(id, host)| {
let relative = host.strip_prefix(assets_root).unwrap_or(host.as_path());
(*id, Path::new(GUEST_ASSETS).join(relative))
})
.collect();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SandboxError::Runtime {
stage: "runtime",
message: e.to_string(),
})?;
Ok(Self {
adapter,
image,
assets,
guest_assets,
assets_root: assets_root.to_path_buf(),
runtime,
})
}
#[must_use]
pub fn adapter(&self) -> &'static dyn Adapter {
self.adapter
}
#[must_use]
pub fn image(&self) -> &'static SandboxImage {
self.image
}
#[must_use]
pub fn invocation(&self) -> Invocation {
self.adapter.command(&AssetPaths::new(&self.guest_assets))
}
fn rules_digest(&self) -> Option<String> {
self.assets.iter().find_map(|(id, _)| {
let spec = assets::asset(id)?;
(spec.kind == assets::AssetKind::Rules)
.then(|| assets::installed(&self.assets_root, spec).map(|record| record.digest))
.flatten()
})
}
fn boxlite_home(&self) -> PathBuf {
self.assets_root.join("boxlite-home")
}
fn open_runtime(&self) -> Result<BoxliteRuntime, SandboxError> {
BoxliteRuntime::new(BoxliteOptions {
home_dir: self.boxlite_home(),
image_registries: Vec::new(),
})
.map_err(|e| SandboxError::Runtime {
stage: "open",
message: e.to_string(),
})
}
}
impl AnalyzerRunner for BoxliteRunner {
fn kind(&self) -> RunnerKind {
RunnerKind::Sandboxed
}
fn isolation(&self) -> Isolation {
Isolation::MicroVm
}
fn run(&self, request: &AnalysisRequest) -> Result<AnalysisResponse, ExecError> {
check_request(request)?;
let invocation = self.invocation();
let started_at = rfc3339_utc(std::time::SystemTime::now());
let output = self.runtime.block_on(self.execute(&invocation, request))?;
let ended_at = rfc3339_utc(std::time::SystemTime::now());
let snippets = WorktreeSnippets::new(&request.worktree.path);
let ctx = NativeContext {
started_at,
ended_at,
analyzer_version: Some(self.image.analyzer_version.to_owned()),
exit_status: output.status,
source: &request.source,
rules_digest: self.rules_digest(),
advisory_db: assets::advisory_db_evidence(&self.assets_root, &request.analyzer),
worktree: Some(Path::new(GUEST_WORKTREE)),
snippets: &snippets,
};
let mut report = self.adapter.normalize(&output.stdout, &ctx)?;
report.image_digest = Some(self.image.digest.to_owned());
assemble(
report,
request,
self.kind(),
self.isolation(),
&output.stdout,
)
}
}
struct Captured {
stdout: Vec<u8>,
status: i32,
}
impl BoxliteRunner {
async fn execute(
&self,
invocation: &Invocation,
request: &AnalysisRequest,
) -> Result<Captured, SandboxError> {
let runtime = self.open_runtime()?;
self.require_image(&runtime).await?;
let options = BoxOptions {
cpus: Some(GUEST_CPUS),
memory_mib: Some(GUEST_MEMORY_MIB),
rootfs: RootfsSpec::Image(self.image.reference.to_owned()),
network: NetworkSpec::Disabled,
volumes: vec![
VolumeSpec {
host_path: request.worktree.path.to_string_lossy().into_owned(),
guest_path: GUEST_WORKTREE.to_owned(),
read_only: true,
},
VolumeSpec {
host_path: self.assets_root.to_string_lossy().into_owned(),
guest_path: GUEST_ASSETS.to_owned(),
read_only: true,
},
],
env: guest_environment(),
working_dir: Some(GUEST_WORKTREE.to_owned()),
entrypoint: Some(GUEST_INIT.iter().map(|s| (*s).to_owned()).collect()),
cmd: Some(Vec::new()),
auto_remove: true,
detach: false,
..Default::default()
};
let boxed = runtime
.create(options, None)
.await
.map_err(|e| SandboxError::Runtime {
stage: "create",
message: e.to_string(),
})?;
let result = self.exec_in(&boxed, invocation).await;
let stopped = boxed.stop().await;
let shutdown = runtime.shutdown(Some(10)).await;
let captured = result?;
stopped.map_err(|e| SandboxError::Runtime {
stage: "stop",
message: e.to_string(),
})?;
shutdown.map_err(|e| SandboxError::Runtime {
stage: "shutdown",
message: e.to_string(),
})?;
Ok(captured)
}
async fn require_image(&self, runtime: &BoxliteRuntime) -> Result<(), SandboxError> {
let images = runtime
.images()
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?
.list()
.await
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?;
let present = images
.iter()
.any(|i| i.id == self.image.digest || i.reference == self.image.reference);
if present {
Ok(())
} else {
Err(SandboxError::ImageNotProvisioned {
analyzer: self.adapter.analyzer().to_owned(),
reference: self.image.reference,
})
}
}
async fn exec_in(
&self,
boxed: &LiteBox,
invocation: &Invocation,
) -> Result<Captured, SandboxError> {
let command = BoxCommand::new(&invocation.program)
.args(invocation.args.clone())
.working_dir(GUEST_WORKTREE)
.timeout(EXEC_TIMEOUT);
let mut execution = boxed
.exec(command)
.await
.map_err(|e| SandboxError::Runtime {
stage: "exec",
message: e.to_string(),
})?;
let stdout = execution.stdout();
let stderr = execution.stderr();
let out = tokio::spawn(collect(stdout));
let err = tokio::spawn(collect(stderr));
let status = execution.wait().await.map_err(|e| SandboxError::Runtime {
stage: "wait",
message: e.to_string(),
})?;
let joined = |handle: tokio::task::JoinHandle<String>, what: &'static str| async move {
handle.await.map_err(|e| SandboxError::Runtime {
stage: what,
message: e.to_string(),
})
};
let stdout = joined(out, "stdout").await?;
let stderr = joined(err, "stderr").await?;
if stdout.len() > MAX_OUTPUT_BYTES {
return Err(SandboxError::OutputTooLarge {
program: invocation.program.clone(),
max: MAX_OUTPUT_BYTES,
});
}
if !invocation.success_statuses.contains(&status.exit_code) {
if status.exit_code < 0 && stderr.trim().is_empty() {
return Err(SandboxError::Killed {
program: invocation.program.clone(),
signal: -status.exit_code,
memory_mib: GUEST_MEMORY_MIB,
});
}
return Err(SandboxError::UnexpectedStatus {
program: invocation.program.clone(),
status: status.exit_code,
expected: invocation
.success_statuses
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(", "),
stderr: stderr_tail(&stderr),
});
}
Ok(Captured {
stdout: stdout.into_bytes(),
status: status.exit_code,
})
}
}
async fn collect(stream: Option<impl futures::Stream<Item = String> + Unpin>) -> String {
let mut out = String::new();
if let Some(mut stream) = stream {
while let Some(chunk) = stream.next().await {
out.push_str(&chunk);
if out.len() > MAX_OUTPUT_BYTES {
break;
}
}
}
out
}
fn guest_environment() -> Vec<(String, String)> {
vec![
("LC_ALL".to_owned(), "C".to_owned()),
("SEMGREP_SEND_METRICS".to_owned(), "off".to_owned()),
]
}
fn stderr_tail(stderr: &str) -> String {
const MAX_LINES: usize = 8;
const MAX_BYTES: usize = 4_000;
let trimmed = stderr.trim_end();
if trimmed.is_empty() {
return String::new();
}
let tail: Vec<&str> = trimmed
.lines()
.rev()
.take(MAX_LINES)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let mut joined = tail.join("\n ");
if joined.len() > MAX_BYTES {
joined.truncate(MAX_BYTES);
joined.push('…');
}
format!("\n its stderr ended:\n {joined}")
}
pub fn provision_image(analyzer: &str, assets_root: &Path) -> Result<String, SandboxError> {
let image = image_for(analyzer).ok_or_else(|| SandboxError::NoImage {
requested: analyzer.to_owned(),
known: SANDBOX_IMAGES
.iter()
.map(|i| i.analyzer)
.collect::<Vec<_>>()
.join(", "),
})?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| SandboxError::Runtime {
stage: "runtime",
message: e.to_string(),
})?;
runtime.block_on(async {
let boxlite = BoxliteRuntime::new(BoxliteOptions {
home_dir: assets_root.join("boxlite-home"),
image_registries: Vec::new(),
})
.map_err(|e| SandboxError::Runtime {
stage: "open",
message: e.to_string(),
})?;
boxlite
.images()
.map_err(|e| SandboxError::Runtime {
stage: "images",
message: e.to_string(),
})?
.pull(image.reference)
.await
.map_err(|e| SandboxError::Runtime {
stage: "pull",
message: e.to_string(),
})?;
Ok(image.digest.to_owned())
})
}
#[cfg(test)]
mod tests {
use super::{
GUEST_ASSETS, GUEST_WORKTREE, MAX_OUTPUT_BYTES, SANDBOX_IMAGES, guest_environment,
image_for, sandbox_probe, stderr_tail,
};
#[test]
fn every_pinned_image_is_addressed_by_its_recorded_digest() {
assert!(!SANDBOX_IMAGES.is_empty());
for image in SANDBOX_IMAGES {
assert!(
image.digest.starts_with("sha256:"),
"{} digest is not a sha256 reference: {}",
image.analyzer,
image.digest
);
assert!(
image.reference.ends_with(image.digest),
"{} is not pinned to the digest it records: {} vs {}",
image.analyzer,
image.reference,
image.digest
);
assert!(
!image.reference.contains(':') || image.reference.contains('@'),
"{} must be pinned by digest, not by tag: {}",
image.analyzer,
image.reference
);
assert!(
!image.analyzer_version.is_empty(),
"{} does not say what version it carries",
image.analyzer
);
}
}
#[test]
fn the_image_registry_answers_for_every_analyzer_it_lists() {
for image in SANDBOX_IMAGES {
assert_eq!(image_for(image.analyzer).expect("registered"), image);
}
assert!(image_for("no-such-analyzer").is_none());
}
#[test]
fn the_guest_environment_carries_no_ambient_credentials() {
let env = guest_environment();
let names: Vec<&str> = env.iter().map(|(k, _)| k.as_str()).collect();
for secret in [
"GITHUB_TOKEN",
"AWS_ACCESS_KEY_ID",
"SEMGREP_APP_TOKEN",
"SSH_AUTH_SOCK",
"HOME",
"PATH",
] {
assert!(!names.contains(&secret), "{secret} reaches the guest");
}
assert!(names.contains(&"LC_ALL"));
}
#[test]
fn the_guest_mount_points_are_absolute_and_distinct() {
assert!(GUEST_WORKTREE.starts_with('/'));
assert!(GUEST_ASSETS.starts_with('/'));
assert_ne!(GUEST_WORKTREE, GUEST_ASSETS);
assert!(!GUEST_ASSETS.starts_with(&format!("{GUEST_WORKTREE}/")));
assert!(!GUEST_WORKTREE.starts_with(&format!("{GUEST_ASSETS}/")));
}
#[cfg(feature = "exec-subprocess")]
#[test]
fn both_backends_bound_analyzer_output_identically() {
assert_eq!(MAX_OUTPUT_BYTES, crate::subprocess::MAX_OUTPUT_BYTES);
}
#[test]
fn a_failure_message_carries_a_bounded_tail_of_stderr() {
assert_eq!(stderr_tail(""), "");
assert_eq!(stderr_tail(" \n "), "");
assert!(stderr_tail("line1\nline2\nline3").contains("line3"));
let noisy: Vec<String> = (0..500).map(|i| format!("line {i}")).collect();
let tail = stderr_tail(&noisy.join("\n"));
assert!(tail.contains("line 499"), "the tail must be the end");
assert!(!tail.contains("line 100"), "and not the whole thing");
}
#[test]
fn the_capability_probe_always_answers() {
let probe = sandbox_probe();
match &probe {
super::SandboxProbe::Available => assert!(probe.reason().is_none()),
super::SandboxProbe::Unavailable(why) => {
assert!(!why.is_empty(), "an unavailable sandbox must say why");
assert_eq!(probe.reason(), Some(why.as_str()));
}
}
}
#[test]
fn an_unavailable_sandbox_always_gives_a_printable_reason() {
assert_eq!(super::probe_reason("no /dev/kvm"), "no /dev/kvm");
assert_eq!(super::probe_reason(" padded "), "padded");
for empty in ["", " ", "\n\t "] {
let reason = super::probe_reason(empty);
assert!(
!reason.trim().is_empty(),
"an empty probe failure must still print something"
);
assert!(reason.contains("without giving a reason"), "{reason}");
}
}
}