后端新增 (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 通过
1639 lines
64 KiB
Python
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,
|
|
}
|