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 +11 -0
- kevward/cli.py +212 -0
- kevward/config.py +72 -0
- kevward/engine.py +56 -0
- kevward/integrations/__init__.py +1 -0
- kevward/integrations/fail2ban.py +77 -0
- kevward/integrations/logwatch.py +55 -0
- kevward/integrations/trends.py +118 -0
- kevward/model.py +116 -0
- kevward/plugin.py +116 -0
- kevward/plugins/__init__.py +5 -0
- kevward/plugins/_apt.py +119 -0
- kevward/plugins/_composer.py +55 -0
- kevward/plugins/apache.py +42 -0
- kevward/plugins/apt_base.py +57 -0
- kevward/plugins/composer.py +52 -0
- kevward/plugins/laravel.py +87 -0
- kevward/plugins/mariadb.py +14 -0
- kevward/plugins/mysql.py +14 -0
- kevward/plugins/nginx.py +51 -0
- kevward/plugins/nodejs.py +116 -0
- kevward/plugins/php.py +54 -0
- kevward/plugins/postgresql.py +14 -0
- kevward/plugins/redis.py +14 -0
- kevward/plugins/sshd.py +66 -0
- kevward/plugins/ufw.py +33 -0
- kevward/plugins/wordpress.py +104 -0
- kevward/reporting/__init__.py +1 -0
- kevward/reporting/html.py +186 -0
- kevward/scheduler.py +51 -0
- kevward/util.py +60 -0
- kevward-0.2.0.dist-info/METADATA +166 -0
- kevward-0.2.0.dist-info/RECORD +37 -0
- kevward-0.2.0.dist-info/WHEEL +5 -0
- kevward-0.2.0.dist-info/entry_points.txt +2 -0
- kevward-0.2.0.dist-info/licenses/LICENSE +661 -0
- kevward-0.2.0.dist-info/top_level.txt +1 -0
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
|
+
}
|