use crate::{audit, canary::CANARY_DOMAIN, manifest::Manifest, paths, proc};
use anyhow::{Context, Result};
use serde_json::Value;
struct Check {
label: &'static str,
pass: bool,
detail: String,
}
fn probe(
label: &'static str,
result: Result<(std::process::ExitStatus, String)>,
detail_prefix: &str,
pass_when: impl Fn(bool, &str) -> bool,
) -> Check {
match result {
Ok((status, out)) => Check {
label,
pass: pass_when(status.success(), out.trim()),
detail: format!(
"{detail_prefix}: exit_success={} out={:?}",
status.success(),
out.trim()
),
},
Err(e) => Check {
label,
pass: false,
detail: format!("{detail_prefix}: could not run probe: {e}"),
},
}
}
fn run_breakout_battery(container: &str) -> Vec<Check> {
vec![
probe(
"canary domain is unreachable",
proc::run_capture(
"docker",
&[
"exec",
container,
"curl",
"-sS",
"-o",
"/dev/null",
"--max-time",
"5",
&format!("https://{CANARY_DOMAIN}"),
],
),
&format!("curl https://{CANARY_DOMAIN}"),
|success, _| !success,
),
probe(
"root filesystem rejects writes",
proc::run_capture(
"docker",
&["exec", container, "sh", "-c", "touch /moor-write-test 2>&1"],
),
"touch /moor-write-test",
|success, _| !success,
),
probe(
"docker.sock is not present",
proc::run_capture(
"docker",
&["exec", container, "test", "-S", "/var/run/docker.sock"],
),
"test -S /var/run/docker.sock",
|success, _| !success,
),
]
}
fn evaluate(info: &Value) -> Vec<Check> {
let mut checks = vec![];
let readonly = info["HostConfig"]["ReadonlyRootfs"]
.as_bool()
.unwrap_or(false);
checks.push(Check {
label: "read-only root filesystem",
pass: readonly,
detail: format!("ReadonlyRootfs={readonly}"),
});
let cap_drop = info["HostConfig"]["CapDrop"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
let drops_all = cap_drop.iter().any(|c| c.eq_ignore_ascii_case("ALL"));
checks.push(Check {
label: "all capabilities dropped",
pass: drops_all,
detail: format!("CapDrop={cap_drop:?}"),
});
let sec_opt = info["HostConfig"]["SecurityOpt"]
.as_array()
.map(|a| a.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
let no_new_priv = sec_opt.iter().any(|s| s.starts_with("no-new-privileges"));
checks.push(Check {
label: "no-new-privileges set",
pass: no_new_priv,
detail: format!("SecurityOpt={sec_opt:?}"),
});
let privileged = info["HostConfig"]["Privileged"].as_bool().unwrap_or(true);
checks.push(Check {
label: "not privileged",
pass: !privileged,
detail: format!("Privileged={privileged}"),
});
let mounts_present = info.get("Mounts").map(|v| !v.is_null()).unwrap_or(false);
let mounts = info["Mounts"].as_array().cloned().unwrap_or_default();
let bind_mounts: Vec<String> = mounts
.iter()
.filter(|mnt| mnt["Type"].as_str() == Some("bind"))
.map(|mnt| mnt["Source"].as_str().unwrap_or("?").to_string())
.collect();
checks.push(Check {
label: "no host bind mounts",
pass: mounts_present && bind_mounts.is_empty(),
detail: format!("bind mounts={bind_mounts:?} (Mounts field present={mounts_present})"),
});
let user = info["Config"]["User"].as_str().unwrap_or("");
let non_root = !user.is_empty() && user != "0" && user != "root";
checks.push(Check {
label: "non-root user",
pass: non_root,
detail: format!("User='{user}'"),
});
let networks_present = info
.get("NetworkSettings")
.and_then(|ns| ns.get("Networks"))
.map(|v| !v.is_null())
.unwrap_or(false);
let networks = info["NetworkSettings"]["Networks"]
.as_object()
.map(|o| o.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let on_default_bridge = networks.iter().any(|n| n == "bridge");
checks.push(Check {
label: "not on the default bridge network",
pass: networks_present && !on_default_bridge,
detail: format!("networks={networks:?} (Networks field present={networks_present})"),
});
let pids_limit = info["HostConfig"]["PidsLimit"].as_i64().unwrap_or(0);
checks.push(Check {
label: "pids limit set",
pass: pids_limit > 0,
detail: format!("PidsLimit={pids_limit}"),
});
checks
}
pub fn run(name: &str) -> Result<()> {
let m = Manifest::load(&paths::manifest_path(name)?)?;
let container = m.sandbox_container();
let (status, out) = proc::run_capture("docker", &["inspect", &container])?;
if !status.success() {
anyhow::bail!(
"`docker inspect {container}` failed — is the project up? (`moor up {name}`)"
);
}
let parsed: Vec<Value> = serde_json::from_str(&out).context("parsing docker inspect output")?;
let info = parsed
.first()
.context("docker inspect returned no containers")?;
let mut checks = evaluate(info);
println!("-- static hardening checks (docker inspect) --");
for c in &checks {
let mark = if c.pass { "PASS" } else { "FAIL" };
println!("[{mark}] {} ({})", c.label, c.detail);
}
println!("\n-- active breakout battery (live probes) --");
let battery = run_breakout_battery(&container);
for c in &battery {
let mark = if c.pass { "PASS" } else { "FAIL" };
println!("[{mark}] {} ({})", c.label, c.detail);
}
audit::log_exec(
name,
&m,
"tripwire-check",
&["selftest".into(), "breakout-battery".into()],
None,
)
.ok();
let folded = audit::fold_egress_log(name, &m).unwrap_or(0);
if folded > 0 {
println!("\n(folded {folded} new egress-log entries into the audit chain — check `moor audit {name}` for any TRIPWIRE lines)");
}
checks.extend(battery.into_iter().map(|c| Check {
label: c.label,
pass: c.pass,
detail: c.detail,
}));
let all_pass = checks.iter().all(|c| c.pass);
if all_pass {
println!("\nselftest: all checks passed for '{name}'.");
Ok(())
} else {
anyhow::bail!(
"selftest: one or more hardening checks failed for '{name}' — see FAIL lines above"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn hardened_container() -> Value {
json!({
"HostConfig": {
"ReadonlyRootfs": true,
"CapDrop": ["ALL"],
"SecurityOpt": ["no-new-privileges:true"],
"Privileged": false,
"PidsLimit": 512
},
"Config": { "User": "agent" },
"Mounts": [
{"Type": "volume", "Source": "myproj-workspace"},
{"Type": "tmpfs", "Source": ""}
],
"NetworkSettings": {
"Networks": { "myproj-internal": {} }
}
})
}
#[test]
fn hardened_container_passes_every_check() {
let checks = evaluate(&hardened_container());
assert_eq!(checks.len(), 8, "expected 8 hardening checks");
for c in &checks {
assert!(
c.pass,
"expected '{}' to pass, detail={}",
c.label, c.detail
);
}
}
#[test]
fn writable_rootfs_fails() {
let mut v = hardened_container();
v["HostConfig"]["ReadonlyRootfs"] = json!(false);
let checks = evaluate(&v);
let c = checks
.iter()
.find(|c| c.label == "read-only root filesystem")
.unwrap();
assert!(!c.pass);
}
#[test]
fn missing_cap_drop_all_fails() {
let mut v = hardened_container();
v["HostConfig"]["CapDrop"] = json!(["NET_ADMIN"]);
let checks = evaluate(&v);
let c = checks
.iter()
.find(|c| c.label == "all capabilities dropped")
.unwrap();
assert!(!c.pass);
}
#[test]
fn missing_no_new_privileges_fails() {
let mut v = hardened_container();
v["HostConfig"]["SecurityOpt"] = json!([]);
let checks = evaluate(&v);
let c = checks
.iter()
.find(|c| c.label == "no-new-privileges set")
.unwrap();
assert!(!c.pass);
}
#[test]
fn privileged_container_fails() {
let mut v = hardened_container();
v["HostConfig"]["Privileged"] = json!(true);
let checks = evaluate(&v);
let c = checks.iter().find(|c| c.label == "not privileged").unwrap();
assert!(!c.pass);
}
#[test]
fn bind_mount_fails() {
let mut v = hardened_container();
v["Mounts"] = json!([{"Type": "bind", "Source": "/Users/danebalia"}]);
let checks = evaluate(&v);
let c = checks
.iter()
.find(|c| c.label == "no host bind mounts")
.unwrap();
assert!(!c.pass);
assert!(c.detail.contains("/Users/danebalia"));
}
#[test]
fn root_user_fails() {
for root_spelling in ["root", "0", ""] {
let mut v = hardened_container();
v["Config"]["User"] = json!(root_spelling);
let checks = evaluate(&v);
let c = checks.iter().find(|c| c.label == "non-root user").unwrap();
assert!(!c.pass, "'{root_spelling}' should not count as non-root");
}
}
#[test]
fn default_bridge_network_fails() {
let mut v = hardened_container();
v["NetworkSettings"]["Networks"] = json!({"bridge": {}});
let checks = evaluate(&v);
let c = checks
.iter()
.find(|c| c.label == "not on the default bridge network")
.unwrap();
assert!(!c.pass);
}
#[test]
fn zero_pids_limit_fails() {
let mut v = hardened_container();
v["HostConfig"]["PidsLimit"] = json!(0);
let checks = evaluate(&v);
let c = checks.iter().find(|c| c.label == "pids limit set").unwrap();
assert!(!c.pass);
}
#[test]
fn missing_fields_default_to_failing_safe() {
let checks = evaluate(&json!({}));
assert!(
checks.iter().all(|c| !c.pass),
"empty inspect output must fail every check"
);
}
}