Files
opsxsql/backend/app/services/postgres_audit_rules.py
T
logan 58eb78c987 merge: SQL平台代码v1 优化包合并 — 193条审核规则 + 16页面UI优化
后端新增 (Step 1):
- mysql_audit_rules.py: 63条 MySQL审核规则 (DDL 36 + DML 9 + 命名 5 + 性能 5 + 安全 5)
- redis_audit_rules.py: 43条 Redis审核规则 (KEY管理 10 + 命令安全 12 + 数据结构 8 + 性能 8 + 安全 5)
- mongodb_audit_rules.py: 42条 MongoDB审核规则 (集合管理 + 查询安全 + 聚合管道 + 写入安全 + 性能)
- postgres_audit_rules.py: 45条 PostgreSQL审核规则 (DDL 18 + DML 8 + DQL 6 + 安全 6 + 性能 7)

后端合并 (Step 2):
- audit_engine_v2.py: 新增4个DB规则模块import (100+→200+规则)
- audit_rule_seeds.py: 新增30条MongoDB种子规则 + 别名映射
- audit_rule.py: 新增数据库类型别名 (postgresql→postgres, pg→postgres, mongo→mongodb)

前端优化 (Step 4) — 16页面全覆盖:
- InstancePage, WorkflowPage, TicketsPage, TicketCreatePage, TicketDetailPage
- UsersPage, SlowQueryPage, DataDictionaryPage, Dashboard
- InstanceGroupPage, AuditLogsPage, QueriesPage, AuditRulesPage, RolesPage
- useAuth.tsx, SqlEditor.tsx

后端测试 895/895 通过, 前端 tsc --noEmit 通过
2026-05-23 16:46:13 +08:00

