use std::collections::BTreeSet;
use anyhow::{Context as _, Result};
use openmoat_audit::{FileProvenance, NewProvenance, ProvenanceKind, SessionWrite, Store};
use openmoat_core::{Action, AtomicAction, CompiledPolicy, TaintSettings};
use openmoat_hosts::Replacement;
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 edits: &'a [Replacement],
pub untrusted: Option<&'a str>,
}
#[derive(Debug, Default)]
pub struct Seen {
pub rows: Vec<NewProvenance>,
pub written: Vec<SessionWrite>,
}
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")?;
store
.add_session_writes(event, host, session, &self.written)
.context("recording the files this session wrote")
}
}
pub fn observe(store: &Store, policy: &CompiledPolicy<'_>, call: &Call<'_>) -> Result<Seen> {
let mut seen = Seen::default();
let files = Files::of(policy, call.action);
let ci = policy.context().case_insensitive_paths;
let sha256 = call.content.map(|c| content_hash(c.as_bytes()));
seen.written = files
.writes
.iter()
.map(|path| {
let (sha256, before) = match (&sha256, call.edits.is_empty()) {
(Some(sha256), _) => (Some(sha256.clone()), state_of(path)),
(None, false) => match edited(path, call.edits) {
Some((after, before)) => (Some(after), Some(before)),
None => (None, None),
},
(None, true) => (None, None),
};
SessionWrite {
path: key(path, ci),
sha256,
before,
}
})
.collect();
if !TaintSettings::provenance_on(policy.policy().taint.as_ref())
|| (call.untrusted.is_none() && !store.has_provenance()?)
{
return Ok(seen);
}
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);
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('@')
}
pub(crate) fn key(path: &str, case_insensitive: bool) -> String {
if case_insensitive {
path.to_lowercase()
} else {
path.to_owned()
}
}
enum FileNow {
Missing,
Content(Vec<u8>),
}
fn file_now(path: &str) -> Option<FileNow> {
match std::fs::metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Some(FileNow::Missing),
Err(_) => None,
Ok(meta) if !meta.is_file() || meta.len() > MAX_PAYLOAD_BYTES => None,
Ok(_) => std::fs::read(path).ok().map(FileNow::Content),
}
}
pub(crate) fn state_of(path: &str) -> Option<String> {
Some(match file_now(path)? {
FileNow::Missing => String::new(),
FileNow::Content(bytes) => content_hash(&bytes),
})
}
fn edited(path: &str, edits: &[Replacement]) -> Option<(String, String)> {
let FileNow::Content(bytes) = file_now(path)? else {
return None;
};
let text = std::str::from_utf8(&bytes).ok()?;
let after = apply_edits(text, edits)?;
Some((content_hash(after.as_bytes()), content_hash(&bytes)))
}
pub(crate) fn apply_edits(text: &str, edits: &[Replacement]) -> Option<String> {
let mut text = text.to_owned();
for edit in edits {
if edit.old.is_empty() || edit.old == edit.new {
return None;
}
let occurrences = text.matches(edit.old.as_str()).count();
text = match occurrences {
0 => return None,
1 => text.replacen(edit.old.as_str(), &edit.new, 1),
_ if edit.all => text.replace(edit.old.as_str(), &edit.new),
_ => return None,
};
}
Some(text)
}
pub(crate) 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;