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:
1 parent
213a5a3df5
commit
26e5ebb592
9 files changed
+706
-45
No files matched your search
+134
-43
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
pub mod aff4;
|
||||
pub mod assemble;
|
||||
pub mod audit;
|
||||
pub mod carve;
|
||||
pub mod crypt;
|
||||
pub mod defrag;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user