use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::path::Path;
use std::time::{Duration, Instant};
use anyhow::{Context as _, Result, bail};
use openmoat_audit::{Cached, Event, EventId, Store};
use openmoat_core::{Action, CompiledPolicy, Decision, TAINT_RULE, Taint, Verdict};
use openmoat_hosts::{HookEvent, Host};
use serde::{Deserialize, Serialize};
use crate::approvals::{self, Grants};
use crate::context;
use crate::home::Home;
use crate::integrity::sha256_hex;
use crate::realpath::FsPathResolver;
pub mod provenance;
pub mod run;
#[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionTaint {
pub taint: Taint,
pub untrusted_last_ms: Option<i64>,
pub untrusted_last_row: Option<i64>,
}
pub fn of_session(
store: &Store,
host: Host,
session: &str,
policy: &CompiledPolicy<'_>,
cwd: &str,
progress: &mut Progress<'_>,
) -> Result<(SessionTaint, Option<CacheWrite>)> {
let digest = digest(host, policy, cwd)?;
let (mut out, start) = store
.session_cache(session, host.id(), &digest)
.ok()
.flatten()
.and_then(|cached| Some((serde_json::from_str(&cached.state).ok()?, cached.upto)))
.unwrap_or((SessionTaint::default(), EventId(0)));
let mut upto = start;
let mut pass = Pass {
deadline: Some(progress.deadline),
..Pass::default()
};
let mut saved = Instant::now();
let write = |out: &SessionTaint, upto: EventId| -> Result<CacheWrite> {
Ok(CacheWrite {
session: session.to_owned(),
host,
cached: Cached {
digest: digest.clone(),
upto,
state: serde_json::to_string(out)?,
},
})
};
loop {
let events = store
.session_page(session, upto, PAGE_LEN)
.context("reading this session from the audit log")?;
let folded = fold(&mut out, &events, host, policy, cwd, &mut pass)?;
progress.folded = pass.folded;
if let Some(last) = events.get(..folded).and_then(<[Event]>::last) {
upto = last.id;
}
if folded < events.len() {
if upto != start {
(progress.save)(&write(&out, upto)?);
}
bail!(
"this session has more earlier calls than one call has time to read; what was \
read is kept, so a later call reads on from there"
);
}
if events.len() < PAGE_LEN {
break;
}
if saved.elapsed() >= CHECKPOINT_EVERY {
(progress.save)(&write(&out, upto)?);
saved = Instant::now();
}
}
let write = if upto == start {
None
} else {
Some(write(&out, upto)?)
};
if openmoat_core::TaintSettings::provenance_on(policy.policy().taint.as_ref())
&& let Some(exposure) = store
.exposure(session, host.id())
.context("reading this session's file provenance from the audit log")?
{
out.taint.untrusted.get_or_insert(exposure.source);
out.untrusted_last_ms = out.untrusted_last_ms.max(Some(exposure.last_ts_ms));
out.untrusted_last_row = out.untrusted_last_row.max(Some(exposure.last_event.0));
}
Ok((out, write))
}
const PAGE_LEN: usize = 512;
pub const CHECKPOINT_EVERY: Duration = Duration::from_secs(2);
pub struct Progress<'a> {
pub deadline: Instant,
pub save: &'a mut dyn FnMut(&CacheWrite),
pub folded: usize,
}
pub struct CacheWrite {
session: String,
host: Host,
cached: Cached,
}
impl CacheWrite {
pub fn write(&self, store: &Store) {
let _ = store.try_set_session_cache(&self.session, self.host.id(), &self.cached);
}
}
fn digest(host: Host, policy: &CompiledPolicy<'_>, cwd: &str) -> Result<String> {
let mut bytes = Vec::new();
for part in [
crate::VERSION.to_owned(),
host.id().to_owned(),
serde_json::to_string(policy.policy())?,
format!("{:?}", policy.context()),
cwd.to_owned(),
] {
bytes.extend_from_slice(&part.len().to_le_bytes());
bytes.extend_from_slice(part.as_bytes());
}
Ok(sha256_hex(&bytes))
}
#[derive(Default)]
struct Pass {
seen: HashMap<(String, String), Taint>,
deadline: Option<Instant>,
folded: usize,
}
fn fold(
out: &mut SessionTaint,
events: &[Event],
host: Host,
policy: &CompiledPolicy<'_>,
cwd: &str,
pass: &mut Pass,
) -> Result<usize> {
for (taken, event) in events.iter().enumerate() {
if pass.deadline.is_some_and(|d| Instant::now() >= d) {
return Ok(taken);
}
if event.host != host.id() || !ran(host, event.verdict) {
continue;
}
pass.folded += 1;
if event.action_unreadable {
bail!("audit event {} of this session cannot be decoded", event.id);
}
let Some(action) = &event.action else {
continue;
};
let cwd = event
.cwd
.as_deref()
.map_or_else(|| cwd.to_owned(), |c| context::path_string(Path::new(c)));
let key = (serde_json::to_string(action)?, cwd);
let exposure = match pass.seen.entry(key) {
Entry::Occupied(known) => known.get().clone(),
Entry::Vacant(slot) => {
let exposure = policy.exposure(action, &slot.key().1, &FsPathResolver);
slot.insert(exposure).clone()
}
};
if exposure.untrusted.is_some() {
out.untrusted_last_ms = out.untrusted_last_ms.max(Some(event.ts_ms));
out.untrusted_last_row = out.untrusted_last_row.max(Some(event.id.0));
}
out.taint.absorb(exposure);
}
Ok(events.len())
}
pub struct Call<'a> {
pub home: &'a Home,
pub host: Host,
pub session: &'a str,
pub action: &'a Action,
pub cwd: &'a str,
pub user_home: &'a str,
}
pub fn answered(
call: &Call<'_>,
policy: &CompiledPolicy<'_>,
session: &SessionTaint,
untainted: Decision,
tainted: Decision,
now_ms: i64,
) -> Result<Decision> {
let Some(last_read) = session.untrusted_last_ms else {
return Ok(tainted);
};
if tainted.verdict != Verdict::Ask || !tainted.rules.iter().any(|r| r == TAINT_RULE) {
return Ok(tainted);
}
let secrets_only = Taint {
secrets: session.taint.secrets.clone(),
untrusted: None,
};
let with_secrets = policy.with_taint(
untainted.clone(),
call.action,
&FsPathResolver,
&secrets_only,
);
if with_secrets.rules.iter().any(|r| r == TAINT_RULE) {
return Ok(tainted);
}
let grants = Grants::load_in(call.home)?;
let read_row = session.untrusted_last_row;
let fresh =
|g: &&approvals::Grant| after_read(g.granted_at_ms, g.audit_row, last_read, read_row);
let project = approvals::project_of(Path::new(call.cwd), Path::new(call.user_home));
let scope = approvals::Scope {
host: call.host.id(),
session_id: call.session,
project: &project,
};
let exact = approvals::approvable(call.action, Some(call.cwd), call.user_home)
.and_then(|asked| grants.newest(scope, &asked, now_ms).cloned());
if exact.as_ref().is_some_and(|g| fresh(&g)) {
let mut decision = untainted;
decision.reasons.push(
"approved with `openmoat allow` after the session last read untrusted content"
.to_owned(),
);
return Ok(decision);
}
if grants
.covering_taint(scope, now_ms)
.is_some_and(|g| fresh(&g))
{
let mut decision = untainted;
decision.reasons.push(
"the session's protected writes were approved with `openmoat allow` after it last read \
untrusted content"
.to_owned(),
);
return Ok(decision);
}
let mut decision = tainted;
if call.host.spec().asks.allow_last_hint {
decision.reasons.push(
"to stop asking for this in this session, run `openmoat allow --last` in a terminal"
.to_owned(),
);
}
Ok(decision)
}
fn after_read(granted_ms: i64, seen_row: Option<i64>, read_ms: i64, read_row: Option<i64>) -> bool {
match (seen_row, read_row) {
(Some(seen), Some(read)) => seen >= read,
_ => granted_ms > read_ms,
}
}
pub(crate) fn ran(host: Host, verdict: Verdict) -> bool {
host.answer(&HookEvent::PreToolUse, &Decision::new(verdict))
.verdict
!= Verdict::Deny
}
#[cfg(test)]
mod tests;