From a41ff2da2d1bc7533def544c808b7e2bf1967719 Mon Sep 17 00:00:00 2001 From: Tim Date: Sat, 30 May 2026 01:55:38 +0000 Subject: [PATCH] feat(quarantine): contain directory-shaped findings (Chrome extensions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scanner/browser_scanner.py emits Chrome/Edge extension findings with path = ext_id_dir — the extension's directory under %LocalAppData%\Google\Chrome\User Data\\Extensions\\. AutomatedResponseService gated on File.Exists, so these findings silently skipped with 'Quarantine skipped (file missing)' and the malicious extension stayed installed. QuarantineService now handles directory sources alongside files: - QuarantineFile detects Directory.Exists and routes to a new QuarantineDirectory helper. The directory is zipped into the vault with the .zip suffix on the vault filename, then the source is removed via recursive delete. - When the source can't be recursively deleted (Chrome still has the extension files open), fall back to Directory.Move into a parked name (.wraith-quarantined-<8-hex>) and schedule each remaining file for delete-on-reboot. The rename alone is enough to break the extension on Chrome's next scan — Chrome looks up by path, and the path is gone. - If even the rename fails, the vault copy is rolled back and a clear error tells the user to close the holding process. - QuarantineRecord gains an IsDirectory flag so the JSON round-trip preserves the entry type. - Restore extracts the zip back to OriginalPath (or a _restored_- suffixed sibling if the path is now occupied). DeleteFromVault is unchanged — the zip is just another file in the vault. - AutomatedResponseService gate now allows Directory.Exists, with a clearer skip message for registry-path findings (HKLM\..., etc.) which the vault legitimately can't contain. - QuarantineWindow.ImportFiles also accepts dropped folders. --- WRAITH/QuarantineWindow.xaml.cs | 2 +- WRAITH/Services/AutomatedResponseService.cs | 8 +- WRAITH/Services/QuarantineService.cs | 160 ++++++++++++++++---- 3 files changed, 138 insertions(+), 32 deletions(-) diff --git a/WRAITH/QuarantineWindow.xaml.cs b/WRAITH/QuarantineWindow.xaml.cs index 8596959..927b942 100644 --- a/WRAITH/QuarantineWindow.xaml.cs +++ b/WRAITH/QuarantineWindow.xaml.cs @@ -221,7 +221,7 @@ private void ImportFiles(IEnumerable paths) { try { - if (!System.IO.File.Exists(path)) + if (!System.IO.File.Exists(path) && !System.IO.Directory.Exists(path)) { failed++; continue; diff --git a/WRAITH/Services/AutomatedResponseService.cs b/WRAITH/Services/AutomatedResponseService.cs index 5bb1eee..38cd7c8 100644 --- a/WRAITH/Services/AutomatedResponseService.cs +++ b/WRAITH/Services/AutomatedResponseService.cs @@ -168,9 +168,13 @@ public async Task ApplyAsync(IEnumerable continue; } - if (!File.Exists(containmentPath)) + // Directory-shaped findings (Chrome extensions, etc.) come through as a + // folder path; QuarantineService handles those by zipping the tree. + // Registry-path findings (HKLM\..., HKCU\...) have neither File nor Directory + // and aren't containable through the vault — they need a different code path. + if (!File.Exists(containmentPath) && !Directory.Exists(containmentPath)) { - report.Messages.Add($"Quarantine skipped (file missing): {containmentPath}"); + report.Messages.Add($"Quarantine skipped (path not on disk): {containmentPath}"); continue; } diff --git a/WRAITH/Services/QuarantineService.cs b/WRAITH/Services/QuarantineService.cs index 4c95c5e..6560b14 100644 --- a/WRAITH/Services/QuarantineService.cs +++ b/WRAITH/Services/QuarantineService.cs @@ -1,3 +1,4 @@ +using System.IO.Compression; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Security.Principal; @@ -19,6 +20,8 @@ public sealed class QuarantineRecord public bool Restored { get; set; } /// True when the original file was locked and is scheduled for deletion at next boot. public bool PendingRebootDelete { get; set; } + /// True when the source was a directory; the vault entry is a zip archive. + public bool IsDirectory { get; set; } public string Severity { get; set; } = "Info"; // Critical, High, Medium, Low, Info /// Computed lifecycle state for the UI — never serialised. @@ -71,44 +74,58 @@ public QuarantineRecord QuarantineFile(string filePath, string reason, string se if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException("file path is required", nameof(filePath)); var sourcePath = NormalizeFullPath(filePath); - if (!File.Exists(sourcePath)) + + var isDirectory = Directory.Exists(sourcePath); + if (!isDirectory && !File.Exists(sourcePath)) throw new FileNotFoundException("File not found", sourcePath); lock (_sync) { var id = Guid.NewGuid().ToString("N"); - var fileName = Path.GetFileName(sourcePath); - var safeName = $"{DateTime.UtcNow:yyyyMMdd_HHmmss}_{id}_{fileName}"; + var leafName = Path.GetFileName(sourcePath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + // Directory entries land as zip archives in the vault. The .zip suffix + // lets the user (and any later tooling) tell at a glance that this + // record is a packaged directory, not a single file. + var safeName = isDirectory + ? $"{DateTime.UtcNow:yyyyMMdd_HHmmss}_{id}_{leafName}.zip" + : $"{DateTime.UtcNow:yyyyMMdd_HHmmss}_{id}_{leafName}"; var dest = NormalizeFullPath(Path.Combine(_vaultDir, safeName)); if (!IsUnderRoot(dest, _vaultDir)) throw new InvalidOperationException("Resolved quarantine destination is outside the vault directory."); bool pendingReboot = false; - try + if (isDirectory) { - File.Move(sourcePath, dest); + QuarantineDirectory(sourcePath, dest); } - catch (IOException) + else { - // Source is locked (mapped into a running process, AV scanner open handle, etc.). - // Copy the bytes into the vault so we can still hash/contain them, then schedule - // the original for deletion at next reboot. Without this fallback the most - // important case (active malware) just fails outright. - CopyWithSharedAccess(sourcePath, dest); - - if (!TryDeleteFile(sourcePath)) + try + { + File.Move(sourcePath, dest); + } + catch (IOException) { - if (!MoveFileEx(sourcePath, null, MOVEFILE_DELAY_UNTIL_REBOOT)) + // Source is locked (mapped into a running process, AV scanner open handle, etc.). + // Copy the bytes into the vault so we can still hash/contain them, then schedule + // the original for deletion at next reboot. Without this fallback the most + // important case (active malware) just fails outright. + CopyWithSharedAccess(sourcePath, dest); + + if (!TryDeleteFile(sourcePath)) { - var err = Marshal.GetLastWin32Error(); - // Vault copy succeeded but we couldn't even schedule the delete. - // Roll back the vault copy so we don't leak duplicates. - TryDeleteFile(dest); - throw new IOException( - $"File is locked and could not be scheduled for reboot deletion (Win32 error {err})."); + if (!MoveFileEx(sourcePath, null, MOVEFILE_DELAY_UNTIL_REBOOT)) + { + var err = Marshal.GetLastWin32Error(); + // Vault copy succeeded but we couldn't even schedule the delete. + // Roll back the vault copy so we don't leak duplicates. + TryDeleteFile(dest); + throw new IOException( + $"File is locked and could not be scheduled for reboot deletion (Win32 error {err})."); + } + pendingReboot = true; } - pendingReboot = true; } } @@ -123,6 +140,7 @@ public QuarantineRecord QuarantineFile(string filePath, string reason, string se Deleted = false, Restored = false, PendingRebootDelete = pendingReboot, + IsDirectory = isDirectory, Severity = severity, }; @@ -133,6 +151,56 @@ public QuarantineRecord QuarantineFile(string filePath, string reason, string se } } + /// + /// Zips a directory into the vault, then removes the original. Handles Chrome + /// extensions and other directory-shaped findings. Falls back to renaming the + /// source out of place when recursive delete fails (e.g. Chrome holding files + /// open) — that's enough to neutralise the extension on Chrome's next scan. + /// + private static void QuarantineDirectory(string sourceDir, string destZip) + { + // Zip first. If this throws (permission, disk full, etc.) the source + // remains untouched. + ZipFile.CreateFromDirectory(sourceDir, destZip, + CompressionLevel.Optimal, includeBaseDirectory: true); + + try + { + Directory.Delete(sourceDir, recursive: true); + return; + } + catch (IOException) { /* fall through to rename neutralisation */ } + catch (UnauthorizedAccessException) { /* fall through */ } + + // Couldn't delete (Chrome/other process has files open). Rename the + // root out of place so the application no longer finds the extension. + // Open file handles inside the directory continue to work via the OS's + // existing references, but no new opens via the original path succeed. + var parked = sourceDir + ".wraith-quarantined-" + Guid.NewGuid().ToString("N").Substring(0, 8); + try + { + Directory.Move(sourceDir, parked); + // Schedule the parked tree for delete-on-reboot, file by file. + // Best-effort: if any file can't be scheduled, leave it — the + // rename alone has already broken the extension's discovery path. + foreach (var f in Directory.EnumerateFiles(parked, "*", SearchOption.AllDirectories)) + { + if (!TryDeleteFile(f)) + MoveFileEx(f, null, MOVEFILE_DELAY_UNTIL_REBOOT); + } + } + catch + { + // Rename failed too — the vault still has the zip, but the + // original directory is intact and the extension may still load. + // Surface a clear error so the caller knows containment is partial. + TryDeleteFile(destZip); + throw new IOException( + $"Directory '{sourceDir}' is in use and could not be moved or renamed. " + + "Close the process holding it (e.g. quit Chrome) and try again."); + } + } + public bool Restore(string id, out string restoredPath) { restoredPath = string.Empty; @@ -160,18 +228,52 @@ public bool Restore(string id, out string restoredPath) if (string.IsNullOrWhiteSpace(targetDir)) return false; Directory.CreateDirectory(targetDir); - var target = originalPath; - if (File.Exists(target)) + if (rec.IsDirectory) { - var baseName = Path.GetFileNameWithoutExtension(target); - var ext = Path.GetExtension(target); - target = Path.Combine(targetDir, $"{baseName}_restored_{DateTime.Now:yyyyMMdd_HHmmss}{ext}"); + // Vault entry is a zip — extract back to OriginalPath (or a sibling + // if the original location now exists). The zip was created with + // includeBaseDirectory=true so ExtractToDirectory(targetDir,...) + // recreates the leaf folder name automatically. + var target = originalPath; + if (Directory.Exists(target) || File.Exists(target)) + { + target = Path.Combine(targetDir, + Path.GetFileName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) + + $"_restored_{DateTime.Now:yyyyMMdd_HHmmss}"); + } + + // ExtractToDirectory recreates the included base folder inside targetDir, + // so we extract into the parent and let the zip place the leaf folder. + ZipFile.ExtractToDirectory(quarantinedPath, targetDir); + + // The above places the leaf at originalPath. If we need the + // _restored_ suffix because originalPath already existed, + // rename the extracted leaf into place. + if (!string.Equals(target, originalPath, StringComparison.OrdinalIgnoreCase) + && Directory.Exists(originalPath)) + { + Directory.Move(originalPath, target); + } + + File.Delete(quarantinedPath); + restoredPath = target; } + else + { + var target = originalPath; + if (File.Exists(target)) + { + var baseName = Path.GetFileNameWithoutExtension(target); + var ext = Path.GetExtension(target); + target = Path.Combine(targetDir, $"{baseName}_restored_{DateTime.Now:yyyyMMdd_HHmmss}{ext}"); + } - target = NormalizeFullPath(target); + target = NormalizeFullPath(target); + + File.Move(quarantinedPath, target); + restoredPath = target; + } - File.Move(quarantinedPath, target); - restoredPath = target; rec.QuarantinedPath = string.Empty; rec.Restored = true; SaveIndex(index);