Ensign
Greetings!
Ensign was originally a personal project. I liked clamscan, but I noticed it would keep breaking on certain files in my system — permission denials when clamd's own user couldn't read a path, files rejected outright for exceeding clamd's size limit, broken symlinks pointing at files that no longer exist, and a genuinely bizarre recursive mess with Steam's Proton Z: drive that kept re-scanning snapshots of snapshots of itself (more on that further down). On top of that, temp files have a way of vanishing between one moment and the next — the world of seemingly quantum temp files is terrifying.
I decided to make a logic brain for this madness, Ensign. The idea was for Ensign to walk the entire system and log every file in an SQLAlchemy database. That database file is kept locally on your system. The db includes the id, filepath, filename, file hash, file size, modification time (mtime_ns), inode change time (ctime_ns), the version of ClamAV's signature database it was last scanned under, scan status, scan result, threat name, last time file was scanned, file creation time, and 'last updated at time' of each file logged in the db.
The idea was to be as paranoid as possible when designing this. That being said, it is completely possible that someone one else will notice something I missed and that in itself, is okay.
Status: alpha. Ensign has so far been tested only by its author, on Arch Linux. Expect rough edges, and read its reports as advice, not verdicts.
Disclaimer
Ensign is an independent project. It is not affiliated with Ensign InfoSecurity, The Ensign Group, the U.S. Navy or the U.S. Government.
Parts of this repo were written with assistance of Claude. The architecture and design decisions were made by me. Feel free to scrutinize this code, I welcome any flaws that are found. This project is not perfect, the same way I am not. A wise man loves correction, a fool abhors it.
License
Ensign is released under the MIT License. See the LICENSE file.
Installation
Ensign needs Python 3.11 or newer and ClamAV with its daemon (clamd) running.
With pipx (recommended on Arch):
sudo pacman -S --needed python-pipx clamav
pipx install ensign-scan
Arch marks the system Python as externally managed, so a plain pip install outside a virtual environment is refused. pipx installs Ensign into its own isolated environment and puts the ensign-scan command on your PATH. Update it later with pipx upgrade ensign-scan.
With pip, in a virtual environment (any distro):
python3 -m venv ~/.venvs/ensign
~/.venvs/ensign/bin/pip install ensign-scan
~/.venvs/ensign/bin/ensign-scan --help
From a git clone (to read or change the code, or to run the latest commit before a release):
git clone https://github.com/WolfdeerNerd/ensign-scan.git
cd ensign-scan
sudo pacman -S --needed python-sqlalchemy python-rich clamav
python3 main.py --help
main.py runs straight from the clone with no install step. The Requirements section below has the pip alternative. If you want the ensign-scan command from a clone, install it into a virtual environment with pip install . from the repo folder.
Either way, read the first-run note under Usage before your first scan.
Requirements
This section is for running Ensign from a source checkout; an installed copy (above) pulls in its own dependencies.
To get started with Ensign you need Python 3.11 or newer, SQLAlchemy, and rich. Ensign requires Python 3.11+ specifically for the tomllib module, used for config parsing.
A requirements.txt file is included.
If you have pip installed, the command is:
pip install -r requirements.txt
If you have pacman installed, the command is:
sudo pacman -S --needed python-sqlalchemy python-rich
A note for non-Arch users
Ensign's core scanning, hashing, and database tracking are plain Python + SQLAlchemy + ClamAV — nothing Arch-specific. It should run on any Linux distro with Python 3.11+ and ClamAV installed.
The one exception is --verify-pacman, which cross-checks files against pacman -Qkk's own integrity data — that's an Arch-only feature by nature (there's no equivalent on Debian/Fedora/etc.). If pacman isn't found on the system, Ensign detects that and skips the check cleanly rather than erroring; every other command works the same regardless of distro.
setfacl (used for the permission-fix suggestions) and notify-send (desktop notifications) aren't Arch-specific either — both are standard Linux tooling available on most major distros, just under different package names (e.g. Debian/Ubuntu: acl and libnotify-bin).
What Ensign Touches, and What It Can't See
Network
Ensign makes no network connections. It talks to clamd over a local Unix socket (clamd_socket in your config) and nothing else. Virus-signature updates come from ClamAV's own freshclam, a separate program that you run or schedule yourself. It is the only networked part of the setup, and it is not part of Ensign.
Programs Ensign runs
| Program | When | What for |
|---|---|---|
clamscan |
Only for a file clamd rejects as over its size limit | Scans that one file with the command-line scanner, which has no such limit |
pacman -Qkk |
Only with --verify-pacman |
Read-only integrity check of installed files |
pacman -Qo <file> |
When listing infected files | Read-only lookup of which package owns a flagged file |
notify-send |
At the end of a run, if installed | Desktop notification |
gdbus |
After an infected-files alert | Closes that notification after its timeout |
pgrep |
When sending an infected-files alert | Checks whether a Steam game is running (see Alerts) |
Ensign never changes permissions. It prints setfacl commands for you to review and run yourself. It also never deletes, quarantines or edits a file: it reports.
What Ensign can't see
- Files clamd can't read. clamd runs as the
clamavuser, so a file that user can't open is not scanned. It is recorded aserror, or asclassifiedwhen its path looks like a secrets store (see Permissions Setup). - Directories Ensign can't enter. They are skipped, and each run ends with an "UNREADABLE DIRECTORIES" section listing them. The list is rebuilt every run, so nothing about it is stored in the database.
- Symlinked directories. They are never walked into (see the Proton note below). Symlinks to individual files are followed and scanned.
- Excluded paths, and unchanged snapshot subtrees. Whatever you list under
excludesis not scanned, and subtrees undersnapshot_rootsare skipped while their modification time is unchanged. - Files that vanish mid-scan. Temp files and browser caches come and go; they are counted as skipped, not as errors.
- Anything ClamAV has no signature for. Ensign is a file scanner built on ClamAV. It doesn't look at memory or running processes, and a clean result means "no known signature matched", not "safe".
Permissions Setup
When building Ensign, I ran into errors concerning file permissions. Turns out that the ClamAV daemon requires permissions, as it operates under the user clamav. The daemon needs this whenever it needs to traverse directories.
To solve this I had to run the two following commands:
sudo setfacl -R -m g:clamav:rX /path/to/hotspot
sudo setfacl -R -d -m g:clamav:rX /path/to/hotspot
- The first command allows the group
clamavto read the files in the directory. - The second command makes it default for any new files in the same directory.
A note on updates: Permissions can get reset when programs or games update, because an update may replace files or directories with fresh copies that don't carry the ACL you set. If permission errors come back after an update, run the two commands again on the affected folder (python3 main.py --suggest-fixes prints the right commands for the current errors).
Threading & Worker Sizing
Ensign scans files concurrently using a thread pool, since the work per file (hashing, then a clamd socket round-trip) is I/O-bound, not CPU-bound — the Python GIL isn't a bottleneck here the way it would be for CPU-heavy parallel work.
Worker count is either set explicitly (max_workers in config) or resolved automatically (max_workers = 0, the default): Ensign asks clamd for its own MaxThreads limit, subtracts a little headroom so the daemon stays responsive to other clients, and caps the result by your CPU count.
If clamd can't be reached to ask, Ensign falls back to a safe default of 5 workers rather than guessing wildly. And no matter how constrained the machine — single core, a very low MaxThreads on clamd — the worker count can never drop below 1. Ensign will always run, even on modest hardware; it just scans one file at a time in that case rather than failing or requiring a separate no-threading mode.
Usage
The examples below are written as python3 main.py (running from a checkout). If you installed Ensign, use ensign-scan instead: ensign-scan --help, ensign-scan ~/Downloads, and so on.
First run: don't use sudo. Run Ensign under your own username the first time. Ensign creates its config, database and log files on first run, and whoever runs it owns them. If the first run is as root, those files end up owned by root and your username can't use them afterwards. If that happens, delete the root-created files (the database in ~/.local/share/ensign/ and the config in ~/.config/ensign/) and run Ensign once under your own username to recreate them.
First quick scan: Before scanning everything, test your setup on one folder.
- Make sure ClamAV's daemon is running (Ensign tells you if it isn't):
sudo systemctl start clamav-daemon - Run Ensign on a small tree, as your own username:
python3 path/to/main.py ~/Downloads - If you see permission errors, see Permissions Setup above, then run it again.
For a full scan, you only need to run:
python3 path/to/main.py
This scans everything listed in your config's scan_targets, runs the infected/error/stale rescan passes, prints a summary and error breakdown, and sends a desktop notification when it's done (if one is available).
While it runs, the bottom line of the terminal shows live progress: files done out of files to process, a bar and percentage, an estimated time remaining (ETA), how many infected files and errors have turned up so far, and the worker count. Each file's result scrolls past above it. The ETA follows the pace of roughly the last minute, so it tracks the current speed, and it shows -- for the first few seconds or whenever nothing is finishing. When the scan is done, the ETA is replaced by the total time it took. On a narrow terminal the line drops detail instead of wrapping: the worker count goes first, then the full words shrink to symbols, and only on a very narrow one the ETA. When the output isn't a terminal (a scheduled service, or piped to a file), the line is printed once at the end instead of being redrawn.
Targeting and behavior flags
| Command | What it does |
|---|---|
python3 main.py --help |
Show all available flags and exit |
python3 main.py ~/Downloads |
Scan just that one tree instead of everything in config |
python3 main.py --no-scan |
Hash and record files, skip ClamAV entirely |
python3 main.py --skip-rescans |
Skip the infected/error/stale rescan passes at the end |
python3 main.py --config PATH |
Use an alternate config file (default: ~/.config/ensign/config.toml) |
ENSIGN_CONFIG=PATH python3 main.py |
The same as --config, set through an environment variable. If both are given, --config wins |
Standalone maintenance commands
These run one job and exit — no full scan.
| Command | What it does |
|---|---|
python3 main.py --purge-missing |
Remove DB records for files that no longer exist on disk |
python3 main.py --rescan-errors |
Retry only files currently marked ERROR |
python3 main.py --verify-pacman |
Cross-check installed files against pacman's own checksums, flag mismatches (Arch only, can be slow) |
python3 main.py --suggest-fixes |
Print setfacl commands for current permission errors, optionally scoped to given targets |
Only one run at a time
Before it scans, purges or retries errors, Ensign takes a lock: a small file called ensign.lock in your data directory (~/.local/share/ensign/ unless you've set a custom data_dir). If another Ensign already holds it, the new one prints which process has it, exits with code 75 and scans nothing. That way a scheduled run and a manual run can't overlap and scan the same files twice.
- The lock frees itself. It belongs to the running process, so it is released when that process ends, even after a crash or
kill -9. There is never a stale lock to clean up. - Leave the file in place. Deleting
ensign.lockwhile a run is using it can let a second run start. - Read-only commands skip it.
--verify-pacmanand--suggest-fixesonly read the database, so they can run while a scan is going.
If a suggested fix doesn't seem to work
If you apply a suggested 'setfacl' command and the same error keeps showing up on a later run, the file may have been deleted since Ensign last saw it - common with temp files, downloads that get cleaned up, or build artifacts. A permission fix can't help a file that no longer exists.
Run:
python3 main.py --purge-missing
to clear out stale records, then run a normal scan again to re-establish a clean baseline.
What Gets Rescanned
After the first full scan, Ensign sends a file to ClamAV again only when something suggests it needs it. A file is rescanned when:
- Its size or modification time changed.
- Its inode change time (ctime) changed. A program can set a file's modification time back to what it was, but it can't do that to ctime. So a file that was edited and then given its old timestamp back is still caught. The side effect is that
chmod,chownand renaming a file also change ctime, so they trigger a rescan too. - It was last scanned clean under an out-of-date signature database. A file ClamAV called clean yesterday can match a signature released today.
rescan_after_signature_updatesin your config sets how many signature updates behind a clean file can fall before it is scanned again. Updates arrive about once a day, so the number is roughly days: the default7re-checks everything about weekly, and31about monthly. The allowed range is 7 to 31 (a value outside it is clamped, with a warning), or0to turn this off. Each pass sends every out-of-date file through clamd again, which on a machine with millions of files takes a while, so pick the longest interval you are comfortable with.
The first run after upgrading from a version that didn't track ctime or signature versions scans every file once. After that it settles back to scanning only what changed.
Alerts
When a scan ends with infected files, Ensign sends a critical desktop notification (needs notify-send). That alert behaves differently from the normal ones, because an alert that sits on top of a game until you tab out and dismiss it is a nuisance.
- It closes itself after
infected_alert_seconds(default60).0leaves it up until you dismiss it. KDE Plasma ignores the timeout thatnotify-sendsets on critical alerts, so Ensign closes the alert itself over D-Bus withgdbus. - By default it only does that while a Steam game is running. With
infected_alert_only_while_gaming = true, a scan you start yourself in a terminal leaves the alert up for you to come back to, unless a Steam game is running (Ensign looks for Steam'sreaper SteamLaunch AppId=process). Steam open and idle doesn't count.falsealways auto-closes. - Runs with no terminal always auto-close. A scheduled run (a service or cron job) has nobody to come back to the alert.
- A new infected alert replaces the previous one instead of stacking up. Ensign remembers the last alert's ID in
alert_state.jsonin the data directory, and only trusts it within the same boot. If that file is root-owned (see the first-run note under Usage), replacement quietly stops working.
Logs
Errors are written to one log file per run: <data_dir>/logs/ensign-YYYYMMDD-HHMMSS.log, which is ~/.local/share/ensign/logs/ unless you've set a custom data_dir. A run with no errors leaves no file.
- Repeats are collapsed. The first 20 occurrences of each distinct error are logged in full, then only counted, with a one-line total at the end of the run. One repeating problem (Ensign once logged 302,289 "stat failed" lines for broken symlinks) can't fill the disk.
- Old logs are deleted automatically. Runs older than
log_keep_days(default90) go first. If the folder is still overlog_max_total_mb(default2048), the oldest go next, so the size limit wins.0turns either rule off. Only files namedensign-*.login that folder are ever deleted. - Older versions wrote one
error_logs.txtfile. It is no longer used and can be deleted.
Explaining Findings
When a scan finds an infected file, or --verify-pacman finds a mismatch, Ensign prints a short ⓘ note under it saying what the threat name or mismatch reason generally means. The point is to help you tell a false positive from something worth chasing.
- Curated notes first. Ensign ships a small table of researched notes, and you can add your own (see Customizing below).
- Everything else is decoded from the name. ClamAV names follow
Platform.Category.Name-SignatureID-Revision, so for a name with no curated note Ensign describes what the name says. A Windows-only signature on Linux, for example, gets a Hint that it is often a false positive. A hint is a pointer, not a verdict. - Findings are grouped by threat name, so a signature that hits many files is explained once. Each file gets a context line: size, last modified date, and whether a pacman package owns it.
- All local and advisory. Nothing is looked up online, and the notes never change a scan result.
Customizing
Three optional sections in your config.toml extend Ensign's built-in lists. They are additive only: your entries are added, never replace a built-in one, and an entry that collides with a built-in is ignored with a warning.
[custom_threats."Win.Malware.Example-1234567-0"]
description = "What this signature generally means."
false_positive_note = "Optional: when it's commonly a false positive."
[custom_pacman_reasons]
"Some new mismatch type" = "What this pacman -Qkk reason means."
[custom_secrets]
high_dirs = ["myvault"]
high_prefixes = [["mywallet_", "wallets"]]
[custom_threats]adds a note for a threat name. The name must match exactly, including the numbers.[custom_pacman_reasons]adds a note for apacman -Qkkmismatch reason.[custom_secrets]adds paths that look like secrets stores. The keys arehigh_dirs,high_dir_paths,high_filenames,high_extensions,high_scoped_filenames,high_prefixes,medium_prefixesandmedium_extensions. A HIGH match means a permission-denied file is classified as expected instead of reported as an error, and neither tier will get asetfaclfix suggested for it. Seeensign_scan/secrets.pyfor what the tiers mean.
The first time you run Ensign it writes a starter config.toml with all of these documented as comments. config.example.toml in the repo has the same content.
Querying the Database Directly
Most day-to-day questions are covered by the commands above. But the database is just SQLite, kept locally at the path printed in the Database: line when Ensign starts (~/.local/share/ensign/system.db unless you've set a custom data_dir) — for anything the built-in reports don't cover, sqlite3 gets you there directly.
The files table's scan_status column is one of: clean, infected, error, pending, classified.
See the details behind current errors:
sqlite3 ~/.local/share/ensign/system.db "SELECT filepath, scan_result FROM files WHERE scan_status='error';"
See what's been classified (permission denied on a path that looks like a secrets store — expected, not chased as an error):
sqlite3 ~/.local/share/ensign/system.db "SELECT filepath, scan_result FROM files WHERE scan_status='classified';"
The list of paths/filenames/extensions used to make that call isn't arbitrary — see docs/Classified_Paths_Research.md for the sourcing behind each entry, and ensign_scan/secrets.py for the confidence tiers and how the two different call sites use them.
See infected files and what triggered them:
sqlite3 ~/.local/share/ensign/system.db "SELECT filepath, threat_name FROM files WHERE scan_status='infected';"
Look up everything Ensign knows about one specific file:
sqlite3 ~/.local/share/ensign/system.db "SELECT * FROM files WHERE filepath='/full/path/to/file';"
A note on infected results
ClamAV includes heuristic signatures (like SVGDynamicFunction) that can match on legitimate code patterns — for example, JS build tools that generate functions at runtime from template/markup content (Vue's compiler-sfc, Vite's dependency cache) can trigger a phishing-heuristic match despite being ordinary framework code. An infected status is worth investigating, not treating as an automatic threat. Check what the file actually is and, if it looks like legitimate tooling, search the threat name plus the library name before assuming compromise.
For Steam users,
A note on Proton's 'Z:' drive
When developing Ensign, there wasn't anything to stop the walk from wandering and rescanning symlinks since I didn't understand them. You can imagine my surprise when the db was gradually growing in size despite not installing any updates or playing any new games. After some digging I found the culprit. Steam's Z: drive in Proton. The Z: drive allows steam to see and interact with your file system on linux. Nothing wrong with that. What I had failed to understand was that it included the ENTIRE system. INCLUDING ALL THE SNAPSHOTS (system backups)! So as a result, the db was seemingly growing by hundreds of thousands of files as it identifies files by the file paths. Example: ~/Pictures/IMG001.jpg would also show up as ~/.steam/path/to/z:/home/user/Pictures/IMG0001.jpg. A duplicate entry despite being the same file. Now take into account all the usual games that are in a user's steam library and all of those individual files, the walk would take literally days, going onto weeks, just to finish one sweep of the system.
The reason it kept compounding wasn't just that Z: mirrored the whole filesystem — it's that Z: pointing back at / meant it also mirrored itself. A snapshot taken of the system would include a copy of the Z: drive, which pointed back at /, which contained the Steam compatdata folder, which contained more snapshots, each with their own Z: drive pointing back at / again. So the walk wasn't just duplicating files — it was recursively duplicating its own duplicates, with the depth compounding on every snapshot layer. For a time it was quite literally logging backups of the backups of the backups, REGARDLESS OF HOW MANY LAYERS DEEP IT WENT.
Hence why for this build, I've opted to set followlinks to False in the walk function itself:
for dirpath, dirnames, filenames in os.walk(root, topdown=True, followlinks=False):
That way there's no endless cascade of walking a single directory. Symlinked directories are skipped entirely now — the walk sees them but never descends in. Individual files that happen to be symlinks (like a shortcut to a document) are still followed and scanned through to their real target, same as before — it's only symlinked directories that stopped getting wandered into.
Last Note
This readme will be updated when needed. IE. FAQ questions that keep getting asked, or something needs further explanation.
Metadata
Release files for ensign-scan 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| ensign_scan-0.1.0.tar.gz | 66.4 kB | Without provenance | Details for ensign_scan-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| ensign_scan-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for ensign_scan-0.1.0-py3-none-any.whl |
Total release size: 129.9 kB
Release files / ensign_scan-0.1.0.tar.gz
| Download URL | ensign_scan-0.1.0.tar.gz |
|---|---|
| Size | 66.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f4e2272501e7e2e8c9d5e8905fe0a73fce95d129b9d53cf50e642b875b6c75ad
|
|
BLAKE2b-256 checksum How to use checksums |
f30e21667ea02508421cf60c39edcb4cde79ef55a1470d0cf391a7c970407afc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Release files / ensign_scan-0.1.0-py3-none-any.whl
| Download URL | ensign_scan-0.1.0-py3-none-any.whl |
|---|---|
| Size | 63.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
ab03bf60a55fa68a55dbf9ba6002ce85eaa72aee9b420d0aaa6f60bb0eb9eb89
|
|
BLAKE2b-256 checksum How to use checksums |
502287e7886baaff69ea32b23e7bf3cb1c68d6acd817c832394f4b1be745c3e9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.8
|
Security for ensign-scan 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.