简介:基于Python的日志审计系统项目源码包,面向网络安全、计算机相关专业学生及需要完成毕业设计或课程设计的开发者,提供一套可运行、可扩展的日志审计与行为分析实践方案。资源共51个文件,以25个JavaScript前端脚本、10个Python后端模块为主,另含XML配置、Vue组件、Markdown说明及JSON等工程文件,压缩包约33KB。目录涵盖后端模型、视图、路由、测试与前端页面构建配置,便于理解日志采集、解析、存储、统计分析与告警响应的整体链路。已有184人学习下载。读者可据此参考Django后端与Vue前端的组织方式,结合日志审计场景补充异常检测、可视化展示和邮件通知等模块,适合作为毕业设计原型、课程实验底稿或二次开发起点。
1. 从一台机器每天 200 万行日志说起:这套 Python 日志审计系统解决什么
凌晨两点被叫起来,只因为要查「上周三谁用这个账号登过数据库」。五台机器的 ssh、nginx、应用日志各写各的格式,时间戳一个带时区一个不带,grep 到天亮也拼不出完整链路——这是很多中小团队的真实样子。基于 Python 的日志审计系统把这堆手工活做成一条流水线:采集端跟着文件尾巴读,解析器把每行拆成时间、来源、级别、主体、动作,规则引擎按阈值和时间窗口挑出可疑事件,Web 端负责查、筛、导。它适合两类人:不想为了审计上重量级平台的团队,以及想搞懂日志处理链路怎么从零搭起来的 Python 开发者。没有玄学依赖,一台 4 核 8G 机器足够跑起采集、存储、查询三件套。
2. 技术选型与目录结构:采集、解析、规则、Web 四层怎么摆
日志审计系统的第一个认知误区,是把它当成高并发写入系统来设计。真实的写入量其实很有限:一台应用服务器峰值每秒几百行,五到十台机器加起来也就几千行每秒,一年存量撑死千万级。真正吃功夫的是「查得出来、追得下去、告警不吵人」这三件事。所以选型的目标不是吞吐上限,而是维护成本和可改造性。
2.1 为什么是 Flask + SQLAlchemy + APScheduler
Web 层用 Flask,原因很实际:这个系统的页面就那几张——事件查询、规则管理、告警列表、源配置,路由加起来不超过二十个,蓝图按功能拆开就够清晰。上 Django 会带来一堆用不上的 admin、auth、middleware,二次开发时反而要花时间绕开它。ORM 用 SQLAlchemy,是因为审计系统的存储一定会变:项目初期用 SQLite 落单机,数据涨到几百万行之后换 MySQL,接口层几乎不用动,只改连接串和几个方言相关的参数。
定时任务交给 APScheduler,主要干三件活:事件保留期清理(默认 90 天)、按小时聚合统计、告警发送失败重试。这几件事都不需要精确到秒,用内存调度器嵌在进程里最省事。要注意的是调度器只能在一个进程里跑,后面 Web 端用 gunicorn 起多 worker 时,得把调度器拆到单独的进程,或者用文件锁保证只有一个实例执行,这是踩过的坑。
采集端必须独立成进程,不能用 APScheduler 定时读文件。跟随式读取需要常驻的文件句柄和稳定的循环,混在 Web 进程里会被请求阻塞打断,还可能因为 worker 重启丢偏移量。独立进程还有个好处:某台日志源解析出问题,只影响那一个采集进程,Web 查询照常可用。
提示:全文检索方面,SQLite 的 FTS5 或 MySQL 的 FULLTEXT 索引在千万级以下足够用,不必为了「搜得快」提前引入独立的搜索引擎,多一个组件就多一份运维成本。
2.2 目录结构:依赖方向必须是单向的
项目按职责切四层,collector只负责读文件和解析,rules只负责匹配和告警,app负责 Web 与 API,models和config被所有层共享。依赖方向单向,谁都不许反向 import,这样后面想给采集端单独做 Docker 镜像,直接打包collector + models + config就行。
log-audit/ ├── app/ │ ├── __init__.py # create_app 工厂,注册蓝图 │ ├── models.py # Event / AuditRule / Alert / SourceOffset │ ├── api/ │ │ ├── events.py # 事件查询、导出 │ │ └── rules.py # 规则 CRUD │ ├── views/ │ │ └── dashboard.py # 页面路由 │ ├── templates/ # Jinja2 模板 │ └── static/ ├── collector/ │ ├── tailer.py # 跟随式读取,处理轮转 │ ├── parsers.py # 正则解析器注册表 │ ├── pipeline.py # 解析 → 归一化 → 批量入库 │ └── sources.yaml # 日志源配置 ├── rules/ │ ├── engine.py # 条件匹配 + 滑动窗口 │ ├── notifier.py # 告警去重与发送 │ └── builtin.yaml # 内置规则集 ├── config.py # 数据库 URL、保留期、批次大小 ├── manage.py # CLI 入口:run / collect / check ├── requirements.txt └── tests/manage.py用 click 或 argparse 都行,至少要提供三个子命令:collect起采集进程、run起 Web 服务、check校验规则文件语法。规则文件是人工编辑的,语法错一次就可能导致整个引擎加载失败,check子命令能在上线前把 YAML 语法、字段名、正则编译逐个走一遍,比事后看日志省事得多。
sources.yaml用配置而不是数据库表管理日志源,理由很简单:日志源路径是部署环境强相关的,跟着机器走,跟着代码版本走,不适合让运营在界面里改。改配置文件加重启采集进程,责任边界清楚。
2.3 数据模型:四张表撑起审计闭环
表结构决定了后面所有查询和告警的写法,值得一开始就想清楚。核心是events,每行一条已经解析过的事件;audit_rules存规则;alerts存触发记录;source_offsets存每个日志源读到的字节偏移,这是重启续采的依据。
from datetime import datetime from sqlalchemy import Index, String, Integer, DateTime, Text, JSON from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column class Base(DeclarativeBase): pass class Event(Base): __tablename__ = "events" id: Mapped[int] = mapped_column(Integer, primary_key=True) ts: Mapped[datetime] = mapped_column(DateTime, nullable=False) # 统一存 UTC source: Mapped[str] = mapped_column(String(64), nullable=False) # 来自哪个日志源 level: Mapped[str] = mapped_column(String(16), default="INFO") actor: Mapped[str] = mapped_column(String(128), default="") # 用户/账号,审计主体 action: Mapped[str] = mapped_column(String(64), default="") # login / delete / access remote_addr: Mapped[str] = mapped_column(String(64), default="") raw: Mapped[str] = mapped_column(Text, default="") # 原文永远保留 extra: Mapped[dict] = mapped_column(JSON, default=dict) # 解析出的其余字段 # 查询几乎都是「某个源 + 时间范围」,顺序不能反 __table_args__ = ( Index("ix_events_source_ts", "source", "ts"), Index("ix_events_actor_ts", "actor", "ts"), ) class SourceOffset(Base): __tablename__ = "source_offsets" source: Mapped[str] = mapped_column(String(64), primary_key=True) inode: Mapped[int] = mapped_column(Integer, default=0) offset: Mapped[int] = mapped_column(Integer, default=0) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)几个字段值得单独说。ts统一存 UTC,展示层按浏览器时区转换——这一条能省掉后面无数「为什么昨晚的告警显示在前天」的争论。actor和action是为审计场景专门抽出来的,因为绝大多数审计查询问的都是「谁做了什么」,把这两个字段从extra里提出来建独立索引,查询能快一个数量级。raw保留原文,解析失败的日志也能查,出问题时它是最后一道后悔药。
注意:
extra用 JSON 类型,SQLite 需要 3.38 以上版本才原生支持,低版本要么升级要么退化成 Text 存 JSON 字符串,查询时在应用层反序列化。
3. 日志采集与解析链路:从文件尾随到结构化字段落库
采集链路是整个系统的入口,也是最容易出问题的地方。写得糙一点,跑起来看着正常,等到某天日志轮转或者进程重启,数据就开始悄悄丢——这种丢数据不会报错,等你发现时已经缺了一周的记录。
3.1 跟随式读取:inode 和 offset 是两个必须记住的东西
日志轮转有两种常见方式:rename(把旧文件改名,新建同名文件)和copytruncate(复制一份后清空原文件)。前者会让文件的 inode 变化,后者 inode 不变但文件大小归零。采集器必须同时应付这两种情况。
import os import time from app.models import SourceOffset class Tailer: """按 inode 跟随单个日志文件,轮转或截断后自动重开""" def __init__(self, source: dict, session): self.source = source # {name, path, parser} self.session = session self.fh = None self.ino = None self._open() def _open(self): self.fh = open(self.source["path"], "r", encoding="utf-8", errors="replace") self.ino = os.fstat(self.fh.fileno()).st_ino row = self.session.get(SourceOffset, self.source["name"]) # 重启后从上次提交的偏移续读,避免整文件重扫 self.fh.seek(row.offset if row else 0) def _check_rotated(self) -> bool: try: st = os.stat(self.source["path"]) except FileNotFoundError: return False rotated = st.st_ino != self.ino or st.st_size < self.fh.tell() if rotated: self.fh.close() self._open() if st.st_size < (row := self.session.get(SourceOffset, self.source["name"]) or None) is None: pass return rotated def readline(self): line = self.fh.readline() if line: return line # 没读到新内容,回头看看文件是不是被轮转或截断了 self._check_rotated() return None_check_rotated里同时判断了两个条件:inode 变了说明是 rename 式轮转;当前文件大小小于已读位置,说明是 copytruncate 式的截断。只判断其中一个,另一种轮转方式就会导致采集器一直读着一个已经没人写的旧句柄,看着进程活着,其实一条新日志都拿不到——这种「假活」状态最难排查。
上面那段截断判断写得有点绕,实际落地时我一般抽成独立方法:一旦检测到截断,就把offset重置为 0 并重新 seek,同时把SourceOffset里的记录updated。逻辑复杂的时候宁可拆成三段小函数,也别在条件表达式里塞 walrus 运算符,三个月后自己都看不懂。
提示:
seek(row.offset)依赖文件内容在两次运行间不变。如果日志文件被手工编辑过,偏移会错位,解析出一堆乱码行。这种情况的兜底是raw字段,乱码也能查出来。
3.2 解析器:命名分组 + 格式注册表
解析器要做的事就一件:把一行文本变成字典。正则的命名分组(?P<name>...)天生适合干这个,groupdict()直接拿到字段名到值的映射,不用按下标一个个取。多个日志格式用注册表管理,一个源配置里写个parser名字就能对上。
import re # 每个日志格式一条正则,命名分组直接映射到 Event 字段 PATTERNS = { "nginx_access": re.compile( r'(?P<remote_addr>\S+) - - \[(?P<ts>[^\]]+)\] ' r'"(?P<method>[A-Z]+) (?P<path>\S+) [^"]*" (?P<status>\d{3}) (?P<bytes>\d+)' ), "ssh_auth": re.compile( r'(?P<ts>\w{3}\s+\d+ [\d:]+) \S+ sshd\[\d+\]: ' r'(?P<action>Accepted|Failed) password for (?P<user>\S+) from (?P<remote_addr>\S+)' ), "app_json": None, # JSON 行日志不走正则,见 parse_line 分支 } def parse_line(parser_name: str, line: str) -> dict | None: """返回字段字典;解析失败返回 None,由调用方决定是否落原文""" if parser_name == "app_json": try: data = json.loads(line) except json.JSONDecodeError: return None data["parser"] = "app_json" return data pattern = PATTERNS.get(parser_name) if pattern is None: return None m = pattern.match(line) # 用 match 锚行首,比 search 少一半回溯 if not m: return None data = m.groupdict() data["parser"] = parser_name return data两个细节:一是用match而不是search,日志行首通常就是固定格式,锚定行首能显著减少正则引擎的无效尝试,对于每秒几千行的场景,这点开销差得出来;二是解析失败不要直接丢,pipeline里会兜底成{"raw": line},宁可存一条半结构化的记录,也别让原始信息消失。
正则预编译放在模块加载时完成,不要写在函数里。Python 的re模块内部有缓存,但缓存有上限(默认 512),日志格式一多加上动态拼接正则,缓存击穿之后每次都要重编译,性能会断崖式下跌。
3.3 归一化与批量落库:500 条一批,别一条一条 insert
解析出来的原始字段时间格式五花八门,01/Jan/2025:03:12:44 +0800、Jan 1 03:12:44、2025-01-01T03:12:44Z都见过。归一化层负责把这些统一成 UTC 的datetime,同时把user、remote_addr这类字段映射到actor、remote_addr列。
from datetime import datetime, timezone from email.utils import parsedate_to_datetime from app.models import Event class Pipeline: def __init__(self, session, source, batch_size: int = 500): self.session = session self.source = source self.batch_size = batch_size self.buf = [] def feed(self, line: str): row = parse_line(self.source["parser"], line) if row is None: row = {"raw": line.strip(), "parser": "raw"} # 解析失败也留原文 event = self._normalize(row) self.buf.append(event) if len(self.buf) >= self.batch_size: self.flush() def _normalize(self, row: dict) -> dict: ts = self._to_utc(row.get("ts")) return { "ts": ts, "source": self.source["name"], "level": (row.get("level") or "INFO").upper(), "actor": row.get("user") or row.get("actor") or "", "action": row.get("action") or row.get("method") or "", "remote_addr": row.get("remote_addr", ""), "raw": row.get("raw") or row.get("_raw_line", ""), "extra": {k: v for k, v in row.items() if k not in {"ts", "user", "action", "remote_addr", "level", "raw"}}, } def flush(self): if not self.buf: return self.session.bulk_insert_mappings(Event, self.buf) self.session.commit() # 一批一个事务,回滚粒度可控 self.buf.clear() @staticmethod def _to_utc(value: str | None) -> datetime: if not value: return datetime.now(timezone.utc) for fmt in ("%d/%b/%Y:%H:%M:%S %z", "%b %d %H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"): try: dt = datetime.strptime(value, fmt) break except ValueError: continue else: return datetime.now(timezone.utc) return dt.replace(tzinfo=dt.tzinfo or timezone.utc).astimezone(timezone.utc)bulk_insert_mappings比逐条session.add快一个数量级,因为它跳过 ORM 的 identity map 和 flush 事件。批次大小 500 是个经验值:太小事务多、写放大明显;太大一次 commit 持有锁时间长,SQLite 上尤其明显,会阻塞 Web 端读。真要压到 2000 也行,但得配合 WAL 模式和合理的busy_timeout。
_to_utc里那个 for-else 结构容易被忽略:else分支只在循环没有break时执行,正好用来兜底「所有格式都匹配失败」。时间解析失败不应该抛异常中断整条采集链路,用当前时间兜底并标记来源,至少数据还在,事后能靠raw反推。
注意:
datetime.now(timezone.utc)兜底会产生大量「看起来是现在」的记录,如果某个源的解析格式一直配错,这批数据会污染时间轴。监控上要盯「兜底时间戳占比」这个指标,超过 1% 就该去查解析器了。
3.4 采集进程守护:崩溃重启后从哪继续
采集进程用manage.py collect --source xxx启动,一个源一个进程,交给 systemd 托管,Restart=always。进程重启后Tailer._open()会读SourceOffset表里的偏移继续读,所以重启本身不丢数据。但有个前提:offset的更新必须和事件的 commit 在同一个事务里,否则会出现「事件写进去了、偏移没更新」导致的重复采集,或者反过来导致的丢数据。
我一般会在Pipeline.flush()提交事件后,紧跟着更新偏移并提交第二次。这两次提交不是原子的,中间崩了会重复一小批(最多一个 batch),代价是几十条重复记录,比丢数据容易接受。去重交给入库后的唯一约束或者查询时的DISTINCT,不要为了避免重复去搞分布式事务,那是杀鸡用牛刀。
另一种更稳的做法是给events表加一个(source, ts, raw_hash)的唯一索引,bulk_insert_mappings冲突时整批回滚,然后降级成逐条插入并忽略冲突。这种做法适合数据量不大、对重复零容忍的审计场景。
4. 审计规则与告警引擎:用 YAML 描述规则,让运营自己改
采集只是把数据搬进来,审计的价值在规则引擎这一层。设计目标很明确:规则要能被非开发人员看懂和修改,改规则不需要发版。
4.1 规则的数据结构:字段、操作符、阈值、时间窗
一条规则拆开就四样东西:匹配什么事件(字段条件)、多久内发生几次(时间窗口 + 阈值)、触发后归到哪个级别、通知谁。用 YAML 描述,字段名和Event模型对齐,运营照着现有规则改就行。
# rules/builtin.yaml - id: ssh_brute_force name: 同一来源 IP 连续登录失败 severity: high match: source: ssh_auth action: Failed group_by: remote_addr # 按来源 IP 分桶计数 threshold: 5 # 5 次 window: 300 # 300 秒内 message: "IP {remote_addr} 在 5 分钟内登录失败 5 次" - id: sensitive_file_access name: 敏感路径被访问 severity: medium match: source: nginx_access path: "^/admin/.*" # 以 ^ 开头的值按正则处理 threshold: 1 window: 60 message: "{remote_addr} 访问了 {path}"加载时做三件事:校验match里的字段名是否存在于Event模型(防止拼写错误静默失效)、预编译以^开头的正则、把group_by的字段名转成取值函数。这三步在启动时做完,运行时就不用再做字符串比较和正则编译了。
import re from app.models import Event VALID_FIELDS = {c.name for c in Event.__table__.columns} def load_rules(path: str) -> list[dict]: rules = yaml.safe_load(open(path, encoding="utf-8")) for rule in rules: for field, expected in rule["match"].items(): if field not in VALID_FIELDS and field != "path": raise ValueError(f"规则 {rule['id']} 引用了不存在的字段 {field}") if isinstance(expected, str) and expected.startswith("^"): rule.setdefault("_compiled", {})[field] = re.compile(expected) rule["severity"] = rule.get("severity", "low") return rules字段名校验这一步看着多余,实际非常有必要。match里写个remote_ip而不是remote_addr,规则加载不报错,运行时永远匹配不上,告警永远不触发,运营以为「最近很安全」。这种静默失效比报错难查十倍。
4.2 匹配与时间窗口:用 deque 做滑动窗口计数
阈值类规则的核心是「一个时间窗口内同一个主体命中了几次」。最直白的实现是每次命中都去数据库count(*) where ts > now - window,但这样每条事件都要打一次库,采集吞吐直接被拖死。内存里维护每个「规则 + 分组值」的命中时间队列,是最省的做法。
from collections import defaultdict, deque class RuleEngine: def __init__(self, rules: list[dict]): self.rules = rules # (rule_id, group_value) -> 最近命中时间戳队列 self.buckets = defaultdict(deque) def feed(self, event: dict) -> list[dict]: hits = [] for rule in self.rules: if not self._match(rule, event): continue group = event.get(rule.get("group_by", ""), "") key = (rule["id"], group) dq = self.buckets[key] now = event["ts"].timestamp() window = rule.get("window", 300) # 滑出窗口的命中直接丢掉,队列长度天然等于窗口内次数 while dq and now - dq[0] > window: dq.popleft() dq.append(now) if len(dq) >= rule.get("threshold", 1): hits.append({"rule": rule, "event": event, "count": len(dq)}) dq.clear() # 触发后清空,避免同一批事件反复告警 return hits def _match(self, rule: dict, event: dict) -> bool: for field, expected in rule["match"].items(): value = event.get(field, "") compiled = rule.get("_compiled", {}).get(field) if compiled is not None: if not compiled.search(str(value)): return False elif str(value) != str(expected): return False return Truebuckets用defaultdict(deque)有个隐患:分组值理论上无限多(比如按 IP 分组,公网访问会产生海量 key),内存会一直涨。实际部署时我一般加个定期清理,比如每小时扫一遍,把队列空了的 key 删掉;或者用OrderedDict加容量上限做 LRU。数据量真的很大时,改成 Redis 的有序集合,ZREMRANGEBYSCORE清理过期成员,ZCARD取计数,这套操作是原子的。
dq.clear()那一行是血泪经验。最早没写,结果一个 IP 触发告警后,后续每来一条失败记录都还在窗口里,5 分钟内连发几十条告警,值班的人直接把这个通知渠道静音了——告警系统的可信度就是这么作没的。
4.3 告警收敛:指纹去重 + 冷却窗口
规则触发只是「发现了异常」,离「该通知人」还有一段距离。同一个问题在短时间内重复通知,除了制造焦虑没有别的效果。收敛策略用两层:一是指纹(规则 ID + 分组值)级冷却,同一指纹在冷却期内只发一次;二是小时级汇总,低危规则不实时推送,攒够一小时合并成一封。
import hashlib import time class Notifier: def __init__(self, cooldown: int = 1800): self.cooldown = cooldown self.last_sent: dict[str, float] = {} def _fingerprint(self, hit: dict) -> str: rule_id = hit["rule"]["id"] group = str(hit["event"].get(hit["rule"].get("group_by", ""), "")) return hashlib.md5(f"{rule_id}:{group}".encode()).hexdigest() def should_send(self, hit: dict) -> bool: fp = self._fingerprint(hit) now = time.time() if now - self.last_sent.get(fp, 0) < self.cooldown: return False # 冷却期内,压掉 self.last_sent[fp] = now return True def send(self, hit: dict, channels: list): if not self.should_send(hit): return text = hit["rule"]["message"].format(**hit["event"]) for ch in channels: ch.push(text, severity=hit["rule"]["severity"])last_sent存在内存里,进程重启就丢了,冷却期失效,重启瞬间可能补发一批。审计系统一般能接受这个代价;要求严格的话落一张alert_cooldown表,用(fingerprint, expire_at)做唯一约束,靠数据库保证。
消息模板用str.format有个小坑:event字典里缺少模板引用的字段会抛KeyError,告警发不出去还不报错。稳妥做法是用string.Template的safe_substitute,缺字段就原样保留占位符,至少消息能发出来,人能看出哪里没填上。
4.4 查询接口:组合条件 + 分页 + 导出
Web 端的查询接口是审计人员用得最多的地方,写法上没什么花活,关键是条件组合要全、分页要稳、导出别把内存撑爆。
from flask import Blueprint, request, jsonify bp = Blueprint("events", __name__, url_prefix="/api") @bp.get("/events") def list_events(): q = Event.query if src := request.args.get("source"): q = q.filter(Event.source == src) if actor := request.args.get("actor"): q = q.filter(Event.actor == actor) if level := request.args.get("level"): q = q.filter(Event.level == level) if kw := request.args.get("q"): q = q.filter(Event.raw.like(f"%{kw}%")) # 兜底全文模糊查 if start := request.args.get("start"): q = q.filter(Event.ts >= parse_ts(start)) if end := request.args.get("end"): q = q.filter(Event.ts <= parse_ts(end)) page = q.order_by(Event.ts.desc()).paginate( page=int(request.args.get("page", 1)), per_page=50, error_out=False ) return jsonify({"total": page.total, "items": [e.to_dict() for e in page.items]})raw.like("%kw%")左右都是通配符,用不上索引,全表扫。几百万行以内还能忍,再往上就得挂到 FTS5 上,或者限制「模糊搜索必须带时间范围」,靠时间索引先把候选集砍下来。导出功能同理,别用query.all()一把捞,用yield_per流式返回,边查边写 CSV,内存占用是常数级的。
5. 避坑与排查:几个让采集悄悄失效的坑
这套系统上线之后出的问题,绝大多数不是代码逻辑写错,而是对运行环境的假设不成立。下面几条都是真实踩过的,现象往往很迷惑——系统看着在跑,数据就是不对。
5.1 采集侧:轮转、重复与时区
坑一:日志轮转后采集器「假活」
现象:进程状态是 running,日志里也没有报错,但新产生的日志一条都进不来,events表里某个源的数据停在某个时间点不动了。
原因:只判断了 inode 变化,没判断文件被截断。copytruncate式轮转时 inode 不变,文件大小归零,readline()一直返回空字符串,采集器就一直等在新文件末尾的位置上,其实早就该 seek 回 0 了。
解决:在「读不到新内容」的分支里同时比较 inode 和st_size,st_size < fh.tell()就判定为截断,重置偏移重新打开。更省心的办法是给每个源加一个心跳指标:最近 10 分钟是否有新事件写入,没有就在监控上告警,比等人发现靠谱。
坑二:同一批日志被采集两次
现象:查询结果里同一时间、同一内容的事件出现两条,导出报表的计数对不上。
原因:Pipeline.flush()是先提交事件、再更新偏移,两次提交不是原子的。如果进程在两次提交之间被杀掉,重启后从旧偏移开始读,那一批 500 条就重复了。
解决:给events加(source, ts, raw_md5)唯一索引,插入冲突时整批回滚降级逐条插入并忽略重复;或者接受重复,查询层按source + ts + raw去重。我一般选前者,审计数据里重复记录会让「发生了几次」这个结论直接失效。
坑三:跨时区日志导致时间轴错乱
现象:某个源的告警总是显示在错误的时间点,明明是上班时间的操作,报表里显示在凌晨。
原因:应用日志带+0800时区,系统日志不带时区信息,代码里统一按 UTC 存但解析时把本地时间当成了 UTC,差了 8 小时。
解决:_to_utc里必须有明确的「无时区信息时按哪个时区解释」的配置项,不能默认 UTC 也不能默认本地时间,得写在sources.yaml里,一个源一个timezone字段。存库统一 UTC,展示时按用户时区转换,这条要坚持到底,中途混用一次就再也理不清了。
5.2 解析与告警侧:正则回溯与告警风暴
坑四:正则回溯爆炸,采集进程 CPU 打满
现象:某个日志源接入后,采集进程 CPU 一直 100%,事件写入几乎停滞,但进程没崩。
原因:规则里写了.*加嵌套量词的模式,碰上一条超长且不匹配的行(比如某个应用把整个 JSON 请求体打进一行日志),正则引擎开始指数级回溯。
解决:所有正则改成非贪婪加锚定,能不用.*就不用,用[^"]*这种明确的字符类代替;匹配前先做长度截断,超过 8KB 的行只取前 8KB 解析;再狠一点就用regex库的timeout参数,单个匹配超过 100ms 直接放弃,计数上报。哪一行会超长是猜不到的,只能在运行时兜住。
坑五:告警风暴把人逼到静音
现象:某个规则上线当晚推送了几百条通知,第二天值班的人把所有通知渠道都关了,之后真出事没人知道。
原因:只做了「阈值判断」,没做「触发后抑制」。同一主体在窗口内持续命中,每次都会重新计数再触发。
解决:命中队列在触发后clear(),加指纹级冷却窗口(半小时),低危规则改成小时级汇总。再补一条:所有通知都带一个「已抑制 N 条」的尾巴,让人知道收敛机制在工作,而不是以为漏发了。
坑六:SQLite 并发写入报 locked
现象:Web 端在查询的同时,采集端批量写入偶尔抛database is locked,重试后能成功但吞吐不稳。
原因:SQLite 默认的日志模式下,写操作会持有排他锁,读操作等锁。采集端一批 500 条提交,Web 端的复杂查询又在跑,两边撞上了。
解决:开启 WAL 模式(PRAGMA journal_mode=WAL),读写可以并发;设置busy_timeout=5000让写操作等锁而不是直接失败;采集端的 batch 从 2000 降到 500。数据量再涨就老老实实换 MySQL,别在 SQLite 上硬扛。
6. 二次开发:加一条自定义审计规则并验证它真的生效
接手这套系统之后最常做的改动,就是加规则。流程看着简单——写 YAML、重启——但真要让规则「确实在跑」而不是「以为在跑」,中间有几个必查的点。下面按我实际的顺序走一遍。
第一步,确认字段对得上。规则里match用的字段必须是Event表的列名,或者解析器输出的extra里的键。加规则前先去数据库SELECT extra FROM events WHERE source='xxx' LIMIT 5看一眼真实的字段结构,别照着文档猜。我见过不止一次规则写完了不生效,最后发现解析器输出的是user,规则里写的是username。
- id: admin_path_brute name: 管理后台路径高频探测 severity: high match: source: nginx_access path: "^/admin/.*" status: "404" # 注意是字符串,事件里字段值都是字符串 group_by: remote_addr threshold: 20 window: 600 message: "{remote_addr} 十分钟内探测后台路径 404 共 20 次"第二步,跑manage.py check校验。这一步会做字段名检查、正则编译、模板占位符检查,报错信息里带上规则 ID 和具体字段,改起来很快。
第三步,用真实日志回放验证。别等线上慢慢积累,直接拿历史日志喂给引擎:
# tests/test_rule_replay.py from rules.engine import RuleEngine from collector.parsers import parse_line def test_admin_path_brute(load_rules, sample_lines): engine = RuleEngine(load_rules(["admin_path_brute"])) hits = [] for line in sample_lines("nginx_access"): parsed = parse_line("nginx_access", line) if parsed: hits.extend(engine.feed(normalize(parsed))) assert len(hits) == 1 # 一批 404 只应触发一次 assert hits[0]["count"] >= 20这个测试的价值在于同时验证了三件事:解析器能正确拆出字段、规则条件能匹配上、触发后不会重复告警。三条里任何一条错,测试都会红,比上线后盯着数据库等告警快得多。
第四步,加页面指标。改app/api/events.py加一个聚合端点,按规则 ID 统计近 24 小时的命中次数,前台表格里显示。加完之后你能一眼看出规则是不是「哑火」了——一条规则连续几天零命中,要么是真的太平,要么是条件写错了。
| 扩展点 | 改哪个文件 | 验证方式 |
|---|---|---|
| 新日志格式 | collector/parsers.py的PATTERNS | 用样本行跑parse_line单测 |
| 新审计规则 | rules/builtin.yaml | manage.py check+ 回放测试 |
| 新通知渠道 | rules/notifier.py加一个push实现 | 手动触发一条高危规则,看是否送达 |
| 新查询维度 | app/api/events.py加过滤条件 | 对比 SQL 执行计划,确认走索引 |
改通知渠道时有个容易忽略的地方:所有渠道实现同一个push(text, severity)接口,但邮件和 webhook 的失败语义不一样。邮件发失败可以重试,webhook 超时重试可能导致对端收到重复消息,得在请求头里带一个幂等键。这种细节不写在接口定义里,下一个人接手足矣翻车。
从那以后我每次加规则,都强制走一遍「查字段 → check → 回放测试 → 上线后看一天命中数」这四步,哪怕规则再简单。规则失效是静默的,它不会报错,只会让你以为一切正常——审计系统最怕的不是漏报,是让人误以为没发生过。希望这些能帮你在自己搭这套东西的时候少走两段弯路。
本文还有配套的精品资源,点击获取