use crate::{audit, manifest::Manifest, paths, proc};
use anyhow::{Context, Result};
use serde_json::{json, Value};
pub const POSTURE_SCHEMA: &str = "keel.posture/1";
pub const IN_SANDBOX_PATH: &str = "/run/moor/posture.json";
fn verdict(status: &str, evidence: String) -> Value {
json!({ "status": status, "evidence": evidence })
}
fn unproven(what: &str) -> Value {
verdict("unproven", format!("docker inspect does not report {what}"))
}
fn flag(v: &Value, what: &str, good: bool) -> Value {
match v.as_bool() {
Some(b) if b == good => verdict("proven", format!("{what} is {b}")),
Some(b) => verdict("violated", format!("{what} is {b}")),
None => unproven(what),
}
}
pub fn derive(container: &Value, networks: &[Value]) -> Value {
let host = &container["HostConfig"];
let mut p = serde_json::Map::new();
p.insert(
"fs.read_only_root".into(),
flag(&host["ReadonlyRootfs"], "HostConfig.ReadonlyRootfs", true),
);
p.insert(
"privileged.off".into(),
flag(&host["Privileged"], "HostConfig.Privileged", false),
);
p.insert(
"mounts.no_host_bind".into(),
match container["Mounts"].as_array() {
None => unproven("Mounts"),
Some(mounts) => {
let binds: Vec<&str> = mounts
.iter()
.filter(|m| m["Type"] == "bind")
.filter_map(|m| m["Destination"].as_str())
.collect();
if binds.is_empty() {
verdict(
"proven",
format!("{} mount(s), none of type bind", mounts.len()),
)
} else {
verdict("violated", format!("bind mount(s) at {}", binds.join(", ")))
}
}
},
);
p.insert(
"user.non_root".into(),
match container["Config"]["User"].as_str() {
None => unproven("Config.User"),
Some(u) => {
let name = u.split(':').next().unwrap_or("");
if name.is_empty() || name == "root" || name == "0" {
verdict("violated", format!("Config.User is {u:?}"))
} else {
verdict("proven", format!("Config.User is {u:?}"))
}
}
},
);
let listed =
|field: &str, wanted: &dyn Fn(&str) -> bool, what: &str| match host[field].as_array() {
None => unproven(&format!("HostConfig.{field}")),
Some(items) => {
let items: Vec<&str> = items.iter().filter_map(Value::as_str).collect();
let status = if items.iter().any(|i| wanted(i)) {
"proven"
} else {
"violated"
};
verdict(
status,
format!("HostConfig.{field} is [{}] ({what})", items.join(", ")),
)
}
};
p.insert(
"caps.dropped_all".into(),
listed("CapDrop", &|c| c.eq_ignore_ascii_case("ALL"), "needs ALL"),
);
p.insert(
"privileges.no_new".into(),
listed(
"SecurityOpt",
&|o| o == "no-new-privileges" || o == "no-new-privileges:true",
"needs no-new-privileges",
),
);
p.insert(
"network.internal_only".into(),
match container["NetworkSettings"]["Networks"].as_object() {
None => unproven("NetworkSettings.Networks"),
Some(attached) if attached.is_empty() => unproven("any attached network"),
Some(attached) => {
let mut open = vec![];
let mut unknown = vec![];
for name in attached.keys() {
match networks
.iter()
.find(|n| n["Name"] == name.as_str())
.map(|n| &n["Internal"])
{
Some(Value::Bool(true)) => {}
Some(Value::Bool(false)) => open.push(name.as_str()),
_ => unknown.push(name.as_str()),
}
}
if !open.is_empty() {
verdict("violated", format!("not internal: {}", open.join(", ")))
} else if !unknown.is_empty() {
unproven(&format!("Internal for {}", unknown.join(", ")))
} else {
verdict(
"proven",
format!(
"internal: {}",
attached.keys().cloned().collect::<Vec<_>>().join(", ")
),
)
}
}
},
);
Value::Object(p)
}
fn inspect(args: &[&str]) -> Result<Value> {
let (status, out) = proc::run_capture("docker", args)?;
if !status.success() {
anyhow::bail!("`docker {}` failed: {}", args.join(" "), out.trim());
}
let v: Value = serde_json::from_str(&out).context("parsing docker inspect output")?;
Ok(v.as_array().and_then(|a| a.first()).cloned().unwrap_or(v))
}
pub fn attest(name: &str, m: &Manifest) -> Result<()> {
let sandbox = m.sandbox_container();
let container = inspect(&["inspect", &sandbox])?;
let networks: Vec<Value> = container["NetworkSettings"]["Networks"]
.as_object()
.map(|nets| {
nets.keys()
.filter_map(|n| inspect(&["network", "inspect", n]).ok())
.collect()
})
.unwrap_or_default();
let doc = json!({
"schema": POSTURE_SCHEMA,
"runtime": "moor",
"image_digest": container["Image"],
"attested_at": chrono::Utc::now().to_rfc3339(),
"properties": derive(&container, &networks),
});
let text = serde_json::to_string_pretty(&doc)?;
let host_copy = paths::posture_path(name)?;
std::fs::write(&host_copy, &text)
.with_context(|| format!("writing {}", host_copy.display()))?;
let status = proc::run_with_stdin_file(
"docker",
&[
"exec",
"-i",
"-u",
"root",
&sandbox,
"sh",
"-c",
&format!("cat > {IN_SANDBOX_PATH} && chmod 0444 {IN_SANDBOX_PATH}"),
],
&host_copy,
)?;
proc::require_success("writing the posture attestation into the sandbox", status)?;
audit::append_chained(
&paths::chain_log_path(name)?,
"attest",
json!({
"sha256": hex::encode(<sha2::Sha256 as sha2::Digest>::digest(text.as_bytes())),
"runtime": "moor",
"image_digest": doc["image_digest"],
"properties": doc["properties"],
}),
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn hardened() -> (Value, Vec<Value>) {
let container = json!({
"Image": "sha256:0123abcd",
"Config": { "User": "agent" },
"HostConfig": {
"ReadonlyRootfs": true,
"Privileged": false,
"CapDrop": ["ALL"],
"SecurityOpt": ["no-new-privileges:true"],
},
"Mounts": [
{ "Type": "volume", "Destination": "/workspace" },
{ "Type": "tmpfs", "Destination": "/run/moor" },
],
"NetworkSettings": { "Networks": { "demo-internal": {} } },
});
let networks = vec![json!({ "Name": "demo-internal", "Internal": true })];
(container, networks)
}
fn status(props: &Value, name: &str) -> String {
props[name]["status"]
.as_str()
.unwrap_or("<absent>")
.to_string()
}
const ALL: [&str; 7] = [
"fs.read_only_root",
"privileged.off",
"mounts.no_host_bind",
"user.non_root",
"caps.dropped_all",
"privileges.no_new",
"network.internal_only",
];
#[test]
fn attestation_is_derived_from_inspect_output() {
let (c, n) = hardened();
let props = derive(&c, &n);
for name in ALL {
assert_eq!(status(&props, name), "proven", "{name}: {}", props[name]);
}
let mut c2 = c.clone();
c2["HostConfig"]["ReadonlyRootfs"] = json!(false);
c2["HostConfig"]["Privileged"] = json!(true);
c2["HostConfig"]["CapDrop"] = json!(["NET_RAW"]);
c2["HostConfig"]["SecurityOpt"] = json!([]);
c2["Config"]["User"] = json!("0:0");
c2["Mounts"] = json!([{ "Type": "bind", "Destination": "/host" }]);
let open = vec![json!({ "Name": "demo-internal", "Internal": false })];
let props = derive(&c2, &open);
for name in ALL {
assert_eq!(status(&props, name), "violated", "{name}: {}", props[name]);
}
}
#[test]
fn missing_inspect_fields_are_unproven() {
let props = derive(&json!({}), &[]);
for name in ALL {
assert_eq!(status(&props, name), "unproven", "{name}: {}", props[name]);
}
let (mut c, _) = hardened();
c["HostConfig"]["ReadonlyRootfs"] = json!("yes");
c["HostConfig"]["CapDrop"] = Value::Null;
let props = derive(&c, &[]);
assert_eq!(status(&props, "fs.read_only_root"), "unproven");
assert_eq!(status(&props, "caps.dropped_all"), "unproven");
assert_eq!(status(&props, "network.internal_only"), "unproven");
assert_eq!(status(&props, "user.non_root"), "proven");
}
}