Trilha de auditoria encadeada por hash e comando guara audit

Cada ação sobre a evidência (inspect, scan, recover, image, hash, search, verify,
exportações e sessões) vira uma linha JSON com usuário, máquina, hora UTC e detalhes,
encadeada por SHA-256. guara audit lista e --verify aponta a entrada onde a cadeia
quebra. Senhas e chaves nunca entram no registro.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 16:04:43 -03:00
1 parent 213a5a3df5
commit 26e5ebb592
9 files changed
+706 -45

No files matched your search

+134 -43
View File
@@ -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<String>,
/// 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<usize>,
#[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<Option<bool>, 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<String>, verify: bool, tail: Option<usize>, 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<Value, String> {
let interrupted = Arc::new(AtomicBool::new(false));
let flag = interrupted.clone();
+324
View File
@@ -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<String>,
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<String> {
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<Option<String>> {
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<PathBuf>) -> 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<Entry> {
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<Vec<Entry>> {
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<Verification> {
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::<Entry>(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");
}
}
+1
View File
@@ -12,6 +12,7 @@
pub mod aff4;
pub mod assemble;
pub mod audit;
pub mod carve;
pub mod crypt;
pub mod defrag;
+77 -2
View File
@@ -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<String> {
@@ -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<Job>, cp: Option<Checkpoint>) {
}
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);
+142
View File
@@ -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::<Vec<_>>(), [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<String>)) {
let text = std::fs::read_to_string(log.path()).unwrap();
let mut lines: Vec<String> = 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");
}
+1
View File
@@ -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 |
+13
View File
@@ -20,6 +20,7 @@ Reading a raw disk requires privileges: administrator on Windows, `sudo` on Linu
| `search <source> <terms...>` | Text across the whole disk, UTF-8 and UTF-16 |
| `export <session> <csv>` | Spreadsheet of the findings |
| `report <session> <html>` | 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:
+1
View File
@@ -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 |
+13
View File
@@ -20,6 +20,7 @@ Ler disco bruto exige privilégios: administrador no Windows, `sudo` no Linux e
| `search <origem> <termos...>` | Texto no disco inteiro, UTF-8 e UTF-16 |
| `export <sessão> <csv>` | Planilha dos achados |
| `report <sessão> <html>` | 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: