sshsentry 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.
sshsentry.py ADDED
@@ -0,0 +1,1341 @@
1
+ #!/usr/bin/env python3
2
+ """sshsentry: a tiny, detection-only intrusion watcher for Linux SSH servers.
3
+
4
+ Rules: brute force, login after failures, login from a new IP/country, and new
5
+ authorized_keys lines or /etc/passwd users. State lives in SQLite.
6
+ Requires Python 3.8+ and PyYAML; geoip2 + GeoLite2-Country.mmdb are optional.
7
+ Optional, off-by-default extras: alert destinations (email / Slack / webhook),
8
+ threshold learn mode, a daily digest, and community sharing of attacking IPs only (see README).
9
+ """
10
+ import argparse, glob, hashlib, ipaddress, json, math, os, re, smtplib, sqlite3, sys, time, uuid, zipfile
11
+ import urllib.error, urllib.parse, urllib.request
12
+ from datetime import datetime, timedelta, timezone
13
+ from email.message import EmailMessage
14
+
15
+ try:
16
+ import yaml
17
+ except ImportError:
18
+ sys.exit("PyYAML is required: pip install pyyaml")
19
+
20
+ VERSION = "0.2.0"
21
+
22
+ DEFAULTS = {
23
+ "log_file": "auto", # auto = /var/log/auth.log or /var/log/secure
24
+ "state_db": "/var/lib/sshsentry/state.db",
25
+ "poll_seconds": 1.0, # tail-follow polling interval
26
+ "file_check_seconds": 60, # how often to re-check passwd/authorized_keys
27
+ "alert_log": "", # optional file to append alerts to (JSON lines)
28
+ "cooldown_minutes": 60, # do not repeat the same alert within this time
29
+ "rules": {
30
+ "brute_force": {"enabled": True, "failures": 5, "window_minutes": 10},
31
+ "login_after_failures": {"enabled": True, "failures": 5, "lookback_minutes": 60},
32
+ "new_ip": {"enabled": True}, "new_country": {"enabled": True},
33
+ "new_user": {"enabled": True, "passwd_file": "/etc/passwd"},
34
+ "new_ssh_key": {"enabled": True,
35
+ "paths": ["/root/.ssh/authorized_keys", "/home/*/.ssh/authorized_keys"]},
36
+ },
37
+ "learn_existing_log": True, # first start: read existing log silently to learn normal IPs
38
+ # Opt-in learn mode: for `days`, alerts print to stdout marked "learning" but are never sent
39
+ # anywhere, while per-IP failure counts per brute-force window are recorded to suggest a
40
+ # threshold. The manual rules.brute_force.failures stays in force unless you accept it.
41
+ "learn_mode": {"enabled": False, "days": 3, "percentile": 99, "margin": 2,
42
+ "min_threshold": 3, "max_threshold": 50},
43
+ "geoip": {"database": "/usr/share/GeoIP/GeoLite2-Country.mmdb"},
44
+ "email": {"enabled": False, "smtp_host": "", "smtp_port": 587, "starttls": True, "username": "",
45
+ "password_env": "SSHSENTRY_SMTP_PASSWORD", "password": "", "from": "", "to": [],
46
+ "subject_prefix": "[sshsentry]"},
47
+ "sharing": {"enabled": False, "endpoint": "", "timeout": 5}, # opt-in only
48
+ # Alert destinations (stdout is always on). SMTP settings for alerting.email fall back to
49
+ # the legacy top-level `email:` block, which keeps working on its own as before.
50
+ # Daily summary at `time` (local) to one destination, reusing that destination's alerting settings.
51
+ # Daily digest: generated + stored locally at `time` (local) every day when enabled; delivery
52
+ # to one destination (reusing its alerting settings) is controlled separately by `send`.
53
+ "digest": {"enabled": False, "time": "08:00", "send": True, "destination": "email",
54
+ "send_when_quiet": True, "save_text": True, "text_dir": "", "host_label": "",
55
+ "include_chain_hash": True},
56
+ "alerting": {"enabled": False, "timeout": 5,
57
+ "email": {"enabled": False, "min_severity": "LOW"},
58
+ "slack": {"enabled": False, "min_severity": "MEDIUM", "webhook_url": "",
59
+ "webhook_url_env": "SSHSENTRY_SLACK_WEBHOOK"},
60
+ "webhook": {"enabled": False, "min_severity": "LOW", "url": "",
61
+ "url_env": "SSHSENTRY_WEBHOOK_URL", "headers": {}}},
62
+ }
63
+ SEVERITIES = ["LOW", "MEDIUM", "HIGH", "CRITICAL"]
64
+ # Only alerts about an *attacker* are ever shared: never new-IP/country logins of your
65
+ # own users, and never new-user/new-key alerts (they have no attacker IP).
66
+ SHARED_RULES = ("brute_force", "login_after_failures")
67
+ # Private / local source addresses are never shared (useless for a blocklist, and they
68
+ # reveal your internal network).
69
+ LOCAL_NETS = [ipaddress.ip_network(n) for n in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
70
+ "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16", "::1/128", "fc00::/7", "fe80::/10")]
71
+
72
+ SCHEMA = ["meta (k TEXT PRIMARY KEY, v TEXT)", "failures (ip TEXT, user TEXT, ts REAL)",
73
+ "seen_ips (user TEXT, ip TEXT, first_seen REAL, PRIMARY KEY (user, ip))",
74
+ "seen_countries (user TEXT, country TEXT, PRIMARY KEY (user, country))",
75
+ "approved_ips (ip TEXT PRIMARY KEY)", "users (name TEXT PRIMARY KEY)",
76
+ "keys (path TEXT, line_hash TEXT, PRIMARY KEY (path, line_hash))",
77
+ "alerts (rule TEXT, key TEXT, ts REAL, PRIMARY KEY (rule, key))",
78
+ "shared (id INTEGER PRIMARY KEY, ip TEXT, rule TEXT, event_minute TEXT, sent_at REAL, "
79
+ "status TEXT, payload TEXT)",
80
+ # IPs already alerted for brute force (drives the one-time success escalation)
81
+ "alerted_ips (ip TEXT PRIMARY KEY, alerted_ts REAL, escalated INTEGER)",
82
+ # learn mode: failed logins per IP per brute-force window
83
+ "learn_windows (ip TEXT, window_start INTEGER, count INTEGER, PRIMARY KEY (ip, window_start))",
84
+ # successful logins (for the daily digest) and digests already produced
85
+ "logins (ts REAL, user TEXT, ip TEXT, method TEXT, failures_before INTEGER)",
86
+ "daily_digests (date TEXT PRIMARY KEY, period_start REAL, period_end REAL, created_at REAL, "
87
+ "stats TEXT, text TEXT, status TEXT, destination TEXT, error TEXT, prev_hash TEXT, chain_hash TEXT)"]
88
+
89
+ # Log line formats: classic syslog ("Oct 17 02:10:01 host sshd[123]: msg")
90
+ # and ISO-8601 rsyslog ("2026-10-17T02:10:01.123-04:00 host sshd[123]: msg").
91
+ LINE_RE = re.compile(r"^(?P<ts>[A-Z][a-z]{2}\s+\d{1,2} \d\d:\d\d:\d\d|\d{4}-\d\d-\d\dT\S+)"
92
+ r" \S+ (?P<proc>[\w.\-/]+)(?:\[\d+\])?: (?P<msg>.*)$")
93
+ IP = r"(?P<ip>[0-9a-fA-F:.]+)"
94
+ # FAIL_RE deliberately does not match "Failed password for invalid user x": the
95
+ # "Invalid user x" line before it is counted instead (catches key-only servers too).
96
+ FAIL_RE = re.compile(r"Failed (?:password|publickey|keyboard-interactive/pam) for (?P<user>\S+) from " + IP)
97
+ INVALID_RE = re.compile(r"Invalid user (?P<user>\S*) from " + IP)
98
+ OK_RE = re.compile(r"Accepted (?P<method>\S+) for (?P<user>\S+) from " + IP)
99
+
100
+
101
+ def merge(base, over):
102
+ """Recursively merge user config over defaults."""
103
+ out = dict(base)
104
+ for k, v in (over or {}).items():
105
+ out[k] = merge(base[k], v) if isinstance(v, dict) and isinstance(base.get(k), dict) else v
106
+ return out
107
+
108
+
109
+ def parse_ts(raw):
110
+ """Turn a log timestamp into epoch seconds (syslog lines carry no year)."""
111
+ if raw[0].isdigit():
112
+ return datetime.fromisoformat(raw.replace("Z", "+00:00")).timestamp()
113
+ now = datetime.now()
114
+ dt = datetime.strptime(f"{now.year} {' '.join(raw.split())}", "%Y %b %d %H:%M:%S")
115
+ if dt.timestamp() > now.timestamp() + 86400: # e.g. December lines read in January
116
+ dt = dt.replace(year=now.year - 1)
117
+ return dt.timestamp()
118
+
119
+
120
+ def fmt(ts):
121
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
122
+
123
+
124
+ def endpoint_problem(url):
125
+ """Sharing endpoints must be https:// (plain http:// only to localhost, for testing)."""
126
+ u = urllib.parse.urlparse(url or "")
127
+ if u.hostname and (u.scheme == "https" or (u.scheme == "http" and u.hostname in ("localhost", "127.0.0.1", "::1"))):
128
+ return None
129
+ return f"endpoint must be https:// (http:// allowed only for localhost), got {url!r}"
130
+
131
+
132
+ def post_json(url, payload, timeout, headers=None):
133
+ """POST a JSON payload with stdlib urllib. Returns a status string; never raises."""
134
+ if endpoint_problem(url):
135
+ return "refused: " + endpoint_problem(url)
136
+ hdrs = {"Content-Type": "application/json", "User-Agent": f"sshsentry/{VERSION}", **(headers or {})}
137
+ req = urllib.request.Request(url, data=json.dumps(payload).encode(), method="POST", headers=hdrs)
138
+ try:
139
+ with urllib.request.urlopen(req, timeout=float(timeout)) as r:
140
+ return f"sent (HTTP {r.status})"
141
+ except urllib.error.HTTPError as e:
142
+ return f"failed (HTTP {e.code})"
143
+ except Exception as e:
144
+ return f"failed ({type(e).__name__}: {getattr(e, 'reason', e)})"
145
+
146
+
147
+ # ---------- digest integrity chain ----------
148
+ # chain_hash(day) = sha256(prev_chain_hash + canonical(day)); the first day uses GENESIS.
149
+ # GENESIS is a fixed public constant, so anyone can recompute a chain from an export.
150
+ GENESIS = hashlib.sha256(b"sshsentry-genesis-v1").hexdigest()
151
+
152
+
153
+ def canonical(rec):
154
+ """Canonical bytes of one digest: sorted-key compact JSON of date, period, stats and text."""
155
+ keep = {k: rec[k] for k in ("date", "period_start", "period_end", "stats", "text")}
156
+ return json.dumps(keep, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
157
+
158
+
159
+ def chain_next(prev, rec):
160
+ return hashlib.sha256((prev + canonical(rec)).encode()).hexdigest()
161
+
162
+
163
+ def with_chain(text, chain):
164
+ """The digest text as delivered / saved: body plus a short and full chain hash."""
165
+ return f"{text}\n\nIntegrity chain: {chain[:12]} (sha256 {chain})"
166
+
167
+
168
+ def iso(ts):
169
+ return datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") if ts else None
170
+
171
+
172
+ def emit(obj):
173
+ """Print one structured JSON document (for --format json)."""
174
+ print(json.dumps(obj, indent=2, sort_keys=False))
175
+
176
+
177
+ def normalize(cfg, user_cfg):
178
+ """Map legacy config keys onto the current ones (old configs keep working)."""
179
+ old = user_cfg.get("learning") or {}
180
+ if "learn_existing_log" in old and "learn_existing_log" not in user_cfg:
181
+ cfg["learn_existing_log"] = old["learn_existing_log"]
182
+ if float(old.get("days") or 0) > 0 and not (user_cfg.get("learn_mode") or {}).get("enabled"):
183
+ print("[sshsentry] note: learning.days is deprecated; treating it as learn_mode "
184
+ f"(enabled, days: {old['days']})", file=sys.stderr)
185
+ cfg["learn_mode"].update(enabled=True, days=old["days"])
186
+ return cfg
187
+
188
+
189
+ def host_of(url):
190
+ """Webhook URLs are secrets: only ever log the host part."""
191
+ return urllib.parse.urlparse(url or "").hostname or "(no URL)"
192
+
193
+
194
+ def slack_escape(text):
195
+ """Escape Slack mrkdwn control characters (attacker-chosen usernames end up in messages)."""
196
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
197
+
198
+
199
+ def build_destinations(cfg, ignore_master=False):
200
+ """Validate configured alert destinations; warn about and drop broken ones. Never raises."""
201
+ al, legacy, out = cfg["alerting"], cfg["email"], []
202
+ on = al.get("enabled") or ignore_master
203
+ def warn(name, why):
204
+ print(f"[sshsentry] warning: {name} alert destination DISABLED: {why}", file=sys.stderr)
205
+ def level(d, name):
206
+ sev = str(d.get("min_severity") or "LOW").upper()
207
+ if sev not in SEVERITIES:
208
+ print(f"[sshsentry] warning: {name}.min_severity {sev!r} unknown; using LOW", file=sys.stderr)
209
+ sev = "LOW"
210
+ return SEVERITIES.index(sev)
211
+ # Email: alerting.email inherits SMTP settings from the legacy top-level `email:` block.
212
+ if (on and al["email"].get("enabled")) or legacy.get("enabled"):
213
+ e = merge(legacy, {k: v for k, v in al["email"].items() if k != "enabled"})
214
+ e["password_value"] = os.environ.get(e.get("password_env") or "", "") or e.get("password", "")
215
+ to = e.get("to") or []
216
+ if not e.get("smtp_host"):
217
+ warn("email", "smtp_host is not set")
218
+ elif not to or not (e.get("from") or e.get("username")):
219
+ warn("email", "'to' and 'from' (or 'username') are required")
220
+ elif e.get("username") and not e["password_value"]:
221
+ warn("email", f"username is set but no password (set ${e.get('password_env')})")
222
+ else:
223
+ out.append({"name": "email", "kind": "email", "min": level(e, "email"), "cfg": e})
224
+ for name, key, env_key in (("slack", "webhook_url", "webhook_url_env"), ("webhook", "url", "url_env")):
225
+ d = al[name]
226
+ if not (on and d.get("enabled")):
227
+ continue
228
+ url = os.environ.get(d.get(env_key) or "", "") or d.get(key, "")
229
+ headers = d.get("headers") or {}
230
+ if endpoint_problem(url):
231
+ warn(name, "URL missing or not https:// (http:// allowed only for localhost); "
232
+ f"set {key} or ${d.get(env_key)}" if url else f"no URL (set {key} or ${d.get(env_key)})")
233
+ elif not isinstance(headers, dict) or not all(isinstance(v, str) for v in headers.values()):
234
+ warn(name, "headers must be a mapping of header name -> string value")
235
+ else:
236
+ out.append({"name": name, "kind": name, "min": level(d, name), "url": url,
237
+ "headers": {str(k): v for k, v in headers.items()}})
238
+ return out
239
+
240
+
241
+ class Agent:
242
+ def __init__(self, cfg, dry_run=False, admin=False):
243
+ self.cfg, self.rules, self.dry_run = cfg, cfg["rules"], dry_run
244
+ self.learning = False # True = ingesting old history: record, stay silent
245
+ os.makedirs(os.path.dirname(cfg["state_db"]) or ".", exist_ok=True)
246
+ self.db = sqlite3.connect(cfg["state_db"])
247
+ for ddl in SCHEMA:
248
+ self.db.execute(f"CREATE TABLE IF NOT EXISTS {ddl}")
249
+ self.collect = None # list = collect alerts for --format json instead of printing
250
+ self.upgrade_chain()
251
+ self.db.execute("CREATE INDEX IF NOT EXISTS fail_ip ON failures (ip, ts)")
252
+ if self.meta("first_run") is None:
253
+ self.set_meta("first_run", time.time())
254
+ self.geo = None if admin else self.open_geoip() # admin commands need no GeoIP
255
+ sh = cfg["sharing"]
256
+ if sh.get("enabled") and endpoint_problem(sh.get("endpoint")):
257
+ print(f"[sshsentry] sharing DISABLED: {endpoint_problem(sh.get('endpoint'))}", file=sys.stderr)
258
+ sh["enabled"] = False
259
+ self.dests = [] if admin else build_destinations(cfg)
260
+ lm = cfg["learn_mode"]
261
+ if lm.get("enabled") and self.meta("learn_start") is None:
262
+ self.set_meta("learn_start", time.time())
263
+
264
+ def upgrade_chain(self):
265
+ """Upgrade older databases: add the chain columns and backfill hashes for old digests."""
266
+ cols = [r[1] for r in self.db.execute("PRAGMA table_info(daily_digests)")]
267
+ for c in ("prev_hash", "chain_hash"):
268
+ if c not in cols:
269
+ self.db.execute(f"ALTER TABLE daily_digests ADD COLUMN {c} TEXT")
270
+ rows = self.db.execute("SELECT date, chain_hash FROM daily_digests ORDER BY date").fetchall()
271
+ if not any(ch is None for _, ch in rows):
272
+ return
273
+ prev, n = GENESIS, 0
274
+ for date, ch in rows:
275
+ if ch is None:
276
+ rec = self.digest_record(date)
277
+ ch = chain_next(prev, rec)
278
+ self.db.execute("UPDATE daily_digests SET prev_hash=?, chain_hash=? WHERE date=?", (prev, ch, date))
279
+ n += 1
280
+ path = os.path.join(self.digest_dir(), f"{date}.txt")
281
+ try: # add the chain line to an untouched pre-upgrade text copy
282
+ if os.path.exists(path) and open(path).read() == rec["text"] + "\n":
283
+ with open(path, "w") as f:
284
+ f.write(with_chain(rec["text"], ch) + "\n")
285
+ except OSError:
286
+ pass
287
+ prev = ch
288
+ self.db.commit()
289
+ print(f"[sshsentry] note: added integrity chain hashes to {n} existing digest(s). The chain protects "
290
+ "them from now on; it can't vouch for changes made before this upgrade.", file=sys.stderr)
291
+
292
+ def digest_record(self, date):
293
+ """Stored digest as a dict (the canonical fields plus metadata), or None."""
294
+ row = self.q("SELECT date, period_start, period_end, created_at, stats, text, status, destination, "
295
+ "error, prev_hash, chain_hash FROM daily_digests WHERE date=?", date)
296
+ if not row:
297
+ return None
298
+ keys = ("date", "period_start", "period_end", "created_at", "stats", "text", "status", "destination",
299
+ "error", "prev_hash", "chain_hash")
300
+ rec = dict(zip(keys, row))
301
+ rec["stats"] = json.loads(rec["stats"])
302
+ return rec
303
+
304
+ # ---------- small helpers ----------
305
+ def q(self, sql, *args):
306
+ """Return the first row of a query (or None)."""
307
+ return self.db.execute(sql, args).fetchone()
308
+
309
+ def meta(self, k):
310
+ row = self.q("SELECT v FROM meta WHERE k=?", k)
311
+ return row[0] if row else None
312
+
313
+ def set_meta(self, k, v):
314
+ self.db.execute("INSERT OR REPLACE INTO meta VALUES (?, ?)", (k, str(v)))
315
+ self.db.commit()
316
+
317
+ def learn_active(self):
318
+ """True while opt-in learn mode is running (alerts print but are never sent)."""
319
+ lm = self.cfg["learn_mode"]
320
+ if not lm.get("enabled") or self.meta("learn_start") is None:
321
+ return False
322
+ return time.time() < float(self.meta("learn_start")) + float(lm.get("days") or 0) * 86400
323
+
324
+ def open_geoip(self):
325
+ """Country lookups are optional: skip with a note if geoip2 or the database is missing."""
326
+ if not self.rules["new_country"].get("enabled"):
327
+ return None
328
+ try:
329
+ import geoip2.database
330
+ return geoip2.database.Reader(self.cfg["geoip"]["database"])
331
+ except Exception as e:
332
+ print(f"[sshsentry] note: country checks disabled ({type(e).__name__}: {e})", file=sys.stderr)
333
+
334
+ def country(self, ip):
335
+ try:
336
+ return self.geo.country(ip).country.iso_code if self.geo else None
337
+ except Exception:
338
+ return None # private or unknown address
339
+
340
+ # ---------- alerting ----------
341
+ def alert(self, severity, rule, key, ts, text, attacker=None, notify=None, share_as=None):
342
+ """Print an alert and fan it out, respecting the per-rule cool-down. Returns True if raised.
343
+ `notify` = (concise text, usernames, success) for destinations; defaults to the stdout text."""
344
+ if self.learning:
345
+ return False
346
+ row = self.q("SELECT ts FROM alerts WHERE rule=? AND key=?", rule, key)
347
+ if row and abs(ts - row[0]) < self.cfg["cooldown_minutes"] * 60:
348
+ return False
349
+ self.db.execute("INSERT OR REPLACE INTO alerts VALUES (?, ?, ?)", (rule, key, ts))
350
+ self.db.commit()
351
+ learning = self.learn_active()
352
+ line = f"[{fmt(ts)}] {severity}: {text}"
353
+ if self.collect is not None:
354
+ self.collect.append({"time": fmt(ts), "timestamp": iso(ts), "severity": severity, "rule": rule,
355
+ "message": text, "learning": learning})
356
+ else:
357
+ print(("[learning: not sent] " if learning else "") + line, flush=True)
358
+ if self.cfg.get("alert_log"):
359
+ with open(self.cfg["alert_log"], "a") as f:
360
+ f.write(json.dumps({"time": fmt(ts), "severity": severity, "rule": rule, "message": text,
361
+ "learning": learning}) + "\n")
362
+ if learning:
363
+ return True # learn mode: nothing leaves the machine
364
+ msg, users, success = notify or (text, [], None)
365
+ self.dispatch({"severity": severity, "rule": rule, "ip": attacker, "usernames": users,
366
+ "success": success, "timestamp": datetime.fromtimestamp(ts, timezone.utc)
367
+ .strftime("%Y-%m-%dT%H:%M:%SZ"), "message": msg})
368
+ if attacker and (share_as or rule) in SHARED_RULES:
369
+ self.share(share_as or rule, attacker, ts)
370
+ return True
371
+
372
+ # ---------- alert destinations ----------
373
+ def dispatch(self, event, dests=None, force=False):
374
+ """Send one alert event to every destination at or above its min_severity. Never raises.
375
+ --replay never sends; --test-alerts uses force=True to bypass severity filters."""
376
+ if self.dry_run and not force:
377
+ return []
378
+ results = []
379
+ for d in (self.dests if dests is None else dests):
380
+ if not force and SEVERITIES.index(event["severity"]) < d["min"]:
381
+ continue
382
+ status = self.send_to(d, event)
383
+ results.append((d["name"], status))
384
+ if not status.startswith("sent"):
385
+ print(f"[sshsentry] {d['name']} alert failed: {status}", file=sys.stderr)
386
+ return results
387
+
388
+ def send_to(self, d, event):
389
+ timeout = self.cfg["alerting"].get("timeout", 5)
390
+ line = f"{event['severity']}: {event['message']}"
391
+ if d["kind"] == "email":
392
+ return self.send_email(d["cfg"], f"{event['severity']}: {event['message'].split('.')[0][:120]}",
393
+ f"[{event['timestamp']}] {line}", timeout)
394
+ if d["kind"] == "slack":
395
+ return post_json(d["url"], {"text": slack_escape(f"[sshsentry] {line}")}, timeout)
396
+ return post_json(d["url"], event, timeout, d["headers"]) # generic JSON webhook
397
+
398
+ def test_alerts(self):
399
+ """--test-alerts: send one sample alert to each enabled destination and report status."""
400
+ dests = build_destinations(self.cfg, ignore_master=True)
401
+ if not self.cfg["alerting"].get("enabled"):
402
+ print("Note: alerting.enabled is false, so real alerts are not sent yet (testing anyway).")
403
+ if not dests:
404
+ print("No usable alert destinations are enabled (see warnings above, if any).")
405
+ return False
406
+ event = {"severity": "LOW", "rule": "test", "ip": None, "usernames": [], "success": None,
407
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
408
+ "message": "Test alert from sshsentry. If you can read this, this destination works."}
409
+ ok = True
410
+ for name, status in self.dispatch(event, dests, force=True):
411
+ target = next((host_of(d.get("url")) for d in dests if d["name"] == name), "")
412
+ print(f" {name:<8} {target if name != 'email' else ''} -> {status}")
413
+ ok = ok and status.startswith("sent")
414
+ return ok
415
+
416
+ # ---------- opt-in sharing ----------
417
+ def install_id(self):
418
+ """Random ID created locally the first time something is shared; links nothing to you."""
419
+ if self.meta("install_id") is None:
420
+ self.set_meta("install_id", uuid.uuid4())
421
+ return self.meta("install_id")
422
+
423
+ def share(self, rule, ip, ts):
424
+ """Report an attacking IP if sharing is on. Records every attempt; never raises."""
425
+ sh = self.cfg["sharing"]
426
+ try:
427
+ addr = ipaddress.ip_address(ip)
428
+ except ValueError:
429
+ return
430
+ if (not sh.get("enabled") or self.dry_run or any(addr in n for n in LOCAL_NETS)
431
+ or self.q("SELECT 1 FROM approved_ips WHERE ip=?", ip)):
432
+ return
433
+ minute = datetime.fromtimestamp(ts - ts % 60, timezone.utc).strftime("%Y-%m-%dT%H:%M:00Z")
434
+ # The complete payload. Nothing else (usernames, hostnames, log text, server IP) is sent.
435
+ payload = {"ip": ip, "rule": rule, "timestamp": minute,
436
+ "sshsentry_version": VERSION, "install_id": self.install_id()}
437
+ status = post_json(sh["endpoint"], payload, sh.get("timeout", 5))
438
+ self.db.execute("INSERT INTO shared (ip, rule, event_minute, sent_at, status, payload) "
439
+ "VALUES (?, ?, ?, ?, ?, ?)", (ip, rule, minute, time.time(), status, json.dumps(payload)))
440
+ self.db.commit()
441
+ if not status.startswith("sent"):
442
+ print(f"[sshsentry] sharing report for {ip} {status}", file=sys.stderr)
443
+
444
+ def show_shared(self, as_json=False):
445
+ sh = self.cfg["sharing"]
446
+ if as_json:
447
+ rows = self.db.execute("SELECT id, ip, rule, event_minute, sent_at, status, payload FROM shared "
448
+ "ORDER BY id").fetchall()
449
+ emit({"schema": "sshsentry.shared/1", "sharing_enabled": bool(sh.get("enabled")),
450
+ "endpoint": sh.get("endpoint") or None, "install_id": self.meta("install_id"),
451
+ "reports": [{"id": i, "ip": ip, "rule": r, "event_minute": m, "sent_at": iso(t),
452
+ "status": st, "payload": json.loads(pl)} for i, ip, r, m, t, st, pl in rows]})
453
+ return
454
+ state = f"ON -> {sh['endpoint']}" if sh.get("enabled") else "OFF"
455
+ print(f"Sharing is {state}. Install ID: {self.meta('install_id') or '(none generated)'}")
456
+ rows = self.db.execute("SELECT id, ip, rule, event_minute, sent_at, status FROM shared ORDER BY id").fetchall()
457
+ if not rows:
458
+ print("Nothing has been shared yet: no reports are recorded in this install's database.")
459
+ return
460
+ print(f"{len(rows)} report(s) recorded. Each report contains exactly: ip, rule, timestamp, sshsentry_version, install_id.")
461
+ w = max(11, *(len(r[1]) for r in rows))
462
+ print(f"{'#':>4} {'ATTACKER IP':<{w}} {'RULE':<20} {'EVENT (UTC)':<20} {'SENT AT (local)':<19} STATUS")
463
+ for i, ip, rule, minute, sent, status in rows:
464
+ print(f"{i:>4} {ip:<{w}} {rule:<20} {minute:<20} {fmt(sent):<19} {status}")
465
+
466
+ def forget_me(self, local_only=False):
467
+ """Delete local sharing history first; then (best effort) ask the endpoint to delete too."""
468
+ n = self.db.execute("DELETE FROM shared").rowcount
469
+ old_id = self.meta("install_id")
470
+ self.db.execute("DELETE FROM meta WHERE k='install_id'") # next share gets a fresh ID
471
+ self.db.commit()
472
+ print(f"Deleted {n} shared-report record(s) from the local database ({self.cfg['state_db']}).")
473
+ sh = self.cfg["sharing"]
474
+ if old_id and sh.get("endpoint") and not local_only:
475
+ status = post_json(sh["endpoint"], {"request": "delete", "install_id": old_id,
476
+ "sshsentry_version": VERSION}, sh.get("timeout", 5))
477
+ print(f"Remote deletion request for install ID {old_id}: {status}")
478
+ if old_id:
479
+ print(f"Old install ID: {old_id} (keep it to request deletion by email if needed). "
480
+ "A new random ID will be created if you share again.")
481
+ if sh.get("enabled"):
482
+ print("Note: sharing is still enabled; set sharing.enabled: false to stop future reports.")
483
+
484
+ def send_email(self, e, subject, body, timeout):
485
+ msg = EmailMessage()
486
+ msg["Subject"], msg["From"] = f"{e['subject_prefix']} {subject}", e["from"] or e["username"]
487
+ msg["To"] = ", ".join(e["to"]) if isinstance(e["to"], list) else e["to"]
488
+ msg.set_content(body + "\n\n-- sshsentry (detection only; review and respond manually)")
489
+ try:
490
+ with smtplib.SMTP(e["smtp_host"], int(e["smtp_port"]), timeout=float(timeout)) as s:
491
+ if e.get("starttls", True):
492
+ s.starttls()
493
+ if e.get("username"):
494
+ s.login(e["username"], e["password_value"])
495
+ s.send_message(msg)
496
+ return "sent"
497
+ except Exception as ex: # never let a mail problem stop monitoring
498
+ return f"failed ({type(ex).__name__}: {ex})"
499
+
500
+ # ---------- log rules ----------
501
+ def handle_line(self, line):
502
+ m = LINE_RE.match(line.strip())
503
+ if not m or not m.group("proc").startswith("sshd"): # sshd or sshd-session
504
+ return
505
+ try:
506
+ msg, ts = m.group("msg"), parse_ts(m.group("ts"))
507
+ except ValueError: # malformed timestamp (e.g. Feb 29 guessed into a non-leap year)
508
+ return
509
+ if (f := INVALID_RE.search(msg) or FAIL_RE.search(msg)):
510
+ self.on_failure(f.group("ip"), f.group("user") or "(empty)", ts)
511
+ elif (ok := OK_RE.search(msg)):
512
+ self.on_success(ok.group("ip"), ok.group("user"), ok.group("method"), ts)
513
+
514
+ def on_failure(self, ip, user, ts):
515
+ self.db.execute("INSERT INTO failures VALUES (?, ?, ?)", (ip, user, ts))
516
+ self.db.execute("DELETE FROM failures WHERE ts < ?", (ts - 8 * 86400,)) # 8 days (digest catch-up)
517
+ r = self.rules["brute_force"]
518
+ if self.learn_active(): # learn mode: count failures per IP per (tumbling) window
519
+ w = int(r["window_minutes"] * 60)
520
+ self.db.execute("INSERT INTO learn_windows VALUES (?, ?, 1) ON CONFLICT (ip, window_start) "
521
+ "DO UPDATE SET count = count + 1", (ip, int(ts // w * w)))
522
+ if not r.get("enabled"):
523
+ return
524
+ rows = self.db.execute("SELECT user, ts FROM failures WHERE ip=? AND ts BETWEEN ? AND ?",
525
+ (ip, ts - r["window_minutes"] * 60, ts)).fetchall()
526
+ if len(rows) >= r["failures"]:
527
+ users = sorted({u for u, _ in rows})
528
+ span = ts - min(t for _, t in rows)
529
+ took = f"{int(span)} sec" if span < 120 else f"{round(span / 60)} min"
530
+ tried = f"{', '.join(u[:64] for u in users[:8])}{' ...' if len(users) > 8 else ''}"
531
+ prior = self.q("SELECT user FROM seen_ips WHERE ip=?", ip)
532
+ status = f"a successful login from this IP was seen earlier (user '{prior[0]}')" if prior \
533
+ else "no successful login yet"
534
+ if self.alert("MEDIUM", "brute_force", ip, ts,
535
+ f"Brute force. {len(rows)} failed SSH logins from {ip} in {took}, trying "
536
+ f"user(s): {tried}. "
537
+ "Consider fail2ban, a firewall block, or disabling password logins.", attacker=ip,
538
+ notify=(f"Brute force from {ip}: {len(rows)} failed SSH logins in {took}; "
539
+ f"users tried: {tried}; {status}.", users[:8], bool(prior))):
540
+ # Remember the alert so a later success from this IP escalates exactly once.
541
+ self.db.execute("INSERT OR REPLACE INTO alerted_ips VALUES (?, ?, 0)", (ip, ts))
542
+ self.db.commit()
543
+
544
+ def on_success(self, ip, user, method, ts):
545
+ r = self.rules["login_after_failures"]
546
+ hit = self.q("SELECT alerted_ts FROM alerted_ips WHERE ip=? AND escalated=0", ip)
547
+ if hit and self.cfg["alerting"].get("enabled"):
548
+ # Escalation: an IP we already alerted on for brute force just got in. This replaces
549
+ # the login_after_failures alert for this login, and fires once per brute-force alert.
550
+ fails = self.db.execute("SELECT user FROM failures WHERE ip=? AND ts<=?", (ip, ts)).fetchall()
551
+ tried = sorted({u for (u,) in fails})
552
+ text = (f"ESCALATION: successful login from {ip} after brute force. User '{user}' logged in "
553
+ f"via {method} after {len(fails)} failed attempts from that IP (alerted at "
554
+ f"{fmt(hit[0])}; users tried: {', '.join(u[:64] for u in tried[:8])}). Treat this as a "
555
+ "likely compromise: lock the account, check ~/.ssh/authorized_keys, review processes.")
556
+ if self.alert("CRITICAL", "brute_force_success", ip, ts, text, attacker=ip,
557
+ notify=(text, [user] + [u for u in tried[:8] if u != user], True),
558
+ share_as="login_after_failures"):
559
+ self.db.execute("UPDATE alerted_ips SET escalated=1 WHERE ip=?", (ip,))
560
+ # Start the login_after_failures cool-down too, so the same user's next logins
561
+ # from this IP don't re-alert right after the escalation.
562
+ self.db.execute("INSERT OR REPLACE INTO alerts VALUES ('login_after_failures', ?, ?)",
563
+ (f"{user}@{ip}", ts))
564
+ r = {} # already covered by the escalation
565
+ if r.get("enabled"):
566
+ n = self.q("SELECT COUNT(*) FROM failures WHERE ip=? AND ts BETWEEN ? AND ?",
567
+ ip, ts - r["lookback_minutes"] * 60, ts)[0]
568
+ if n >= r["failures"]:
569
+ self.alert("HIGH", "login_after_failures", f"{user}@{ip}", ts,
570
+ f"Login after failures. User '{user}' logged in via {method} from {ip} "
571
+ f"after {n} failed attempts from that IP. If this wasn't you, change the "
572
+ "password, check ~/.ssh/authorized_keys and review running processes.",
573
+ attacker=ip)
574
+ approved = self.q("SELECT 1 FROM approved_ips WHERE ip=?", ip)
575
+ cc = self.country(ip)
576
+ first = "" if self.q("SELECT 1 FROM seen_ips WHERE user=?", user) else " (first recorded login)"
577
+ src = f"logged in via {method} from {ip}{f' ({cc})' if cc else ''}"
578
+ # A new country outranks a new IP, so at most one of the two fires per login.
579
+ if (cc and not approved and self.rules["new_country"].get("enabled")
580
+ and not self.q("SELECT 1 FROM seen_countries WHERE user=? AND country=?", user, cc)):
581
+ t = (f"New-country login. User '{user}' {src}, a country never seen for this "
582
+ f"user{first}. If this wasn't you, lock the account and investigate.")
583
+ self.alert("HIGH", "new_country", f"{user}@{cc}", ts, t, notify=(t, [user], True))
584
+ elif (not approved and self.rules["new_ip"].get("enabled")
585
+ and not self.q("SELECT 1 FROM seen_ips WHERE user=? AND ip=?", user, ip)):
586
+ t = (f"New IP login. User '{user}' {src}, an address never seen for this "
587
+ f"user{first}. If expected, approve it with: sshsentry.py --approve {ip}")
588
+ self.alert("LOW", "new_ip", f"{user}@{ip}", ts, t, notify=(t, [user], True))
589
+ before = self.q("SELECT COUNT(*) FROM failures WHERE ip=? AND ts BETWEEN ? AND ?",
590
+ ip, ts - self.rules["login_after_failures"]["lookback_minutes"] * 60, ts)[0]
591
+ if self.cfg["digest"].get("enabled") or self.dry_run: # login history only feeds the digest
592
+ self.db.execute("INSERT INTO logins VALUES (?, ?, ?, ?, ?)", (ts, user, ip, method, before))
593
+ self.db.execute("DELETE FROM logins WHERE ts < ?", (ts - 7 * 86400,))
594
+ self.db.execute("INSERT OR IGNORE INTO seen_ips VALUES (?, ?, ?)", (user, ip, ts))
595
+ if cc:
596
+ self.db.execute("INSERT OR IGNORE INTO seen_countries VALUES (?, ?)", (user, cc))
597
+ self.db.commit()
598
+
599
+ # ---------- learn mode: threshold suggestion ----------
600
+ def suggestion_data(self):
601
+ """Structured learn-mode suggestion (schema sshsentry.threshold_suggestion/1)."""
602
+ lm, cur = self.cfg["learn_mode"], self.rules["brute_force"]["failures"]
603
+ counts = sorted(c for (c,) in self.db.execute("SELECT count FROM learn_windows"))
604
+ out = {"schema": "sshsentry.threshold_suggestion/1", "suggested": None, "current": cur,
605
+ "window_minutes": self.rules["brute_force"]["window_minutes"], "stats": None,
606
+ "settings": {k: lm.get(k) for k in ("percentile", "margin", "min_threshold", "max_threshold")}}
607
+ if counts:
608
+ def pct(p):
609
+ return counts[max(0, math.ceil(p / 100 * len(counts)) - 1)]
610
+ p, margin = float(lm.get("percentile", 99)), float(lm.get("margin", 2))
611
+ lo, hi = int(lm.get("min_threshold", 3)), int(lm.get("max_threshold", 50))
612
+ out["suggested"] = min(hi, max(lo, math.ceil(pct(p) + margin)))
613
+ out["stats"] = {"failed_logins": sum(counts), "ips": self.q("SELECT COUNT(DISTINCT ip) FROM learn_windows")[0],
614
+ "ip_windows": len(counts), "p50": pct(50), "p95": pct(95), "p99": pct(99),
615
+ "max": counts[-1], "chosen_percentile_value": pct(p)}
616
+ return out
617
+
618
+ def suggestion(self):
619
+ """Return (suggested threshold or None, printable report) from recorded learn-mode data."""
620
+ lm, cur = self.cfg["learn_mode"], self.rules["brute_force"]["failures"]
621
+ win = self.rules["brute_force"]["window_minutes"]
622
+ counts = sorted(c for (c,) in self.db.execute("SELECT count FROM learn_windows"))
623
+ if not counts:
624
+ return None, ("No failed-login data has been recorded by learn mode yet, so there is no "
625
+ f"suggestion. Keep the manual threshold (rules.brute_force.failures: {cur}).")
626
+ def pct(p): # nearest-rank percentile
627
+ return counts[max(0, math.ceil(p / 100 * len(counts)) - 1)]
628
+ p, margin = float(lm.get("percentile", 99)), float(lm.get("margin", 2))
629
+ lo, hi = int(lm.get("min_threshold", 3)), int(lm.get("max_threshold", 50))
630
+ sug = min(hi, max(lo, math.ceil(pct(p) + margin)))
631
+ ips = self.q("SELECT COUNT(DISTINCT ip) FROM learn_windows")[0]
632
+ report = [f"Threshold suggestion (learn mode; brute-force window = {win} min):",
633
+ f" data: {sum(counts)} failed logins from {ips} IP(s) across {len(counts)} IP-window(s)",
634
+ f" failures per IP per window: p50={pct(50)} p95={pct(95)} p99={pct(99)} max={counts[-1]}",
635
+ f" suggested rules.brute_force.failures: {sug} (p{p:g}={pct(p)} + margin {margin:g}; "
636
+ f"floor {lo}, cap {hi})",
637
+ f" current manual threshold: {cur}" + (" (unchanged until you run --accept-threshold)"
638
+ if sug != cur else " (already matches)")]
639
+ if sug > cur:
640
+ report.append(" note: this data probably includes real attacks; a higher threshold "
641
+ "means fewer, later brute-force alerts.")
642
+ return sug, "\n".join(report)
643
+
644
+ # ---------- daily digest ----------
645
+ def digest_stats(self, start, end):
646
+ """Structured stats for [start, end): the source for both the stored JSON and the text."""
647
+ fails = self.db.execute("SELECT ip, user FROM failures WHERE ts >= ? AND ts < ?", (start, end)).fetchall()
648
+ rows = self.db.execute("SELECT user, ip, COUNT(*), MAX(failures_before) FROM logins WHERE ts >= ? "
649
+ "AND ts < ? GROUP BY user, ip ORDER BY MIN(ts)", (start, end)).fetchall()
650
+ new_ips = [ip for (ip,) in self.db.execute("SELECT ip FROM seen_ips WHERE first_seen >= ? AND "
651
+ "first_seen < ? GROUP BY ip ORDER BY MIN(first_seen)", (start, end))]
652
+ lim = self.rules["login_after_failures"]["failures"]
653
+ logins = [{"user": u, "ip": ip, "count": n, "failures_before": fb, "after_failures": fb >= lim,
654
+ "new_ip": ip in new_ips} for u, ip, n, fb in rows]
655
+ by_ip = {}
656
+ for ip, user in fails:
657
+ by_ip.setdefault(ip, []).append(user)
658
+ top = None
659
+ if by_ip:
660
+ ip, users = max(by_ip.items(), key=lambda kv: len(kv[1]))
661
+ top = {"ip": ip, "attempts": len(users), "usernames": sorted(set(u[:32] for u in users))[:6]}
662
+ sug = self.suggestion()[0] if self.q("SELECT 1 FROM learn_windows") else None
663
+ flagged = sum(1 for l in logins if l["after_failures"])
664
+ return {"failed_logins": len(fails), "distinct_ips": len(by_ip), "top_offender": top,
665
+ "successful_logins": sum(l["count"] for l in logins), "logins": logins,
666
+ "flagged_logins": flagged, "new_ips": new_ips, "suggestion": sug,
667
+ "current_threshold": self.rules["brute_force"]["failures"],
668
+ "quiet": not fails and not new_ips and not flagged}
669
+
670
+ def digest_render(self, st, start, end, label):
671
+ """One short screen of plain language. Returns (email subject, text)."""
672
+ host = self.cfg["digest"].get("host_label") # hostname is never added automatically
673
+ head = f"sshsentry daily digest{f' for {host}' if host else ''}, {label}"
674
+ span = f"{datetime.fromtimestamp(start):%a %b %d %H:%M} to {datetime.fromtimestamp(end):%a %b %d %H:%M}"
675
+ if st["quiet"]:
676
+ n = st["successful_logins"]
677
+ extra = f" {n} routine login(s) from known addresses." if n else ""
678
+ return "Daily digest: no incidents", f"{head}\nNo incidents today.{extra} Period: {span} (server time)."
679
+ out = [head, f"Period: {span} (server time)", "",
680
+ f"Failed logins: {st['failed_logins']} from {st['distinct_ips']} IP address(es)."]
681
+ if st["top_offender"]:
682
+ t = st["top_offender"]
683
+ out.append(f"Most active: {t['ip']}, {t['attempts']} attempts (usernames tried: {', '.join(t['usernames'])}).")
684
+ out.append(f"Successful logins: {st['successful_logins'] or 'none.'}")
685
+ for l in st["logins"]:
686
+ times = f" ({l['count']}x)" if l["count"] > 1 else ""
687
+ new = " [new IP]" if l["new_ip"] else ""
688
+ flag = f" << after {l['failures_before']} failed attempts from that IP. Check this." \
689
+ if l["after_failures"] else ""
690
+ out.append(f" - {l['user']} from {l['ip']}{times}{new}{flag}")
691
+ if st["new_ips"]:
692
+ ips = st["new_ips"]
693
+ out.append(f"New IPs seen: {len(ips)} ({', '.join(ips[:5])}{' ...' if len(ips) > 5 else ''}).")
694
+ if st["suggestion"] is not None:
695
+ out.append(f"Learn mode suggests a brute-force threshold of {st['suggestion']} "
696
+ f"(currently {st['current_threshold']}); see --suggest-threshold.")
697
+ subject = (f"Daily digest: {st['failed_logins']} failed, {st['successful_logins']} successful login(s)"
698
+ f"{', CHECK flagged login' if st['flagged_logins'] else ''}")
699
+ return subject, "\n".join(out)
700
+
701
+ def deliver_digest(self, subject, text, end, quiet=False):
702
+ """Send a digest to digest.destination. Returns (status, destination, error); never raises."""
703
+ if quiet and not self.cfg["digest"].get("send_when_quiet", True):
704
+ return "skipped (quiet day, send_when_quiet: false)", "", ""
705
+ kind = str(self.cfg["digest"].get("destination") or "email").lower()
706
+ if kind not in ("email", "slack", "webhook"):
707
+ return "failed", kind, f"unknown digest.destination {kind!r} (use email, slack or webhook)"
708
+ c = merge(self.cfg, {"email": {"enabled": False}, "alerting": {"enabled": True}})
709
+ for k in ("email", "slack", "webhook"):
710
+ c["alerting"][k] = dict(c["alerting"][k], enabled=(k == kind))
711
+ dests = build_destinations(c) # reuses the alerting validation (prints warnings)
712
+ if not dests:
713
+ return "failed", kind, f"{kind} destination is not usable (see warning)"
714
+ d = dests[0]
715
+ event = {"severity": "LOW", "rule": "digest", "ip": None, "usernames": [], "success": None,
716
+ "timestamp": datetime.fromtimestamp(end, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
717
+ "message": text}
718
+ try:
719
+ r = (self.send_email(d["cfg"], subject, text, self.cfg["alerting"].get("timeout", 5))
720
+ if kind == "email" else self.send_to(d, event))
721
+ except Exception as e: # a digest must never stop monitoring
722
+ r = f"failed ({type(e).__name__}: {e})"
723
+ return ("sent", kind, "") if r.startswith("sent") else ("failed", kind, r)
724
+
725
+ def save_digest_text(self, date, text):
726
+ dg = self.cfg["digest"]
727
+ if not dg.get("save_text", True):
728
+ return
729
+ folder = self.digest_dir()
730
+ try:
731
+ os.makedirs(folder, exist_ok=True)
732
+ with open(os.path.join(folder, f"{date}.txt"), "w") as f:
733
+ f.write(text + "\n")
734
+ except OSError as e:
735
+ print(f"[sshsentry] warning: could not write digest text file: {e}", file=sys.stderr)
736
+
737
+ def digest_dir(self):
738
+ return self.cfg["digest"].get("text_dir") or os.path.join(
739
+ os.path.dirname(os.path.abspath(self.cfg["state_db"])), "digests")
740
+
741
+ def make_digest(self, date, start, end, deliver):
742
+ """Generate, chain, store (always), save text and optionally deliver one day's digest."""
743
+ start, end = float(start), float(end)
744
+ st = json.loads(json.dumps(self.digest_stats(start, end))) # same form as stored/re-read
745
+ subject, text = self.digest_render(st, start, end, date)
746
+ prev = (self.q("SELECT chain_hash FROM daily_digests WHERE date < ? ORDER BY date DESC LIMIT 1", date)
747
+ or (GENESIS,))[0]
748
+ chain = chain_next(prev, {"date": date, "period_start": start, "period_end": end, "stats": st, "text": text})
749
+ full = with_chain(text, chain)
750
+ print(full, flush=True)
751
+ self.db.execute("INSERT OR REPLACE INTO daily_digests VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
752
+ (date, start, end, time.time(), json.dumps(st), text, "pending", "", "", prev, chain))
753
+ self.db.commit()
754
+ self.save_digest_text(date, full)
755
+ if deliver is True:
756
+ body = full if self.cfg["digest"].get("include_chain_hash", True) else text
757
+ status, dest, err = self.deliver_digest(subject, body, end, st["quiet"])
758
+ else:
759
+ status, dest, err = deliver or "not sent (digest.send: false)", "", ""
760
+ if status == "failed":
761
+ print(f"[sshsentry] digest for {date} failed ({dest}): {err}", file=sys.stderr)
762
+ self.db.execute("UPDATE daily_digests SET status=?, destination=?, error=? WHERE date=?",
763
+ (status, dest, err, date))
764
+ self.db.commit()
765
+
766
+ def digest_slot(self, now):
767
+ """Today's scheduled digest time (local) as a datetime, or None if digest.time is invalid."""
768
+ try:
769
+ hh, mm = (int(x) for x in str(self.cfg["digest"].get("time", "08:00")).split(":"))
770
+ return datetime.fromtimestamp(now).replace(hour=hh, minute=mm, second=0, microsecond=0)
771
+ except ValueError:
772
+ print(f"[sshsentry] warning: digest.time {self.cfg['digest'].get('time')!r} is not HH:MM; "
773
+ "digest disabled", file=sys.stderr)
774
+ self.cfg["digest"]["enabled"] = False
775
+ return None
776
+
777
+ def maybe_digest(self, now=None):
778
+ """Generate every due daily digest (catching up missed days, up to 7). Each covers the
779
+ 24 h before its slot. Only the newest one is delivered, and only if digest.send is on."""
780
+ dg = self.cfg["digest"]
781
+ if not dg.get("enabled"):
782
+ return
783
+ # TEST-ONLY hook: SSHSENTRY_TEST_NOW (epoch seconds) simulates another day for the test
784
+ # suite. It only shifts digest scheduling; never set it in production.
785
+ now = now or float(os.environ.get("SSHSENTRY_TEST_NOW") or time.time())
786
+ slot = self.digest_slot(now)
787
+ if slot is None:
788
+ return
789
+ if slot.timestamp() > now:
790
+ slot -= timedelta(days=1) # most recent slot that has already passed
791
+ if self.meta("digest_last_slot") is None: # first time: start with the next slot
792
+ self.set_meta("digest_last_slot", slot.timestamp())
793
+ return
794
+ last = datetime.fromtimestamp(float(self.meta("digest_last_slot")))
795
+ due = []
796
+ while last + timedelta(days=1) <= slot:
797
+ last += timedelta(days=1)
798
+ due.append(last)
799
+ for i, sl in enumerate(due[-7:]):
800
+ newest = i == len(due[-7:]) - 1
801
+ deliver = (True if dg.get("send", True) else None) if newest else "not sent (catch-up)"
802
+ self.set_meta("digest_last_slot", sl.timestamp()) # mark first: never twice
803
+ if self.q("SELECT 1 FROM daily_digests WHERE date=?", f"{sl:%Y-%m-%d}"):
804
+ continue
805
+ self.make_digest(f"{sl:%Y-%m-%d}", (sl - timedelta(days=1)).timestamp(), sl.timestamp(), deliver)
806
+
807
+ def digest_json(self, rec, include_text=True):
808
+ out = {"date": rec["date"], "period_start": iso(rec["period_start"]), "period_end": iso(rec["period_end"]),
809
+ "generated_at": iso(rec["created_at"]), "stats": rec["stats"],
810
+ "delivery": {"status": rec["status"], "destination": rec["destination"] or None,
811
+ "error": rec["error"] or None},
812
+ "prev_hash": rec["prev_hash"], "chain_hash": rec["chain_hash"]}
813
+ if include_text:
814
+ out["text"] = with_chain(rec["text"], rec["chain_hash"])
815
+ return out
816
+
817
+ def show_digests(self, n, as_json=False):
818
+ dates = [d for (d,) in self.db.execute("SELECT date FROM daily_digests ORDER BY date DESC LIMIT ?", (n,))]
819
+ if as_json:
820
+ emit({"schema": "sshsentry.digests/1", "count": len(dates),
821
+ "digests": [self.digest_json(self.digest_record(d), include_text=False) for d in dates]})
822
+ return
823
+ if not dates:
824
+ print("No daily digests recorded yet. Enable them with digest.enabled: true "
825
+ "(they're generated at digest.time each day).")
826
+ return
827
+ print(f"Last {len(dates)} daily digest(s). Details: sshsentry.py --digest YYYY-MM-DD")
828
+ print(f"{'DATE':<10} {'FAILED':>6} {'IPS':>4} {'TOP OFFENDER':<24} {'LOGINS':>6} {'NEW IPS':>7} "
829
+ f"{'CHAIN':<12} DELIVERED")
830
+ for d in dates:
831
+ rec = self.digest_record(d)
832
+ st, t = rec["stats"], rec["stats"]["top_offender"]
833
+ top = f"{t['ip']} ({t['attempts']})" if t else "-"
834
+ logins = f"{st['successful_logins']}{'!' if st['flagged_logins'] else ''}"
835
+ yes = "yes" if rec["status"] == "sent" else f"no: {rec['status']}"
836
+ print(f"{d:<10} {st['failed_logins']:>6} {st['distinct_ips']:>4} {top:<24} {logins:>6} "
837
+ f"{len(st['new_ips']):>7} {rec['chain_hash'][:12]:<12} {yes}")
838
+ print("(! = includes a successful login after failed attempts)")
839
+
840
+ def show_digest(self, date, as_json=False):
841
+ rec = self.digest_record(date)
842
+ if not rec:
843
+ if as_json:
844
+ emit({"schema": "sshsentry.digest/1", "date": date, "found": False})
845
+ else:
846
+ print(f"No digest recorded for {date}. List recorded days with --digests.")
847
+ return False
848
+ if as_json:
849
+ emit({"schema": "sshsentry.digest/1", "found": True, "stored": True, **self.digest_json(rec)})
850
+ return True
851
+ print(with_chain(rec["text"], rec["chain_hash"]))
852
+ print(f"\n[generated {fmt(rec['created_at'])}; delivery: {rec['status']}"
853
+ f"{' via ' + rec['destination'] if rec['destination'] else ''}"
854
+ f"{'; error: ' + rec['error'] if rec['error'] else ''}]")
855
+ return True
856
+
857
+ def verify_chain(self, as_json=False):
858
+ """Recompute the local digest chain; report the first day that doesn't match."""
859
+ checks, prev, broken = [], GENESIS, None
860
+ dates = [d for (d,) in self.db.execute("SELECT date FROM daily_digests ORDER BY date")]
861
+ for d in dates:
862
+ rec = self.digest_record(d)
863
+ want = chain_next(prev, rec)
864
+ ok = rec["prev_hash"] == prev and rec["chain_hash"] == want
865
+ why = ("ok" if ok else "previous-hash link broken (a day was removed or reordered)"
866
+ if rec["prev_hash"] != prev else "content changed after it was recorded")
867
+ checks.append({"check": "chain", "date": d, "ok": ok, "detail": why})
868
+ path = os.path.join(self.digest_dir(), f"{d}.txt")
869
+ if os.path.exists(path):
870
+ same = open(path).read() == with_chain(rec["text"], rec["chain_hash"]) + "\n"
871
+ checks.append({"check": "text_file", "date": d, "ok": same,
872
+ "detail": "matches" if same else f"{path} differs from the stored digest"})
873
+ if not ok and broken is None:
874
+ broken = d
875
+ prev = rec["chain_hash"] # continue from the stored value so later days are judged on their own
876
+ result = {"schema": "sshsentry.verify/1", "target": "local database", "ok": all(c["ok"] for c in checks),
877
+ "days": len(dates), "chain_head": prev if dates else None, "first_broken_date": broken,
878
+ "checks": checks}
879
+ report_verify(result, as_json)
880
+ return result["ok"]
881
+
882
+ def export_digests(self, start, end, out=None, host_label=None, as_json=False):
883
+ """Zip stored digests for [start, end]: per-day text + JSON record, a manifest with per-file
884
+ SHA-256 and chain hashes, and a `sha256sum -c` sidecar. Returns an exit code."""
885
+ def fail(msg, code):
886
+ if as_json:
887
+ emit({"schema": "sshsentry.export/1", "ok": False, "error": msg})
888
+ else:
889
+ print(msg, file=sys.stderr)
890
+ return code
891
+ try:
892
+ d0, d1 = (datetime.strptime(x, "%Y-%m-%d").date() for x in (start, end))
893
+ except ValueError:
894
+ return fail(f"Invalid date: use YYYY-MM-DD (got {start!r} {end!r}).", 2)
895
+ if d0 > d1:
896
+ return fail(f"START ({start}) is after END ({end}).", 2)
897
+ dates = [d for (d,) in self.db.execute("SELECT date FROM daily_digests WHERE date BETWEEN ? AND ? "
898
+ "ORDER BY date", (start, end))]
899
+ if not dates:
900
+ return fail(f"No daily digests are stored between {start} and {end}; nothing exported. "
901
+ "See --digests for recorded days.", 1)
902
+ days = [(d0 + timedelta(days=i)).isoformat() for i in range((d1 - d0).days + 1)]
903
+ missing = [d for d in days if d not in dates]
904
+ out = out or f"sshsentry-digests-{start}_to_{end}.zip"
905
+ files, entries = {}, []
906
+ for d in dates:
907
+ rec = self.digest_record(d)
908
+ txt = (with_chain(rec["text"], rec["chain_hash"]) + "\n").encode()
909
+ record = {k: rec[k] for k in ("date", "period_start", "period_end", "stats", "text")}
910
+ rj = (json.dumps({"schema": "sshsentry.digest_record/1", **record, "prev_hash": rec["prev_hash"],
911
+ "chain_hash": rec["chain_hash"], "delivery": rec["status"]}, indent=2,
912
+ sort_keys=True, ensure_ascii=False) + "\n").encode()
913
+ files[f"digests/{d}.txt"], files[f"records/{d}.json"] = txt, rj
914
+ entries.append({"date": d, "text_file": f"digests/{d}.txt", "text_sha256": hashlib.sha256(txt).hexdigest(),
915
+ "record_file": f"records/{d}.json", "record_sha256": hashlib.sha256(rj).hexdigest(),
916
+ "prev_hash": rec["prev_hash"], "chain_hash": rec["chain_hash"], "delivery": rec["status"]})
917
+ other = {}
918
+ if missing:
919
+ files["MISSING-DAYS.txt"] = ("No digest was recorded for these dates (digest disabled, sshsentry "
920
+ "not running, or before the first digest):\n" + "\n".join(missing) + "\n").encode()
921
+ other["MISSING-DAYS.txt"] = hashlib.sha256(files["MISSING-DAYS.txt"]).hexdigest()
922
+ manifest = {"schema": "sshsentry.export_manifest/2", "tool": "sshsentry", "sshsentry_version": VERSION,
923
+ "range": {"start": start, "end": end},
924
+ "generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
925
+ "genesis": GENESIS, "chain_head": entries[-1]["chain_hash"], "days": entries,
926
+ "other_files": other, "missing_dates": missing,
927
+ "chain_rule": "chain_hash = sha256(prev_hash + canonical(record)); canonical = compact "
928
+ "sorted-key JSON of date, period_start, period_end, stats, text",
929
+ "note": "Hashes show these files are unchanged since export, and the chain shows days weren't "
930
+ "edited, removed or reordered relative to each other. Compare chain_head with a hash "
931
+ "that left the server earlier (e.g. an emailed digest) to detect a rewritten chain. "
932
+ "None of this proves the digests were accurate."}
933
+ if host_label:
934
+ manifest["host_label"] = host_label
935
+ files["manifest.json"] = (json.dumps(manifest, indent=2) + "\n").encode()
936
+ try:
937
+ with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
938
+ for name in ["manifest.json"] + sorted(n for n in files if n != "manifest.json"):
939
+ z.writestr(name, files[name])
940
+ digest = hashlib.sha256(open(out, "rb").read()).hexdigest()
941
+ with open(out + ".sha256", "w") as f:
942
+ f.write(f"{digest} {os.path.basename(out)}\n")
943
+ except OSError as e:
944
+ return fail(f"Export failed: {e}", 1)
945
+ path = os.path.abspath(out)
946
+ if as_json:
947
+ emit({"schema": "sshsentry.export/1", "ok": True, "path": path, "sha256": digest,
948
+ "sidecar": path + ".sha256", "exported_days": len(entries), "missing_dates": missing,
949
+ "chain_head": manifest["chain_head"]})
950
+ return 0
951
+ print(f"Exported {len(entries)} digest(s) ({len(missing)} missing day(s)) to {path}")
952
+ print(f"SHA-256: {digest}")
953
+ print(f"Chain head: {manifest['chain_head']}")
954
+ print(f"Checksum file: {path}.sha256 (verify: cd {os.path.dirname(path)} && sha256sum -c "
955
+ f"{os.path.basename(out)}.sha256, or sshsentry.py --verify-export {os.path.basename(out)})")
956
+ print("Send the SHA-256 to the recipient separately (or store it elsewhere) now, so later changes can be detected.")
957
+ return 0
958
+
959
+ def resend_digest(self, date):
960
+ rec = self.digest_record(date)
961
+ if not rec:
962
+ print(f"No digest recorded for {date}.")
963
+ return False
964
+ body = with_chain(rec["text"], rec["chain_hash"]) if self.cfg["digest"].get("include_chain_hash", True) \
965
+ else rec["text"]
966
+ status, dest, err = self.deliver_digest(f"Daily digest ({date}, resent)", body, rec["period_end"])
967
+ self.db.execute("UPDATE daily_digests SET status=?, destination=?, error=? WHERE date=?",
968
+ (status, dest, err, date))
969
+ self.db.commit()
970
+ print(f"Resend of {date} via {dest or '?'}: {status}{f' ({err})' if err else ''}")
971
+ return status == "sent"
972
+
973
+ def maybe_report_learning(self):
974
+ """Print the suggestion once when the learn-mode period ends."""
975
+ if (self.cfg["learn_mode"].get("enabled") and self.meta("learn_start") and not self.learn_active()
976
+ and self.meta("learn_reported") is None):
977
+ print("[sshsentry] learn mode period finished; alerts are being sent again using the "
978
+ f"manual threshold ({self.rules['brute_force']['failures']}).", flush=True)
979
+ print(self.suggestion()[1], flush=True)
980
+ self.set_meta("learn_reported", time.time())
981
+
982
+ # ---------- file rules ----------
983
+ def unseen(self, flag, table, cols, found):
984
+ """Yield entries of `found` ({key: info}) missing from `table`, recording them.
985
+ The first pass only builds the baseline, so nothing is yielded then."""
986
+ first, where = self.meta(flag) is None, " AND ".join(f"{c}=?" for c in cols)
987
+ for key, info in found.items():
988
+ if not self.q(f"SELECT 1 FROM {table} WHERE {where}", *key):
989
+ self.db.execute(f"INSERT INTO {table} VALUES ({','.join('?' * len(key))})", key)
990
+ if not first:
991
+ yield key, info
992
+ self.set_meta(flag, 1)
993
+
994
+ def check_files(self):
995
+ """Compare /etc/passwd and authorized_keys files against the SQLite baseline."""
996
+ now, r = time.time(), self.rules["new_user"]
997
+ if r.get("enabled") and os.path.exists(r["passwd_file"]):
998
+ with open(r["passwd_file"], errors="replace") as f:
999
+ users = {(p[0],): p for p in (l.strip().split(":") for l in f) if len(p) >= 7}
1000
+ for (name,), p in self.unseen("users_baselined", "users", ["name"], users):
1001
+ self.alert("HIGH", "new_user", name, now,
1002
+ f"New user account. '{name}' (uid {p[2]}, shell {p[6]}) appeared in "
1003
+ f"{r['passwd_file']}. If you didn't create it, investigate immediately.")
1004
+ r, keys = self.rules["new_ssh_key"], {}
1005
+ if not r.get("enabled"):
1006
+ return
1007
+ for path in sorted({p for pat in r["paths"] for p in glob.glob(pat)}):
1008
+ try:
1009
+ with open(path, errors="replace") as f:
1010
+ for l in (l.strip() for l in f):
1011
+ if l and not l.startswith("#"):
1012
+ keys[(path, hashlib.sha256(l.encode()).hexdigest())] = l.split()
1013
+ except OSError:
1014
+ continue # unreadable: run as root to see every user's keys
1015
+ for (path, h), parts in self.unseen("keys_baselined", "keys", ["path", "line_hash"], keys):
1016
+ ktype = next((x for x in parts if x.startswith(("ssh-", "ecdsa-", "sk-"))), "unknown-type")
1017
+ comment = parts[-1] if len(parts) > 2 else "no comment"
1018
+ self.alert("HIGH", "new_ssh_key", f"{path}:{h[:16]}", now,
1019
+ f"New SSH key. A key ({ktype}, '{comment}', line hash {h[:12]}) was added to "
1020
+ f"{path}. Anyone holding it can log in. Remove it if you didn't add it.")
1021
+
1022
+
1023
+ def report_verify(result, as_json):
1024
+ if as_json:
1025
+ emit(result)
1026
+ return
1027
+ for c in result["checks"]:
1028
+ print(f"{'PASS' if c['ok'] else 'FAIL'} {c['check']:<10} {c.get('date') or '':<10} {c['detail']}")
1029
+ if result.get("first_broken_date"):
1030
+ print(f"First broken day: {result['first_broken_date']}")
1031
+ print(f"RESULT: {'PASS' if result['ok'] else 'FAIL'} ({result['target']}; {result.get('days', 0)} day(s); "
1032
+ f"chain head {(result.get('chain_head') or '-')[:12]})")
1033
+
1034
+
1035
+ def verify_export(path, expected_head=None, as_json=False):
1036
+ """Check an export zip: sidecar, every file vs manifest, the recomputed chain, and optionally
1037
+ that the chain head matches a hash that left the server earlier. Returns True if all pass."""
1038
+ checks, broken = [], None
1039
+ def add(check, ok, detail, date=None):
1040
+ nonlocal broken
1041
+ checks.append({"check": check, "date": date, "ok": bool(ok), "detail": detail})
1042
+ if not ok and date and broken is None:
1043
+ broken = date
1044
+ head = None
1045
+ side = path + ".sha256"
1046
+ try:
1047
+ actual = hashlib.sha256(open(path, "rb").read()).hexdigest()
1048
+ except OSError as e:
1049
+ add("zip", False, f"cannot read {path}: {e}")
1050
+ actual = None
1051
+ if actual and os.path.exists(side):
1052
+ parts = open(side).read().split()
1053
+ good = len(parts) >= 2 and parts[0] == actual and parts[1].lstrip("*") == os.path.basename(path)
1054
+ add("sidecar", good, "zip SHA-256 matches .sha256 file" if good else
1055
+ f"zip SHA-256 {actual[:12]} does not match {side}")
1056
+ elif actual:
1057
+ add("sidecar", True, "no .sha256 file next to the zip (skipped)")
1058
+ days = []
1059
+ try:
1060
+ z = zipfile.ZipFile(path)
1061
+ m = json.loads(z.read("manifest.json"))
1062
+ names = set(z.namelist()) - {"manifest.json"}
1063
+ days = m.get("days") or []
1064
+ listed = {d["text_file"] for d in days} | {d["record_file"] for d in days} | set(m.get("other_files", {}))
1065
+ extra = sorted(names - listed)
1066
+ add("files", not extra, "no unlisted files in zip" if not extra else f"unlisted files: {', '.join(extra)}")
1067
+ for name, sha in m.get("other_files", {}).items():
1068
+ data = z.read(name) if name in names else b""
1069
+ add("file", hashlib.sha256(data).hexdigest() == sha, f"{name} matches manifest")
1070
+ prev = days[0]["prev_hash"] if days else None
1071
+ for d in days:
1072
+ date = d["date"]
1073
+ try:
1074
+ txt, rj = z.read(d["text_file"]), z.read(d["record_file"])
1075
+ except KeyError as e:
1076
+ add("file", False, f"missing from zip: {e}", date)
1077
+ continue
1078
+ add("text_sha", hashlib.sha256(txt).hexdigest() == d["text_sha256"],
1079
+ "text file matches manifest" if hashlib.sha256(txt).hexdigest() == d["text_sha256"]
1080
+ else "text file differs from manifest (edited after export)", date)
1081
+ add("record_sha", hashlib.sha256(rj).hexdigest() == d["record_sha256"],
1082
+ "record matches manifest" if hashlib.sha256(rj).hexdigest() == d["record_sha256"]
1083
+ else "JSON record differs from manifest (edited after export)", date)
1084
+ rec = json.loads(rj)
1085
+ want = chain_next(prev, rec)
1086
+ link = d["prev_hash"] == prev and rec.get("prev_hash") == prev
1087
+ add("chain", link and want == d["chain_hash"] == rec.get("chain_hash"),
1088
+ "chain recomputes" if link and want == d["chain_hash"] == rec.get("chain_hash") else
1089
+ ("previous-hash link broken (day removed or reordered)" if not link
1090
+ else "recomputed chain hash differs (digest content edited)"), date)
1091
+ same = txt.decode(errors="replace") == with_chain(rec["text"], d["chain_hash"]) + "\n"
1092
+ add("text_body", same, "text matches its record" if same else "text file doesn't match its record", date)
1093
+ prev = d["chain_hash"]
1094
+ head = prev
1095
+ add("head", head == m.get("chain_head"), f"chain head {(head or '-')[:12]} matches manifest"
1096
+ if head == m.get("chain_head") else "manifest chain_head doesn't match the last day")
1097
+ except (OSError, KeyError, ValueError, zipfile.BadZipFile) as e:
1098
+ add("manifest", False, f"cannot read export: {type(e).__name__}: {e}")
1099
+ if expected_head:
1100
+ exp = expected_head.strip().lower()
1101
+ hit = next((d["date"] for d in days if len(exp) >= 12 and d["chain_hash"].startswith(exp)), None)
1102
+ if head and len(exp) >= 12 and head.startswith(exp):
1103
+ add("expected", True, "chain head matches the expected hash")
1104
+ elif hit:
1105
+ add("expected", True, f"expected hash matches the chain at {hit} (not the head): days up to {hit} "
1106
+ "are the ones that left the server")
1107
+ else:
1108
+ add("expected", False, "expected hash not found in this export's chain (at least 12 hex chars needed)"
1109
+ if len(exp) < 12 else "expected hash not found: this export's chain differs from what left the server")
1110
+ result = {"schema": "sshsentry.verify/1", "target": os.path.basename(path), "ok": all(c["ok"] for c in checks),
1111
+ "days": len(days), "chain_head": head, "first_broken_date": broken, "checks": checks}
1112
+ report_verify(result, as_json)
1113
+ return result["ok"]
1114
+
1115
+
1116
+ def accept_threshold(path, value):
1117
+ """Write rules.brute_force.failures into the YAML file, preserving comments and layout.
1118
+ Only edits a block-style `failures:` line under rules -> brute_force; returns False otherwise."""
1119
+ try:
1120
+ lines = open(path).read().splitlines(keepends=True)
1121
+ except OSError:
1122
+ return False
1123
+ indent = lambda l: len(l) - len(l.lstrip())
1124
+ def child(start, parent_indent, key):
1125
+ for i in range(start, len(lines)):
1126
+ l = lines[i]
1127
+ if not l.strip() or l.lstrip().startswith("#"):
1128
+ continue
1129
+ if indent(l) <= parent_indent:
1130
+ return None
1131
+ if re.match(rf"\s*{key}:\s*(#.*)?$", l):
1132
+ return i
1133
+ return None
1134
+ rules = next((i for i, l in enumerate(lines) if re.match(r"rules:\s*(#.*)?$", l)), None)
1135
+ bf = rules is not None and child(rules + 1, 0, "brute_force")
1136
+ if not bf:
1137
+ return False
1138
+ bf_indent = indent(lines[bf])
1139
+ for i in range(bf + 1, len(lines)):
1140
+ l = lines[i]
1141
+ if l.strip() and not l.lstrip().startswith("#") and indent(l) <= bf_indent:
1142
+ break
1143
+ m = re.match(r"(\s*failures:\s*)\d+(.*)$", l.rstrip("\n"))
1144
+ if m and indent(l) > bf_indent:
1145
+ lines[i] = f"{m.group(1)}{value}{m.group(2)}\n"
1146
+ break
1147
+ else:
1148
+ return False
1149
+ new = "".join(lines)
1150
+ try: # never write a file that no longer parses to the intended value
1151
+ if yaml.safe_load(new)["rules"]["brute_force"]["failures"] != value:
1152
+ return False
1153
+ except Exception:
1154
+ return False
1155
+ with open(path + ".bak", "w") as f:
1156
+ f.write(open(path).read())
1157
+ tmp = path + ".tmp"
1158
+ with open(tmp, "w") as f:
1159
+ f.write(new)
1160
+ os.chmod(tmp, os.stat(path).st_mode)
1161
+ os.replace(tmp, path)
1162
+ return True
1163
+
1164
+
1165
+ def find_log(cfg):
1166
+ if cfg["log_file"] != "auto":
1167
+ return cfg["log_file"]
1168
+ found = [p for p in ("/var/log/auth.log", "/var/log/secure") if os.path.exists(p)]
1169
+ return found[0] if found else sys.exit("No /var/log/auth.log or /var/log/secure; set log_file.")
1170
+
1171
+
1172
+ def watch(s, path, follow):
1173
+ """Read new log lines from the saved offset; keep following (survives rotation) if asked."""
1174
+ f, offset = open(path, errors="replace"), s.meta("log_offset")
1175
+ if offset is None and s.cfg.get("learn_existing_log"):
1176
+ s.learning = True # first run: silently learn IPs from existing history
1177
+ for line in f:
1178
+ s.handle_line(line)
1179
+ s.learning = False
1180
+ elif offset is None:
1181
+ f.seek(0, os.SEEK_END) # first run without learning: start at the end
1182
+ elif s.meta("log_inode") == str(os.fstat(f.fileno()).st_ino) and int(offset) <= os.path.getsize(path):
1183
+ f.seek(int(offset)) # same file as last time: resume where we stopped
1184
+ last_check = 0
1185
+ while True:
1186
+ line = f.readline()
1187
+ if line:
1188
+ s.handle_line(line)
1189
+ continue
1190
+ s.set_meta("log_offset", f.tell())
1191
+ s.set_meta("log_inode", os.fstat(f.fileno()).st_ino)
1192
+ if not follow or time.time() - last_check >= s.cfg["file_check_seconds"]:
1193
+ s.check_files()
1194
+ s.maybe_report_learning()
1195
+ s.maybe_digest()
1196
+ last_check = time.time()
1197
+ if not follow:
1198
+ return
1199
+ time.sleep(s.cfg["poll_seconds"])
1200
+ try: # rotated (new inode) or truncated: reopen and read the new file from the start
1201
+ st = os.stat(path)
1202
+ if st.st_ino != os.fstat(f.fileno()).st_ino or st.st_size < f.tell():
1203
+ f.close()
1204
+ f = open(path, errors="replace")
1205
+ except FileNotFoundError:
1206
+ pass # mid-rotation; try again next poll
1207
+
1208
+
1209
+ def main():
1210
+ ap = argparse.ArgumentParser(description="sshsentry: plain-English SSH intrusion alerts (detection only).")
1211
+ ap.add_argument("-c", "--config", default="/etc/sshsentry/sshsentry.yaml", help="YAML config path")
1212
+ ap.add_argument("--log", help="override log file path")
1213
+ ap.add_argument("--db", help="override SQLite state path")
1214
+ ap.add_argument("--replay", "--dry-run", dest="replay", metavar="FILE",
1215
+ help="process FILE from the start and print alerts; never sends anything")
1216
+ ap.add_argument("--once", action="store_true", help="process new log lines once, then exit (timers)")
1217
+ ap.add_argument("--approve", metavar="IP", help="mark an IP as known for all users, then exit")
1218
+ ap.add_argument("--show-shared", action="store_true", help="list everything this install has shared")
1219
+ ap.add_argument("--forget-me", action="store_true",
1220
+ help="delete local sharing history (and ask the endpoint to delete it, if configured)")
1221
+ ap.add_argument("--local-only", action="store_true", help="with --forget-me: skip the remote request")
1222
+ ap.add_argument("--test-alerts", action="store_true", help="send a sample alert to each enabled destination")
1223
+ ap.add_argument("--suggest-threshold", action="store_true", help="print the learn-mode threshold suggestion")
1224
+ ap.add_argument("--digest-now", action="store_true",
1225
+ help="print a digest of the last 24 h now and send it if digest.send is on "
1226
+ "(with --replay: print only, for the replayed log); not stored")
1227
+ ap.add_argument("--digests", nargs="?", const=7, type=int, metavar="N",
1228
+ help="list the last N stored daily digests (default 7)")
1229
+ ap.add_argument("--digest", metavar="DATE", help="show one stored daily digest (YYYY-MM-DD)")
1230
+ ap.add_argument("--resend", metavar="DATE", help="send a stored daily digest again")
1231
+ ap.add_argument("--export-digests", nargs=2, metavar=("START", "END"),
1232
+ help="zip stored digests for an inclusive YYYY-MM-DD range, with manifest + SHA-256")
1233
+ ap.add_argument("-o", "--output", metavar="PATH", help="with --export-digests: zip path")
1234
+ ap.add_argument("--host-label", help="with --export-digests: label to include in the manifest")
1235
+ ap.add_argument("--accept-threshold", action="store_true",
1236
+ help="write the suggested brute-force threshold into the config file")
1237
+ ap.add_argument("--verify-chain", action="store_true", help="recompute the local digest hash chain")
1238
+ ap.add_argument("--verify-export", metavar="ZIP", help="verify an --export-digests zip")
1239
+ ap.add_argument("--expected-head", metavar="HASH",
1240
+ help="with --verify-export: chain hash that left the server earlier (e.g. from an emailed digest)")
1241
+ ap.add_argument("--format", choices=("text", "json"), default="text",
1242
+ help="output format for --digests, --digest, --digest-now, --suggest-threshold, "
1243
+ "--show-shared, --export-digests, --verify-*, and --replay")
1244
+ a = ap.parse_args()
1245
+ as_json = a.format == "json"
1246
+ if a.verify_export: # needs no config or database
1247
+ sys.exit(0 if verify_export(a.verify_export, a.expected_head, as_json) else 1)
1248
+
1249
+ if not os.path.exists(a.config) and a.config != ap.get_default("config"):
1250
+ sys.exit(f"Config not found: {a.config}")
1251
+ user_cfg = yaml.safe_load(open(a.config)) if os.path.exists(a.config) else {}
1252
+ cfg = normalize(merge(DEFAULTS, user_cfg), user_cfg)
1253
+ cfg["state_db"], cfg["log_file"] = a.db or cfg["state_db"], a.log or cfg["log_file"]
1254
+
1255
+ admin = (a.approve or a.show_shared or a.forget_me or a.test_alerts or a.suggest_threshold
1256
+ or a.accept_threshold or (a.digest_now and not a.replay) or a.digests is not None
1257
+ or a.digest or a.resend or a.export_digests or a.verify_chain)
1258
+ s = Agent(cfg, dry_run=bool(a.replay), admin=bool(admin))
1259
+ if a.test_alerts:
1260
+ sys.exit(0 if s.test_alerts() else 1)
1261
+ elif a.verify_chain:
1262
+ sys.exit(0 if s.verify_chain(as_json) else 1)
1263
+ elif a.export_digests:
1264
+ sys.exit(s.export_digests(*a.export_digests, out=a.output, host_label=a.host_label, as_json=as_json))
1265
+ elif a.digests is not None:
1266
+ s.show_digests(max(1, a.digests), as_json)
1267
+ elif a.digest:
1268
+ sys.exit(0 if s.show_digest(a.digest, as_json) else 1)
1269
+ elif a.resend:
1270
+ sys.exit(0 if s.resend_digest(a.resend) else 1)
1271
+ elif a.digest_now and not a.replay: # on demand preview: not stored, schedule untouched
1272
+ now = time.time()
1273
+ st = s.digest_stats(now - 86400, now)
1274
+ subject, text = s.digest_render(st, now - 86400, now, "last 24 hours (on demand)")
1275
+ if not as_json:
1276
+ print(text)
1277
+ status, dest, err = "off (digest.send: false)", "", ""
1278
+ if cfg["digest"].get("send", True):
1279
+ status, dest, err = s.deliver_digest(subject, text, now, st["quiet"])
1280
+ if as_json:
1281
+ emit({"schema": "sshsentry.digest/1", "found": True, "stored": False, "date": None,
1282
+ "period_start": iso(now - 86400), "period_end": iso(now), "stats": st, "text": text,
1283
+ "delivery": {"status": status, "destination": dest or None, "error": err or None}})
1284
+ else:
1285
+ print(f"[delivery: {status}{f' via {dest}' if dest else ''}{f'; {err}' if err else ''}]")
1286
+ elif a.suggest_threshold:
1287
+ emit(s.suggestion_data()) if as_json else print(s.suggestion()[1])
1288
+ elif a.accept_threshold:
1289
+ value, report = s.suggestion()
1290
+ print(report)
1291
+ if value is None:
1292
+ sys.exit(1)
1293
+ if os.path.exists(a.config) and accept_threshold(a.config, value):
1294
+ print(f"Updated {a.config}: rules.brute_force.failures = {value} (backup: {a.config}.bak). "
1295
+ "Restart sshsentry to apply.")
1296
+ else:
1297
+ print(f"Could not safely edit {a.config}; add this to it yourself:\n"
1298
+ f"rules:\n brute_force:\n failures: {value}")
1299
+ elif a.show_shared:
1300
+ s.show_shared(as_json)
1301
+ elif a.forget_me:
1302
+ s.forget_me(a.local_only)
1303
+ elif a.approve:
1304
+ s.db.execute("INSERT OR IGNORE INTO approved_ips VALUES (?)", (a.approve,))
1305
+ print(f"Approved {a.approve}; logins from it will no longer raise new-IP/country alerts.")
1306
+ elif a.replay:
1307
+ if as_json:
1308
+ s.collect = []
1309
+ with open(a.replay, errors="replace") as f:
1310
+ for line in f:
1311
+ s.handle_line(line)
1312
+ s.check_files()
1313
+ out = {"schema": "sshsentry.replay/1", "file": a.replay, "alerts": s.collect}
1314
+ if cfg["learn_mode"].get("enabled"):
1315
+ if as_json:
1316
+ out["threshold_suggestion"] = s.suggestion_data()
1317
+ else:
1318
+ print(s.suggestion()[1])
1319
+ if a.digest_now: # digest of the replayed events only; never sent
1320
+ lo, hi = s.q("SELECT MIN(t), MAX(t) FROM (SELECT ts t FROM failures UNION ALL SELECT ts FROM logins)")
1321
+ lo, hi = lo or time.time(), (hi or time.time()) + 1
1322
+ st = s.digest_stats(lo, hi)
1323
+ text = s.digest_render(st, lo, hi - 1, "replayed log")[1]
1324
+ if as_json:
1325
+ out["digest"] = {"stored": False, "period_start": iso(lo), "period_end": iso(hi - 1),
1326
+ "stats": st, "text": text}
1327
+ else:
1328
+ print()
1329
+ print(text)
1330
+ if as_json:
1331
+ emit(out)
1332
+ else:
1333
+ try:
1334
+ watch(s, find_log(cfg), follow=not a.once)
1335
+ except KeyboardInterrupt:
1336
+ pass
1337
+ s.db.commit()
1338
+
1339
+
1340
+ if __name__ == "__main__":
1341
+ main()