#!/usr/bin/env python3 """ ops-readonly: the read-only twin of ops-actions (episode 11). The security report agents never get a shell. They get this: a fixed list of questions, each answered by a few lines of code over three read-only mounts. /logs/caddy/access.log Caddy's JSON access log (episode 10) /var/log/journal the host journal, sshd lines (read via libsystemd) /run/fail2ban fail2ban's control socket (status queries only) /waf WAF alerts as JSON, written by waf-export.py on the host (episode 14); cscli and its credentials stay there No docker socket, no write access, no free-form arguments. An action name that is not in ACTIONS is refused, an argument outside its range is refused, and every call is written to stdout so the container log shows what the agents asked for. The fail2ban socket speaks pickle. Only two verbs are ever sent through it, "status" and "banned"; the same protocol could also send "set ... unbanip", so the verb list below is the allowlist for that socket. """ import calendar, collections, datetime, json, os, pickle, re, socket, time from http.server import BaseHTTPRequestHandler, HTTPServer from urllib.parse import urlparse, parse_qs TOKEN = os.environ.get("OPS_READONLY_TOKEN", "") CADDY_LOG = "/logs/caddy/access.log" JOURNAL_DIR = "/var/log/journal" F2B_SOCK = "/run/fail2ban/fail2ban.sock" WINDOW_H = 24 JAILS = ("sshd", "caddy-probes") N_MIN, N_MAX, N_DEFAULT = 1, 20, 5 WAF_DIR = os.environ.get("WAF_DIR", "/waf") H_MIN, H_MAX, H_DEFAULT = 1, 48, 24 # ---------------------------------------------------------------- fail2ban -- F2B_END = b"" F2B_VERBS = ("status", "banned") def f2b(cmd): if cmd[0] not in F2B_VERBS: raise ValueError("verb not allowed: %s" % cmd[0]) s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(5) s.connect(F2B_SOCK) s.sendall(pickle.dumps(cmd, 2) + F2B_END) buf = b"" while not buf.endswith(F2B_END): chunk = s.recv(65536) if not chunk: break buf += chunk s.close() code, payload = pickle.loads(buf[: -len(F2B_END)]) if code != 0: raise RuntimeError("fail2ban answered %s: %s" % (code, payload)) return payload def f2b_status(jail): if jail not in JAILS: raise ValueError("jail must be one of %s" % ", ".join(JAILS)) raw = f2b(["status", jail]) flat = {} for section, rows in raw: for key, value in rows: flat[key] = value return { "jail": jail, "currently_failed": int(flat.get("Currently failed", 0)), "total_failed": int(flat.get("Total failed", 0)), "currently_banned": int(flat.get("Currently banned", 0)), "total_banned": int(flat.get("Total banned", 0)), "banned_ips": list(flat.get("Banned IP list", []) or []), } def f2b_banned(): raw = f2b(["banned"]) out = {} for entry in raw: for jail, ips in entry.items(): out[jail] = list(ips or []) return {"jails": out, "banned_total_now": sum(len(v) for v in out.values())} # -------------------------------------------------------------- sshd journal -- RE_INVALID = re.compile(r"^Invalid user (.*?) from ([0-9a-f.:]+) port \d+") RE_FAILED = re.compile(r"^Failed password for (?:invalid user )?(.+?) from ([0-9a-f.:]+) port \d+") RE_PREAUTH = re.compile(r"^Connection closed by (?:authenticating|invalid) user (.+?) ([0-9a-f.:]+) port \d+") RE_ACCEPTED = re.compile(r"^Accepted (?:publickey|password) for (\S+) from ([0-9a-f.:]+)") def sshd_failed_summary(n=N_DEFAULT): from systemd import journal # libsystemd, reads the mounted journal files since = datetime.datetime.now() - datetime.timedelta(hours=WINDOW_H) j = journal.Reader(path=JOURNAL_DIR) j.add_match(_SYSTEMD_UNIT="ssh.service") j.seek_realtime(since) lines = invalid = failed = preauth = accepted = 0 users = collections.Counter() ips = collections.Counter() for entry in j: msg = str(entry.get("MESSAGE", "")) lines += 1 m = RE_INVALID.match(msg) if m: invalid += 1 users[m.group(1)] += 1 ips[m.group(2)] += 1 continue m = RE_FAILED.match(msg) if m: failed += 1 users[m.group(1)] += 1 ips[m.group(2)] += 1 continue m = RE_PREAUTH.match(msg) if m: preauth += 1 ips[m.group(2)] += 1 continue if RE_ACCEPTED.match(msg): accepted += 1 return { "window_hours": WINDOW_H, "journal_lines": lines, "login_failures": invalid + failed, "invalid_user": invalid, "failed_password": failed, "preauth_closed": preauth, "accepted_logins": accepted, "distinct_source_ips": len(ips), "top_usernames": [{"user": u[:120], "count": c} for u, c in users.most_common(n)], "top_source_ips": [{"ip": i, "count": c} for i, c in ips.most_common(n)], } # ---------------------------------------------------------------- caddy log -- def caddy_rows(): """Yield (status, client_ip, uri) for every request inside the window.""" cutoff = time.time() - WINDOW_H * 3600 with open(CADDY_LOG, "rb") as fh: for line in fh: try: e = json.loads(line) except ValueError: continue if e.get("ts", 0) < cutoff: continue req = e.get("request", {}) yield int(e.get("status", 0)), str(req.get("client_ip", "")), str(req.get("uri", ""))[:200] def caddy_status_counts(): by_status = collections.Counter() ips = set() total = 0 for status, ip, _ in caddy_rows(): total += 1 by_status[str(status)] += 1 ips.add(ip) by_class = collections.Counter() for s, c in by_status.items(): by_class[s[0] + "xx"] += c return { "window_hours": WINDOW_H, "requests_total": total, "by_class": dict(sorted(by_class.items())), "by_status": dict(sorted(by_status.items())), "rejected_4xx": by_class.get("4xx", 0), "rate_limited_429": by_status.get("429", 0), "server_errors_5xx": by_class.get("5xx", 0), "distinct_client_ips": len(ips), } def caddy_top_paths(n=N_DEFAULT): paths = collections.Counter() statuses = collections.defaultdict(set) for status, _, uri in caddy_rows(): if status >= 400: paths[uri] += 1 statuses[uri].add(status) return { "window_hours": WINDOW_H, "filter": "responses with status 400 or higher", "paths": [{"path": p, "count": c, "statuses": sorted(statuses[p])} for p, c in paths.most_common(n)], } def caddy_top_ips(n=N_DEFAULT): ips = collections.Counter() for status, ip, _ in caddy_rows(): if status >= 400: ips[ip] += 1 return { "window_hours": WINDOW_H, "filter": "responses with status 400 or higher", "ips": [{"ip": i, "count": c} for i, c in ips.most_common(n)], } # ---------------------------------------------------------------- WAF alerts -- # Episode 14. Everything in here except rule ids, counts and the source IP was # written by whoever sent the request. The reader truncates, it does not clean. def _waf_json(name): with open(os.path.join(WAF_DIR, name), "rb") as fh: return json.load(fh) def _auth_index(): """(client_ip, host, uri) -> times of every 403 that carried an Authorization or Cookie header. Presence only: the WAF blocks before the app checks the credential, so anyone can set this. It is a hint, never proof.""" seen = collections.defaultdict(list) with open(CADDY_LOG, "rb") as fh: for line in fh: if b'"status":403' not in line: continue try: e = json.loads(line) except ValueError: continue req = e.get("request", {}) h = req.get("headers", {}) if "Authorization" in h or "Cookie" in h: key = (str(req.get("client_ip", "")), str(req.get("host", "")), str(req.get("uri", ""))[:300]) seen[key].append(float(e.get("ts", 0))) return seen def _had_auth(index, alert): """Same request, not just the same URI: the log line must sit within 3 s of the alert.""" t = calendar.timegm(time.strptime(alert["ts"], "%Y-%m-%dT%H:%M:%SZ")) return any(abs(ts - t) <= 3 for ts in index.get((alert["source_ip"], alert["host"], alert["uri"]), ())) def waf_blocked(hours=H_DEFAULT): data = _waf_json("alerts.json") catalog = _waf_json("crs-rules.json") cutoff = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - hours * 3600)) auth = _auth_index() groups = collections.OrderedDict() total = 0 for a in data.get("alerts", []): if a.get("ts", "") < cutoff: continue total += 1 ids = [r["id"] for r in a.get("rules", [])] key = (a["source_ip"], a["host"], a["method"], a["uri"], tuple(ids)) g = groups.get(key) if g is None: g = groups[key] = { "source_ip": a["source_ip"], "host": a["host"], "method": a["method"], "uri": a["uri"][:160], "count": 0, "first_seen": a["ts"], "last_seen": a["ts"], "auth_header_present": _had_auth(auth, a), "rule_ids": ids, "attack_classes": sorted({catalog.get(i, {}).get("attack_class", "") for i in ids} - {""}), } g["count"] += 1 g["last_seen"] = max(g["last_seen"], a["ts"]) return {"window_hours": hours, "exported_at": data.get("exported_at"), "blocked_total": total, "distinct_requests": len(groups), "requests": list(groups.values())} def waf_rule_info(rule_id): if not re.fullmatch(r"\d{6}", rule_id or ""): raise ValueError("id must be a six digit CRS rule id") rule = _waf_json("crs-rules.json").get(rule_id) if rule is None: return {"id": rule_id, "known": False, "note": "no such rule in the installed CRS"} return dict(rule, id=rule_id, known=True) # ---------------------------------------------------------------- the list -- def arg_n(q): raw = q.get("n", [str(N_DEFAULT)])[0] try: n = int(str(raw).strip()) except ValueError: raise ValueError("n must be a whole number") if not N_MIN <= n <= N_MAX: raise ValueError("n must be between %d and %d" % (N_MIN, N_MAX)) return n def arg_hours(q): raw = q.get("hours", [str(H_DEFAULT)])[0] try: h = int(str(raw).strip()) except ValueError: raise ValueError("hours must be a whole number") if not H_MIN <= h <= H_MAX: raise ValueError("hours must be between %d and %d" % (H_MIN, H_MAX)) return h ACTIONS = { "f2b_status": lambda q: f2b_status(q.get("jail", [""])[0]), "f2b_banned": lambda q: f2b_banned(), "sshd_failed_summary": lambda q: sshd_failed_summary(arg_n(q)), "caddy_status_counts": lambda q: caddy_status_counts(), "caddy_top_paths": lambda q: caddy_top_paths(arg_n(q)), "caddy_top_ips": lambda q: caddy_top_ips(arg_n(q)), "waf_blocked": lambda q: waf_blocked(arg_hours(q)), "waf_rule_info": lambda q: waf_rule_info(q.get("id", [""])[0]), } def log(entry): entry["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) print(json.dumps(entry), flush=True) class Handler(BaseHTTPRequestHandler): def _reply(self, code, obj): body = json.dumps(obj).encode() self.send_response(code) self.send_header("content-type", "application/json") self.send_header("content-length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *a): pass def do_GET(self): u = urlparse(self.path) if u.path == "/healthz": return self._reply(200, {"status": "ok", "actions": sorted(ACTIONS), "window_hours": WINDOW_H}) if not u.path.startswith("/read/"): return self._reply(404, {"error": "not found"}) if TOKEN and self.headers.get("x-ops-token") != TOKEN: log({"event": "denied", "why": "bad token", "path": u.path}) return self._reply(401, {"error": "bad token"}) action = u.path[len("/read/"):] q = parse_qs(u.query) if action not in ACTIONS: log({"event": "denied", "why": "action not on allowlist", "action": action}) return self._reply(403, {"error": "action not on allowlist", "allowed": sorted(ACTIONS)}) t0 = time.time() try: result = ACTIONS[action](q) except ValueError as e: log({"event": "denied", "why": "bad argument", "action": action, "detail": str(e)}) return self._reply(400, {"error": "bad argument", "detail": str(e)}) except Exception as e: log({"event": "failed", "action": action, "detail": str(e)}) return self._reply(500, {"error": "read failed", "detail": str(e)}) log({"event": "served", "action": action, "args": {k: v[0] for k, v in q.items()}, "seconds": round(time.time() - t0, 3)}) return self._reply(200, result) def do_POST(self): self._reply(405, {"error": "read only"}) if __name__ == "__main__": log({"event": "start", "actions": sorted(ACTIONS), "token_set": bool(TOKEN)}) HTTPServer(("0.0.0.0", 8098), Handler).serve_forever()