后端新增 (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 通过
1290 lines
60 KiB
Python
1290 lines
60 KiB
Python
"""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}")
|