ensign-scan 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ensign-scan might be problematic. Click here for more details.
- ensign_scan/__init__.py +1 -0
- ensign_scan/clamd.py +186 -0
- ensign_scan/cli.py +174 -0
- ensign_scan/config.py +325 -0
- ensign_scan/db.py +47 -0
- ensign_scan/explanations.py +251 -0
- ensign_scan/fsutil.py +93 -0
- ensign_scan/logsetup.py +163 -0
- ensign_scan/models.py +87 -0
- ensign_scan/notify.py +239 -0
- ensign_scan/pacman.py +147 -0
- ensign_scan/progress.py +177 -0
- ensign_scan/reports.py +423 -0
- ensign_scan/runlock.py +77 -0
- ensign_scan/scanner.py +522 -0
- ensign_scan/secrets.py +285 -0
- ensign_scan-0.1.0.dist-info/METADATA +316 -0
- ensign_scan-0.1.0.dist-info/RECORD +22 -0
- ensign_scan-0.1.0.dist-info/WHEEL +5 -0
- ensign_scan-0.1.0.dist-info/entry_points.txt +2 -0
- ensign_scan-0.1.0.dist-info/licenses/LICENSE +22 -0
- ensign_scan-0.1.0.dist-info/top_level.txt +1 -0
ensign_scan/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Ensign — file hashing and ClamAV scanning with a change-tracking database."""
|
ensign_scan/clamd.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Minimal clamd unix-socket client, plus a clamscan CLI fallback."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .models import ScanStatus
|
|
13
|
+
|
|
14
|
+
_FOUND_RE = re.compile(r":\s+(.+?)\s+FOUND")
|
|
15
|
+
|
|
16
|
+
# clamd's wording for "file too large to scan" is inconsistent across
|
|
17
|
+
# versions -- some report it plainly, others substitute the misleading
|
|
18
|
+
# "Can't allocate memory" for the exact same underlying condition
|
|
19
|
+
# (a known, longstanding clamd quirk, not an actual OOM).
|
|
20
|
+
_SIZE_LIMIT_MARKERS = ("size limit exceeded", "size limit reached", "can't allocate memory")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def is_size_limit_error(raw: str) -> bool:
|
|
24
|
+
"""True if a clamd response indicates a size-limit rejection, not a
|
|
25
|
+
genuine scan failure -- these call for a different remedy (raise the
|
|
26
|
+
limit, or fall back to the clamscan CLI) than a real error does."""
|
|
27
|
+
text = raw.lower()
|
|
28
|
+
return any(marker in text for marker in _SIZE_LIMIT_MARKERS)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_PERMISSION_MARKERS = ("access denied", "permission denied")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def is_permission_error(raw: str) -> bool:
|
|
35
|
+
"""True if a clamd response indicates it couldn't read the file due
|
|
36
|
+
to permissions -- as opposed to a size limit or a genuine scan
|
|
37
|
+
failure. Used to distinguish "clamd was refused" from "clamd tried
|
|
38
|
+
and something went wrong."""
|
|
39
|
+
text = raw.lower()
|
|
40
|
+
return any(marker in text for marker in _PERMISSION_MARKERS)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def clamscan_cli_available() -> bool:
|
|
44
|
+
return shutil.which("clamscan") is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def scan_with_clamscan_cli(filepath: Path | str, timeout: float = 180.0) -> "ScanOutcome":
|
|
48
|
+
"""Fall back to the clamscan CLI for one file, bypassing clamd's
|
|
49
|
+
configured MaxFileSize/MaxScanSize entirely via explicit overrides.
|
|
50
|
+
|
|
51
|
+
Only meant for files the daemon has already rejected as too large.
|
|
52
|
+
Each invocation loads the whole signature database fresh -- seconds,
|
|
53
|
+
not milliseconds -- so this is not something to run routinely.
|
|
54
|
+
"""
|
|
55
|
+
try:
|
|
56
|
+
result = subprocess.run(
|
|
57
|
+
["clamscan", "--no-summary", "--infected",
|
|
58
|
+
"--max-filesize=0", "--max-scansize=0", str(filepath)],
|
|
59
|
+
capture_output=True, text=True, timeout=timeout,
|
|
60
|
+
)
|
|
61
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
62
|
+
return ScanOutcome(ScanStatus.ERROR, f"clamscan CLI fallback failed: {exc}")
|
|
63
|
+
|
|
64
|
+
output = result.stdout.strip()
|
|
65
|
+
if result.returncode == 0:
|
|
66
|
+
return ScanOutcome(ScanStatus.CLEAN, output or "OK (clamscan CLI fallback)")
|
|
67
|
+
if result.returncode == 1:
|
|
68
|
+
match = _FOUND_RE.search(output)
|
|
69
|
+
threat = match.group(1) if match else "Unknown"
|
|
70
|
+
return ScanOutcome(ScanStatus.INFECTED, output, threat)
|
|
71
|
+
return ScanOutcome(ScanStatus.ERROR, output or f"clamscan CLI exited {result.returncode}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class ScanOutcome:
|
|
76
|
+
status: ScanStatus
|
|
77
|
+
raw: str
|
|
78
|
+
threat_name: str | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ClamdClient:
|
|
82
|
+
def __init__(self, socket_path: str, timeout: float = 300.0):
|
|
83
|
+
self.socket_path = socket_path
|
|
84
|
+
self.timeout = timeout
|
|
85
|
+
|
|
86
|
+
def _command(self, command: str) -> str:
|
|
87
|
+
"""Send one command and read the full reply.
|
|
88
|
+
|
|
89
|
+
clamd terminates replies with a newline; a single recv(4096)
|
|
90
|
+
can truncate long responses, so read until the terminator.
|
|
91
|
+
"""
|
|
92
|
+
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
|
|
93
|
+
sock.settimeout(self.timeout)
|
|
94
|
+
sock.connect(self.socket_path)
|
|
95
|
+
sock.sendall(f"n{command}\n".encode())
|
|
96
|
+
chunks: list[bytes] = []
|
|
97
|
+
while True:
|
|
98
|
+
data = sock.recv(4096)
|
|
99
|
+
if not data:
|
|
100
|
+
break
|
|
101
|
+
chunks.append(data)
|
|
102
|
+
if data.endswith(b"\n"):
|
|
103
|
+
break
|
|
104
|
+
return b"".join(chunks).decode().strip()
|
|
105
|
+
|
|
106
|
+
def ping(self) -> bool:
|
|
107
|
+
"""True if clamd is up and answering on the socket."""
|
|
108
|
+
try:
|
|
109
|
+
return self._command("PING") == "PONG"
|
|
110
|
+
except OSError:
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
def max_threads(self) -> int | None:
|
|
114
|
+
"""clamd's own MaxThreads limit, read from its STATS output.
|
|
115
|
+
|
|
116
|
+
Sending more concurrent scans than this just queues inside the
|
|
117
|
+
daemon (and overflows MaxQueue under sustained load), so it is
|
|
118
|
+
the true ceiling for useful worker-pool size.
|
|
119
|
+
"""
|
|
120
|
+
try:
|
|
121
|
+
stats = self._command("STATS")
|
|
122
|
+
except OSError:
|
|
123
|
+
return None
|
|
124
|
+
match = re.search(r"THREADS:.*?\bmax\s+(\d+)", stats)
|
|
125
|
+
return int(match.group(1)) if match else None
|
|
126
|
+
|
|
127
|
+
def signature_version(self) -> int | None:
|
|
128
|
+
"""The loaded signature database's version number, via VERSION.
|
|
129
|
+
|
|
130
|
+
The middle field of "ClamAV <engine>/<sig-version>/<build date>"
|
|
131
|
+
(e.g. 27455). It rises with each database release, so comparing two
|
|
132
|
+
of them says how many releases apart two scans were. None if clamd
|
|
133
|
+
is unreachable or the response doesn't parse -- "unknown", not 0.
|
|
134
|
+
"""
|
|
135
|
+
try:
|
|
136
|
+
version = self._command("VERSION")
|
|
137
|
+
except OSError:
|
|
138
|
+
return None
|
|
139
|
+
parts = version.split("/")
|
|
140
|
+
if len(parts) < 3:
|
|
141
|
+
return None
|
|
142
|
+
try:
|
|
143
|
+
return int(parts[-2])
|
|
144
|
+
except ValueError:
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def signature_age_days(self) -> int | None:
|
|
148
|
+
"""Days since the loaded signature database was built, via VERSION.
|
|
149
|
+
|
|
150
|
+
Response format: "ClamAV <engine>/<sig-version>/<build date>",
|
|
151
|
+
e.g. "ClamAV 1.2.0/27455/Wed Jan 15 08:12:00 2026". Returns None
|
|
152
|
+
if clamd is unreachable or the response doesn't parse -- callers
|
|
153
|
+
should treat that as "unknown," not "stale."
|
|
154
|
+
"""
|
|
155
|
+
try:
|
|
156
|
+
version = self._command("VERSION")
|
|
157
|
+
except OSError:
|
|
158
|
+
return None
|
|
159
|
+
parts = version.split("/")
|
|
160
|
+
if len(parts) < 3:
|
|
161
|
+
return None
|
|
162
|
+
date_str = parts[-1].strip()
|
|
163
|
+
try:
|
|
164
|
+
built = datetime.strptime(date_str, "%a %b %d %H:%M:%S %Y")
|
|
165
|
+
except ValueError:
|
|
166
|
+
return None
|
|
167
|
+
return (datetime.now() - built).days
|
|
168
|
+
|
|
169
|
+
def scan(self, filepath: Path | str) -> ScanOutcome:
|
|
170
|
+
"""Scan one file via clamd's SCAN command.
|
|
171
|
+
|
|
172
|
+
clamd reads the file itself, so it needs read permission on the
|
|
173
|
+
path (see the ACL notes in the README).
|
|
174
|
+
"""
|
|
175
|
+
try:
|
|
176
|
+
response = self._command(f"SCAN {filepath}")
|
|
177
|
+
except OSError as exc:
|
|
178
|
+
return ScanOutcome(ScanStatus.ERROR, f"Scan error: {exc}")
|
|
179
|
+
|
|
180
|
+
if response.endswith("OK"):
|
|
181
|
+
return ScanOutcome(ScanStatus.CLEAN, response)
|
|
182
|
+
if "FOUND" in response:
|
|
183
|
+
match = _FOUND_RE.search(response)
|
|
184
|
+
threat = match.group(1) if match else "Unknown"
|
|
185
|
+
return ScanOutcome(ScanStatus.INFECTED, response, threat)
|
|
186
|
+
return ScanOutcome(ScanStatus.ERROR, response)
|
ensign_scan/cli.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Ensign command-line entry point (the `ensign-scan` command).
|
|
2
|
+
|
|
3
|
+
All side effects (greeting, logging setup, the scan itself) happen in
|
|
4
|
+
main(), not at import time, so the package can be imported by tests or
|
|
5
|
+
other scripts without launching a full system scan.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
if sys.version_info < (3, 11):
|
|
12
|
+
v = sys.version_info
|
|
13
|
+
print(f"Ensign requires Python 3.11 or newer (found {v.major}.{v.minor}).")
|
|
14
|
+
print("It uses the standard-library 'tomllib' module for config parsing.")
|
|
15
|
+
raise SystemExit(1)
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
from rich import print
|
|
21
|
+
|
|
22
|
+
from ensign_scan.config import HOME, HOSTNAME, Config, ensure_default_config
|
|
23
|
+
from ensign_scan import explanations, logsetup, notify, pacman, secrets
|
|
24
|
+
from ensign_scan.runlock import AlreadyRunning, RunLock
|
|
25
|
+
from ensign_scan.reports import error_summary, list_infected, pacman_report, scan_summary, suggest_fixes, unreadable_report
|
|
26
|
+
from ensign_scan.scanner import Scanner
|
|
27
|
+
|
|
28
|
+
EXIT_BUSY = 75 # sysexits EX_TEMPFAIL: another Ensign holds the lock; try again later
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_args() -> argparse.Namespace:
|
|
32
|
+
parser = argparse.ArgumentParser(description="Hash and ClamAV-scan the system.")
|
|
33
|
+
parser.add_argument("targets", nargs="*",
|
|
34
|
+
help="Directories to scan (default: scan_targets from config)")
|
|
35
|
+
parser.add_argument("--config", metavar="PATH",
|
|
36
|
+
help="Config file (default: ~/.config/ensign/config.toml)")
|
|
37
|
+
parser.add_argument("--no-scan", action="store_true",
|
|
38
|
+
help="Hash and record files without ClamAV scanning")
|
|
39
|
+
parser.add_argument("--skip-rescans", action="store_true",
|
|
40
|
+
help="Skip the infected/error/stale rescan passes")
|
|
41
|
+
parser.add_argument("--purge-missing", action="store_true",
|
|
42
|
+
help="Remove DB records for files that no longer exist, then exit")
|
|
43
|
+
parser.add_argument("--rescan-errors", action="store_true",
|
|
44
|
+
help="Retry only files currently marked ERROR, then exit (no full scan)")
|
|
45
|
+
parser.add_argument("--verify-pacman", action="store_true",
|
|
46
|
+
help="Check installed files against pacman's records, then exit (Arch only, can be slow)")
|
|
47
|
+
parser.add_argument("--suggest-fixes", action="store_true",
|
|
48
|
+
help="Print suggested setfacl fixes for current errors, then exit (no full scan). "
|
|
49
|
+
"Scoped to any targets given.")
|
|
50
|
+
return parser.parse_args()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def setup_logging(config: Config) -> None:
|
|
54
|
+
logsetup.setup(config)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def greet(config: Config) -> None:
|
|
58
|
+
print()
|
|
59
|
+
print(f" Greetings, {HOME.name}!")
|
|
60
|
+
print("-" * 71)
|
|
61
|
+
print(" I, Ensign, will start scanning your system shortly…")
|
|
62
|
+
print(f" Host: {HOSTNAME}")
|
|
63
|
+
print(f" Config: {config.source}")
|
|
64
|
+
print(f" Database: {config.data_dir / 'system.db'}")
|
|
65
|
+
print("-" * 71)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def main() -> int:
|
|
69
|
+
args = parse_args()
|
|
70
|
+
if not args.config:
|
|
71
|
+
ensure_default_config()
|
|
72
|
+
config = Config.load(args.config)
|
|
73
|
+
secrets.load_custom(config)
|
|
74
|
+
explanations.load_custom(config)
|
|
75
|
+
setup_logging(config)
|
|
76
|
+
|
|
77
|
+
# The read-only reports never write to the database, so they don't need
|
|
78
|
+
# the lock and can run alongside a scan.
|
|
79
|
+
if args.verify_pacman or args.suggest_fixes:
|
|
80
|
+
return run(args, config)
|
|
81
|
+
|
|
82
|
+
lock = RunLock(config.data_dir / "ensign.lock")
|
|
83
|
+
try:
|
|
84
|
+
lock.acquire()
|
|
85
|
+
except AlreadyRunning as busy:
|
|
86
|
+
who = f"pid {busy.holder_pid}" if busy.holder_pid else "another process"
|
|
87
|
+
print(f"[yellow]Ensign is already running ({who}). Not starting a second one.[/yellow]")
|
|
88
|
+
print(f" Lock: {busy.path}")
|
|
89
|
+
return EXIT_BUSY
|
|
90
|
+
try:
|
|
91
|
+
return run(args, config)
|
|
92
|
+
finally:
|
|
93
|
+
lock.release()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run(args: argparse.Namespace, config: Config) -> int:
|
|
97
|
+
greet(config)
|
|
98
|
+
|
|
99
|
+
scanner = Scanner(config)
|
|
100
|
+
if args.purge_missing:
|
|
101
|
+
scanner.purge_missing()
|
|
102
|
+
return 0
|
|
103
|
+
if args.rescan_errors:
|
|
104
|
+
if not scanner.clamd.ping():
|
|
105
|
+
print("[red]✗ clamd is not answering on its socket.[/red]")
|
|
106
|
+
return 1
|
|
107
|
+
scanner.rescan_errors()
|
|
108
|
+
return 0
|
|
109
|
+
if args.verify_pacman:
|
|
110
|
+
pacman_report(scanner.engine, pacman.verify_all())
|
|
111
|
+
return 0
|
|
112
|
+
if args.suggest_fixes:
|
|
113
|
+
suggest_fixes(scanner.engine, paths=args.targets or None)
|
|
114
|
+
return 0
|
|
115
|
+
auto = " (auto)" if config.max_workers <= 0 else ""
|
|
116
|
+
print(f" Workers: {scanner.workers}{auto}")
|
|
117
|
+
scan = not args.no_scan
|
|
118
|
+
|
|
119
|
+
if scan and not scanner.clamd.ping():
|
|
120
|
+
print("[red]✗ clamd is not answering on its socket.[/red]")
|
|
121
|
+
print(f" Socket: {config.clamd_socket}")
|
|
122
|
+
print(" Start it with: sudo systemctl start clamav-daemon")
|
|
123
|
+
return 1
|
|
124
|
+
if scan:
|
|
125
|
+
print("[green]✓ clamd is up.[/green]")
|
|
126
|
+
if config.signature_max_age_days > 0:
|
|
127
|
+
sig_age = scanner.clamd.signature_age_days()
|
|
128
|
+
if sig_age is not None and sig_age > config.signature_max_age_days:
|
|
129
|
+
print(f"[yellow]! Virus signatures are {sig_age} days old "
|
|
130
|
+
f"(threshold: {config.signature_max_age_days}). Run: sudo freshclam[/yellow]")
|
|
131
|
+
print()
|
|
132
|
+
|
|
133
|
+
targets = args.targets or config.scan_targets
|
|
134
|
+
start = time.time()
|
|
135
|
+
print("=== Starting Scan ===")
|
|
136
|
+
unreadable: dict[str, str] = {}
|
|
137
|
+
missing: set[str] = set()
|
|
138
|
+
for target in targets:
|
|
139
|
+
print(f"\nChecking [violet]{target}[/violet]:")
|
|
140
|
+
stats = scanner.scan_tree(target, scan=scan)
|
|
141
|
+
unreadable.update(stats.unreadable_dirs)
|
|
142
|
+
missing |= stats.missing
|
|
143
|
+
|
|
144
|
+
print(f"\nExecution time: {time.time() - start:.1f} seconds")
|
|
145
|
+
|
|
146
|
+
if scan and not args.skip_rescans:
|
|
147
|
+
print("\n=== Processing Rescans ===")
|
|
148
|
+
scanner.rescan_infected()
|
|
149
|
+
scanner.rescan_errors()
|
|
150
|
+
scanner.rescan_stale()
|
|
151
|
+
|
|
152
|
+
summary = scan_summary(scanner.engine, paths=args.targets or None)
|
|
153
|
+
if missing:
|
|
154
|
+
print(f"Broken symlinks / vanished files skipped: {len(missing)}")
|
|
155
|
+
error_summary(scanner.engine, paths=args.targets or None)
|
|
156
|
+
unreadable_report(unreadable)
|
|
157
|
+
infected = list_infected(scanner.engine, paths=args.targets or None)
|
|
158
|
+
suggest_fixes(scanner.engine, paths=args.targets or None)
|
|
159
|
+
notify.send(summary, infected, config.infected_alert_seconds,
|
|
160
|
+
only_when_gaming=config.infected_alert_only_while_gaming,
|
|
161
|
+
interactive=sys.stdin.isatty(),
|
|
162
|
+
state_path=config.data_dir / "alert_state.json")
|
|
163
|
+
|
|
164
|
+
print("\n=== All Scans Finished ===")
|
|
165
|
+
print("-" * 71)
|
|
166
|
+
print(" All finished.")
|
|
167
|
+
print(" Feel free to call upon me again.")
|
|
168
|
+
print(" — Ensign")
|
|
169
|
+
print()
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
if __name__ == "__main__":
|
|
174
|
+
raise SystemExit(main())
|
ensign_scan/config.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Configuration for Ensign.
|
|
2
|
+
|
|
3
|
+
Design for multi-machine use:
|
|
4
|
+
|
|
5
|
+
* The code contains NO machine-specific values. Everything lives in a
|
|
6
|
+
TOML file, so any user on any box gets sane defaults out of the box.
|
|
7
|
+
* Config search order: --config flag > $ENSIGN_CONFIG >
|
|
8
|
+
~/.config/ensign/config.toml > built-in defaults.
|
|
9
|
+
* Per-machine differences go in [hosts.<hostname>] sections of the
|
|
10
|
+
SAME file, so you can sync one config across all your machines and
|
|
11
|
+
each one picks up its own overrides.
|
|
12
|
+
* "~" in the config file always means the *invoking* user's home,
|
|
13
|
+
even under sudo (resolved via SUDO_USER + the passwd database, so
|
|
14
|
+
it also works for homes outside /home).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import pwd
|
|
20
|
+
import socket
|
|
21
|
+
import tomllib
|
|
22
|
+
from dataclasses import dataclass, field, fields
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ----------------------------------------------------------------------
|
|
27
|
+
# Identity: who is really running this, and on which machine?
|
|
28
|
+
# ----------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
def real_home() -> Path:
|
|
31
|
+
"""Home directory of the invoking user, even under sudo.
|
|
32
|
+
|
|
33
|
+
Path.home() returns /root under sudo. SUDO_USER preserves the real
|
|
34
|
+
account, and the passwd database gives its true home directory —
|
|
35
|
+
no assumption that homes live under /home.
|
|
36
|
+
"""
|
|
37
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
38
|
+
if sudo_user:
|
|
39
|
+
try:
|
|
40
|
+
return Path(pwd.getpwnam(sudo_user).pw_dir)
|
|
41
|
+
except KeyError:
|
|
42
|
+
pass # SUDO_USER set but unknown; fall through
|
|
43
|
+
return Path.home()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
HOME = real_home()
|
|
47
|
+
HOSTNAME = socket.gethostname()
|
|
48
|
+
|
|
49
|
+
DEFAULT_DATA_DIR = HOME / ".local" / "share" / "ensign"
|
|
50
|
+
DEFAULT_CONFIG_PATH = HOME / ".config" / "ensign" / "config.toml"
|
|
51
|
+
|
|
52
|
+
_DEFAULT_TARGETS = ("~/Downloads", "~/Documents", "~/Pictures", "~")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _expand(value: str | Path) -> Path:
|
|
56
|
+
"""Expand ~ and environment variables relative to the invoking user."""
|
|
57
|
+
text = os.path.expandvars(str(value))
|
|
58
|
+
if text == "~":
|
|
59
|
+
return HOME
|
|
60
|
+
if text.startswith("~/"):
|
|
61
|
+
return HOME / text[2:]
|
|
62
|
+
return Path(text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ----------------------------------------------------------------------
|
|
66
|
+
# The config object
|
|
67
|
+
# ----------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# Allowed range for rescan_after_signature_updates in a config file.
|
|
70
|
+
# Below 7 is a full pass of a big machine every few days; above 31 leaves
|
|
71
|
+
# clean files unchecked for over a month. 0 (off) is always allowed.
|
|
72
|
+
SIGNATURE_RESCAN_MIN = 7
|
|
73
|
+
SIGNATURE_RESCAN_MAX = 31
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class Config:
|
|
78
|
+
db_url: str = f"sqlite:///{DEFAULT_DATA_DIR / 'system.db'}"
|
|
79
|
+
log_file: Path = DEFAULT_DATA_DIR / "error_logs.txt"
|
|
80
|
+
clamd_socket: str = "/var/run/clamav/clamd.ctl"
|
|
81
|
+
# 0 = auto: sized from clamd's MaxThreads and CPU count at startup.
|
|
82
|
+
max_workers: int = 0
|
|
83
|
+
rescan_after_days: int = 90
|
|
84
|
+
# Rescan a clean file once the signature database has moved this many
|
|
85
|
+
# versions past the one it was last scanned under. Usually about one
|
|
86
|
+
# version per day, so this is roughly "days". A config file may set
|
|
87
|
+
# SIGNATURE_RESCAN_MIN..SIGNATURE_RESCAN_MAX (clamped, with a warning),
|
|
88
|
+
# or 0 to turn it off.
|
|
89
|
+
rescan_after_signature_updates: int = 7
|
|
90
|
+
# Seconds before the infected-files desktop alert closes itself, so it
|
|
91
|
+
# doesn't sit over a game until dismissed. 0 = stays until dismissed.
|
|
92
|
+
infected_alert_seconds: int = 60
|
|
93
|
+
# Error logs are one file per run under <data_dir>/logs/. Delete runs
|
|
94
|
+
# older than this many days (0 = never)...
|
|
95
|
+
log_keep_days: int = 90
|
|
96
|
+
# ...and, if the folder is still bigger than this many MB, the oldest
|
|
97
|
+
# first (0 = no size limit). The size limit wins when the two disagree.
|
|
98
|
+
log_max_total_mb: int = 2048
|
|
99
|
+
# Apply infected_alert_seconds only while a Steam game is running;
|
|
100
|
+
# otherwise the alert stays until dismissed.
|
|
101
|
+
infected_alert_only_while_gaming: bool = True
|
|
102
|
+
# Warn (not block) if the loaded virus-signature database is older
|
|
103
|
+
# than this. Usually means freshclam has silently stopped running.
|
|
104
|
+
signature_max_age_days: int = 3
|
|
105
|
+
snapshot_roots: tuple[str, ...] = ()
|
|
106
|
+
# Path prefixes to skip entirely (high-churn temp/cache trees).
|
|
107
|
+
excludes: tuple[str, ...] = ()
|
|
108
|
+
scan_targets: tuple[Path, ...] = tuple(_expand(t) for t in _DEFAULT_TARGETS)
|
|
109
|
+
|
|
110
|
+
# User-supplied additions to secrets.py's pattern lists and
|
|
111
|
+
# explanations.py's curated lookup tables, from the [custom_secrets],
|
|
112
|
+
# [custom_threats] and [custom_pacman_reasons] config sections.
|
|
113
|
+
# Raw dicts, straight from TOML -- secrets.load_custom() and
|
|
114
|
+
# explanations.load_custom() validate and apply them (additive-only).
|
|
115
|
+
custom_secrets: dict = field(default_factory=dict)
|
|
116
|
+
custom_threats: dict = field(default_factory=dict)
|
|
117
|
+
custom_pacman_reasons: dict = field(default_factory=dict)
|
|
118
|
+
|
|
119
|
+
# Where this config came from, for the startup banner. Not a setting.
|
|
120
|
+
source: str = field(default="built-in defaults", compare=False)
|
|
121
|
+
|
|
122
|
+
# -- loading ------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@classmethod
|
|
125
|
+
def load(cls, path: str | Path | None = None) -> "Config":
|
|
126
|
+
"""Load config: explicit path > $ENSIGN_CONFIG > default location.
|
|
127
|
+
|
|
128
|
+
An explicitly requested file that doesn't exist is an error; the
|
|
129
|
+
default location merely falling back to defaults is not.
|
|
130
|
+
"""
|
|
131
|
+
explicit = path or os.environ.get("ENSIGN_CONFIG")
|
|
132
|
+
cfg_path = Path(explicit) if explicit else DEFAULT_CONFIG_PATH
|
|
133
|
+
|
|
134
|
+
if not cfg_path.is_file():
|
|
135
|
+
if explicit:
|
|
136
|
+
raise FileNotFoundError(f"Config file not found: {cfg_path}")
|
|
137
|
+
return cls()
|
|
138
|
+
|
|
139
|
+
with open(cfg_path, "rb") as f:
|
|
140
|
+
data = tomllib.load(f)
|
|
141
|
+
|
|
142
|
+
# Per-machine overrides: [hosts.<hostname>] beats top level.
|
|
143
|
+
host_overrides = data.pop("hosts", {}).get(HOSTNAME, {})
|
|
144
|
+
merged = {**data, **host_overrides}
|
|
145
|
+
return cls._from_dict(merged, source=str(cfg_path))
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def _from_dict(cls, raw: dict, source: str) -> "Config":
|
|
149
|
+
known = {
|
|
150
|
+
"data_dir", "clamd_socket", "max_workers",
|
|
151
|
+
"rescan_after_days", "rescan_after_signature_updates",
|
|
152
|
+
"infected_alert_seconds", "infected_alert_only_while_gaming",
|
|
153
|
+
"log_keep_days", "log_max_total_mb",
|
|
154
|
+
"scan_targets", "snapshot_roots",
|
|
155
|
+
"excludes", "signature_max_age_days",
|
|
156
|
+
"custom_secrets", "custom_threats", "custom_pacman_reasons",
|
|
157
|
+
}
|
|
158
|
+
for key in raw.keys() - known:
|
|
159
|
+
print(f"[ensign] Warning: unknown config key {key!r} ignored")
|
|
160
|
+
|
|
161
|
+
data_dir = _expand(raw.get("data_dir", DEFAULT_DATA_DIR))
|
|
162
|
+
kwargs: dict = {
|
|
163
|
+
"db_url": f"sqlite:///{data_dir / 'system.db'}",
|
|
164
|
+
"log_file": data_dir / "error_logs.txt",
|
|
165
|
+
"source": source,
|
|
166
|
+
}
|
|
167
|
+
for key in ("clamd_socket", "max_workers", "rescan_after_days",
|
|
168
|
+
"rescan_after_signature_updates", "infected_alert_seconds",
|
|
169
|
+
"infected_alert_only_while_gaming", "log_keep_days",
|
|
170
|
+
"log_max_total_mb", "signature_max_age_days"):
|
|
171
|
+
if key in raw:
|
|
172
|
+
kwargs[key] = raw[key]
|
|
173
|
+
if "scan_targets" in raw:
|
|
174
|
+
kwargs["scan_targets"] = tuple(_expand(t) for t in raw["scan_targets"])
|
|
175
|
+
if "snapshot_roots" in raw:
|
|
176
|
+
kwargs["snapshot_roots"] = tuple(str(_expand(t)) for t in raw["snapshot_roots"])
|
|
177
|
+
if "excludes" in raw:
|
|
178
|
+
kwargs["excludes"] = tuple(str(_expand(t)).rstrip("/") for t in raw["excludes"])
|
|
179
|
+
for key in ("custom_secrets", "custom_threats", "custom_pacman_reasons"):
|
|
180
|
+
if key in raw:
|
|
181
|
+
kwargs[key] = raw[key]
|
|
182
|
+
n = kwargs.get("rescan_after_signature_updates")
|
|
183
|
+
if isinstance(n, int) and n != 0:
|
|
184
|
+
clamped = min(max(n, SIGNATURE_RESCAN_MIN), SIGNATURE_RESCAN_MAX)
|
|
185
|
+
if clamped != n:
|
|
186
|
+
print(f"[ensign] Warning: rescan_after_signature_updates = {n} is outside "
|
|
187
|
+
f"{SIGNATURE_RESCAN_MIN}-{SIGNATURE_RESCAN_MAX}; using {clamped}")
|
|
188
|
+
kwargs["rescan_after_signature_updates"] = clamped
|
|
189
|
+
return cls(**kwargs)
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def data_dir(self) -> Path:
|
|
193
|
+
return self.log_file.parent
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def log_dir(self) -> Path:
|
|
197
|
+
return self.data_dir / "logs"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ----------------------------------------------------------------------
|
|
201
|
+
# First-run template
|
|
202
|
+
# ----------------------------------------------------------------------
|
|
203
|
+
# EXAMPLE_CONFIG must stay identical to config.example.toml at the repo
|
|
204
|
+
# root (config_test.py checks this). Edit the .toml, then copy it here.
|
|
205
|
+
|
|
206
|
+
EXAMPLE_CONFIG = """\
|
|
207
|
+
# Ensign configuration.
|
|
208
|
+
# Paths may use "~" (the invoking user's home, sudo-safe) and $VARS.
|
|
209
|
+
# Anything set under [hosts.<hostname>] overrides the values above it
|
|
210
|
+
# on that machine only — so this one file can be synced everywhere.
|
|
211
|
+
|
|
212
|
+
# Where the database and error log live.
|
|
213
|
+
# data_dir = "~/.local/share/ensign"
|
|
214
|
+
|
|
215
|
+
# Worker threads. 0 = auto (sized from clamd's MaxThreads and CPU count).
|
|
216
|
+
max_workers = 0
|
|
217
|
+
rescan_after_days = 90
|
|
218
|
+
|
|
219
|
+
# Rescan an unchanged, previously-clean file once the virus-signature
|
|
220
|
+
# database has moved this many versions past the one it was last scanned
|
|
221
|
+
# under. The database moves about once a day, so this is roughly "days":
|
|
222
|
+
# 7 = every file is re-checked about weekly, 31 = about monthly. Allowed
|
|
223
|
+
# range 7-31 (anything else is clamped, with a warning); 0 turns it off.
|
|
224
|
+
# Each rescan is a full clamd pass over everything that is out of date,
|
|
225
|
+
# which on a large machine (millions of files) is a long one.
|
|
226
|
+
rescan_after_signature_updates = 7
|
|
227
|
+
|
|
228
|
+
# Seconds before the "infected files" desktop alert closes itself, so it
|
|
229
|
+
# doesn't sit on top of a game until you dismiss it. Longer than a normal
|
|
230
|
+
# alert on purpose. 0 = stays until you dismiss it. Other alerts are not
|
|
231
|
+
# affected.
|
|
232
|
+
infected_alert_seconds = 60
|
|
233
|
+
|
|
234
|
+
# true = for a scan you start yourself in a terminal, only auto-close that
|
|
235
|
+
# alert while a Steam game is running, so it stays on screen until you come
|
|
236
|
+
# back to it. false = always auto-close. (Steam open but idle does not count
|
|
237
|
+
# as gaming.) Runs with no terminal (a service or cron job) always auto-close,
|
|
238
|
+
# and each new infected alert replaces the previous one instead of stacking.
|
|
239
|
+
infected_alert_only_while_gaming = true
|
|
240
|
+
|
|
241
|
+
# Error logs: one file per run in <data_dir>/logs/, created only when a run
|
|
242
|
+
# has errors. Runs older than log_keep_days are deleted; if the folder is
|
|
243
|
+
# still over log_max_total_mb, the oldest go first (so the size limit wins).
|
|
244
|
+
# 0 turns either rule off. Within a run, an error that repeats is logged in
|
|
245
|
+
# full 20 times, then only counted, with a total at the end of the run.
|
|
246
|
+
log_keep_days = 90
|
|
247
|
+
log_max_total_mb = 2048
|
|
248
|
+
|
|
249
|
+
# Warn if the virus-signature database is older than this many days
|
|
250
|
+
# (usually means freshclam has silently stopped running). 0 disables.
|
|
251
|
+
signature_max_age_days = 3
|
|
252
|
+
clamd_socket = "/var/run/clamav/clamd.ctl"
|
|
253
|
+
|
|
254
|
+
scan_targets = [
|
|
255
|
+
"~/Downloads",
|
|
256
|
+
"~/Documents",
|
|
257
|
+
"~/Pictures",
|
|
258
|
+
"~",
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
# Directory trees whose contents never change in place (e.g. proton
|
|
262
|
+
# prefix snapshots). Unchanged subtrees under these are skipped by mtime.
|
|
263
|
+
snapshot_roots = []
|
|
264
|
+
|
|
265
|
+
# Trees to skip entirely. High-churn temp/cache directories mostly
|
|
266
|
+
# produce "file vanished mid-scan" errors and no security value.
|
|
267
|
+
# If you use snapper (or another btrfs snapshot tool) on a subvolume
|
|
268
|
+
# under your home directory, its .snapshots dir holds full copies of
|
|
269
|
+
# your whole home tree per snapshot -- left un-excluded, Ensign will
|
|
270
|
+
# walk every file once per snapshot, and the count balloons with each
|
|
271
|
+
# new snapshot snapper takes.
|
|
272
|
+
excludes = [
|
|
273
|
+
"~/.cache",
|
|
274
|
+
"~/.local/share/Trash",
|
|
275
|
+
"~/.snapshots",
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
# --- Per-machine overrides (section name = `hostname` output) ---------
|
|
279
|
+
#
|
|
280
|
+
# [hosts.gaming-rig]
|
|
281
|
+
# max_workers = 8 # explicit override beats auto
|
|
282
|
+
# scan_targets = ["~/Downloads", "~/.local/share/Steam", "~"]
|
|
283
|
+
# snapshot_roots = [
|
|
284
|
+
# "~/.local/share/Steam/steamapps/compatdata/<app_id>/pfx/dosdevices/z:/snapshots",
|
|
285
|
+
# ]
|
|
286
|
+
#
|
|
287
|
+
# [hosts.old-laptop]
|
|
288
|
+
# max_workers = 2
|
|
289
|
+
|
|
290
|
+
# --- Custom secrets-path patterns (ensign_scan/secrets.py) ------------------
|
|
291
|
+
# Additive only: these are ADDED to the built-in HIGH/MEDIUM pattern
|
|
292
|
+
# lists, never replace or override a built-in entry. See secrets.py's
|
|
293
|
+
# module docstring for what HIGH vs MEDIUM means.
|
|
294
|
+
#
|
|
295
|
+
# [custom_secrets]
|
|
296
|
+
# high_dirs = ["myvault"] # whole path component
|
|
297
|
+
# high_dir_paths = ["myapp/keys"] # multi-segment substring
|
|
298
|
+
# high_filenames = ["my_custom_key"] # exact filename, any dir
|
|
299
|
+
# high_extensions = [".mysecret"]
|
|
300
|
+
# high_scoped_filenames = [["myapp", "config"]] # [required parent substring, filename]
|
|
301
|
+
# high_prefixes = [["mywallet_", "wallets"]] # [prefix, required parent substring]
|
|
302
|
+
# medium_prefixes = [["myprefix_", "somedir"]] # [prefix, required parent substring or ""]
|
|
303
|
+
# medium_extensions = [[".maybesecret", ""]] # [extension, required parent substring or ""]
|
|
304
|
+
|
|
305
|
+
# --- Custom explanations (ensign_scan/explanations.py) -----------------------
|
|
306
|
+
# Additive only, same as above -- a name/reason that's already built in
|
|
307
|
+
# is left alone, not overridden.
|
|
308
|
+
#
|
|
309
|
+
# [custom_threats."Some.New.ThreatName"]
|
|
310
|
+
# description = "What this signature generally means."
|
|
311
|
+
# false_positive_note = "Optional: when it's commonly a false positive."
|
|
312
|
+
#
|
|
313
|
+
# [custom_pacman_reasons]
|
|
314
|
+
# "Some new mismatch type" = "What this pacman -Qkk reason means."
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def ensure_default_config() -> None:
|
|
319
|
+
"""Write the commented template on first run so users have something
|
|
320
|
+
to edit instead of reading source code to learn the options."""
|
|
321
|
+
if DEFAULT_CONFIG_PATH.exists():
|
|
322
|
+
return
|
|
323
|
+
DEFAULT_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
324
|
+
DEFAULT_CONFIG_PATH.write_text(EXAMPLE_CONFIG)
|
|
325
|
+
print(f"[ensign] Wrote starter config to {DEFAULT_CONFIG_PATH}")
|