release: v0.7.2 — Security Audit Remediation (SA-01 bis SA-07)
Sanctum Release / Build & Test (Windows x86_64) (push) Canceled after 0s
Sanctum Release / Sign & Release (push) Canceled after 0s

- 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:
2026-09-18 23:40:35 +02:00
parent 1cdb30147b
commit fba7f305e3
28 changed files with 3048 additions and 968 deletions
+159 -36
View File
@@ -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));
}
}