- AuditRule模型: 新增code/config_json字段,支持规则编码和阈值参数持久化 - audit_engine_v2: _load_rules从DB读config_json覆盖默认config - audit_rule_sync: 启动时自动从_RULE_REGISTRY同步规则到DB(239条) - settings API: update后刷新query_service配置缓存 - query_service: MAX_ROWS/QUERY_TIMEOUT/MAX_QUERY_LENGTH/MAX_RESULT_MEMORY改为动态读取system_settings - ticket_executor: SQL_TIMEOUT/REDIS_TIMEOUT/MONGO_TIMEOUT改为动态获取 - settings_seeds: 新增executor分组(sql/redis/mongo超时) + query_result_max_memory_mb - audit_rule endpoint: 移除无效的AuditEngine缓存清理(每次audit是新实例) - 测试: 1057 passed(修复所有新引入失败,17个历史遗留)
2216 lines
91 KiB
Python
2216 lines
91 KiB
Python
"""MySQL 审核规则引擎 — 63条 MySQL 专属审核规则。
|
||
|
||
本模块包含 MySQL 数据库的完整审核规则集,涵盖:
|
||
- DDL 规则 (36条): 建表规范、索引规范、字段规范、字符集规范
|
||
- DML 规则 (9条): WHERE条件、批量限制、事务规范
|
||
- Online-DDL 规则 (3条): 在线变更工具配置
|
||
- 命名规则 (5条): 表名/字段名/索引名规范
|
||
- 性能规则 (5条): 全表扫描、索引使用
|
||
- 安全规则 (5条): 敏感字段、SQL注入风险
|
||
|
||
使用方式:
|
||
规则通过 @register_rule 装饰器自动注册到审核引擎,
|
||
由 SQLAuditEngineV2 在审核时自动调用。
|
||
|
||
依赖:
|
||
- audit_engine_v2.py: register_rule, AuditRuleV2, AuditFindingV2, AuditReportV2
|
||
- Severity, RuleCategory 枚举
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from app.services.audit_engine_v2 import register_rule, AuditRuleV2, AuditFindingV2, AuditReportV2
|
||
from app.services.audit_engine_v2 import Severity, RuleCategory
|
||
from app.services.audit_engine_v2 import (
|
||
_extract_table_name,
|
||
_extract_column_definitions,
|
||
_extract_index_definitions,
|
||
_is_snake_case,
|
||
)
|
||
|
||
# ──────────────────────────────────────────────
|
||
# 辅助解析函数
|
||
# ──────────────────────────────────────────────────
|
||
|
||
|
||
def _extract_create_table_comment(sql_upper: str) -> Optional[str]:
|
||
"""从 CREATE TABLE 语句中提取表注释。"""
|
||
comment_match = re.search(r"COMMENT\s*=\s*['\"]([^'\"]*)['\"]", sql_upper, re.IGNORECASE)
|
||
if comment_match:
|
||
return comment_match.group(1).strip()
|
||
# 也匹配 COMMENT 'xxx' 格式
|
||
comment_match = re.search(r"COMMENT\s+['\"]([^'\"]+)['\"]", sql_upper, re.IGNORECASE)
|
||
if comment_match:
|
||
return comment_match.group(1).strip()
|
||
return None
|
||
|
||
|
||
def _extract_charset_info(sql_upper: str) -> Dict[str, Optional[str]]:
|
||
"""从 CREATE TABLE 语句中提取字符集和排序规则信息。"""
|
||
result: Dict[str, Optional[str]] = {"charset": None, "collation": None}
|
||
cs_match = re.search(r"DEFAULT\s+CHARSET\s*=\s*(\w+)", sql_upper, re.IGNORECASE)
|
||
if not cs_match:
|
||
cs_match = re.search(r"CHARSET\s*=\s*(\w+)", sql_upper, re.IGNORECASE)
|
||
if cs_match:
|
||
result["charset"] = cs_match.group(1).lower()
|
||
|
||
col_match = re.search(r"COLLATE\s*=\s*(\w+)", sql_upper, re.IGNORECASE)
|
||
if col_match:
|
||
result["collation"] = col_match.group(1).lower()
|
||
return result
|
||
|
||
|
||
def _extract_alter_operations(sql_upper: str) -> List[Dict[str, str]]:
|
||
"""从 ALTER TABLE 语句中提取操作列表。"""
|
||
operations: List[Dict[str, str]] = []
|
||
# 匹配 ADD COLUMN, DROP COLUMN, MODIFY COLUMN, CHANGE COLUMN 等
|
||
patterns = [
|
||
(r"ADD\s+COLUMN\s+[`\"']?(\w+)", "ADD_COLUMN"),
|
||
(r"ADD\s+(?:INDEX|KEY)\s+[`\"']?(\w*)", "ADD_INDEX"),
|
||
(r"DROP\s+COLUMN\s+[`\"']?(\w+)", "DROP_COLUMN"),
|
||
(r"MODIFY\s+COLUMN\s+[`\"']?(\w+)", "MODIFY_COLUMN"),
|
||
(r"CHANGE\s+COLUMN\s+[`\"']?(\w+)", "CHANGE_COLUMN"),
|
||
(r"DROP\s+(?:INDEX|KEY)\s+[`\"']?(\w+)", "DROP_INDEX"),
|
||
(r"ADD\s+PRIMARY\s+KEY", "ADD_PK"),
|
||
(r"DROP\s+PRIMARY\s+KEY", "DROP_PK"),
|
||
]
|
||
for pattern, op_type in patterns:
|
||
for match in re.finditer(pattern, sql_upper, re.IGNORECASE):
|
||
name = match.group(1) if match.lastindex and match.group(1) else ""
|
||
operations.append({"type": op_type, "name": name})
|
||
return operations
|
||
|
||
|
||
def _has_where_clause(sql_upper: str) -> bool:
|
||
"""检查SQL语句是否包含WHERE子句。"""
|
||
return bool(re.search(r"\bWHERE\b", sql_upper, re.IGNORECASE))
|
||
|
||
|
||
def _has_limit_clause(sql_upper: str) -> bool:
|
||
"""检查SQL语句是否包含LIMIT子句。"""
|
||
return bool(re.search(r"\bLIMIT\s+\d+", sql_upper, re.IGNORECASE))
|
||
|
||
|
||
def _has_order_by(sql_upper: str) -> bool:
|
||
"""检查SQL语句是否包含ORDER BY子句。"""
|
||
return bool(re.search(r"\bORDER\s+BY\b", sql_upper, re.IGNORECASE))
|
||
|
||
|
||
def _extract_insert_columns(sql_upper: str) -> List[str]:
|
||
"""从 INSERT 语句中提取插入的字段名列表。"""
|
||
cols: List[str] = []
|
||
match = re.search(r"\bINSERT\b.+?\(([^)]+)\)", sql_upper, re.IGNORECASE | re.DOTALL)
|
||
if match:
|
||
inner = match.group(1)
|
||
for col in inner.split(","):
|
||
cols.append(col.strip().strip("`'\""))
|
||
return cols
|
||
|
||
|
||
def _count_insert_rows(sql_clean: str) -> int:
|
||
"""统计 INSERT 语句插入的行数(通过 VALUES 子句数量)。"""
|
||
values_count = len(re.findall(r"\bVALUES\s*\(", sql_clean, re.IGNORECASE))
|
||
if values_count == 0:
|
||
# 尝试 INSERT ... SELECT 形式
|
||
if re.search(r"\bINSERT\b.*\bSELECT\b", sql_clean, re.IGNORECASE | re.DOTALL):
|
||
return -1 # 表示无法确定的行数
|
||
return max(values_count, 1)
|
||
|
||
|
||
def _is_ddl_statement(sql_upper: str) -> bool:
|
||
"""判断是否为DDL语句。"""
|
||
return bool(re.search(r"\b(CREATE|ALTER|DROP|TRUNCATE)\b", sql_upper, re.IGNORECASE))
|
||
|
||
|
||
def _is_dml_statement(sql_upper: str) -> bool:
|
||
"""判断是否为DML语句。"""
|
||
return bool(re.search(r"\b(INSERT|UPDATE|DELETE|REPLACE)\b", sql_upper, re.IGNORECASE))
|
||
|
||
|
||
def _extract_column_defaults(sql_upper: str) -> List[Dict[str, Any]]:
|
||
"""从 CREATE TABLE 中提取字段默认值信息。"""
|
||
columns = _extract_column_definitions(sql_upper)
|
||
result: List[Dict[str, Any]] = []
|
||
for col in columns:
|
||
has_default = "DEFAULT" in col["full"].upper()
|
||
is_nullable = "NOT NULL" not in col["full"].upper()
|
||
is_timestamp = col["type"].upper() in ("TIMESTAMP", "DATETIME")
|
||
result.append({
|
||
"name": col["name"],
|
||
"type": col["type"],
|
||
"has_default": has_default,
|
||
"is_nullable": is_nullable,
|
||
"is_timestamp": is_timestamp,
|
||
"full": col["full"],
|
||
})
|
||
return result
|
||
|
||
|
||
def _extract_primary_key_info(sql_upper: str) -> Optional[Dict[str, Any]]:
|
||
"""从 CREATE TABLE 中提取主键信息。"""
|
||
# 内联主键定义
|
||
pk_match = re.search(r"PRIMARY\s+KEY\s*\(([^)]+)\)", sql_upper, re.IGNORECASE)
|
||
if pk_match:
|
||
cols = [c.strip().strip("`'\"") for c in pk_match.group(1).split(",")]
|
||
return {"type": "inline", "columns": cols}
|
||
|
||
# 单列主键如 `id` INT PRIMARY KEY
|
||
for col in _extract_column_definitions(sql_upper):
|
||
if "PRIMARY KEY" in col["full"].upper():
|
||
return {"type": "column", "columns": [col["name"]], "column_def": col}
|
||
return None
|
||
|
||
|
||
def _count_alter_statements(sql_upper: str) -> int:
|
||
"""统计 ALTER TABLE 语句数量。"""
|
||
return len(re.findall(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE))
|
||
|
||
|
||
def _has_auto_increment(sql_upper: str) -> bool:
|
||
"""检查是否包含 AUTO_INCREMENT。"""
|
||
return bool(re.search(r"\bAUTO_INCREMENT\b", sql_upper, re.IGNORECASE))
|
||
|
||
|
||
def _extract_auto_increment_init(sql_upper: str) -> Optional[int]:
|
||
"""提取 AUTO_INCREMENT 初始值。"""
|
||
match = re.search(r"AUTO_INCREMENT\s*=\s*(\d+)", sql_upper, re.IGNORECASE)
|
||
if match:
|
||
return int(match.group(1))
|
||
return None
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ══════════════════════════════════════════════
|
||
# DDL 规则 (36条) — MYSQL-DDL-001 ~ MYSQL-DDL-036
|
||
# ══════════════════════════════════════════════
|
||
# ──────────────────────────────────────────────
|
||
|
||
|
||
# ── DDL-001: 强制表必须拥有表注释 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-001",
|
||
name="DDLCheckTableComment",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="CREATE TABLE 语句必须包含 COMMENT 表注释",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 缺少表注释,请添加 COMMENT='...'",
|
||
))
|
||
def check_mysql_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
|
||
comment = _extract_create_table_comment(sql_upper)
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
if not comment:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name),
|
||
))
|
||
|
||
|
||
# ── DDL-002: 强制表字段必须拥有列注释 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-002",
|
||
name="DDLCheckColumnComment",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="CREATE TABLE 中每个字段定义必须包含 COMMENT 注释",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的字段 '{column}' 缺少列注释,请添加 COMMENT '...'",
|
||
))
|
||
def check_mysql_ddl_002(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
paren_match = re.search(r"\((.*)\)", 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())
|
||
|
||
for part in parts:
|
||
# 跳过约束定义
|
||
if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE):
|
||
continue
|
||
col_match = re.match(r"[`\"']?(\w+)[`\"']?\s+(\w+)", part, re.IGNORECASE)
|
||
if col_match:
|
||
col_name = col_match.group(1)
|
||
if "COMMENT" not in part.upper():
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, column=col_name),
|
||
))
|
||
|
||
|
||
# ── DDL-003: 强制非timestamp类型字段必须为NOT NULL ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-003",
|
||
name="DDLCheckColumnNullable",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="非 TIMESTAMP/DATETIME 类型字段必须定义为 NOT NULL",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的字段 '{column}' (类型: {ctype}) 必须定义为 NOT NULL",
|
||
))
|
||
def check_mysql_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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
paren_match = re.search(r"\((.*)\)", 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())
|
||
|
||
for part in parts:
|
||
if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE):
|
||
continue
|
||
col_match = re.match(r"[`\"']?(\w+)[`\"']?\s+(\w+)", part, re.IGNORECASE)
|
||
if col_match:
|
||
col_name = col_match.group(1)
|
||
col_type = col_match.group(2).upper()
|
||
if col_type in ("TIMESTAMP", "DATETIME"):
|
||
continue
|
||
if "NOT NULL" not in part.upper():
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, column=col_name, ctype=col_type),
|
||
))
|
||
|
||
|
||
# ── DDL-004: 强制非text,blob,json,timestamp类型字段必须拥有默认值 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-004",
|
||
name="DDLCheckColumnDefault",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="非 TEXT/BLOB/JSON/TIMESTAMP/DATETIME 类型字段必须指定 DEFAULT 值",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的字段 '{column}' (类型: {ctype}) 缺少 DEFAULT 值",
|
||
))
|
||
def check_mysql_ddl_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""强制非text,blob,json,timestamp类型字段必须拥有默认值。"""
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
excluded_types = {"TEXT", "BLOB", "JSON", "TIMESTAMP", "DATETIME", "TINYTEXT", "MEDIUMTEXT",
|
||
"LONGTEXT", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB"}
|
||
|
||
paren_match = re.search(r"\((.*)\)", 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())
|
||
|
||
for part in parts:
|
||
if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE):
|
||
continue
|
||
col_match = re.match(r"[`\"']?(\w+)[`\"']?\s+(\w+)", part, re.IGNORECASE)
|
||
if col_match:
|
||
col_name = col_match.group(1)
|
||
col_type = col_match.group(2).upper()
|
||
if col_type in excluded_types:
|
||
continue
|
||
if "DEFAULT" not in part.upper() and "NOT NULL" in part.upper():
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, column=col_name, ctype=col_type),
|
||
))
|
||
|
||
|
||
# ── DDL-005: 强制float/double类型变更为decimal类型 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-005",
|
||
name="DDLCheckFloatDouble",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="禁止使用 FLOAT/DOUBLE 类型存储金额/数值数据,必须使用 DECIMAL 保证精度",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的字段 '{column}' 使用了 {ctype} 类型,存在精度丢失风险,必须使用 DECIMAL 替代(如 DECIMAL(16,4))",
|
||
))
|
||
def check_mysql_ddl_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""禁止 FLOAT/DOUBLE 类型,强制使用 DECIMAL 保证精度。"""
|
||
# 检查 CREATE TABLE
|
||
if re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
columns = _extract_column_definitions(sql_upper)
|
||
for col in columns:
|
||
if col["type"].upper() in ("FLOAT", "DOUBLE", "REAL"):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, column=col["name"], ctype=col["type"]),
|
||
))
|
||
|
||
# 检查 ALTER TABLE ADD/MODIFY/CHANGE COLUMN
|
||
alter_match = re.search(r"\bALTER\s+TABLE\s+[`\"']?(\w+)", sql_upper, re.IGNORECASE)
|
||
if alter_match:
|
||
table_name = alter_match.group(1)
|
||
# 匹配 ADD/MODIFY/CHANGE COLUMN 中出现的 FLOAT/DOUBLE 类型
|
||
float_pattern = r"(?:ADD|MODIFY|CHANGE)\s+(?:COLUMN\s+)?[`\"']?(\w+)[`\"']?\s+(?:FLOAT|DOUBLE|REAL)\b"
|
||
for m in re.finditer(float_pattern, sql_upper, re.IGNORECASE):
|
||
col_name = m.group(1)
|
||
ctype = "FLOAT" if "FLOAT" in m.group(0).upper() else ("DOUBLE" if "DOUBLE" in m.group(0).upper() else "REAL")
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, column=col_name, ctype=ctype),
|
||
))
|
||
|
||
|
||
# ── DDL-006: 强制自增列初始值为1 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-006",
|
||
name="DDLEnableAutoincrementInit",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="AUTO_INCREMENT 初始值必须为 1",
|
||
config={"enabled": True, "init_value": 1},
|
||
message_template="表 '{table}' 的 AUTO_INCREMENT 初始值不为 {init},建议设置为 AUTO_INCREMENT=1",
|
||
))
|
||
def check_mysql_ddl_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""强制自增列初始值为1。"""
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
if not _has_auto_increment(sql_upper):
|
||
return
|
||
init_val = _extract_auto_increment_init(sql_upper)
|
||
expected = rule.config.get("init_value", 1)
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
if init_val is not None and init_val != expected:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, init=expected),
|
||
))
|
||
|
||
|
||
# ── DDL-007/008: 强制必须拥有主键 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-007",
|
||
name="DDLEnablePrimaryKey",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="CREATE TABLE 语句必须定义主键",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 缺少主键定义,必须添加 PRIMARY KEY",
|
||
))
|
||
def check_mysql_ddl_007(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
|
||
pk_info = _extract_primary_key_info(sql_upper)
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
if pk_info is None:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name),
|
||
))
|
||
|
||
|
||
# ── DDL-009: 强制主键名称为ID ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-009",
|
||
name="DDLPrimaryKeyMust",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.INFO,
|
||
db_type="mysql",
|
||
description="主键列名必须为 'id'(不区分大小写)",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的主键列名为 '{pk_name}',建议命名为 'id'",
|
||
))
|
||
def check_mysql_ddl_009(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
|
||
pk_info = _extract_primary_key_info(sql_upper)
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
if pk_info and len(pk_info["columns"]) == 1:
|
||
pk_name = pk_info["columns"][0]
|
||
if pk_name.lower() != "id":
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, pk_name=pk_name),
|
||
))
|
||
|
||
|
||
# ── DDL-010: 强制主键为自增列 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-010",
|
||
name="DDLEnableAutoIncrement",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="主键列必须使用 AUTO_INCREMENT",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的主键列 '{pk_name}' 缺少 AUTO_INCREMENT",
|
||
))
|
||
def check_mysql_ddl_010(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
pk_info = _extract_primary_key_info(sql_upper)
|
||
if pk_info is None:
|
||
return
|
||
if pk_info["type"] == "column":
|
||
col_def = pk_info.get("column_def", {})
|
||
pk_name = pk_info["columns"][0]
|
||
if "AUTO_INCREMENT" not in col_def.get("full", "").upper():
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, pk_name=pk_name),
|
||
))
|
||
|
||
|
||
# ── DDL-011: 强制主键必须使用无符号标志unsigned ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-011",
|
||
name="DDLEnableAutoincrementUnsigned",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="主键整数类型必须使用 UNSIGNED 属性",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的主键列 '{pk_name}' 建议使用 UNSIGNED 属性",
|
||
))
|
||
def check_mysql_ddl_011(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""强制主键必须使用无符号标志unsigned。"""
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
pk_info = _extract_primary_key_info(sql_upper)
|
||
if pk_info is None:
|
||
return
|
||
if pk_info["type"] == "column":
|
||
col_def = pk_info.get("column_def", {})
|
||
pk_name = pk_info["columns"][0]
|
||
col_type = col_def.get("type", "").upper()
|
||
if col_type in ("INT", "BIGINT", "TINYINT", "SMALLINT", "MEDIUMINT"):
|
||
if "UNSIGNED" not in col_def.get("full", "").upper():
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, pk_name=pk_name),
|
||
))
|
||
|
||
|
||
# ── DDL-012: 基于主键回滚 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-012",
|
||
name="PRIRollBack",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.INFO,
|
||
db_type="mysql",
|
||
description="建议基于主键进行数据变更回滚操作",
|
||
config={"enabled": True},
|
||
message_template="建议在回滚操作中使用主键进行定位",
|
||
))
|
||
def check_mysql_ddl_012(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""基于主键回滚建议。"""
|
||
# 这是一个建议性规则,在 DELETE/UPDATE 语句中提示
|
||
if not _is_dml_statement(sql_upper):
|
||
return
|
||
pk_info = _extract_primary_key_info(sql_upper)
|
||
if pk_info is None and _is_dml_statement(sql_upper):
|
||
# 简单提示:DML操作建议基于主键
|
||
if re.search(r"\b(DELETE|UPDATE)\b", sql_upper, re.IGNORECASE):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ── DDL-013: 开启索引名称规范(索引名必须以idx_为开头) ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-013",
|
||
name="DDLIndexNameSpec",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="普通索引名称必须以 'idx_' 为前缀,唯一索引以 'uniq_' 为前缀",
|
||
config={"enabled": True, "index_prefix": "idx_", "unique_prefix": "uniq_"},
|
||
message_template="表 '{table}' 的索引 '{idx_name}' 命名不规范,普通索引应以 '{prefix}' 开头",
|
||
))
|
||
def check_mysql_ddl_013(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
idx_prefix = rule.config.get("index_prefix", "idx_")
|
||
uniq_prefix = rule.config.get("unique_prefix", "uniq_")
|
||
indexes = _extract_index_definitions(sql_upper)
|
||
for idx in indexes:
|
||
if idx["type"] == "INDEX" and not idx["name"].lower().startswith(idx_prefix.lower()):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(
|
||
table=table_name, idx_name=idx["name"] or "(unnamed)", prefix=idx_prefix,
|
||
),
|
||
))
|
||
|
||
|
||
# ── DDL-014: 开启MySQL关键词检查 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-014",
|
||
name="CheckIdentifier",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="表名和字段名不能是MySQL保留关键字",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 中的标识符 '{identifier}' 是MySQL保留关键字,请修改",
|
||
))
|
||
def check_mysql_ddl_014(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""开启MySQL关键词检查。"""
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
# MySQL保留关键字列表(常用部分)
|
||
mysql_keywords = {
|
||
"SELECT", "INSERT", "UPDATE", "DELETE", "FROM", "WHERE", "ORDER", "GROUP",
|
||
"BY", "HAVING", "LIMIT", "OFFSET", "JOIN", "LEFT", "RIGHT", "INNER", "OUTER",
|
||
"ON", "AS", "AND", "OR", "NOT", "NULL", "TRUE", "FALSE", "CREATE", "DROP",
|
||
"ALTER", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", "PRIMARY", "KEY",
|
||
"FOREIGN", "REFERENCES", "UNIQUE", "CHECK", "DEFAULT", "AUTO_INCREMENT",
|
||
"UNSIGNED", "CHARSET", "COLLATE", "ENGINE", "COMMENT", "INT", "VARCHAR",
|
||
"TEXT", "BLOB", "DATE", "TIME", "DATETIME", "TIMESTAMP", "FLOAT", "DOUBLE",
|
||
"DECIMAL", "SHOW", "USE", "DESC", "EXPLAIN", "SET", "COMMIT", "ROLLBACK",
|
||
}
|
||
columns = _extract_column_definitions(sql_upper)
|
||
if table_name.upper() in mysql_keywords:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, identifier=table_name),
|
||
))
|
||
for col in columns:
|
||
if col["name"].upper() in mysql_keywords:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, identifier=col["name"]),
|
||
))
|
||
|
||
|
||
# ── DDL-015: 允许跨库表迁移 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-015",
|
||
name="DDLEnableAcrossDBRename",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="默认不允许跨库RENAME TABLE操作",
|
||
config={"enabled": False},
|
||
message_template="禁止跨库RENAME TABLE操作: {detail}",
|
||
))
|
||
def check_mysql_ddl_015(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许跨库表迁移(默认关闭)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if not re.search(r"\bRENAME\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
# 检查是否包含数据库名前缀 (db.table)
|
||
if re.search(r"`?\w+`?\.`?\w+`?\s+TO\s+`?\w+`?\.`?\w+`?", sql_upper, re.IGNORECASE):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(detail=sql_clean[:80]),
|
||
))
|
||
|
||
|
||
# ── DDL-016: 允许删除表 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-016",
|
||
name="DDLEnableDropTable",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="默认不允许 DROP TABLE 操作",
|
||
config={"enabled": False},
|
||
message_template="禁止 DROP TABLE 操作,表 '{table}' 将被删除",
|
||
))
|
||
def check_mysql_ddl_016(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许删除表(默认关闭)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
match = re.search(r"\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?[`\"']?(\w+)[`\"']?", sql_upper, re.IGNORECASE)
|
||
if match:
|
||
table_name = match.group(1)
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name),
|
||
))
|
||
|
||
|
||
# ── DDL-017: 允许删除库 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-017",
|
||
name="DDLEnableDropDatabase",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="默认不允许 DROP DATABASE 操作",
|
||
config={"enabled": False},
|
||
message_template="禁止 DROP DATABASE 操作,数据库 '{db}' 将被删除",
|
||
))
|
||
def check_mysql_ddl_017(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许删除库(默认关闭)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
match = re.search(r"\bDROP\s+DATABASE\s+(?:IF\s+EXISTS\s+)?[`\"']?(\w+)[`\"']?", sql_upper, re.IGNORECASE)
|
||
if match:
|
||
db_name = match.group(1)
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(db=db_name),
|
||
))
|
||
|
||
|
||
# ── DDL-018: 允许主键类型为非int/bigint ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-018",
|
||
name="DDLAllowPRINotInt",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认要求主键类型必须为 INT 或 BIGINT",
|
||
config={"enabled": False},
|
||
message_template="表 '{table}' 的主键类型为 '{pk_type}',建议使用 INT 或 BIGINT",
|
||
))
|
||
def check_mysql_ddl_018(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许主键类型为非int/bigint(默认不允许)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
pk_info = _extract_primary_key_info(sql_upper)
|
||
if pk_info is None:
|
||
return
|
||
if pk_info["type"] == "column":
|
||
col_def = pk_info.get("column_def", {})
|
||
pk_type = col_def.get("type", "").upper()
|
||
if pk_type not in ("INT", "BIGINT", "TINYINT", "SMALLINT", "MEDIUMINT"):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, pk_type=pk_type),
|
||
))
|
||
|
||
|
||
# ── DDL-019: 允许索引名为空 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-019",
|
||
name="DDLEnableNullIndexName",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认要求索引必须命名",
|
||
config={"enabled": False},
|
||
message_template="表 '{table}' 存在未命名的索引,建议为所有索引指定名称",
|
||
))
|
||
def check_mysql_ddl_019(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许索引名为空(默认不允许)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
indexes = _extract_index_definitions(sql_upper)
|
||
for idx in indexes:
|
||
if not idx["name"] and idx["type"] not in ("PRIMARY", "FOREIGN"):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name),
|
||
))
|
||
|
||
|
||
# ── DDL-020: 允许单个工单提交多条DDL语句 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-020",
|
||
name="DDLMultiToCommit",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认不允许单个工单提交多条DDL语句",
|
||
config={"enabled": False, "max_statements": 1},
|
||
message_template="单个工单包含 {count} 条DDL语句,建议每条工单只提交1条DDL",
|
||
))
|
||
def check_mysql_ddl_020(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许单个工单提交多条DDL语句(默认不允许)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
ddl_count = 0
|
||
# 按分号分割统计DDL语句数
|
||
statements = [s.strip() for s in sql_clean.split(";") if s.strip()]
|
||
for stmt in statements:
|
||
if re.search(r"\b(CREATE|ALTER|DROP|TRUNCATE)\b", stmt, re.IGNORECASE):
|
||
ddl_count += 1
|
||
max_stmt = rule.config.get("max_statements", 1)
|
||
if ddl_count > max_stmt:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(count=ddl_count),
|
||
))
|
||
|
||
|
||
# ── DDL-021: DDL单个工单允许多alter语句提交 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-021",
|
||
name="DDLAllowMultiAlter",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认不允许单个工单包含多条 ALTER TABLE 语句",
|
||
config={"enabled": False, "max_alter": 1},
|
||
message_template="单个工单包含 {count} 条 ALTER TABLE 语句,建议拆分为多个工单",
|
||
))
|
||
def check_mysql_ddl_021(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""DDL单个工单允许多alter语句提交(默认不允许)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
alter_count = _count_alter_statements(sql_upper)
|
||
max_alter = rule.config.get("max_alter", 1)
|
||
if alter_count > max_alter:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(count=alter_count),
|
||
))
|
||
|
||
|
||
# ── DDL-022: 允许字段进行类型转换 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-022",
|
||
name="DDLAllowColumnType",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认不允许 ALTER TABLE MODIFY/CHANGE 进行字段类型转换",
|
||
config={"enabled": False},
|
||
message_template="ALTER TABLE 包含字段类型变更操作,请确认变更的必要性和兼容性",
|
||
))
|
||
def check_mysql_ddl_022(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许字段进行类型转换(默认不允许)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
operations = _extract_alter_operations(sql_upper)
|
||
for op in operations:
|
||
if op["type"] in ("MODIFY_COLUMN", "CHANGE_COLUMN"):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ── DDL-023: 允许使用after/first ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-023",
|
||
name="DDLAllowChangeColumnPosition",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认不建议使用 AFTER/FIRST 调整字段位置(引起全表重建)",
|
||
config={"enabled": False},
|
||
message_template="ALTER TABLE 使用了 AFTER/FIRST 调整字段位置,可能引起全表重建",
|
||
))
|
||
def check_mysql_ddl_023(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许使用after/first(默认不建议)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
if re.search(r"\b(AFTER|FIRST)\b", sql_upper, re.IGNORECASE):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ── DDL-024: 允许创建视图 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-024",
|
||
name="AllowCreateView",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认不允许创建视图",
|
||
config={"enabled": False},
|
||
message_template="禁止创建视图: '{view_name}'",
|
||
))
|
||
def check_mysql_ddl_024(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许创建视图(默认不允许)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
match = re.search(r"\bCREATE\s+(?:OR\s+REPLACE\s+)?VIEW\s+[`\"']?(\w+)[`\"']?", sql_upper, re.IGNORECASE)
|
||
if match:
|
||
view_name = match.group(1)
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(view_name=view_name),
|
||
))
|
||
|
||
|
||
# ── DDL-025: 允许创建外键 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-025",
|
||
name="DDLEnableForeignKey",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="默认不允许创建外键约束",
|
||
config={"enabled": False},
|
||
message_template="禁止创建外键约束: '{fk_name}',建议在应用层实现外键逻辑",
|
||
))
|
||
def check_mysql_ddl_025(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许创建外键(默认不允许)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if re.search(r"\bFOREIGN\s+KEY\b", sql_upper, re.IGNORECASE):
|
||
fk_match = re.search(r"FOREIGN\s+KEY\s+[`\"']?(\w*)[`\"']?", sql_upper, re.IGNORECASE)
|
||
fk_name = fk_match.group(1) if fk_match and fk_match.group(1) else "(unnamed)"
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(fk_name=fk_name),
|
||
))
|
||
|
||
|
||
# ── DDL-026: 允许创建分区表 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-026",
|
||
name="AllowCreatePartition",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认不建议创建分区表",
|
||
config={"enabled": False},
|
||
message_template="创建分区表需要谨慎评估,请确认分区策略的合理性",
|
||
))
|
||
def check_mysql_ddl_026(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许创建分区表(默认不建议)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if re.search(r"\bPARTITION\s+BY\b", sql_upper, re.IGNORECASE):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ── DDL-027: 允许添加bit,enum,set类型字段 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-027",
|
||
name="AllowSpecialType",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认不建议使用 BIT/ENUM/SET 类型",
|
||
config={"enabled": False},
|
||
message_template="表 '{table}' 使用了特殊类型 '{sp_type}',建议评估替代方案",
|
||
))
|
||
def check_mysql_ddl_027(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许添加bit,enum,set类型字段(默认不建议)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
columns = _extract_column_definitions(sql_upper)
|
||
special_types = {"BIT", "ENUM", "SET"}
|
||
for col in columns:
|
||
if col["type"].upper() in special_types:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, sp_type=col["type"]),
|
||
))
|
||
|
||
|
||
# ── DDL-028: 指定表名前缀 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-028",
|
||
name="DDLTablePrefix",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="表名必须以指定前缀开头",
|
||
config={"enabled": True, "prefix": "t_"},
|
||
message_template="表名 '{table}' 不符合前缀规范,必须以 '{prefix}' 开头",
|
||
))
|
||
def check_mysql_ddl_028(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
|
||
prefix = rule.config.get("prefix", "t_")
|
||
table_name = _extract_table_name(sql_upper, sql_clean)
|
||
if table_name and not table_name.lower().startswith(prefix.lower()):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, prefix=prefix),
|
||
))
|
||
|
||
|
||
# ── DDL-029: 允许的Collate范围 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-029",
|
||
name="SupportCollation",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="表字符集排序规则必须在允许的范围内",
|
||
config={"enabled": True, "allowed_collations": ["utf8mb4_general_ci", "utf8mb4_unicode_ci"]},
|
||
message_template="表 '{table}' 使用了不允许的排序规则 '{collation}',允许的规则: {allowed}",
|
||
))
|
||
def check_mysql_ddl_029(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许的Collate范围。"""
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
charset_info = _extract_charset_info(sql_upper)
|
||
collation = charset_info.get("collation")
|
||
allowed = rule.config.get("allowed_collations", [])
|
||
if collation and allowed and collation.lower() not in [a.lower() for a in allowed]:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(
|
||
table=table_name, collation=collation, allowed=", ".join(allowed),
|
||
),
|
||
))
|
||
|
||
|
||
# ── DDL-030: 允许的Charset范围 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-030",
|
||
name="SupportCharset",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="表字符集必须在允许的范围内",
|
||
config={"enabled": True, "allowed_charsets": ["utf8mb4"]},
|
||
message_template="表 '{table}' 使用了不允许的字符集 '{charset}',允许的字符集: {allowed}",
|
||
))
|
||
def check_mysql_ddl_030(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许的Charset范围。"""
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
charset_info = _extract_charset_info(sql_upper)
|
||
charset = charset_info.get("charset")
|
||
allowed = rule.config.get("allowed_charsets", ["utf8mb4"])
|
||
if charset and allowed and charset.lower() not in [a.lower() for a in allowed]:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(
|
||
table=table_name, charset=charset, allowed=", ".join(allowed),
|
||
),
|
||
))
|
||
|
||
|
||
# ── DDL-031: 建表必须拥有的字段 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-031",
|
||
name="MustHaveColumns",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="建表时必须包含指定的字段",
|
||
config={"enabled": True, "required_columns": ["id", "created_at", "updated_at"]},
|
||
message_template="表 '{table}' 缺少必须字段 '{column}'",
|
||
))
|
||
def check_mysql_ddl_031(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
required = rule.config.get("required_columns", [])
|
||
columns = _extract_column_definitions(sql_upper)
|
||
column_names = {c["name"].lower() for c in columns}
|
||
for req_col in required:
|
||
if req_col.lower() not in column_names:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, column=req_col),
|
||
))
|
||
|
||
|
||
# ── DDL-032: 单个索引指定字段上限 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-032",
|
||
name="DDLMaxKeyParts",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="单个索引最多允许包含的字段数",
|
||
config={"enabled": True, "max_parts": 8},
|
||
message_template="表 '{table}' 的索引 '{idx_name}' 包含 {count} 个字段,超过上限 {max_parts}",
|
||
))
|
||
def check_mysql_ddl_032(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
max_parts = rule.config.get("max_parts", 8)
|
||
indexes = _extract_index_definitions(sql_upper)
|
||
for idx in indexes:
|
||
col_count = len(idx.get("columns", []))
|
||
if col_count > max_parts:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(
|
||
table=table_name, idx_name=idx["name"] or "(unnamed)",
|
||
count=col_count, max_parts=max_parts,
|
||
),
|
||
))
|
||
|
||
|
||
# ── DDL-033: 单个表最多允许几个索引 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-033",
|
||
name="DDLMaxKey",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="单个表最多允许创建的索引数量",
|
||
config={"enabled": True, "max_index": 20},
|
||
message_template="表 '{table}' 包含 {count} 个索引,超过上限 {max_idx}",
|
||
))
|
||
def check_mysql_ddl_033(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
max_idx = rule.config.get("max_index", 12)
|
||
indexes = _extract_index_definitions(sql_upper)
|
||
idx_count = len([i for i in indexes if i["type"] != "FOREIGN"])
|
||
if idx_count > max_idx:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, count=idx_count, max_idx=max_idx),
|
||
))
|
||
|
||
|
||
# ── DDL-034: DDL最大影响行数 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-034",
|
||
name="MaxDDLAffectRows",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="DDL操作影响的最大行数限制(0表示不限制)",
|
||
config={"enabled": False, "max_rows": 0},
|
||
message_template="DDL操作可能影响 {rows} 行,超过上限 {max_rows}",
|
||
))
|
||
def check_mysql_ddl_034(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""DDL最大影响行数(此规则作为配置项存在,实际行数需运行时检查)。"""
|
||
max_rows = rule.config.get("max_rows", 0)
|
||
if max_rows == 0:
|
||
return
|
||
# 此规则的实际检查需要数据库连接获取表行数
|
||
# 这里仅做语句级别的标记提示
|
||
if re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=f"ALTER TABLE '{table_name}' 需要确认影响行数不超过 {max_rows}",
|
||
))
|
||
|
||
|
||
# ── DDL-035: char字段最大长度 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-035",
|
||
name="DDLMaxCharLength",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="CHAR 类型字段的最大长度限制",
|
||
config={"enabled": True, "max_length": 255},
|
||
message_template="表 '{table}' 的字段 '{column}' CHAR({length}) 超过最大长度限制 {max_len}",
|
||
))
|
||
def check_mysql_ddl_035(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""char字段最大长度。"""
|
||
if not re.search(r"\bCREATE\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
max_len = rule.config.get("max_length", 10)
|
||
paren_match = re.search(r"\((.*)\)", 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())
|
||
|
||
for part in parts:
|
||
if re.match(r"^(PRIMARY\s+KEY|FOREIGN\s+KEY|INDEX|KEY|UNIQUE|CONSTRAINT|CHECK)\b", part, re.IGNORECASE):
|
||
continue
|
||
char_match = re.search(r"[`\"']?(\w+)[`\"']?\s+CHAR\s*\((\d+)\)", part, re.IGNORECASE)
|
||
if char_match:
|
||
col_name = char_match.group(1)
|
||
length = int(char_match.group(2))
|
||
if length > max_len:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(
|
||
table=table_name, column=col_name, length=length, max_len=max_len,
|
||
),
|
||
))
|
||
|
||
|
||
# ── DDL-036: 表名最大长度限制 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DDL-036",
|
||
name="MaxTableNameLen",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="表名最大长度限制",
|
||
config={"enabled": True, "max_length": 45},
|
||
message_template="表名 '{table}' 长度为 {length},超过最大长度限制 {max_len}",
|
||
))
|
||
def check_mysql_ddl_036(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean)
|
||
if not table_name:
|
||
return
|
||
max_len = rule.config.get("max_length", 45)
|
||
if len(table_name) > max_len:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, length=len(table_name), max_len=max_len),
|
||
))
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ══════════════════════════════════════════════
|
||
# DML 规则 (9条) — MYSQL-DML-001 ~ MYSQL-DML-009
|
||
# ══════════════════════════════════════════════
|
||
# ──────────────────────────────────────────────
|
||
|
||
|
||
# ── DML-001: Insert最大插入行数上限 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-001",
|
||
name="DMLMaxInsertRows",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="INSERT 语句单次插入的最大行数限制",
|
||
config={"enabled": True, "max_rows": 500},
|
||
message_template="INSERT 语句插入 {count} 行,超过最大限制 {max_rows} 行,请分批插入",
|
||
))
|
||
def check_mysql_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_rows", 500)
|
||
row_count = _count_insert_rows(sql_clean)
|
||
if row_count == -1:
|
||
# INSERT ... SELECT 无法确定行数
|
||
return
|
||
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_rows=max_rows),
|
||
))
|
||
|
||
|
||
# ── DML-002: 允许update/insert语句使用limit关键字 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-002",
|
||
name="DMLAllowLimitSTMT",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="默认不允许 UPDATE/DELETE 语句使用 LIMIT 关键字",
|
||
config={"enabled": False},
|
||
message_template="{stmt_type} 语句禁止使用 LIMIT 关键字",
|
||
))
|
||
def check_mysql_dml_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许update/insert语句使用limit关键字(默认不允许)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if not re.search(r"\b(UPDATE|DELETE)\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
if _has_limit_clause(sql_upper):
|
||
stmt_type = "UPDATE" if re.search(r"\bUPDATE\b", sql_upper, re.IGNORECASE) else "DELETE"
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(stmt_type=stmt_type),
|
||
))
|
||
|
||
|
||
# ── DML-003: 允许insert语句插入Null值 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-003",
|
||
name="DMLAllowInsertNull",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="默认不建议 INSERT 语句显式插入 NULL 值",
|
||
config={"enabled": False},
|
||
message_template="INSERT 语句中包含 NULL 值插入,建议确认必要性",
|
||
))
|
||
def check_mysql_dml_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""允许insert语句插入Null值(默认不建议)。"""
|
||
if rule.config.get("enabled", False):
|
||
return
|
||
if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
# 匹配 VALUES (...NULL...) 中的 NULL
|
||
values_section = re.search(r"\bVALUES\s+\((.*?)\)", sql_upper, re.IGNORECASE | re.DOTALL)
|
||
if values_section and "NULL" in values_section.group(1).upper():
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ── DML-004: 不允许隐式转换 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-004",
|
||
name="DDLImplicitTypeConversion",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="WHERE 条件中避免隐式类型转换",
|
||
config={"enabled": True},
|
||
message_template="WHERE 条件中字段 '{column}' 可能存在隐式类型转换,请确保类型一致",
|
||
))
|
||
def check_mysql_dml_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""不允许隐式转换(检测WHERE中可能的类型不匹配)。"""
|
||
if not _has_where_clause(sql_upper):
|
||
return
|
||
# 简单检测:数字字段与字符串值比较,或函数字段使用
|
||
where_match = re.search(r"\bWHERE\b(.+?)(?:\s+ORDER\s+BY|\s+GROUP\s+BY|\s+LIMIT|\s+HAVING|$)",
|
||
sql_upper, re.IGNORECASE | re.DOTALL)
|
||
if not where_match:
|
||
return
|
||
where_clause = where_match.group(1)
|
||
# 检测函数包裹字段(常见隐式转换场景)
|
||
func_patterns = [
|
||
r"\b(CONVERT|CAST)\s*\(",
|
||
r"\bDATE\s*\(",
|
||
r"\bYEAR\s*\(",
|
||
r"\bMONTH\s*\(",
|
||
]
|
||
for pattern in func_patterns:
|
||
if re.search(pattern, where_clause, re.IGNORECASE):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(column="(函数包装字段)"),
|
||
))
|
||
|
||
|
||
# ── DML-005: 检查Insert语句中插入的字段名是否存在 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-005",
|
||
name="DMLInsertColumns",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="INSERT 语句必须显式指定字段名",
|
||
config={"enabled": True},
|
||
message_template="INSERT 语句必须显式指定插入的字段名,禁止使用 INSERT INTO table VALUES (...)",
|
||
))
|
||
def check_mysql_dml_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""检查Insert语句中插入的字段名是否存在。"""
|
||
if not re.search(r"\bINSERT\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
insert_cols = _extract_insert_columns(sql_upper)
|
||
if not insert_cols:
|
||
# 检查是否是 INSERT INTO table VALUES (没有指定字段)
|
||
if re.search(r"\bINSERT\b.+?\bVALUES\b", sql_upper, re.IGNORECASE) and \
|
||
not re.search(r"\bINSERT\b.+?\([^)]+\)\s*\bVALUES\b", sql_upper, re.IGNORECASE):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ── DML-006: 强制DML语句必须拥有where条件 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-006",
|
||
name="DMLWhere",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="UPDATE/DELETE 语句必须包含 WHERE 条件",
|
||
config={"enabled": True},
|
||
message_template="{stmt_type} 语句必须包含 WHERE 条件,禁止全表操作",
|
||
))
|
||
def check_mysql_dml_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""强制DML语句必须拥有where条件。"""
|
||
if not re.search(r"\b(UPDATE|DELETE)\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
if not _has_where_clause(sql_upper):
|
||
stmt_type = "UPDATE" if re.search(r"\bUPDATE\b", sql_upper, re.IGNORECASE) else "DELETE"
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(stmt_type=stmt_type),
|
||
))
|
||
|
||
|
||
# ── DML-007: 禁止DML语句使用Order by子句 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-007",
|
||
name="DMLOrder",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="UPDATE/DELETE 语句禁止使用 ORDER BY 子句",
|
||
config={"enabled": True},
|
||
message_template="{stmt_type} 语句禁止使用 ORDER BY 子句",
|
||
))
|
||
def check_mysql_dml_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""禁止DML语句使用Order by子句。"""
|
||
if not re.search(r"\b(UPDATE|DELETE)\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
if _has_order_by(sql_upper):
|
||
stmt_type = "UPDATE" if re.search(r"\bUPDATE\b", sql_upper, re.IGNORECASE) else "DELETE"
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(stmt_type=stmt_type),
|
||
))
|
||
|
||
|
||
# ── DML-008: 禁止DML语句使用Select子句 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-008",
|
||
name="DMLSelect",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="UPDATE/DELETE 语句禁止使用子查询(SELECT 子句)",
|
||
config={"enabled": True},
|
||
message_template="{stmt_type} 语句禁止使用 SELECT 子查询",
|
||
))
|
||
def check_mysql_dml_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""禁止DML语句使用Select子句。"""
|
||
if not re.search(r"\b(UPDATE|DELETE)\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
if re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE):
|
||
stmt_type = "UPDATE" if re.search(r"\bUPDATE\b", sql_upper, re.IGNORECASE) else "DELETE"
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(stmt_type=stmt_type),
|
||
))
|
||
|
||
|
||
# ── DML-009: DML最大影响行数 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-DML-009",
|
||
name="MaxAffectRows",
|
||
category=RuleCategory.DML,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="DML 操作影响的最大行数限制(0表示不限制)",
|
||
config={"enabled": False, "max_rows": 0},
|
||
message_template="DML操作可能影响 {rows} 行,超过上限 {max_rows}",
|
||
))
|
||
def check_mysql_dml_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""DML最大影响行数(此规则作为配置项存在,实际行数需运行时检查)。"""
|
||
max_rows = rule.config.get("max_rows", 0)
|
||
if max_rows == 0:
|
||
return
|
||
# 仅作标记提示,实际行数需要在执行时检查
|
||
if _is_dml_statement(sql_upper):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=f"DML语句需要确认影响行数不超过 {max_rows}",
|
||
))
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ══════════════════════════════════════════════
|
||
# Online-DDL 规则 (3条) — MYSQL-OSC-001 ~ MYSQL-OSC-003
|
||
# ══════════════════════════════════════════════
|
||
# ──────────────────────────────────────────────
|
||
|
||
|
||
# ── OSC-001: 开启在线表变更工具 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-OSC-001",
|
||
name="IsOSC",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.INFO,
|
||
db_type="mysql",
|
||
description="建议使用在线表变更工具(pt-osc/gh-ost)执行大表DDL",
|
||
config={"enabled": True, "tool": "gh-ost"},
|
||
message_template="表 '{table}' 的DDL操作建议使用 {tool} 在线变更工具执行",
|
||
))
|
||
def check_mysql_osc_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""开启在线表变更工具建议。"""
|
||
if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
tool = rule.config.get("tool", "gh-ost")
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, tool=tool),
|
||
))
|
||
|
||
|
||
# ── OSC-002: 表体积大于该值后触发在线表变更同步 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-OSC-002",
|
||
name="OscSize",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="表体积超过阈值(MB)后必须使用在线变更工具",
|
||
config={"enabled": True, "size_mb": 100},
|
||
message_template="表 '{table}' 预估体积 {size}MB,超过阈值 {threshold}MB,必须使用在线变更工具",
|
||
))
|
||
def check_mysql_osc_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""表体积大于阈值触发在线表变更建议。"""
|
||
if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
threshold = rule.config.get("size_mb", 100)
|
||
# 此规则实际需要在数据库中查询表大小,这里仅作提示
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=f"ALTER TABLE '{table_name}' 请确认表体积是否超过 {threshold}MB,大表请使用在线变更工具",
|
||
))
|
||
|
||
|
||
# ── OSC-003: 同步工具参数配置 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-OSC-003",
|
||
name="OSCExpr",
|
||
category=RuleCategory.DDL,
|
||
severity=Severity.INFO,
|
||
db_type="mysql",
|
||
description="在线变更工具参数配置检查",
|
||
config={"enabled": True, "chunk_size": 1000, "max_load": "Threads_running=80"},
|
||
message_template="请确认在线变更工具参数: chunk_size={chunk_size}, max_load={max_load}",
|
||
))
|
||
def check_mysql_osc_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""同步工具参数配置检查。"""
|
||
if not re.search(r"\bALTER\s+TABLE\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
chunk_size = rule.config.get("chunk_size", 1000)
|
||
max_load = rule.config.get("max_load", "Threads_running=80")
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(chunk_size=chunk_size, max_load=max_load),
|
||
))
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ══════════════════════════════════════════════
|
||
# 命名规则 (5条) — MYSQL-NAM-001 ~ MYSQL-NAM-005
|
||
# ══════════════════════════════════════════════
|
||
# ──────────────────────────────────────────────
|
||
|
||
|
||
# ── NAM-001: 表名小写下划线规范 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-NAM-001",
|
||
name="TableNamingCase",
|
||
category=RuleCategory.NAMING,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="表名必须使用小写下划线命名法(snake_case)",
|
||
config={"enabled": True},
|
||
message_template="表名 '{table}' 不符合命名规范,必须使用小写下划线命名(snake_case)",
|
||
))
|
||
def check_mysql_nam_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
|
||
table_name = _extract_table_name(sql_upper, sql_clean)
|
||
if table_name and not _is_snake_case(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),
|
||
))
|
||
|
||
|
||
# ── NAM-002: 字段名小写下划线规范 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-NAM-002",
|
||
name="ColumnNamingCase",
|
||
category=RuleCategory.NAMING,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="字段名必须使用小写下划线命名法(snake_case)",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的字段 '{column}' 不符合命名规范,必须使用小写下划线命名(snake_case)",
|
||
))
|
||
def check_mysql_nam_002(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
columns = _extract_column_definitions(sql_upper)
|
||
for col in columns:
|
||
if not _is_snake_case(col["name"]):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, column=col["name"]),
|
||
))
|
||
|
||
|
||
# ── NAM-003: 索引名小写下划线规范 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-NAM-003",
|
||
name="IndexNamingCase",
|
||
category=RuleCategory.NAMING,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="索引名必须使用小写下划线命名法",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 的索引 '{idx_name}' 不符合命名规范,必须使用小写下划线命名",
|
||
))
|
||
def check_mysql_nam_003(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
indexes = _extract_index_definitions(sql_upper)
|
||
for idx in indexes:
|
||
if idx["name"] and not _is_snake_case(idx["name"]):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, idx_name=idx["name"]),
|
||
))
|
||
|
||
|
||
# ── NAM-004: 表名数字开头检查 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-NAM-004",
|
||
name="TableNamingNoDigitPrefix",
|
||
category=RuleCategory.NAMING,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="表名不能以数字开头",
|
||
config={"enabled": True},
|
||
message_template="表名 '{table}' 不能以数字开头",
|
||
))
|
||
def check_mysql_nam_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
|
||
table_name = _extract_table_name(sql_upper, sql_clean)
|
||
if table_name and re.match(r"^\d", 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),
|
||
))
|
||
|
||
|
||
# ── NAM-005: 字段名长度限制 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-NAM-005",
|
||
name="MaxColumnNameLength",
|
||
category=RuleCategory.NAMING,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="字段名最大长度限制",
|
||
config={"enabled": True, "max_length": 30},
|
||
message_template="表 '{table}' 的字段 '{column}' 名长度为 {length},超过限制 {max_len}",
|
||
))
|
||
def check_mysql_nam_005(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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
max_len = rule.config.get("max_length", 30)
|
||
columns = _extract_column_definitions(sql_upper)
|
||
for col in columns:
|
||
if len(col["name"]) > max_len:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(
|
||
table=table_name, column=col["name"], length=len(col["name"]), max_len=max_len,
|
||
),
|
||
))
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ══════════════════════════════════════════════
|
||
# 性能规则 (5条) — MYSQL-PERF-001 ~ MYSQL-PERF-005
|
||
# ══════════════════════════════════════════════
|
||
# ──────────────────────────────────────────────
|
||
|
||
|
||
# ── PERF-001: 禁止SELECT * ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-PERF-001",
|
||
name="NoSelectAll",
|
||
category=RuleCategory.PERFORMANCE,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="禁止 SELECT * 查询,必须显式指定字段",
|
||
config={"enabled": True},
|
||
message_template="禁止使用 SELECT *,请显式指定查询字段",
|
||
))
|
||
def check_mysql_perf_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+\*\b", sql_upper, re.IGNORECASE):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ── PERF-002: 缺少LIMIT的大查询检查 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-PERF-002",
|
||
name="SelectLimitRequired",
|
||
category=RuleCategory.PERFORMANCE,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="SELECT 查询建议添加 LIMIT 子句",
|
||
config={"enabled": True, "max_limit": 1000},
|
||
message_template="SELECT 查询缺少 LIMIT 子句,建议添加 LIMIT {max_limit}",
|
||
))
|
||
def check_mysql_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""缺少LIMIT的大查询检查。"""
|
||
if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
if _is_dml_statement(sql_upper):
|
||
return
|
||
if not _has_limit_clause(sql_upper):
|
||
max_limit = rule.config.get("max_limit", 1000)
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(max_limit=max_limit),
|
||
))
|
||
|
||
|
||
# ── PERF-003: 全表扫描风险 — WHERE条件缺少索引字段 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-PERF-003",
|
||
name="FullTableScanRisk",
|
||
category=RuleCategory.PERFORMANCE,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="UPDATE/DELETE 的 WHERE 条件应尽量使用索引字段",
|
||
config={"enabled": True},
|
||
message_template="{stmt_type} 语句 WHERE 条件 '{where_clause}' 可能无法使用索引,存在全表扫描风险",
|
||
))
|
||
def check_mysql_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""全表扫描风险检查。"""
|
||
if not re.search(r"\b(UPDATE|DELETE|SELECT)\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
if not _has_where_clause(sql_upper):
|
||
return
|
||
# 检测 WHERE 中使用函数或 LIKE '%xxx%' 等无法使用索引的模式
|
||
where_match = re.search(r"\bWHERE\b(.+?)(?:\s+ORDER\s+BY|\s+GROUP\s+BY|\s+LIMIT|\s+HAVING|$)",
|
||
sql_upper, re.IGNORECASE | re.DOTALL)
|
||
if not where_match:
|
||
return
|
||
where_clause = where_match.group(1).strip()
|
||
# 检测 LIKE '%xxx' 或 LIKE '%xxx%'
|
||
if re.search(r"\bLIKE\s+['\"]%", where_clause, re.IGNORECASE):
|
||
stmt_type_match = re.search(r"\b(UPDATE|DELETE|SELECT)\b", sql_upper, re.IGNORECASE)
|
||
stmt_type = stmt_type_match.group(1).upper() if stmt_type_match else "SQL"
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(stmt_type=stmt_type, where_clause=where_clause[:50]),
|
||
))
|
||
|
||
|
||
# ── PERF-004: 大IN子句检查 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-PERF-004",
|
||
name="LargeInClause",
|
||
category=RuleCategory.PERFORMANCE,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="IN 子句中的值数量不应超过阈值",
|
||
config={"enabled": True, "max_in_values": 100},
|
||
message_template="IN 子句包含 {count} 个值,超过建议上限 {max_in}",
|
||
))
|
||
def check_mysql_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""大IN子句检查。"""
|
||
max_in = rule.config.get("max_in_values", 100)
|
||
in_counts = []
|
||
for match in re.finditer(r"\bIN\s*\(([^)]+)\)", sql_upper, re.IGNORECASE):
|
||
inner = match.group(1)
|
||
if inner.strip():
|
||
count = inner.count(",") + 1
|
||
in_counts.append(count)
|
||
for count in in_counts:
|
||
if count > max_in:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(count=count, max_in=max_in),
|
||
))
|
||
|
||
|
||
# ── PERF-005: 多表JOIN检查 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-PERF-005",
|
||
name="ExcessiveJoins",
|
||
category=RuleCategory.PERFORMANCE,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="JOIN 表数量不应超过阈值",
|
||
config={"enabled": True, "max_joins": 5},
|
||
message_template="查询包含 {count} 个 JOIN,超过建议上限 {max_joins}",
|
||
))
|
||
def check_mysql_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""多表JOIN检查。"""
|
||
if not re.search(r"\bSELECT\b", sql_upper, re.IGNORECASE):
|
||
return
|
||
join_count = len(re.findall(r"\bJOIN\b", sql_upper, re.IGNORECASE))
|
||
max_joins = rule.config.get("max_joins", 5)
|
||
if join_count > max_joins:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(count=join_count, max_joins=max_joins),
|
||
))
|
||
|
||
|
||
# ──────────────────────────────────────────────
|
||
# ══════════════════════════════════════════════
|
||
# 安全规则 (5条) — MYSQL-SEC-001 ~ MYSQL-SEC-005
|
||
# ══════════════════════════════════════════════
|
||
# ──────────────────────────────────────────────
|
||
|
||
|
||
# ── SEC-001: 敏感字段检测 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-001",
|
||
name="SensitiveColumnDetection",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="禁止在表定义中使用敏感字段名(如password, secret等)",
|
||
config={"enabled": True},
|
||
message_template="表 '{table}' 包含敏感字段名 '{column}',请确认是否符合安全规范",
|
||
))
|
||
def check_mysql_sec_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
|
||
table_name = _extract_table_name(sql_upper, sql_clean) or "unknown"
|
||
sensitive_patterns = ["password", "passwd", "secret", "token", "api_key", "apikey", "credential", "auth"]
|
||
columns = _extract_column_definitions(sql_upper)
|
||
for col in columns:
|
||
col_lower = col["name"].lower()
|
||
for pattern in sensitive_patterns:
|
||
if pattern in col_lower:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(table=table_name, column=col["name"]),
|
||
))
|
||
|
||
|
||
# ── SEC-002: SQL注入风险 — 字符串拼接检查 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-002",
|
||
name="SQLInjectionRisk",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="检测SQL中可能的注入风险(如字符串拼接占位符)",
|
||
config={"enabled": True},
|
||
message_template="SQL语句可能存在注入风险,请使用参数化查询",
|
||
))
|
||
def check_mysql_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""SQL注入风险检查。"""
|
||
# 检测 ${xxx} 或 #{xxx} 等模板占位符(不应出现在最终SQL中)
|
||
if re.search(r"\$\{[^}]+\}", sql_clean) or re.search(r"#\{[^}]+\}", sql_clean):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
# 检测字符串拼接模式
|
||
if "' || '" in sql_clean or "' + '" in sql_clean or "CONCAT(" in sql_upper:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ── SEC-003: 明文密码检测 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-003",
|
||
name="PlaintextPassword",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="禁止 INSERT/UPDATE 中设置明文密码值",
|
||
config={"enabled": True},
|
||
message_template="禁止在SQL中写入明文密码,请使用加密后的值",
|
||
))
|
||
def check_mysql_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""明文密码检测。"""
|
||
if not _is_dml_statement(sql_upper):
|
||
return
|
||
# 检测 password/secret 相关的值设置
|
||
if re.search(r"\b(password|secret|passwd)\b\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,
|
||
))
|
||
|
||
|
||
# ── SEC-004: 注释符检查 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-004",
|
||
name="NoCommentInSQL",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.WARNING,
|
||
db_type="mysql",
|
||
description="SQL中不应包含注释(可能是SQL注入手法)",
|
||
config={"enabled": True},
|
||
message_template="SQL中包含注释符,请移除: {comment}",
|
||
))
|
||
def check_mysql_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""注释符检查。"""
|
||
if "--" in sql_clean:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(comment="--"),
|
||
))
|
||
if "/*" in sql_clean or "*/" in sql_clean:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template.format(comment="/* */"),
|
||
))
|
||
|
||
|
||
# ── SEC-005: 多语句检查 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-005",
|
||
name="NoMultiStatement",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="禁止单条输入中包含多条SQL语句(分号分隔)",
|
||
config={"enabled": True},
|
||
message_template="禁止提交多条SQL语句,请拆分为多个工单",
|
||
))
|
||
def check_mysql_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""多语句检查。"""
|
||
stripped = sql_clean.rstrip(";").strip()
|
||
if ";" in stripped:
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code,
|
||
rule_name=rule.name,
|
||
level=rule.severity,
|
||
message=rule.message_template,
|
||
))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# 模块总结
|
||
# ── SEC-006: 权限相关高危语句拦截 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-006",
|
||
name="DangerousPrivilegeStatement",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="拦截 GRANT ALL / REVOKE ALL 等全局权限变更操作",
|
||
config={"enabled": True},
|
||
message_template="禁止执行 '{keyword}' 全局权限操作,请联系 DBA 通过权限管理流程申请",
|
||
))
|
||
def check_mysql_sec_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""全局权限语句拦截。"""
|
||
# GRANT ALL / GRANT ALL PRIVILEGES
|
||
if re.search(r'\bGRANT\s+ALL\b', sql_upper):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||
message=rule.message_template.format(keyword="GRANT ALL"),
|
||
))
|
||
# REVOKE ALL
|
||
elif re.search(r'\bREVOKE\s+ALL\b', sql_upper):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||
message=rule.message_template.format(keyword="REVOKE ALL"),
|
||
))
|
||
|
||
|
||
# ── SEC-007: LOAD DATA / LOAD XML 拦截 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-007",
|
||
name="BlockLoadData",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="禁止 LOAD DATA / LOAD XML 操作,存在安全风险且可能导入大量数据",
|
||
config={"enabled": True},
|
||
message_template="禁止使用 '{keyword}',该操作存在安全风险,请使用 INSERT 批量导入",
|
||
))
|
||
def check_mysql_sec_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""LOAD DATA/LOAD XML 拦截。"""
|
||
if re.search(r'\bLOAD\s+DATA\b', sql_upper):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||
message=rule.message_template.format(keyword="LOAD DATA"),
|
||
))
|
||
elif re.search(r'\bLOAD\s+XML\b', sql_upper):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||
message=rule.message_template.format(keyword="LOAD XML"),
|
||
))
|
||
|
||
|
||
# ── SEC-008: SLEEP / BENCHMARK 注入拦截 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-008",
|
||
name="BlockTimeBasedInjection",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="拦截 SLEEP() / BENCHMARK() 等时间盲注函数",
|
||
config={"enabled": True},
|
||
message_template="禁止使用 '{func}()' 函数,疑似时间盲注攻击",
|
||
))
|
||
def check_mysql_sec_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""时间盲注函数拦截。"""
|
||
if re.search(r'\bSLEEP\s*\(', sql_upper):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||
message=rule.message_template.format(func="SLEEP"),
|
||
))
|
||
if re.search(r'\bBENCHMARK\s*\(', sql_upper):
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||
message=rule.message_template.format(func="BENCHMARK"),
|
||
))
|
||
|
||
|
||
# ── SEC-009: INTO OUTFILE / INTO DUMPFILE 拦截 ──
|
||
@register_rule(AuditRuleV2(
|
||
code="MYSQL-SEC-009",
|
||
name="BlockIntoOutfile",
|
||
category=RuleCategory.SECURITY,
|
||
severity=Severity.ERROR,
|
||
db_type="mysql",
|
||
description="禁止 INTO OUTFILE / INTO DUMPFILE,防止写入 WebShell 或导出敏感数据",
|
||
config={"enabled": True},
|
||
message_template="禁止使用 '{keyword}',该操作可写入文件系统,存在严重安全风险",
|
||
))
|
||
def check_mysql_sec_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||
"""INTO OUTFILE/DUMPFILE 拦截。"""
|
||
if re.search(r'\bINTO\s+(OUT|DUMP)FILE\b', sql_upper):
|
||
keyword = "INTO OUTFILE" if "OUTFILE" in sql_upper else "INTO DUMPFILE"
|
||
report.findings.append(AuditFindingV2(
|
||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||
message=rule.message_template.format(keyword=keyword),
|
||
))
|
||
|
||
|
||
# ═══════════════════════════════════════════════════
|
||
# 总计 67 条规则:
|
||
# - DDL 规则: 36 条 (MYSQL-DDL-001 ~ MYSQL-DDL-036)
|
||
# - DML 规则: 9 条 (MYSQL-DML-001 ~ MYSQL-DML-009)
|
||
# - Online-DDL 规则: 3 条 (MYSQL-OSC-001 ~ MYSQL-OSC-003)
|
||
# - 命名规则: 5 条 (MYSQL-NAM-001 ~ MYSQL-NAM-005)
|
||
# - 性能规则: 5 条 (MYSQL-PERF-001 ~ MYSQL-PERF-005)
|
||
# - 安全规则: 9 条 (MYSQL-SEC-001 ~ MYSQL-SEC-009)
|
||
# ═══════════════════════════════════════════════════
|