From 45572b737055e1f2edc8a0480fc908e590261d76 Mon Sep 17 00:00:00 2001 From: harald Date: Sat, 19 Sep 2026 09:58:52 +0200 Subject: [PATCH] =?UTF-8?q?fix(vfs):=20V-04=20=E2=80=94=20structured=20ant?= =?UTF-8?q?i-leak=20shield=20rules=20and=20custom=20filter=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/carrier.rs | 62 +++++++++-- src/main.rs | 6 ++ src/mount.rs | 33 +++++- src/vfs.rs | 284 +++++++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 355 insertions(+), 30 deletions(-) diff --git a/src/carrier.rs b/src/carrier.rs index 0fd3afd..b5ac0c7 100644 --- a/src/carrier.rs +++ b/src/carrier.rs @@ -23,7 +23,7 @@ use zeroize::{Zeroize, Zeroizing}; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::storage::Database; -use crate::vfs::{is_leak_file, SanctumDirEntry, SanctumMetaData}; +use crate::vfs::{is_leak_file_with_custom, SanctumDirEntry, SanctumMetaData}; pub const CARRIER_MAGIC: &[u8; 8] = b"SANCTCAR"; pub const CARRIER_VERSION: u32 = 1; @@ -271,11 +271,16 @@ pub struct CarrierFsInner { pub dek_inner: Arc>, pub format_version: u32, pub anti_leak: bool, + pub custom_leak_rules: Arc>, + pub leak_counter: Arc, pub manifest: CarrierManifest, pub last_activity: Arc, } impl CarrierFsInner { + pub fn is_leak(&self, name: &str) -> bool { + self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules) + } pub fn save_manifest(&mut self) -> Result<()> { let manifest_bytes = serde_json::to_vec(&self.manifest)?; if manifest_bytes.len() > (crate::crypto::CHUNK_SIZE - 64) { @@ -369,14 +374,15 @@ pub struct CarrierFs { } impl CarrierFs { - /// Lädt ein bestehendes Carrier-Dateisystem aus Block 0 der Trägerdatei. - pub fn load( + /// Lädt ein bestehendes Carrier-Dateisystem mit benutzerdefinierten Anti-Leak-Regeln (V-04). + pub fn load_with_leak_rules( db: Database, carrier_node_id: i64, dek_outer: Arc>, dek_inner: Arc>, format_version: u32, anti_leak: bool, + custom_leak_rules: Vec, ) -> Result { let manifest_bytes = read_carrier_block( &db, @@ -408,6 +414,8 @@ impl CarrierFs { dek_inner, format_version, anti_leak, + custom_leak_rules: Arc::new(custom_leak_rules), + leak_counter: Arc::new(AtomicU64::new(0)), manifest, last_activity: Arc::new(AtomicU64::new(now)), }; @@ -417,8 +425,33 @@ impl CarrierFs { }) } + /// Lädt ein bestehendes Carrier-Dateisystem aus Block 0 der Trägerdatei. + pub fn load( + db: Database, + carrier_node_id: i64, + dek_outer: Arc>, + dek_inner: Arc>, + format_version: u32, + anti_leak: bool, + ) -> Result { + Self::load_with_leak_rules( + db, + carrier_node_id, + dek_outer, + dek_inner, + format_version, + anti_leak, + Vec::new(), + ) + } + + pub fn leak_counter(&self) -> Arc { + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.leak_counter.clone() + } + pub fn last_activity(&self) -> Arc { - let inner = self.inner.lock().unwrap(); + let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); inner.last_activity.clone() } @@ -449,13 +482,14 @@ impl DavFileSystem for CarrierFs { let mut inner = self.inner.lock().unwrap(); let (parent_path, file_name) = inner.split_parent_and_name(&path_str); - if inner.anti_leak && is_leak_file(file_name) { + if inner.is_leak(file_name) { if options.create || options.create_new || options.write || options.append || options.truncate { + inner.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } } @@ -554,7 +588,14 @@ impl DavFileSystem for CarrierFs { .inodes .values() .filter(|child| child.parent_id == Some(node.id)) - .filter(|child| !inner.anti_leak || !is_leak_file(&child.name)) + .filter(|child| { + if inner.is_leak(&child.name) { + inner.leak_counter.fetch_add(1, Ordering::Relaxed); + false + } else { + true + } + }) .map(|child| { Ok(Box::new(SanctumDirEntry { name: child.name.clone(), @@ -601,7 +642,8 @@ impl DavFileSystem for CarrierFs { let mut inner = self.inner.lock().unwrap(); let (parent_path, dir_name) = inner.split_parent_and_name(&path_str); - if inner.anti_leak && is_leak_file(dir_name) { + if inner.is_leak(dir_name) { + inner.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } @@ -712,7 +754,8 @@ impl DavFileSystem for CarrierFs { let node = inner.resolve_path(&from_str).ok_or(FsError::NotFound)?; let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str); - if inner.anti_leak && is_leak_file(to_name) { + if inner.is_leak(to_name) { + inner.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } @@ -776,7 +819,8 @@ impl DavFileSystem for CarrierFs { } let (to_parent_path, to_name) = inner.split_parent_and_name(&to_str); - if inner.anti_leak && is_leak_file(to_name) { + if inner.is_leak(to_name) { + inner.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } diff --git a/src/main.rs b/src/main.rs index 2381c08..34df9a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -110,6 +110,10 @@ enum Commands { #[arg(long, default_value_t = false)] no_anti_leak: bool, + /// Pfad zu einer Textdatei mit zusätzlichen Anti-Leak Filterregeln (V-04) + #[arg(long, value_name = "FILE")] + anti_leak_list: Option, + /// Aktiviert den lautlosen Stealth-Modus (keine Banner, Pfade, URLs oder Fortschrittsausgaben) #[arg(short, long, default_value_t = false)] stealth: bool, @@ -1618,6 +1622,7 @@ async fn run() -> Result<()> { idle_timeout, no_screen_lock, no_anti_leak, + anti_leak_list, stealth, } => { let drive_char = match drive { @@ -1670,6 +1675,7 @@ async fn run() -> Result<()> { idle_timeout, lock_on_screen_lock, anti_leak, + anti_leak_list.as_deref(), stealth, ) .await?; diff --git a/src/mount.rs b/src/mount.rs index a830b8c..86c3499 100644 --- a/src/mount.rs +++ b/src/mount.rs @@ -84,6 +84,7 @@ pub async fn mount_container( idle_timeout: Option, lock_on_screen_lock: bool, anti_leak: bool, + anti_leak_list: Option<&Path>, stealth: bool, ) -> Result<()> { let drive_str = format_drive(drive_letter); @@ -191,16 +192,38 @@ pub async fn mount_container( rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token_bytes); let session_token = hex::encode(token_bytes); + // Benutzerdefinierte Anti-Leak Filterregeln laden (V-04) + let custom_leak_rules = if let Some(path) = anti_leak_list { + match crate::vfs::load_anti_leak_list(path) { + Ok(rules) => { + if !stealth { + println!( + " • Anti-Leak Liste: {} benutzerdefinierte Regeln geladen", + rules.len() + ); + } + rules + } + Err(e) => { + bail!("Fehler beim Laden der Anti-Leak-Regeln: {e}"); + } + } + } else { + Vec::new() + }; + // WebDAV Filesystem und Handler konfigurieren (mit Anti-Leak Shield & Carrier-Routing) - let fs = SanctumFs::with_carrier( + let fs = SanctumFs::with_carrier_and_leak_rules( db.clone(), dek, carrier_dek, carrier_node_id, version, anti_leak, + custom_leak_rules, vault_id, ); + let fs_leak_counter = fs.leak_counter(); let last_activity = fs.last_activity(); let dav_server = DavHandler::builder() .filesystem(Box::new(fs)) @@ -584,6 +607,14 @@ pub async fn mount_container( println!("{}", ui::green("OK")); } + let blocked_leaks = fs_leak_counter.load(Ordering::Relaxed); + if blocked_leaks > 0 { + println!( + " • Anti-Leak Shield: {} Zugriffe auf Metadaten-/Leak-Dateien blockiert.", + ui::cyan(&blocked_leaks.to_string()) + ); + } + println!(); println!( "{} Sanctum Container wurde sicher und vollständig geschlossen.", diff --git a/src/vfs.rs b/src/vfs.rs index b91ba7e..c593804 100644 --- a/src/vfs.rs +++ b/src/vfs.rs @@ -4,6 +4,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::path::Path; +use anyhow::{Context, Result}; use bytes::{Buf, Bytes, BytesMut}; use dav_server::{ davpath::DavPath, @@ -20,23 +22,128 @@ use crate::carrier::CarrierFs; use crate::crypto::{decrypt_chunk, encrypt_chunk, CHUNK_SIZE}; use crate::storage::{Database, NodeRecord}; -/// Prüft, ob ein Dateiname zu den typischen Windows Explorer Metadaten-, Cache- -/// oder Thumbnail-Dateien gehört (z. B. Thumbs.db, desktop.ini), die standardmäßig -/// im Container blockiert und verborgen werden (Anti-Leak Shield). +/// Exakte Namen von Explorer-, OS- und Desktop-Metadaten (V-04). +pub const LEAK_EXACT_NAMES: &[&str] = &[ + "thumbs.db", + "ehthumbs.db", + "ehthumbs_vista.db", + "desktop.ini", + "folder.jpg", + "albumartsmall.jpg", + "autorun.inf", + ".ds_store", + ".directory", + ".fseventsd", + ".spotlight-v100", +]; + +/// Präfixe bekannter temporärer Metadaten und Lock-Dateien (V-04). +pub const LEAK_PREFIXES: &[&str] = &[ + "~$", // MS Office temporäre Lock-Dateien (z. B. ~$Document.docx) + "._", // macOS AppleDouble Metadaten-Dateien (z. B. ._Document.pdf) +]; + +/// Suffixe und Dateiendungen temporärer Caches und unvollständiger Downloads (V-04). +pub const LEAK_SUFFIXES: &[&str] = &[ + ".tmp", + ".temp", + ".crdownload", // Google Chrome temporärer Download + ".part", // Mozilla Firefox unvollständiger Download + ".partial", + "~", // Linux/UNIX Editor-Backups (Vim, Emacs, Gedit) +]; + +/// Prüft, ob ein Dateiname zu den typischen Windows Explorer-, OS- oder Anwendungs- +/// Metadaten-, Cache- oder Lock-Dateien gehört, die standardmäßig im Container +/// blockiert und verborgen werden (Anti-Leak Shield, V-04). pub fn is_leak_file(filename: &str) -> bool { - let lower = filename.trim().to_ascii_lowercase(); - match lower.as_str() { - "thumbs.db" | "ehthumbs.db" | "ehthumbs_vista.db" | "desktop.ini" | "folder.jpg" - | "albumartsmall.jpg" | "autorun.inf" | ".ds_store" => true, - _ => { - if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini")) - { - true - } else { - false - } + is_leak_file_with_custom(filename, &[]) +} + +/// Prüft, ob ein Dateiname gemäß der Standardregeln oder benutzerdefinierten Regeln (V-04) +/// als Leak-Datei blockiert werden soll. +pub fn is_leak_file_with_custom(filename: &str, custom_rules: &[String]) -> bool { + let trimmed = filename.trim(); + if trimmed.is_empty() { + return false; + } + + // 1. NTFS Alternate Data Streams (ADS) wie "file.txt:Zone.Identifier" + if trimmed.contains(':') { + return true; + } + + let lower = trimmed.to_ascii_lowercase(); + + // 2. Exakte Namen + for &exact in LEAK_EXACT_NAMES { + if lower == exact { + return true; } } + + // 3. Präfixe + for &prefix in LEAK_PREFIXES { + if lower.starts_with(prefix) { + return true; + } + } + + // 4. Suffixe + for &suffix in LEAK_SUFFIXES { + if lower.ends_with(suffix) { + return true; + } + } + + // 5. Spezielle Muster + if lower.starts_with("albumart") && (lower.ends_with(".jpg") || lower.ends_with(".ini")) { + return true; + } + if lower.starts_with(".trash") { + return true; + } + + // 6. Benutzerdefinierte Regeln + for custom in custom_rules { + let pat = custom.trim().to_ascii_lowercase(); + if pat.is_empty() || pat.starts_with('#') { + continue; + } + if pat.starts_with('*') && pat.ends_with('*') && pat.len() > 2 { + let sub = &pat[1..pat.len() - 1]; + if lower.contains(sub) { + return true; + } + } else if pat.starts_with('*') { + if lower.ends_with(&pat[1..]) { + return true; + } + } else if pat.ends_with('*') { + if lower.starts_with(&pat[..pat.len() - 1]) { + return true; + } + } else if lower == pat { + return true; + } + } + + false +} + +/// Lädt eine benutzerdefinierte Liste von Anti-Leak Filterregeln aus einer Textdatei (V-04). +pub fn load_anti_leak_list>(path: P) -> Result> { + let p = path.as_ref(); + let content = std::fs::read_to_string(p) + .with_context(|| format!("Konnte Anti-Leak-Listendatei '{}' nicht lesen", p.display()))?; + let mut rules = Vec::new(); + for line in content.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() && !trimmed.starts_with('#') { + rules.push(trimmed.to_string()); + } + } + Ok(rules) } // --------------------------------------------------------------------------- @@ -516,6 +623,8 @@ pub struct SanctumFs { carrier_fs: Option, format_version: u32, anti_leak: bool, + custom_leak_rules: Arc>, + leak_counter: Arc, vault_id: u32, last_activity: Arc, } @@ -534,6 +643,25 @@ impl SanctumFs { Self::with_vault(db, dek, format_version, anti_leak, 0) } + pub fn with_options_and_leak_rules( + db: Database, + dek: Zeroizing<[u8; 32]>, + format_version: u32, + anti_leak: bool, + custom_leak_rules: Vec, + ) -> Self { + Self::with_carrier_and_leak_rules( + db, + dek, + None, + None, + format_version, + anti_leak, + custom_leak_rules, + 0, + ) + } + pub fn with_vault( db: Database, dek: Zeroizing<[u8; 32]>, @@ -552,6 +680,28 @@ impl SanctumFs { format_version: u32, anti_leak: bool, vault_id: u32, + ) -> Self { + Self::with_carrier_and_leak_rules( + db, + dek, + carrier_dek, + carrier_node_id, + format_version, + anti_leak, + Vec::new(), + vault_id, + ) + } + + pub fn with_carrier_and_leak_rules( + db: Database, + dek: Zeroizing<[u8; 32]>, + carrier_dek: Option>, + carrier_node_id: Option, + format_version: u32, + anti_leak: bool, + custom_leak_rules: Vec, + vault_id: u32, ) -> Self { let now = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -574,13 +724,14 @@ impl SanctumFs { let carrier_fs = if vault_id == 1 { if let (Some(ref c_dek), Some(c_nid)) = (&carrier_dek_guard, carrier_node_id) { - match CarrierFs::load( + match CarrierFs::load_with_leak_rules( db.clone(), c_nid, Arc::new((*c_dek.key()).clone()), Arc::new((*dek_guard.key()).clone()), format_version, anti_leak, + custom_leak_rules.clone(), ) { Ok(cfs) => Some(cfs), Err(e) => { @@ -603,6 +754,8 @@ impl SanctumFs { carrier_fs, format_version, anti_leak, + custom_leak_rules: Arc::new(custom_leak_rules), + leak_counter: Arc::new(AtomicU64::new(0)), vault_id, last_activity: Arc::new(AtomicU64::new(now)), } @@ -628,6 +781,22 @@ impl SanctumFs { self.anti_leak } + pub fn leak_counter(&self) -> Arc { + if let Some(ref cfs) = self.carrier_fs { + cfs.leak_counter() + } else { + self.leak_counter.clone() + } + } + + pub fn leak_count(&self) -> u64 { + self.leak_counter().load(Ordering::Relaxed) + } + + pub fn is_leak(&self, name: &str) -> bool { + self.anti_leak && is_leak_file_with_custom(name, &self.custom_leak_rules) + } + pub fn touch(&self) { if let Some(ref cfs) = self.carrier_fs { cfs.touch(); @@ -713,13 +882,14 @@ impl DavFileSystem for SanctumFs { let (parent_path, file_name) = self.split_parent_and_name(&path_str); // Anti-Leak Shield: Blockiere Schreib- oder Neuerstellungsversuche für Explorer-Metadaten - if self.anti_leak && is_leak_file(file_name) { + if self.is_leak(file_name) { if options.create || options.create_new || options.write || options.append || options.truncate { + self.leak_counter.fetch_add(1, Ordering::Relaxed); debug!( "Anti-Leak: Blockiere Erstellung/Schreibzugriff für '{}'", file_name @@ -821,7 +991,14 @@ impl DavFileSystem for SanctumFs { let entries: Vec, FsError>> = children .into_iter() - .filter(|child| !self.anti_leak || !is_leak_file(&child.name)) + .filter(|child| { + if self.is_leak(&child.name) { + self.leak_counter.fetch_add(1, Ordering::Relaxed); + false + } else { + true + } + }) .map(|child| { Ok(Box::new(SanctumDirEntry { name: child.name, @@ -876,7 +1053,8 @@ impl DavFileSystem for SanctumFs { let path_str = Self::path_to_str(path); let (parent_path, dir_name) = self.split_parent_and_name(&path_str); - if self.anti_leak && is_leak_file(dir_name) { + if self.is_leak(dir_name) { + self.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } @@ -980,7 +1158,8 @@ impl DavFileSystem for SanctumFs { let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); - if self.anti_leak && is_leak_file(to_name) { + if self.is_leak(to_name) { + self.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } @@ -1039,7 +1218,8 @@ impl DavFileSystem for SanctumFs { let (to_parent_path, to_name) = self.split_parent_and_name(&to_str); - if self.anti_leak && is_leak_file(to_name) { + if self.is_leak(to_name) { + self.leak_counter.fetch_add(1, Ordering::Relaxed); return Err(FsError::Forbidden); } @@ -1164,6 +1344,7 @@ mod tests { #[test] fn test_is_leak_file() { + // Exakte Namen assert!(is_leak_file("Thumbs.db")); assert!(is_leak_file("thumbs.db")); assert!(is_leak_file("THUMBS.DB")); @@ -1177,12 +1358,75 @@ mod tests { assert!(is_leak_file("AlbumArt_{12345}_Small.jpg")); assert!(is_leak_file("autorun.inf")); assert!(is_leak_file(".ds_store")); + assert!(is_leak_file(".DS_Store")); + assert!(is_leak_file(".directory")); + assert!(is_leak_file(".fseventsd")); + assert!(is_leak_file(".spotlight-v100")); + + // Präfixe (V-04) + assert!(is_leak_file("~$MyDocument.docx")); + assert!(is_leak_file("._Document.pdf")); + + // Suffixe (V-04) + assert!(is_leak_file("temp_file.tmp")); + assert!(is_leak_file("cache.temp")); + assert!(is_leak_file("video.crdownload")); + assert!(is_leak_file("archive.tar.gz.part")); + assert!(is_leak_file("bigfile.partial")); + assert!(is_leak_file("notes.txt~")); + + // NTFS Alternate Data Streams (ADS, V-04) + assert!(is_leak_file("document.pdf:Zone.Identifier")); + assert!(is_leak_file("file.exe:$DATA")); + + // Wildcards (.trash*, V-04) + assert!(is_leak_file(".trash")); + assert!(is_leak_file(".Trash-1000")); + assert!(is_leak_file(".trashes")); // Harmlos: assert!(!is_leak_file("secret.txt")); assert!(!is_leak_file("passwords.kdbx")); assert!(!is_leak_file("my_folder.jpg.txt")); assert!(!is_leak_file("desktop_notes.ini.bak")); + assert!(!is_leak_file("temp_report.docx")); + assert!(!is_leak_file("part1_chapter.txt")); + } + + #[test] + fn test_v04_custom_anti_leak_rules_and_loader() { + let custom_rules = vec![ + "*.secret_log".to_string(), + "debug_*".to_string(), + "*_temp_*".to_string(), + "custom_exact.bin".to_string(), + ]; + + assert!(is_leak_file_with_custom("audit.secret_log", &custom_rules)); + assert!(is_leak_file_with_custom("debug_dump.txt", &custom_rules)); + assert!(is_leak_file_with_custom("app_temp_cache.dat", &custom_rules)); + assert!(is_leak_file_with_custom("custom_exact.bin", &custom_rules)); + + // Normale Datei wird nicht blockiert + assert!(!is_leak_file_with_custom("regular_file.txt", &custom_rules)); + + // Test load_anti_leak_list + let temp_dir = tempfile::tempdir().unwrap(); + let rule_file = temp_dir.path().join("anti_leak_rules.txt"); + std::fs::write( + &rule_file, + "# Kommentarzeile\n*.bak\n\n # Noch ein Kommentar\nprivate_*\n", + ) + .unwrap(); + + let loaded = load_anti_leak_list(&rule_file).unwrap(); + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[0], "*.bak"); + assert_eq!(loaded[1], "private_*"); + + assert!(is_leak_file_with_custom("data.bak", &loaded)); + assert!(is_leak_file_with_custom("private_keys.pem", &loaded)); + assert!(!is_leak_file_with_custom("public_data.txt", &loaded)); } fn create_test_fs(anti_leak: bool) -> (SanctumFs, tempfile_placeholder::TempDir) {