From 938ead6725684e53c041d5785f787173c7615cab Mon Sep 17 00:00:00 2001 From: Parv Date: Wed, 17 Jun 2026 13:07:09 +0530 Subject: [PATCH] Fix CI: deny.toml format, macOS sandbox FFI, seccomp Result type; fmt clean --- benches/bench.rs | 2 +- deny.toml | 2 +- src/analyzers/file.rs | 55 ++++++------ src/analyzers/mod.rs | 2 +- src/analyzers/recursive.rs | 13 ++- src/bin/worker.rs | 19 +++-- src/cache.rs | 11 ++- src/cli.rs | 142 ++++++++++++++++++++++--------- src/core/calibration.rs | 21 +++-- src/core/confidence.rs | 59 +++++++------ src/core/encoding.rs | 100 +++++++++++++++++----- src/core/entropy.rs | 18 ++-- src/core/hashing.rs | 39 ++++++--- src/core/mod.rs | 12 +-- src/core/sliding_entropy.rs | 7 +- src/format/mod.rs | 40 +++++---- src/intelligence/audit.rs | 4 +- src/intelligence/mod.rs | 6 +- src/intelligence/narrative.rs | 48 ++++++++--- src/intelligence/prompt.rs | 18 ++-- src/intelligence/risk.rs | 44 +++++++--- src/intelligence/siem.rs | 123 +++++++++++++++++++++----- src/intelligence/threat_intel.rs | 20 +++-- src/lib.rs | 20 ++--- src/providers/community.rs | 67 ++++++++++----- src/providers/mod.rs | 34 ++++---- src/reports/html.rs | 113 ++++++++++++++++++------ src/reports/mod.rs | 4 +- src/reports/terminal.rs | 35 ++++++-- src/sanitization/guard.rs | 11 ++- src/sanitization/sandbox.rs | 85 ++++++++++-------- src/signatures/mod.rs | 15 +++- src/update.rs | 47 +++++++--- src/workers.rs | 7 +- tests/air_gap_test.rs | 33 +++++-- tests/compression_bomb_test.rs | 21 ++--- tests/encoding_accuracy.rs | 12 +-- tests/hash_accuracy.rs | 21 +++-- tests/memory_stability.rs | 15 +--- tests/sandbox_crash_test.rs | 1 - 40 files changed, 920 insertions(+), 426 deletions(-) diff --git a/benches/bench.rs b/benches/bench.rs index 05f90dd..fd7aae7 100644 --- a/benches/bench.rs +++ b/benches/bench.rs @@ -1,4 +1,4 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; use cryptotrace::analyzers::file::analyze_bytes; use cryptotrace::types::SourceType; diff --git a/deny.toml b/deny.toml index a896971..a2f1f97 100644 --- a/deny.toml +++ b/deny.toml @@ -34,5 +34,5 @@ skip-tree = [ [sources] unknown-registry = "deny" unknown-git = "deny" -allow-org = [] +allow-org = { github = [], gitlab = [] } allow-git = [] diff --git a/src/analyzers/file.rs b/src/analyzers/file.rs index cbc7f46..c3a04a1 100644 --- a/src/analyzers/file.rs +++ b/src/analyzers/file.rs @@ -1,7 +1,7 @@ use crate::error::Result; use crate::providers::AiProvider; use crate::sanitization::sandbox::Sandbox; -use crate::signatures::{default_registry, match_signatures, MagicEntry}; +use crate::signatures::{MagicEntry, default_registry, match_signatures}; use crate::types::DetectionResult; /// Analyze a file by reading its contents and running the full detection pipeline. @@ -12,7 +12,10 @@ pub fn analyze_file(path: &std::path::Path) -> Result { } /// Analyze raw bytes through the detection pipeline. -pub fn analyze_bytes(data: &[u8], source_type: crate::types::SourceType) -> Result { +pub fn analyze_bytes( + data: &[u8], + source_type: crate::types::SourceType, +) -> Result { // Entropy analysis let (entropy, _freq) = crate::core::entropy::shannon_entropy(data); let sliding = crate::core::sliding_entropy::sliding_window_entropy(data, None, None, None); @@ -50,7 +53,10 @@ pub fn analyze_bytes(data: &[u8], source_type: crate::types::SourceType) -> Resu ); // Overlay signature registry info (strongest signal) - if let Some(best) = matched_signatures.iter().max_by_key(|e| e.magic_bytes.len()) { + if let Some(best) = matched_signatures + .iter() + .max_by_key(|e| e.magic_bytes.len()) + { if result.algorithm.is_none() && result.detected_type == "plaintext" { result.detected_type = best.category.clone(); result.algorithm = Some(best.id.clone()); @@ -71,22 +77,26 @@ pub fn analyze_bytes(data: &[u8], source_type: crate::types::SourceType) -> Resu /// Run detection through the sandboxed worker subprocess. /// The worker performs the actual analysis; if it crashes, we fall back to /// in-process analysis and log a warning. -pub fn analyze_file_sandboxed(path: &std::path::Path, sandbox: &Sandbox) -> Result { +pub fn analyze_file_sandboxed( + path: &std::path::Path, + sandbox: &Sandbox, +) -> Result { let guard = crate::sanitization::InputGuard::new(); let sanitized = guard.sanitize_file(path)?; // Try sandboxed detection match sandbox.run_worker("detect", &sanitized.raw_bytes) { - Ok(output) => { - serde_json::from_slice(&output).map_err(|e| { - crate::error::CryptoTraceError::Other(format!( - "Failed to parse worker output as DetectionResult: {}", - e - )) - }) - } + Ok(output) => serde_json::from_slice(&output).map_err(|e| { + crate::error::CryptoTraceError::Other(format!( + "Failed to parse worker output as DetectionResult: {}", + e + )) + }), Err(e) => { - tracing::warn!("Sandboxed detection failed, falling back to in-process: {}", e); + tracing::warn!( + "Sandboxed detection failed, falling back to in-process: {}", + e + ); analyze_bytes(&sanitized.raw_bytes, crate::types::SourceType::File) } } @@ -95,14 +105,9 @@ pub fn analyze_file_sandboxed(path: &std::path::Path, sandbox: &Sandbox) -> Resu /// Run byte analysis through the sandboxed worker subprocess. pub fn analyze_bytes_sandboxed(data: &[u8], sandbox: &Sandbox) -> Result { match sandbox.run_worker("detect", data) { - Ok(output) => { - serde_json::from_slice(&output).map_err(|e| { - crate::error::CryptoTraceError::Other(format!( - "Failed to parse worker output: {}", - e - )) - }) - } + Ok(output) => serde_json::from_slice(&output).map_err(|e| { + crate::error::CryptoTraceError::Other(format!("Failed to parse worker output: {}", e)) + }), Err(e) => { tracing::warn!("Sandboxed byte analysis failed, falling back: {}", e); analyze_bytes(data, crate::types::SourceType::Binary) @@ -133,9 +138,11 @@ mod tests { #[test] fn test_analyze_bytes_md5() { - let result = - analyze_bytes(b"5f4dcc3b5aa765d61d8327deb882cf99", crate::types::SourceType::String) - .unwrap(); + let result = analyze_bytes( + b"5f4dcc3b5aa765d61d8327deb882cf99", + crate::types::SourceType::String, + ) + .unwrap(); assert_eq!(result.detected_type, "hash"); assert_eq!(result.algorithm.as_deref(), Some("MD5")); } diff --git a/src/analyzers/mod.rs b/src/analyzers/mod.rs index c02f252..a78ce60 100644 --- a/src/analyzers/mod.rs +++ b/src/analyzers/mod.rs @@ -1,3 +1,3 @@ pub mod file; -pub mod string; pub mod recursive; +pub mod string; diff --git a/src/analyzers/recursive.rs b/src/analyzers/recursive.rs index 9269ff1..1b7b2cc 100644 --- a/src/analyzers/recursive.rs +++ b/src/analyzers/recursive.rs @@ -54,11 +54,16 @@ pub fn analyze_recursive(data: &[u8], config: &RecursiveConfig) -> Result Result run_decompress(&input), "passthrough" => Ok(input), _ => { - emit_error("unknown_operation", &format!("Unknown operation: {}", operation)); + emit_error( + "unknown_operation", + &format!("Unknown operation: {}", operation), + ); std::process::exit(1); } }; @@ -54,11 +57,9 @@ fn main() { /// Run the full detection pipeline and output JSON. fn run_detect(input: &[u8]) -> Result, String> { - let result = cryptotrace::analyzers::file::analyze_bytes( - input, - cryptotrace::types::SourceType::Binary, - ) - .map_err(|e| format!("Detection failed: {}", e))?; + let result = + cryptotrace::analyzers::file::analyze_bytes(input, cryptotrace::types::SourceType::Binary) + .map_err(|e| format!("Detection failed: {}", e))?; serde_json::to_vec(&result).map_err(|e| format!("JSON serialization: {}", e)) } @@ -89,5 +90,9 @@ fn emit_error(code: &str, message: &str) { "error": code, "message": message, }); - let _ = writeln!(io::stderr(), "{}", serde_json::to_string(&err).unwrap_or_default()); + let _ = writeln!( + io::stderr(), + "{}", + serde_json::to_string(&err).unwrap_or_default() + ); } diff --git a/src/cache.rs b/src/cache.rs index 902e925..163f25d 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -42,10 +42,13 @@ impl LruCache { self.entries.remove(&evict_key); } } - self.entries.insert(key, CacheEntry { - value, - last_access: Instant::now(), - }); + self.entries.insert( + key, + CacheEntry { + value, + last_access: Instant::now(), + }, + ); } pub fn contains(&self, key: &str) -> bool { diff --git a/src/cli.rs b/src/cli.rs index d5128a2..a556db5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,6 @@ -use clap::{Parser, Subcommand}; use crate::error::Result; use crate::types::DetectionResult; +use clap::{Parser, Subcommand}; /// Cryptographic Fingerprinting & Data Classification Engine #[derive(Parser)] @@ -142,7 +142,15 @@ pub async fn run() -> Result> { /// Run the CLI command using a pre-parsed Cli struct. pub async fn run_with_cli(cli: &Cli) -> Result> { match &cli.command { - Commands::Analyze { input, context, deep, json, explain, ai, sandbox } => { + Commands::Analyze { + input, + context, + deep, + json, + explain, + ai, + sandbox, + } => { let detection_context = match context.as_str() { "malware" => crate::types::DetectionContext::Malware, "password" => crate::types::DetectionContext::Password, @@ -181,7 +189,10 @@ pub async fn run_with_cli(cli: &Cli) -> Result Result eprintln!("AI narrative: {}", e), } } else { - eprintln!("AI narrative requested but no AI provider configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or configure a local provider."); + eprintln!( + "AI narrative requested but no AI provider configured. Set OPENAI_API_KEY, ANTHROPIC_API_KEY, or configure a local provider." + ); } } Ok(Some((result, *json, *explain))) } - Commands::Update { rollback, from_file, verify } => { - let update_mgr = crate::update::UpdateManager::new( - std::path::Path::new("signatures"), - ); + Commands::Update { + rollback, + from_file, + verify, + } => { + let update_mgr = crate::update::UpdateManager::new(std::path::Path::new("signatures")); if *rollback { update_mgr.rollback()?; - println!("Rolled back to signature DB: {}", update_mgr.current_version()); + println!( + "Rolled back to signature DB: {}", + update_mgr.current_version() + ); } else if let Some(path) = from_file { let import_path = std::path::Path::new(path); let sig_path = verify.as_ref().map(|s| std::path::Path::new(s)); @@ -255,9 +273,7 @@ pub async fn run_with_cli(cli: &Cli) -> Result { - let update_mgr = crate::update::UpdateManager::new( - std::path::Path::new("signatures"), - ); + let update_mgr = crate::update::UpdateManager::new(std::path::Path::new("signatures")); println!("CryptoTrace v{}", env!("CARGO_PKG_VERSION")); println!("Engine: {}", env!("CARGO_PKG_VERSION")); println!("Signature DB: {}", update_mgr.current_version()); @@ -287,10 +303,22 @@ pub async fn run_with_cli(cli: &Cli) -> Result { let config = crate::types::AppConfig::default(); println!("AI enabled: {}", config.ai.enabled); - println!("AI provider: {}", config.ai.provider.as_deref().unwrap_or("none")); - println!("AI model: {}", config.ai.model_family.as_deref().unwrap_or("gpt-4o")); - println!("AI temperature: {}", config.ai.temperature.as_ref().map_or(0.1, |t| *t)); - println!("AI max tokens: {}", config.ai.max_tokens.as_ref().map_or(512, |t| *t)); + println!( + "AI provider: {}", + config.ai.provider.as_deref().unwrap_or("none") + ); + println!( + "AI model: {}", + config.ai.model_family.as_deref().unwrap_or("gpt-4o") + ); + println!( + "AI temperature: {}", + config.ai.temperature.as_ref().map_or(0.1, |t| *t) + ); + println!( + "AI max tokens: {}", + config.ai.max_tokens.as_ref().map_or(512, |t| *t) + ); if let Some(ref cache) = config.ai.cache { println!("AI cache enabled: {}", cache.enabled); println!("AI cache TTL days: {}", cache.ttl_days); @@ -300,11 +328,16 @@ pub async fn run_with_cli(cli: &Cli) -> Result Result { match action { - CalibrateAction::Train { data, output, learning_rate, epochs, l2_lambda } => { + CalibrateAction::Train { + data, + output, + learning_rate, + epochs, + l2_lambda, + } => { let samples = crate::core::calibration::load_csv(data)?; if samples.is_empty() { eprintln!("No samples loaded from {}", data); @@ -327,7 +366,12 @@ pub async fn run_with_cli(cli: &Cli) -> Result Result { let data = crate::core::calibration::generate_synthetic_dataset(*samples); // Write CSV - let mut wtr = csv::Writer::from_path(output) - .map_err(|e| crate::error::CryptoTraceError::Other( - format!("Cannot create CSV: {}", e) - ))?; + let mut wtr = csv::Writer::from_path(output).map_err(|e| { + crate::error::CryptoTraceError::Other(format!("Cannot create CSV: {}", e)) + })?; wtr.write_record(&[ - "entropy", "block_alignment", "magic_bytes", "length_pattern", - "charset_purity", "window_variance", "label", "detected_type", - ]).ok(); + "entropy", + "block_alignment", + "magic_bytes", + "length_pattern", + "charset_purity", + "window_variance", + "label", + "detected_type", + ]) + .ok(); for sample in &data { wtr.write_record(&[ format!("{:.6}", sample.signals.entropy), format!("{:.6}", sample.signals.block_alignment), format!("{:.6}", sample.signals.magic_bytes), format!("{:.6}", sample.signals.length_pattern), - sample.signals.charset_purity + sample + .signals + .charset_purity .map(|v| format!("{:.6}", v)) .unwrap_or_default(), - sample.signals.window_variance + sample + .signals + .window_variance .map(|v| format!("{:.6}", v)) .unwrap_or_default(), format!("{}", sample.label as u8), sample.detected_type.clone(), - ]).ok(); + ]) + .ok(); } wtr.flush().ok(); println!("Generated {} synthetic samples → {}", data.len(), output); @@ -398,7 +453,10 @@ pub fn print_result_ext(result: &DetectionResult, json: bool, explain: bool) { if json { println!("{}", crate::reports::json::format_json(result)); } else { - print!("{}", crate::reports::terminal::format_terminal_ext(result, explain)); + print!( + "{}", + crate::reports::terminal::format_terminal_ext(result, explain) + ); } } @@ -429,15 +487,20 @@ pub fn load_ai_provider() -> Result> { // Try to load from cryptotrace.toml let toml_path = std::path::Path::new("cryptotrace.toml"); if toml_path.exists() { - let content = std::fs::read_to_string(toml_path) - .map_err(|e| crate::error::CryptoTraceError::Other(format!("Config read: {}", e)))?; - let parsed: serde_json::Value = toml::from_str(&content) - .map_err(|e| crate::error::CryptoTraceError::Other(format!("Config parse: {}", e)))?; + let content = std::fs::read_to_string(toml_path).map_err(|e| { + crate::error::CryptoTraceError::Other(format!("Config read: {}", e)) + })?; + let parsed: serde_json::Value = toml::from_str(&content).map_err(|e| { + crate::error::CryptoTraceError::Other(format!("Config parse: {}", e)) + })?; if let Some(ai) = parsed.get("ai") { if let Some(provider) = ai.get("provider").and_then(|v| v.as_str()) { config.provider_type = provider.to_string(); } - config.api_key = ai.get("api_key").and_then(|v| v.as_str()).map(|s| s.to_string()); + config.api_key = ai + .get("api_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); config.model = ai .get("model") .and_then(|v| v.as_str()) @@ -452,7 +515,10 @@ pub fn load_ai_provider() -> Result> { .and_then(|v| v.as_u64()) .map(|v| v as u32) .unwrap_or(config.max_tokens); - config.base_url = ai.get("base_url").and_then(|v| v.as_str()).map(|s| s.to_string()); + config.base_url = ai + .get("base_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); config.timeout_seconds = ai .get("timeout_seconds") .and_then(|v| v.as_u64()) diff --git a/src/core/calibration.rs b/src/core/calibration.rs index 33423c8..9b0a1de 100644 --- a/src/core/calibration.rs +++ b/src/core/calibration.rs @@ -83,7 +83,12 @@ fn logistic(x: f64) -> f64 { /// Train a calibration model using gradient descent on a labeled dataset. /// Uses binary cross-entropy loss with L2 regularization. -pub fn train(dataset: &[CalibrationSample], learning_rate: f64, epochs: usize, l2_lambda: f64) -> CalibrationModel { +pub fn train( + dataset: &[CalibrationSample], + learning_rate: f64, + epochs: usize, + l2_lambda: f64, +) -> CalibrationModel { let n_features = 6; let n = dataset.len() as f64; @@ -134,9 +139,10 @@ pub fn save_model(model: &CalibrationModel, path: &str) -> Result<(), CryptoTrac /// Load a calibration model from a JSON file. pub fn load_model(path: &str) -> Result { - let content = - std::fs::read_to_string(path).map_err(|e| CryptoTraceError::Other(format!("Cannot read model: {}", e)))?; - serde_json::from_str(&content).map_err(|e| CryptoTraceError::Other(format!("Cannot parse model: {}", e))) + let content = std::fs::read_to_string(path) + .map_err(|e| CryptoTraceError::Other(format!("Cannot read model: {}", e)))?; + serde_json::from_str(&content) + .map_err(|e| CryptoTraceError::Other(format!("Cannot parse model: {}", e))) } /// Load the default bundled model (if available), or None. @@ -273,8 +279,8 @@ fn chrono_now() -> String { /// Load calibration samples from a CSV file. pub fn load_csv(path: &str) -> Result, CryptoTraceError> { - let mut reader = - csv::Reader::from_path(path).map_err(|e| CryptoTraceError::Other(format!("Cannot open CSV: {}", e)))?; + let mut reader = csv::Reader::from_path(path) + .map_err(|e| CryptoTraceError::Other(format!("Cannot open CSV: {}", e)))?; let mut samples = Vec::new(); for result in reader.deserialize() { @@ -290,7 +296,8 @@ pub fn load_csv(path: &str) -> Result, CryptoTraceError> detected_type: String, } - let row: Row = result.map_err(|e| CryptoTraceError::Other(format!("CSV parse error: {}", e)))?; + let row: Row = + result.map_err(|e| CryptoTraceError::Other(format!("CSV parse error: {}", e)))?; samples.push(CalibrationSample { signals: SignalBreakdown { entropy: row.entropy, diff --git a/src/core/confidence.rs b/src/core/confidence.rs index b2e7701..f247218 100644 --- a/src/core/confidence.rs +++ b/src/core/confidence.rs @@ -78,11 +78,7 @@ pub fn compute_confidence( .unwrap_or(0.0); let entropy_consistency = if hash_detection.is_some() { - if entropy < 4.0 { - 0.9 - } else { - 0.3 - } + if entropy < 4.0 { 0.9 } else { 0.3 } } else if encoding_detection.is_some() { if entropy > 3.0 && entropy < 7.0 { 0.8 @@ -95,7 +91,9 @@ pub fn compute_confidence( // Correlated signal cap: if both hash AND encoding are positive, cap // the combined contribution to prevent overcounting. - let combined = signal_strength * SIGNAL_STRENGTH_WEIGHT + entropy_consistency * ENTROPY_WEIGHT + BASELINE_CONFIDENCE; + let combined = signal_strength * SIGNAL_STRENGTH_WEIGHT + + entropy_consistency * ENTROPY_WEIGHT + + BASELINE_CONFIDENCE; if hash_detection.is_some() && encoding_detection.is_some() { combined.min(0.95) } else { @@ -104,10 +102,7 @@ pub fn compute_confidence( } /// Compute primary signal drivers — the signals that most influence confidence. -fn compute_primary_drivers( - signal_strength: f64, - entropy_consistency: f64, -) -> Vec { +fn compute_primary_drivers(signal_strength: f64, entropy_consistency: f64) -> Vec { let signal_contrib = signal_strength * SIGNAL_STRENGTH_WEIGHT; let entropy_contrib = entropy_consistency * ENTROPY_WEIGHT; @@ -116,7 +111,8 @@ fn compute_primary_drivers( ("entropy_consistency", entropy_contrib), ]; drivers.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); - drivers.into_iter() + drivers + .into_iter() .filter(|(_, v)| *v > 0.0) .take(2) .map(|(name, val)| format!("{} ({:.2})", name, val)) @@ -134,10 +130,17 @@ fn compute_conflicting_signals( // Hash detected but entropy is too high for a hash if let Some(h) = hash_detection { if entropy > 5.0 && h.algorithm != "NTLM" { - conflicts.push(format!("Hash mismatch: {} detected but entropy {:.1} is too high for a hash", h.algorithm, entropy)); + conflicts.push(format!( + "Hash mismatch: {} detected but entropy {:.1} is too high for a hash", + h.algorithm, entropy + )); } if encoding_detection.is_some() { - conflicts.push(format!("Type conflict: hash ({}) and encoding ({}) both detected", h.algorithm, encoding_detection.unwrap().encoding_type)); + conflicts.push(format!( + "Type conflict: hash ({}) and encoding ({}) both detected", + h.algorithm, + encoding_detection.unwrap().encoding_type + )); } } @@ -164,7 +167,11 @@ pub fn build_detection_result( let entropy_consistency = if hash_detection.is_some() { if entropy < 4.0 { 0.9 } else { 0.3 } } else if encoding_detection.is_some() { - if entropy > 3.0 && entropy < 7.0 { 0.8 } else { 0.4 } + if entropy > 3.0 && entropy < 7.0 { + 0.8 + } else { + 0.4 + } } else { 0.5 }; @@ -223,19 +230,22 @@ pub fn build_detection_result( // Compute primary drivers and conflicting signals let primary_drivers = compute_primary_drivers(signal_strength, entropy_consistency); - let conflicting_signals = compute_conflicting_signals(hash_detection, encoding_detection, entropy); + let conflicting_signals = + compute_conflicting_signals(hash_detection, encoding_detection, entropy); // Apply calibration if available // Apply risk overrides and CVE data let mut weakness_cve = Vec::new(); if let Some(ref algo) = algorithm { - let (overridden_risk, algo_cves) = crate::intelligence::risk::resolve_risk_level(algo, &risk_overrides); + let (overridden_risk, algo_cves) = + crate::intelligence::risk::resolve_risk_level(algo, &risk_overrides); if risk_overrides.contains_key(algo) { risk_level = overridden_risk; } weakness_cve = algo_cves.clone(); // Also try loading from external CVE databases - let ext_cves = crate::intelligence::risk::build_cve_map("signatures/cve_map.yaml", "cve-db.json"); + let ext_cves = + crate::intelligence::risk::build_cve_map("signatures/cve_map.yaml", "cve-db.json"); for (cve_id, desc) in &ext_cves { if algo.contains(cve_id) || desc.to_lowercase().contains(&algo.to_lowercase()) { if !weakness_cve.contains(cve_id) { @@ -251,9 +261,9 @@ pub fn build_detection_result( let contribs = calibration::signal_contributions(m, &signals); let trace = calibration::format_contributions(&contribs); (cal_conf, true, Some(trace)) - } else { - (heuristic_confidence, false, None) - }; + } else { + (heuristic_confidence, false, None) + }; DetectionResult { input_hash, @@ -326,14 +336,7 @@ mod tests { }; set_model(model); - let result = build_detection_result( - b"test", - SourceType::String, - None, - None, - 4.0, - None, - ); + let result = build_detection_result(b"test", SourceType::String, None, None, 4.0, None); assert!(result.calibrated); assert!(!result.confidence_is_provisional); assert!((result.confidence - 0.731).abs() < 0.01); diff --git a/src/core/encoding.rs b/src/core/encoding.rs index 2ac9b49..040a7c2 100644 --- a/src/core/encoding.rs +++ b/src/core/encoding.rs @@ -51,9 +51,9 @@ pub fn detect_encoding(input: &str) -> Option { } fn detect_base64(input: &str) -> Option { - let charset_ok = input.chars().all(|c| { - matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '+' | '/' | '=') - }); + let charset_ok = input + .chars() + .all(|c| matches!(c, 'A'..='Z' | 'a'..='z' | '0'..='9' | '+' | '/' | '=')); if !charset_ok { return None; } @@ -86,18 +86,20 @@ fn detect_base64(input: &str) -> Option { .decode(input) .is_ok(); - let openssl_prefix = if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(input) { - decoded.starts_with(b"Salted__") - } else { - false - }; + let openssl_prefix = + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(input) { + decoded.starts_with(b"Salted__") + } else { + false + }; let charset_score = if charset_ok { 1.0 } else { 0.0 }; let padding_score = if padding_count <= 2 { 1.0 } else { 0.0 }; let decode_score = if decode_ok { 1.0 } else { 0.0 }; let openssl_score = if openssl_prefix { 1.0 } else { 0.0 }; - let confidence: f64 = charset_score * 0.3 + padding_score * 0.2 + decode_score * 0.4 + openssl_score * 0.1; + let confidence: f64 = + charset_score * 0.3 + padding_score * 0.2 + decode_score * 0.4 + openssl_score * 0.1; if confidence < 0.5 { return None; @@ -123,7 +125,10 @@ fn detect_hex(input: &str) -> Option { if input.is_empty() || input.len() % 2 != 0 { return None; } - if !input.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f' | 'A'..='F')) { + if !input + .chars() + .all(|c| matches!(c, '0'..='9' | 'a'..='f' | 'A'..='F')) + { return None; } @@ -149,9 +154,7 @@ fn detect_url_encoding(input: &str) -> Option { .as_bytes() .windows(3) .filter(|w| w[0] == b'%') - .all(|w| { - w[1].is_ascii_hexdigit() && w[2].is_ascii_hexdigit() - }); + .all(|w| w[1].is_ascii_hexdigit() && w[2].is_ascii_hexdigit()); if !valid_pairs { return None; @@ -168,7 +171,10 @@ fn detect_base32(input: &str) -> Option { if input.len() % 8 != 0 { return None; } - if !input.chars().all(|c| matches!(c, 'A'..='Z' | '2'..='7' | '=')) { + if !input + .chars() + .all(|c| matches!(c, 'A'..='Z' | '2'..='7' | '=')) + { return None; } @@ -181,9 +187,7 @@ fn detect_base32(input: &str) -> Option { fn detect_base58(input: &str) -> Option { // Base58 alphabet (Bitcoin): no 0, O, I, l - let base58_chars = |c: char| { - matches!(c, '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z') - }; + let base58_chars = |c: char| matches!(c, '1'..='9' | 'A'..='H' | 'J'..='N' | 'P'..='Z' | 'a'..='k' | 'm'..='z'); if input.len() < 4 || !input.chars().all(base58_chars) { return None; } @@ -192,7 +196,10 @@ fn detect_base58(input: &str) -> Option { let has_upper = input.chars().any(|c| c.is_ascii_uppercase()); let has_lower = input.chars().any(|c| c.is_ascii_lowercase()); let has_digit = input.chars().any(|c| c.is_ascii_digit()); - let class_count = [has_upper, has_lower, has_digit].iter().filter(|&&x| x).count(); + let class_count = [has_upper, has_lower, has_digit] + .iter() + .filter(|&&x| x) + .count(); if class_count < 2 { return None; } @@ -207,7 +214,32 @@ fn detect_base85(input: &str) -> Option { // Z85 (ZeroMQ): [0-9a-zA-Z._:+-=^!/*?&<>()[]{}@%$#] let z85_chars = |c: char| { c.is_ascii_alphanumeric() - || matches!(c, '.' | '_' | ':' | '+' | '-' | '=' | '^' | '!' | '/' | '*' | '?' | '&' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | '@' | '%' | '$' | '#') + || matches!( + c, + '.' | '_' + | ':' + | '+' + | '-' + | '=' + | '^' + | '!' + | '/' + | '*' + | '?' + | '&' + | '<' + | '>' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '@' + | '%' + | '$' + | '#' + ) }; // Ascii85 (Adobe): starts with ~<, ends with ~> if input.starts_with("~<") && input.ends_with("~>") && input.len() > 4 { @@ -221,7 +253,32 @@ fn detect_base85(input: &str) -> Option { // colliding with Base58/Base64 which also match pure alphanumeric strings). if input.len() >= 4 && input.chars().all(z85_chars) { let has_special = input.chars().any(|c| { - matches!(c, '.' | '_' | ':' | '+' | '-' | '=' | '^' | '!' | '/' | '*' | '?' | '&' | '<' | '>' | '(' | ')' | '[' | ']' | '{' | '}' | '@' | '%' | '$' | '#') + matches!( + c, + '.' | '_' + | ':' + | '+' + | '-' + | '=' + | '^' + | '!' + | '/' + | '*' + | '?' + | '&' + | '<' + | '>' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '@' + | '%' + | '$' + | '#' + ) }); if !has_special { return None; @@ -279,8 +336,7 @@ mod tests { #[test] fn test_base64_openssl() { - let data = base64::engine::general_purpose::STANDARD - .encode(b"Salted__some_encrypted_data"); + let data = base64::engine::general_purpose::STANDARD.encode(b"Salted__some_encrypted_data"); let result = detect_encoding(&data).unwrap(); assert_eq!(result.encoding_type, "Base64"); assert!(result.confidence > 0.95); diff --git a/src/core/entropy.rs b/src/core/entropy.rs index 2caee07..6d9f707 100644 --- a/src/core/entropy.rs +++ b/src/core/entropy.rs @@ -17,11 +17,7 @@ pub fn shannon_entropy(data: &[u8]) -> (f64, HashMap) { .values() .map(|&count| { let p = count as f64 / len; - if p > 0.0 { - -p * p.log2() - } else { - 0.0 - } + if p > 0.0 { -p * p.log2() } else { 0.0 } }) .sum(); @@ -29,7 +25,12 @@ pub fn shannon_entropy(data: &[u8]) -> (f64, HashMap) { } /// Classify entropy score into a human-readable category using configurable thresholds. -pub fn classify_entropy(score: f64, plaintext_max: f64, mixed_max: f64, compressed_max: f64) -> &'static str { +pub fn classify_entropy( + score: f64, + plaintext_max: f64, + mixed_max: f64, + compressed_max: f64, +) -> &'static str { if score < plaintext_max { "plaintext/structured" } else if score < mixed_max { @@ -76,7 +77,10 @@ mod tests { #[test] fn test_classify_entropy() { assert_eq!(classify_entropy(2.0, 3.5, 6.0, 7.5), "plaintext/structured"); - assert_eq!(classify_entropy(4.5, 3.5, 6.0, 7.5), "mixed/partially_encoded"); + assert_eq!( + classify_entropy(4.5, 3.5, 6.0, 7.5), + "mixed/partially_encoded" + ); assert_eq!(classify_entropy(7.0, 3.5, 6.0, 7.5), "compressed/encoded"); assert_eq!(classify_entropy(7.8, 3.5, 6.0, 7.5), "high_entropy"); } diff --git a/src/core/hashing.rs b/src/core/hashing.rs index 9181320..223e049 100644 --- a/src/core/hashing.rs +++ b/src/core/hashing.rs @@ -28,7 +28,9 @@ pub fn detect_hash(input: &str) -> Option { fn try_detect(s: &str) -> Option { let len = s.len(); - let is_hex = s.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f' | 'A'..='F')); + let is_hex = s + .chars() + .all(|c| matches!(c, '0'..='9' | 'a'..='f' | 'A'..='F')); if !is_hex && !is_prefix_based(s) { return None; @@ -39,7 +41,8 @@ fn try_detect(s: &str) -> Option { // with at least one uppercase letter to distinguish from pure-digit hashes) if len == 32 && s.chars().any(|c| c.is_ascii_uppercase()) - && s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) + && s.chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) { return Some(HashDetection { algorithm: "NTLM".to_string(), @@ -62,7 +65,10 @@ fn try_detect(s: &str) -> Option { algorithm: "MD5".to_string(), confidence: 0.95, risk_level: RiskLevel::Critical, - weakness_flags: vec!["collision_vulnerable".to_string(), "rainbow_table_crackable".to_string()], + weakness_flags: vec![ + "collision_vulnerable".to_string(), + "rainbow_table_crackable".to_string(), + ], }); } if len == 40 { @@ -130,7 +136,11 @@ fn detect_prefix_based(s: &str) -> Option { return Some(HashDetection { algorithm: "bcrypt".to_string(), confidence: 0.99, - risk_level: if cost >= 12 { RiskLevel::Low } else { RiskLevel::Medium }, + risk_level: if cost >= 12 { + RiskLevel::Low + } else { + RiskLevel::Medium + }, weakness_flags: if cost < 12 { vec!["insufficient_work_factor".to_string()] } else { @@ -167,13 +177,18 @@ fn detect_prefix_based(s: &str) -> Option { let parts: Vec<&str> = s.split('$').collect(); // Minimum: $pbkdf2-digest$iterations$salt$hash if parts.len() >= 4 { - let digest = parts.first().and_then(|_| parts.get(1).and_then(|p| { - // extract digest after "$pbkdf2-" - let rest = p.strip_prefix("pbkdf2-").unwrap_or(""); - if rest.is_empty() { None } else { Some(rest) } - })); + let digest = parts.first().and_then(|_| { + parts.get(1).and_then(|p| { + // extract digest after "$pbkdf2-" + let rest = p.strip_prefix("pbkdf2-").unwrap_or(""); + if rest.is_empty() { None } else { Some(rest) } + }) + }); let algo = digest.unwrap_or("unknown"); - let has_iterations = parts.get(2).map(|i| i.parse::().is_ok()).unwrap_or(false); + let has_iterations = parts + .get(2) + .map(|i| i.parse::().is_ok()) + .unwrap_or(false); return Some(HashDetection { algorithm: format!("PBKDF2-{}", algo.to_uppercase()), @@ -219,7 +234,9 @@ mod tests { #[test] fn test_sha256() { - let result = detect_hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855").unwrap(); + let result = + detect_hash("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855") + .unwrap(); assert_eq!(result.algorithm, "SHA256"); } diff --git a/src/core/mod.rs b/src/core/mod.rs index 8479306..7378a30 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,8 +1,8 @@ -pub mod entropy; -pub mod sliding_entropy; -pub mod hashing; -pub mod encoding; +pub mod calibration; pub mod compression; -pub mod encryption; pub mod confidence; -pub mod calibration; +pub mod encoding; +pub mod encryption; +pub mod entropy; +pub mod hashing; +pub mod sliding_entropy; diff --git a/src/core/sliding_entropy.rs b/src/core/sliding_entropy.rs index 147389a..8160d90 100644 --- a/src/core/sliding_entropy.rs +++ b/src/core/sliding_entropy.rs @@ -1,7 +1,7 @@ use crate::types::{OffsetRange, SlidingEntropy}; const DEFAULT_WINDOW_SIZE: usize = 4096; // 4KB -const DEFAULT_STRIDE: usize = 2048; // 2KB overlap +const DEFAULT_STRIDE: usize = 2048; // 2KB overlap const DEFAULT_ENTROPY_THRESHOLD: f64 = 7.0; /// Compute sliding-window entropy over byte data. @@ -113,7 +113,10 @@ mod tests { data.extend_from_slice(&b"A".repeat(4096)); let result = sliding_window_entropy(&data, Some(4096), Some(2048), Some(6.5)); - assert!(!result.embedded_regions.is_empty(), "Should detect high-entropy region"); + assert!( + !result.embedded_regions.is_empty(), + "Should detect high-entropy region" + ); } #[test] diff --git a/src/format/mod.rs b/src/format/mod.rs index ec35a3a..65a1cc6 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -60,7 +60,12 @@ pub fn infer_format_hierarchy(entry: &MagicEntry, data: &[u8]) -> FormatHierarch pub fn format_tree_string(hierarchy: &FormatHierarchy) -> String { match hierarchy { FormatHierarchy::Simple(name) => name.clone(), - FormatHierarchy::Nested { category, format, subtype, detail } => { + FormatHierarchy::Nested { + category, + format, + subtype, + detail, + } => { let mut parts = vec![category.clone(), format.clone()]; if let Some(sub) = subtype { parts.push(sub.clone()); @@ -92,12 +97,7 @@ pub fn detect_pe_subsystem(data: &[u8]) -> Option { return None; } // Read e_lfanew at offset 0x3C (4 bytes, little-endian) - let e_lfanew = u32::from_le_bytes([ - data[0x3C], - data[0x3D], - data[0x3E], - data[0x3F], - ]) as usize; + let e_lfanew = u32::from_le_bytes([data[0x3C], data[0x3D], data[0x3E], data[0x3F]]) as usize; if e_lfanew + 0x5C + 2 > data.len() { return None; @@ -127,10 +127,7 @@ pub fn detect_pe_subsystem(data: &[u8]) -> Option { if subsystem_offset + 2 > data.len() { return None; } - let subsystem = u16::from_le_bytes([ - data[subsystem_offset], - data[subsystem_offset + 1], - ]); + let subsystem = u16::from_le_bytes([data[subsystem_offset], data[subsystem_offset + 1]]); Some(match subsystem { 1 => "Native (Driver)".to_string(), @@ -175,13 +172,11 @@ mod tests { category: "compression".to_string(), risk_level: "LOW".to_string(), notes: None, - subtypes: vec![ - crate::signatures::SubtypeEntry { - id: "zip_docx".to_string(), - name: "Office Open XML Document (DOCX)".to_string(), - detect: "[Content_Types].xml".to_string(), - }, - ], + subtypes: vec![crate::signatures::SubtypeEntry { + id: "zip_docx".to_string(), + name: "Office Open XML Document (DOCX)".to_string(), + detect: "[Content_Types].xml".to_string(), + }], provenance: None, } } @@ -208,17 +203,20 @@ mod tests { fn test_pe_subsystem_gui() { // Minimal DOS header + PE signature + optional header indicating GUI let mut data = vec![0u8; 0x100]; - data[0] = b'M'; data[1] = b'Z'; + data[0] = b'M'; + data[1] = b'Z'; data[0x3C] = 0x40; // e_lfanew = 0x40 // PE signature at 0x40 data[0x40..0x44].copy_from_slice(&[0x50, 0x45, 0x00, 0x00]); // PE optional header magic at 0x40 + 0x18 = 0x58 - data[0x58] = 0x0B; data[0x59] = 0x01; // PE32 magic (0x010B) + data[0x58] = 0x0B; + data[0x59] = 0x01; // PE32 magic (0x010B) // Subsystem at offset 0x40 + 0x18 + 0x44 = 0x9C - data[0x9C] = 2; data[0x9D] = 0; // GUI subsystem + data[0x9C] = 2; + data[0x9D] = 0; // GUI subsystem let result = detect_pe_subsystem(&data); assert_eq!(result, Some("GUI Application".to_string())); diff --git a/src/intelligence/audit.rs b/src/intelligence/audit.rs index 1eb37b8..ad76404 100644 --- a/src/intelligence/audit.rs +++ b/src/intelligence/audit.rs @@ -15,7 +15,9 @@ fn audit_dir() -> PathBuf { } else { std::env::var("XDG_DATA_HOME") .map(PathBuf::from) - .or_else(|_| std::env::var("HOME").map(|h| PathBuf::from(h).join(".local").join("share"))) + .or_else(|_| { + std::env::var("HOME").map(|h| PathBuf::from(h).join(".local").join("share")) + }) .unwrap_or_else(|_| PathBuf::from(".")) }; base.join("cryptotrace").join("audit") diff --git a/src/intelligence/mod.rs b/src/intelligence/mod.rs index 04a5374..6c127c8 100644 --- a/src/intelligence/mod.rs +++ b/src/intelligence/mod.rs @@ -1,6 +1,6 @@ -pub mod risk; -pub mod prompt; pub mod audit; pub mod narrative; -pub mod threat_intel; +pub mod prompt; +pub mod risk; pub mod siem; +pub mod threat_intel; diff --git a/src/intelligence/narrative.rs b/src/intelligence/narrative.rs index a591548..94e988d 100644 --- a/src/intelligence/narrative.rs +++ b/src/intelligence/narrative.rs @@ -38,12 +38,14 @@ pub fn validate_narrative(response: &str) -> Result { let risk_reason = extract_field(obj, "risk_reason", "No risk reasoning provided.", |s| { validate_risk_reason(s) }); - let recommended_action = extract_field(obj, "recommended_action", "No action recommended.", |s| { - validate_action(s) - }); - let confidence_statement = extract_field(obj, "confidence_statement", "Confidence not stated.", |s| { - validate_confidence(s) - }); + let recommended_action = + extract_field(obj, "recommended_action", "No action recommended.", |s| { + validate_action(s) + }); + let confidence_statement = + extract_field(obj, "confidence_statement", "Confidence not stated.", |s| { + validate_confidence(s) + }); Ok(AiNarrative { summary, @@ -77,15 +79,19 @@ fn extract_field( fn contains_hallucination(text: &str) -> bool { // Check for hallucinated CVE numbers for word in text.split_whitespace() { - let word = word.trim_end_matches(|c: char| c == '.' || c == ',' || c == '!' || c == '?' || c == ';' || c == ':'); + let word = word.trim_end_matches(|c: char| { + c == '.' || c == ',' || c == '!' || c == '?' || c == ';' || c == ':' + }); if word.starts_with(CVE_PREFIX) && word.len() > 4 { // CVE-YYYY-NNNNN format check let rest = &word[4..]; if let Some(dash) = rest.find('-') { let year = &rest[..dash]; let num = &rest[dash + 1..]; - if year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()) - && num.len() >= 4 && num.chars().all(|c| c.is_ascii_digit()) + if year.len() == 4 + && year.chars().all(|c| c.is_ascii_digit()) + && num.len() >= 4 + && num.chars().all(|c| c.is_ascii_digit()) { return false; // Valid CVE format — not hallucinated } @@ -99,7 +105,10 @@ fn contains_hallucination(text: &str) -> bool { /// Validate summary: max 2 sentences, no hallucinated algorithms not in known list. fn validate_summary(s: &str) -> Option { - let sentence_count = s.matches(|c: char| c == '.' || c == '!' || c == '?').count().max(1); + let sentence_count = s + .matches(|c: char| c == '.' || c == '!' || c == '?') + .count() + .max(1); if sentence_count > 3 { return None; // Allow up to 3 sentences } @@ -110,8 +119,18 @@ fn validate_summary(s: &str) -> Option { fn validate_risk_reason(s: &str) -> Option { let lower = s.to_lowercase(); let has_signal = [ - "entropy", "signal", "hash", "encoding", "compression", "encrypt", - "magic byte", "base64", "md5", "sha", "risk", "confidence", + "entropy", + "signal", + "hash", + "encoding", + "compression", + "encrypt", + "magic byte", + "base64", + "md5", + "sha", + "risk", + "confidence", ] .iter() .any(|kw| lower.contains(kw)); @@ -292,6 +311,9 @@ mod tests { // The summary has 1 sentence (one period at end) so it passes // We only reject if sentence count > 3 let result = validate_narrative(json).unwrap(); - assert_eq!(result.summary, "This is a very long summary that has way too many words and should probably be rejected because it exceeds the maximum allowed length for this field."); + assert_eq!( + result.summary, + "This is a very long summary that has way too many words and should probably be rejected because it exceeds the maximum allowed length for this field." + ); } } diff --git a/src/intelligence/prompt.rs b/src/intelligence/prompt.rs index 10c7b30..826fe92 100644 --- a/src/intelligence/prompt.rs +++ b/src/intelligence/prompt.rs @@ -34,8 +34,10 @@ pub struct CacheInfo { /// Return current cache statistics. pub fn cache_info() -> CacheInfo { - NARRATIVE_CACHE.read().ok().map(|guard| { - match guard.as_ref() { + NARRATIVE_CACHE + .read() + .ok() + .map(|guard| match guard.as_ref() { Some(cache) => CacheInfo { enabled: true, capacity: cache.capacity(), @@ -46,12 +48,12 @@ pub fn cache_info() -> CacheInfo { capacity: 0, count: 0, }, - } - }).unwrap_or(CacheInfo { - enabled: false, - capacity: 0, - count: 0, - }) + }) + .unwrap_or(CacheInfo { + enabled: false, + capacity: 0, + count: 0, + }) } /// Build a deterministic cache key from detection fields (no raw bytes). diff --git a/src/intelligence/risk.rs b/src/intelligence/risk.rs index f62be88..e83ed65 100644 --- a/src/intelligence/risk.rs +++ b/src/intelligence/risk.rs @@ -5,13 +5,22 @@ use std::collections::HashMap; /// Users can override these in `cryptotrace.toml` → `[risk.overrides]`. pub fn default_risk_level(algorithm: &str) -> (RiskLevel, Vec) { match algorithm { - "MD5" => (RiskLevel::Critical, vec!["CVE-2013-6623".to_string(), "CVE-2004-0913".to_string()]), - "SHA1" => (RiskLevel::High, vec!["CVE-2017-11476".to_string(), "CVE-2020-13785".to_string()]), + "MD5" => ( + RiskLevel::Critical, + vec!["CVE-2013-6623".to_string(), "CVE-2004-0913".to_string()], + ), + "SHA1" => ( + RiskLevel::High, + vec!["CVE-2017-11476".to_string(), "CVE-2020-13785".to_string()], + ), "SHA256" => (RiskLevel::Low, vec![]), "SHA512" => (RiskLevel::Low, vec![]), "bcrypt" => (RiskLevel::Low, vec![]), "Argon2id" | "Argon2i" => (RiskLevel::Low, vec![]), - "NTLM" => (RiskLevel::Critical, vec!["CVE-2010-0234".to_string(), "CVE-2012-0125".to_string()]), + "NTLM" => ( + RiskLevel::Critical, + vec!["CVE-2010-0234".to_string(), "CVE-2012-0125".to_string()], + ), "DES" => (RiskLevel::Critical, vec!["CVE-2024-1234".to_string()]), "PBKDF2-SHA256" => (RiskLevel::Low, vec![]), "PBKDF2-SHA512" => (RiskLevel::Low, vec![]), @@ -25,13 +34,18 @@ pub fn default_risk_level(algorithm: &str) -> (RiskLevel, Vec) { "Base64" | "Base58" | "Base32" | "Base91" | "Ascii85" | "Z85" | "Hex" | "URLEncoding" => { (RiskLevel::Low, vec![]) } - "GZIP" | "BZ2" | "Zstd" | "XZ" | "Brotli" | "LZ4" | "Zlib" | "ZIP" => (RiskLevel::Low, vec![]), + "GZIP" | "BZ2" | "Zstd" | "XZ" | "Brotli" | "LZ4" | "Zlib" | "ZIP" => { + (RiskLevel::Low, vec![]) + } _ => (RiskLevel::Unknown, vec![]), } } /// Apply user-configured overrides on top of default risk levels. -pub fn resolve_risk_level(algorithm: &str, overrides: &HashMap) -> (RiskLevel, Vec) { +pub fn resolve_risk_level( + algorithm: &str, + overrides: &HashMap, +) -> (RiskLevel, Vec) { if let Some(overridden) = overrides.get(algorithm) { return (overridden.clone(), vec![]); } @@ -97,7 +111,9 @@ struct CveEntry { pub fn load_cvss_scores(yaml_path: &str) -> HashMap { if let Ok(content) = std::fs::read_to_string(yaml_path) { if let Ok(parsed) = serde_yaml::from_str::(&content) { - return parsed.cves.iter() + return parsed + .cves + .iter() .filter_map(|e| e.cvss_v3_base.map(|s| (e.algorithm.clone(), s))) .collect(); } @@ -113,11 +129,17 @@ pub fn cvss_score_for_algorithm(algorithm: &str, yaml_path: &str) -> Option /// Human-readable CVSS severity label from numeric score. pub fn cvss_severity_label(score: f64) -> &'static str { - if score >= 9.0 { "CRITICAL" } - else if score >= 7.0 { "HIGH" } - else if score >= 4.0 { "MEDIUM" } - else if score > 0.0 { "LOW" } - else { "NONE" } + if score >= 9.0 { + "CRITICAL" + } else if score >= 7.0 { + "HIGH" + } else if score >= 4.0 { + "MEDIUM" + } else if score > 0.0 { + "LOW" + } else { + "NONE" + } } /// Helper: build a combined CVE map from both sources. diff --git a/src/intelligence/siem.rs b/src/intelligence/siem.rs index 1133b37..5ed6767 100644 --- a/src/intelligence/siem.rs +++ b/src/intelligence/siem.rs @@ -11,7 +11,6 @@ /// - `SIEM_SYSLOG_ADDR` — host:port (e.g. `192.168.1.100:514`) /// - `SIEM_SYSLOG_PROTO` — `udp` (default) or `tcp` /// - `SIEM_SYSLOG_FORMAT` — `cef` (default) or `leef` - use crate::types::DetectionResult; /// Format a `DetectionResult` as a CEF log line. @@ -31,14 +30,20 @@ pub fn format_cef(result: &DetectionResult) -> String { let mut ext = String::new(); ext.push_str(&format!("inputHash={} ", result.input_hash)); - ext.push_str(&format!("detectedType={} ", escape_cef(&result.detected_type))); + ext.push_str(&format!( + "detectedType={} ", + escape_cef(&result.detected_type) + )); if let Some(ref algo) = result.algorithm { ext.push_str(&format!("algorithm={} ", escape_cef(algo))); } ext.push_str(&format!("confidence={:.2} ", result.confidence)); ext.push_str(&format!("riskLevel={} ", result.risk_level)); ext.push_str(&format!("entropy={:.2} ", result.entropy)); - ext.push_str(&format!("falsePositiveRisk={:.4} ", result.false_positive_risk)); + ext.push_str(&format!( + "falsePositiveRisk={:.4} ", + result.false_positive_risk + )); if let Some(ref weakness) = result.weakness { ext.push_str(&format!("weakness={} ", escape_cef(weakness))); } @@ -47,9 +52,21 @@ pub fn format_cef(result: &DetectionResult) -> String { } ext.push_str(&format!("context={:?} ", result.detection_context)); ext.push_str(&format!("calibrated={} ", result.calibrated)); - ext.push_str(&format!("primaryDrivers={} ", result.primary_drivers.join(","))); + ext.push_str(&format!( + "primaryDrivers={} ", + result.primary_drivers.join(",") + )); - format!("CEF:0|{}|{}|{}|{}|{}|{}|{}", vendor, product, version, event_id, name, severity, ext.trim()) + format!( + "CEF:0|{}|{}|{}|{}|{}|{}|{}", + vendor, + product, + version, + event_id, + name, + severity, + ext.trim() + ) } /// Format a `DetectionResult` as a LEEF log line. @@ -60,7 +77,10 @@ pub fn format_leef(result: &DetectionResult) -> String { let event_id = "100"; let mut ext = String::new(); - ext.push_str(&format!("cat={} ", escape_leef_value(&result.detected_type))); + ext.push_str(&format!( + "cat={} ", + escape_leef_value(&result.detected_type) + )); ext.push_str(&format!("sev={} ", leef_severity(result.risk_level))); ext.push_str(&format!("inputHash={} ", &result.input_hash)); if let Some(ref algo) = result.algorithm { @@ -72,9 +92,19 @@ pub fn format_leef(result: &DetectionResult) -> String { ext.push_str(&format!("fpr={:.4} ", result.false_positive_risk)); ext.push_str(&format!("context={:?} ", result.detection_context)); ext.push_str(&format!("calibrated={} ", result.calibrated)); - ext.push_str(&format!("primaryDrivers={} ", result.primary_drivers.join(","))); + ext.push_str(&format!( + "primaryDrivers={} ", + result.primary_drivers.join(",") + )); - format!("LEEF:2.0|{}|{}|{}|{}|{}", vendor, product, version, event_id, ext.trim()) + format!( + "LEEF:2.0|{}|{}|{}|{}|{}", + vendor, + product, + version, + event_id, + ext.trim() + ) } /// CEF severity: 0-10 scale, 10=most severe. @@ -165,7 +195,10 @@ pub async fn send_to_syslog(result: &DetectionResult) -> Result<(), String> { } /// Send a DetectionResult to a syslog server with explicit configuration. -pub async fn send_to_syslog_with_config(result: &DetectionResult, config: &SyslogConfig) -> Result<(), String> { +pub async fn send_to_syslog_with_config( + result: &DetectionResult, + config: &SyslogConfig, +) -> Result<(), String> { let message = match config.format { SyslogFormat::Cef => format_cef(result), SyslogFormat::Leef => format_leef(result), @@ -234,10 +267,22 @@ mod tests { let result = sample_result(); let cef = format_cef(&result); assert!(cef.starts_with("CEF:0|"), "starts with CEF: {}", cef); - assert!(cef.contains("inputHash=abc123"), "contains inputHash: {}", cef); + assert!( + cef.contains("inputHash=abc123"), + "contains inputHash: {}", + cef + ); assert!(cef.contains("algorithm=MD5"), "contains algorithm: {}", cef); - assert!(cef.contains("riskLevel=critical"), "contains riskLevel: {}", cef); - assert!(cef.contains("confidence=0.98"), "contains confidence: {}", cef); + assert!( + cef.contains("riskLevel=critical"), + "contains riskLevel: {}", + cef + ); + assert!( + cef.contains("confidence=0.98"), + "contains confidence: {}", + cef + ); } #[test] @@ -245,10 +290,26 @@ mod tests { let result = sample_result(); let leef = format_leef(&result); assert!(leef.starts_with("LEEF:2.0|"), "starts with LEEF: {}", leef); - assert!(leef.contains("inputHash=abc123"), "contains inputHash: {}", leef); - assert!(leef.contains("algorithm=MD5"), "contains algorithm: {}", leef); - assert!(leef.contains("riskLevel=critical"), "contains riskLevel: {}", leef); - assert!(leef.contains("confidence=0.98"), "contains confidence: {}", leef); + assert!( + leef.contains("inputHash=abc123"), + "contains inputHash: {}", + leef + ); + assert!( + leef.contains("algorithm=MD5"), + "contains algorithm: {}", + leef + ); + assert!( + leef.contains("riskLevel=critical"), + "contains riskLevel: {}", + leef + ); + assert!( + leef.contains("confidence=0.98"), + "contains confidence: {}", + leef + ); assert!(leef.contains("sev=10"), "contains sev: {}", leef); } @@ -256,12 +317,32 @@ mod tests { fn test_cef_escape_special_chars() { let s = "a=b|c\\d\ne"; let escaped = escape_cef(s); - assert!(escaped.contains("\\="), "should have escaped =: {}", escaped); - assert!(escaped.contains("\\|"), "should have escaped |: {}", escaped); - assert!(escaped.contains("\\\\"), "should have escaped \\: {}", escaped); - assert!(escaped.contains("\\n"), "should have escaped newline: {}", escaped); + assert!( + escaped.contains("\\="), + "should have escaped =: {}", + escaped + ); + assert!( + escaped.contains("\\|"), + "should have escaped |: {}", + escaped + ); + assert!( + escaped.contains("\\\\"), + "should have escaped \\: {}", + escaped + ); + assert!( + escaped.contains("\\n"), + "should have escaped newline: {}", + escaped + ); // The original = and | and \ should still be present (as part of escape sequences) - assert!(escaped.contains('='), "= should still appear as \\=: {}", escaped); + assert!( + escaped.contains('='), + "= should still appear as \\=: {}", + escaped + ); } #[test] diff --git a/src/intelligence/threat_intel.rs b/src/intelligence/threat_intel.rs index 91ae225..ca1e9be 100644 --- a/src/intelligence/threat_intel.rs +++ b/src/intelligence/threat_intel.rs @@ -157,10 +157,14 @@ pub fn scan_yara(data: &[u8], rules_path: &str) -> Result> { Err(e) => { if e.kind() == std::io::ErrorKind::NotFound { return Err(CryptoTraceError::Other( - "YARA CLI not found. Install yara from https://virustotal.github.io/yara/".to_string(), + "YARA CLI not found. Install yara from https://virustotal.github.io/yara/" + .to_string(), )); } - Err(CryptoTraceError::Other(format!("YARA execution error: {}", e))) + Err(CryptoTraceError::Other(format!( + "YARA execution error: {}", + e + ))) } } } @@ -243,7 +247,9 @@ mod tests { source: "VirusTotal".to_string(), scan_date: Some("1716000000.000".to_string()), threat_labels: vec!["Trojan.Generic.123".to_string()], - vt_link: Some("https://www.virustotal.com/gui/file/d41d8cd98f00b204e9800998ecf8427e".to_string()), + vt_link: Some( + "https://www.virustotal.com/gui/file/d41d8cd98f00b204e9800998ecf8427e".to_string(), + ), }; let json = serde_json::to_string(&report).unwrap(); @@ -254,11 +260,9 @@ mod tests { #[tokio::test] async fn test_composite_scan_no_api_key() { let config = ThreatIntelConfig::default(); - let reports = composite_threat_scan( - "e99a18c428cb38d5f260853678922e03", - b"test", - &config, - ).await.unwrap(); + let reports = composite_threat_scan("e99a18c428cb38d5f260853678922e03", b"test", &config) + .await + .unwrap(); assert!(reports.is_empty()); } } diff --git a/src/lib.rs b/src/lib.rs index 33c8e10..d3ac219 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,17 +1,17 @@ -pub mod core; -pub mod sanitization; pub mod analyzers; -pub mod providers; +pub mod cache; +pub mod cli; +pub mod core; +pub mod error; +pub mod format; pub mod intelligence; +pub mod providers; +pub mod reports; +pub mod sanitization; pub mod signatures; -pub mod format; +pub mod types; pub mod update; -pub mod cli; -pub mod reports; -pub mod cache; pub mod workers; -pub mod types; -pub mod error; -pub use types::*; pub use error::*; +pub use types::*; diff --git a/src/providers/community.rs b/src/providers/community.rs index 1112313..4266fd7 100644 --- a/src/providers/community.rs +++ b/src/providers/community.rs @@ -35,11 +35,17 @@ impl CommunityRegistry { /// Load a registry from a specific JSON file. pub fn from_file(path: &Path) -> Result { - let content = std::fs::read_to_string(path) - .map_err(|e| CryptoTraceError::Other(format!("Cannot read community registry at '{}': {}", path.display(), e)))?; - - let registry: Self = serde_json::from_str(&content) - .map_err(|e| CryptoTraceError::Other(format!("Invalid community registry JSON: {}", e)))?; + let content = std::fs::read_to_string(path).map_err(|e| { + CryptoTraceError::Other(format!( + "Cannot read community registry at '{}': {}", + path.display(), + e + )) + })?; + + let registry: Self = serde_json::from_str(&content).map_err(|e| { + CryptoTraceError::Other(format!("Invalid community registry JSON: {}", e)) + })?; if registry.providers.is_empty() { return Err(CryptoTraceError::Other( @@ -57,32 +63,44 @@ impl CommunityRegistry { /// Return providers filtered by trust level. pub fn by_trust_level(&self, level: &str) -> Vec<&CommunityProvider> { - self.providers.iter().filter(|p| p.trust_level == level).collect() + self.providers + .iter() + .filter(|p| p.trust_level == level) + .collect() } /// Return providers matching any of the given categories. pub fn by_categories(&self, categories: &[&str]) -> Vec<&CommunityProvider> { self.providers .iter() - .filter(|p| p.categories.iter().any(|c| categories.contains(&c.as_str()))) + .filter(|p| { + p.categories + .iter() + .any(|c| categories.contains(&c.as_str())) + }) .collect() } /// Ensure the local signature path for a provider exists. /// Returns the full path to the local signature file. pub fn resolve_local_path(&self, provider: &CommunityProvider) -> PathBuf { - Path::new("signatures").join("community").join(&provider.signature_path) + Path::new("signatures") + .join("community") + .join(&provider.signature_path) } } /// Load a community provider's signature file, downloading it if necessary. pub async fn load_community_signatures(provider: &CommunityProvider) -> Result> { - let local_path = Path::new("signatures").join("community").join(&provider.signature_path); + let local_path = Path::new("signatures") + .join("community") + .join(&provider.signature_path); // If the local file exists, load it if local_path.exists() { - return std::fs::read(&local_path) - .map_err(|e| CryptoTraceError::Other(format!("Cannot read local signature file: {}", e))); + return std::fs::read(&local_path).map_err(|e| { + CryptoTraceError::Other(format!("Cannot read local signature file: {}", e)) + }); } // Otherwise, download from the provider's URL @@ -96,11 +114,9 @@ async fn download_provider_signatures(provider: &CommunityProvider) -> Result Result Result { - let api_key = config - .api_key - .clone() - .ok_or_else(|| CryptoTraceError::AiProvider("OpenAI requires an API key".to_string()))?; + let api_key = config.api_key.clone().ok_or_else(|| { + CryptoTraceError::AiProvider("OpenAI requires an API key".to_string()) + })?; Ok(Self { api_key, model: config.model.clone(), @@ -114,7 +113,9 @@ impl AiProvider for OpenAiProvider { let content = json["choices"][0]["message"]["content"] .as_str() - .ok_or_else(|| CryptoTraceError::AiProvider("OpenAI returned empty response".to_string()))?; + .ok_or_else(|| { + CryptoTraceError::AiProvider("OpenAI returned empty response".to_string()) + })?; crate::intelligence::narrative::validate_narrative(content) } @@ -170,16 +171,17 @@ impl AiProvider for AnthropicProvider { .json(&body) .send() .await - .map_err(|e| CryptoTraceError::AiProvider(format!("Anthropic request failed: {}", e)))?; + .map_err(|e| { + CryptoTraceError::AiProvider(format!("Anthropic request failed: {}", e)) + })?; - let json: serde_json::Value = resp - .json() - .await - .map_err(|e| CryptoTraceError::AiProvider(format!("Anthropic response parse: {}", e)))?; + let json: serde_json::Value = resp.json().await.map_err(|e| { + CryptoTraceError::AiProvider(format!("Anthropic response parse: {}", e)) + })?; - let content = json["content"][0]["text"] - .as_str() - .ok_or_else(|| CryptoTraceError::AiProvider("Anthropic returned empty response".to_string()))?; + let content = json["content"][0]["text"].as_str().ok_or_else(|| { + CryptoTraceError::AiProvider("Anthropic returned empty response".to_string()) + })?; crate::intelligence::narrative::validate_narrative(content) } @@ -249,9 +251,9 @@ impl AiProvider for LocalProvider { .await .map_err(|e| CryptoTraceError::AiProvider(format!("Local response parse: {}", e)))?; - let content = json["response"] - .as_str() - .ok_or_else(|| CryptoTraceError::AiProvider("Local model returned empty response".to_string()))?; + let content = json["response"].as_str().ok_or_else(|| { + CryptoTraceError::AiProvider("Local model returned empty response".to_string()) + })?; crate::intelligence::narrative::validate_narrative(content) } diff --git a/src/reports/html.rs b/src/reports/html.rs index 1c54a46..75596d0 100644 --- a/src/reports/html.rs +++ b/src/reports/html.rs @@ -14,53 +14,104 @@ pub fn format_html(result: &DetectionResult) -> String { html.push_str(r#"
"#); html.push_str(r#"

CryptoTrace Analysis Report

"#); - html.push_str(&format!("v{}
", result.engine_version)); + html.push_str(&format!( + "v{}
", + result.engine_version + )); // Summary html.push_str(r#"

Summary

"#); - html.push_str(&format!("", result.input_hash)); - html.push_str(&format!("", result.source_type)); - html.push_str(&format!("", result.entropy)); - html.push_str(&format!("", - result.risk_level.to_string().to_lowercase(), result.risk_level)); + html.push_str(&format!( + "", + result.input_hash + )); + html.push_str(&format!( + "", + result.source_type + )); + html.push_str(&format!( + "", + result.entropy + )); + html.push_str(&format!( + "", + result.risk_level.to_string().to_lowercase(), + result.risk_level + )); html.push_str("
Input Hash{}
Source{:?}
Entropy{:.2} / 8.00
Risk Level{:?}
Input Hash{}
Source{:?}
Entropy{:.2} / 8.00
Risk Level{:?}
"); // Detection html.push_str(r#"

Detection

"#); - html.push_str(&format!("", result.detected_type)); - html.push_str(&format!("", - result.algorithm.as_deref().unwrap_or("Unknown"))); - html.push_str(&format!("", + html.push_str(&format!( + "", + result.detected_type + )); + html.push_str(&format!( + "", + result.algorithm.as_deref().unwrap_or("Unknown") + )); + html.push_str(&format!( + "", result.confidence * 100.0, - if result.calibrated { "(calibrated)" } else { "(provisional)" })); + if result.calibrated { + "(calibrated)" + } else { + "(provisional)" + } + )); if let Some(ref weakness) = result.weakness { html.push_str(&format!("", weakness)); } if !result.weakness_cve.is_empty() { - html.push_str(&format!("", - result.weakness_cve.join(", "))); + html.push_str(&format!( + "", + result.weakness_cve.join(", ") + )); } if !result.recommendations.is_empty() { - html.push_str(&format!("", - result.recommendations.join("; "))); + html.push_str(&format!( + "", + result.recommendations.join("; ") + )); } html.push_str("
Type{}
Algorithm{}
Confidence{:.1}% {}
Type{}
Algorithm{}
Confidence{:.1}% {}
Weakness{}
CVEs{}
CVEs{}
Recommendation{}
Recommendation{}
"); // Signals if let Some(ref sig) = result.signals { html.push_str(r#"

Signal Breakdown

"#); - html.push_str(&format!("", sig.entropy)); + html.push_str(&format!( + "", + sig.entropy + )); if let Some(bd) = sig.byte_distribution { - html.push_str(&format!("", bd)); + html.push_str(&format!( + "", + bd + )); } - html.push_str(&format!("", sig.block_alignment)); - html.push_str(&format!("", sig.magic_bytes)); - html.push_str(&format!("", sig.length_pattern)); + html.push_str(&format!( + "", + sig.block_alignment + )); + html.push_str(&format!( + "", + sig.magic_bytes + )); + html.push_str(&format!( + "", + sig.length_pattern + )); if let Some(cp) = sig.charset_purity { - html.push_str(&format!("", cp)); + html.push_str(&format!( + "", + cp + )); } if let Some(wv) = sig.window_variance { - html.push_str(&format!("", wv)); + html.push_str(&format!( + "", + wv + )); } html.push_str("
Entropy{:.2}
Entropy{:.2}
Byte Distribution{:.2}
Byte Distribution{:.2}
Block Alignment{:.2}
Magic Bytes{:.2}
Length Pattern{:.2}
Block Alignment{:.2}
Magic Bytes{:.2}
Length Pattern{:.2}
Charset Purity{:.2}
Charset Purity{:.2}
Window Variance{:.2}
Window Variance{:.2}
"); } @@ -83,19 +134,24 @@ pub fn format_html(result: &DetectionResult) -> String { // Decision trace if let Some(ref trace) = result.decision_trace { - html.push_str(&format!(r#"

Decision Trace

{}

"#, trace)); + html.push_str(&format!( + r#"

Decision Trace

{}

"#, + trace + )); } // Layer tree if !result.layers.is_empty() { html.push_str(r#"

Layer Tree

    "#); for layer in &result.layers { - write!(html, + write!( + html, "
  • [{}] {} ({:.0}% confidence)
  • ", layer.algorithm.as_deref().unwrap_or("?"), layer.detected_type, layer.confidence * 100.0 - ).ok(); + ) + .ok(); } html.push_str("
"); } @@ -105,7 +161,10 @@ pub fn format_html(result: &DetectionResult) -> String { html.push_str(r#"

AI Narrative

"#); html.push_str(&format!("

Summary: {}

", ai.summary)); html.push_str(&format!("

Risk: {}

", ai.risk_reason)); - html.push_str(&format!("

Action: {}

", ai.recommended_action)); + html.push_str(&format!( + "

Action: {}

", + ai.recommended_action + )); html.push_str("
"); } @@ -139,7 +198,7 @@ p { margin: 8px 0; line-height: 1.5; } #[cfg(test)] mod tests { use super::*; - use crate::types::{RiskLevel, SignalBreakdown, AiNarrative, SourceType}; + use crate::types::{AiNarrative, RiskLevel, SignalBreakdown, SourceType}; #[test] fn test_format_html_basic() { diff --git a/src/reports/mod.rs b/src/reports/mod.rs index f73349a..c33548f 100644 --- a/src/reports/mod.rs +++ b/src/reports/mod.rs @@ -1,3 +1,3 @@ -pub mod terminal; -pub mod json; pub mod html; +pub mod json; +pub mod terminal; diff --git a/src/reports/terminal.rs b/src/reports/terminal.rs index 3a95bc2..03a877f 100644 --- a/src/reports/terminal.rs +++ b/src/reports/terminal.rs @@ -12,7 +12,10 @@ pub fn format_terminal_ext(result: &DetectionResult, explain: bool) -> String { output.push_str(" CryptoTrace Analysis Report\n"); output.push_str("═══════════════════════════════════════\n\n"); - output.push_str(&format!(" Input: {}\n", &result.input_hash[..32.min(result.input_hash.len())])); + output.push_str(&format!( + " Input: {}\n", + &result.input_hash[..32.min(result.input_hash.len())] + )); output.push_str(&format!(" Entropy: {:.2} / 8.00", result.entropy)); // Add sliding window info if available @@ -27,7 +30,10 @@ pub fn format_terminal_ext(result: &DetectionResult, explain: bool) -> String { output.push_str(&format!(" Source: {:?}\n", result.source_type)); output.push('\n'); - output.push_str(&format!(" Detection: {}\n", result.algorithm.as_deref().unwrap_or("Unknown"))); + output.push_str(&format!( + " Detection: {}\n", + result.algorithm.as_deref().unwrap_or("Unknown") + )); output.push_str(&format!(" Type: {}\n", result.detected_type)); output.push_str(&format!(" Confidence: {:.0}%", result.confidence * 100.0)); if result.calibrated { @@ -49,9 +55,18 @@ pub fn format_terminal_ext(result: &DetectionResult, explain: bool) -> String { if let Some(bd) = signals.byte_distribution { output.push_str(&format!(" byte_distribution {:.2}\n", bd)); } - output.push_str(&format!(" block_alignment {:.2}\n", signals.block_alignment)); - output.push_str(&format!(" magic_bytes {:.2}\n", signals.magic_bytes)); - output.push_str(&format!(" length_pattern {:.2}\n", signals.length_pattern)); + output.push_str(&format!( + " block_alignment {:.2}\n", + signals.block_alignment + )); + output.push_str(&format!( + " magic_bytes {:.2}\n", + signals.magic_bytes + )); + output.push_str(&format!( + " length_pattern {:.2}\n", + signals.length_pattern + )); if let Some(cp) = signals.charset_purity { output.push_str(&format!(" charset_purity {:.2}\n", cp)); } @@ -75,7 +90,10 @@ pub fn format_terminal_ext(result: &DetectionResult, explain: bool) -> String { } } if result.false_positive_risk > 0.0 { - output.push_str(&format!("\n False Positive Risk: {:.1}%\n", result.false_positive_risk * 100.0)); + output.push_str(&format!( + "\n False Positive Risk: {:.1}%\n", + result.false_positive_risk * 100.0 + )); } if !result.weakness_cve.is_empty() { output.push_str("\n Related CVEs:\n"); @@ -131,7 +149,10 @@ fn format_layer_tree(output: &mut String, layer: &crate::types::Layer, indent: u let prefix = " ".repeat(indent); output.push_str(&format!( "{}├─ [{}] {} ({:.0}% confidence)\n", - prefix, layer.depth, layer.algorithm, layer.confidence * 100.0 + prefix, + layer.depth, + layer.algorithm, + layer.confidence * 100.0 )); if let Some(ratio) = layer.expansion_ratio { output.push_str(&format!("{}│ expansion: {:.1}:1\n", prefix, ratio)); diff --git a/src/sanitization/guard.rs b/src/sanitization/guard.rs index c9097c7..68f827a 100644 --- a/src/sanitization/guard.rs +++ b/src/sanitization/guard.rs @@ -35,7 +35,11 @@ impl InputGuard { self } - pub fn sanitize_bytes(&self, bytes: Vec, source_type: SourceType) -> Result { + pub fn sanitize_bytes( + &self, + bytes: Vec, + source_type: SourceType, + ) -> Result { let original_length = bytes.len(); let max_size = match source_type { SourceType::String => self.max_string_size, @@ -110,7 +114,10 @@ mod tests { let guard = InputGuard::new(); let result = guard.sanitize_string("hello\0world"); assert!(result.is_err()); - assert!(matches!(result.unwrap_err(), CryptoTraceError::NullBytesInString)); + assert!(matches!( + result.unwrap_err(), + CryptoTraceError::NullBytesInString + )); } #[test] diff --git a/src/sanitization/sandbox.rs b/src/sanitization/sandbox.rs index d05dae3..509ef85 100644 --- a/src/sanitization/sandbox.rs +++ b/src/sanitization/sandbox.rs @@ -132,7 +132,10 @@ impl Sandbox { .stderr(Stdio::piped()); // Set memory limit env var for pre_exec closures - cmd.env("CRYPTOTRACE_MAX_MEMORY_MB", self.config.max_memory_mb.to_string()); + cmd.env( + "CRYPTOTRACE_MAX_MEMORY_MB", + self.config.max_memory_mb.to_string(), + ); // Platform-specific sandbox enforcement (pre-spawn) apply_platform_sandbox(&mut cmd, self.config.max_memory_mb); @@ -272,15 +275,29 @@ fn apply_platform_sandbox(cmd: &mut Command, _max_memory_mb: u64) { ); let profile_bytes = profile.as_bytes(); let mut error: *mut libc::c_char = std::ptr::null_mut(); - let ret = libc::sandbox_init( - profile_bytes.as_ptr() as *const libc::c_char, - 0, - &mut error, - ); + extern "C" { + fn sandbox_init( + profile: *const libc::c_char, + flags: u64, + errorbuf: *mut *mut libc::c_char, + ) -> libc::c_int; + fn sandbox_free_error(errorbuf: *mut libc::c_char); + } + let ret = unsafe { + sandbox_init( + profile_bytes.as_ptr() as *const libc::c_char, + 0u64, + &mut error, + ) + }; if ret != 0 { if !error.is_null() { - let msg = std::ffi::CStr::from_ptr(error).to_string_lossy().into_owned(); - libc::sandbox_free_error(error); + let msg = std::ffi::CStr::from_ptr(error) + .to_string_lossy() + .into_owned(); + unsafe { + sandbox_free_error(error); + } Err(std::io::Error::new(std::io::ErrorKind::Other, msg)) } else { Err(std::io::Error::last_os_error()) @@ -352,20 +369,14 @@ fn apply_post_spawn_sandbox( } unsafe extern "system" { - fn CreateJobObjectW( - lpJobAttributes: *const c_void, - lpName: LPCWSTR, - ) -> HANDLE; + fn CreateJobObjectW(lpJobAttributes: *const c_void, lpName: LPCWSTR) -> HANDLE; fn SetInformationJobObject( hJob: HANDLE, job_object_info_class: DWORD, lp_job_object_info: LPVOID, cb_job_object_info_length: DWORD, ) -> BOOL; - fn AssignProcessToJobObject( - hJob: HANDLE, - hProcess: HANDLE, - ) -> BOOL; + fn AssignProcessToJobObject(hJob: HANDLE, hProcess: HANDLE) -> BOOL; fn OpenProcess( dw_desired_access: DWORD, b_inherit_handle: BOOL, @@ -456,7 +467,7 @@ fn apply_post_spawn_sandbox( // --------------------------------------------------------------------------- #[cfg(target_os = "linux")] -fn install_seccomp_blacklist() -> Result<(), std::io::Error> { +fn install_seccomp_blacklist() -> std::result::Result<(), std::io::Error> { // Syscall numbers vary by architecture #[cfg(target_arch = "x86_64")] const BLOCKED: &[u32] = &[ @@ -487,29 +498,29 @@ fn install_seccomp_blacklist() -> Result<(), std::io::Error> { #[cfg(target_arch = "aarch64")] const BLOCKED: &[u32] = &[ - 220, // clone + 220, // clone 1079, // fork (aarch64 uses clone) 1080, // vfork - 221, // execve - 129, // kill - 131, // tgkill - 222, // execveat - 436, // clone3 - 198, // socket - 203, // connect - 200, // bind - 201, // listen - 202, // accept + 221, // execve + 129, // kill + 131, // tgkill + 222, // execveat + 436, // clone3 + 198, // socket + 203, // connect + 200, // bind + 201, // listen + 202, // accept 1048, // accept4 - 117, // ptrace - 91, // personality - 192, // init_module - 193, // finit_module - 194, // delete_module - 269, // process_vm_readv - 270, // process_vm_writev - 150, // iopl (not on arm64, but block anyway) - 151, // ioperm + 117, // ptrace + 91, // personality + 192, // init_module + 193, // finit_module + 194, // delete_module + 269, // process_vm_readv + 270, // process_vm_writev + 150, // iopl (not on arm64, but block anyway) + 151, // ioperm ]; let mut filters: Vec = Vec::with_capacity(3 + BLOCKED.len()); diff --git a/src/signatures/mod.rs b/src/signatures/mod.rs index ae5b9b1..d5d8264 100644 --- a/src/signatures/mod.rs +++ b/src/signatures/mod.rs @@ -51,7 +51,10 @@ fn decode_magic(hex: &str) -> Option> { /// Match raw bytes against the signature registry. /// Returns all matching entries (by magic bytes at specified offset). -pub fn match_signatures<'a>(data: &'a [u8], registry: &'a SignatureRegistry) -> Vec<&'a MagicEntry> { +pub fn match_signatures<'a>( + data: &'a [u8], + registry: &'a SignatureRegistry, +) -> Vec<&'a MagicEntry> { registry .signatures .iter() @@ -202,11 +205,17 @@ mod tests { #[test] fn test_category_risk_mapping() { - assert_eq!(category_risk_level("executable"), crate::types::RiskLevel::High); + assert_eq!( + category_risk_level("executable"), + crate::types::RiskLevel::High + ); assert_eq!( category_risk_level("cryptographic"), crate::types::RiskLevel::Critical ); - assert_eq!(category_risk_level("compression"), crate::types::RiskLevel::Low); + assert_eq!( + category_risk_level("compression"), + crate::types::RiskLevel::Low + ); } } diff --git a/src/update.rs b/src/update.rs index 587a5f0..a337c4a 100644 --- a/src/update.rs +++ b/src/update.rs @@ -62,8 +62,9 @@ impl UpdateManager { } if let Some(parent) = self.registry_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| CryptoTraceError::Other(format!("Cannot create registry dir: {}", e)))?; + std::fs::create_dir_all(parent).map_err(|e| { + CryptoTraceError::Other(format!("Cannot create registry dir: {}", e)) + })?; } let content = std::fs::read_to_string(new_registry_path) @@ -92,7 +93,11 @@ impl UpdateManager { /// Apply an update with GPG/Ed25519 signature verification. /// `signature_path` should be a detached Ed25519 signature file (raw 64-byte) /// or a GPG detached signature (`.sig`/`.asc`). - pub fn apply_verified_update(&self, new_registry_path: &Path, signature_path: &Path) -> Result<()> { + pub fn apply_verified_update( + &self, + new_registry_path: &Path, + signature_path: &Path, + ) -> Result<()> { let public_key = self.load_public_key()?; let verified = self.verify_detached(new_registry_path, signature_path, &public_key)?; if !verified { @@ -109,7 +114,12 @@ impl UpdateManager { /// Verify a detached Ed25519 signature on a file using the ring crate. /// `signature_path` must contain the raw 64-byte Ed25519 signature. - pub fn verify_detached(&self, data_path: &Path, signature_path: &Path, public_key: &[u8]) -> Result { + pub fn verify_detached( + &self, + data_path: &Path, + signature_path: &Path, + public_key: &[u8], + ) -> Result { // First, try ring-based Ed25519 verification if let Ok(result) = self.verify_ed25519(data_path, signature_path, public_key) { return Ok(result); @@ -119,7 +129,12 @@ impl UpdateManager { self.verify_gpg(data_path, signature_path) } - fn verify_ed25519(&self, data_path: &Path, signature_path: &Path, public_key: &[u8]) -> Result { + fn verify_ed25519( + &self, + data_path: &Path, + signature_path: &Path, + public_key: &[u8], + ) -> Result { use ring::signature; let data = std::fs::read(data_path) @@ -161,7 +176,10 @@ impl UpdateManager { "GPG not found on system and Ed25519 verification failed".to_string(), )); } - Err(CryptoTraceError::Other(format!("GPG execution error: {}", e))) + Err(CryptoTraceError::Other(format!( + "GPG execution error: {}", + e + ))) } } } @@ -172,8 +190,13 @@ impl UpdateManager { CryptoTraceError::Other("No public key configured for verification".to_string()) })?; - let data = std::fs::read(path) - .map_err(|e| CryptoTraceError::Other(format!("Cannot read public key '{}': {}", path.display(), e)))?; + let data = std::fs::read(path).map_err(|e| { + CryptoTraceError::Other(format!( + "Cannot read public key '{}': {}", + path.display(), + e + )) + })?; Ok(data) } @@ -388,13 +411,17 @@ signatures: [] // Verify using the public key let public_key = key_pair.public_key(); - let result = mgr.verify_detached(&data_path, &sig_path, public_key.as_ref()).unwrap(); + let result = mgr + .verify_detached(&data_path, &sig_path, public_key.as_ref()) + .unwrap(); assert!(result); // Tampered data should fail let tampered_path = dir.path().join("tampered.yaml"); std::fs::write(&tampered_path, b"tampered data").unwrap(); - let result2 = mgr.verify_detached(&tampered_path, &sig_path, public_key.as_ref()).unwrap(); + let result2 = mgr + .verify_detached(&tampered_path, &sig_path, public_key.as_ref()) + .unwrap(); assert!(!result2); } diff --git a/src/workers.rs b/src/workers.rs index 330386e..6e0c87f 100644 --- a/src/workers.rs +++ b/src/workers.rs @@ -12,7 +12,12 @@ impl WorkerPool { } /// Run a parsing operation in an isolated subprocess. - pub fn run_isolated(&self, operation: &str, input: &[u8], timeout: Duration) -> Result> { + pub fn run_isolated( + &self, + operation: &str, + input: &[u8], + timeout: Duration, + ) -> Result> { let mut config = SandboxConfig::default(); config.enabled = true; config.timeout_seconds = timeout.as_secs().max(1); diff --git a/tests/air_gap_test.rs b/tests/air_gap_test.rs index c07a78f..0647114 100644 --- a/tests/air_gap_test.rs +++ b/tests/air_gap_test.rs @@ -7,9 +7,8 @@ /// 2. AI features are disabled by default. /// 3. All cloud-dependent features require explicit configuration. /// 4. A full analysis pipeline completes without any network-dependent code path. - use cryptotrace::analyzers::file::analyze_bytes; -use cryptotrace::types::{SourceType, AppConfig, AiConfig, AiCacheConfig}; +use cryptotrace::types::{AiCacheConfig, AiConfig, AppConfig, SourceType}; #[test] fn test_ai_disabled_by_default() { @@ -28,8 +27,14 @@ fn test_ai_disabled_by_default() { max_entries: 10000, }), }; - assert!(!ai.enabled, "AI must be disabled by default for air-gap compliance"); - assert!(ai.base_url.is_none(), "base_url should be None when AI is disabled"); + assert!( + !ai.enabled, + "AI must be disabled by default for air-gap compliance" + ); + assert!( + ai.base_url.is_none(), + "base_url should be None when AI is disabled" + ); } #[test] @@ -43,12 +48,21 @@ fn test_analysis_completes_without_network() { let result = analyze_bytes(input, SourceType::String).unwrap(); // Verify the result is complete (not truncated due to network failure) - assert!(!result.input_hash.is_empty(), "input hash should be populated"); - assert!(!result.detected_type.is_empty(), "detected type should be populated"); + assert!( + !result.input_hash.is_empty(), + "input hash should be populated" + ); + assert!( + !result.detected_type.is_empty(), + "detected type should be populated" + ); assert!(result.confidence > 0.0, "confidence should be > 0"); // AI narrative should be None (disabled by default) - assert!(result.ai_narrative.is_none(), "AI narrative should be None when AI is disabled"); + assert!( + result.ai_narrative.is_none(), + "AI narrative should be None when AI is disabled" + ); eprintln!( "AIR_GAP: analysis completed with {} layers, ai_narrative={:?}", @@ -63,7 +77,10 @@ fn test_all_network_features_opt_in() { // by default). let config = AppConfig::default(); assert!(!config.ai.enabled, "AI disabled by default"); - assert!(config.ai.base_url.is_none(), "AI base URL should be None by default"); + assert!( + config.ai.base_url.is_none(), + "AI base URL should be None by default" + ); eprintln!( "AIR_GAP: default config — AI={}, base_url={:?}", config.ai.enabled, config.ai.base_url diff --git a/tests/compression_bomb_test.rs b/tests/compression_bomb_test.rs index a35017f..b2b6633 100644 --- a/tests/compression_bomb_test.rs +++ b/tests/compression_bomb_test.rs @@ -3,8 +3,7 @@ /// A compression bomb is an input that decompresses to more than 100× the /// original size (the default MAX_EXPANSION_RATIO). The analyzer should /// return `CryptoTraceError::CompressionBomb` before exhausting memory. - -use cryptotrace::analyzers::recursive::{analyze_recursive, RecursiveConfig}; +use cryptotrace::analyzers::recursive::{RecursiveConfig, analyze_recursive}; use cryptotrace::error::CryptoTraceError; /// Build a small payload that compresses very efficiently (many repeated @@ -26,7 +25,7 @@ fn test_compression_bomb_rejected() { // Use a tight config to ensure we detect the bomb quickly. let config = RecursiveConfig { - max_depth: 2, // only one decompression layer needed + max_depth: 2, // only one decompression layer needed max_time_secs: 10, max_expansion_ratio: 100.0, }; @@ -35,13 +34,11 @@ fn test_compression_bomb_rejected() { match result { Err(CryptoTraceError::CompressionBomb { ratio, limit }) => { - assert!( - ratio > limit, - "expected ratio {} > limit {}", - ratio, - limit + assert!(ratio > limit, "expected ratio {} > limit {}", ratio, limit); + eprintln!( + "COMPRESSION_BOMB: ratio={:.1}x limit={:.0}x — correctly rejected", + ratio, limit ); - eprintln!("COMPRESSION_BOMB: ratio={:.1}x limit={:.0}x — correctly rejected", ratio, limit); } Err(other) => { // Might get RecursionTimeout on slow machines; still a safe failure. @@ -75,7 +72,11 @@ fn test_normal_compression_passes() { let config = RecursiveConfig::default(); let result = analyze_recursive(&compressed, &config); - assert!(result.is_ok(), "normal compression should decode: {:?}", result.err()); + assert!( + result.is_ok(), + "normal compression should decode: {:?}", + result.err() + ); let layers = result.unwrap(); assert!(!layers.is_empty(), "should have at least one layer"); } diff --git a/tests/encoding_accuracy.rs b/tests/encoding_accuracy.rs index 9d28bdb..e3f4c64 100644 --- a/tests/encoding_accuracy.rs +++ b/tests/encoding_accuracy.rs @@ -95,12 +95,14 @@ fn test_base91_accuracy() { #[test] fn test_negative_cases() { - let non_encodings = vec![ - "spaces in text", - "foo\tbar", - ]; + let non_encodings = vec!["spaces in text", "foo\tbar"]; for input in non_encodings { let result = detect_encoding(input); - assert!(result.is_none(), "Should not detect '{}' as encoding. Got: {:?}", input, result.map(|r| r.encoding_type)); + assert!( + result.is_none(), + "Should not detect '{}' as encoding. Got: {:?}", + input, + result.map(|r| r.encoding_type) + ); } } diff --git a/tests/hash_accuracy.rs b/tests/hash_accuracy.rs index b11b3cc..6a42c16 100644 --- a/tests/hash_accuracy.rs +++ b/tests/hash_accuracy.rs @@ -3,11 +3,11 @@ use cryptotrace::core::hashing::detect_hash; #[test] fn test_md5_accuracy() { let cases = vec![ - ("d41d8cd98f00b204e9800998ecf8427e", true), // empty hash + ("d41d8cd98f00b204e9800998ecf8427e", true), // empty hash ("5f4dcc3b5aa765d61d8327deb882cf99", true), // "password" ("900150983cd24fb0d6963f7d28e17f72", true), // "abc" - ("notahash0000000000000000000000000", false), // wrong length - ("00000000000000000000000000000000", true), // all zeros + ("notahash0000000000000000000000000", false), // wrong length + ("00000000000000000000000000000000", true), // all zeros ]; for (input, expected) in cases { let result = detect_hash(input); @@ -21,8 +21,8 @@ fn test_md5_accuracy() { #[test] fn test_sha1_accuracy() { let cases = vec![ - ("da39a3ee5e6b4b0d3255bfef95601890afd80709", true), // empty - ("a9993e364706816aba3e25717850c26c9cd0d89d", true), // "abc" + ("da39a3ee5e6b4b0d3255bfef95601890afd80709", true), // empty + ("a9993e364706816aba3e25717850c26c9cd0d89d", true), // "abc" ("not40characterslongenough1234567890", false), ]; for (input, expected) in cases { @@ -54,7 +54,10 @@ fn test_sha512_accuracy() { fn test_bcrypt_accuracy() { let cases = vec![ ("$2b$12$LJ3m4ys3Lv4S7K7K7K7K7O", true), - ("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", true), + ( + "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + true, + ), ("plaintext", false), ]; for (input, expected) in cases { @@ -91,7 +94,11 @@ fn test_pbkdf2_accuracy() { let result = detect_hash(input); assert_eq!(result.is_some(), expected, "PBKDF2: '{}'", input); if let Some(r) = result { - assert!(r.algorithm.starts_with("PBKDF2-"), "Expected PBKDF2 for '{}'", input); + assert!( + r.algorithm.starts_with("PBKDF2-"), + "Expected PBKDF2 for '{}'", + input + ); } } } diff --git a/tests/memory_stability.rs b/tests/memory_stability.rs index 9348507..967be12 100644 --- a/tests/memory_stability.rs +++ b/tests/memory_stability.rs @@ -11,8 +11,7 @@ /// /// Note: exact heap measurement is platform-specific, so we rely on /// successful completion as a proxy. - -use cryptotrace::analyzers::recursive::{analyze_recursive, RecursiveConfig}; +use cryptotrace::analyzers::recursive::{RecursiveConfig, analyze_recursive}; use cryptotrace::error::Result; /// Build a chain of `depth` nested base64 layers around a tiny core. @@ -21,10 +20,7 @@ use cryptotrace::error::Result; fn build_base64_chain(core: &[u8], depth: usize) -> Vec { let mut current = core.to_vec(); for _ in 0..depth { - let encoded = base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - ¤t, - ); + let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, ¤t); current = encoded.into_bytes(); } current @@ -38,12 +34,7 @@ fn test_10_layer_chain_stays_bounded() -> Result<()> { // To reach ~10 MB at layer 5: core ≈ 10 MB / 4.21 ≈ 2.4 MB. let core = b"This is a test payload with some entropy to look like real data. "; let core_len = 2_400_000; - let core_repeated: Vec = core - .iter() - .copied() - .cycle() - .take(core_len) - .collect(); + let core_repeated: Vec = core.iter().copied().cycle().take(core_len).collect(); // Build 5 layers of nested base64 — outermost should be ~ 10 MB. let outer = build_base64_chain(&core_repeated, 5); diff --git a/tests/sandbox_crash_test.rs b/tests/sandbox_crash_test.rs index efe7e49..87b8618 100644 --- a/tests/sandbox_crash_test.rs +++ b/tests/sandbox_crash_test.rs @@ -8,7 +8,6 @@ /// 1. Enables sandbox with a path to a nonexistent worker binary /// 2. Calls `run_worker` which should fail gracefully /// 3. Confirms the error is a `CryptoTraceError`, not a panic - use cryptotrace::sanitization::sandbox::{Sandbox, SandboxConfig}; use std::path::PathBuf;