use std::path::{Path, PathBuf};
use anyhow::Context;
use crate::config::NetMode;
use crate::policy::{self, Policy};
use crate::sandbox;
#[cfg(unix)]
use crate::doctor::run_probe_script;
#[cfg(unix)]
use crate::policy::Tier;
#[cfg(unix)]
const STATUS_PASS: &str = "pass";
const STATUS_LEAK: &str = "LEAK";
const STATUS_INFO: &str = "info";
const STATUS_SKIPPED: &str = "skipped";
pub struct CheckResult {
pub name: &'static str,
pub status: &'static str,
pub detail: String,
}
pub struct VerifyReport {
pub tier: String,
pub net: String,
pub checks: Vec<CheckResult>,
}
impl VerifyReport {
pub fn leaks(&self) -> usize {
self.checks
.iter()
.filter(|check| check.status == STATUS_LEAK)
.count()
}
pub fn status(&self) -> &'static str {
if self.leaks() > 0 {
return "failed";
}
let backend_marker = self
.checks
.iter()
.any(|check| check.name == "backend" && check.status == STATUS_INFO);
let no_verdicts = self
.checks
.iter()
.all(|check| check.status == STATUS_INFO || check.status == STATUS_SKIPPED);
if backend_marker && no_verdicts {
"unavailable"
} else {
"pass"
}
}
pub fn summary(&self) -> String {
format!(
"boundary verify: tier={} net={} checks={} leaks={}",
self.tier,
self.net,
self.checks.len(),
self.leaks()
)
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"tier": self.tier,
"net": self.net,
"status": self.status(),
"leaks": self.leaks(),
"checks": self
.checks
.iter()
.map(|check| {
serde_json::json!({
"name": check.name,
"status": check.status,
"detail": check.detail,
})
})
.collect::<Vec<serde_json::Value>>(),
})
}
}
pub fn run_cli(
json: bool,
profile: &str,
policy_path: Option<&Path>,
net: &NetMode,
) -> anyhow::Result<()> {
let tier = match sandbox::Backend::detect(net.clone(), false) {
Ok(b) => b.tier().unwrap_or(policy::Tier::Full),
Err(_) => policy::Tier::Full,
};
let project = std::env::current_dir().context("getcwd")?;
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.context("neither $HOME nor %USERPROFILE% is set")?;
let pol = policy::loader::load(profile, policy_path, &project, &home, tier)?;
let report = preflight(&pol, net)?;
if json {
println!("{}", serde_json::to_string_pretty(&report.to_json())?);
} else {
println!("{}", report.summary());
for check in &report.checks {
println!(" {:<9} {:<14} {}", check.status, check.name, check.detail);
}
match report.status() {
"failed" => println!("boundary verify: FAILED ({} leak(s))", report.leaks()),
"unavailable" => {
println!("boundary verify: UNAVAILABLE (backend could not run the battery)")
}
_ => println!("boundary verify: PASS"),
}
}
if report.leaks() > 0 {
std::process::exit(1);
}
Ok(())
}
pub fn preflight(pol: &Policy, net: &NetMode) -> anyhow::Result<VerifyReport> {
#[cfg(not(unix))]
{
let _ = pol;
Ok(unavailable(
net,
"n/a",
"verification battery is unix-only".to_string(),
))
}
#[cfg(unix)]
battery(pol, net)
}
fn unavailable(net: &NetMode, tier: &str, detail: String) -> VerifyReport {
VerifyReport {
tier: tier.to_string(),
net: net.label(),
checks: vec![CheckResult {
name: "backend",
status: STATUS_INFO,
detail,
}],
}
}
#[cfg(unix)]
fn pass(name: &'static str, detail: String) -> CheckResult {
CheckResult {
name,
status: STATUS_PASS,
detail,
}
}
#[cfg(unix)]
fn leak(name: &'static str, detail: String) -> CheckResult {
CheckResult {
name,
status: STATUS_LEAK,
detail,
}
}
#[cfg(unix)]
fn skipped(name: &'static str, detail: String) -> CheckResult {
CheckResult {
name,
status: STATUS_SKIPPED,
detail,
}
}
#[cfg(unix)]
fn battery(pol: &Policy, net: &NetMode) -> anyhow::Result<VerifyReport> {
let project = std::env::current_dir().context("getcwd")?;
let backend = match sandbox::Backend::detect(net.clone(), false) {
Ok(backend) => backend,
Err(error) => {
return Ok(unavailable(
net,
"unknown",
format!("backend cannot run the battery: {error:#}"),
))
}
};
let tier = backend.tier();
let mut checks: Vec<CheckResult> = Vec::new();
let mut script_args: Vec<String> = pol
.deny_resolved
.iter()
.map(|entry| entry.path.display().to_string())
.collect();
let mut listener = None;
match std::net::TcpListener::bind(("127.0.0.1", 0)) {
Ok(bound) => match bound.local_addr() {
Ok(addr) => {
script_args.push(format!("NETCHECK:{}", addr.port()));
listener = Some(bound);
}
Err(error) => checks.push(skipped(
"net-loopback",
format!("host listener local_addr failed: {error}"),
)),
},
Err(error) => checks.push(skipped(
"net-loopback",
format!("host listener bind failed: {error}"),
)),
}
let mut write_probe = None;
if let Some(home) = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
{
if !pol.in_write_scope(&home) {
let path = home.join(format!("vetto-verify-probe-{}", std::process::id()));
script_args.push(format!("WRITECHECK:{}", path.display()));
write_probe = Some(path);
}
}
let probe = match run_probe_script(pol, &project, script_args) {
Ok(probe) => probe,
Err(error) => {
return Ok(unavailable(
net,
"unknown",
format!("sandbox spawn failed: {error:#}"),
))
}
};
drop(listener); if let Some(path) = &write_probe {
let _ = std::fs::remove_file(path); }
parse_probe_output(&probe.stdout, tier, &mut checks);
let stderr = probe.stderr.trim();
if !stderr.is_empty() {
checks.push(CheckResult {
name: "probe-stderr",
status: STATUS_INFO,
detail: stderr.to_string(),
});
}
Ok(VerifyReport {
tier: tier_label(tier),
net: net.label(),
checks,
})
}
#[cfg(unix)]
fn tier_label(tier: Option<Tier>) -> String {
match tier {
Some(tier) => tier.label().to_string(),
None if cfg!(target_os = "macos") => "seatbelt".to_string(),
None => "none".to_string(),
}
}
#[cfg(unix)]
fn net_pass_detail(tier: Option<Tier>) -> String {
match tier {
Some(Tier::Full) => "host loopback listener unreachable (netns isolation)".to_string(),
Some(Tier::FsOnly) | Some(Tier::Seccomp) => {
"host loopback listener unreachable (seccomp socket block)".to_string()
}
None if cfg!(target_os = "macos") => {
"host loopback listener unreachable (seatbelt deny)".to_string()
}
None => "host loopback listener unreachable".to_string(),
}
}
#[cfg(unix)]
fn parse_probe_output(output: &str, tier: Option<Tier>, checks: &mut Vec<CheckResult>) {
for line in output.lines() {
let mut parts = line.splitn(3, '|');
let (kind, path, verdict) = match (parts.next(), parts.next(), parts.next()) {
(Some(kind), Some(path), Some(verdict)) => (kind, path, verdict),
_ => continue,
};
match (kind, verdict) {
("D", "contents-denied") => checks.push(pass(
"deny-path",
format!(
"{path}/: file contents denied (entry names may remain visible in FS-ONLY)"
),
)),
("D", "content-readable") => checks.push(leak(
"deny-path",
format!("{path}/: file content is readable"),
)),
("F", "unreadable") => checks.push(pass("deny-path", format!("{path}: open denied"))),
("F", bytes) => {
let in_sandbox: u64 = bytes.parse().unwrap_or(0);
let host = std::fs::metadata(path).map(|meta| meta.len()).unwrap_or(0);
if host == 0 {
checks.push(pass(
"deny-path",
format!("{path}: empty on host; trivially safe"),
));
} else if in_sandbox == 0 {
checks.push(pass(
"deny-path",
format!("{path}: masked (appears empty inside)"),
));
} else {
checks.push(leak(
"deny-path",
format!("{path}: {in_sandbox}/{host} bytes readable"),
));
}
}
("NET", "unreachable") => checks.push(pass("net-loopback", net_pass_detail(tier))),
("NET", "reachable") => checks.push(leak(
"net-loopback",
"sandbox reached a host loopback listener".to_string(),
)),
("NET", "nobash") => checks.push(skipped(
"net-loopback",
"no bash inside the sandbox; /dev/tcp probe unavailable".to_string(),
)),
("WRITE", "denied") => checks.push(pass(
"write-outside",
format!("write to {path} outside every write root denied"),
)),
("WRITE", "allowed") => checks.push(leak(
"write-outside",
format!("wrote outside every write root: {path}"),
)),
_ => {}
}
}
}