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.

@@ -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}")