CLI: senhas, chaves, membros de RAID, referências de vídeo, imagem E01 e verify

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-19 00:08:47 -03:00
1 parent df194edb2f
commit b02bbcb7d6
6 files changed
+300 -14

No files matched your search

+1
View File
@@ -18,3 +18,4 @@ serde_json = "1"
tiny_http = "0.12"
getrandom = "0.2"
ctrlc = "3"
rpassword = "7"
+180 -5
View File
@@ -31,6 +31,8 @@ enum Cmd {
path: String,
#[arg(long)]
json: bool,
#[command(flatten)]
asm: AsmArgs,
},
/// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos
Health {
@@ -56,7 +58,36 @@ enum Cmd {
filter: FilterArgs,
},
/// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia
Image { path: String, dest: String },
Image {
path: String,
dest: String,
/// Formato da imagem: raw (padrão) ou e01 (Expert Witness, com hash e metadados)
#[arg(long, default_value = "raw")]
format: String,
/// E01: número do caso
#[arg(long, default_value = "")]
case: String,
/// E01: examinador
#[arg(long, default_value = "")]
examiner: String,
/// E01: descrição da evidência
#[arg(long, default_value = "")]
description: String,
/// E01: número da evidência
#[arg(long, default_value = "")]
evidence: String,
/// E01: observações
#[arg(long, default_value = "")]
notes: String,
/// E01: tamanho máximo de cada segmento em MiB (0 = um arquivo só)
#[arg(long, default_value_t = 1500)]
segment_mib: u64,
/// E01: compressão 0 (nenhuma) a 9
#[arg(long, default_value_t = 1)]
compression: u32,
},
/// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem
Verify { path: String },
/// Exporta os achados de uma sessão para CSV (abre no Excel)
Export {
session: String,
@@ -140,6 +171,111 @@ struct OutArgs {
json: bool,
}
/// Montagem do disco: volumes cifrados, RAID e LVM.
#[derive(clap::Args, Clone, Default)]
struct AsmArgs {
/// Senha de volume cifrado (BitLocker, LUKS, FileVault 2). Prefira --ask-password ou GUARA_PASSWORD
#[arg(long = "password")]
passwords: Vec<String>,
/// Pergunta a senha no terminal, sem mostrar o que é digitado
#[arg(long)]
ask_password: bool,
/// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos)
#[arg(long = "recovery-key")]
recovery_keys: Vec<String>,
/// Arquivo de chave (.BEK do BitLocker ou keyfile do LUKS)
#[arg(long = "key-file")]
key_files: Vec<String>,
/// Chave do volume em hexadecimal (a que o Guará mostra ao abrir um volume)
#[arg(long = "volume-key")]
volume_keys: Vec<String>,
/// Outro disco ou imagem do mesmo conjunto (RAID/LVM). Pode repetir; "-" marca um disco ausente
#[arg(long = "member")]
members: Vec<String>,
/// Os discos (alvo + --member, nesta ordem) formam um RAID sem metadados: descobre sozinho nível, faixa e layout
#[arg(long)]
raid: bool,
/// Fixa o nível do RAID: 0, 5, 6, 1
#[arg(long)]
raid_level: Option<String>,
/// Fixa o tamanho da faixa do RAID em KiB
#[arg(long)]
raid_chunk_kb: Option<u64>,
/// Fixa o layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric
#[arg(long)]
raid_layout: Option<String>,
/// Ordem dos discos no RAID (índices; 0 = alvo)
#[arg(long, value_delimiter = ',')]
raid_order: Vec<usize>,
/// Guarda na sessão as chaves dos volumes abertos (a senha nunca é guardada)
#[arg(long)]
remember_keys: bool,
/// Vídeo íntegro da mesma câmera/drone para refazer o índice de MP4 sem moov (pode repetir)
#[arg(long = "reference")]
references: Vec<String>,
}
impl AsmArgs {
fn assembly(&self) -> Result<guara_core::assemble::Assembly, String> {
use guara_core::assemble::{Assembly, RaidSpec, SecretInput};
let mut secrets = vec![];
for p in &self.passwords {
secrets.push(SecretInput::Password { value: p.clone() });
}
if let Ok(p) = std::env::var("GUARA_PASSWORD") {
if !p.is_empty() {
secrets.push(SecretInput::Password { value: p });
}
}
if self.ask_password {
let p = rpassword::prompt_password("Senha do volume: ").map_err(s)?;
secrets.push(SecretInput::Password { value: p });
}
for r in &self.recovery_keys {
secrets.push(SecretInput::Recovery { value: r.clone() });
}
for k in &self.key_files {
secrets.push(SecretInput::Keyfile { path: k.clone() });
}
for k in &self.volume_keys {
secrets.push(SecretInput::Key { hex: k.clone() });
}
let raid = if self.raid || self.raid_level.is_some() || self.raid_chunk_kb.is_some() || self.raid_layout.is_some() || !self.raid_order.is_empty() {
let level = match self.raid_level.as_deref() {
None => None,
Some("0") => Some(guara_core::raid::Level::R0),
Some("1") => Some(guara_core::raid::Level::R1),
Some("5") => Some(guara_core::raid::Level::R5),
Some("6") => Some(guara_core::raid::Level::R6),
Some(x) => return Err(format!("nível de RAID não suportado: {x} (use 0, 1, 5 ou 6)")),
};
let layout = match self.raid_layout.as_deref() {
None => None,
Some("left-symmetric") => Some(guara_core::raid::Layout::LeftSymmetric),
Some("left-asymmetric") => Some(guara_core::raid::Layout::LeftAsymmetric),
Some("right-symmetric") => Some(guara_core::raid::Layout::RightSymmetric),
Some("right-asymmetric") => Some(guara_core::raid::Layout::RightAsymmetric),
Some(x) => return Err(format!("layout desconhecido: {x}")),
};
Some(RaidSpec {
level,
chunk_kb: self.raid_chunk_kb,
layout,
order: (!self.raid_order.is_empty()).then(|| self.raid_order.clone()),
})
} else {
None
};
Ok(Assembly {
raid,
members: self.members.iter().filter(|m| *m != "-").cloned().collect(),
secrets,
remember_keys: self.remember_keys,
keys: vec![],
})
}
}
#[derive(clap::Args)]
struct ScanArgs {
/// Dispositivo ou imagem
@@ -154,6 +290,8 @@ struct ScanArgs {
#[arg(long)]
recover: Option<String>,
#[command(flatten)]
asm: AsmArgs,
#[command(flatten)]
filter: FilterArgs,
#[command(flatten)]
out: OutArgs,
@@ -204,8 +342,9 @@ fn run(cli: Cli) -> Result<(), String> {
}
Ok(())
}
Cmd::Inspect { path, json } => {
let v = eng.rpc("inspect", json!({ "path": path })).map_err(s)?;
Cmd::Inspect { path, json, asm } => {
let a = asm.assembly()?;
let v = eng.rpc("inspect", json!({ "path": path, "assembly": serde_json::to_value(&a).map_err(s)?})).map_err(s)?;
if json {
println!("{}", serde_json::to_string_pretty(&v).unwrap());
} else {
@@ -231,6 +370,8 @@ fn run(cli: Cli) -> Result<(), String> {
},
include_allocated: a.include_allocated,
categories: parse_cats(&a.filter.category)?,
assembly: a.asm.assembly()?,
references: a.asm.references.clone(),
..Default::default()
};
let job = eng.start_scan(&a.path, opts).map_err(s)?;
@@ -272,8 +413,41 @@ fn run(cli: Cli) -> Result<(), String> {
eng.save_session(&job, &session).map_err(s)?;
Ok(())
}
Cmd::Image { path, dest } => {
let job = eng.start_image(&path, &dest).map_err(s)?;
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)" });
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(())
}
}
}
Cmd::Image { path, dest, format, case, examiner, description, evidence, notes, segment_mib, compression } => {
let fmt = match format.as_str() {
"raw" => guara_core::image::ImageFormat::Raw,
"e01" => guara_core::image::ImageFormat::E01 {
case_number: case,
description,
evidence_number: evidence,
examiner,
notes,
segment_mib,
compression,
},
x => return Err(format!("formato de imagem desconhecido: {x} (use raw ou e01)")),
};
let job = eng.start_image_as(&path, &dest, &fmt).map_err(s)?;
let st = wait(&eng, &job, None)?;
if st["status"] == "error" {
return Err(st["error"].as_str().unwrap_or("falha").to_string());
@@ -813,3 +987,4 @@ fn print_health(v: &Value) {
println!("\n{}", adv.as_str().unwrap_or(""));
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
#[derive(Clone, Debug, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum SecretInput {
Password { value: String },
@@ -50,8 +50,8 @@ pub struct Assembly {
/// Outros discos ou imagens do mesmo conjunto (RAID/LVM), além do alvo principal.
#[serde(default)]
pub members: Vec<String>,
/// Senhas e chaves: nunca vão para o disco junto com a sessão.
#[serde(default, skip_serializing)]
/// Senhas e chaves: o `snapshot` da sessão as descarta antes de gravar.
#[serde(default)]
pub secrets: Vec<SecretInput>,
/// Guarda as chaves de volume derivadas na sessão, para reabrir sem pedir a senha de novo.
#[serde(default)]
+63
View File
@@ -24,6 +24,69 @@ pub struct ImageReport {
const BLOCK: usize = 1 << 20;
const SECTOR: usize = 512;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum ImageFormat {
#[default]
Raw,
/// Expert Witness (EnCase 6): `.E01`, dividido em segmentos.
E01 {
#[serde(default)]
case_number: String,
#[serde(default)]
description: String,
#[serde(default)]
evidence_number: String,
#[serde(default)]
examiner: String,
#[serde(default)]
notes: String,
/// Tamanho máximo de cada segmento em MiB (0 = um arquivo só).
#[serde(default = "default_segment_mib")]
segment_mib: u64,
/// 0 = sem compressão, 1 a 9 = deflate.
#[serde(default = "default_compression")]
compression: u32,
},
}
fn default_segment_mib() -> u64 {
1500
}
fn default_compression() -> u32 {
1
}
pub fn create_image_as(
src: &dyn Source,
dest: &Path,
format: &ImageFormat,
cancelled: &(dyn Fn() -> bool + Sync),
progress: &mut dyn FnMut(u64, u64),
) -> Result<ImageReport> {
match format {
ImageFormat::Raw => create_image(src, dest, cancelled, progress),
ImageFormat::E01 { case_number, description, evidence_number, examiner, notes, segment_mib, compression } => {
let opts = crate::ewf::E01Options {
case_number: case_number.clone(),
description: description.clone(),
evidence_number: evidence_number.clone(),
examiner: examiner.clone(),
notes: notes.clone(),
segment_size: segment_mib * 1024 * 1024,
compression: *compression,
..Default::default()
};
// O destino pode vir com ou sem a extensão .E01.
let base = match dest.extension().and_then(|e| e.to_str()) {
Some(e) if e.eq_ignore_ascii_case("e01") => dest.with_extension(""),
_ => dest.to_path_buf(),
};
crate::ewf::write_e01(src, &base, &opts, cancelled, progress)
}
}
}
pub fn create_image(
src: &dyn Source,
dest: &Path,
+1 -1
View File
@@ -248,7 +248,7 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout {
for p in &layout.partitions {
if let Some(e) = &p.encrypted {
layout.warnings.push(format!(
"Partição {} está cifrada com {}: o Guará só detecta cifragem na v1 e não varre esse volume sem a chave.",
"Partição {} está cifrada com {}: informe a senha, a chave de recuperação ou o arquivo de chave para abrir e varrer o conteúdo.",
p.index + 1,
e
));
+52 -5
View File
@@ -548,6 +548,12 @@ impl Engine {
// ---------------------------------------------------------------- imagem
pub fn start_image(&self, path: &str, dest: &str) -> Result<String> {
self.start_image_as(path, dest, &image::ImageFormat::Raw)
}
/// Cria a imagem em bruto ou em outro formato forense (E01 ou AFF4).
pub fn start_image_as(&self, path: &str, dest: &str, format: &image::ImageFormat) -> Result<String> {
let format = format.clone();
let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?;
recover::check_destination(
Some(Path::new(path)),
@@ -570,7 +576,7 @@ impl Engine {
total: t,
};
};
let res = image::create_image(src.as_ref(), &dest, &cancelled, &mut prog);
let res = image::create_image_as(src.as_ref(), &dest, &format, &cancelled, &mut prog);
let mut s = j2.st.lock().unwrap();
match res {
Ok(rep) => {
@@ -990,8 +996,35 @@ impl Engine {
"devices" => json!(device::list_devices()),
"inspect" => {
let path = s("path")?;
let src = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?;
json!(partition::analyze(src.as_ref(), false))
let asm: crate::assemble::Assembly = p
.get("assembly")
.cloned()
.map(serde_json::from_value)
.transpose()?
.unwrap_or_default();
let raw = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?;
let a = crate::assemble::assemble_source(raw, &asm)?;
let mut layout = partition::analyze(a.source.as_ref(), false);
if a.skip_primary {
layout.partitions.clear();
layout.gaps.clear();
layout.table = "membro de RAID".into();
}
for pi in &a.extra_parts {
if !layout.partitions.iter().any(|q| q.start == pi.start) {
let mut pi = pi.clone();
pi.index = layout.partitions.len();
layout.partitions.push(pi);
}
}
layout.warnings.extend(a.notes.iter().cloned());
for u in &a.unlocked {
layout.warnings.push(format!("Volume aberto em {}: {}.", u.start, u.description));
}
for l in &a.locked {
layout.warnings.push(format!("Volume cifrado em {} continua fechado: {}.", l.start, l.reason));
}
json!(layout)
}
"scan_start" => {
let opts: ScanOptions = p
@@ -1124,7 +1157,15 @@ impl Engine {
.unwrap_or_default();
json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?, layout)? })
}
"image_start" => json!({ "job": self.start_image(&s("path")?, &s("dest")?)? }),
"image_start" => {
let format: image::ImageFormat = p
.get("format")
.cloned()
.map(serde_json::from_value)
.transpose()?
.unwrap_or(image::ImageFormat::Raw);
json!({ "job": self.start_image_as(&s("path")?, &s("dest")?, &format)? })
}
"session_save" => {
self.save_session(&s("job")?, &s("path")?)?;
json!({})
@@ -1546,10 +1587,16 @@ fn new_session_path(target: &str) -> PathBuf {
fn snapshot(j: &Job) -> SessionFile {
let s = j.st.lock().unwrap();
// Senhas e chaves nunca vão para o arquivo de sessão (só as chaves de volume, se o usuário pediu).
let mut options = j.opts.clone();
options.assembly.secrets.clear();
if !options.assembly.remember_keys {
options.assembly.keys.clear();
}
SessionFile {
version: 1,
target: j.target.clone(),
options: j.opts.clone(),
options,
found: s.found.clone(),
checkpoint: s.checkpoint.clone(),
layout: s.layout.clone(),