| Version | Supported |
|---|---|
| v2.0.x | ✅ |
| < 2.0 | ❌ |
We take the security of fo-core seriously. If you believe you have found a security vulnerability, please do not report it through public GitHub issues.
Instead, use GitHub Security Advisories to report privately. Include:
- Type of vulnerability and affected component
- Environment details (OS, Python version, fo-core version)
- Steps to reproduce
- Any proof-of-concept code or screenshots
You should receive an acknowledgment within 48 hours. Patches are typically released within 7–14 days.
fo-core is a CLI file organizer that reads from and writes to user-supplied directory trees. The primary attack surface is symlink injection: a malicious actor could place symlinks inside the watched/organized directory to redirect reads or writes outside the intended root.
All POSIX filesystem access that touches user-supplied paths goes through SafeDir, which
opens files and directories with O_NOFOLLOW (or openat() + O_NOFOLLOW) so that symlinks
in the path raise SymlinkRejected rather than being followed silently.
Key API surface:
SafeDir.open_root(path)— open a watched or output root withO_DIRECTORYSafeDir.open_child(name)— open a file within the root usingO_NOFOLLOWSafeDir.open_subdir(name)— open a subdirectory withO_DIRECTORY | O_NOFOLLOWSafeDir.mkdir(name)— create a subdirectory viamkdirat()
safe_walk() is used for all recursive directory traversal. It skips:
- Symlinks (at both file and directory level)
- Hidden files and directories (dot-prefixed names)
The filesystem watcher (FileEventHandler) accepts an optional SafeDir opened on the watch
root. Every CREATED or MODIFIED event for a non-directory entry is checked via
_safedir_allows() before being enqueued. Events for symlinks are dropped and a
security_event watcher_symlink_rejected log entry is emitted.
PostprocessorStage opens the output root as a SafeDir on POSIX and caches per-category
subdirectory handles opened with O_NOFOLLOW | O_DIRECTORY. A symlink swap of a category
directory is detected at open time and raises SymlinkRejected — the file is not organized
and a security_event destination_symlink_swap entry is logged.
WriterStage writes the destination file using os.open(name, O_WRONLY | O_CREAT | O_TRUNC, dir_fd=safedir._fd) so that a symlink swap of the destination file between the existence
check and the open is also rejected.
durable_move captures the destination inode (st_dev, st_ino, st_size) after the move
completes and stores it in the history database. Before replaying an undo, rollback.py
re-reads the inode via os.lstat() and refuses to proceed if the inode changed, logging a
security_event undo_inode_mismatch entry.
Path components resolved from history records are validated against the configured undo root
using Path.is_relative_to() before any filesystem operation.
The repository has AST-based lint rails enforced in CI:
| Rail | Script | What it checks |
|---|---|---|
| SafeDir reader rail | scripts/check_safedir_required.py |
Library readers (fitz.open, shutil.copy2, etc.) and bare open() calls in enforced directories must carry a # safedir: ok opt-out or go through SafeDir |
| Atomic-write rail | scripts/check_atomic_write.py |
Persistent state writes use tempfile + os.replace() |
| Anchored traversal rail | scripts/check_anchored_traversal.py |
Path-join results validated against allowed root |
SafeDir is POSIX-only. On Windows, the pipeline falls back to shutil.copy2 / Path.mkdir.
Windows path traversal is mitigated at the collection layer (safe_walk) and by NTFS junction
restrictions. Symlink creation on Windows requires elevated privileges.
- Cross-device moves in
durable_move: when source and destination are on different filesystems, the move uses copy + unlink rather thanos.rename. The inode verification step still guards undo replay. - NFS / FUSE mounts:
O_NOFOLLOWsemantics vary across network filesystems. fo-core does not support watching NFS roots. - macOS SIP paths: organizing files under SIP-protected directories (
/System,/usr) will fail withEPERMregardless of SafeDir protection.