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

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

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

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

1639 lines
64 KiB
Python

"""MongoDB 审核规则引擎 — 42+ 条 MongoDB 特有审核规则。
支持:
- 集合管理规则 (Collection Management)
- 查询安全规则 (Query Safety)
- 聚合管道规则 (Aggregation Pipeline)
- 写入安全规则 (Write Safety)
- 安全规则 (Security)
- 性能规则 (Performance)
使用方式:
engine = SQLAuditEngineV2(db_session)
report = await engine.audit(
'{"find": "users", "filter": {"age": {"$gt": 18}}}',
db_type="mongodb"
)
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Optional, Tuple
from app.services.audit_engine_v2 import (
register_rule,
AuditRuleV2,
AuditFindingV2,
AuditReportV2,
Severity,
RuleCategory,
)
# ──────────────────────────────────────────────
# JSON / BSON 辅助解析函数
# ──────────────────────────────────────────────
def _try_parse_json(sql_clean: str) -> Tuple[bool, Dict[str, Any]]:
"""尝试解析 MongoDB 查询为 JSON 对象。
Returns:
(success, parsed_dict) — 解析成功返回 (True, dict),失败返回 (False, {})
"""
# 去除首尾空白
text = sql_clean.strip()
if not text:
return False, {}
# 如果文本被单引号包裹,去除外层引号
if text.startswith("'") and text.endswith("'"):
text = text[1:-1]
if text.startswith('"') and text.endswith('"') and len(text) > 1:
inner = text[1:-1]
# 检查内部是否是有效的 JSON(避免普通字符串被误剥)
if inner.strip().startswith("{"):
text = inner
# 去除末尾可能存在的分号
text = text.rstrip(";").strip()
if not text:
return False, {}
try:
parsed = json.loads(text)
if isinstance(parsed, dict):
return True, parsed
return False, {}
except (json.JSONDecodeError, ValueError):
return False, {}
def _is_json_mode(sql_clean: str) -> bool:
"""检查是否为 JSON 格式的 MongoDB 查询。
MongoDB 查询通常是 JSON 格式(以 { 开头),
而 shell 命令如 db.collection.find() 以 db. 或 javascript 开头。
"""
text = sql_clean.strip()
if not text:
return False
# 标准的 JSON 查询格式 {"find": "collection", ...}
if text.startswith("{"):
return True
# 或者 shell 命令中带有 { } 参数
if "{" in text and "}" in text:
return True
return False
def _is_shell_command(sql_clean: str) -> bool:
"""检查是否为 MongoDB shell 命令(如 db.collection.find())。"""
text = sql_clean.strip()
return text.startswith("db.") or text.startswith("sh.")
def _extract_collection_name(sql_clean: str) -> Optional[str]:
"""从 MongoDB 查询中提取集合名称。
支持格式:
- JSON: {"find": "collection_name", ...}
- Shell: db.collection_name.find(...)
- Shell: db.getCollection("name").find(...)
"""
# 尝试 JSON 格式
success, parsed = _try_parse_json(sql_clean)
if success:
# {"find": "users", ...}
if "find" in parsed and isinstance(parsed["find"], str):
return parsed["find"]
# {"insert": "users", ...}
for op in ("insert", "update", "delete", "aggregate", "count", "distinct",
"findOne", "insertOne", "updateOne", "deleteOne",
"insertMany", "updateMany", "deleteMany"):
if op in parsed and isinstance(parsed[op], str):
return parsed[op]
return None
# Shell 格式: db.collection_name.operation()
text = sql_clean.strip()
# db.getCollection("name").method(...)
gc_match = re.search(r'\bgetCollection\s*\(\s*["\'](\w+)["\']\s*\)', text)
if gc_match:
return gc_match.group(1)
# db.name.method(...)
shell_match = re.search(r'\bdb\.(\w+)\.', text)
if shell_match:
return shell_match.group(1)
return None
def _has_where_operator(sql_clean: str) -> bool:
"""检查是否使用了 $where 运算符(注入风险)。"""
return "\"$where\"" in sql_clean or "'$where'" in sql_clean
def _has_function_operator(sql_clean: str) -> bool:
"""检查是否使用了 $function 运算符。"""
return "\"$function\"" in sql_clean or "'$function'" in sql_clean
def _count_json_keys(obj: Any) -> int:
"""递归计算 JSON 对象中的键总数(估算文档大小)。"""
if isinstance(obj, dict):
return sum(1 + _count_json_keys(v) for k, v in obj.items())
elif isinstance(obj, list):
return sum(_count_json_keys(item) for item in obj)
return 1
def _get_nesting_depth(obj: Any, current_depth: int = 0) -> int:
"""计算 JSON 对象的最大嵌套深度。"""
if isinstance(obj, dict):
if not obj:
return current_depth
return max(_get_nesting_depth(v, current_depth + 1) for v in obj.values())
elif isinstance(obj, list):
if not obj:
return current_depth
return max(_get_nesting_depth(item, current_depth + 1) for item in obj)
return current_depth
def _find_all_values(obj: Any, key: str) -> List[Any]:
"""递归查找 JSON 对象中所有指定 key 的值。"""
results: List[Any] = []
if isinstance(obj, dict):
for k, v in obj.items():
if k == key:
results.append(v)
results.extend(_find_all_values(v, key))
elif isinstance(obj, list):
for item in obj:
results.extend(_find_all_values(item, key))
return results
def _has_key_recursive(obj: Any, key: str) -> bool:
"""递归检查 JSON 对象是否包含指定 key。"""
if isinstance(obj, dict):
if key in obj:
return True
return any(_has_key_recursive(v, key) for v in obj.values())
elif isinstance(obj, list):
return any(_has_key_recursive(item, key) for item in obj)
return False
def _get_json_string_length(sql_clean: str) -> int:
"""估算 JSON 字符串的字节长度(用于文档大小估算)。"""
return len(sql_clean.encode("utf-8"))
def _count_pipeline_stages(sql_clean: str) -> int:
"""计算聚合管道中的阶段数量。"""
success, parsed = _try_parse_json(sql_clean)
if success and "pipeline" in parsed and isinstance(parsed["pipeline"], list):
return len(parsed["pipeline"])
# shell 格式中粗略统计 $ 开头的 stage
stages = re.findall(r'\{\\s*"\\$\w+"', sql_clean)
return len(stages)
def _is_snake_case(name: str) -> bool:
"""检查是否为 snake_case 命名。"""
if not name:
return False
return bool(re.match(r"^[a-z][a-z0-9_]*$", name))
# ═══════════════════════════════════════════════
# 集合管理规则 (8条) — MONGO-COL-001 ~ 008
# ═══════════════════════════════════════════════
@register_rule(AuditRuleV2(
code="MONGO-COL-001",
name="集合命名规范(snake_case)",
category=RuleCategory.DDL,
severity=Severity.WARNING,
db_type="mongodb",
description="MongoDB 集合名应使用小写下划线命名(snake_case)",
config={"pattern": "^[a-z][a-z0-9_]*$"},
message_template="集合名 '{name}' 不符合 snake_case 命名规范,应使用小写下划线格式",
))
def check_mongo_col_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""集合命名规范: snake_case。"""
if not _is_json_mode(sql_clean):
return
collection_name = _extract_collection_name(sql_clean)
if not collection_name:
return
if not _is_snake_case(collection_name):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(name=collection_name),
))
@register_rule(AuditRuleV2(
code="MONGO-COL-002",
name="集合名最大长度限制",
category=RuleCategory.DDL,
severity=Severity.WARNING,
db_type="mongodb",
description="MongoDB 集合名最大长度为 64 字节(不含前缀)",
config={"max_name_length": 64},
message_template="集合名 '{name}' 长度为 {length} 字节,超过最大限制 {max} 字节",
))
def check_mongo_col_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""集合名最大长度检查: 64 字节。"""
if not _is_json_mode(sql_clean):
return
collection_name = _extract_collection_name(sql_clean)
if not collection_name:
return
max_len = rule.config.get("max_name_length", 64)
name_bytes = len(collection_name.encode("utf-8"))
if name_bytes > max_len:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(name=collection_name, length=name_bytes, max=max_len),
))
@register_rule(AuditRuleV2(
code="MONGO-COL-003",
name="禁止删除集合(DROP)",
category=RuleCategory.DDL,
severity=Severity.ERROR,
db_type="mongodb",
description="生产环境禁止执行删除集合操作,数据将永久丢失",
config={"dangerous_ops": ["drop"]},
message_template="禁止执行删除集合操作: {op},数据将永久丢失",
))
def check_mongo_col_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止删除集合。"""
if not _is_json_mode(sql_clean):
return
success, parsed = _try_parse_json(sql_clean)
if not success:
# 检查 shell 命令
if re.search(r'\bdb\.\w+\.drop\s*\(', sql_clean):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op="db.collection.drop()"),
))
return
dangerous = rule.config.get("dangerous_ops", ["drop"])
for op in dangerous:
if op in parsed:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op=op),
))
return
@register_rule(AuditRuleV2(
code="MONGO-COL-004",
name="禁止删除数据库(DROP DATABASE)",
category=RuleCategory.DDL,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止执行 dropDatabase 操作,将删除整个数据库",
message_template="禁止执行 dropDatabase 操作,将删除整个数据库及其所有集合",
))
def check_mongo_col_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止删除数据库。"""
text = sql_clean.strip()
# 检查 JSON 格式 {"dropDatabase": 1}
success, parsed = _try_parse_json(text)
if success and "dropDatabase" in parsed:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
# 检查 shell 格式
if re.search(r'\bdb\.dropDatabase\s*\(', text):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-COL-005",
name="禁止删除索引(DROP INDEX)",
category=RuleCategory.DDL,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止执行删除索引操作,删除后查询性能可能急剧下降",
message_template="禁止执行删除索引操作: {op},删除索引可能导致查询性能急剧下降",
))
def check_mongo_col_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止删除索引。"""
text = sql_clean.strip()
# JSON 格式 {"dropIndexes": "collection", "index": "name"}
success, parsed = _try_parse_json(text)
if success and "dropIndexes" in parsed:
idx_name = parsed.get("index", "*")
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op=f'dropIndexes: {idx_name}'),
))
return
# shell 格式: db.collection.dropIndex() / dropIndexes()
if re.search(r'\b(dropIndex|dropIndexes)\s*\(', text):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op="dropIndex(s)"),
))
@register_rule(AuditRuleV2(
code="MONGO-COL-006",
name="集合文档数上限建议",
category=RuleCategory.DDL,
severity=Severity.WARNING,
db_type="mongodb",
description="单个集合文档数建议不超过 10 亿,超过应考虑分片",
config={"max_docs": 1_000_000_000},
message_template="集合文档数超过 {max} 时建议进行分片(sharding)或归档",
))
def check_mongo_col_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""集合文档数上限建议。通过 count 操作进行提示。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if success and ("count" in parsed or "estimatedDocumentCount" in parsed):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(max=rule.config["max_docs"]),
))
return
if re.search(r'\b(countDocuments|estimatedDocumentCount)\s*\(', text):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(max=rule.config["max_docs"]),
))
@register_rule(AuditRuleV2(
code="MONGO-COL-007",
name="推荐使用 Schema Validator",
category=RuleCategory.DDL,
severity=Severity.INFO,
db_type="mongodb",
description="建议在创建集合时使用 JSON Schema Validator 进行数据校验",
message_template="建议为集合添加 JSON Schema Validator 以约束文档结构",
))
def check_mongo_col_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""推荐使用 Schema Validator: 检查 create/createCollection 是否包含 validator。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
# shell 格式
if re.search(r'\b(createCollection|create)\s*\(', text):
if "validator" not in text.lower() and "\"validator\"" not in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
# JSON 格式: {"create": "coll", ...}
if "create" in parsed or "createCollection" in parsed:
if "validator" not in parsed:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-COL-008",
name="Capped Collection 大小限制",
category=RuleCategory.DDL,
severity=Severity.WARNING,
db_type="mongodb",
description="Capped Collection 应设置合理的大小限制(默认不超过 1GB)",
config={"max_capped_size": 1_073_741_824}, # 1GB
message_template="Capped Collection 大小(size)为 {size} 字节,建议不超过 {max} 字节(1GB)",
))
def check_mongo_col_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""Capped Collection 大小限制检查。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
create_op = parsed.get("create") or parsed.get("createCollection")
if not create_op:
return
capped = parsed.get("capped", False)
size = parsed.get("size")
max_size = rule.config.get("max_capped_size", 1_073_741_824)
if capped and size and isinstance(size, (int, float)) and size > max_size:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(size=size, max=max_size),
))
# ═══════════════════════════════════════════════
# 查询安全规则 (10条) — MONGO-QRY-001 ~ 010
# ═══════════════════════════════════════════════
@register_rule(AuditRuleV2(
code="MONGO-QRY-001",
name="find/delete/update 必须带 filter",
category=RuleCategory.DQL,
severity=Severity.ERROR,
db_type="mongodb",
description="MongoDB 查询/更新/删除操作必须包含 filter 条件,禁止无条件操作",
message_template="{op} 操作缺少 filter/条件,将操作全集合数据,必须指定查询条件",
))
def check_mongo_qry_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""find/delete/update 必须带 filter。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
# 检查各类操作
ops_with_filter = {
"find": "filter",
"findOne": "filter",
"update": "updates",
"updateOne": "filter",
"updateMany": "filter",
"delete": "deletes",
"deleteOne": "filter",
"deleteMany": "filter",
}
for op, filter_key in ops_with_filter.items():
if op in parsed:
# 检查 filter 是否存在且非空
if filter_key == "filter":
filter_val = parsed.get(filter_key)
if filter_val is None or (isinstance(filter_val, dict) and not filter_val):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op=op),
))
return
elif filter_key == "updates":
updates = parsed.get("updates", [])
if isinstance(updates, list) and updates:
for upd in updates:
if isinstance(upd, dict) and not upd.get("q"):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op=op),
))
return
elif filter_key == "deletes":
deletes = parsed.get("deletes", [])
if isinstance(deletes, list) and deletes:
for d in deletes:
if isinstance(d, dict) and not d.get("q"):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op=op),
))
return
@register_rule(AuditRuleV2(
code="MONGO-QRY-002",
name="推荐指定 projection",
category=RuleCategory.DQL,
severity=Severity.INFO,
db_type="mongodb",
description="find/findOne 查询推荐指定 projection 限制返回字段,减少网络传输",
message_template="{op} 查询未指定 projection,建议添加 projection 限制返回字段",
))
def check_mongo_qry_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""推荐指定 projection。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
query_ops = ("find", "findOne")
for op in query_ops:
if op in parsed:
if "projection" not in parsed:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op=op),
))
return
@register_rule(AuditRuleV2(
code="MONGO-QRY-003",
name="禁止 $where/$function JavaScript 执行",
category=RuleCategory.DQL,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止使用 $where 和 $function 运算符执行 JavaScript,存在注入和安全风险",
message_template="禁止使用 {op} 运算符执行 JavaScript,存在严重的安全和注入风险",
))
def check_mongo_qry_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止 $where/$function JavaScript 执行。"""
text = sql_clean.strip()
if "\"$where\"" in text or "'$where'" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op="$where"),
))
return
if "\"$function\"" in text or "'$function'" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op="$function"),
))
return
# 检查函数表达式中的 $where
if _has_where_operator(text):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(op="$where"),
))
@register_rule(AuditRuleV2(
code="MONGO-QRY-004",
name="禁止前缀模糊匹配(regex)",
category=RuleCategory.DQL,
severity=Severity.WARNING,
db_type="mongodb",
description="禁止正则表达式前缀模糊匹配(如 /^abc/),无法有效使用索引",
message_template="正则表达式查询 {pattern} 为前缀匹配,无法有效使用索引,建议调整查询策略",
))
def check_mongo_qry_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止前缀模糊匹配: 检测 /^ 开头的正则或 $regex 中以 ^ 开头的模式。"""
text = sql_clean.strip()
# 检测 $regex 中以 ^ 开头的模式
regex_patterns = re.findall(r'"\$regex"\s*:\s*"(\^[^"]*)"', text)
for pat in regex_patterns:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(pattern=pat),
))
return
# 检测 JS 正则字面量 /^.../
js_regex = re.findall(r'/\^(.*?)/', text)
for pat in js_regex:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(pattern=f"^{pat}"),
))
return
@register_rule(AuditRuleV2(
code="MONGO-QRY-005",
name="查询返回结果上限",
category=RuleCategory.DQL,
severity=Severity.WARNING,
db_type="mongodb",
description="查询返回结果建议不超过 10000 条,大数据量应分页",
config={"max_limit": 10000},
message_template="查询未设置 limit 或 limit 超过 {max} 条,建议分页查询",
))
def check_mongo_qry_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""查询返回结果上限检查。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
if "find" not in parsed and "findOne" not in parsed and "aggregate" not in parsed:
return
limit = parsed.get("limit")
max_limit = rule.config.get("max_limit", 10000)
if limit is None:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(max=max_limit),
))
elif isinstance(limit, (int, float)) and limit > max_limit:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(max=max_limit),
))
@register_rule(AuditRuleV2(
code="MONGO-QRY-006",
name="explain 不应在生产环境频繁使用",
category=RuleCategory.DQL,
severity=Severity.INFO,
db_type="mongodb",
description="explain() 会额外收集执行计划信息,不应在生产环境频繁使用",
message_template="explain() 操作在生产环境频繁使用可能影响性能,建议仅在调试时使用",
))
def check_mongo_qry_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""explain() 不应在生产环境频繁使用。"""
text = sql_clean.strip()
if "explain" in text.lower() or "\"explain\"" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-QRY-007",
name="不推荐 snapshot 模式",
category=RuleCategory.DQL,
severity=Severity.INFO,
db_type="mongodb",
description="snapshot 模式($snapshot 或 readConcern snapshot)性能较差",
message_template="使用 snapshot 模式可能影响查询性能,如无强一致性需求请避免使用",
))
def check_mongo_qry_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""不推荐 snapshot 模式。"""
text = sql_clean.strip()
has_snapshot = False
# JSON 格式
if "\"snapshot\"" in text and "true" in text.lower():
has_snapshot = True
if "snapshot" in text.lower():
success, parsed = _try_parse_json(text)
if success:
snapshot_val = parsed.get("snapshot")
read_concern = parsed.get("readConcern", {})
if snapshot_val is True:
has_snapshot = True
elif isinstance(read_concern, dict) and read_concern.get("level") == "snapshot":
has_snapshot = True
if has_snapshot:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-QRY-008",
name="不建议使用 allowDiskUse",
category=RuleCategory.DQL,
severity=Severity.WARNING,
db_type="mongodb",
description="allowDiskUse: true 允许聚合操作使用磁盘,可能表明查询设计不合理",
message_template="使用 allowDiskUse: true 表明查询可能消耗大量内存,建议优化查询或索引",
))
def check_mongo_qry_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""不建议使用 allowDiskUse。"""
text = sql_clean.strip()
if "allowDiskUse" not in text:
return
success, parsed = _try_parse_json(text)
if success:
allow_disk = parsed.get("allowDiskUse")
if allow_disk is True:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
# 正则匹配
if re.search(r'"allowDiskUse"\s*:\s*true', text, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-QRY-009",
name="text 搜索使用限制",
category=RuleCategory.DQL,
severity=Severity.WARNING,
db_type="mongodb",
description="MongoDB 的 $text 搜索功能有限,建议使用 Elasticsearch 等专业搜索引擎",
message_template="使用 $text 全文搜索功能有限,建议考虑 Elasticsearch 等专业搜索方案",
))
def check_mongo_qry_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""text 搜索使用限制。"""
text = sql_clean.strip()
if "\"$text\"" in text or "'$text'" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-QRY-010",
name="filter 必须是有效的 BSON 格式",
category=RuleCategory.DQL,
severity=Severity.ERROR,
db_type="mongodb",
description="MongoDB filter 必须是有效的 JSON/BSON 对象格式",
message_template="查询条件 filter 不是有效的 JSON/BSON 对象格式,请修正",
))
def check_mongo_qry_010(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""filter 必须是有效的 BSON 格式。"""
text = sql_clean.strip()
if not text.startswith("{"):
return
# 尝试解析 JSON
success, parsed = _try_parse_json(text)
if not success:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
# 检查 filter 字段是否为对象
for filter_key in ("filter", "q"):
if filter_key in parsed:
filter_val = parsed[filter_key]
if filter_val is not None and not isinstance(filter_val, dict):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
# ═══════════════════════════════════════════════
# 聚合管道规则 (6条) — MONGO-AGG-001 ~ 006
# ═══════════════════════════════════════════════
@register_rule(AuditRuleV2(
code="MONGO-AGG-001",
name="禁止 $out/$merge 写操作",
category=RuleCategory.DQL,
severity=Severity.ERROR,
db_type="mongodb",
description="聚合管道中禁止使用 $out 和 $merge 阶段,可能覆盖现有集合数据",
message_template="禁止在聚合管道中使用 {stage} 阶段,可能覆盖或修改现有集合数据",
))
def check_mongo_agg_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止 $out/$merge 写操作。"""
text = sql_clean.strip()
# 检查 JSON 中的 pipeline
success, parsed = _try_parse_json(text)
if success and "pipeline" in parsed and isinstance(parsed["pipeline"], list):
for stage in parsed["pipeline"]:
if isinstance(stage, dict):
if "$out" in stage:
out_val = stage["$out"]
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(stage=f"$out: {out_val}"),
))
return
if "$merge" in stage:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(stage="$merge"),
))
return
# 文本检查
if "\"$out\"" in text or "'$out'" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(stage="$out"),
))
return
if "\"$merge\"" in text or "'$merge'" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(stage="$merge"),
))
@register_rule(AuditRuleV2(
code="MONGO-AGG-002",
name="聚合管道阶段数上限",
category=RuleCategory.DQL,
severity=Severity.WARNING,
db_type="mongodb",
description="聚合管道阶段数建议不超过 20 个,过多阶段影响性能",
config={"max_stages": 20},
message_template="聚合管道包含 {count} 个阶段,超过建议上限 {max} 个",
))
def check_mongo_agg_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""聚合管道阶段数上限检查。"""
text = sql_clean.strip()
stage_count = 0
success, parsed = _try_parse_json(text)
if success and "pipeline" in parsed and isinstance(parsed["pipeline"], list):
stage_count = len(parsed["pipeline"])
else:
# 粗略统计 $ 开头的 stage 数量
stage_count = len(re.findall(r'\{\\s*"\$(\w+)"', text))
max_stages = rule.config.get("max_stages", 20)
if stage_count > max_stages:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(count=stage_count, max=max_stages),
))
@register_rule(AuditRuleV2(
code="MONGO-AGG-003",
name="$unwind 大数据量警告",
category=RuleCategory.DQL,
severity=Severity.WARNING,
db_type="mongodb",
description="$unwind 展开数组可能导致文档数量急剧膨胀,影响性能",
message_template="使用 $unwind 阶段可能急剧增加文档数量,请确认数组字段的平均元素数量",
))
def check_mongo_agg_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""$unwind 大数据量警告。"""
text = sql_clean.strip()
if "\"$unwind\"" in text or "'$unwind'" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-AGG-004",
name="$group 操作索引建议",
category=RuleCategory.DQL,
severity=Severity.INFO,
db_type="mongodb",
description="$group 操作的 _id 字段建议创建索引以提升性能",
message_template="$group 操作的 _id 字段建议创建索引,可显著提升聚合性能",
))
def check_mongo_agg_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""$group 操作索引建议。"""
text = sql_clean.strip()
if "\"$group\"" in text or "'$group'" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-AGG-005",
name="聚合内存限制(100MB)",
category=RuleCategory.DQL,
severity=Severity.WARNING,
db_type="mongodb",
description="单个聚合阶段内存限制为 100MB,超出需使用 allowDiskUse",
config={"memory_limit_mb": 100},
message_template="聚合操作内存限制为 {limit}MB,大型聚合可能触发内存限制错误",
))
def check_mongo_agg_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""聚合内存限制提示。"""
text = sql_clean.strip()
if "aggregate" in text.lower() or "\"pipeline\"" in text:
limit_mb = rule.config.get("memory_limit_mb", 100)
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(limit=limit_mb),
))
@register_rule(AuditRuleV2(
code="MONGO-AGG-006",
name="$lookup 嵌套层数限制",
category=RuleCategory.DQL,
severity=Severity.WARNING,
db_type="mongodb",
description="$lookup 嵌套层数建议不超过 3 层,过多嵌套影响性能",
config={"max_lookup_nesting": 3},
message_template="$lookup 嵌套层数可能超过 {max} 层,建议优化查询结构",
))
def check_mongo_agg_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""$lookup 嵌套层数限制。"""
text = sql_clean.strip()
lookup_count = text.count('"$lookup"') + text.count("'$lookup'")
max_nesting = rule.config.get("max_lookup_nesting", 3)
if lookup_count > max_nesting:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(max=max_nesting),
))
# ═══════════════════════════════════════════════
# 写入安全规则 (8条) — MONGO-WR-001 ~ 008
# ═══════════════════════════════════════════════
@register_rule(AuditRuleV2(
code="MONGO-WR-001",
name="批量写入上限(1000条)",
category=RuleCategory.DML,
severity=Severity.WARNING,
db_type="mongodb",
description="insertMany/bulkWrite 单次文档数建议不超过 1000 条",
config={"max_bulk_write": 1000},
message_template="批量写入文档数为 {count},超过建议上限 {max} 条,请分批写入",
))
def check_mongo_wr_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""批量写入上限检查。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
doc_count = 0
if success:
# insertMany: {"documents": [...]}
if "insertMany" in parsed or "insert" in parsed:
docs = parsed.get("documents", [])
if isinstance(docs, list):
doc_count = len(docs)
# bulkWrite: {"operations": [...]}
if "operations" in parsed and isinstance(parsed["operations"], list):
doc_count = len(parsed["operations"])
# updateMany: {"updates": [...]}
if "updates" in parsed and isinstance(parsed["updates"], list):
doc_count = len(parsed["updates"])
max_docs = rule.config.get("max_bulk_write", 1000)
if doc_count > max_docs:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(count=doc_count, max=max_docs),
))
@register_rule(AuditRuleV2(
code="MONGO-WR-002",
name="updateMany 必须带条件 filter",
category=RuleCategory.DML,
severity=Severity.ERROR,
db_type="mongodb",
description="updateMany 操作必须包含 filter 条件,禁止无条件更新",
message_template="updateMany 操作缺少有效的 filter 条件,将更新全集合文档",
))
def check_mongo_wr_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""updateMany 必须带条件 filter。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
if "updateMany" in parsed:
filter_val = parsed.get("filter")
if filter_val is None or (isinstance(filter_val, dict) and not filter_val):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
# 检查 bulk updates
if "updates" in parsed and isinstance(parsed.get("updates"), list):
for upd in parsed["updates"]:
if isinstance(upd, dict):
q = upd.get("q")
u = upd.get("u")
multi = upd.get("multi", False)
if multi and (q is None or (isinstance(q, dict) and not q)):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
@register_rule(AuditRuleV2(
code="MONGO-WR-003",
name="deleteMany 必须带条件 filter",
category=RuleCategory.DML,
severity=Severity.ERROR,
db_type="mongodb",
description="deleteMany 操作必须包含 filter 条件,禁止无条件删除",
message_template="deleteMany 操作缺少有效的 filter 条件,将删除全集合文档",
))
def check_mongo_wr_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""deleteMany 必须带条件 filter。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
if "deleteMany" in parsed:
filter_val = parsed.get("filter")
if filter_val is None or (isinstance(filter_val, dict) and not filter_val):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
# 检查 bulk deletes
if "deletes" in parsed and isinstance(parsed.get("deletes"), list):
for d in parsed["deletes"]:
if isinstance(d, dict):
q = d.get("q")
limit = d.get("limit", 0)
if limit == 0 and (q is None or (isinstance(q, dict) and not q)):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
@register_rule(AuditRuleV2(
code="MONGO-WR-004",
name="禁止 unset 关键字段",
category=RuleCategory.DML,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止 $unset 关键字段(如 _id, create_time, update_time 等)",
config={"critical_fields": ["_id", "create_time", "update_time", "created_at", "updated_at"]},
message_template="禁止使用 $unset 删除关键字段: {fields},可能导致数据完整性问题",
))
def check_mongo_wr_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止 unset 关键字段。"""
text = sql_clean.strip()
critical = rule.config.get("critical_fields", ["_id"])
if "\"$unset\"" not in text and "'$unset'" not in text:
return
success, parsed = _try_parse_json(text)
found_fields: List[str] = []
if success:
# 查找 updates 中的 $unset
for update_dict in _find_all_values(parsed, "u"):
if isinstance(update_dict, dict) and "$unset" in update_dict:
unset_fields = update_dict["$unset"]
if isinstance(unset_fields, dict):
for f in unset_fields:
if f in critical:
found_fields.append(f)
elif isinstance(unset_fields, list):
for f in unset_fields:
if f in critical:
found_fields.append(f)
# 也检查顶层 update 的 $unset
if isinstance(parsed, dict):
for k, v in parsed.items():
if isinstance(v, dict) and "$unset" in v:
unset_val = v["$unset"]
if isinstance(unset_val, dict):
for f in unset_val:
if f in critical:
found_fields.append(f)
else:
# 正则提取
unset_match = re.findall(r'"\$unset"\s*:\s*\{([^}]+)\}', text)
for m in unset_match:
for f in critical:
if f'"{f}"' in m or f"'{f}'" in m:
found_fields.append(f)
if found_fields:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(fields=", ".join(set(found_fields))),
))
@register_rule(AuditRuleV2(
code="MONGO-WR-005",
name="禁止重命名集合",
category=RuleCategory.DML,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止执行 renameCollection 操作,可能影响应用连接",
message_template="禁止执行 renameCollection 操作,重命名集合可能导致应用连接失败",
))
def check_mongo_wr_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止重命名集合。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if success:
if "renameCollection" in parsed or "collMod" in parsed:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
if re.search(r'\b(renameCollection|collMod)\b', text, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-WR-006",
name="禁止绕过文档验证(bypassDocumentValidation)",
category=RuleCategory.DML,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止设置 bypassDocumentValidation: true 绕过文档验证",
message_template="禁止设置 bypassDocumentValidation: true 绕过 Schema 验证",
))
def check_mongo_wr_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止绕过文档验证。"""
text = sql_clean.strip()
if "bypassDocumentValidation" not in text:
return
success, parsed = _try_parse_json(text)
if success:
bypass = parsed.get("bypassDocumentValidation")
if bypass is True:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
# 正则匹配
if re.search(r'"bypassDocumentValidation"\s*:\s*true', text, re.IGNORECASE):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-WR-007",
name="单文档大小限制(16MB警告阈值10MB)",
category=RuleCategory.DML,
severity=Severity.WARNING,
db_type="mongodb",
description="MongoDB 单文档最大 16MB,建议不超过 10MB 作为警告阈值",
config={"warn_threshold_bytes": 10_485_760, "max_size_bytes": 16_777_216}, # 10MB, 16MB
message_template="文档大小约为 {size_mb}MB,超过警告阈值 {warn_mb}MB,建议拆分或精简文档",
))
def check_mongo_wr_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""单文档大小限制检查。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
# 检查是否为写入操作
write_ops = ("insert", "insertOne", "insertMany", "update", "updateOne", "updateMany")
is_write = any(op in parsed for op in write_ops)
if not is_write:
return
# 估算文档大小
doc_size = _get_json_string_length(text)
warn_threshold = rule.config.get("warn_threshold_bytes", 10_485_760)
if doc_size > warn_threshold:
size_mb = round(doc_size / (1024 * 1024), 2)
warn_mb = round(warn_threshold / (1024 * 1024), 2)
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(size_mb=size_mb, warn_mb=warn_mb),
))
@register_rule(AuditRuleV2(
code="MONGO-WR-008",
name="禁止嵌套深度超过5层",
category=RuleCategory.DML,
severity=Severity.WARNING,
db_type="mongodb",
description="文档嵌套深度建议不超过 5 层,过深影响查询和更新性能",
config={"max_nesting_depth": 5},
message_template="文档嵌套深度为 {depth} 层,超过建议上限 {max} 层",
))
def check_mongo_wr_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止嵌套深度超过5层。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
# 检查 documents 或 document 字段的嵌套深度
docs = parsed.get("documents") or parsed.get("document")
if docs is None:
# 检查 updates 中的 $set/$setOnInsert 等
updates = parsed.get("updates", [])
if isinstance(updates, list) and updates:
docs = updates[0].get("u") if isinstance(updates[0], dict) else None
if not docs:
return
target_doc = docs[0] if isinstance(docs, list) else docs
if not isinstance(target_doc, dict):
return
depth = _get_nesting_depth(target_doc)
max_depth = rule.config.get("max_nesting_depth", 5)
if depth > max_depth:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(depth=depth, max=max_depth),
))
# ═══════════════════════════════════════════════
# 安全规则 (5条) — MONGO-SEC-001 ~ 005
# ═══════════════════════════════════════════════
@register_rule(AuditRuleV2(
code="MONGO-SEC-001",
name="禁止 db.eval() 执行 JavaScript",
category=RuleCategory.SECURITY,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止 db.eval() 执行任意 JavaScript 代码,存在严重安全风险",
message_template="禁止 db.eval() 执行 JavaScript 代码,存在严重的安全和注入风险",
))
def check_mongo_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止 db.eval() 执行 JavaScript。"""
text = sql_clean.strip()
if re.search(r'\bdb\.eval\s*\(', text):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-SEC-002",
name="禁止操作系统集合",
category=RuleCategory.SECURITY,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止查询/操作系统集合(以 system. 开头),可能导致安全问题",
message_template="禁止操作系统集合: '{coll}',系统集合操作可能导致安全问题",
))
def check_mongo_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止操作系统集合。"""
text = sql_clean.strip()
coll = _extract_collection_name(text)
if coll and coll.startswith("system."):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(coll=coll),
))
return
# 也检查 JSON 中的 find 等操作
success, parsed = _try_parse_json(text)
if success:
for op_key in ("find", "findOne", "aggregate", "insert", "update", "delete", "count"):
val = parsed.get(op_key)
if isinstance(val, str) and val.startswith("system."):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(coll=val),
))
return
@register_rule(AuditRuleV2(
code="MONGO-SEC-003",
name="禁止 MapReduce(使用聚合管道)",
category=RuleCategory.SECURITY,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止使用 mapReduce 命令,map 和 reduce 函数可执行任意 JavaScript",
message_template="禁止 mapReduce 操作,请使用聚合管道(Aggregation Pipeline)替代",
))
def check_mongo_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""禁止 MapReduce。"""
text = sql_clean.strip()
if re.search(r'\bmapReduce\b|\bmapReduce\s*\(', text) or "\"mapReduce\"" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-SEC-004",
name="防止 $cmd 注入攻击",
category=RuleCategory.SECURITY,
severity=Severity.ERROR,
db_type="mongodb",
description="禁止直接操作 $cmd 集合,可能存在注入攻击风险",
message_template="禁止直接操作 $cmd 集合,存在注入攻击风险",
))
def check_mongo_sec_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""防止 $cmd 注入攻击。"""
text = sql_clean.strip()
if "$cmd" in text:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-SEC-005",
name="敏感操作必须记录审计日志",
category=RuleCategory.SECURITY,
severity=Severity.INFO,
db_type="mongodb",
description="删除、更新、聚合写操作等敏感操作应记录审计日志",
message_template="敏感操作({ops})应确保已记录审计日志,便于安全追踪和合规审计",
))
def check_mongo_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""敏感操作审计日志提醒。"""
text = sql_clean.strip()
sensitive_ops = []
success, parsed = _try_parse_json(text)
op_names = {
"delete": "delete", "deleteOne": "delete", "deleteMany": "delete",
"update": "update", "updateOne": "update", "updateMany": "update",
"drop": "drop", "dropDatabase": "dropDatabase", "dropIndexes": "dropIndexes",
"renameCollection": "rename", "createUser": "userManagement",
"dropUser": "userManagement", "grantRoles": "userManagement",
}
if success:
for op, label in op_names.items():
if op in parsed:
sensitive_ops.append(label)
else:
text_lower = text.lower()
for op, label in op_names.items():
if op.lower() in text_lower:
sensitive_ops.append(label)
if sensitive_ops:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(ops=", ".join(set(sensitive_ops))),
))
# ═══════════════════════════════════════════════
# 性能规则 (5条) — MONGO-PERF-001 ~ 005
# ═══════════════════════════════════════════════
@register_rule(AuditRuleV2(
code="MONGO-PERF-001",
name="缺少索引查询警告",
category=RuleCategory.PERFORMANCE,
severity=Severity.WARNING,
db_type="mongodb",
description="查询条件字段应创建索引,缺少索引会导致全集合扫描(COLLSCAN)",
message_template="查询条件字段 '{fields}' 缺少索引可能导致全集合扫描,建议创建索引",
))
def check_mongo_perf_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""缺少索引查询警告: 检查查询条件中常见字段是否可能缺少索引。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
if "find" not in parsed and "findOne" not in parsed:
return
filter_val = parsed.get("filter")
if not isinstance(filter_val, dict) or not filter_val:
return
# 检查常见的可能需要索引的字段(排除 _id 已有默认索引)
query_fields = [k for k in filter_val.keys() if not k.startswith("$") and k != "_id"]
if query_fields:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(fields=", ".join(query_fields)),
))
@register_rule(AuditRuleV2(
code="MONGO-PERF-002",
name="推荐覆盖索引查询",
category=RuleCategory.PERFORMANCE,
severity=Severity.INFO,
db_type="mongodb",
description="推荐 projection 仅包含索引字段,实现覆盖索引查询(Covered Query)",
message_template="查询条件 '{fields}' 建议 projection 仅包含索引字段以实现覆盖索引查询",
))
def check_mongo_perf_002(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""推荐覆盖索引查询。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
if "find" not in parsed and "findOne" not in parsed:
return
filter_val = parsed.get("filter")
projection = parsed.get("projection")
if not isinstance(filter_val, dict) or not filter_val:
return
# 如果有 projection 且设置了 _id: 0,可能是尝试覆盖索引
if isinstance(projection, dict) and projection.get("_id") == 0:
query_fields = [k for k in filter_val.keys() if not k.startswith("$")]
if query_fields:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template.format(fields=", ".join(query_fields)),
))
@register_rule(AuditRuleV2(
code="MONGO-PERF-003",
name="全集合扫描警告",
category=RuleCategory.PERFORMANCE,
severity=Severity.WARNING,
db_type="mongodb",
description="缺少 filter 条件或 filter 为空对象 {} 将导致全集合扫描",
message_template="查询缺少有效过滤条件,将导致全集合扫描(COLLSCAN),严重影响性能",
))
def check_mongo_perf_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""全集合扫描警告。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
if "find" not in parsed and "findOne" not in parsed:
return
filter_val = parsed.get("filter")
if filter_val is None:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
elif isinstance(filter_val, dict) and not filter_val:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
@register_rule(AuditRuleV2(
code="MONGO-PERF-004",
name="复合索引字段顺序建议",
category=RuleCategory.PERFORMANCE,
severity=RuleCategory.PERFORMANCE,
db_type="mongodb",
description="复合索引字段顺序应遵循 ESR 规则(Equality, Sort, Range)",
message_template="复合查询条件 '{fields}' 建议遵循 ESR 规则设计索引: 等值 > 排序 > 范围",
))
def check_mongo_perf_004(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""复合索引字段顺序建议: ESR 规则。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if not success:
return
if "find" not in parsed and "findOne" not in parsed:
return
filter_val = parsed.get("filter")
if not isinstance(filter_val, dict) or not filter_val:
return
# 统计查询条件中的字段数量
query_fields = [k for k in filter_val.keys() if not k.startswith("$")]
# 如果有多于 1 个查询字段,提示复合索引 ESR 规则
if len(query_fields) > 1:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=Severity.INFO,
message=rule.message_template.format(fields=", ".join(query_fields)),
))
@register_rule(AuditRuleV2(
code="MONGO-PERF-005",
name="分片键选择建议",
category=RuleCategory.PERFORMANCE,
severity=Severity.INFO,
db_type="mongodb",
description="分片集合应选择高基数、均匀分布的字段作为分片键",
message_template="分片集合建议: 选择高基数均匀分布字段作为分片键,避免单调递增字段和热点",
))
def check_mongo_perf_005(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
"""分片键选择建议: 检查 shardCollection 操作。"""
text = sql_clean.strip()
success, parsed = _try_parse_json(text)
if success and "shardCollection" in parsed:
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
return
if re.search(r'\bshardCollection\b', text):
report.findings.append(AuditFindingV2(
rule_code=rule.code,
rule_name=rule.name,
level=rule.severity,
message=rule.message_template,
))
# ──────────────────────────────────────────────
# 规则统计信息
# ──────────────────────────────────────────────
def get_mongodb_rule_count() -> Dict[str, int]:
"""获取 MongoDB 规则分类统计。
Returns:
{"COL": 8, "QRY": 10, "AGG": 6, "WR": 8, "SEC": 5, "PERF": 5, "TOTAL": 42}
"""
return {
"COL": 8,
"QRY": 10,
"AGG": 6,
"WR": 8,
"SEC": 5,
"PERF": 5,
"TOTAL": 42,
}