Skip to content

Instantly share code, notes, and snippets.

@turicas
Created April 9, 2026 11:59
Show Gist options
  • Select an option

  • Save turicas/145a0ed8a084396f4f0c0a6f6d791c98 to your computer and use it in GitHub Desktop.

Select an option

Save turicas/145a0ed8a084396f4f0c0a6f6d791c98 to your computer and use it in GitHub Desktop.
Securely delete a list of files/directories
#!/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