1290 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""PostgreSQL 特有审核规则引擎。
包含 45+ 条 PostgreSQL 特有审核规则,覆盖以下维度:
- DDL (18条): CREATE TABLE / ALTER TABLE 特有语法规范
- DML (8条): INSERT/UPDATE/DELETE 操作规范
- DQL (6条): SELECT 查询规范
- SECURITY (6条): PostgreSQL 安全风险检测
- PERFORMANCE (7条): PostgreSQL 性能优化建议
规则编码规范:
- DDL: PG-DDL-001 ~ PG-DDL-018
- DML: PG-DML-001 ~ PG-DML-008
- DQL: PG-DQL-001 ~ PG-DQL-006
- SEC: PG-SEC-001 ~ PG-SEC-006
- PERF: PG-PERF-001 ~ PG-PERF-007
依赖:
from app.services.audit_engine_v2 import (
AuditRuleV2, AuditFindingV2, AuditReportV2,
Severity, RuleCategory, register_rule,
_extract_table_name, _extract_column_definitions,
_extract_index_definitions,
)
"""
from __future__ import annotations
import re
from typing import Any, Callable, Dict, List, Optional, Tuple
from app.services.audit_engine_v2 import (
AuditRuleV2,
AuditFindingV2,
AuditReportV2,
Severity,
RuleCategory,
register_rule,
_extract_table_name,
_extract_column_definitions,
_extract_index_definitions,
)
# ═══════════════════════════════════════════════
# PostgreSQL 规则专用辅助函数
# ═══════════════════════════════════════════════
def _extract_pg_table_name(sql_upper: str, sql_clean: str) -> Optional[str]:
"""从 PostgreSQL CREATE/ALTER TABLE 语句中提取表名(支持 schema.table 形式)。"""
m = re.search(
r"\bCREATE\s+(?:TEMPORARY\s+|TEMP\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:\"?\w+\"?\.)?(\"?\w+\"?)",
sql_clean, re.IGNORECASE,
)
if m:
return m.group(1).strip('"')
m = re.search(
r"\bALTER\s+TABLE\s+(?:ONLY\s+)?(?:\"?\w+\"?\.)?(\"?\w+\"?)",
sql_clean, re.IGNORECASE,
)
if m:
return m.group(1).strip('"')
return None
def _extract_pg_column_defs(sql_upper: str) -> List[Dict[str, str]]:
"""从 PostgreSQL CREATE TABLE (...) 中提取列定义。"""
paren_match = re.search(r"\((.*)\)\s*(?:INHERITS|TABLESPACE|PARTITION|WITH|;|$)", sql_upper, re.DOTALL)
if not paren_match:
return []
inner = paren_match.group(1)
parts = []
depth = 0
current = ""
for char in inner:
if char == "(":
depth += 1
current += char
elif char == ")":
depth -= 1
current += char
elif char == "," and depth == 0:
parts.append(current.strip())
current = ""
else:
current += char
if current.strip():
parts.append(current.strip())
columns: List[Dict[str, str]] = []
for part in parts:
if re.match(r"^(CONSTRAINT|PRIMARY\s+KEY|UNIQUE|CHECK|EXCLUDE|FOREIGN\s+KEY|REFERENCES|LIKE)\b", part, re.IGNORECASE):
continue
col_match = re.match(r'"?(\w+)"?\s+(\w+(?:\s*\[\s*\])?|\w+\s+\w+)', part, re.IGNORECASE)
if col_match:
columns.append({"name": col_match.group(1), "type": col_match.group(2).upper(), "full": part})
return columns
def _has_pg_serial_type(col_type: str) -> bool:
"""判断是否为 SERIAL 类型。"""
return any(st in col_type for st in ("SERIAL", "BIGSERIAL", "SMALLSERIAL"))
# ═══════════════════════════════════════════════
# DDL 规则 (PG-DDL-001 ~ PG-DDL-018, 共18条)
# ═══════════════════════════════════════════════
# ── PG-DDL-001 ──
@register_rule(AuditRuleV2(
code="PG-DDL-001", name="PGDDLEnablePrimaryKey",
category=RuleCategory.DDL, severity=Severity.ERROR, db_type="postgres",
description="CREATE TABLE 语句必须包含 PRIMARY KEY 定义",
config={"allow_no_pk": False},
message_template="建表语句缺少主键定义,请添加 PRIMARY KEY(推荐使用 GENERATED ALWAYS AS IDENTITY)",
))
def check_pg_ddl_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""强制必须拥有主键。"""
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
return
if "PRIMARY KEY" not in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DDL-002 ──
@register_rule(AuditRuleV2(
code="PG-DDL-002", name="PGDDLCheckTableComment",
category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres",
description="建表语句必须包含 COMMENT ON 表注释",
config={"require_column_comment": True},
message_template="表 '{table}' 缺少注释,请添加 COMMENT ON TABLE {table} IS '...'",
))
def check_pg_ddl_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""强制表/列必须拥有注释(COMMENT ON)。"""
table_name = _extract_pg_table_name(sql_upper, sql_clean)
if not table_name:
return
has_comment = re.search(r"\bCOMMENT\s+ON\s+(?:TABLE|COLUMN)\b", sql_upper, re.IGNORECASE)
if not has_comment:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(table=table_name),
))
# ── PG-DDL-003 ──
@register_rule(AuditRuleV2(
code="PG-DDL-003", name="PGDDLCheckColumnNullable",
category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres",
description="非timestamp类型字段必须声明 NOT NULL",
config={"nullable_exceptions": ["TIMESTAMP", "TIMESTAMPTZ", "DATE", "TIME", "JSON", "JSONB", "TEXT"]},
message_template="字段 '{column}' 类型为 {col_type},未声明 NOT NULL,建议添加约束",
))
def check_pg_ddl_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""强制非timestamp类型字段NOT NULL。"""
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
return
columns = _extract_pg_column_defs(sql_upper)
exceptions = rule.config.get("nullable_exceptions", [])
for col in columns:
col_type = col["type"]
if any(exc in col_type for exc in exceptions):
continue
if "NOT NULL" not in col["full"] and "NULL" not in col["full"]:
if "DEFAULT" in col["full"]:
continue
if _has_pg_serial_type(col_type):
continue
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(column=col["name"], col_type=col_type),
))
# ── PG-DDL-004 ──
@register_rule(AuditRuleV2(
code="PG-DDL-004", name="PGDDLCheckColumnDefault",
category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres",
description="字段应当拥有默认值(除主键和 GENERATED 列外)",
config={"require_default": True},
message_template="字段 '{column}' 缺少 DEFAULT 值,建议添加",
))
def check_pg_ddl_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""字段必须拥有默认值。"""
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
return
for col in _extract_pg_column_defs(sql_upper):
if "PRIMARY KEY" in col["full"] or "GENERATED" in col["full"] or _has_pg_serial_type(col["type"]):
continue
if "DEFAULT" not in col["full"]:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(column=col["name"]),
))
# ── PG-DDL-005 ──
@register_rule(AuditRuleV2(
code="PG-DDL-005", name="PGDDLCheckFloatDouble",
category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres",
description="浮点类型精度丢失,建议使用 NUMERIC/DECIMAL",
message_template="字段 '{column}' 使用了 {col_type} 类型,建议使用 NUMERIC(p,s) 以确保精度",
))
def check_pg_ddl_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""NUMERIC vs FLOAT 精度建议。"""
if not re.search(r"\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
return
float_types = ("FLOAT", "REAL", "DOUBLE", "DOUBLE PRECISION")
for col in _extract_pg_column_defs(sql_upper):
for ft in float_types:
if ft in col["type"]:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(column=col["name"], col_type=col["type"]),
))
break
# ── PG-DDL-006 ──
@register_rule(AuditRuleV2(
code="PG-DDL-006", name="PGDDLEnableSerial",
category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres",
description="推荐使用 GENERATED ALWAYS AS IDENTITY 替代 SERIAL",
config={"recommend_identity": True, "generated_type": "ALWAYS"},
message_template="建议使用 GENERATED {generated_type} AS IDENTITY 替代 SERIAL/BIGSERIAL,更符合 SQL 标准",
))
def check_pg_ddl_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""推荐自增列使用 GENERATED ALWAYS AS IDENTITY。"""
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
return
for col in _extract_pg_column_defs(sql_upper):
if _has_pg_serial_type(col["type"]):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(generated_type=rule.config.get("generated_type", "ALWAYS")),
))
break
# ── PG-DDL-007 ──
@register_rule(AuditRuleV2(
code="PG-DDL-007", name="PGDDLPrimaryKeyMust",
category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres",
description="主键字段名应为 id(支持配置)",
config={"pk_name": "id"},
message_template="主键名 '{pk_name}' 建议改为 '{suggested}'",
))
def check_pg_ddl_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""主键名称为 id。"""
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
return
suggested = rule.config.get("pk_name", "id")
pk_match = re.search(r"PRIMARY\s+KEY\s*\(([^)]+)\)", sql_upper, re.IGNORECASE)
if pk_match:
for col in [c.strip().strip('"') for c in pk_match.group(1).split(",")]:
if col.lower() != suggested.lower():
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(pk_name=col, suggested=suggested),
))
for col in _extract_pg_column_defs(sql_upper):
if "PRIMARY KEY" in col["full"] and col["name"].lower() != suggested.lower():
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(pk_name=col["name"], suggested=suggested),
))
# ── PG-DDL-008 ──
@register_rule(AuditRuleV2(
code="PG-DDL-008", name="PGDDLEnableDropTable",
category=RuleCategory.DDL, severity=Severity.ERROR, db_type="postgres",
description="生产环境默认禁止执行 DROP TABLE 操作",
config={"env_production_only": True, "allow_drop": False},
message_template="禁止执行 DROP TABLE 操作,数据将永久丢失。如需删除请先备份",
))
def check_pg_ddl_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许删除表(生产环境默认禁止)。"""
if not re.search(r"\bDROP\s+TABLE\b", sql_upper, re.IGNORECASE):
return
if not rule.config.get("allow_drop", False):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DDL-009 ──
@register_rule(AuditRuleV2(
code="PG-DDL-009", name="PGDDLEnableDropDatabase",
category=RuleCategory.DDL, severity=Severity.ERROR, db_type="postgres",
description="禁止执行 DROP DATABASE 操作",
config={"allow_drop_db": False},
message_template="禁止执行 DROP DATABASE 操作,将导致整个数据库被删除",
))
def check_pg_ddl_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许删除库(默认禁止)。"""
if not re.search(r"\bDROP\s+DATABASE\b", sql_upper, re.IGNORECASE):
return
if not rule.config.get("allow_drop_db", False):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DDL-010 ──
@register_rule(AuditRuleV2(
code="PG-DDL-010", name="PGDDLAllowPRINotInt",
category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres",
description="检查主键类型是否为 integer/bigint(允许 UUID 等)",
config={"allowed_pk_types": ["INT", "BIGINT", "INTEGER", "SERIAL", "BIGSERIAL", "UUID"]},
message_template="主键类型 '{pk_type}' 不在推荐列表中,建议使用 BIGINT/SERIAL/UUID",
))
def check_pg_ddl_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许主键类型为非integer/bigint(给出建议)。"""
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
return
allowed = rule.config.get("allowed_pk_types", [])
for col in _extract_pg_column_defs(sql_upper):
if "PRIMARY KEY" in col["full"]:
if not any(a.upper() in col["type"] for a in allowed):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(pk_type=col["type"]),
))
# ── PG-DDL-011 ──
@register_rule(AuditRuleV2(
code="PG-DDL-011", name="PGDDLMultiToCommit",
category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres",
description="单工单包含多条 DDL 语句",
config={"max_ddl_per_ticket": 5, "allow_multi": True},
message_template="检测到单工单包含 {count} 条 DDL 语句,建议不超过 {max} 条",
))
def check_pg_ddl_011(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许单工单多条DDL(但建议不超过限制)。"""
ddl_patterns = [
r"\bCREATE\s+(?:TABLE|INDEX|SEQUENCE|VIEW|MATERIALIZED\s+VIEW|TRIGGER|FUNCTION)\b",
r"\bALTER\s+TABLE\b",
r"\bDROP\s+(?:TABLE|INDEX|SEQUENCE|VIEW)\b",
]
count = sum(len(re.findall(pat, sql_upper, re.IGNORECASE)) for pat in ddl_patterns)
max_ddl = rule.config.get("max_ddl_per_ticket", 5)
if count > max_ddl:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(count=count, max=max_ddl),
))
# ── PG-DDL-012 ──
@register_rule(AuditRuleV2(
code="PG-DDL-012", name="PGDDLAllowColumnType",
category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres",
description="ALTER TABLE ALTER TYPE 必须带 USING 子句",
message_template="字段类型转换 ALTER TYPE 缺少 USING 子句,可能导致数据丢失",
))
def check_pg_ddl_012(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许字段类型转换(USING子句)。"""
if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
return
if re.search(r"\bALTER\s+(?:COLUMN\s+)?\w+\s+TYPE\b", sql_upper, re.IGNORECASE):
if "USING" not in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DDL-013 ──
@register_rule(AuditRuleV2(
code="PG-DDL-013", name="PGAllowCreateView",
category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres",
description="检查 MATERIALIZED VIEW 的刷新策略",
config={"allow_materialized_view": True, "refresh_strategy": "CONCURRENTLY"},
message_template="创建 MATERIALIZED VIEW 时建议使用 REFRESH MATERIALIZED VIEW CONCURRENTLY",
))
def check_pg_ddl_013(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许创建视图(给出 MATERIALIZED VIEW 建议)。"""
if not re.search(r"\bCREATE\s+(?:MATERIALIZED\s+)?VIEW\b", sql_upper, re.IGNORECASE):
return
if "MATERIALIZED" in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DDL-014 ──
@register_rule(AuditRuleV2(
code="PG-DDL-014", name="PGDDLEnableForeignKey",
category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres",
description="外键约束会导致共享行锁,影响并发性能",
config={"allow_foreign_key": True, "warn_about_lock": True},
message_template="外键约束(REFERENCES)在 PG 中可能导致共享行锁,影响并发写入性能,建议应用层控制",
))
def check_pg_ddl_014(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许外键(但警告PG外键有共享锁问题)。"""
if not re.search(r"\bCREATE\s+TABLE\b|\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
return
if re.search(r"\bREFERENCES\s+\w+", sql_upper, re.IGNORECASE) or "FOREIGN KEY" in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DDL-015 ──
@register_rule(AuditRuleV2(
code="PG-DDL-015", name="PGAllowCreatePartition",
category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres",
description="检查分区表创建规范(Declarative Partitioning)",
config={"supported_partition_types": ["RANGE", "LIST", "HASH"]},
message_template="分区表建议使用 PARTITION BY {supported_types},并确保有分区键索引",
))
def check_pg_ddl_015(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许创建分区表(Declarative Partitioning)。"""
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
return
if "PARTITION BY" in sql_upper:
supported = rule.config.get("supported_partition_types", ["RANGE", "LIST", "HASH"])
if not any(pt in sql_upper for pt in supported):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(supported_types="/".join(supported)),
))
# ── PG-DDL-016 ──
@register_rule(AuditRuleV2(
code="PG-DDL-016", name="PGDDLTablePrefix",
category=RuleCategory.DDL, severity=Severity.INFO, db_type="postgres",
description="表名前缀规范检查",
config={"required_prefixes": []},
message_template="表名 '{table}' 不符合前缀规范,建议前缀: {prefixes}",
))
def check_pg_ddl_016(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""表名前缀规范。"""
required = rule.config.get("required_prefixes", [])
if not required:
return
table_name = _extract_pg_table_name(sql_upper, sql_clean)
if not table_name or not any(table_name.startswith(p) for p in required):
if table_name:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(table=table_name, prefixes=", ".join(required)),
))
# ── PG-DDL-017 ──
@register_rule(AuditRuleV2(
code="PG-DDL-017", name="PGSupportEncoding",
category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres",
description="数据库/表应使用 UTF8 编码",
config={"allowed_encodings": ["UTF8"]},
message_template="编码设置 '{encoding}' 不在推荐列表中,建议使用 UTF8",
))
def check_pg_ddl_017(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许的编码(UTF8推荐)。"""
enc_match = re.search(r"\bENCODING\s*[=:]\s*['\"](\w+)['\"]|\bENCODING\s+(\w+)", sql_upper, re.IGNORECASE)
if enc_match:
encoding = enc_match.group(1) or enc_match.group(2)
allowed = rule.config.get("allowed_encodings", ["UTF8"])
if encoding and encoding.upper() not in [a.upper() for a in allowed]:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(encoding=encoding),
))
# ── PG-DDL-018 ──
@register_rule(AuditRuleV2(
code="PG-DDL-018", name="PGMaxTableNameLen",
category=RuleCategory.DDL, severity=Severity.WARNING, db_type="postgres",
description="PostgreSQL 表名最大长度限制为 63 字节(NAMEDATALEN - 1)",
config={"max_name_bytes": 63},
message_template="表名 '{table}' 长度为 {length} 字节,超过 PG 限制 {max} 字节,超长部分将被截断",
))
def check_pg_ddl_018(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""表名最大长度(PG限制63字节)。"""
table_name = _extract_pg_table_name(sql_upper, sql_clean)
if not table_name:
return
name_bytes = len(table_name.encode("utf-8"))
max_bytes = rule.config.get("max_name_bytes", 63)
if name_bytes > max_bytes:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(table=table_name, length=name_bytes, max=max_bytes),
))
# ═══════════════════════════════════════════════
# DML 规则 (PG-DML-001 ~ PG-DML-008, 共8条)
# ═══════════════════════════════════════════════
# ── PG-DML-001 ──
@register_rule(AuditRuleV2(
code="PG-DML-001", name="PGDMLMaxInsertRows",
category=RuleCategory.DML, severity=Severity.WARNING, db_type="postgres",
description="INSERT 语句最大行数限制",
config={"max_insert_rows": 1000},
message_template="单次 INSERT 包含 {count} 行,超过最大限制 {max},请分批插入",
))
def check_pg_dml_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""Insert最大行数上限。"""
if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE):
return
max_rows = rule.config.get("max_insert_rows", 1000)
values_match = re.search(r"\bVALUES\s+(.+)", sql_upper, re.IGNORECASE | re.DOTALL)
if values_match:
values_part = values_match.group(1)
row_count = 0
depth = 0
for char in values_part:
if char == "(":
if depth == 0:
row_count += 1
depth += 1
elif char == ")":
depth -= 1
if row_count > max_rows:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(count=row_count, max=max_rows),
))
# ── PG-DML-002 ──
@register_rule(AuditRuleV2(
code="PG-DML-002", name="PGDMLAllowLimitSTMT",
category=RuleCategory.DML, severity=Severity.INFO, db_type="postgres",
description="PostgreSQL 原生支持 UPDATE/DELETE 带 LIMIT,但需谨慎使用",
config={"allow_limit": True, "warn_when_used": True},
message_template="UPDATE/DELETE 使用了 LIMIT,可能导致部分数据未处理,建议配合 ORDER BY 使用",
))
def check_pg_dml_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许UPDATE/DELETE带LIMIT(PG原生支持,但给出警告)。"""
for keyword in ("UPDATE", "DELETE"):
if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE):
if re.search(r"\bLIMIT\s+\d+", sql_upper, re.IGNORECASE):
if rule.config.get("warn_when_used", True):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
break
# ── PG-DML-003 ──
@register_rule(AuditRuleV2(
code="PG-DML-003", name="PGDMLAllowInsertNull",
category=RuleCategory.DML, severity=Severity.INFO, db_type="postgres",
description="允许插入 NULL,但建议显式使用 DEFAULT",
config={"allow_null": True, "prefer_default": True},
message_template="INSERT 包含 NULL 值,建议改用 DEFAULT 关键字以保持语义清晰",
))
def check_pg_dml_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""允许插入Null(但建议使用DEFAULT)。"""
if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE):
return
if re.search(r"\bVALUES\b.*\bNULL\b", sql_upper, re.IGNORECASE):
if rule.config.get("prefer_default", True):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DML-004 ──
@register_rule(AuditRuleV2(
code="PG-DML-004", name="PGDMLWhere",
category=RuleCategory.DML, severity=Severity.ERROR, db_type="postgres",
description="UPDATE/DELETE 必须包含 WHERE 条件",
message_template="{operation} 语句缺少 WHERE 条件,将操作全表数据,极其危险",
))
def check_pg_dml_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""强制DML必须拥有WHERE。"""
for keyword in ("UPDATE", "DELETE"):
if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE):
if "WHERE" not in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(operation=keyword),
))
# ── PG-DML-005 ──
@register_rule(AuditRuleV2(
code="PG-DML-005", name="PGDMLReturning",
category=RuleCategory.DML, severity=Severity.INFO, db_type="postgres",
description="推荐 DML 使用 RETURNING 子句获取操作结果",
config={"recommend_returning": True},
message_template="DML 语句建议使用 RETURNING 子句获取操作后的值(如 RETURNING id)",
))
def check_pg_dml_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""推荐DML使用RETURNING子句。"""
for keyword in ("INSERT", "UPDATE", "DELETE"):
if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE):
if "RETURNING" not in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
break
# ── PG-DML-006 ──
@register_rule(AuditRuleV2(
code="PG-DML-006", name="PGDMLCTE",
category=RuleCategory.DML, severity=Severity.INFO, db_type="postgres",
description="CTE (Common Table Expression) 使用规范",
config={"max_cte_depth": 3, "warn_recursive": True},
message_template="CTE 使用规范: {cte_msg}",
))
def check_pg_dml_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""CTE使用规范。"""
if not re.search(r"\bWITH\s+\w+\s+AS\s*\(", sql_upper, re.IGNORECASE):
return
if rule.config.get("warn_recursive", True) and re.search(r"\bWITH\s+RECURSIVE\b", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING,
message="递归 CTE (WITH RECURSIVE) 可能导致无限循环,请确保有终止条件",
))
cte_count = len(re.findall(r"\bWITH\b", sql_upper, re.IGNORECASE))
max_depth = rule.config.get("max_cte_depth", 3)
if cte_count > max_depth:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(cte_msg=f"检测到 {cte_count} 层嵌套 CTE,建议不超过 {max_depth} 层"),
))
# ── PG-DML-007 ──
@register_rule(AuditRuleV2(
code="PG-DML-007", name="PGMaxAffectRows",
category=RuleCategory.DML, severity=Severity.WARNING, db_type="postgres",
description="DML 最大影响行数(通过 LIMIT 或 WHERE 范围控制)",
config={"max_affected_rows": 10000},
message_template="UPDATE/DELETE 缺少行数限制,可能影响超过 {max} 行数据,建议添加 LIMIT 或更精确的 WHERE",
))
def check_pg_dml_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""DML最大影响行数。"""
max_rows = rule.config.get("max_affected_rows", 10000)
for keyword in ("UPDATE", "DELETE"):
if re.match(rf"\s*{keyword}\b", sql_upper, re.IGNORECASE):
has_limit = re.search(r"\bLIMIT\s+\d+", sql_upper, re.IGNORECASE) is not None
has_pk = re.search(r"\bid\s*[=<>]|\b\w+_id\s*[=<>]", sql_upper, re.IGNORECASE) is not None
if not has_limit and not has_pk and "WHERE" in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(max=max_rows),
))
break
# ── PG-DML-008 ──
@register_rule(AuditRuleV2(
code="PG-DML-008", name="PGDDLImplicitTypeConversion",
category=RuleCategory.DML, severity=Severity.WARNING, db_type="postgres",
description="禁止 WHERE 条件中的隐式类型转换,会导致索引失效",
message_template="WHERE 条件中字段 '{column}' 与值之间存在隐式类型转换风险,请使用显式转换 ::TYPE",
))
def check_pg_dml_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""不允许隐式类型转换。"""
if "WHERE" not in sql_upper:
return
where_match = re.search(r"\bWHERE\b(.+?)(?:GROUP|HAVING|ORDER|LIMIT|RETURNING|;|$)", sql_upper, re.IGNORECASE | re.DOTALL)
if not where_match:
return
where_clause = where_match.group(1)
patterns = [
r"(\w+)\s*[=<>]\s*'\d+'",
r"(\w+)\s*=\s*NULL",
]
for pat in patterns:
matches = re.findall(pat, where_clause, re.IGNORECASE)
if matches:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(column=matches[0]),
))
break
# ═══════════════════════════════════════════════
# DQL 规则 (PG-DQL-001 ~ PG-DQL-006, 共6条)
# ═══════════════════════════════════════════════
# ── PG-DQL-001 ──
@register_rule(AuditRuleV2(
code="PG-DQL-001", name="PGDQLSelectStar",
category=RuleCategory.DQL, severity=Severity.WARNING, db_type="postgres",
description="SELECT * 返回不必要的列,影响网络传输和内存",
message_template="禁止使用 SELECT *,请明确指定需要的字段(COUNT(*) 除外)",
))
def check_pg_dql_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""SELECT * 检测。"""
if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE):
return
if re.search(r"\bSELECT\s+\*\s+FROM\b", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DQL-002 ──
@register_rule(AuditRuleV2(
code="PG-DQL-002", name="PGDQLLimitOffset",
category=RuleCategory.DQL, severity=Severity.INFO, db_type="postgres",
description="大表分页建议使用游标分页替代 LIMIT/OFFSET",
config={"max_offset_rows": 100000},
message_template="使用 LIMIT/OFFSET 在大表上性能较差(OFFSET 越大越慢),建议改用游标分页 (WHERE id > ? LIMIT N) 或 keyset pagination",
))
def check_pg_dql_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""推荐LIMIT+OFFSET分页优化。"""
if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE):
return
if re.search(r"\bOFFSET\s+\d+\b", sql_upper, re.IGNORECASE):
offset_match = re.search(r"\bOFFSET\s+(\d+)", sql_upper, re.IGNORECASE)
if offset_match:
offset_val = int(offset_match.group(1))
max_offset = rule.config.get("max_offset_rows", 100000)
if offset_val > max_offset:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING,
message=f"OFFSET {offset_val} 过大,超过建议值 {max_offset},强烈建议改用游标分页",
))
else:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DQL-003 ──
@register_rule(AuditRuleV2(
code="PG-DQL-003", name="PGDQLWindowFunction",
category=RuleCategory.DQL, severity=Severity.INFO, db_type="postgres",
description="Window 函数使用检查,确保有合适的索引支持",
config={"warn_no_partition": True},
message_template="Window 函数 ({functions}) 使用 detected,建议确保 PARTITION BY / ORDER BY 列有索引支持",
))
def check_pg_dql_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""Window函数使用检查。"""
window_funcs = [
"ROW_NUMBER", "RANK", "DENSE_RANK", "NTILE",
"LAG", "LEAD", "FIRST_VALUE", "LAST_VALUE",
"NTH_VALUE", "PERCENT_RANK", "CUME_DIST",
]
found_funcs = [f for f in window_funcs if re.search(rf"\b{f}\s*\(", sql_upper, re.IGNORECASE)]
if found_funcs:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(functions=", ".join(found_funcs)),
))
# ── PG-DQL-004 ──
@register_rule(AuditRuleV2(
code="PG-DQL-004", name="PGDQLRegex",
category=RuleCategory.DQL, severity=Severity.WARNING, db_type="postgres",
description="~ 操作符(正则表达式匹配)性能警告,无法使用索引",
config={"prefer_prefix_regex": True},
message_template="正则表达式匹配 (~ / ~*) 无法使用 B-Tree 索引,性能较差。建议: 1) 使用前缀匹配 ^ 配合 text_pattern_ops 索引; 2) 改用 pg_trgm 的 gin/gist 索引",
))
def check_pg_dql_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""~操作符性能警告。"""
if re.search(r"\w+\s*~\*?\s*['\"]", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DQL-005 ──
@register_rule(AuditRuleV2(
code="PG-DQL-005", name="PGDQLConcurrentIndex",
category=RuleCategory.DQL, severity=Severity.INFO, db_type="postgres",
description="生产环境创建索引应使用 CREATE INDEX CONCURRENTLY 避免锁表",
config={"require_concurrently": True},
message_template="生产环境建议 CREATE INDEX CONCURRENTLY 代替 CREATE INDEX,避免阻塞写入",
))
def check_pg_dql_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""推荐CREATE INDEX CONCURRENTLY。"""
if not re.search(r"\bCREATE\s+INDEX\b", sql_upper, re.IGNORECASE):
return
if "CONCURRENTLY" not in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ── PG-DQL-006 ──
@register_rule(AuditRuleV2(
code="PG-DQL-006", name="PGDQLAnalyzeVacuum",
category=RuleCategory.DQL, severity=Severity.INFO, db_type="postgres",
description="大批量数据变更后应执行 ANALYZE 更新统计信息",
config={"recommend_analyze": True},
message_template="大批量数据操作后建议执行 ANALYZE table_name 更新统计信息,帮助优化器生成正确执行计划",
))
def check_pg_dql_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""检查ANALYZE更新统计信息。"""
is_bulk = False
if re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE):
values_match = re.search(r"\bVALUES\s+(.+)", sql_upper, re.IGNORECASE | re.DOTALL)
if values_match:
values_part = values_match.group(1)
row_count = 0
depth = 0
for char in values_part:
if char == "(":
if depth == 0:
row_count += 1
depth += 1
elif char == ")":
depth -= 1
if row_count > 100:
is_bulk = True
if re.search(r"\bUPDATE\s+\w+\s+SET\b", sql_upper, re.IGNORECASE) and "WHERE" in sql_upper:
if not re.search(r"\bid\s*[=<>]", sql_upper, re.IGNORECASE) and "LIMIT" not in sql_upper:
is_bulk = True
if is_bulk:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity, message=rule.message_template,
))
# ═══════════════════════════════════════════════
# 安全规则 (PG-SEC-001 ~ PG-SEC-006, 共6条)
# ═══════════════════════════════════════════════
# ── PG-SEC-001 ──
@register_rule(AuditRuleV2(
code="PG-SEC-001", name="PGSecuritySuperuser",
category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="postgres",
description="禁止使用超级用户(superuser)执行日常操作",
config={"forbidden_roles": ["postgres", "rds_superuser"]},
message_template="检测到超级用户操作 ({role}),禁止使用超级用户执行日常 SQL,应使用最小权限原则",
))
def check_pg_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止超级用户操作。"""
forbidden = rule.config.get("forbidden_roles", ["postgres", "rds_superuser"])
superuser_patterns = [
r"\bALTER\s+(?:ROLE|USER)\s+(\w+)\s+.*\bSUPERUSER\b",
r"\bCREATE\s+(?:ROLE|USER)\s+(\w+).*\bSUPERUSER\b",
r"\bSET\s+ROLE\s+(\w+)",
]
for pat in superuser_patterns:
match = re.search(pat, sql_upper, re.IGNORECASE)
if match:
role = match.group(1)
if role in forbidden or "SUPERUSER" in sql_upper.upper():
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(role=role),
))
break
# ── PG-SEC-002 ──
@register_rule(AuditRuleV2(
code="PG-SEC-002", name="PGSecurityFileAccess",
category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="postgres",
description="禁止调用 pg_read_file, pg_read_binary_file 等文件访问函数",
config={"forbidden_functions": [
"pg_read_file", "pg_read_binary_file", "pg_ls_dir",
"pg_stat_file", "pg_read_file_all",
]},
message_template="禁止调用危险函数 '{func}',该函数可访问服务器文件系统",
))
def check_pg_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止pg_read_file/pg_load。"""
forbidden = rule.config.get("forbidden_functions", [])
for func in forbidden:
if re.search(rf"\b{func}\s*\(", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(func=func),
))
break
# ── PG-SEC-003 ──
@register_rule(AuditRuleV2(
code="PG-SEC-003", name="PGSecurityCopyFile",
category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="postgres",
description="COPY FROM/TO 可读写服务器文件系统,存在安全风险",
config={"allow_copy": False, "allowed_paths": []},
message_template="COPY {direction} 可访问文件系统,存在安全风险。如需使用请确保文件路径受控",
))
def check_pg_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""COPY FROM/TO文件系统风险。"""
if rule.config.get("allow_copy", False):
return
copy_from = re.search(r"\bCOPY\b.*\bFROM\b", sql_upper, re.IGNORECASE)
copy_to = re.search(r"\bCOPY\b.*\bTO\b", sql_upper, re.IGNORECASE)
if copy_from:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(direction="FROM"),
))
if copy_to:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(direction="TO"),
))
# ── PG-SEC-004 ──
@register_rule(AuditRuleV2(
code="PG-SEC-004", name="PGSecurityExtension",
category=RuleCategory.SECURITY, severity=Severity.WARNING, db_type="postgres",
description="危险扩展检查(dblink, plpython 等可执行外部代码)",
config={
"dangerous_extensions": [
"dblink", "plpython3u", "plpython2u", "plperlu", "pltclu",
"file_fdw", "postgres_fdw", "pg_file_settings",
"adminpack", "auto_explain",
],
"allowed_extensions": ["pgcrypto", "uuid-ossp", "pg_trgm"],
},
message_template="扩展 '{ext}' 存在安全风险,请评估必要性: 可访问外部资源或执行外部代码",
))
def check_pg_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""危险扩展检查。"""
if not re.search(r"\bCREATE\s+EXTENSION\b", sql_upper, re.IGNORECASE):
return
dangerous = rule.config.get("dangerous_extensions", [])
allowed = rule.config.get("allowed_extensions", [])
for ext in dangerous:
if ext in allowed:
continue
if re.search(rf"\bCREATE\s+EXTENSION\s+(?:IF\s+NOT\s+EXISTS\s+)?{re.escape(ext)}\b", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(ext=ext),
))
# ── PG-SEC-005 ──
@register_rule(AuditRuleV2(
code="PG-SEC-005", name="PGSecurityPassword",
category=RuleCategory.SECURITY, severity=Severity.ERROR, db_type="postgres",
description="密码复杂度检查",
config={
"min_length": 12,
"require_special": True,
"require_upper": True,
"require_digit": True,
},
message_template="密码不符合复杂度要求: 最少 {min_len} 位,需包含大小写字母、数字和特殊字符",
))
def check_pg_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""密码复杂度检查。"""
pwd_match = re.search(
r"\b(?:CREATE|ALTER)\s+(?:ROLE|USER)\s+\w+.*\bPASSWORD\s+['\"]([^'\"]+)['\"]",
sql_clean, re.IGNORECASE,
)
if not pwd_match:
return
password = pwd_match.group(1)
min_len = rule.config.get("min_length", 12)
issues = []
if len(password) < min_len:
issues.append(f"长度不足 {min_len} 位")
if rule.config.get("require_upper", True) and not re.search(r"[A-Z]", password):
issues.append("缺少大写字母")
if not re.search(r"[a-z]", password):
issues.append("缺少小写字母")
if rule.config.get("require_digit", True) and not re.search(r"\d", password):
issues.append("缺少数字")
if rule.config.get("require_special", True) and not re.search(r"[!@#$%^&*()_+\-=\[\]{};':\"|,.<>/?]", password):
issues.append("缺少特殊字符")
if issues:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=f"密码不符合复杂度要求: {', '.join(issues)}",
))
# ── PG-SEC-006 ──
@register_rule(AuditRuleV2(
code="PG-SEC-006", name="PGSecurityListenNotify",
category=RuleCategory.SECURITY, severity=Severity.INFO, db_type="postgres",
description="LISTEN/NOTIFY 使用限制(防止 DoS 攻击)",
config={"max_channels_per_session": 10, "warn_payload_size": 8000},
message_template="LISTEN/NOTIFY 使用 detected: {msg}",
))
def check_pg_sec_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""LISTEN/NOTIFY使用限制。"""
has_listen = re.search(r"\bLISTEN\s+\w+", sql_upper, re.IGNORECASE)
has_notify = re.search(r"\bNOTIFY\s+\w+", sql_upper, re.IGNORECASE)
if has_listen:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(
msg="LISTEN 通道在会话断开时自动取消订阅,建议配置 UNLISTEN 清理"
),
))
if has_notify:
payload_match = re.search(r"\bNOTIFY\s+\w+\s*,\s*['\"]([^'\"]+)['\"]", sql_clean, re.IGNORECASE)
if payload_match:
payload = payload_match.group(1)
max_size = rule.config.get("warn_payload_size", 8000)
if len(payload.encode("utf-8")) > max_size:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING,
message=f"NOTIFY payload 大小超过 {max_size} 字节(PG限制 8000 字节),建议减小消息体",
))
# ═══════════════════════════════════════════════
# 性能规则 (PG-PERF-001 ~ PG-PERF-007, 共7条)
# ═══════════════════════════════════════════════
# ── PG-PERF-001 ──
@register_rule(AuditRuleV2(
code="PG-PERF-001", name="PGPerfVacuum",
category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres",
description="大表 VACUUM 检查,建议避免 VACUUM FULL 阻塞操作",
config={"warn_vacuum_full": True, "recommend_autovacuum": True, "table_size_threshold_mb": 1000},
message_template="VACUUM 操作建议: {msg}",
))
def check_pg_perf_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""大表VACUUM检查。"""
if not re.search(r"\bVACUUM\b", sql_upper, re.IGNORECASE):
return
if rule.config.get("warn_vacuum_full", True) and re.search(r"\bVACUUM\s+FULL\b", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING,
message=rule.message_template.format(
msg="VACUUM FULL 会获取排他锁并重建整个表,大表操作会长时间阻塞读写。建议优先使用标准 VACUUM 或 pg_repack"
),
))
else:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(
msg="建议启用 autovacuum 并合理配置阈值 (autovacuum_vacuum_scale_factor),避免手动 VACUUM 遗漏"
),
))
# ── PG-PERF-002 ──
@register_rule(AuditRuleV2(
code="PG-PERF-002", name="PGPerfSeqScan",
category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="postgres",
description="检测可能导致全表扫描的查询模式",
config={"min_table_size_for_warn": 10000},
message_template="查询可能触发全表扫描: {reason}",
))
def check_pg_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""全表扫描检测(静态检查)。"""
if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE):
return
reasons = []
if "WHERE" not in sql_upper and "LIMIT" not in sql_upper:
reasons.append("SELECT 缺少 WHERE 和 LIMIT 条件")
where_match = re.search(r"\bWHERE\b(.+?)(?:GROUP|ORDER|LIMIT|OFFSET|;|$)", sql_upper, re.IGNORECASE | re.DOTALL)
if where_match:
where_clause = where_match.group(1)
if re.search(r"\b\w+\s*\([^)]+\)\s*[=<>]", where_clause):
reasons.append("WHERE 子句中列上使用了函数,可能导致索引失效")
if re.search(r"\bLIKE\s+'%[^']+'", where_clause):
reasons.append("LIKE 前导通配符 % 无法使用索引")
if re.search(r"\b\w+\s+IS\s+NULL\b", where_clause):
reasons.append("IS NULL 条件可能无法使用标准 B-Tree 索引")
if re.search(r"\bILIKE\b", sql_upper, re.IGNORECASE):
reasons.append("ILIKE 无法使用 B-Tree 索引,建议使用 citext 类型或 pg_trgm 扩展")
if reasons:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(reason="; ".join(reasons)),
))
# ── PG-PERF-003 ──
@register_rule(AuditRuleV2(
code="PG-PERF-003", name="PGPerfIndexBloat",
category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres",
description="索引膨胀检测建议,REINDEX CONCURRENTLY 使用",
config={"bloat_threshold_pct": 50, "recommend_reindex_concurrently": True},
message_template="索引维护建议: {msg}",
))
def check_pg_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""索引膨胀检测。"""
if re.search(r"\bREINDEX\s+INDEX\b", sql_upper, re.IGNORECASE) and "CONCURRENTLY" not in sql_upper:
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING,
message=rule.message_template.format(
msg="REINDEX 会锁表,生产环境建议使用 REINDEX TABLE CONCURRENTLY 或 REINDEX INDEX CONCURRENTLY (PG 12+)"
),
))
if re.search(r"\bCREATE\s+(?:UNIQUE\s+)?INDEX\b", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(
msg="定期监控索引膨胀率 (pgstattuple 扩展),膨胀超过 50% 时考虑 REINDEX CONCURRENTLY"
),
))
# ── PG-PERF-004 ──
@register_rule(AuditRuleV2(
code="PG-PERF-004", name="PGPerfLockTimeout",
category=RuleCategory.PERFORMANCE, severity=Severity.WARNING, db_type="postgres",
description="锁超时配置检查,长时间持有锁影响并发",
config={"recommended_lock_timeout_ms": 5000, "recommended_statement_timeout_ms": 30000},
message_template="锁/超时配置建议: {msg}",
))
def check_pg_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""锁超时配置。"""
timeout_patterns = [
(r"\bSET\s+lock_timeout\s*=\s*['\"]?(\d+\s*\w*)['\"]?", "lock_timeout"),
(r"\bSET\s+statement_timeout\s*=\s*['\"]?(\d+\s*\w*)['\"]?", "statement_timeout"),
]
for pat, name in timeout_patterns:
match = re.search(pat, sql_upper, re.IGNORECASE)
if match:
value = match.group(1)
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(msg=f"{name} 设置为 {value},建议合理配置避免长时间锁等待"),
))
if re.search(r"\bUPDATE\s+\w+\s+SET\b", sql_upper, re.IGNORECASE) and "WHERE" in sql_upper and "LIMIT" not in sql_upper:
recommended = rule.config.get("recommended_statement_timeout_ms", 30000)
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(
msg=f"大批量操作建议在事务中设置 SET LOCAL statement_timeout = '{recommended}ms' 避免长时间持有锁"
),
))
# ── PG-PERF-005 ──
@register_rule(AuditRuleV2(
code="PG-PERF-005", name="PGPerfConnectionPool",
category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres",
description="连接池使用建议",
config={"max_connections": 200, "recommended_pool_size": 20},
message_template="连接管理建议: {msg}",
))
def check_pg_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""连接池使用建议。"""
if re.search(r"\bSET\s+(?:SESSION|LOCAL)\b", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(
msg="频繁使用 SET SESSION/LOCAL 可能影响连接池复用,建议通过连接池统一配置参数"
),
))
if re.search(r"\bBEGIN\b", sql_upper, re.IGNORECASE) and not re.search(r"\bCOMMIT\b|\bROLLBACK\b", sql_upper, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=Severity.WARNING,
message=rule.message_template.format(
msg="检测到 BEGIN 但无 COMMIT/ROLLBACK,长时间打开的事务可能导致连接池耗尽和锁堆积"
),
))
# ── PG-PERF-006 ──
@register_rule(AuditRuleV2(
code="PG-PERF-006", name="PGPerfWorkMem",
category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres",
description="work_mem 配置建议,影响排序和哈希操作性能",
config={"min_work_mem_mb": 4, "recommended_work_mem_mb": 16},
message_template="内存配置建议: {msg}",
))
def check_pg_perf_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""work_mem配置建议。"""
has_sort = False
has_hash = False
if "ORDER BY" in sql_upper:
order_cols = re.findall(r"\bORDER\s+BY\s+(.+?)(?:LIMIT|OFFSET|;|$)", sql_upper, re.IGNORECASE)
if order_cols and len(order_cols[0].split(",")) > 2:
has_sort = True
if "GROUP BY" in sql_upper:
has_hash = True
if re.search(r"\bDISTINCT\b", sql_upper, re.IGNORECASE):
has_sort = True
if "WITH" in sql_upper and ("JOIN" in sql_upper or "UNION" in sql_upper):
has_sort = True
if has_sort or has_hash:
rec_mem = rule.config.get("recommended_work_mem_mb", 16)
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(
msg=f"复杂排序/哈希操作建议确保 work_mem >= {rec_mem}MB,或设置 SET LOCAL work_mem = '{rec_mem}MB'"
),
))
# ── PG-PERF-007 ──
@register_rule(AuditRuleV2(
code="PG-PERF-007", name="PGPerfParallelQuery",
category=RuleCategory.PERFORMANCE, severity=Severity.INFO, db_type="postgres",
description="并行查询使用规范(max_parallel_workers_per_gather)",
config={"max_parallel_workers": 4, "min_table_size_for_parallel": "8MB"},
message_template="并行查询建议: {msg}",
))
def check_pg_perf_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""并行查询使用规范。"""
if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE):
return
is_complex = False
join_count = len(re.findall(r"\bJOIN\b", sql_upper, re.IGNORECASE))
if join_count >= 2:
is_complex = True
if re.search(r"\bUNION\s+ALL?\b", sql_upper, re.IGNORECASE):
is_complex = True
if re.search(r"\bCOUNT\s*\(|\bSUM\s*\(|\bAVG\s*\(", sql_upper, re.IGNORECASE) and "GROUP BY" in sql_upper:
is_complex = True
if is_complex:
max_workers = rule.config.get("max_parallel_workers", 4)
report.findings.append(AuditFindingV2(
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
message=rule.message_template.format(
msg=f"复杂查询可受益于并行查询,建议确认 max_parallel_workers_per_gather <= {max_workers},并确保 min_parallel_table_scan_size 设置合理"
),
))
# ═══════════════════════════════════════════════
# 注册表导出
# ═══════════════════════════════════════════════
PG_RULE_REGISTRY: Dict[str, Tuple[AuditRuleV2, Callable]] = {}
"""PostgreSQL 特有规则注册表 (code -> (rule, check_func))。"""
def _init_pg_registry() -> None:
"""初始化 PostgreSQL 规则注册表。"""
from app.services.audit_engine_v2 import _RULE_REGISTRY
global PG_RULE_REGISTRY
PG_RULE_REGISTRY = {
code: (rule, func)
for code, (rule, func) in _RULE_REGISTRY.items()
if code.startswith("PG-")
}
_init_pg_registry()
def get_pg_rules() -> List[AuditRuleV2]:
"""获取所有 PostgreSQL 审核规则定义。"""
return sorted(
[rule for rule, _ in PG_RULE_REGISTRY.values()],
key=lambda r: r.code,
)
def get_pg_rules_by_category(category: RuleCategory) -> List[AuditRuleV2]:
"""按分类获取 PostgreSQL 审核规则。"""
return [rule for rule in get_pg_rules() if rule.category == category]
def get_pg_rule_count() -> Dict[str, int]:
"""获取 PostgreSQL 规则统计信息。"""
rules = get_pg_rules()
return {
"total": len(rules),
"ddl": sum(1 for r in rules if r.category == RuleCategory.DDL),
"dml": sum(1 for r in rules if r.category == RuleCategory.DML),
"dql": sum(1 for r in rules if r.category == RuleCategory.DQL),
"security": sum(1 for r in rules if r.category == RuleCategory.SECURITY),
"performance": sum(1 for r in rules if r.category == RuleCategory.PERFORMANCE),
}
# ──────────────────────────────────────────────
# 模块加载自检
# ──────────────────────────────────────────────
if __name__ == "__main__":
stats = get_pg_rule_count()
print("=" * 60)
print("PostgreSQL 审核规则引擎 - 规则统计")
print("=" * 60)
print(f"DDL 规则: {stats['ddl']} 条 (PG-DDL-001 ~ PG-DDL-018)")
print(f"DML 规则: {stats['dml']} 条 (PG-DML-001 ~ PG-DML-008)")
print(f"DQL 规则: {stats['dql']} 条 (PG-DQL-001 ~ PG-DQL-006)")
print(f"安全规则: {stats['security']} 条 (PG-SEC-001 ~ PG-SEC-006)")
print(f"性能规则: {stats['performance']} 条 (PG-PERF-001 ~ PG-PERF-007)")
print("-" * 60)
print(f"总计: {stats['total']} 条规则")
print("=" * 60)
print("\n规则明细:")
for rule in get_pg_rules():
print(f" {rule.code} | {rule.severity.value:7s} | {rule.name}")