diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs index 3c6c579..81f935d 100644 --- a/crates/guara-cli/src/main.rs +++ b/crates/guara-cli/src/main.rs @@ -88,6 +88,20 @@ enum Cmd { }, /// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem Verify { path: String }, + /// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash + Audit { + /// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG) + #[arg(long)] + log: Option, + /// Só confere a cadeia de hashes; sai com erro se ela estiver quebrada + #[arg(long)] + verify: bool, + /// Mostra só as últimas N entradas + #[arg(long)] + tail: Option, + #[arg(long)] + json: bool, + }, /// Exporta os achados de uma sessão para CSV (abre no Excel) Export { session: String, @@ -427,52 +441,20 @@ fn run(cli: Cli) -> Result<(), String> { eng.save_session(&job, &session).map_err(s)?; Ok(()) } - Cmd::Verify { path } if path.to_ascii_lowercase().ends_with(".aff4") => { - let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(&path)).map_err(s)?; - println!("Formato: AFF4"); - for (k, v) in &a.info { - println!("{k}: {v}"); - } - eprintln!("Recalculando o hash..."); - match a.verify_stream_md5().map_err(s)? { - Some(true) => { - println!("MD5 gravado e recalculado conferem."); - Ok(()) - } - Some(false) => Err("O MD5 recalculado difere do gravado na imagem.".into()), - None => { - println!("Esta imagem não traz MD5 para conferir."); - Ok(()) - } - } - } Cmd::Verify { path } => { - let e = guara_core::ewf::EwfSource::open(std::path::Path::new(&path)).map_err(s)?; - println!( - "Formato: {}", - if e.info.version == 2 { - "Ex01 (EnCase 7)" - } else { - "E01 (EnCase)" - } + let r = verify_image(&path); + guara_core::audit::record( + "verify", + json!({ "path": path, "result": match &r { Ok(Some(true)) => "conferem", Ok(Some(false)) => "diferem", Ok(None) => "sem hash gravado", Err(_) => "erro" } }), ); - println!("Segmentos: {}", e.info.segments); - for (k, v) in &e.info.fields { - println!("{k}: {v}"); - } - eprintln!("Recalculando o hash..."); - match e.verify().map_err(s)? { - Some(true) => { - println!("MD5 gravado e recalculado conferem: {}", e.info.md5.clone().unwrap_or_default()); - Ok(()) - } - Some(false) => Err("O MD5 recalculado difere do gravado na imagem: ela está corrompida ou foi alterada.".into()), - None => { - println!("Esta imagem não traz hash gravado para conferir."); - Ok(()) - } - } + r.map(|_| ()) } + Cmd::Audit { + log, + verify, + tail, + json, + } => audit(log, verify, tail, json), Cmd::Image { path, dest, @@ -790,6 +772,115 @@ fn recover_job( Ok(()) } +/// Confere o hash gravado numa imagem E01/Ex01 ou AFF4. `Some(true)` = confere. +fn verify_image(path: &str) -> Result, String> { + if path.to_ascii_lowercase().ends_with(".aff4") { + let a = guara_core::aff4::Aff4Source::open(std::path::Path::new(path)).map_err(s)?; + println!("Formato: AFF4"); + for (k, v) in &a.info { + println!("{k}: {v}"); + } + eprintln!("Recalculando o hash..."); + return match a.verify_stream_md5().map_err(s)? { + Some(true) => { + println!("MD5 gravado e recalculado conferem."); + Ok(Some(true)) + } + Some(false) => Err("O MD5 recalculado difere do gravado na imagem.".into()), + None => { + println!("Esta imagem não traz MD5 para conferir."); + Ok(None) + } + }; + } + let e = guara_core::ewf::EwfSource::open(std::path::Path::new(path)).map_err(s)?; + println!( + "Formato: {}", + if e.info.version == 2 { + "Ex01 (EnCase 7)" + } else { + "E01 (EnCase)" + } + ); + println!("Segmentos: {}", e.info.segments); + for (k, v) in &e.info.fields { + println!("{k}: {v}"); + } + eprintln!("Recalculando o hash..."); + match e.verify().map_err(s)? { + Some(true) => { + println!( + "MD5 gravado e recalculado conferem: {}", + e.info.md5.clone().unwrap_or_default() + ); + Ok(Some(true)) + } + Some(false) => Err( + "O MD5 recalculado difere do gravado na imagem: ela está corrompida ou foi alterada." + .into(), + ), + None => { + println!("Esta imagem não traz hash gravado para conferir."); + Ok(None) + } + } +} + +/// `guara audit`: lista a trilha ou confere a cadeia de hashes. +fn audit(log: Option, verify: bool, tail: Option, json: bool) -> Result<(), String> { + let log = match log { + Some(p) => guara_core::audit::Log::at(p), + None => guara_core::audit::Log::open_default(), + }; + let v = log.verify().map_err(s)?; + if verify { + if json { + println!("{}", serde_json::to_string_pretty(&v).unwrap()); + } else if v.ok { + println!("Cadeia íntegra: {} entradas.", v.entries); + if v.entries > 0 { + println!( + "Hash da última entrada (guarde-o fora desta máquina): {}", + v.head + ); + } + } + return match v.broken_at { + None => Ok(()), + Some((n, why)) => Err(format!("cadeia quebrada na entrada {n}: {why}")), + }; + } + let mut entries = log.entries().map_err(s)?; + if let Some(n) = tail { + let skip = entries.len().saturating_sub(n); + entries.drain(..skip); + } + if json { + println!("{}", serde_json::to_string_pretty(&entries).unwrap()); + } else if entries.is_empty() { + println!("Trilha vazia ({}).", log.path().display()); + } else { + for e in &entries { + let who = match &e.examiner { + Some(x) => format!("{}/{x}", e.user), + None => e.user.clone(), + }; + println!( + "#{:<4} {} {who}@{} {} {}", + e.seq, e.time, e.host, e.action, e.detail + ); + } + } + if !v.ok { + let (n, why) = v.broken_at.unwrap_or_default(); + eprintln!( + "{} cadeia quebrada na entrada {n}: {why}", + paint("31", "atenção:") + ); + } + Ok(()) +} + fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result { let interrupted = Arc::new(AtomicBool::new(false)); let flag = interrupted.clone(); diff --git a/crates/guara-core/src/audit.rs b/crates/guara-core/src/audit.rs new file mode 100644 index 0000000..90b8af1 --- /dev/null +++ b/crates/guara-core/src/audit.rs @@ -0,0 +1,324 @@ +//! Trilha de auditoria: cada ação do examinador (abrir uma origem, varrer, recuperar, criar +//! imagem, exportar) vira uma linha JSON com quem, quando e o quê. +//! +//! As linhas são encadeadas por hash: o `hash` de cada uma é o SHA-256 do `hash` da anterior +//! seguido do corpo da própria linha. Alterar ou apagar uma linha do meio quebra a cadeia e +//! `guara audit --verify` aponta onde. Cortar o fim do arquivo não é detectável sozinho; para +//! isso, guarde o hash da última entrada (a "cabeça") fora da máquina, por exemplo no relatório. +//! +//! O arquivo fica na pasta de configuração (`auditoria.jsonl`) ou onde `GUARA_AUDIT_LOG` +//! apontar. Segredos (senhas, chaves) nunca entram no registro. + +use crate::error::{Error, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; + +const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + +#[derive(Serialize)] +struct Body<'a> { + seq: u64, + time: &'a str, + user: &'a str, + host: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + examiner: Option<&'a str>, + version: &'a str, + action: &'a str, + detail: &'a Value, + prev: &'a str, +} + +/// Uma linha da trilha. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Entry { + pub seq: u64, + /// UTC, ISO 8601. + pub time: String, + pub user: String, + pub host: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub examiner: Option, + pub version: String, + pub action: String, + pub detail: Value, + pub prev: String, + pub hash: String, +} + +/// Resultado de conferir a cadeia. +#[derive(Debug, Clone, Serialize)] +pub struct Verification { + pub entries: usize, + pub ok: bool, + /// Entrada (1, 2, ...) onde a cadeia quebra e o motivo. + pub broken_at: Option<(u64, String)>, + /// Hash da última entrada; guarde-o fora da máquina para detectar truncamento. + pub head: String, +} + +pub struct Log { + path: PathBuf, +} + +fn sha_hex(prev: &str, body: &str) -> String { + let mut h = Sha256::new(); + h.update(prev.as_bytes()); + h.update(b"\n"); + h.update(body.as_bytes()); + h.finalize().iter().map(|b| format!("{b:02x}")).collect() +} + +/// UTC em ISO 8601 a partir de segundos Unix (conversão civil de Howard Hinnant). +pub fn utc_iso(secs: i64) -> String { + let days = secs.div_euclid(86400); + let rem = secs.rem_euclid(86400); + let z = days + 719468; + let era = z.div_euclid(146097); + let doe = z.rem_euclid(146097); + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!( + "{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", + rem / 3600, + rem % 3600 / 60, + rem % 60 + ) +} + +fn now_iso() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + utc_iso(secs) +} + +fn env(k: &str) -> Option { + std::env::var(k).ok().filter(|v| !v.is_empty()) +} + +/// Usuário do sistema por trás da ação (o que chamou sudo, quando houver). +fn current_user() -> String { + env("SUDO_USER") + .or_else(|| env("USER")) + .or_else(|| env("USERNAME")) + .or_else(|| env("LOGNAME")) + .unwrap_or_else(|| "?".into()) +} + +fn host_name() -> String { + env("HOSTNAME") + .or_else(|| env("COMPUTERNAME")) + .or_else(|| { + std::fs::read_to_string("/etc/hostname") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + }) + .unwrap_or_else(|| "?".into()) +} + +/// Última linha não vazia do arquivo, lendo só o fim. +fn last_line(path: &Path) -> std::io::Result> { + let mut f = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e), + }; + let len = f.metadata()?.len(); + let mut want = 8192u64; + loop { + let start = len.saturating_sub(want); + f.seek(SeekFrom::Start(start))?; + let mut buf = vec![]; + f.read_to_end(&mut buf)?; + let text = String::from_utf8_lossy(&buf); + let trimmed = text.trim_end(); + match trimmed.rfind('\n') { + Some(i) => return Ok(Some(trimmed[i + 1..].to_string())), + None if start == 0 => { + return Ok((!trimmed.is_empty()).then(|| trimmed.to_string())); + } + None => want *= 4, + } + } +} + +impl Log { + pub fn at(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// `GUARA_AUDIT_LOG` ou `auditoria.jsonl` na pasta de configuração. + pub fn default_path() -> PathBuf { + match env("GUARA_AUDIT_LOG") { + Some(p) => PathBuf::from(p), + None => crate::settings::config_dir().join("auditoria.jsonl"), + } + } + + pub fn open_default() -> Self { + Self::at(Self::default_path()) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// Acrescenta uma entrada ao fim da cadeia. + pub fn append(&self, action: &str, detail: Value) -> Result { + let (seq, prev) = match last_line(&self.path)? { + None => (1, GENESIS.to_string()), + Some(l) => { + let e: Entry = serde_json::from_str(&l) + .map_err(|e| Error::msg(format!("última linha da trilha inválida: {e}")))?; + (e.seq + 1, e.hash) + } + }; + let (time, user, host) = (now_iso(), current_user(), host_name()); + let examiner = env("GUARA_EXAMINER"); + let version = env!("CARGO_PKG_VERSION"); + let body = serde_json::to_string(&Body { + seq, + time: &time, + user: &user, + host: &host, + examiner: examiner.as_deref(), + version, + action, + detail: &detail, + prev: &prev, + })?; + let hash = sha_hex(&prev, &body); + let line = format!("{},\"hash\":\"{hash}\"}}\n", &body[..body.len() - 1]); + if let Some(dir) = self.path.parent() { + crate::owner::create_dir_all_owned(dir)?; + } + let existed = self.path.exists(); + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&self.path)?; + f.write_all(line.as_bytes())?; + f.flush()?; + if !existed { + crate::owner::give_to_user(&self.path); + } + Ok(Entry { + seq, + time, + user, + host, + examiner, + version: version.into(), + action: action.into(), + detail, + prev, + hash, + }) + } + + /// Todas as entradas, na ordem. Linha ilegível vira erro (use [`Log::verify`] para saber onde). + pub fn entries(&self) -> Result> { + let text = match std::fs::read_to_string(&self.path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(e.into()), + }; + text.lines() + .filter(|l| !l.trim().is_empty()) + .enumerate() + .map(|(i, l)| { + serde_json::from_str(l) + .map_err(|e| Error::msg(format!("linha {} da trilha inválida: {e}", i + 1))) + }) + .collect() + } + + /// Confere a numeração, o encadeamento e o hash de cada linha. + pub fn verify(&self) -> Result { + let text = match std::fs::read_to_string(&self.path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(e) => return Err(e.into()), + }; + let mut prev = GENESIS.to_string(); + let mut n = 0usize; + let broken = |n: usize, why: &str, entries: usize, head: &str| Verification { + entries, + ok: false, + broken_at: Some((n as u64, why.to_string())), + head: head.to_string(), + }; + for line in text.lines().filter(|l| !l.trim().is_empty()) { + n += 1; + let Ok(e) = serde_json::from_str::(line) else { + return Ok(broken(n, "linha ilegível", n - 1, &prev)); + }; + if e.seq != n as u64 { + return Ok(broken( + n, + "numeração fora de ordem (entrada removida ou inserida)", + n - 1, + &prev, + )); + } + if e.prev != prev { + return Ok(broken(n, "não continua a entrada anterior", n - 1, &prev)); + } + let Some(i) = line.rfind(",\"hash\":\"") else { + return Ok(broken(n, "linha sem hash", n - 1, &prev)); + }; + let body = format!("{}}}", &line[..i]); + if sha_hex(&prev, &body) != e.hash { + return Ok(broken( + n, + "conteúdo alterado (o hash não confere)", + n - 1, + &prev, + )); + } + prev = e.hash; + } + Ok(Verification { + entries: n, + ok: true, + broken_at: None, + head: prev, + }) + } +} + +/// Registra uma ação na trilha padrão. Falha ao gravar não interrompe a operação, mas é +/// avisada no stderr: uma ação sem registro precisa ser notada. +pub fn record(action: &str, detail: Value) { + if let Err(e) = Log::open_default().append(action, detail) { + eprintln!("aviso: não foi possível gravar a trilha de auditoria ({action}): {e}"); + } +} + +/// Atalho para detalhes de um caminho de origem. +pub fn path_detail(path: &str) -> Value { + json!({ "path": path }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn iso_de_datas_conhecidas() { + assert_eq!(utc_iso(0), "1970-01-01T00:00:00Z"); + assert_eq!(utc_iso(951_782_400), "2000-02-29T00:00:00Z"); + assert_eq!(utc_iso(1_792_000_000 + 3661), "2026-10-14T18:47:41Z"); + } +} diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 3068f53..552746e 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod aff4; pub mod assemble; +pub mod audit; pub mod carve; pub mod crypt; pub mod defrag; diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index fef7f5f..08643de 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -247,6 +247,13 @@ impl Engine { } s.warnings.extend(asm.notes.iter().cloned()); } + crate::audit::record( + "scan.start", + json!({ + "job": job.id, "source": path, "size": asm.source.size(), + "fingerprint": fp, "encrypted_volumes_opened": asm.unlocked.len(), + }), + ); *job.fingerprint.lock().unwrap() = fp; *job.session_file.lock().unwrap() = Some(new_session_path(path)); spawn_scan(job.clone(), None); @@ -487,6 +494,13 @@ impl Engine { } } recover::check_destination(Some(Path::new(&sj.target)), &dest_path)?; + crate::audit::record( + "recover.start", + json!({ + "scan_job": sj.id, "source": sj.target, "destination": dest, + "files": files.len(), "bytes": total, + }), + ); let job = self.register( JobKind::Recover, dest.to_string(), @@ -525,6 +539,15 @@ impl Engine { let mut s = j2.st.lock().unwrap(); match res { Ok(rep) => { + crate::audit::record( + "recover.finish", + json!({ + "scan_job": parent.id, "destination": rep.destination, + "recovered": rep.recovered.len(), "failed": rep.failed.len(), + "skipped": rep.skipped, "bytes": rep.total_bytes, + "cancelled": rep.cancelled, + }), + ); s.report = serde_json::to_value(&rep).ok(); parent.st.lock().unwrap().recoveries.push(rep.clone()); if !rep.cancelled @@ -571,6 +594,10 @@ impl Engine { Some(Path::new(path)), Path::new(dest).parent().unwrap_or(Path::new(".")), )?; + crate::audit::record( + "image.start", + json!({ "source": path, "destination": dest, "format": format!("{format:?}").split([' ', '{']).next().unwrap_or("").to_lowercase() }), + ); let job = self.register( JobKind::Image, dest.to_string(), @@ -592,6 +619,14 @@ impl Engine { let mut s = j2.st.lock().unwrap(); match res { Ok(rep) => { + crate::audit::record( + "image.finish", + json!({ + "destination": dest, "bytes": rep.bytes, "md5": rep.md5, + "sha1": rep.sha1, "sha256": rep.sha256, + "unreadable_ranges": rep.bad_ranges.len(), "cancelled": rep.cancelled, + }), + ); s.status = if rep.cancelled { Status::Stopped } else { @@ -620,6 +655,10 @@ impl Engine { None => s.found.iter().collect(), }; crate::owner::write_owned(Path::new(path), crate::report::csv(&items))?; + crate::audit::record( + "export.csv", + json!({ "job": id, "source": j.target, "path": path, "rows": items.len() }), + ); Ok(items.len()) } @@ -649,6 +688,10 @@ impl Engine { source_hashes: hashes.as_ref(), }); crate::owner::write_owned(Path::new(path), html)?; + crate::audit::record( + "export.report", + json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }), + ); Ok(()) } @@ -659,6 +702,10 @@ impl Engine { return Err(Error::msg("informe pelo menos um termo")); } let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; + crate::audit::record( + "search.start", + json!({ "source": path, "terms": opts.terms }), + ); let job = self.register( JobKind::Search, path.to_string(), @@ -751,7 +798,15 @@ impl Engine { } else if pos < size { s.status = Status::Stopped; } else { - s.report = serde_json::to_value(h.finalize()).ok(); + let hashes = h.finalize(); + crate::audit::record( + "hash.finish", + json!({ + "source": j2.target, "bytes": size, "md5": hashes.md5, + "sha1": hashes.sha1, "sha256": hashes.sha256, + }), + ); + s.report = serde_json::to_value(hashes).ok(); s.status = Status::Done; } s.finished_in = Some(s.started.elapsed().as_secs_f64()); @@ -763,7 +818,12 @@ impl Engine { pub fn save_session(&self, id: &str, path: &str) -> Result<()> { let j = self.job(id)?; - write_session(&j, Path::new(path)) + write_session(&j, Path::new(path))?; + crate::audit::record( + "session.save", + json!({ "job": id, "source": j.target, "path": path }), + ); + Ok(()) } pub fn load_session(&self, path: &str) -> Result { @@ -834,6 +894,10 @@ impl Engine { } // continua salvando automaticamente no mesmo arquivo se a varredura for retomada *job.session_file.lock().unwrap() = Some(PathBuf::from(path)); + crate::audit::record( + "session.load", + json!({ "job": job.id, "source": data.target, "path": path }), + ); if data.done { spawn_enrich(job.clone()); } @@ -1021,6 +1085,10 @@ impl Engine { .transpose()? .unwrap_or_default(); let raw = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?; + crate::audit::record( + "inspect", + json!({ "source": path, "size": raw.size(), "fingerprint": crate::source::fingerprint(raw.as_ref()) }), + ); let a = crate::assemble::assemble_source(raw, &asm)?; let mut layout = partition::analyze(a.source.as_ref(), false); if a.skip_primary { @@ -1319,6 +1387,13 @@ fn spawn_scan(job: Arc, cp: Option) { } s.finished_in = Some(s.started.elapsed().as_secs_f64()); let done = s.status == Status::Done; + crate::audit::record( + "scan.finish", + json!({ + "job": job.id, "source": job.target, + "status": format!("{:?}", s.status).to_lowercase(), "found": s.found.len(), + }), + ); drop(s); if done { spawn_enrich(job); diff --git a/crates/guara-core/tests/audit.rs b/crates/guara-core/tests/audit.rs new file mode 100644 index 0000000..d900490 --- /dev/null +++ b/crates/guara-core/tests/audit.rs @@ -0,0 +1,142 @@ +//! Trilha de auditoria: encadeamento por hash, detecção de adulteração e registro das ações do motor. + +mod common; +use common::*; +use guara_core::audit::Log; +use guara_core::{service, ScanOptions}; +use serde_json::json; + +fn log_with(n: usize) -> (tempfile::TempDir, Log) { + let dir = tempfile::tempdir().unwrap(); + let log = Log::at(dir.path().join("sub/auditoria.jsonl")); + for i in 0..n { + log.append("teste", json!({ "i": i, "caminho": "/dev/sdb" })) + .unwrap(); + } + (dir, log) +} + +#[test] +fn entries_are_numbered_and_chained() { + let (_d, log) = log_with(4); + let e = log.entries().unwrap(); + assert_eq!(e.iter().map(|x| x.seq).collect::>(), [1, 2, 3, 4]); + assert_eq!(e[0].prev, "0".repeat(64)); + for w in e.windows(2) { + assert_eq!(w[1].prev, w[0].hash); + } + assert!(e[0].time.ends_with('Z') && e[0].time.len() == 20); + let v = log.verify().unwrap(); + assert!(v.ok && v.entries == 4 && v.head == e[3].hash); + // continua de onde parou, inclusive numa instância nova + let again = Log::at(log.path()); + assert_eq!(again.append("mais", json!({})).unwrap().seq, 5); + assert!(again.verify().unwrap().ok); +} + +#[test] +fn empty_or_missing_log_is_valid() { + let dir = tempfile::tempdir().unwrap(); + let log = Log::at(dir.path().join("nada.jsonl")); + let v = log.verify().unwrap(); + assert!(v.ok && v.entries == 0); + assert!(log.entries().unwrap().is_empty()); +} + +fn tamper(log: &Log, f: impl FnOnce(&mut Vec)) { + let text = std::fs::read_to_string(log.path()).unwrap(); + let mut lines: Vec = text.lines().map(String::from).collect(); + f(&mut lines); + std::fs::write(log.path(), lines.join("\n") + "\n").unwrap(); +} + +#[test] +fn editing_a_line_breaks_the_chain_at_that_line() { + let (_d, log) = log_with(5); + tamper(&log, |l| l[2] = l[2].replace("/dev/sdb", "/dev/sdc")); + let v = log.verify().unwrap(); + assert!(!v.ok); + let (n, why) = v.broken_at.unwrap(); + assert_eq!(n, 3, "{why}"); + assert!(why.contains("hash"), "{why}"); +} + +#[test] +fn removing_or_reordering_lines_is_detected() { + let (_d, log) = log_with(5); + tamper(&log, |l| { + l.remove(1); + }); + let (n, why) = log.verify().unwrap().broken_at.unwrap(); + assert_eq!(n, 2, "{why}"); + + let (_d, log) = log_with(5); + tamper(&log, |l| l.swap(1, 2)); + assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 2); + + // trocar o hash por outro também não passa: o seguinte deixa de continuar + let (_d, log) = log_with(3); + tamper(&log, |l| { + let h = l[0].rsplit('"').nth(1).unwrap().to_string(); + l[0] = l[0].replace(&h, &"a".repeat(64)); + }); + assert_eq!(log.verify().unwrap().broken_at.unwrap().0, 1); +} + +#[test] +fn garbage_line_is_reported_not_skipped() { + let (_d, log) = log_with(3); + tamper(&log, |l| l[1] = "isto não é json".into()); + let (n, why) = log.verify().unwrap().broken_at.unwrap(); + assert_eq!((n, why.as_str()), (2, "linha ilegível")); +} + +#[test] +fn engine_actions_are_recorded_without_secrets() { + init(); + let dir = tempfile::tempdir().unwrap(); + let img = dir.path().join("disco.img"); + std::fs::write(&img, vec![0u8; 2 << 20]).unwrap(); + let eng = service::Engine::new(); + let job = eng + .start_scan(img.to_str().unwrap(), ScanOptions::default()) + .unwrap(); + wait(&eng, &job); + let hash = eng.start_hash(img.to_str().unwrap()).unwrap(); + wait(&eng, &hash); + let csv = dir.path().join("achados.csv"); + eng.export_csv(&job, csv.to_str().unwrap(), None).unwrap(); + eng.save_session(&job, dir.path().join("s.guara").to_str().unwrap()) + .unwrap(); + + let log = Log::open_default(); + let e = log.entries().unwrap(); + let actions: Vec<&str> = e.iter().map(|x| x.action.as_str()).collect(); + for want in [ + "scan.start", + "scan.finish", + "hash.finish", + "export.csv", + "session.save", + ] { + assert!(actions.contains(&want), "{want} ausente em {actions:?}"); + } + let start = e.iter().find(|x| x.action == "scan.start").unwrap(); + assert_eq!(start.detail["source"], img.to_str().unwrap()); + assert_eq!(start.detail["size"], 2 << 20); + let h = e.iter().find(|x| x.action == "hash.finish").unwrap(); + assert_eq!(h.detail["sha256"].as_str().unwrap().len(), 64); + assert!(log.verify().unwrap().ok); + let all = std::fs::read_to_string(log.path()).unwrap(); + assert!(!all.contains("password") && !all.contains("senha")); +} + +fn wait(eng: &service::Engine, job: &str) { + for _ in 0..1000 { + if eng.status(job).unwrap()["status"] != "running" { + return; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + panic!("job não terminou"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 01a0c7f..83a77dc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,7 @@ recover copies with MD5, SHA-1 and SHA-256 and writes the | `source`, `device` | Open devices and images read-only; list disks | | `image` | Raw image tolerant of bad sectors, with an error map | | `ewf`, `aff4` | Read and write E01/Ex01 and AFF4 | +| `audit` | Hash-chained audit trail of every examiner action | | `vdisk/` | Read QCOW2, VMDK, VHD, VHDX and DMG as the disk they contain | | `assemble` | Builds the virtual disk: image, RAID, LVM and encrypted volume | | `raid`, `raid_detect`, `lvm` | RAID reassembly (with automatic parameter discovery) and LVM2 | diff --git a/docs/cli.md b/docs/cli.md index 8646657..308fd79 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,6 +20,7 @@ Reading a raw disk requires privileges: administrator on Windows, `sudo` on Linu | `search ` | Text across the whole disk, UTF-8 and UTF-16 | | `export ` | Spreadsheet of the findings | | `report ` | Printable HTML report | +| `audit` | Audit trail of every action (who, when, what); `--verify` checks the hash chain | | `sessions` | Automatically saved sessions | | `ui` | Interface in the browser (127.0.0.1, with token) | @@ -99,6 +100,18 @@ guara verify /mnt/other-disk/sdb.E01 For E01: `--segment-mib` (size of each segment, default 1500, 0 for a single file) and `--compression` (0 to 9, default 1). +## Audit trail + +Every action that touches evidence is appended to `auditoria.jsonl` in the configuration folder (or to the file named by `GUARA_AUDIT_LOG`): opening a source (`inspect`, `scan`), `recover`, `image`, `hash`, `search`, `verify`, exports and sessions. Each line has the time (UTC), the system user (the one behind `sudo`), the host, the Guará version and the details; set `GUARA_EXAMINER` to also record the examiner's name. Passwords and keys are never written. + +```bash +guara audit # lists the entries +guara audit --tail 20 --json +guara audit --verify # checks the chain; exits with an error if it is broken +``` + +The lines are chained: each one carries the SHA-256 of the previous hash plus its own content, so editing, removing or reordering an entry is detected and `--verify` says which one. Cutting the end of the file cannot be detected by the file alone: keep the hash printed by `--verify` somewhere else (for example in the case notes). + ## Video without an index (MP4 without moov) Pass an **intact video from the same camera or drone** so Guará can rebuild the index: diff --git a/docs/pt-BR/arquitetura.md b/docs/pt-BR/arquitetura.md index dba10cd..5803051 100644 --- a/docs/pt-BR/arquitetura.md +++ b/docs/pt-BR/arquitetura.md @@ -43,6 +43,7 @@ recover copia com MD5, SHA-1 e SHA-256 e grava o manifesto | `source`, `device` | Abrir dispositivos e imagens somente para leitura; listar discos | | `image` | Imagem bruta tolerante a setores ruins, com mapa de erros | | `ewf`, `aff4` | Ler e gravar E01/Ex01 e AFF4 | +| `audit` | Trilha de auditoria encadeada por hash de cada ação do perito | | `vdisk/` | Ler QCOW2, VMDK, VHD, VHDX e DMG como o disco que contêm | | `assemble` | Monta o disco virtual: imagem, RAID, LVM e volume cifrado | | `raid`, `raid_detect`, `lvm` | Remontagem de RAID (com descoberta automática de parâmetros) e LVM2 | diff --git a/docs/pt-BR/cli.md b/docs/pt-BR/cli.md index 7271297..fd1fe5c 100644 --- a/docs/pt-BR/cli.md +++ b/docs/pt-BR/cli.md @@ -20,6 +20,7 @@ Ler disco bruto exige privilégios: administrador no Windows, `sudo` no Linux e | `search ` | Texto no disco inteiro, UTF-8 e UTF-16 | | `export ` | Planilha dos achados | | `report ` | Relatório HTML imprimível | +| `audit` | Trilha de auditoria de cada ação (quem, quando, o quê); `--verify` confere o encadeamento por hash | | `sessions` | Sessões salvas automaticamente | | `ui` | Interface no navegador (127.0.0.1, com token) | @@ -99,6 +100,18 @@ guara verify /mnt/outro-disco/sdb.E01 Para E01: `--segment-mib` (tamanho de cada segmento, padrão 1500, 0 para um arquivo só) e `--compression` (0 a 9, padrão 1). +## Trilha de auditoria + +Toda ação que mexe na evidência é acrescentada a `auditoria.jsonl` na pasta de configuração (ou ao arquivo indicado em `GUARA_AUDIT_LOG`): abrir uma origem (`inspect`, `scan`), `recover`, `image`, `hash`, `search`, `verify`, exportações e sessões. Cada linha traz a hora (UTC), o usuário do sistema (o que está por trás do `sudo`), a máquina, a versão do Guará e os detalhes; defina `GUARA_EXAMINER` para registrar também o nome do perito. Senhas e chaves nunca são gravadas. + +```bash +guara audit # lista as entradas +guara audit --tail 20 --json +guara audit --verify # confere a cadeia; sai com erro se estiver quebrada +``` + +As linhas são encadeadas: cada uma leva o SHA-256 do hash anterior mais o próprio conteúdo, então editar, remover ou reordenar uma entrada é detectado e o `--verify` diz qual. Cortar o fim do arquivo não é detectável só pelo arquivo: guarde o hash que o `--verify` imprime em outro lugar (por exemplo nas notas do caso). + ## Vídeo sem índice (MP4 sem moov) Passe um vídeo **íntegro da mesma câmera ou drone** para o Guará refazer o índice: