kevward 0.2.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.
kevward/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """kevward - defensive server hardening & patch automation for Ubuntu.
2
+
3
+ A plugin-based CLI that inspects running services, reports known
4
+ vulnerabilities and available security updates, optionally applies
5
+ security patches / minor version upgrades, and schedules reboots.
6
+
7
+ This is a *defensive* tool. It never touches remote hosts and contains
8
+ no offensive capability.
9
+ """
10
+
11
+ __version__ = "0.2.0"
kevward/cli.py ADDED
@@ -0,0 +1,212 @@
1
+ """kevward command-line interface.
2
+
3
+ Subcommands:
4
+ check Report findings (no changes). Default if none given.
5
+ plan Alias for check, framed as "what apply would do".
6
+ apply Apply security updates (and minor upgrades if policy allows).
7
+ report Write an HTML posture report (+ attacker IPs, + trends).
8
+ trends Show actively-exploited CVEs (CISA KEV) relevant to this host.
9
+ schedule-reboot Schedule a reboot in the maintenance window.
10
+ list-plugins Show discovered service plugins.
11
+
12
+ Safety: `apply` changes the system and is never implied. Everything else is
13
+ read-only. Reboots require both --reboot/`schedule-reboot` AND policy opt-in.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ from . import __version__
23
+ from .config import Config
24
+ from .engine import run as run_engine
25
+ from .integrations import fail2ban, logwatch
26
+ from .model import Severity
27
+ from .plugin import detected_kev_terms, discover
28
+
29
+
30
+ def _add_common(p: argparse.ArgumentParser) -> None:
31
+ p.add_argument("-c", "--config", help="path to config.toml")
32
+ p.add_argument("-s", "--service", action="append", dest="services",
33
+ help="limit to this service (repeatable)")
34
+ p.add_argument("--json", action="store_true", help="emit JSON")
35
+
36
+
37
+ def _mkconfig(args) -> Config:
38
+ cfg = Config.load(getattr(args, "config", None))
39
+ if getattr(args, "services", None):
40
+ cfg.policy.services = args.services
41
+ return cfg
42
+
43
+
44
+ def _collect_attackers(include_trends: bool, host_terms=None) -> dict:
45
+ data = {
46
+ "fail2ban": fail2ban.collect(),
47
+ "logwatch": logwatch.collect(),
48
+ }
49
+ if include_trends:
50
+ from .integrations import trends as trends_mod
51
+ data["trends"] = trends_mod.matching_trends(host_terms)
52
+ return data
53
+
54
+
55
+ def _print_findings(result, use_json: bool) -> int:
56
+ if use_json:
57
+ print(result.to_json())
58
+ else:
59
+ worst = Severity.INFO
60
+ for svc in result.services:
61
+ if not svc.present:
62
+ continue
63
+ head = f"[{svc.service}]"
64
+ if svc.version:
65
+ head += f" {svc.version}"
66
+ print(head)
67
+ if svc.error:
68
+ print(f" ! error: {svc.error}")
69
+ for f in svc.findings:
70
+ sev = f.severity if isinstance(f.severity, str) else f.severity.value
71
+ print(f" - {sev.upper():8} {f.title}")
72
+ if f.severity.rank > worst.rank:
73
+ worst = f.severity
74
+ if not svc.findings:
75
+ print(" ok")
76
+ if result.reboot_required:
77
+ print("\n** reboot required **")
78
+ # exit code reflects worst severity for CI/cron use
79
+ return 0
80
+
81
+
82
+ def cmd_check(args) -> int:
83
+ cfg = _mkconfig(args)
84
+ result = run_engine(cfg, do_apply=False, dry_run=True)
85
+ return _print_findings(result, args.json)
86
+
87
+
88
+ def cmd_apply(args) -> int:
89
+ cfg = _mkconfig(args)
90
+ if args.minor:
91
+ cfg.policy.apply_minor = True
92
+ dry = not args.yes
93
+ if dry:
94
+ print("DRY RUN (pass --yes to actually apply)\n")
95
+ result = run_engine(cfg, do_apply=True, dry_run=dry)
96
+ for svc in result.services:
97
+ for a in svc.actions:
98
+ tag = "would" if a.dry_run else ("ok" if a.ok else "FAIL")
99
+ print(f"[{svc.service}] {tag}: {a.description}")
100
+ if result.reboot_required:
101
+ if cfg.policy.allow_reboot and args.reboot and not dry:
102
+ from .scheduler import schedule_reboot
103
+ print(schedule_reboot(cfg.policy.reboot_window_start))
104
+ else:
105
+ print("\nreboot required (not scheduled; use schedule-reboot or --reboot "
106
+ "with allow_reboot=true)")
107
+ return 0
108
+
109
+
110
+ def cmd_report(args) -> int:
111
+ cfg = _mkconfig(args)
112
+ result = run_engine(cfg, do_apply=False, dry_run=True)
113
+ host_terms = detected_kev_terms(cfg.policy.services or None)
114
+ result.attackers = _collect_attackers(args.trends, host_terms)
115
+ from .reporting.html import render
116
+ out = Path(args.output) if args.output else cfg.report_dir / "report.html"
117
+ out.parent.mkdir(parents=True, exist_ok=True)
118
+ out.write_text(render(result), encoding="utf-8")
119
+ print(f"wrote {out}")
120
+ if args.json:
121
+ print(result.to_json())
122
+ return 0
123
+
124
+
125
+ def cmd_trends(args) -> int:
126
+ cfg = _mkconfig(args)
127
+ from .integrations import trends as trends_mod
128
+ host_terms = detected_kev_terms(cfg.policy.services or None)
129
+ data = trends_mod.matching_trends(host_terms)
130
+ if args.json:
131
+ import json
132
+ print(json.dumps(data, indent=2, ensure_ascii=False))
133
+ return 0
134
+ print(f"CISA KEV catalog: {data['catalog_size']} CVEs "
135
+ f"({'fresh' if data['cache_fresh'] else 'cached'})")
136
+ print(f"matched to detected software ({', '.join(host_terms)}): "
137
+ f"{data['match_count']} ({data['ransomware_linked']} ransomware-linked)")
138
+ print("note: name-level match — confirm version applicability per CVE.\n")
139
+ for m in data["matches"][:25]:
140
+ rw = " [ransomware]" if m["ransomware"] else ""
141
+ print(f" {m['cve']:18} {m['vendor']}/{m['product']} (added {m['date_added']}){rw}")
142
+ return 0
143
+
144
+
145
+ def cmd_schedule_reboot(args) -> int:
146
+ cfg = _mkconfig(args)
147
+ if not cfg.policy.allow_reboot:
148
+ print("refused: policy.allow_reboot is false. Set it to true to permit reboots.")
149
+ return 2
150
+ from .scheduler import schedule_reboot
151
+ print(schedule_reboot(args.window or cfg.policy.reboot_window_start))
152
+ return 0
153
+
154
+
155
+ def cmd_list_plugins(args) -> int:
156
+ for name, cls in sorted(discover().items()):
157
+ mode = "report-only" if not cls.supports_apply else "apply-capable"
158
+ print(f" {name:12} {mode} ({cls.__module__})")
159
+ return 0
160
+
161
+
162
+ def build_parser() -> argparse.ArgumentParser:
163
+ p = argparse.ArgumentParser(prog="kevward", description=__doc__.split("\n")[0])
164
+ p.add_argument("--version", action="version", version=f"kevward {__version__}")
165
+ sub = p.add_subparsers(dest="cmd")
166
+
167
+ c = sub.add_parser("check", help="report findings (no changes)")
168
+ _add_common(c)
169
+ c.set_defaults(func=cmd_check)
170
+
171
+ pl = sub.add_parser("plan", help="alias of check")
172
+ _add_common(pl)
173
+ pl.set_defaults(func=cmd_check)
174
+
175
+ a = sub.add_parser("apply", help="apply security updates")
176
+ _add_common(a)
177
+ a.add_argument("--yes", action="store_true", help="actually apply (else dry-run)")
178
+ a.add_argument("--minor", action="store_true", help="also apply minor upgrades")
179
+ a.add_argument("--reboot", action="store_true", help="schedule reboot if required")
180
+ a.set_defaults(func=cmd_apply)
181
+
182
+ r = sub.add_parser("report", help="write HTML posture report")
183
+ _add_common(r)
184
+ r.add_argument("-o", "--output", help="output HTML path")
185
+ r.add_argument("--trends", action="store_true", help="include CISA KEV trends (network)")
186
+ r.set_defaults(func=cmd_report)
187
+
188
+ t = sub.add_parser("trends", help="actively-exploited CVEs relevant to host")
189
+ _add_common(t)
190
+ t.set_defaults(func=cmd_trends)
191
+
192
+ sr = sub.add_parser("schedule-reboot", help="schedule a maintenance-window reboot")
193
+ _add_common(sr)
194
+ sr.add_argument("--window", help="HH:MM local time")
195
+ sr.set_defaults(func=cmd_schedule_reboot)
196
+
197
+ lp = sub.add_parser("list-plugins", help="list discovered plugins")
198
+ _add_common(lp)
199
+ lp.set_defaults(func=cmd_list_plugins)
200
+ return p
201
+
202
+
203
+ def main(argv: list[str] | None = None) -> int:
204
+ parser = build_parser()
205
+ args = parser.parse_args(argv)
206
+ if not getattr(args, "cmd", None):
207
+ args = parser.parse_args((argv or []) + ["check"])
208
+ return args.func(args)
209
+
210
+
211
+ if __name__ == "__main__":
212
+ sys.exit(main())
kevward/config.py ADDED
@@ -0,0 +1,72 @@
1
+ """Configuration loading and the safety policy.
2
+
3
+ Config is optional; sane, *safe* defaults apply when no file is present.
4
+ Format is TOML (stdlib ``tomllib`` on 3.11+, ``tomli`` backport on 3.10).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+
12
+ try:
13
+ import tomllib # Python 3.11+
14
+ except ModuleNotFoundError: # Python 3.10: pip install tomli
15
+ import tomli as tomllib
16
+
17
+ DEFAULT_PATHS = [
18
+ Path("/etc/kevward/config.toml"),
19
+ Path.home() / ".config" / "kevward" / "config.toml",
20
+ Path("kevward.toml"),
21
+ ]
22
+
23
+
24
+ @dataclass
25
+ class Policy:
26
+ """What kevward is permitted to do. Defaults are conservative."""
27
+
28
+ # Apply security updates when running `apply`.
29
+ apply_security: bool = True
30
+ # Apply non-security minor version upgrades. Off by default.
31
+ apply_minor: bool = False
32
+ # Allow kevward to reboot the host itself. Off by default: by default it
33
+ # only *reports* that a reboot is required, or schedules one in a window.
34
+ allow_reboot: bool = False
35
+ # Maintenance window for scheduled reboots, local time "HH:MM".
36
+ reboot_window_start: str = "04:00"
37
+ # Services to manage. Empty = all discovered plugins.
38
+ services: list[str] = field(default_factory=list)
39
+ # Per-service overrides, e.g. {"nginx": {"apply_minor": true}}.
40
+ overrides: dict[str, dict] = field(default_factory=dict)
41
+
42
+ def for_service(self, name: str) -> Policy:
43
+ ov = self.overrides.get(name)
44
+ if not ov:
45
+ return self
46
+ merged = {**self.__dict__, **ov}
47
+ merged.pop("overrides", None)
48
+ return Policy(**{k: v for k, v in merged.items() if k in Policy.__annotations__})
49
+
50
+
51
+ @dataclass
52
+ class Config:
53
+ policy: Policy = field(default_factory=Policy)
54
+ report_dir: Path = Path("/var/log/kevward")
55
+
56
+ @classmethod
57
+ def load(cls, path: str | Path | None = None) -> Config:
58
+ candidates = [Path(path)] if path else DEFAULT_PATHS
59
+ for p in candidates:
60
+ if p.is_file():
61
+ return cls._from_file(p)
62
+ return cls()
63
+
64
+ @classmethod
65
+ def _from_file(cls, p: Path) -> Config:
66
+ data = tomllib.loads(p.read_text())
67
+ pol = data.get("policy", {})
68
+ policy = Policy(**{k: v for k, v in pol.items() if k in Policy.__annotations__})
69
+ cfg = cls(policy=policy)
70
+ if "report_dir" in data:
71
+ cfg.report_dir = Path(data["report_dir"])
72
+ return cfg
kevward/engine.py ADDED
@@ -0,0 +1,56 @@
1
+ """Runs plugins according to policy and produces an aggregate run result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import platform
7
+ import socket
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+ from .config import Config
13
+ from .model import ServiceReport
14
+ from .plugin import load
15
+
16
+
17
+ @dataclass
18
+ class RunResult:
19
+ host: str
20
+ generated_at: str
21
+ os: str
22
+ services: list[ServiceReport] = field(default_factory=list)
23
+ attackers: dict[str, Any] = field(default_factory=dict)
24
+
25
+ @property
26
+ def reboot_required(self) -> bool:
27
+ return any(s.reboot_required for s in self.services)
28
+
29
+ def to_dict(self) -> dict[str, Any]:
30
+ return {
31
+ "host": self.host,
32
+ "generated_at": self.generated_at,
33
+ "os": self.os,
34
+ "reboot_required": self.reboot_required,
35
+ "services": [s.to_dict() for s in self.services],
36
+ "attackers": self.attackers,
37
+ }
38
+
39
+ def to_json(self, indent: int = 2) -> str:
40
+ return json.dumps(self.to_dict(), indent=indent, ensure_ascii=False)
41
+
42
+
43
+ def run(cfg: Config, do_apply: bool = False, dry_run: bool = True) -> RunResult:
44
+ only = cfg.policy.services or None
45
+ plugins = load(only)
46
+ result = RunResult(
47
+ host=socket.gethostname(),
48
+ generated_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
49
+ os=f"{platform.system()} {platform.release()}",
50
+ )
51
+ for p in plugins:
52
+ sp = cfg.policy.for_service(p.name)
53
+ # Respect per-service apply gating by temporarily narrowing intent.
54
+ apply_this = do_apply and (sp.apply_security or sp.apply_minor)
55
+ result.services.append(p.report(do_apply=apply_this, dry_run=dry_run))
56
+ return result
@@ -0,0 +1 @@
1
+ """External signal integrations: attacker telemetry and threat-trend feeds."""
@@ -0,0 +1,77 @@
1
+ """Collect banned-IP telemetry from fail2ban.
2
+
3
+ Prefers `fail2ban-client` (authoritative current state); falls back to parsing
4
+ the fail2ban log for a historical view of bans over time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ from collections import Counter
11
+ from pathlib import Path
12
+
13
+ from ..util import have, is_root, run
14
+
15
+ _BAN_LINE = re.compile(
16
+ r"(?P<ts>\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}).*?\bBan\b\s+(?P<ip>\d{1,3}(?:\.\d{1,3}){3}|[0-9a-fA-F:]+)" # noqa: E501
17
+ )
18
+
19
+
20
+ def _jails() -> list[str]:
21
+ res = run(["fail2ban-client", "status"], timeout=20)
22
+ if not res.ok:
23
+ return []
24
+ m = re.search(r"Jail list:\s*(.+)", res.out)
25
+ if not m:
26
+ return []
27
+ return [j.strip() for j in m.group(1).split(",") if j.strip()]
28
+
29
+
30
+ def currently_banned() -> dict[str, list[str]]:
31
+ """{jail: [ip, ...]} of IPs banned right now. Needs fail2ban-client + root."""
32
+ out: dict[str, list[str]] = {}
33
+ if not (have("fail2ban-client") and is_root()):
34
+ return out
35
+ for jail in _jails():
36
+ res = run(["fail2ban-client", "status", jail], timeout=20)
37
+ if not res.ok:
38
+ continue
39
+ m = re.search(r"Banned IP list:\s*(.*)", res.out)
40
+ ips = m.group(1).split() if m else []
41
+ out[jail] = ips
42
+ return out
43
+
44
+
45
+ def historical_bans(log_glob: str = "/var/log/fail2ban.log*") -> Counter:
46
+ """Count Ban events per IP across (possibly rotated) fail2ban logs."""
47
+ counts: Counter = Counter()
48
+ import gzip
49
+ from glob import glob
50
+
51
+ for path in sorted(glob(log_glob)):
52
+ p = Path(path)
53
+ try:
54
+ opener = gzip.open if p.suffix == ".gz" else open
55
+ with opener(p, "rt", errors="ignore") as fh: # type: ignore[operator]
56
+ for line in fh:
57
+ m = _BAN_LINE.search(line)
58
+ if m:
59
+ counts[m.group("ip")] += 1
60
+ except OSError:
61
+ continue
62
+ return counts
63
+
64
+
65
+ def collect() -> dict:
66
+ """Aggregate fail2ban telemetry for the report layer."""
67
+ live = currently_banned()
68
+ hist = historical_bans()
69
+ live_total = sum(len(v) for v in live.values())
70
+ return {
71
+ "available": have("fail2ban-client"),
72
+ "live_by_jail": live,
73
+ "live_total": live_total,
74
+ "top_offenders": hist.most_common(50),
75
+ "unique_ips": len(hist),
76
+ "total_ban_events": sum(hist.values()),
77
+ }
@@ -0,0 +1,55 @@
1
+ """Lightweight attacker signal from auth logs (logwatch-style, no dependency).
2
+
3
+ If `logwatch` is installed we capture its summary; independently we parse
4
+ sshd auth failures directly so the report works even without logwatch.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import gzip
10
+ import re
11
+ from collections import Counter
12
+ from glob import glob
13
+ from pathlib import Path
14
+
15
+ from ..util import have, run
16
+
17
+ _IP = r"(\d{1,3}(?:\.\d{1,3}){3}|[0-9a-fA-F:]{3,})"
18
+ _FAILED = re.compile(rf"Failed password for .*? from {_IP}")
19
+ _INVALID = re.compile(rf"Invalid user \S+ from {_IP}")
20
+
21
+
22
+ def ssh_auth_failures(log_glob: str = "/var/log/auth.log*") -> Counter:
23
+ counts: Counter = Counter()
24
+ for path in sorted(glob(log_glob)):
25
+ p = Path(path)
26
+ try:
27
+ opener = gzip.open if p.suffix == ".gz" else open
28
+ with opener(p, "rt", errors="ignore") as fh: # type: ignore[operator]
29
+ for line in fh:
30
+ m = _FAILED.search(line) or _INVALID.search(line)
31
+ if m:
32
+ counts[m.group(1)] += 1
33
+ except OSError:
34
+ continue
35
+ return counts
36
+
37
+
38
+ def logwatch_summary() -> str | None:
39
+ if not have("logwatch"):
40
+ return None
41
+ res = run(
42
+ ["logwatch", "--range", "yesterday", "--service", "sshd", "--output", "stdout"],
43
+ timeout=120,
44
+ )
45
+ return res.out if res.ok else None
46
+
47
+
48
+ def collect() -> dict:
49
+ fails = ssh_auth_failures()
50
+ return {
51
+ "logwatch_available": have("logwatch"),
52
+ "ssh_failed_logins": fails.most_common(50),
53
+ "ssh_unique_sources": len(fails),
54
+ "ssh_total_attempts": sum(fails.values()),
55
+ }
@@ -0,0 +1,118 @@
1
+ """Recent attack-trend awareness.
2
+
3
+ Pulls CISA's Known Exploited Vulnerabilities (KEV) catalog — the authoritative
4
+ list of CVEs *observed being exploited in the wild* — and cross-references it
5
+ against software detected on this host. The point is to answer the operator's
6
+ real question: "of everything being actively attacked right now, does any of it
7
+ apply to me?"
8
+
9
+ Network access is required and entirely opt-in (``kevward trends`` or
10
+ ``report --trends``). The feed URL is configurable; results are cached so an
11
+ offline/locked-down host can still use the last snapshot.
12
+
13
+ No credentials, no outbound host data: this only downloads a public catalog.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import time
20
+ import urllib.request
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+
24
+ KEV_URL = (
25
+ "https://www.cisa.gov/sites/default/files/feeds/"
26
+ "known_exploited_vulnerabilities.json"
27
+ )
28
+ CACHE = Path("/var/cache/kevward/kev.json")
29
+ CACHE_TTL = 6 * 3600 # seconds
30
+
31
+
32
+ @dataclass
33
+ class KevEntry:
34
+ cve: str
35
+ vendor: str
36
+ product: str
37
+ name: str
38
+ date_added: str
39
+ due_date: str
40
+ ransomware: bool
41
+
42
+ @classmethod
43
+ def from_raw(cls, d: dict) -> KevEntry:
44
+ return cls(
45
+ cve=d.get("cveID", ""),
46
+ vendor=(d.get("vendorProject") or "").lower(),
47
+ product=(d.get("product") or "").lower(),
48
+ name=d.get("vulnerabilityName", ""),
49
+ date_added=d.get("dateAdded", ""),
50
+ due_date=d.get("dueDate", ""),
51
+ ransomware=(d.get("knownRansomwareCampaignUse", "") or "").lower() == "known",
52
+ )
53
+
54
+
55
+ def _load_catalog(url: str = KEV_URL, timeout: int = 30) -> list[KevEntry]:
56
+ """Fetch the KEV catalog, falling back to cache on any network error."""
57
+ raw: dict | None = None
58
+ try:
59
+ req = urllib.request.Request(url, headers={"User-Agent": "kevward"})
60
+ with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310
61
+ raw = json.loads(resp.read().decode("utf-8"))
62
+ try:
63
+ CACHE.parent.mkdir(parents=True, exist_ok=True)
64
+ CACHE.write_text(json.dumps(raw))
65
+ except OSError:
66
+ pass
67
+ except Exception:
68
+ if CACHE.is_file():
69
+ raw = json.loads(CACHE.read_text())
70
+ if not raw:
71
+ return []
72
+ return [KevEntry.from_raw(v) for v in raw.get("vulnerabilities", [])]
73
+
74
+
75
+ def _cache_fresh() -> bool:
76
+ try:
77
+ return CACHE.is_file() and (time.time() - CACHE.stat().st_mtime) < CACHE_TTL
78
+ except OSError:
79
+ return False
80
+
81
+
82
+ # Keywords describing software this host may run, mapped to KEV vendor/product
83
+ # substrings. Extend freely — matching is deliberately broad, then surfaced for
84
+ # a human to judge rather than acted on automatically.
85
+ _HOST_KEYWORDS = [
86
+ "nginx", "apache", "php", "openssh", "openssl", "wordpress", "node",
87
+ "nodejs", "laravel", "linux", "ubuntu", "mysql", "mariadb", "postgres",
88
+ "redis", "docker", "fortinet", "cisco", "citrix", "vmware",
89
+ ]
90
+
91
+
92
+ def matching_trends(
93
+ host_terms: list[str] | None = None,
94
+ url: str = KEV_URL,
95
+ ) -> dict:
96
+ """Return KEV entries plausibly relevant to this host, plus summary stats.
97
+
98
+ ``host_terms`` lets callers pass detected product names (e.g. from the
99
+ service plugins). When omitted, a broad built-in keyword list is used.
100
+ """
101
+ catalog = _load_catalog(url)
102
+ terms = [t.lower() for t in (host_terms or _HOST_KEYWORDS)]
103
+ hits: list[KevEntry] = []
104
+ for e in catalog:
105
+ # Match only on vendor/product, never the free-text vulnerability name:
106
+ # a name like "...PHP Remote File Inclusion" for a Zimbra/Juniper CVE
107
+ # would otherwise false-match a host that merely runs PHP.
108
+ hay = f"{e.vendor} {e.product}".lower()
109
+ if any(t in hay for t in terms):
110
+ hits.append(e)
111
+ hits.sort(key=lambda e: e.date_added, reverse=True)
112
+ return {
113
+ "catalog_size": len(catalog),
114
+ "cache_fresh": _cache_fresh(),
115
+ "match_count": len(hits),
116
+ "ransomware_linked": sum(1 for e in hits if e.ransomware),
117
+ "matches": [e.__dict__ for e in hits[:100]],
118
+ }