Created
April 9, 2026 11:59
-
-
Save turicas/145a0ed8a084396f4f0c0a6f6d791c98 to your computer and use it in GitHub Desktop.
Securely delete a list of files/directories
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Securely delete directories, making file recovery via software tools (testdisk, photorec, extundelete etc.) | |
| impractical. | |
| Per-file strategy: | |
| 1. Overwrite contents 3x (zeros, ones, random), writing at least MIN_OVERWRITE_BYTES even for small files | |
| 2. Truncate to 0 bytes | |
| 3. Rename to a random name (destroys original directory entry) | |
| 4. Unlink | |
| Then removes empty directories bottom-up. | |
| Limitations: | |
| - On SSDs with wear leveling, data may persist in reallocated NAND blocks (unrecoverable by software, but accessible | |
| via hardware forensics). For SSDs, prefer `blkdiscard` or ATA Secure Erase on the whole disk when possible. | |
| - Does not erase filesystem journal residue, swap, or btrfs/zfs snapshots. After running, wipe free space with: | |
| dd if=/dev/zero of=/tmp/zero status=progress oflag=sync bs=1M; rm /tmp/zero | |
| Usage: | |
| sudo python3 secure_delete.py /path/to/wipe /another/path/to/wipe | |
| python3 secure_delete.py --test # Run automated tests | |
| """ | |
| import argparse | |
| import os | |
| import stat | |
| import sys | |
| import tempfile | |
| from pathlib import Path | |
| PASSES: list[tuple[str, bytes | None]] = [ | |
| ("zeros", b"\x00"), | |
| ("ones", b"\xff"), | |
| ("random", None), | |
| ] | |
| BLOCK_SIZE = 4096 | |
| MIN_OVERWRITE_BYTES = 1024 * 1024 # 1 MiB | |
| RENAME_LENGTH = 16 | |
| DANGEROUS_PATHS = {"/", "/root", "/etc", "/var", "/usr", "/bin", "/sbin", "/boot", "/dev", "/proc", "/sys"} | |
| def overwrite_file(path: Path, *, dry_run: bool = False) -> None: | |
| """Overwrite file contents with multiple passes, writing at least MIN_OVERWRITE_BYTES.""" | |
| try: | |
| original_size = path.stat().st_size | |
| except OSError as e: | |
| print(f" ERROR reading size of {path}: {e}", file=sys.stderr) | |
| return | |
| write_size = max(original_size, MIN_OVERWRITE_BYTES) | |
| if dry_run: | |
| print(f" [dry-run] Would overwrite {path} ({original_size} bytes) with {len(PASSES)} passes of {write_size} bytes") | |
| return | |
| try: | |
| path.chmod(stat.S_IRUSR | stat.S_IWUSR) | |
| except OSError: | |
| pass | |
| for _pass_name, fill_byte in PASSES: | |
| with path.open("r+b") as f: | |
| remaining = write_size | |
| while remaining > 0: | |
| chunk_size = min(BLOCK_SIZE, remaining) | |
| data = os.urandom(chunk_size) if fill_byte is None else fill_byte * chunk_size | |
| f.write(data) | |
| remaining -= chunk_size | |
| f.flush() | |
| os.fsync(f.fileno()) | |
| with path.open("wb") as f: | |
| f.truncate(0) | |
| def secure_remove_file(path: Path, *, dry_run: bool = False) -> None: | |
| """Overwrite, rename to a random name, then unlink.""" | |
| overwrite_file(path, dry_run=dry_run) | |
| random_name = os.urandom(RENAME_LENGTH).hex() | |
| new_path = path.with_name(random_name) | |
| if dry_run: | |
| print(f" [dry-run] Would rename {path} -> {new_path.name} and unlink") | |
| return | |
| try: | |
| path.rename(new_path) | |
| except OSError: | |
| new_path = path | |
| new_path.unlink() | |
| def secure_delete_tree(root: Path, *, dry_run: bool = False) -> int: | |
| """Recursively delete a directory tree with secure overwrite. Returns number of files destroyed.""" | |
| root = root.resolve() | |
| if not root.exists(): | |
| print(f"Path not found: {root}", file=sys.stderr) | |
| return 0 | |
| print(f"{'[dry-run] Would delete' if dry_run else 'Deleting'}: {root}") | |
| file_count = 0 | |
| for dirpath, _dirnames, filenames in os.walk(root, topdown=False): | |
| dp = Path(dirpath) | |
| for filename in filenames: | |
| filepath = dp / filename | |
| if filepath.is_symlink(): | |
| if dry_run: | |
| print(f" [dry-run] Would unlink symlink {filepath}") | |
| else: | |
| filepath.unlink() | |
| else: | |
| try: | |
| secure_remove_file(filepath, dry_run=dry_run) | |
| except OSError as e: | |
| print(f" ERROR on {filepath}: {e}", file=sys.stderr) | |
| file_count += 1 | |
| if file_count % 100 == 0: | |
| print(f" {file_count} files {'found' if dry_run else 'deleted'}...") | |
| for dirpath, dirnames, _ in os.walk(root, topdown=False): | |
| dp = Path(dirpath) | |
| for dirname in dirnames: | |
| child = dp / dirname | |
| if child.is_symlink(): | |
| if dry_run: | |
| print(f" [dry-run] Would unlink dir symlink {child}") | |
| else: | |
| child.unlink() | |
| else: | |
| if dry_run: | |
| print(f" [dry-run] Would rmdir {child}") | |
| else: | |
| try: | |
| child.rmdir() | |
| except OSError as e: | |
| print(f" ERROR removing dir {child}: {e}", file=sys.stderr) | |
| if dry_run: | |
| print(f" [dry-run] Would rmdir {root}") | |
| else: | |
| try: | |
| root.rmdir() | |
| except OSError as e: | |
| print(f" ERROR removing root {root}: {e}", file=sys.stderr) | |
| print(f" {'Summary' if dry_run else 'Done'}: {file_count} files {'would be destroyed' if dry_run else 'destroyed'}.") | |
| return file_count | |
| def validate_targets(targets: list[Path]) -> None: | |
| """Abort if any target resolves to a dangerous system path.""" | |
| for target in targets: | |
| real = str(target.resolve()) | |
| if real in DANGEROUS_PATHS: | |
| print(f"REFUSED: {target} resolves to {real}", file=sys.stderr) | |
| sys.exit(1) | |
| def parse_args(argv: list[str] | None = None) -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description="Securely delete directories, preventing software-based file recovery.", | |
| ) | |
| parser.add_argument("targets", nargs="*", type=Path, help="Directories to securely delete") | |
| parser.add_argument("--test", action="store_true", help="Run built-in tests and exit") | |
| parser.add_argument("-d", "--dry-run", action="store_true", help="Print what would be done without deleting anything") | |
| parser.add_argument("--yes", action="store_true", help="Skip confirmation prompt") | |
| return parser.parse_args(argv) | |
| def main(argv: list[str] | None = None) -> None: | |
| args = parse_args(argv) | |
| if args.test: | |
| run_tests() | |
| return | |
| if not args.targets: | |
| print("ERROR: no targets specified. Use -h for help.", file=sys.stderr) | |
| sys.exit(1) | |
| validate_targets(args.targets) | |
| print(f"Targets: {[str(t) for t in args.targets]}") | |
| if not args.yes and not args.dry_run: | |
| print("WARNING: this operation is irreversible.") | |
| confirm = input("Type 'DELETE' to confirm: ") | |
| if confirm != "DELETE": | |
| print("Aborted.") | |
| sys.exit(0) | |
| for target in args.targets: | |
| secure_delete_tree(target, dry_run=args.dry_run) | |
| if not args.dry_run: | |
| print("\nDone. To wipe residual free space on disk:") | |
| print(" dd if=/dev/zero of=zero status=progress oflag=sync bs=1M; rm zero") | |
| def test_overwrite_truncates_to_zero(): | |
| """After overwrite, file should be truncated to 0 bytes.""" | |
| with tempfile.NamedTemporaryFile(delete=False) as f: | |
| f.write(b"secret") | |
| path = Path(f.name) | |
| try: | |
| overwrite_file(path) | |
| assert path.stat().st_size == 0, f"Expected 0, got {path.stat().st_size}" | |
| finally: | |
| path.unlink(missing_ok=True) | |
| def test_overwrite_large_file(): | |
| """A file larger than MIN_OVERWRITE_BYTES should be fully overwritten.""" | |
| size = MIN_OVERWRITE_BYTES + 4096 | |
| with tempfile.NamedTemporaryFile(delete=False) as f: | |
| f.write(os.urandom(size)) | |
| path = Path(f.name) | |
| try: | |
| overwrite_file(path) | |
| assert path.stat().st_size == 0 | |
| finally: | |
| path.unlink(missing_ok=True) | |
| def test_overwrite_empty_file(): | |
| """An empty file should still be overwritten with MIN_OVERWRITE_BYTES then truncated.""" | |
| with tempfile.NamedTemporaryFile(delete=False) as f: | |
| path = Path(f.name) | |
| try: | |
| overwrite_file(path) | |
| assert path.stat().st_size == 0 | |
| finally: | |
| path.unlink(missing_ok=True) | |
| def test_secure_remove_deletes_file(): | |
| """After secure_remove_file, the original path must not exist.""" | |
| with tempfile.NamedTemporaryFile(delete=False) as f: | |
| f.write(b"confidential data here") | |
| path = Path(f.name) | |
| secure_remove_file(path) | |
| assert not path.exists(), f"File still exists: {path}" | |
| def test_secure_remove_no_original_content_in_leftovers(): | |
| """No leftover file in the same directory should contain the original content.""" | |
| content = b"UNIQUE_MARKER_FOR_TEST_" + os.urandom(32) | |
| with tempfile.NamedTemporaryFile(delete=False, dir="/tmp") as f: | |
| f.write(content) | |
| path = Path(f.name) | |
| parent = path.parent | |
| before = {p.name for p in parent.iterdir()} | |
| secure_remove_file(path) | |
| after = {p.name for p in parent.iterdir()} | |
| for name in after - before: | |
| leftover_path = parent / name | |
| if leftover_path.is_file(): | |
| data = leftover_path.read_bytes() | |
| assert content not in data, "Original content found in leftover file" | |
| def test_delete_tree_removes_everything(): | |
| """A directory tree should be completely removed.""" | |
| tmp = Path(tempfile.mkdtemp()) | |
| (tmp / "sub").mkdir() | |
| (tmp / "a.txt").write_text("aaa") | |
| (tmp / "sub" / "b.txt").write_text("bbb") | |
| (tmp / "sub" / "c.bin").write_bytes(os.urandom(100)) | |
| count = secure_delete_tree(tmp) | |
| assert not tmp.exists(), f"Directory still exists: {tmp}" | |
| assert count == 3, f"Expected 3 files, got {count}" | |
| def test_delete_tree_handles_symlinks(): | |
| """Symlinks should be unlinked without following the target.""" | |
| target_dir = Path(tempfile.mkdtemp()) | |
| real_file = target_dir / "real.txt" | |
| real_file.write_text("real content") | |
| delete_dir = Path(tempfile.mkdtemp()) | |
| (delete_dir / "link.txt").symlink_to(real_file) | |
| secure_delete_tree(delete_dir) | |
| assert not delete_dir.exists() | |
| assert real_file.exists(), "Symlink target should not have been deleted" | |
| secure_delete_tree(target_dir) | |
| def test_validate_targets_refuses_dangerous(): | |
| """validate_targets should exit on dangerous paths.""" | |
| try: | |
| validate_targets([Path("/")]) | |
| assert False, "Should have called sys.exit" | |
| except SystemExit as e: | |
| assert e.code == 1 | |
| def test_validate_targets_accepts_normal_path(): | |
| """A normal path like /tmp/something should be accepted.""" | |
| tmp = Path(tempfile.mkdtemp()) | |
| try: | |
| validate_targets([tmp]) | |
| finally: | |
| tmp.rmdir() | |
| def test_delete_tree_nonexistent_returns_zero(): | |
| """Nonexistent path should return 0 and not raise.""" | |
| count = secure_delete_tree(Path("/tmp/nonexistent_path_abc123")) | |
| assert count == 0 | |
| def test_dry_run_does_not_delete(): | |
| """Dry-run should traverse the tree but leave all files and directories intact.""" | |
| tmp = Path(tempfile.mkdtemp()) | |
| (tmp / "sub").mkdir() | |
| (tmp / "a.txt").write_text("aaa") | |
| (tmp / "sub" / "b.txt").write_text("bbb") | |
| count = secure_delete_tree(tmp, dry_run=True) | |
| assert count == 2, f"Expected 2 files found, got {count}" | |
| assert tmp.exists(), "Directory should still exist after dry-run" | |
| assert (tmp / "a.txt").read_text() == "aaa", "File content should be unchanged after dry-run" | |
| assert (tmp / "sub" / "b.txt").read_text() == "bbb" | |
| # Cleanup | |
| secure_delete_tree(tmp) | |
| def run_tests() -> None: | |
| test_functions = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] | |
| passed = 0 | |
| failed = 0 | |
| for fn in test_functions: | |
| try: | |
| fn() | |
| print(f" PASS {fn.__name__}") | |
| passed += 1 | |
| except Exception as e: | |
| print(f" FAIL {fn.__name__}: {e}") | |
| failed += 1 | |
| print(f"\n{passed} passed, {failed} failed.") | |
| if failed: | |
| sys.exit(1) | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment