release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
- SA-01: Container-DoS / KDF-Amplification Schutz mit Pre-KDF Validierung, max 2 Slots (nur 0 und 1), Slot 0 Pflicht und strikten BLOB-Laengen - SA-02: Release-Signierung in CI entkoppelt (getrennte build und sign-and-release Jobs, Secret-Isolation) - SA-03: Pinned Download-Integritaet fuer minisign.exe in CI via SHA-256 - SA-04: Immutable Action-Pinning (@sha) und Toolchain-Pinning (1.85.0) in CI - SA-05: Session-Token vollstaendig aus URIs verbannt (403 Forbidden bei Vorkommen im Pfad/Query) - SA-06: Constant-Time Token- und Auth-Vergleiche via subtle::ConstantTimeEq - SA-07: Dokumentations-Klarstellung bzgl. logischem Shredding vs. physischer SSD/FTL/CoW-Persistenz
This commit is contained in:
+159
-36
@@ -141,7 +141,8 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
||||
// Wildcard-Muster: prefix*
|
||||
else if p.ends_with('*') && !p[..p.len() - 1].contains('*') {
|
||||
let prefix = p[..p.len() - 1].to_ascii_lowercase();
|
||||
if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix) {
|
||||
if norm_name.starts_with(&prefix) || norm_rel.to_ascii_lowercase().starts_with(&prefix)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -151,7 +152,9 @@ pub fn is_excluded(name: &str, rel_path: &str, patterns: &[String]) -> bool {
|
||||
if norm_name == p_lower {
|
||||
return true;
|
||||
}
|
||||
if norm_rel.trim_start_matches('/').to_ascii_lowercase() == p_lower.trim_start_matches('/') {
|
||||
if norm_rel.trim_start_matches('/').to_ascii_lowercase()
|
||||
== p_lower.trim_start_matches('/')
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -176,8 +179,8 @@ fn read_chunk_buffer(file: &mut File, buf: &mut [u8]) -> std::io::Result<usize>
|
||||
|
||||
/// Setzt den Modifikationszeitstempel einer lokalen Datei via std::fs::FileTimes.
|
||||
fn set_local_file_mtime(file: &File, mtime_secs: u64) {
|
||||
let times = std::fs::FileTimes::new()
|
||||
.set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs));
|
||||
let times =
|
||||
std::fs::FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(mtime_secs));
|
||||
let _ = file.set_times(times);
|
||||
}
|
||||
|
||||
@@ -206,7 +209,10 @@ pub fn ensure_vault_dir_tree(
|
||||
let children = db.list_children_in_vault(current_id, vault_id, dek)?;
|
||||
if let Some(existing) = children.into_iter().find(|c| c.name == segment) {
|
||||
if !existing.is_dir {
|
||||
bail!("Pfad-Konflikt: '{}' existiert im Container bereits als Datei", segment);
|
||||
bail!(
|
||||
"Pfad-Konflikt: '{}' existiert im Container bereits als Datei",
|
||||
segment
|
||||
);
|
||||
}
|
||||
current_id = existing.id;
|
||||
current_node = existing;
|
||||
@@ -234,8 +240,12 @@ pub fn sync_single_file_to_vault(
|
||||
) -> Result<FileTransferResult> {
|
||||
validate_node_name(file_name)?;
|
||||
|
||||
let meta = fs::metadata(local_path)
|
||||
.with_context(|| format!("Konnte Metadaten für '{}' nicht lesen", local_path.display()))?;
|
||||
let meta = fs::metadata(local_path).with_context(|| {
|
||||
format!(
|
||||
"Konnte Metadaten für '{}' nicht lesen",
|
||||
local_path.display()
|
||||
)
|
||||
})?;
|
||||
let local_size = meta.len();
|
||||
let local_mtime = meta
|
||||
.modified()
|
||||
@@ -250,7 +260,10 @@ pub fn sync_single_file_to_vault(
|
||||
if let Some(ref node) = existing_node {
|
||||
db.assert_not_carrier(node.id)?;
|
||||
if node.is_dir {
|
||||
bail!("Pfad-Konflikt: '{}' existiert im Tresor als Ordner", file_name);
|
||||
bail!(
|
||||
"Pfad-Konflikt: '{}' existiert im Tresor als Ordner",
|
||||
file_name
|
||||
);
|
||||
}
|
||||
|
||||
// Fast Check: Wenn Größe und mtime identisch sind, überspringen (ohne --checksum)
|
||||
@@ -278,7 +291,8 @@ pub fn sync_single_file_to_vault(
|
||||
let node_id = match existing_node {
|
||||
Some(n) => n.id,
|
||||
None => {
|
||||
let new_node = db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?;
|
||||
let new_node =
|
||||
db.create_node_in_vault(vault_id, parent_node_id, file_name, false, dek)?;
|
||||
new_node.id
|
||||
}
|
||||
};
|
||||
@@ -370,8 +384,12 @@ pub fn sync_single_file_to_host(
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let mut out_file = File::create(local_path)
|
||||
.with_context(|| format!("Konnte Zieldatei '{}' nicht erstellen", local_path.display()))?;
|
||||
let mut out_file = File::create(local_path).with_context(|| {
|
||||
format!(
|
||||
"Konnte Zieldatei '{}' nicht erstellen",
|
||||
local_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let total_chunks = if node.size == 0 {
|
||||
0
|
||||
@@ -381,10 +399,22 @@ pub fn sync_single_file_to_host(
|
||||
|
||||
for idx in 0..total_chunks {
|
||||
if let Some(record) = db.read_chunk(node.id, idx)? {
|
||||
let plaintext = decrypt_chunk(dek, node.id, idx, &record.ciphertext, &record.nonce, &record.tag, version)?;
|
||||
let plaintext = decrypt_chunk(
|
||||
dek,
|
||||
node.id,
|
||||
idx,
|
||||
&record.ciphertext,
|
||||
&record.nonce,
|
||||
&record.tag,
|
||||
version,
|
||||
)?;
|
||||
out_file.write_all(&plaintext)?;
|
||||
} else {
|
||||
bail!("Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt", idx, node.name);
|
||||
bail!(
|
||||
"Beschädigte Datei im Tresor: Chunk #{} für Knoten '{}' fehlt",
|
||||
idx,
|
||||
node.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,10 +439,14 @@ pub fn run_sync(
|
||||
|
||||
match options.direction {
|
||||
SyncDirection::Push => {
|
||||
sync_push(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
|
||||
sync_push(
|
||||
db, vault_id, dek, version, source_arg, target_arg, options, &mut stats,
|
||||
)?;
|
||||
}
|
||||
SyncDirection::Pull => {
|
||||
sync_pull(db, vault_id, dek, version, source_arg, target_arg, options, &mut stats)?;
|
||||
sync_pull(
|
||||
db, vault_id, dek, version, source_arg, target_arg, options, &mut stats,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,7 +470,11 @@ fn sync_push(
|
||||
bail!("Lokale Quelle '{}' existiert nicht.", source_str);
|
||||
}
|
||||
|
||||
let target_vault_dir = if target_str.is_empty() { "/" } else { target_str };
|
||||
let target_vault_dir = if target_str.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
target_str
|
||||
};
|
||||
|
||||
if local_source.is_file() {
|
||||
let file_name = local_source
|
||||
@@ -469,14 +507,24 @@ fn sync_push(
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Übertragen: {} ({})", ui::green("[+]"), file_name, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} Übertragen: {} ({})",
|
||||
ui::green("[+]"),
|
||||
file_name,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), file_name, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde übertragen: {} ({})",
|
||||
ui::yellow("[~]"),
|
||||
file_name,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
@@ -556,11 +604,20 @@ fn collect_and_push_dir(
|
||||
if path.is_dir() {
|
||||
// Ordner im Tresor anlegen falls nötig
|
||||
let children = db.list_children_in_vault(current_vault_parent_id, vault_id, dek)?;
|
||||
let sub_dir_node = match children.into_iter().find(|c| c.name == file_name && c.is_dir) {
|
||||
let sub_dir_node = match children
|
||||
.into_iter()
|
||||
.find(|c| c.name == file_name && c.is_dir)
|
||||
{
|
||||
Some(n) => n,
|
||||
None => {
|
||||
if !options.dry_run {
|
||||
db.create_node_in_vault(vault_id, current_vault_parent_id, &file_name, true, dek)?
|
||||
db.create_node_in_vault(
|
||||
vault_id,
|
||||
current_vault_parent_id,
|
||||
&file_name,
|
||||
true,
|
||||
dek,
|
||||
)?
|
||||
} else {
|
||||
// Dummy für dry-run
|
||||
NodeRecord {
|
||||
@@ -605,14 +662,24 @@ fn collect_and_push_dir(
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Übertragen: {} ({})", ui::green("[+]"), rel_path, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} Übertragen: {} ({})",
|
||||
ui::green("[+]"),
|
||||
rel_path,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde übertragen: {} ({})", ui::yellow("[~]"), rel_path, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde übertragen: {} ({})",
|
||||
ui::yellow("[~]"),
|
||||
rel_path,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
@@ -644,7 +711,10 @@ pub fn delete_orphans_in_vault(
|
||||
|
||||
for child in children {
|
||||
// S-03 Carrier Guard: Trägerdatei und übergeordnete Verzeichnisse niemals löschen!
|
||||
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
|
||||
if carrier_id > 0
|
||||
&& (child.id == carrier_id
|
||||
|| db.is_descendant_of(carrier_id, child.id).unwrap_or(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -658,7 +728,11 @@ pub fn delete_orphans_in_vault(
|
||||
stats.files_deleted += 1;
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde aus Tresor löschen: {}", ui::red("[-]"), child_rel);
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde aus Tresor löschen: {}",
|
||||
ui::red("[-]"),
|
||||
child_rel
|
||||
);
|
||||
}
|
||||
} else {
|
||||
db.delete_node(child.id)?;
|
||||
@@ -724,20 +798,34 @@ fn sync_pull(
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), local_file_path.display(), ui::format_bytes(size));
|
||||
println!(
|
||||
" {} Wiederhergestellt: {} ({})",
|
||||
ui::green("[+]"),
|
||||
local_file_path.display(),
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), local_file_path.display(), ui::format_bytes(size));
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde wiederherstellen: {} ({})",
|
||||
ui::yellow("[~]"),
|
||||
local_file_path.display(),
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
stats.files_skipped += 1;
|
||||
if !options.quiet {
|
||||
println!(" {} Aktuell (übersprungen): {}", ui::dim("[=]"), local_file_path.display());
|
||||
println!(
|
||||
" {} Aktuell (übersprungen): {}",
|
||||
ui::dim("[=]"),
|
||||
local_file_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -795,7 +883,10 @@ fn collect_and_pull_dir(
|
||||
|
||||
for child in children {
|
||||
// R-02 Carrier Guard: Trägerdatei niemals auf den Host spiegeln / herausziehen
|
||||
if carrier_id > 0 && (child.id == carrier_id || db.is_descendant_of(carrier_id, child.id).unwrap_or(false)) {
|
||||
if carrier_id > 0
|
||||
&& (child.id == carrier_id
|
||||
|| db.is_descendant_of(carrier_id, child.id).unwrap_or(false))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -816,7 +907,10 @@ fn collect_and_pull_dir(
|
||||
for comp in Path::new(&child_rel).components() {
|
||||
match comp {
|
||||
std::path::Component::Normal(_) => {}
|
||||
_ => bail!("Path traversal Versuch erkannt in relativem Pfad: '{}'", child_rel),
|
||||
_ => bail!(
|
||||
"Path traversal Versuch erkannt in relativem Pfad: '{}'",
|
||||
child_rel
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -824,7 +918,10 @@ fn collect_and_pull_dir(
|
||||
let local_child_path = local_target_base.join(&child_rel.replace('/', "\\"));
|
||||
|
||||
if !local_child_path.starts_with(local_target_base) {
|
||||
bail!("Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus", child_rel);
|
||||
bail!(
|
||||
"Path traversal Versuch erkannt: '{}' bricht aus Zielverzeichnis aus",
|
||||
child_rel
|
||||
);
|
||||
}
|
||||
|
||||
if child.is_dir {
|
||||
@@ -859,14 +956,24 @@ fn collect_and_pull_dir(
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} Wiederhergestellt: {} ({})", ui::green("[+]"), child_rel, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} Wiederhergestellt: {} ({})",
|
||||
ui::green("[+]"),
|
||||
child_rel,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::DryRunTransferred { size } => {
|
||||
stats.files_transferred += 1;
|
||||
stats.bytes_transferred += size;
|
||||
if !options.quiet {
|
||||
println!(" {} [DRY-RUN] Würde wiederherstellen: {} ({})", ui::yellow("[~]"), child_rel, ui::format_bytes(size));
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde wiederherstellen: {} ({})",
|
||||
ui::yellow("[~]"),
|
||||
child_rel,
|
||||
ui::format_bytes(size)
|
||||
);
|
||||
}
|
||||
}
|
||||
FileTransferResult::Skipped { .. } => {
|
||||
@@ -908,7 +1015,11 @@ fn delete_orphans_on_host(
|
||||
if path.is_dir() {
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde lokalen Ordner löschen: {}", ui::red("[-]"), rel_path);
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde lokalen Ordner löschen: {}",
|
||||
ui::red("[-]"),
|
||||
rel_path
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fs::remove_dir_all(&path)?;
|
||||
@@ -919,7 +1030,11 @@ fn delete_orphans_on_host(
|
||||
} else {
|
||||
if dry_run {
|
||||
if !quiet {
|
||||
println!(" {} [DRY-RUN] Würde lokale Datei löschen: {}", ui::red("[-]"), rel_path);
|
||||
println!(
|
||||
" {} [DRY-RUN] Würde lokale Datei löschen: {}",
|
||||
ui::red("[-]"),
|
||||
rel_path
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fs::remove_file(&path)?;
|
||||
@@ -951,10 +1066,18 @@ mod tests {
|
||||
];
|
||||
|
||||
assert!(is_excluded("file.tmp", "sub/file.tmp", &patterns));
|
||||
assert!(is_excluded("download.crdownload", "download.crdownload", &patterns));
|
||||
assert!(is_excluded(
|
||||
"download.crdownload",
|
||||
"download.crdownload",
|
||||
&patterns
|
||||
));
|
||||
assert!(is_excluded("Thumbs.db", "Thumbs.db", &patterns));
|
||||
assert!(is_excluded("backup_2026.tar", "backup_2026.tar", &patterns));
|
||||
assert!(!is_excluded("important.doc", "sub/important.doc", &patterns));
|
||||
assert!(!is_excluded(
|
||||
"important.doc",
|
||||
"sub/important.doc",
|
||||
&patterns
|
||||
));
|
||||
assert!(!is_excluded("video.mp4", "video.mp4", &patterns));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user