//! The ledger's first and third steps: decide and record; raise the state and record the result. //! And what happens after a failed append or a panic. Do not edit. #[path = "support/build.rs"] mod build; #[path = "support/rig.rs"] mod rig; #[path = "support/sink.rs"] mod sink; #[path = "support/tmp.rs"] mod tmp; use std::os::unix::fs::PermissionsExt; use std::panic::AssertUnwindSafe; use brokerd::ledger::{Call, Decided, Grants, NOT_RECORDED, POISONED, STOPPED}; use brokerd::policy::{Label, SessionState}; use build::{grant, now, read, set}; use proto::{ AuditEvent, CallId, DataClass, DecisionRecord, DenyReason, GrantProblem, Mode, ResultStatus, SessionId, ToolResponse, }; use rig::Rig; fn one(grants: Vec) -> Grants { Ok(set(grants)) } fn notes(mode: Mode) -> Grants { one(vec![grant("n", "read_file", mode).paths(&["/n"])]) } fn call(label: Label) -> Call { Call { session: SessionId::new("s1").unwrap(), call: CallId(1), decision: 0, label, } } fn secret() -> Label { Label { class: DataClass::Secret, untrusted: true, } } fn result(content: &str, label: Label) -> ToolResponse { ToolResponse::Result { content: content.to_string(), class: label.class, untrusted: label.untrusted, truncated: false, } } #[test] fn an_allowed_call_is_recorded_in_full() { let rig = Rig::new("ledger-allowed"); let ledger = rig.ledger(); let request = read("/n/a"); let arguments = request.arguments.clone(); match ledger.decide(request, ¬es(Mode::Auto), now()) { Decided::Allowed { decision, seq } => { assert_eq!(seq, 0); assert_eq!(decision.grant(), "n"); } other => panic!("{other:?}"), } let records = rig.records(); assert_eq!(records.len(), 1); assert_eq!(records[0].time, now()); assert_eq!( records[0].event, AuditEvent::Decision { session: SessionId::new("s1").unwrap(), call: CallId(1), tool: "read_file".to_string(), arguments, outcome: DecisionRecord::Allowed {}, grant: Some("n".to_string()), grant_sha256: Some(proto::sha256(b"n").unwrap()), taint: DataClass::Private, untrusted: false, } ); } #[test] fn an_ask_is_recorded_as_ask_with_the_state_it_was_decided_at() { let rig = Rig::new("ledger-ask"); let ledger = rig.ledger(); match ledger.decide(read("/n/a"), ¬es(Mode::Ask), now()) { Decided::Ask { ask, seq, state } => { assert_eq!((seq, ask.grant()), (0, "n")); assert_eq!(state, SessionState::default()); } other => panic!("{other:?}"), } match &rig.events()[0] { AuditEvent::Decision { outcome, grant, .. } => { assert_eq!(*outcome, DecisionRecord::Ask {}); assert_eq!(grant.as_deref(), Some("n")); } other => panic!("{other:?}"), } } #[test] fn a_denial_names_a_grant_only_when_a_grant_denied() { let rig = Rig::new("ledger-denied"); let ledger = rig.ledger(); let none = ledger.decide(read("/n/a"), &one(Vec::new()), now()); assert!( matches!(none, Decided::Denied(DenyReason::NoGrant)), "{none:?}" ); let deny = one(vec![grant("d", "read_file", Mode::Deny).paths(&["/n"])]); let by = ledger.decide(read("/n/a"), &deny, now()); assert!( matches!(by, Decided::Denied(DenyReason::DeniedByGrant)), "{by:?}" ); let events = rig.events(); let named: Vec<(DecisionRecord, Option)> = events .iter() .map(|e| match e { AuditEvent::Decision { outcome, grant, .. } => (outcome.clone(), grant.clone()), other => panic!("{other:?}"), }) .collect(); assert_eq!( named, [ ( DecisionRecord::Denied { reason: DenyReason::NoGrant }, None ), ( DecisionRecord::Denied { reason: DenyReason::DeniedByGrant }, Some("d".to_string()) ), ] ); } #[test] fn invalid_grants_come_first_then_an_unreadable_state() { let rig = Rig::new("ledger-order"); let ledger = rig.ledger(); std::fs::create_dir_all(rig.cfg.state_dir()).unwrap(); std::fs::write(rig.state_file("s1"), "not json").unwrap(); let invalid: Grants = Err(vec![GrantProblem { file: "x.toml".to_string(), line: None, problem: "bad".to_string(), }]); let first = ledger.decide(read("/n/a"), &invalid, now()); assert!( matches!(first, Decided::Denied(DenyReason::GrantsInvalid)), "{first:?}" ); let second = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); assert!( matches!(second, Decided::Denied(DenyReason::StateUnreadable)), "{second:?}" ); // A state brokerd cannot read is recorded as the most sensitive one. for event in rig.events() { match event { AuditEvent::Decision { taint, untrusted, .. } => assert_eq!((taint, untrusted), (DataClass::Secret, true)), other => panic!("{other:?}"), } } let damaged = rig.lines.with("see docs/runbook.md#broker-state-damaged"); assert!(!damaged.is_empty(), "{:?}", rig.lines.all()); } #[test] fn a_failed_append_denies_this_call_and_every_later_one() { let rig = Rig::new("ledger-stop"); let ledger = rig.ledger(); rig.switch.fail(true); let first = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); assert!( matches!(first, Decided::Denied(DenyReason::AuditUnavailable)), "{first:?}" ); assert_eq!(rig.switch.attempts(), 1); // The sink would work again; the ledger does not try it. rig.switch.fail(false); let later = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); assert!( matches!(later, Decided::Denied(DenyReason::AuditUnavailable)), "{later:?}" ); let finished = ledger.finish(&call(secret()), result("x", secret()), now()); assert_eq!( finished, ToolResponse::Failed { message: NOT_RECORDED.to_string() } ); assert_eq!(rig.switch.attempts(), 1, "nothing more was written"); assert!(rig.records().is_empty()); assert!(!rig.state_file("s1").exists(), "the state was not raised"); assert!(!rig.lines.with(STOPPED).is_empty()); assert!( rig.lines .all() .iter() .all(|l| !l.contains("runbook") || l.ends_with("see docs/runbook.md#audit-unavailable")), "{:?}", rig.lines.all() ); } #[test] fn a_panic_while_holding_the_ledger_denies_every_later_call() { let rig = Rig::new("ledger-poison"); let ledger = rig.ledger(); rig.switch.panic_next(); let panicked = std::panic::catch_unwind(AssertUnwindSafe(|| { ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()) })); assert!(panicked.is_err()); let later = ledger.decide(read("/n/a"), ¬es(Mode::Auto), now()); assert!( matches!(later, Decided::Denied(DenyReason::AuditUnavailable)), "{later:?}" ); assert_eq!(rig.switch.attempts(), 1); assert!( !rig.lines.with(POISONED).is_empty(), "{:?}", rig.lines.all() ); assert!(POISONED.ends_with("see docs/runbook.md#audit-unavailable")); } #[test] fn a_result_raises_the_state_then_is_recorded() { let rig = Rig::new("ledger-result"); let ledger = rig.ledger(); let answer = ledger.finish(&call(secret()), result("key", secret()), now()); assert_eq!(answer, result("key", secret()), "passed on unchanged"); let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap(); assert_eq!( state, SessionState { taint: DataClass::Secret, untrusted: true } ); assert_eq!( rig.events(), [AuditEvent::Result { session: SessionId::new("s1").unwrap(), call: CallId(1), decision: 0, status: ResultStatus::Result, class: DataClass::Secret, untrusted: true, truncated: false, bytes: 3, sha256: proto::sha256(b"key").unwrap(), taint_after: DataClass::Secret, }] ); } #[test] fn taint_never_goes_down() { let rig = Rig::new("ledger-down"); let ledger = rig.ledger(); let public = Label { class: DataClass::Public, untrusted: false, }; ledger.finish(&call(secret()), result("a", secret()), now()); ledger.finish(&call(public), result("b", public), now()); let after: Vec = rig .events() .iter() .map(|e| match e { AuditEvent::Result { taint_after, .. } => *taint_after, other => panic!("{other:?}"), }) .collect(); assert_eq!(after, [DataClass::Secret, DataClass::Secret]); let state = rig.state().read(&SessionId::new("s1").unwrap()).unwrap(); assert_eq!((state.taint, state.untrusted), (DataClass::Secret, true)); } #[test] fn a_failure_changes_no_state_and_is_recorded_by_its_message() { let rig = Rig::new("ledger-failed"); let ledger = rig.ledger(); let failed = ToolResponse::Failed { message: "the tool timed out".to_string(), }; assert_eq!( ledger.finish(&call(secret()), failed.clone(), now()), failed ); assert!(!rig.state_file("s1").exists()); match &rig.events()[0] { AuditEvent::Result { status, class, bytes, sha256, taint_after, .. } => { assert_eq!(*status, ResultStatus::Failed); assert_eq!( *class, DataClass::Secret, "the label the result would have had" ); assert_eq!(*bytes, 18); assert_eq!(*sha256, proto::sha256(b"the tool timed out").unwrap()); assert_eq!(*taint_after, DataClass::Private, "the taint before"); } other => panic!("{other:?}"), } } #[test] fn a_state_that_cannot_be_written_withholds_the_content() { if tmp::running_as_root("a_state_that_cannot_be_written_withholds_the_content") { return; } let rig = Rig::new("ledger-ro"); let ledger = rig.ledger(); let dir = rig.cfg.state_dir(); std::fs::create_dir_all(&dir).unwrap(); std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o500)).unwrap(); let answer = ledger.finish(&call(secret()), result("key", secret()), now()); std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap(); assert_eq!( answer, ToolResponse::Failed { message: NOT_RECORDED.to_string() } ); assert!( rig.records().is_empty(), "no Result for a state not on disk" ); assert!( !rig.lines .with("see docs/runbook.md#broker-state-damaged") .is_empty() ); } #[test] fn a_result_record_that_cannot_be_written_withholds_the_content() { let rig = Rig::new("ledger-norecord"); let ledger = rig.ledger(); rig.switch.fail(true); let answer = ledger.finish(&call(secret()), result("key", secret()), now()); assert_eq!( answer, ToolResponse::Failed { message: NOT_RECORDED.to_string() } ); assert!( !rig.lines .with("see docs/runbook.md#audit-unavailable") .is_empty() ); }