"""Redis 审核规则引擎 — 43+ 条 Redis 特有的审核规则。 为 SQL 审核平台提供完整的 Redis 命令审核能力,覆盖: - KEY 管理规则 (10条): Key命名规范、大小限制、过期策略 - 命令安全规则 (12条): 危险命令检测(FLUSHDB/SHUTDOWN/CONFIG SET等) - 数据结构规则 (8条): 各类型最佳实践与大小限制 - 性能规则 (8条): 大Key检测、批量操作限制、阻塞命令 - 安全规则 (5条): AUTH, ACL 配置与保护模式 使用方式: engine = SQLAuditEngineV2(db_session) report = await engine.audit("SET user:1001:name张三 EX3600", db_type="redis") 规则编码规范: KEY: REDIS-KEY-001 ~ REDIS-KEY-010 CMD: REDIS-CMD-001 ~ REDIS-CMD-012 DS: REDIS-DS-001 ~ REDIS-DS-008 PERF: REDIS-PERF-001 ~ REDIS-PERF-008 SEC: REDIS-SEC-001 ~ REDIS-SEC-005 """ from __future__ import annotations import re from typing import Any, Dict, List, Optional, Tuple from app.services.audit_engine_v2 import ( AuditFindingV2, AuditRuleV2, AuditReportV2, Severity, RuleCategory, register_rule, ) # ═══════════════════════════════════════════════ # Redis 辅助解析函数 # ═══════════════════════════════════════════════ def _parse_redis_command(sql_clean: str) -> Tuple[str, List[str]]: """解析 Redis 命令,返回 (命令名大写, 参数列表)。 Args: sql_clean: 单行 Redis 命令字符串 Returns: (command_upper, args_list) 元组 例如: "SET key value EX 3600" -> ("SET", ["key", "value", "EX", "3600"]) """ parts = sql_clean.strip().split() if not parts: return "", [] command = parts[0].upper() args = parts[1:] if len(parts) > 1 else [] return command, args def _extract_key_from_command(command: str, args: List[str]) -> Optional[str]: """从 Redis 命令中提取 Key 名称。 大多数命令的第一个参数是 Key,部分命令需要特殊处理。 Args: command: 命令名(大写) args: 参数列表 Returns: Key 名称,如果无法提取则返回 None """ if not args: return None # 特殊命令:第二个参数才是 key second_key_commands = { "GETSET", "LPUSH", "RPUSH", "LPOP", "RPOP", "LINDEX", "LSET", "LREM", "LINSERT", "SADD", "SREM", "SPOP", "SMOVE", "SISMEMBER", "ZADD", "ZREM", "ZSCORE", "ZRANK", "ZREVRANK", "ZINCRBY", "HSET", "HGET", "HDEL", "HINCRBY", "HINCRBYFLOAT", "HMSET", "HMGET", "HSETNX", "HEXISTS", "HLEN", "HKEYS", "HVALS", "HGETALL", "HSCAN", "HPOP", "SETEX", "PSETEX", "SETNX", } # 多 key 命令 multi_key_commands = { "DEL", "UNLINK", "EXISTS", "TOUCH", "MGET", "MSET", "MSETNX", "SDIFF", "SINTER", "SUNION", "SDIFFSTORE", "SINTERSTORE", "SUNIONSTORE", "BLPOP", "BRPOP", "BRPOPLPUSH", "BLMOVE", "RPOPLPUSH", "LMOVE", "RENAME", "RENAMENX", "SMOVE", "PFMERGE", "PFCOUNT", "BZPOPMIN", "BZPOPMAX", "ZDIFF", "ZINTER", "ZUNION", "ZDIFFSTORE", "ZINTERSTORE", "ZUNIONSTORE", } if command in multi_key_commands and len(args) >= 1: return args[0] if command in second_key_commands and len(args) >= 1: return args[0] # 默认:第一个参数是 key return args[0] def _is_redis_command(sql_upper: str) -> bool: """判断是否为 Redis 命令行(非空且非注释)。""" stripped = sql_upper.strip() return bool(stripped) and not stripped.startswith("#") def _has_namespace_separator(key: str, separator: str = ":") -> bool: """检查 Key 是否包含命名空间分隔符(默认冒号)。""" return separator in key def _count_namespaces(key: str, separator: str = ":") -> int: """统计 Key 中命名空间层级数。""" return key.count(separator) + 1 def _extract_ttl_from_args(command: str, args: List[str]) -> Optional[int]: """从命令参数中提取 TTL 值(秒)。 检测 EX, PX, EXAT, PXAT 参数。 Returns: TTL 秒数,如果没有找到则返回 None """ if not args: return None cmd = command.upper() # SET ... EX seconds / PX milliseconds / EXAT timestamp / PXAT ms_timestamp if cmd == "SET" or cmd.startswith("SET"): for i, arg in enumerate(args): arg_upper = arg.upper() if arg_upper == "EX" and i + 1 < len(args): try: return int(args[i + 1]) except (ValueError, TypeError): return None elif arg_upper == "PX" and i + 1 < len(args): try: return int(args[i + 1]) // 1000 except (ValueError, TypeError): return None elif arg_upper in ("EXAT", "PXAT") and i + 1 < len(args): return -1 # 绝对时间戳,视为已设置 TTL return None # SETEX key seconds value / PSETEX key milliseconds value if cmd == "SETEX" and len(args) >= 2: try: return int(args[1]) except (ValueError, TypeError): return None if cmd == "PSETEX" and len(args) >= 2: try: return int(args[1]) // 1000 except (ValueError, TypeError): return None # EXPIRE key seconds / PEXPIRE key milliseconds / EXPIREAT key timestamp if cmd in ("EXPIRE", "EXPIREAT") and len(args) >= 2: try: return int(args[1]) except (ValueError, TypeError): return None if cmd == "PEXPIRE" and len(args) >= 2: try: return int(args[1]) // 1000 except (ValueError, TypeError): return None return None def _is_ttl_set_in_command(sql_clean: str, command: str, args: List[str]) -> bool: """检查命令是否设置了 TTL。""" cmd = command.upper() # 直接设置 TTL 的命令 ttl_commands = { "EXPIRE", "PEXPIRE", "EXPIREAT", "PEXPIREAT", "SETEX", "PSETEX" } if cmd in ttl_commands: return True # SET 命令带 EX/PX/EXAT/PXAT if cmd in ("SET", "GETSET"): for i, arg in enumerate(args): if arg.upper() in ("EX", "PX", "EXAT", "PXAT"): return True return False def _is_write_command(command: str) -> bool: """判断是否为写入类命令。""" write_commands = { "SET", "GETSET", "MSET", "MSETNX", "SETNX", "SETEX", "PSETEX", "HSET", "HMSET", "HSETNX", "HDEL", "HINCRBY", "HINCRBYFLOAT", "LPUSH", "RPUSH", "LPOP", "RPOP", "LSET", "LREM", "LINSERT", "LTRIM", "LMOVE", "BLMOVE", "RPOPLPUSH", "BRPOPLPUSH", "SADD", "SREM", "SPOP", "SMOVE", "SDIFFSTORE", "SINTERSTORE", "SUNIONSTORE", "ZADD", "ZREM", "ZINCRBY", "ZREMRANGEBYRANK", "ZREMRANGEBYSCORE", "ZREMRANGEBYLEX", "ZDIFFSTORE", "ZINTERSTORE", "ZUNIONSTORE", "XADD", "XDEL", "XTRIM", "XGROUP", "XSETID", "DEL", "UNLINK", "FLUSHDB", "FLUSHALL", "APPEND", "INCR", "DECR", "INCRBY", "DECRBY", "INCRBYFLOAT", "PFADD", "PFMERGE", "PFCOUNT", "GEOADD", "GEORADIUS", "GEORADIUSBYMEMBER", "BITSET", "SETBIT", "BITOP", "RESTORE", "MIGRATE", "RENAME", "RENAMENX", } return command.upper() in write_commands def _extract_pipeline_commands(sql_clean: str) -> List[str]: """从 Pipeline 输入中提取每条命令。 Pipeline 命令通常用换行符分隔。 """ commands = [] for line in sql_clean.split("\n"): stripped = line.strip() if stripped and not stripped.startswith("#"): commands.append(stripped) return commands # ═══════════════════════════════════════════════ # KEY 管理规则 (10条) # ═══════════════════════════════════════════════ @register_rule(AuditRuleV2( code="REDIS-KEY-001", name="Key命名规范", category=RuleCategory.NAMING, severity=Severity.WARNING, db_type="redis", description="Key 建议使用冒号分隔的命名空间,如 user:1001:profile", config={"namespace_separator": ":", "min_namespace_levels": 2}, message_template="Key '{key}' 命名不符合规范,建议使用冒号分隔的命名空间(如 app:module:id)", )) def check_redis_key_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Key命名规范检查 - 建议使用冒号分隔的命名空间。""" command, args = _parse_redis_command(sql_clean) if not command: return key = _extract_key_from_command(command, args) if not key: return separator = rule.config.get("namespace_separator", ":") min_levels = rule.config.get("min_namespace_levels", 2) if separator not in key or _count_namespaces(key, separator) < min_levels: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(key=key), )) @register_rule(AuditRuleV2( code="REDIS-KEY-002", name="Key最大长度限制", category=RuleCategory.NAMING, severity=Severity.WARNING, db_type="redis", description="Key 名称最大长度建议不超过128字节", config={"max_key_length": 128}, message_template="Key '{key}' 长度为 {length} 字节,超过最大限制 {max} 字节", )) def check_redis_key_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Key最大长度限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return key = _extract_key_from_command(command, args) if not key: return max_len = rule.config.get("max_key_length", 128) key_bytes = len(key.encode("utf-8")) if key_bytes > max_len: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(key=key, length=key_bytes, max=max_len), )) @register_rule(AuditRuleV2( code="REDIS-KEY-003", name="Key值大小限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="String 值建议不超过 10KB,Hash 建议不超过 1000 字段", config={"max_string_size": 10240, "max_hash_fields": 1000}, message_template="{cmd} 操作可能导致大 Key 问题,String 值建议不超过 {max_str} 字节,Hash 建议不超过 {max_hash} 字段", )) def check_redis_key_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Key值大小限制检查 - 针对大 Value 的写入操作给出警告。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # 检测 HMSET/HSET 大量字段(超过阈值的一半即警告) hash_cmds = {"HSET", "HMSET", "HGETALL"} if cmd in hash_cmds and len(args) > 20: max_hash = rule.config.get("max_hash_fields", 1000) # HMSET key field1 value1 field2 value2 ... # 字段数 = (args - 1) // 2 field_count = (len(args) - 1) // 2 if cmd in ("HSET", "HMSET") else 0 if field_count > max_hash // 2: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=f"HMSET/HSET 操作包含约 {field_count} 个字段,接近大 Key 阈值 {max_hash},建议拆分 Hash 结构", )) return # 检测大 String 写入 string_cmds = {"SET", "SETEX", "PSETEX", "APPEND", "GETSET"} if cmd in string_cmds and len(args) >= 2: max_str = rule.config.get("max_string_size", 10240) # 找到 value 参数(通常是第2或第3个参数) value = "" if cmd in ("SETEX", "PSETEX") and len(args) >= 3: value = args[2] elif cmd == "APPEND" and len(args) >= 2: value = args[1] elif len(args) >= 2: value = args[1] value_bytes = len(value.encode("utf-8")) if value else 0 if value_bytes > max_str: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=f"SET 操作的 Value 大小为 {value_bytes} 字节,超过建议阈值 {max_str} 字节(10KB)", )) @register_rule(AuditRuleV2( code="REDIS-KEY-004", name="写入Key必须设置TTL", category=RuleCategory.DDL, severity=Severity.ERROR, db_type="redis", description="写入 Key 必须设置 TTL,不允许永久 Key 存在", config={"whitelist_keys": []}, message_template="写入 Key '{key}' 未设置 TTL,请添加 EX/PX 参数或单独执行 EXPIRE 命令,禁止创建永久 Key", )) def check_redis_key_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """写入 Key 必须设置 TTL 检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # 只对写入命令检查 if not _is_write_command(cmd): return key = _extract_key_from_command(command, args) if not key: return # 检查白名单 whitelist = rule.config.get("whitelist_keys", []) if any(key.startswith(w) for w in whitelist): return # 检查是否已设置 TTL if _is_ttl_set_in_command(sql_clean, cmd, args): return # 排除一些天然带 TTL 的命令和一些特殊的命令 ttl_builtin_cmds = {"SETEX", "PSETEX"} if cmd in ttl_builtin_cmds: return # DEL/UNLINK/FLUSH 等删除命令不需要 TTL delete_cmds = {"DEL", "UNLINK", "FLUSHDB", "FLUSHALL", "EXPIRE", "PEXPIRE", "EXPIREAT", "PEXPIREAT", "TTL", "PTTL", "PERSIST"} if cmd in delete_cmds: return # 自增类命令通常是更新已有 Key,不需要设置 TTL incr_cmds = {"INCR", "DECR", "INCRBY", "DECRBY", "INCRBYFLOAT", "HINCRBY", "HINCRBYFLOAT", "ZINCRBY"} if cmd in incr_cmds: return report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(key=key), )) @register_rule(AuditRuleV2( code="REDIS-KEY-005", name="默认TTL建议", category=RuleCategory.DDL, severity=Severity.INFO, db_type="redis", description="建议设置合理的默认 TTL,如 3600 秒(1小时)", config={"recommended_default_ttl": 3600, "min_recommended_ttl": 60}, message_template="建议为 Key '{key}' 设置合理的 TTL,默认推荐 {ttl} 秒(1小时),避免数据永久驻留", )) def check_redis_key_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """默认 TTL 建议 - 对未设置 TTL 或 TTL 过短的命令给出提示。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # 只检查写入命令 if not _is_write_command(cmd): return key = _extract_key_from_command(command, args) if not key: return ttl = _extract_ttl_from_args(cmd, args) if ttl is None: return # 已由 REDIS-KEY-004 检查 min_ttl = rule.config.get("min_recommended_ttl", 60) if ttl > 0 and ttl < min_ttl: default_ttl = rule.config.get("recommended_default_ttl", 3600) report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=f"Key '{key}' 的 TTL 设置为 {ttl} 秒,过短可能导致频繁缓存失效,建议至少 {min_ttl} 秒或默认 {default_ttl} 秒", )) @register_rule(AuditRuleV2( code="REDIS-KEY-006", name="最大TTL限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="Key 的 TTL 建议不超过 30 天(2592000 秒)", config={"max_ttl_seconds": 2592000}, message_template="Key '{key}' 的 TTL {ttl} 秒超过最大限制 {max} 秒({days} 天),建议缩短过期时间", )) def check_redis_key_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """最大 TTL 限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() ttl = _extract_ttl_from_args(cmd, args) if ttl is None or ttl <= 0: return max_ttl = rule.config.get("max_ttl_seconds", 2592000) if ttl > max_ttl: key = _extract_key_from_command(command, args) or "unknown" days = max_ttl // 86400 report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(key=key, ttl=ttl, max=max_ttl, days=days), )) @register_rule(AuditRuleV2( code="REDIS-KEY-007", name="禁止使用KEYS命令", category=RuleCategory.PERFORMANCE, severity=Severity.ERROR, db_type="redis", description="KEYS 命令会扫描全库,阻塞服务器,必须使用 SCAN 替代", message_template="禁止使用 KEYS 命令('{cmd}'),会导致全库扫描阻塞服务器,请使用 SCAN 替代", )) def check_redis_key_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止使用 KEYS 命令。""" command, args = _parse_redis_command(sql_clean) if not command: return if command.upper() == "KEYS": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=sql_clean.strip()), )) @register_rule(AuditRuleV2( code="REDIS-KEY-008", name="必须指定命名空间前缀", category=RuleCategory.NAMING, severity=Severity.WARNING, db_type="redis", description="Key 必须以指定的命名空间前缀开头,如 app:service:key", config={"required_prefixes": ["app:", "svc:", "cache:", "session:", "mq:"]}, message_template="Key '{key}' 缺少命名空间前缀,Key 必须以 {prefixes} 之一开头", )) def check_redis_key_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """必须指定命名空间前缀检查。""" command, args = _parse_redis_command(sql_clean) if not command: return key = _extract_key_from_command(command, args) if not key: return prefixes = rule.config.get("required_prefixes", ["app:", "svc:", "cache:", "session:", "mq:"]) if not any(key.startswith(p) for p in prefixes): report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(key=key, prefixes=", ".join(prefixes)), )) @register_rule(AuditRuleV2( code="REDIS-KEY-009", name="禁止批量删除Key模式", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止使用 DEL key:* 等通配符批量删除 Key 的模式", config={}, message_template="禁止使用通配符批量删除 Key 的模式 '{cmd}',请使用 SCAN + DEL 循环逐个删除", )) def check_redis_key_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止批量删除 Key 模式检查。 检测使用通配符配合 DEL 的模式,如: - DEL user:* - EVAL "... KEYS[1] ..." 0 prefix:* """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # 检查 DEL/UNLINK 命令参数中包含通配符 * if cmd in ("DEL", "UNLINK") and args: for arg in args: if "*" in arg or "?" in arg: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=sql_clean.strip()), )) return # 检查 EVAL/EVALSHA 脚本中的批量删除模式 if cmd in ("EVAL", "EVALSHA") and args: script_body = args[0].upper() if "DEL" in script_body and ("KEYS" in script_body or "ARGV" in script_body): report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=f"Lua 脚本 '{cmd}' 中可能包含批量删除操作,请确保使用 SCAN 逐个删除", )) @register_rule(AuditRuleV2( code="REDIS-KEY-010", name="推荐高效编码", category=RuleCategory.DDL, severity=Severity.INFO, db_type="redis", description="建议开启 ziplist/intset 等高效编码以节省内存", config={"ziplist_max_entries": 512, "ziplist_max_value": 64}, message_template="建议配置 ziplist/intset 高效编码参数(hash-max-ziplist-entries/list-max-ziplist-entries/set-max-intset-entries),节省内存", )) def check_redis_key_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """推荐高效编码检查 - 检测 CONFIG SET 命令中关于编码的配置。 当用户执行 CONFIG SET 修改编码相关参数时进行检查和提示。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() if cmd == "CONFIG" and len(args) >= 2: subcmd = args[0].upper() param = args[1].upper() if subcmd == "SET" and "ziplist" in param.lower(): try: value = int(args[2]) if len(args) >= 3 else 0 max_entries = rule.config.get("ziplist_max_entries", 512) if value > max_entries: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"ziplist 条目数设置为 {value},超过建议值 {max_entries},过大可能导致性能下降", )) except (ValueError, TypeError, IndexError): pass # 对于 Hash 操作,如果字段数较少但使用了大配置,给出提示 if cmd in ("HSET", "HMSET") and len(args) >= 4: field_count = (len(args) - 1) // 2 max_entries = 512 if field_count <= max_entries: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"Hash 字段数为 {field_count},建议配置 hash-max-ziplist-entries >= {max_entries} 以启用 ziplist 编码节省内存", )) # ═══════════════════════════════════════════════ # 命令安全规则 (12条) # ═══════════════════════════════════════════════ @register_rule(AuditRuleV2( code="REDIS-CMD-001", name="禁止FLUSHDB", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 FLUSHDB 命令,将清空当前数据库所有数据", message_template="禁止执行 FLUSHDB,该命令将清空当前数据库所有数据,如需清理请使用 SCAN + DEL 逐个删除", )) def check_redis_cmd_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 FLUSHDB。""" command, args = _parse_redis_command(sql_clean) if command.upper() == "FLUSHDB": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-CMD-002", name="禁止FLUSHALL", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 FLUSHALL 命令,将清空所有数据库数据", message_template="禁止执行 FLUSHALL,该命令将清空所有数据库数据,属于高危操作", )) def check_redis_cmd_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 FLUSHALL。""" command, args = _parse_redis_command(sql_clean) if command.upper() == "FLUSHALL": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-CMD-003", name="禁止SHUTDOWN", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 SHUTDOWN 命令,将关闭 Redis 服务器", message_template="禁止执行 SHUTDOWN,该命令将关闭 Redis 服务器,如需维护请联系 DBA", )) def check_redis_cmd_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 SHUTDOWN。""" command, args = _parse_redis_command(sql_clean) if command.upper() == "SHUTDOWN": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-CMD-004", name="禁止CONFIG SET", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 CONFIG SET 命令修改 Redis 配置", message_template="禁止执行 CONFIG SET 修改配置,参数调整需通过运维流程审批后由 DBA 执行", )) def check_redis_cmd_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 CONFIG SET。""" command, args = _parse_redis_command(sql_clean) if command.upper() == "CONFIG" and len(args) >= 1: if args[0].upper() == "SET": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-CMD-005", name="禁止DEBUG命令", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 DEBUG 命令(含 DEBUG SEGFAULT 等调试命令)", message_template="禁止执行 DEBUG 命令('{cmd}'),调试命令可能导致服务崩溃或性能问题", )) def check_redis_cmd_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 DEBUG 命令。""" command, args = _parse_redis_command(sql_clean) if command.upper() == "DEBUG": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=sql_clean.strip()), )) @register_rule(AuditRuleV2( code="REDIS-CMD-006", name="禁止REPLICAOF/SLAVEOF", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 REPLICAOF/SLAVEOF 命令修改主从配置", message_template="禁止执行 {cmd} 修改主从复制配置,主从变更需通过运维流程审批", )) def check_redis_cmd_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 REPLICAOF/SLAVEOF。""" command, args = _parse_redis_command(sql_clean) cmd = command.upper() if cmd in ("REPLICAOF", "SLAVEOF"): report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=cmd), )) @register_rule(AuditRuleV2( code="REDIS-CMD-007", name="禁止MIGRATE", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 MIGRATE 命令进行数据迁移", message_template="禁止执行 MIGRATE 数据迁移命令,数据迁移需通过 DBA 审批和协助", )) def check_redis_cmd_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 MIGRATE。""" command, args = _parse_redis_command(sql_clean) if command.upper() == "MIGRATE": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-CMD-008", name="禁止RESTORE", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 RESTORE 命令恢复序列化数据", message_template="禁止执行 RESTORE 恢复数据命令,数据恢复需通过 DBA 审批和协助", )) def check_redis_cmd_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 RESTORE。""" command, args = _parse_redis_command(sql_clean) if command.upper() == "RESTORE": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-CMD-009", name="禁止EVAL/EVALSHA", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 EVAL/EVALSHA 运行 Lua 脚本(未授权脚本风险)", config={"allowed_scripts": []}, message_template="禁止执行 {cmd} 运行 Lua 脚本,未授权脚本可能导致性能问题或安全风险,请使用 Function 或提交审批", )) def check_redis_cmd_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 EVAL/EVALSHA。""" command, args = _parse_redis_command(sql_clean) cmd = command.upper() if cmd in ("EVAL", "EVALSHA"): report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=cmd), )) @register_rule(AuditRuleV2( code="REDIS-CMD-010", name="禁止MODULE LOAD", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 MODULE LOAD 加载外部模块(安全风险)", message_template="禁止执行 MODULE LOAD 加载外部模块,模块加载需通过安全审批和代码审计", )) def check_redis_cmd_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 MODULE LOAD。""" command, args = _parse_redis_command(sql_clean) cmd = command.upper() if cmd == "MODULE" and len(args) >= 1 and args[0].upper() == "LOAD": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-CMD-011", name="禁止ACL SETUSER", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="禁止执行 ACL SETUSER 修改用户权限", message_template="禁止执行 ACL SETUSER 修改用户权限,账户管理需通过运维流程", )) def check_redis_cmd_011(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止 ACL SETUSER。""" command, args = _parse_redis_command(sql_clean) cmd = command.upper() if cmd == "ACL" and len(args) >= 1: if args[0].upper() in ("SETUSER", "DELUSER", "LOAD", "SAVE", "GENPASS"): report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-CMD-012", name="限制MONITOR命令", category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="redis", description="MONITOR 命令会导致严重的性能影响,生产环境限制使用", message_template="MONITOR 命令会导致严重的性能影响(所有命令输出到客户端),生产环境请限制使用", )) def check_redis_cmd_012(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """限制 MONITOR 命令。""" command, args = _parse_redis_command(sql_clean) if command.upper() == "MONITOR": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) # ═══════════════════════════════════════════════ # 数据结构规则 (8条) # ═══════════════════════════════════════════════ @register_rule(AuditRuleV2( code="REDIS-DS-001", name="String值最大大小限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="String 类型值大小建议不超过 10MB", config={"max_string_size_mb": 10, "max_string_size_bytes": 10485760}, message_template="String 值大小约为 {size} 字节,超过建议阈值 {max} 字节({max_mb}MB),建议使用 Hash/List 拆分存储", )) def check_redis_ds_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """String 值最大大小限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() string_write_cmds = {"SET", "SETEX", "PSETEX", "GETSET", "APPEND"} if cmd not in string_write_cmds or len(args) < 2: return value = "" if cmd in ("SETEX", "PSETEX") and len(args) >= 3: value = args[2] elif cmd in ("SET", "GETSET", "APPEND") and len(args) >= 2: value = args[1] max_bytes = rule.config.get("max_string_size_bytes", 10485760) value_bytes = len(value.encode("utf-8")) if value else 0 if value_bytes > max_bytes: max_mb = rule.config.get("max_string_size_mb", 10) report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(size=value_bytes, max=max_bytes, max_mb=max_mb), )) @register_rule(AuditRuleV2( code="REDIS-DS-002", name="Hash最大字段数限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="Hash 类型字段数建议不超过 10000", config={"max_hash_fields": 10000}, message_template="Hash 字段数 {fields} 超过建议阈值 {max},请拆分 Hash 或使用多个 Key 存储", )) def check_redis_ds_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Hash 最大字段数限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() if cmd in ("HMSET", "HSET") and len(args) >= 3: field_count = (len(args) - 1) // 2 max_fields = rule.config.get("max_hash_fields", 10000) if field_count > max_fields: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(fields=field_count, max=max_fields), )) # HGETALL 读取大 Hash 也警告 if cmd == "HGETALL" and len(args) >= 1: max_fields = rule.config.get("max_hash_fields", 10000) report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"HGETALL 读取 Hash '{args[0]}' 可能返回大量字段,建议使用 HSCAN 分批读取,避免阻塞", )) @register_rule(AuditRuleV2( code="REDIS-DS-003", name="List最大长度限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="List 类型长度建议不超过 100000", config={"max_list_length": 100000}, message_template="List 长度可能超过建议阈值 {max},请使用 LTRIM 控制长度或使用 Stream 替代", )) def check_redis_ds_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """List 最大长度限制检查。 检测 List 写入命令,当未带 LTRIM/XTRIM 等长度控制时警告。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() list_push_cmds = {"LPUSH", "RPUSH", "LPUSHX", "RPUSHX"} if cmd in list_push_cmds and len(args) >= 2: max_len = rule.config.get("max_list_length", 100000) # 大量元素一次性 LPUSH/RPUSH element_count = len(args) - 1 # 减去 key if element_count > 100: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"一次性 {cmd} 插入 {element_count} 个元素,List 长度可能增长过快,建议使用 LTRIM 控制长度不超过 {max_len}", )) # LRANGE 大跨度读取 if cmd == "LRANGE" and len(args) >= 3: try: start = int(args[1]) end = int(args[2]) range_size = abs(end - start) + 1 if end >= 0 else abs(start) + abs(end) max_len = rule.config.get("max_list_length", 100000) if range_size > max_len // 10: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=f"LRANGE 范围 {start} ~ {end} 涉及约 {range_size} 个元素,范围过大可能导致阻塞,建议分页读取", )) except (ValueError, TypeError): pass @register_rule(AuditRuleV2( code="REDIS-DS-004", name="Set最大成员数限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="Set 类型成员数建议不超过 100000", config={"max_set_members": 100000}, message_template="Set 成员数 {members} 超过建议阈值 {max},请拆分 Set 或使用 BloomFilter 替代", )) def check_redis_ds_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Set 最大成员数限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # SADD 大量成员 if cmd == "SADD" and len(args) > 100: member_count = len(args) - 1 max_members = rule.config.get("max_set_members", 100000) if member_count > max_members // 10: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"SADD 一次性添加 {member_count} 个成员,Set 过大(>{max_members})会影响性能,建议拆分", )) # SMEMBERS 全量读取大 Set 警告 if cmd == "SMEMBERS" and len(args) >= 1: max_members = rule.config.get("max_set_members", 100000) report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"SMEMBERS 全量读取 Set '{args[0]}' 可能返回大量数据,建议使用 SSCAN 分批扫描(成员数建议不超过 {max_members})", )) @register_rule(AuditRuleV2( code="REDIS-DS-005", name="ZSet最大成员数限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="ZSet 类型成员数建议不超过 100000", config={"max_zset_members": 100000}, message_template="ZSet 成员数超过建议阈值 {max},请拆分 ZSet 或优化数据模型", )) def check_redis_ds_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """ZSet 最大成员数限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # ZADD 大量成员 if cmd == "ZADD" and len(args) > 200: member_count = (len(args) - 1) // 2 max_members = rule.config.get("max_zset_members", 100000) if member_count > max_members // 10: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"ZADD 一次性添加 {member_count} 个成员,ZSet 过大(>{max_members})会影响排序性能,建议拆分", )) # ZRANGE/ZREVRANGE 大跨度读取 if cmd in ("ZRANGE", "ZREVRANGE") and len(args) >= 3: try: start = int(args[1]) end = int(args[2]) if end < 0 or (end - start) > 10000: max_members = rule.config.get("max_zset_members", 100000) report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"{cmd} 大范围读取({start}~{end})可能阻塞,建议分页(每次不超过 1000)或限制 ZSet 大小(<{max_members})", )) except (ValueError, TypeError): pass @register_rule(AuditRuleV2( code="REDIS-DS-006", name="Stream最大长度限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="Stream 类型必须限制最大长度(使用 MAXLEN),建议不超过 100000", config={"max_stream_length": 100000}, message_template="Stream 写入命令 '{cmd}' 未使用 MAXLEN 限制长度,请添加 MAXLEN ~ {max} 防止内存无限增长", )) def check_redis_ds_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Stream 最大长度限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() if cmd == "XADD" and len(args) >= 2: has_maxlen = any(arg.upper() == "MAXLEN" for arg in args) if not has_maxlen: max_len = rule.config.get("max_stream_length", 100000) report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=sql_clean.strip(), max=max_len), )) # XTRIM 命令也需要检查 if cmd == "XTRIM" and len(args) >= 1: has_maxlen = any(arg.upper() == "MAXLEN" for arg in args) if not has_maxlen: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message="XTRIM 命令未使用 MAXLEN 参数,请确保使用正确的修剪策略", )) @register_rule(AuditRuleV2( code="REDIS-DS-007", name="Bitmap最大大小限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="Bitmap 类型大小建议不超过 512MB(offset < 2^32)", config={"max_bitmap_size_mb": 512, "max_bitmap_offset": 4294967296}, message_template="Bitmap offset {offset} 超过建议阈值 {max_offset}({max_mb}MB),Bitmap 过大消耗大量内存", )) def check_redis_ds_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Bitmap 最大大小限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() bitmap_cmds = {"SETBIT", "GETBIT", "BITPOS", "BITCOUNT", "BITOP"} if cmd not in bitmap_cmds: return max_offset = rule.config.get("max_bitmap_offset", 4294967296) max_mb = rule.config.get("max_bitmap_size_mb", 512) # SETBIT key offset value if cmd == "SETBIT" and len(args) >= 2: try: offset = int(args[1]) if offset > max_offset: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(offset=offset, max_offset=max_offset, max_mb=max_mb), )) except (ValueError, TypeError): pass # BITOP 操作可能产生大 Bitmap if cmd == "BITOP" and len(args) >= 3: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"BITOP 操作涉及 {len(args) - 2} 个 Bitmap,请确保结果 Bitmap 大小不超过 {max_mb}MB", )) @register_rule(AuditRuleV2( code="REDIS-DS-008", name="Geo最大成员数限制", category=RuleCategory.DDL, severity=Severity.WARNING, db_type="redis", description="Geo 类型成员数建议不超过 10000000", config={"max_geo_members": 10000000}, message_template="Geo 集合成员数 {members} 超过建议阈值 {max},大集合 GEOSEARCH/GEORADIUS 性能会显著下降", )) def check_redis_ds_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Geo 最大成员数限制检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # GEOADD 大量成员 if cmd == "GEOADD" and len(args) > 300: member_count = (len(args) - 1) // 3 max_members = rule.config.get("max_geo_members", 10000000) if member_count > max_members // 100: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"GEOADD 一次性添加 {member_count} 个地理坐标,Geo 集合过大(>{max_members})会显著降低查询性能,建议按区域分集合存储", )) # GEORADIUS/GEOSEARCH 大范围查询 if cmd in ("GEORADIUS", "GEORADIUSBYMEMBER", "GEOSEARCH"): if len(args) >= 4: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"{cmd} 地理范围查询可能返回大量结果,建议使用 COUNT 参数限制返回数量", )) # ═══════════════════════════════════════════════ # 性能规则 (8条) # ═══════════════════════════════════════════════ @register_rule(AuditRuleV2( code="REDIS-PERF-001", name="禁止阻塞命令", category=RuleCategory.PERFORMANCE, severity=Severity.ERROR, db_type="redis", description="禁止使用 BLPOP/BRPOP/BRPOPLPUSH/BLMOVE 等阻塞命令", message_template="禁止执行阻塞命令 '{cmd}',会导致客户端连接长时间阻塞,请使用 LPOP/RPOP/LMOVE 非阻塞版本", )) def check_redis_perf_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止阻塞命令检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() blocking_cmds = {"BLPOP", "BRPOP", "BRPOPLPUSH", "BLMOVE", "BZPOPMIN", "BZPOPMAX"} if cmd in blocking_cmds: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=cmd), )) @register_rule(AuditRuleV2( code="REDIS-PERF-002", name="SORT命令必须带LIMIT", category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="redis", description="SORT 命令必须带 LIMIT 参数,防止返回过多数据", message_template="SORT 命令未带 LIMIT 参数,可能返回大量数据导致阻塞,请添加 LIMIT offset count", )) def check_redis_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """SORT 命令必须带 LIMIT 检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() if cmd == "SORT" and len(args) >= 1: has_limit = any(arg.upper() == "LIMIT" for arg in args) if not has_limit: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-PERF-003", name="Pipeline批量限制", category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="redis", description="Pipeline 批量命令数建议不超过 1000 条", config={"max_pipeline_commands": 1000}, message_template="Pipeline 包含 {count} 条命令,超过建议阈值 {max} 条,请分批执行(建议每批不超过 {max} 条)", )) def check_redis_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Pipeline 批量限制检查。 对多行输入的命令进行计数。 """ commands = _extract_pipeline_commands(sql_clean) max_cmds = rule.config.get("max_pipeline_commands", 1000) if len(commands) > max_cmds: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(count=len(commands), max=max_cmds), )) @register_rule(AuditRuleV2( code="REDIS-PERF-004", name="大Key内存使用检测", category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="redis", description="检测可能产生大 Key 的操作,建议定期执行 MEMORY USAGE 检查", config={"big_key_threshold_bytes": 10485760}, # 10MB message_template="{cmd} 操作 Key '{key}' 可能产生大 Key(>{threshold}MB),建议执行 MEMORY USAGE {key} 检查", )) def check_redis_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """大 Key 内存使用检测。 对可能产生大 Key 的操作进行静态检测和警告。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() threshold = rule.config.get("big_key_threshold_bytes", 10485760) threshold_mb = threshold / 1048576 # 检测大 Value 写入 big_value_cmds = { "SET": (1, "value"), "SETEX": (2, "value"), "PSETEX": (2, "value"), "HSET": (slice(2, None, 2), "hash fields"), "HMSET": (slice(2, None, 2), "hash fields"), "SADD": (slice(1, None), "set members"), "ZADD": (slice(2, None, 2), "zset members"), "LPUSH": (slice(1, None), "list elements"), "RPUSH": (slice(1, None), "list elements"), "XADD": (slice(2, None, 2), "stream fields"), } if cmd in big_value_cmds and len(args) >= 1: key = args[0] index_info, desc = big_value_cmds[cmd] # 检查 value 总大小 total_size = 0 if isinstance(index_info, slice): values = args[index_info] for v in values: total_size += len(v.encode("utf-8")) elif isinstance(index_info, int) and len(args) > index_info: total_size = len(args[index_info].encode("utf-8")) if total_size > threshold: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=cmd, key=key, threshold=threshold_mb), )) # 检测全量读取大 Key 的命令 full_read_cmds = {"HGETALL", "SMEMBERS", "ZRANGE", "ZREVRANGE", "LRANGE", "XREAD", "XREVRANGE"} if cmd in full_read_cmds and len(args) >= 1: key = args[0] report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"{cmd} 全量读取 Key '{key}' 可能返回大量数据,建议先用 MEMORY USAGE {key} 确认大小,或使用 SCAN/HSCAN/SSCAN/ZSCAN 分批读取", )) @register_rule(AuditRuleV2( code="REDIS-PERF-005", name="慢查询检查", category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="redis", description="检测可能导致慢查询的操作模式", config={"slowlog_threshold_ms": 10}, message_template="{cmd} 操作可能导致慢查询(>{threshold}ms),建议避免对大量数据执行复杂操作", )) def check_redis_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """慢查询检查。 检测可能导致慢查询的高复杂度操作。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() threshold = rule.config.get("slowlog_threshold_ms", 10) # 高复杂度命令模式检测 slow_patterns = { "SORT": "SORT 全量排序复杂度 O(N+M*log(M))", "SUNIONSTORE": "SUNIONSTORE 集合操作复杂度 O(N)", "SINTERSTORE": "SINTERSTORE 集合操作复杂度 O(N*M)", "ZUNIONSTORE": "ZUNIONSTORE 排序集合操作复杂度 O(N*K) + O(M*log(M))", "ZINTERSTORE": "ZINTERSTORE 排序集合操作复杂度 O(N*K) + O(M*log(M))", "ZRANGEBYLEX": "ZRANGEBYLEX 范围查询可能遍历大量成员", "ZREMRANGEBYLEX": "ZREMRANGEBYLEX 批量删除复杂度 O(log(N)+M)", "ZRANGEBYSCORE": "ZRANGEBYSCORE 大范围查询可能返回大量数据", } if cmd in slow_patterns: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"{slow_patterns[cmd]},可能导致慢查询(>{threshold}ms),请注意监控慢查询日志(SLOWLOG GET)", )) # KEYS 命令(已被 REDIS-KEY-007 覆盖为 ERROR,这里额外提示性能影响) if cmd == "KEYS": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"KEYS 命令扫描全库复杂度 O(N),在数据量大时会导致长时间阻塞,生产环境绝对禁止", )) # 大量元素的 MGET/MSET if cmd in ("MGET", "MSET") and len(args) > 100: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"{cmd} 操作 {len(args)} 个 Key,批量过大可能阻塞,建议分批执行(每批不超过 100 个 Key)", )) @register_rule(AuditRuleV2( code="REDIS-PERF-006", name="Lua脚本执行时间限制", category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="redis", description="Lua 脚本执行时间建议不超过 5 秒", config={"max_lua_exec_ms": 5000}, message_template="Lua 脚本 '{script_snippet}...' 可能执行时间过长,建议设置 lua-time-limit 不超过 {max_ms}ms,避免阻塞", )) def check_redis_perf_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """Lua 脚本执行时间限制检查。 通过脚本长度和复杂度进行静态估计。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() if cmd not in ("EVAL", "EVALSHA", "FCALL", "FCALL_RO"): return max_ms = rule.config.get("max_lua_exec_ms", 5000) # 如果是 EVAL,检查脚本复杂度 if cmd == "EVAL" and args: script = args[0] # 统计脚本中的 Redis 调用次数 redis_call_count = script.upper().count("REDIS.CALL") + script.upper().count("REDIS.PCALL") if redis_call_count > 50: snippet = script[:80].replace("\n", " ") report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=f"Lua 脚本包含 {redis_call_count} 次 redis.call/redis.pcall 调用,可能执行时间超过 {max_ms}ms,建议拆分脚本", )) return # 脚本过长 if len(script) > 5000: snippet = script[:80].replace("\n", " ") report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=rule.message_template.format(script_snippet=snippet, max_ms=max_ms), )) # FCALL 检查 if cmd in ("FCALL", "FCALL_RO") and args: func_name = args[0] report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"Function '{func_name}' 调用需确保执行时间不超过 {max_ms}ms,建议在函数内部添加超时保护", )) @register_rule(AuditRuleV2( code="REDIS-PERF-007", name="连接数限制警告", category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="redis", description="监控 Redis 连接数,建议不超过 maxclients 的 80%", config={"connection_threshold_ratio": 0.8, "maxclients_default": 10000}, message_template="当前连接数监控:建议连接数不超过 maxclients 的 {ratio}%(当前配置 maxclients={maxclients},警戒值={threshold}),请配置连接池和连接复用", )) def check_redis_perf_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """连接数限制警告。 当检测到 INFO CLIENTS 或 CLIENT LIST 查询时给出连接管理建议。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() if cmd == "INFO" and args: section = args[0].upper() if section in ("CLIENTS", "ALL", "DEFAULT"): ratio = rule.config.get("connection_threshold_ratio", 0.8) maxclients = rule.config.get("maxclients_default", 10000) threshold = int(maxclients * ratio) report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=rule.message_template.format(ratio=int(ratio * 100), maxclients=maxclients, threshold=threshold), )) if cmd in ("CLIENT", "CONFIG") and len(args) >= 1: subcmd = args[0].upper() if subcmd in ("LIST", "GET", "INFO"): ratio = rule.config.get("connection_threshold_ratio", 0.8) maxclients = rule.config.get("maxclients_default", 10000) threshold = int(maxclients * ratio) report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message=f"连接管理:建议监控连接数不超过 maxclients 的 {int(ratio * 100)}%(maxclients={maxclients},警戒值={threshold}),使用连接池复用连接", )) @register_rule(AuditRuleV2( code="REDIS-PERF-008", name="避免内存溢出风险命令", category=RuleCategory.PERFORMANCE, severity=Severity.ERROR, db_type="redis", description="检测可能导致 OOM 的高风险命令", config={"oom_warning_threshold_mb": 100}, message_template="{cmd} 操作存在内存溢出风险,可能导致 Redis OOM,请确保内存充足并配置 maxmemory 和淘汰策略", )) def check_redis_perf_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """避免内存溢出风险命令检测。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # 检测可能导致 OOM 的操作模式 oom_risk_cmds = { "SUNIONSTORE": "SUNIONSTORE 结果集可能远大于输入集", "SINTERSTORE": "SINTERSTORE 大量集合求交", "ZUNIONSTORE": "ZUNIONSTORE 大量集合合并", "PFMERGE": "PFMERGE HyperLogLog 合并", "BITOP": "BITOP 位图操作可能产生大结果", } if cmd in oom_risk_cmds: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=cmd) + f",{oom_risk_cmds[cmd]}", )) # 检测无 MAXLEN 的大 Stream XADD(已被 REDIS-DS-006 覆盖,这里补充 OOM 角度) if cmd == "XADD" and len(args) >= 2: has_maxlen = any(arg.upper() == "MAXLEN" for arg in args) if not has_maxlen: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message="XADD 未限制 MAXLEN,Stream 无限增长可能导致内存溢出,建议设置 MAXLEN 或定期 XTRIM", )) # ═══════════════════════════════════════════════ # 安全规则 (5条) # ═══════════════════════════════════════════════ @register_rule(AuditRuleV2( code="REDIS-SEC-001", name="必须认证才能执行命令", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="非只读命令必须执行 AUTH 认证后才能执行", config={"require_auth": True, "allowed_cmds_without_auth": ["PING", "AUTH", "ECHO", "QUIT", "HELLO"]}, message_template="命令 '{cmd}' 需要认证后才能执行,请先执行 AUTH password 进行认证", )) def check_redis_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """必须认证检查。 提示执行敏感命令前需要 AUTH 认证。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() allowed = rule.config.get("allowed_cmds_without_auth", ["PING", "AUTH", "ECHO", "QUIT", "HELLO"]) if cmd in allowed: return # 只对写入和敏感命令要求认证 sensitive_cmds = { "SET", "DEL", "FLUSHDB", "FLUSHALL", "CONFIG", "ACL", "RENAME", "MIGRATE", "RESTORE", "SHUTDOWN", "DEBUG", "MODULE", "REPLICAOF", "SLAVEOF", "SAVE", "BGSAVE", "BGREWRITEAOF", } if cmd in sensitive_cmds: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=cmd), )) @register_rule(AuditRuleV2( code="REDIS-SEC-002", name="命令中不得包含密码", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="Redis 命令中不得明文包含密码,存在安全风险", config={}, message_template="命令中包含明文密码,存在安全风险,请通过配置文件或环境变量传递密码,禁止在命令行中硬编码", )) def check_redis_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """命令中不得包含密码检查。""" command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() # 检查 AUTH 命令中的密码 if cmd == "AUTH" and len(args) >= 1: password = args[-1] # 最后一个参数是密码 # 检查密码强度(简单密码检测) if password and len(password) < 8: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message="AUTH 密码长度过短(< 8 字符),安全性不足,请使用强密码(>= 16 字符,包含大小写+数字+特殊字符)", )) # 检查命令中是否包含常见密码模式 password_patterns = [ r"password[=:]\S+", r"pwd[=:]\S+", r"passwd[=:]\S+", r"secret[=:]\S+", ] for pattern in password_patterns: if re.search(pattern, sql_clean, re.IGNORECASE): report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) return @register_rule(AuditRuleV2( code="REDIS-SEC-003", name="危险命令必须被重命名", category=RuleCategory.SECURITY, severity=Severity.WARNING, db_type="redis", description="建议将危险命令(FLUSHDB/FLUSHALL/CONFIG/DEBUG)通过 rename-command 重命名", config={"dangerous_commands": ["FLUSHDB", "FLUSHALL", "CONFIG", "DEBUG", "SHUTDOWN"]}, message_template="危险命令 '{cmd}' 应通过 rename-command 在配置中重命名,防止误操作或恶意执行", )) def check_redis_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """危险命令重命名检查。 当检测到危险命令使用时,提示应在配置中重命名。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() dangerous = rule.config.get("dangerous_commands", ["FLUSHDB", "FLUSHALL", "CONFIG", "DEBUG", "SHUTDOWN"]) if cmd in dangerous: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template.format(cmd=cmd), )) @register_rule(AuditRuleV2( code="REDIS-SEC-004", name="禁止绑定0.0.0.0(无密码时)", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="Redis 无密码时不应绑定 0.0.0.0,必须绑定指定内网 IP", message_template="Redis 配置安全风险:未设置密码时不应绑定 0.0.0.0,请绑定指定内网 IP 并设置强密码(requirepass + rename-command)", )) def check_redis_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """禁止绑定 0.0.0.0 检查。 检测 CONFIG SET bind 命令。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() if cmd == "CONFIG" and len(args) >= 3: subcmd = args[0].upper() param = args[1].upper() if subcmd == "SET" and param == "BIND": bind_value = args[2] if bind_value == "0.0.0.0": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) @register_rule(AuditRuleV2( code="REDIS-SEC-005", name="必须开启保护模式", category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="redis", description="Redis 必须开启保护模式(protected-mode yes),防止未授权访问", message_template="Redis 安全:未开启保护模式存在严重安全风险,请在配置中设置 protected-mode yes 并配置 bind 内网 IP", )) def check_redis_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: """保护模式检查。 检测 CONFIG SET protected-mode 命令。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() if cmd == "CONFIG" and len(args) >= 3: subcmd = args[0].upper() param = args[1].upper() if subcmd == "SET" and param == "PROTECTED-MODE": value = args[2].upper() if value == "NO": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template, )) # 如果检测到 AUTH 失败后的操作,也提示保护模式 if cmd == "AUTH": report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, level=Severity.INFO, message="认证操作:请确保 Redis 已开启 protected-mode yes 并配置了强密码", )) # ═══════════════════════════════════════════════ # 规则统计与导出 # ═══════════════════════════════════════════════ def get_redis_rule_stats() -> Dict[str, Any]: """获取 Redis 审核规则的统计信息。 Returns: 包含规则总数和各分类数量的字典 """ # 导入注册表获取已注册规则 from app.services.audit_engine_v2 import _RULE_REGISTRY, AuditRuleV2 redis_rules = [ (code, rule, func) for code, (rule, func) in _RULE_REGISTRY.items() if code.startswith("REDIS-") ] categories: Dict[str, int] = {} severities: Dict[str, int] = {} for code, rule, _ in redis_rules: cat = rule.category.value if hasattr(rule.category, "value") else str(rule.category) categories[cat] = categories.get(cat, 0) + 1 sev = rule.severity.value if hasattr(rule.severity, "value") else str(rule.severity) severities[sev] = severities.get(sev, 0) + 1 return { "total": len(redis_rules), "by_category": categories, "by_severity": severities, "rules": sorted([code for code, _, _ in redis_rules]), } def print_redis_rule_summary() -> None: """打印 Redis 审核规则汇总信息。""" stats = get_redis_rule_stats() print("=" * 60) print("Redis 审核规则引擎 - 规则汇总") print("=" * 60) print(f"规则总数: {stats['total']} 条") print() print("按分类统计:") for cat, count in sorted(stats["by_category"].items()): print(f" {cat}: {count} 条") print() print("按严重级别统计:") for sev, count in sorted(stats["by_severity"].items()): print(f" {sev}: {count} 条") print() print("规则编码列表:") for code in stats["rules"]: print(f" {code}") print("=" * 60) # 模块加载时自动打印规则摘要(仅在直接运行时) if __name__ == "__main__": print_redis_rule_summary()