use std::collections::BTreeSet;
use anyhow::{Context as _, Result};
use openmoat_audit::{FileProvenance, NewProvenance, ProvenanceKind, Store};
use openmoat_core::{Action, AtomicAction, CompiledPolicy, TaintSettings};
use crate::guard_relay::MAX_PAYLOAD_BYTES;
use crate::integrity::sha256_hex;
use crate::realpath::FsPathResolver;
const MAX_SOURCE_CHARS: usize = 400;
pub struct Call<'a> {
pub session: &'a str,
pub action: &'a Action,
pub content: Option<&'a str>,
pub untrusted: Option<&'a str>,
}
#[derive(Debug, Default)]
pub struct Seen {
pub rows: Vec<NewProvenance>,
}
impl Seen {
pub fn write(
&self,
store: &Store,
event: openmoat_audit::EventId,
host: &str,
session: &str,
) -> Result<()> {
store
.add_provenance(event, host, session, &self.rows)
.context("recording file provenance")
}
}
pub fn observe(store: &Store, policy: &CompiledPolicy<'_>, call: &Call<'_>) -> Result<Seen> {
let mut seen = Seen::default();
if !TaintSettings::provenance_on(policy.policy().taint.as_ref())
|| (call.untrusted.is_none() && !store.has_provenance()?)
{
return Ok(seen);
}
let files = Files::of(policy, call.action);
let ci = policy.context().case_insensitive_paths;
let mut exposure = None;
for path in &files.reads {
let key = key(path, ci);
let Some(row) = store.file_provenance(&key)? else {
continue;
};
if row.tainted && holds(path, row.sha256.as_deref()) {
let reason = clip(&format!("read {path}, {}", row.source));
exposure.get_or_insert_with(|| reason.clone());
seen.rows.push(NewProvenance {
kind: ProvenanceKind::TaintedRead,
path: key,
sha256: None,
source: reason,
});
}
}
let untrusted = call.untrusted.map(str::to_owned).or(exposure);
let sha256 = call.content.map(|c| content_hash(c.as_bytes()));
for path in &files.writes {
let key = key(path, ci);
let row = match &untrusted {
Some(source) => Some(tainted_write(key, sha256.clone(), call.session, source)),
None => store
.file_provenance(&key)?
.and_then(|known| clean_write(key, sha256.clone(), &known)),
};
seen.rows.extend(row);
}
Ok(seen)
}
fn tainted_write(
key: String,
sha256: Option<String>,
session: &str,
untrusted: &str,
) -> NewProvenance {
NewProvenance {
kind: ProvenanceKind::TaintedWrite,
path: key,
sha256,
source: clip(&format!(
"which session {session} wrote after it read untrusted content ({untrusted})"
)),
}
}
fn clean_write(
key: String,
sha256: Option<String>,
known: &FileProvenance,
) -> Option<NewProvenance> {
if !known.tainted {
return None;
}
match sha256 {
Some(sha256) => Some(NewProvenance {
kind: ProvenanceKind::CleanWrite,
path: key,
sha256: Some(sha256),
source: String::new(),
}),
None if known.sha256.is_some() => Some(NewProvenance {
kind: ProvenanceKind::TaintedWrite,
path: key,
sha256: None,
source: known.source.clone(),
}),
None => None,
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Files {
pub reads: BTreeSet<String>,
pub writes: BTreeSet<String>,
}
impl Files {
pub(crate) fn of(policy: &CompiledPolicy<'_>, action: &Action) -> Self {
let ctx = policy.context();
let mut files = Self::default();
for atom in policy.atoms(action, &FsPathResolver).0 {
match atom {
AtomicAction::FsRead { path } => {
files.reads.insert(path);
}
AtomicAction::FsWrite { path } if literal(&path) => {
files.writes.insert(path);
}
AtomicAction::Shell { argv } => {
let Some(program) = argv.first().filter(|p| p.contains('/') && literal(p))
else {
continue;
};
let project = ctx.project.as_deref();
if let Some(path) =
openmoat_core::resolve_path(program, &ctx.home, project, Some(&ctx.cwd))
{
files.reads.insert(path);
}
}
_ => {}
}
}
files
}
}
fn literal(path: &str) -> bool {
!path.contains(['*', '?', '[', '$', '`']) && !path.starts_with('@')
}
fn key(path: &str, case_insensitive: bool) -> String {
if case_insensitive {
path.to_lowercase()
} else {
path.to_owned()
}
}
fn holds(path: &str, sha256: Option<&str>) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
let Some(want) = sha256 else {
return true;
};
meta.len() <= MAX_PAYLOAD_BYTES
&& std::fs::read(path).is_ok_and(|bytes| content_hash(&bytes) == want)
}
pub(crate) fn content_hash(bytes: &[u8]) -> String {
if !bytes.windows(2).any(|w| w == b"\r\n") {
return sha256_hex(bytes);
}
let mut unix = Vec::with_capacity(bytes.len());
let mut iter = bytes.iter().peekable();
while let Some(&b) = iter.next() {
if b == b'\r' && iter.peek() == Some(&&b'\n') {
continue;
}
unix.push(b);
}
sha256_hex(&unix)
}
fn clip(text: &str) -> String {
match text.char_indices().nth(MAX_SOURCE_CHARS) {
Some((end, _)) => format!("{}…", &text[..end]),
None => text.to_owned(),
}
}
#[cfg(test)]
mod tests;