"""One-button forensic artifact wipe. Surfaces purged: • %TEMP% — per-user temp • %SystemRoot%\\Prefetch\\*.pf (Admin only) — file-launch history • %APPDATA%\\Microsoft\\Windows\\Recent\\* — recent files • %APPDATA%\\Microsoft\\Windows\\Recent\\AutomaticDestinations\\* — Jump Lists • %APPDATA%\\Microsoft\\Windows\\Recent\\CustomDestinations\\* — Jump Lists • HKCU\\…\\Explorer\\RunMRU — Win+R history • HKCU\\…\\Explorer\\TypedPaths — Explorer typed paths • HKCU\\…\\Explorer\\WordWheelQuery — Start / Explorer search history • HKCU\\…\\Explorer\\RecentDocs — recent docs MRU • Clipboard — current contents Counts are reported but specific filenames are never logged (this is the opposite of what we want to leak). All deletes use ``ignore_errors=True`` because files in use by other apps are expected. """ from __future__ import annotations import ctypes import logging import os import shutil import subprocess import winreg from dataclasses import dataclass, field from pathlib import Path from .firewall import is_admin log = logging.getLogger(__name__) _REG_MRU_KEYS = ( r"Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU", r"Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths", r"Software\Microsoft\Windows\CurrentVersion\Explorer\WordWheelQuery", r"Software\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs", ) @dataclass class WipeReport: files_deleted: int = 0 bytes_freed: int = 0 folders_skipped: int = 0 registry_keys_cleared: int = 0 clipboard_cleared: bool = False errors: list[str] = field(default_factory=list) def summary(self) -> str: mb = self.bytes_freed / (1024 * 1024) return ( f"{self.files_deleted} files / {mb:.1f} MB freed, " f"{self.registry_keys_cleared} MRU entries cleared, " f"clipboard={'yes' if self.clipboard_cleared else 'no'}, " f"errors={len(self.errors)}" ) def _walk_size(path: Path) -> int: total = 0 for p in path.rglob("*"): try: if p.is_file(): total += p.stat().st_size except OSError: pass return total def _purge_dir(path: Path, rep: WipeReport, keep_root: bool = True) -> None: """Delete contents of `path`. Preserves the directory itself when keep_root.""" if not path.exists(): return try: size_before = _walk_size(path) except Exception: size_before = 0 count = 0 for child in path.iterdir(): try: if child.is_dir(): shutil.rmtree(child, ignore_errors=True) else: child.unlink(missing_ok=True) count += 1 except OSError: rep.folders_skipped += 1 if not keep_root: try: path.rmdir() except OSError: pass rep.files_deleted += count try: size_after = _walk_size(path) except Exception: size_after = 0 rep.bytes_freed += max(0, size_before - size_after) def _clear_mru_key(path: str, rep: WipeReport) -> None: """Remove every value under a Run/Typed/Search MRU key.""" try: with winreg.OpenKey( winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_ALL_ACCESS ) as key: count = 0 try: while True: name, _, _ = winreg.EnumValue(key, 0) try: winreg.DeleteValue(key, name) count += 1 except OSError: break except OSError: pass # Recurse into subkeys (e.g. RecentDocs/.png) sub_count = 0 try: while True: sub_count += 1 sub_name = winreg.EnumKey(key, 0) try: winreg.DeleteKey(key, sub_name) except OSError: break if sub_count > 200: # safety cap break except OSError: pass if count or sub_count: rep.registry_keys_cleared += 1 except OSError: pass def _clear_clipboard(rep: WipeReport) -> None: try: user32 = ctypes.windll.user32 # type: ignore[attr-defined] if user32.OpenClipboard(None): try: user32.EmptyClipboard() rep.clipboard_cleared = True finally: user32.CloseClipboard() except Exception as e: rep.errors.append(f"clipboard: {e}") def wipe_artifacts(include_prefetch: bool = True) -> WipeReport: rep = WipeReport() appdata = Path(os.environ.get("APPDATA", "")) if os.environ.get("APPDATA") else None temp = Path(os.environ.get("TEMP", "")) if os.environ.get("TEMP") else None sysroot = Path(os.environ.get("SystemRoot", r"C:\Windows")) # %TEMP% if temp and temp.exists(): _purge_dir(temp, rep, keep_root=True) # Prefetch (Admin) if include_prefetch and is_admin(): pf = sysroot / "Prefetch" if pf.exists(): count = 0 for f in pf.glob("*.pf"): try: sz = f.stat().st_size f.unlink(missing_ok=True) count += 1 rep.bytes_freed += sz except OSError as e: rep.errors.append(f"prefetch: {e}") rep.files_deleted += count # Recent / Jump Lists if appdata: recent = appdata / "Microsoft" / "Windows" / "Recent" if recent.exists(): _purge_dir(recent / "AutomaticDestinations", rep, keep_root=True) _purge_dir(recent / "CustomDestinations", rep, keep_root=True) _purge_dir(recent, rep, keep_root=True) # MRU registry keys for path in _REG_MRU_KEYS: _clear_mru_key(path, rep) # Clipboard _clear_clipboard(rep) # Trigger Explorer's "Clear recent items" via Shell API (covers Win10/11 # Quick Access pinned ↔ recent lists not covered by raw file delete). try: shell32 = ctypes.windll.shell32 # type: ignore[attr-defined] # SHCNE_ASSOCCHANGED tells Explorer to refresh its caches. shell32.SHChangeNotify(0x08000000, 0x0000, None, None) except Exception: pass # Best-effort Defender quick-scan history flush — non-fatal. try: subprocess.run( ["powershell", "-NoProfile", "-NonInteractive", "-Command", "Clear-RecycleBin -Force -ErrorAction SilentlyContinue"], capture_output=True, text=True, timeout=20, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) except Exception: pass return rep