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("&", "&").replace("<", "<").replace(">", ">")
|
|
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()
|