fix(vfs): V-04 — structured anti-leak shield rules and custom filter list

This commit is contained in:
2026-09-19 09:58:52 +02:00
parent 17908a64fe
commit 45572b7370
4 changed files with 355 additions and 30 deletions
+53 -9
View File
@@ -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<Zeroizing<[u8; 32]>>,
pub format_version: u32,
pub anti_leak: bool,
pub custom_leak_rules: Arc<Vec<String>>,
pub leak_counter: Arc<AtomicU64>,
pub manifest: CarrierManifest,
pub last_activity: Arc<AtomicU64>,
}
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<Zeroizing<[u8; 32]>>,
dek_inner: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
anti_leak: bool,
custom_leak_rules: Vec<String>,
) -> Result<Self> {
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<Zeroizing<[u8; 32]>>,
dek_inner: Arc<Zeroizing<[u8; 32]>>,
format_version: u32,
anti_leak: bool,
) -> Result<Self> {
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<AtomicU64> {
let inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
inner.leak_counter.clone()
}
pub fn last_activity(&self) -> Arc<AtomicU64> {
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);
}
+6
View File
@@ -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<PathBuf>,
/// 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?;
+32 -1
View File
@@ -84,6 +84,7 @@ pub async fn mount_container(
idle_timeout: Option<u64>,
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.",
+263 -19
View File
@@ -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 {
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<P: AsRef<Path>>(path: P) -> Result<Vec<String>> {
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<CarrierFs>,
format_version: u32,
anti_leak: bool,
custom_leak_rules: Arc<Vec<String>>,
leak_counter: Arc<AtomicU64>,
vault_id: u32,
last_activity: Arc<AtomicU64>,
}
@@ -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<String>,
) -> 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<Zeroizing<[u8; 32]>>,
carrier_node_id: Option<i64>,
format_version: u32,
anti_leak: bool,
custom_leak_rules: Vec<String>,
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<AtomicU64> {
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<Result<Box<dyn DavDirEntry>, 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) {