fix: 审核引擎优化 + 执行结果结构化展示
后端审核规则修复: - Redis SEC-001: SET/DEL等常规命令不再误报AUTH要求,改为仅对管理级命令WARNING - Redis SEC-003: 降级为INFO,排除已由CMD-001拦截的FLUSHDB/FLUSHALL - Redis key提取: 排除CONFIG/ACL等管理命令,不再把子命令参数误认为key名 - MySQL SEC-006: 新增GRANT ALL/REVOKE ALL全局权限拦截 - MySQL SEC-007: 新增LOAD DATA/LOAD XML拦截 - MySQL SEC-008: 新增SLEEP()/BENCHMARK()时间盲注拦截 - MySQL SEC-009: 新增INTO OUTFILE/DUMPFILE文件写入拦截 前端执行结果结构化: - TicketDetailPage执行结果从原始JSON改为结构化展示 - 解析JSON展示: 状态徽章+影响行数+耗时+验证状态 - Errors/Warnings逐条渲染: 红色错误框+黄色警告框+具体消息 - 删除废弃的executeStats/renderExecuteStats 其他: 审核规则6分组/慢查询移除/查询数据重命名/AuditLogs修复 测试: 1040 passed, TSC零错误
This commit is contained in:
1 parent
9f56e866f2
commit
4ac2528f8e
19 files changed
+1202
-325
No files matched your search
@@ -0,0 +1,267 @@
|
||||
"""数据库类型说明 API — 人性化数据库能力查询。
|
||||
|
||||
提供每种数据库类型的详细信息,包括:
|
||||
- 支持的操作和限制
|
||||
- 查询语法示例
|
||||
- 安全规则说明
|
||||
- 推荐使用场景
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import Dict, List, Any
|
||||
|
||||
from app.core.security import get_current_user
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(prefix="/db-types", tags=["数据库类型说明"])
|
||||
|
||||
|
||||
# ── 数据库类型元数据 ──
|
||||
|
||||
DB_TYPE_INFO: Dict[str, Dict[str, Any]] = {
|
||||
"mysql": {
|
||||
"name": "MySQL",
|
||||
"icon": "/db-logos/mysql.svg",
|
||||
"description": "最流行的开源关系型数据库,适合 Web 应用、OLTP 场景",
|
||||
"version": "5.7+ / 8.0+",
|
||||
"category": "关系型",
|
||||
"supported_query_types": [
|
||||
{"type": "SELECT", "description": "数据查询", "example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 100"},
|
||||
{"type": "SHOW", "description": "元数据查询", "example": "SHOW TABLES; SHOW PROCESSLIST; SHOW VARIABLES LIKE 'slow%'"},
|
||||
{"type": "DESCRIBE", "description": "表结构查看", "example": "DESCRIBE users;"},
|
||||
{"type": "EXPLAIN", "description": "执行计划分析", "example": "EXPLAIN SELECT * FROM users WHERE id = 1"},
|
||||
],
|
||||
"security_rules": [
|
||||
"仅支持 SELECT / SHOW / DESCRIBE / EXPLAIN 只读查询",
|
||||
"禁止 DML (INSERT/UPDATE/DELETE) 和 DDL (CREATE/ALTER/DROP)",
|
||||
"查询结果自动脱敏敏感字段",
|
||||
"最大返回行数限制 (默认 1000 行)",
|
||||
],
|
||||
"tips": [
|
||||
"使用 EXPLAIN 分析慢查询",
|
||||
"避免 SELECT *,明确指定需要的列",
|
||||
"大数据量查询使用 LIMIT 分页",
|
||||
"利用索引提示 FORCE INDEX 优化查询",
|
||||
],
|
||||
"common_commands": {
|
||||
"查看所有表": "SHOW TABLES",
|
||||
"查看表结构": "DESCRIBE table_name",
|
||||
"查看建表语句": "SHOW CREATE TABLE table_name",
|
||||
"查看索引": "SHOW INDEX FROM table_name",
|
||||
"查看连接": "SHOW PROCESSLIST",
|
||||
"查看状态": "SHOW STATUS LIKE 'Threads%'",
|
||||
"查看慢查询": "SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20",
|
||||
},
|
||||
"limitations": [
|
||||
"不支持存储过程调用",
|
||||
"不支持临时表创建",
|
||||
"不支持 LOAD DATA INFILE",
|
||||
],
|
||||
},
|
||||
"postgres": {
|
||||
"name": "PostgreSQL",
|
||||
"icon": "/db-logos/postgres.svg",
|
||||
"description": "功能最强大的开源关系型数据库,适合复杂查询、GIS、JSON 场景",
|
||||
"version": "12+ / 13+ / 14+ / 15+",
|
||||
"category": "关系型",
|
||||
"supported_query_types": [
|
||||
{"type": "SELECT", "description": "数据查询", "example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 100"},
|
||||
{"type": "EXPLAIN", "description": "执行计划分析", "example": "EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1"},
|
||||
],
|
||||
"security_rules": [
|
||||
"仅支持 SELECT 只读查询",
|
||||
"支持 EXPLAIN / EXPLAIN ANALYZE (只读)",
|
||||
"禁止 DML (INSERT/UPDATE/DELETE) 和 DDL 操作",
|
||||
"查询结果自动脱敏",
|
||||
],
|
||||
"tips": [
|
||||
"使用 EXPLAIN ANALYZE 获取真实执行统计",
|
||||
"利用 CTE (WITH 语句) 优化复杂查询",
|
||||
"pg_stat_user_tables 查看表统计信息",
|
||||
"使用 JSONB 类型和操作符处理 JSON 数据",
|
||||
],
|
||||
"common_commands": {
|
||||
"查看所有表": "SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
|
||||
"查看表大小": "SELECT pg_size_pretty(pg_total_relation_size('table_name'))",
|
||||
"查看活动查询": "SELECT * FROM pg_stat_activity WHERE state = 'active'",
|
||||
"查看索引使用": "SELECT * FROM pg_stat_user_indexes ORDER BY idx_scan DESC",
|
||||
"查看锁等待": "SELECT * FROM pg_locks WHERE NOT granted",
|
||||
"查看表统计": "SELECT * FROM pg_stat_user_tables ORDER BY n_live_tup DESC",
|
||||
},
|
||||
"limitations": [
|
||||
"不支持存储过程调用",
|
||||
"不支持 COPY 命令",
|
||||
"不支持事务控制 (BEGIN/COMMIT)",
|
||||
],
|
||||
},
|
||||
"redis": {
|
||||
"name": "Redis",
|
||||
"icon": "/db-logos/redis.svg",
|
||||
"description": "高性能内存键值数据库,适合缓存、会话管理、排行榜场景",
|
||||
"version": "6.0+ / 7.0+",
|
||||
"category": "键值型",
|
||||
"supported_query_types": [
|
||||
{"type": "GET", "description": "获取字符串值", "example": "GET user:1001"},
|
||||
{"type": "HGETALL", "description": "获取哈希所有字段", "example": "HGETALL user:1001"},
|
||||
{"type": "LRANGE", "description": "获取列表范围", "example": "LRANGE mylist 0 -1"},
|
||||
{"type": "SMEMBERS", "description": "获取集合所有成员", "example": "SMEMBERS myset"},
|
||||
{"type": "ZRANGE", "description": "获取有序集合范围", "example": "ZRANGE leaderboard 0 10 WITHSCORES"},
|
||||
{"type": "INFO", "description": "服务器信息", "example": "INFO server; INFO memory"},
|
||||
{"type": "SCAN", "description": "迭代键", "example": "SCAN 0 MATCH user:* COUNT 50"},
|
||||
],
|
||||
"security_rules": [
|
||||
"仅支持只读命令白名单 (40+ 命令)",
|
||||
"禁止写入命令 (SET/DEL/FLUSHDB 等)",
|
||||
"禁止管理命令 (SHUTDOWN/DEBUG 等)",
|
||||
"KEYS 命令建议用 SCAN 替代 (生产环境安全)",
|
||||
],
|
||||
"tips": [
|
||||
"使用 SCAN 代替 KEYS 避免阻塞",
|
||||
"HGETALL 获取完整哈希,HGET 获取单个字段",
|
||||
"ZRANGE WITHSCORES 获取分数",
|
||||
"TYPE key 查看键的数据类型",
|
||||
"TTL key 查看剩余过期时间",
|
||||
],
|
||||
"common_commands": {
|
||||
"查看键数量": "DBSIZE",
|
||||
"查看服务器信息": "INFO server",
|
||||
"查看内存使用": "INFO memory",
|
||||
"查看键类型": "TYPE key_name",
|
||||
"扫描键": "SCAN 0 MATCH pattern COUNT 50",
|
||||
"获取字符串": "GET key_name",
|
||||
"获取哈希": "HGETALL key_name",
|
||||
"查看客户端": "CLIENT LIST",
|
||||
},
|
||||
"limitations": [
|
||||
"不支持 Lua 脚本执行 (EVAL)",
|
||||
"不支持事务 (MULTI/EXEC)",
|
||||
"SCAN 命令不支持跨数据库",
|
||||
],
|
||||
},
|
||||
"mongo": {
|
||||
"name": "MongoDB",
|
||||
"icon": "/db-logos/mongo.svg",
|
||||
"description": "文档型 NoSQL 数据库,适合灵活 Schema、JSON 原生存储场景",
|
||||
"version": "4.4+ / 5.0+ / 6.0+",
|
||||
"category": "文档型",
|
||||
"supported_query_types": [
|
||||
{"type": "find", "description": "查询文档", "example": "db.users.find({status: 'active'}).limit(100)"},
|
||||
{"type": "aggregate", "description": "聚合管道", "example": "db.orders.aggregate([{$match: {status: 'completed'}}, {$group: {_id: '$product', total: {$sum: '$amount'}}}])"},
|
||||
{"type": "countDocuments", "description": "计数", "example": "db.users.countDocuments({status: 'active'})"},
|
||||
{"type": "distinct", "description": "去重值", "example": "db.users.distinct('department')"},
|
||||
],
|
||||
"security_rules": [
|
||||
"仅支持 find/aggregate/countDocuments/distinct 只读操作",
|
||||
"聚合管道禁止 $out 和 $merge (防止写入)",
|
||||
"禁止 insert/update/delete/drop 等写操作",
|
||||
"查询结果限制最大返回数量",
|
||||
],
|
||||
"tips": [
|
||||
"使用投影 (projection) 只返回需要的字段",
|
||||
"aggregate 支持多阶段管道处理",
|
||||
"$lookup 实现类似 SQL JOIN 的关联查询",
|
||||
"createIndex / getIndexes 查看索引信息",
|
||||
],
|
||||
"common_commands": {
|
||||
"查看集合": "show collections",
|
||||
"查找文档": "db.collection.find({}).limit(20)",
|
||||
"计数文档": "db.collection.countDocuments({})",
|
||||
"聚合统计": "db.collection.aggregate([{$group: {_id: '$field', count: {$sum: 1}}}])",
|
||||
"查看索引": "db.collection.getIndexes()",
|
||||
"查看统计": "db.collection.stats()",
|
||||
},
|
||||
"limitations": [
|
||||
"聚合管道不支持 $out/$merge",
|
||||
"不支持 eval 命令",
|
||||
"不支持创建/删除集合",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_db_types(
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取所有支持的数据库类型概览。
|
||||
|
||||
返回每种数据库类型的基本信息,包括名称、图标、描述和类别。
|
||||
用于前端展示数据库类型选择器或帮助信息。
|
||||
"""
|
||||
result = []
|
||||
for db_type, info in DB_TYPE_INFO.items():
|
||||
result.append({
|
||||
"type": db_type,
|
||||
"name": info["name"],
|
||||
"icon": info["icon"],
|
||||
"description": info["description"],
|
||||
"category": info["category"],
|
||||
"version": info["version"],
|
||||
"supported_operations": len(info["supported_query_types"]),
|
||||
"security_rules_count": len(info["security_rules"]),
|
||||
})
|
||||
return {"items": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/{db_type}")
|
||||
async def get_db_type_detail(
|
||||
db_type: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取指定数据库类型的详细信息。
|
||||
|
||||
包含: 支持的操作、查询语法示例、安全规则、使用技巧、常用命令。
|
||||
用于查询页面的上下文帮助和语法提示。
|
||||
"""
|
||||
if db_type not in DB_TYPE_INFO:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="不支持的数据库类型: " + db_type + "。支持: mysql, postgres, redis, mongo",
|
||||
)
|
||||
|
||||
info = DB_TYPE_INFO[db_type]
|
||||
return {
|
||||
"type": db_type,
|
||||
**info,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{db_type}/commands")
|
||||
async def get_db_type_commands(
|
||||
db_type: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取指定数据库类型的常用命令列表。
|
||||
|
||||
返回常用命令名称和示例,用于快捷查询模板。
|
||||
"""
|
||||
if db_type not in DB_TYPE_INFO:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="不支持的数据库类型: " + db_type,
|
||||
)
|
||||
|
||||
commands = DB_TYPE_INFO[db_type]["common_commands"]
|
||||
result = [
|
||||
{"name": name, "command": cmd}
|
||||
for name, cmd in commands.items()
|
||||
]
|
||||
return {"items": result, "total": len(result)}
|
||||
|
||||
|
||||
@router.get("/{db_type}/tips")
|
||||
async def get_db_type_tips(
|
||||
db_type: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取指定数据库类型的使用技巧。
|
||||
|
||||
用于前端展示小贴士,帮助用户更好地使用查询功能。
|
||||
"""
|
||||
if db_type not in DB_TYPE_INFO:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="不支持的数据库类型: " + db_type,
|
||||
)
|
||||
|
||||
tips = DB_TYPE_INFO[db_type]["tips"]
|
||||
return {"items": tips, "total": len(tips)}
|
||||
@@ -6,7 +6,7 @@ from app.api.v1.endpoints import (
|
||||
instance_detail, backup, redis, dictionary,
|
||||
instance_group, masking_rule, rollback,
|
||||
workflow, impact, schema_drift, slow_log, pool,
|
||||
mongo, query_history, sql_assistant,
|
||||
mongo, query_history, sql_assistant, db_types,
|
||||
)
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
@@ -37,3 +37,5 @@ api_router.include_router(slow_log.router)
|
||||
api_router.include_router(pool.router)
|
||||
api_router.include_router(query_history.router)
|
||||
api_router.include_router(sql_assistant.router)
|
||||
|
||||
api_router.include_router(db_types.router)
|
||||
@@ -6,7 +6,7 @@ from typing import Optional, Literal
|
||||
class AuditRuleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
rule_type: Literal["syntax", "performance", "safety", "naming", "DDL", "DML"]
|
||||
rule_type: Literal["syntax", "performance", "safety", "security", "naming", "DDL", "DML", "DQL", "DCL"]
|
||||
severity: Literal["error", "warning", "info"] = "warning"
|
||||
db_type: str = "mysql"
|
||||
pattern: Optional[str] = None
|
||||
@@ -18,7 +18,7 @@ class AuditRuleCreate(BaseModel):
|
||||
class AuditRuleUpdate(BaseModel):
|
||||
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||
description: Optional[str] = None
|
||||
rule_type: Optional[Literal["syntax", "performance", "safety", "naming", "DDL", "DML"]] = None
|
||||
rule_type: Optional[Literal["syntax", "performance", "safety", "security", "naming", "DDL", "DML", "DQL", "DCL"]] = None
|
||||
severity: Optional[Literal["error", "warning", "info"]] = None
|
||||
db_type: Optional[str] = None
|
||||
pattern: Optional[str] = None
|
||||
|
||||
@@ -5,6 +5,7 @@ Replaces mock aiomysql calls with actual driver connections.
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
import asyncio
|
||||
import sqlite3
|
||||
from typing import Optional, List, Dict, Any, Tuple
|
||||
|
||||
@@ -61,12 +62,68 @@ class DBConnector:
|
||||
return await DBConnector._test_postgres(instance, start)
|
||||
elif instance.db_type == "sqlite":
|
||||
return await DBConnector._test_sqlite(instance, start)
|
||||
elif instance.db_type == "redis":
|
||||
return await DBConnector._test_redis(instance, start)
|
||||
elif instance.db_type == "mongodb":
|
||||
return await DBConnector._test_mongodb(instance, start)
|
||||
else:
|
||||
return ConnectionResult(False, f"暂不支持 {instance.db_type} 类型数据源")
|
||||
except Exception as e:
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ConnectionResult(False, f"连接失败: {str(e)}", latency_ms=elapsed)
|
||||
|
||||
|
||||
@staticmethod
|
||||
async def _test_redis(instance: 'Instance', start: float) -> ConnectionResult:
|
||||
"""Test Redis connectivity using synchronous redis client in thread."""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
|
||||
def _connect():
|
||||
client = redis_lib.Redis(
|
||||
host=instance.host,
|
||||
port=instance.port,
|
||||
password=instance.password if instance.password and instance.password != "default" else None,
|
||||
db=0,
|
||||
socket_connect_timeout=5,
|
||||
socket_timeout=5,
|
||||
)
|
||||
info = client.info("server")
|
||||
version = info.get("redis_version", "unknown")
|
||||
client.close()
|
||||
return version
|
||||
|
||||
version = await asyncio.to_thread(_connect)
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ConnectionResult(True, f"Redis {version} 连接成功", version=version, latency_ms=elapsed)
|
||||
except Exception as e:
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ConnectionResult(False, f"Redis 连接失败: {str(e)}", latency_ms=elapsed)
|
||||
|
||||
@staticmethod
|
||||
async def _test_mongodb(instance: 'Instance', start: float) -> ConnectionResult:
|
||||
"""Test MongoDB connectivity using pymongo in thread."""
|
||||
try:
|
||||
from pymongo import MongoClient
|
||||
|
||||
def _connect():
|
||||
if instance.username and instance.username != "default":
|
||||
uri = f"mongodb://{instance.username}:{instance.password}@{instance.host}:{instance.port}/"
|
||||
else:
|
||||
uri = f"mongodb://{instance.host}:{instance.port}/"
|
||||
client = MongoClient(uri, serverSelectionTimeoutMS=5000, connectTimeoutMS=5000)
|
||||
info = client.server_info()
|
||||
version = info.get("version", "unknown")
|
||||
client.close()
|
||||
return version
|
||||
|
||||
version = await asyncio.to_thread(_connect)
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ConnectionResult(True, f"MongoDB {version} 连接成功", version=version, latency_ms=elapsed)
|
||||
except Exception as e:
|
||||
elapsed = int((time.time() - start) * 1000)
|
||||
return ConnectionResult(False, f"MongoDB 连接失败: {str(e)}", latency_ms=elapsed)
|
||||
|
||||
@staticmethod
|
||||
async def execute_query(
|
||||
instance: Instance, database: Optional[str], sql: str, user_id: int
|
||||
|
||||
@@ -2090,12 +2090,110 @@ def check_mysql_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, r
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# 模块总结
|
||||
# ── SEC-006: 权限相关高危语句拦截 ──
|
||||
@register_rule(AuditRuleV2(
|
||||
code="MYSQL-SEC-006",
|
||||
name="DangerousPrivilegeStatement",
|
||||
category=RuleCategory.SECURITY,
|
||||
severity=Severity.ERROR,
|
||||
db_type="mysql",
|
||||
description="拦截 GRANT ALL / REVOKE ALL 等全局权限变更操作",
|
||||
config={"enabled": True},
|
||||
message_template="禁止执行 '{keyword}' 全局权限操作,请联系 DBA 通过权限管理流程申请",
|
||||
))
|
||||
def check_mysql_sec_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||||
"""全局权限语句拦截。"""
|
||||
# GRANT ALL / GRANT ALL PRIVILEGES
|
||||
if re.search(r'\bGRANT\s+ALL\b', sql_upper):
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||||
message=rule.message_template.format(keyword="GRANT ALL"),
|
||||
))
|
||||
# REVOKE ALL
|
||||
elif re.search(r'\bREVOKE\s+ALL\b', sql_upper):
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||||
message=rule.message_template.format(keyword="REVOKE ALL"),
|
||||
))
|
||||
|
||||
|
||||
# ── SEC-007: LOAD DATA / LOAD XML 拦截 ──
|
||||
@register_rule(AuditRuleV2(
|
||||
code="MYSQL-SEC-007",
|
||||
name="BlockLoadData",
|
||||
category=RuleCategory.SECURITY,
|
||||
severity=Severity.ERROR,
|
||||
db_type="mysql",
|
||||
description="禁止 LOAD DATA / LOAD XML 操作,存在安全风险且可能导入大量数据",
|
||||
config={"enabled": True},
|
||||
message_template="禁止使用 '{keyword}',该操作存在安全风险,请使用 INSERT 批量导入",
|
||||
))
|
||||
def check_mysql_sec_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||||
"""LOAD DATA/LOAD XML 拦截。"""
|
||||
if re.search(r'\bLOAD\s+DATA\b', sql_upper):
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||||
message=rule.message_template.format(keyword="LOAD DATA"),
|
||||
))
|
||||
elif re.search(r'\bLOAD\s+XML\b', sql_upper):
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||||
message=rule.message_template.format(keyword="LOAD XML"),
|
||||
))
|
||||
|
||||
|
||||
# ── SEC-008: SLEEP / BENCHMARK 注入拦截 ──
|
||||
@register_rule(AuditRuleV2(
|
||||
code="MYSQL-SEC-008",
|
||||
name="BlockTimeBasedInjection",
|
||||
category=RuleCategory.SECURITY,
|
||||
severity=Severity.ERROR,
|
||||
db_type="mysql",
|
||||
description="拦截 SLEEP() / BENCHMARK() 等时间盲注函数",
|
||||
config={"enabled": True},
|
||||
message_template="禁止使用 '{func}()' 函数,疑似时间盲注攻击",
|
||||
))
|
||||
def check_mysql_sec_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||||
"""时间盲注函数拦截。"""
|
||||
if re.search(r'\bSLEEP\s*\(', sql_upper):
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||||
message=rule.message_template.format(func="SLEEP"),
|
||||
))
|
||||
if re.search(r'\bBENCHMARK\s*\(', sql_upper):
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||||
message=rule.message_template.format(func="BENCHMARK"),
|
||||
))
|
||||
|
||||
|
||||
# ── SEC-009: INTO OUTFILE / INTO DUMPFILE 拦截 ──
|
||||
@register_rule(AuditRuleV2(
|
||||
code="MYSQL-SEC-009",
|
||||
name="BlockIntoOutfile",
|
||||
category=RuleCategory.SECURITY,
|
||||
severity=Severity.ERROR,
|
||||
db_type="mysql",
|
||||
description="禁止 INTO OUTFILE / INTO DUMPFILE,防止写入 WebShell 或导出敏感数据",
|
||||
config={"enabled": True},
|
||||
message_template="禁止使用 '{keyword}',该操作可写入文件系统,存在严重安全风险",
|
||||
))
|
||||
def check_mysql_sec_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||||
"""INTO OUTFILE/DUMPFILE 拦截。"""
|
||||
if re.search(r'\bINTO\s+(OUT|DUMP)FILE\b', sql_upper):
|
||||
keyword = "INTO OUTFILE" if "OUTFILE" in sql_upper else "INTO DUMPFILE"
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code, rule_name=rule.name, level=rule.severity,
|
||||
message=rule.message_template.format(keyword=keyword),
|
||||
))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════
|
||||
# 总计 63 条规则:
|
||||
# 总计 67 条规则:
|
||||
# - DDL 规则: 36 条 (MYSQL-DDL-001 ~ MYSQL-DDL-036)
|
||||
# - DML 规则: 9 条 (MYSQL-DML-001 ~ MYSQL-DML-009)
|
||||
# - Online-DDL 规则: 3 条 (MYSQL-OSC-001 ~ MYSQL-OSC-003)
|
||||
# - 命名规则: 5 条 (MYSQL-NAM-001 ~ MYSQL-NAM-005)
|
||||
# - 性能规则: 5 条 (MYSQL-PERF-001 ~ MYSQL-PERF-005)
|
||||
# - 安全规则: 5 条 (MYSQL-SEC-001 ~ MYSQL-SEC-005)
|
||||
# - 安全规则: 9 条 (MYSQL-SEC-001 ~ MYSQL-SEC-009)
|
||||
# ═══════════════════════════════════════════════════
|
||||
@@ -98,6 +98,17 @@ def _extract_key_from_command(command: str, args: List[str]) -> Optional[str]:
|
||||
if command in second_key_commands and len(args) >= 1:
|
||||
return args[0]
|
||||
|
||||
# 管理命令/无 key 命令:不应提取 key
|
||||
no_key_commands = {
|
||||
"CONFIG", "ACL", "AUTH", "INFO", "PING", "FLUSHDB", "FLUSHALL",
|
||||
"DBSIZE", "BGSAVE", "BGREWRITEAOF", "SAVE", "LASTSAVE",
|
||||
"SHUTDOWN", "SLAVEOF", "REPLICAOF", "SLOWLOG", "MONITOR",
|
||||
"DEBUG", "OBJECT", "CLIENT", "CLUSTER", "SENTINEL",
|
||||
"MODULE", "COMMAND", "MEMORY", "LATENCY", "WAIT",
|
||||
}
|
||||
if command in no_key_commands:
|
||||
return None
|
||||
|
||||
# 默认:第一个参数是 key
|
||||
return args[0]
|
||||
|
||||
@@ -1610,35 +1621,28 @@ def check_redis_perf_008(sql_clean: str, sql_upper: str, report: AuditReportV2,
|
||||
|
||||
@register_rule(AuditRuleV2(
|
||||
code="REDIS-SEC-001",
|
||||
name="必须认证才能执行命令",
|
||||
name="敏感管理命令需要二次确认",
|
||||
category=RuleCategory.SECURITY,
|
||||
severity=Severity.ERROR,
|
||||
severity=Severity.WARNING,
|
||||
db_type="redis",
|
||||
description="非只读命令必须执行 AUTH 认证后才能执行",
|
||||
config={"require_auth": True, "allowed_cmds_without_auth": ["PING", "AUTH", "ECHO", "QUIT", "HELLO"]},
|
||||
message_template="命令 '{cmd}' 需要认证后才能执行,请先执行 AUTH password 进行认证",
|
||||
description="CONFIG/ACL/SHUTDOWN 等管理级命令属于高危操作,建议限制执行",
|
||||
config={"admin_cmds": ["CONFIG", "ACL", "SHUTDOWN", "DEBUG", "MODULE", "REPLICAOF", "SLAVEOF", "SAVE", "BGSAVE", "BGREWRITEAOF"]},
|
||||
message_template="命令 '{cmd}' 属于 Redis 管理级操作,请确认业务必要性后再执行",
|
||||
))
|
||||
def check_redis_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||||
"""必须认证检查。
|
||||
"""管理级命令检查。
|
||||
|
||||
提示执行敏感命令前需要 AUTH 认证。
|
||||
仅对 CONFIG/ACL/SHUTDOWN 等管理级命令产生警告,
|
||||
常规数据操作命令(SET/DEL/HSET 等)不触发——
|
||||
平台已通过实例配置管理认证,无需额外提示 AUTH。
|
||||
"""
|
||||
command, args = _parse_redis_command(sql_clean)
|
||||
if not command:
|
||||
return
|
||||
cmd = command.upper()
|
||||
|
||||
allowed = rule.config.get("allowed_cmds_without_auth", ["PING", "AUTH", "ECHO", "QUIT", "HELLO"])
|
||||
if cmd in allowed:
|
||||
return
|
||||
|
||||
# 只对写入和敏感命令要求认证
|
||||
sensitive_cmds = {
|
||||
"SET", "DEL", "FLUSHDB", "FLUSHALL", "CONFIG", "ACL", "RENAME",
|
||||
"MIGRATE", "RESTORE", "SHUTDOWN", "DEBUG", "MODULE", "REPLICAOF",
|
||||
"SLAVEOF", "SAVE", "BGSAVE", "BGREWRITEAOF",
|
||||
}
|
||||
if cmd in sensitive_cmds:
|
||||
admin_cmds = rule.config.get("admin_cmds", [])
|
||||
if cmd in admin_cmds:
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code,
|
||||
rule_name=rule.name,
|
||||
@@ -1696,25 +1700,27 @@ def check_redis_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, r
|
||||
|
||||
@register_rule(AuditRuleV2(
|
||||
code="REDIS-SEC-003",
|
||||
name="危险命令必须被重命名",
|
||||
name="危险命令重命名建议",
|
||||
category=RuleCategory.SECURITY,
|
||||
severity=Severity.WARNING,
|
||||
severity=Severity.INFO,
|
||||
db_type="redis",
|
||||
description="建议将危险命令(FLUSHDB/FLUSHALL/CONFIG/DEBUG)通过 rename-command 重命名",
|
||||
config={"dangerous_commands": ["FLUSHDB", "FLUSHALL", "CONFIG", "DEBUG", "SHUTDOWN"]},
|
||||
message_template="危险命令 '{cmd}' 应通过 rename-command 在配置中重命名,防止误操作或恶意执行",
|
||||
description="建议将 FLUSHDB/FLUSHALL/DEBUG/SHUTDOWN 通过 rename-command 重命名",
|
||||
config={"dangerous_commands": ["DEBUG", "SHUTDOWN"]},
|
||||
message_template="建议将 '{cmd}' 通过 rename-command 重命名,防止误操作",
|
||||
))
|
||||
def check_redis_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
||||
"""危险命令重命名检查。
|
||||
"""危险命令重命名建议。
|
||||
|
||||
当检测到危险命令使用时,提示应在配置中重命名。
|
||||
FLUSHDB/FLUSHALL 已由 CMD-001 硬拦截,此处仅对 DEBUG/SHUTDOWN
|
||||
等管理命令给出 rename-command 配置建议,避免与 CMD-001 重复。
|
||||
"""
|
||||
command, args = _parse_redis_command(sql_clean)
|
||||
if not command:
|
||||
return
|
||||
cmd = command.upper()
|
||||
|
||||
dangerous = rule.config.get("dangerous_commands", ["FLUSHDB", "FLUSHALL", "CONFIG", "DEBUG", "SHUTDOWN"])
|
||||
# 排除已由 CMD-001 拦截的 FLUSHDB/FLUSHALL/CONFIG
|
||||
dangerous = rule.config.get("dangerous_commands", ["DEBUG", "SHUTDOWN"])
|
||||
if cmd in dangerous:
|
||||
report.findings.append(AuditFindingV2(
|
||||
rule_code=rule.code,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""数据库类型说明 API 测试 — db_types 端点。
|
||||
|
||||
覆盖:
|
||||
- GET /db-types — 列表概览
|
||||
- GET /db-types/{db_type} — 详细信息
|
||||
- GET /db-types/{db_type}/commands — 常用命令
|
||||
- GET /db-types/{db_type}/tips — 使用技巧
|
||||
- 404 错误处理
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
"""返回认证头。"""
|
||||
from app.core.security import create_access_token
|
||||
token = create_access_token({"sub": "1", "role": "admin"})
|
||||
return {"Authorization": "Bearer " + token}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_db_types(client, admin_token):
|
||||
"""测试获取所有数据库类型概览。"""
|
||||
resp = await client.get("/api/v1/db-types", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["total"] == 4
|
||||
|
||||
types = {item["type"] for item in data["items"]}
|
||||
assert types == {"mysql", "postgres", "redis", "mongo"}
|
||||
|
||||
# 检查每个条目的结构
|
||||
for item in data["items"]:
|
||||
assert "type" in item
|
||||
assert "name" in item
|
||||
assert "icon" in item
|
||||
assert "description" in item
|
||||
assert "category" in item
|
||||
assert "version" in item
|
||||
assert "supported_operations" in item
|
||||
assert item["supported_operations"] > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mysql_detail(client, admin_token):
|
||||
"""测试获取 MySQL 详细信息。"""
|
||||
resp = await client.get("/api/v1/db-types/mysql", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "mysql"
|
||||
assert data["name"] == "MySQL"
|
||||
assert data["category"] == "关系型"
|
||||
assert len(data["supported_query_types"]) >= 4
|
||||
assert len(data["security_rules"]) >= 1
|
||||
assert len(data["tips"]) >= 1
|
||||
assert len(data["common_commands"]) >= 1
|
||||
assert len(data["limitations"]) >= 1
|
||||
|
||||
# 检查查询类型结构
|
||||
for qt in data["supported_query_types"]:
|
||||
assert "type" in qt
|
||||
assert "description" in qt
|
||||
assert "example" in qt
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_postgres_detail(client, admin_token):
|
||||
"""测试获取 PostgreSQL 详细信息。"""
|
||||
resp = await client.get("/api/v1/db-types/postgres", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "postgres"
|
||||
assert data["name"] == "PostgreSQL"
|
||||
assert data["category"] == "关系型"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_redis_detail(client, admin_token):
|
||||
"""测试获取 Redis 详细信息。"""
|
||||
resp = await client.get("/api/v1/db-types/redis", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "redis"
|
||||
assert data["name"] == "Redis"
|
||||
assert data["category"] == "键值型"
|
||||
assert len(data["supported_query_types"]) >= 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mongo_detail(client, admin_token):
|
||||
"""测试获取 MongoDB 详细信息。"""
|
||||
resp = await client.get("/api/v1/db-types/mongo", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["type"] == "mongo"
|
||||
assert data["name"] == "MongoDB"
|
||||
assert data["category"] == "文档型"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_unknown_db_type(client, admin_token):
|
||||
"""测试获取不支持的数据库类型 — 404。"""
|
||||
resp = await client.get("/api/v1/db-types/oracle", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert "不支持的数据库类型" in resp.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_commands(client, admin_token):
|
||||
"""测试获取常用命令列表。"""
|
||||
resp = await client.get("/api/v1/db-types/mysql/commands", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["total"] >= 5
|
||||
|
||||
for cmd in data["items"]:
|
||||
assert "name" in cmd
|
||||
assert "command" in cmd
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_commands_redis(client, admin_token):
|
||||
"""测试获取 Redis 常用命令列表。"""
|
||||
resp = await client.get("/api/v1/db-types/redis/commands", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 5
|
||||
|
||||
cmd_names = {cmd["name"] for cmd in data["items"]}
|
||||
assert "查看键数量" in cmd_names or "查看服务器信息" in cmd_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_commands_unknown(client, admin_token):
|
||||
"""测试获取不支持类型的命令 — 404。"""
|
||||
resp = await client.get("/api/v1/db-types/cassandra/commands", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tips(client, admin_token):
|
||||
"""测试获取使用技巧。"""
|
||||
resp = await client.get("/api/v1/db-types/mysql/tips", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "items" in data
|
||||
assert data["total"] >= 3
|
||||
# 每条 tip 是字符串
|
||||
for tip in data["items"]:
|
||||
assert isinstance(tip, str)
|
||||
assert len(tip) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tips_mongo(client, admin_token):
|
||||
"""测试获取 MongoDB 使用技巧。"""
|
||||
resp = await client.get("/api/v1/db-types/mongo/tips", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["total"] >= 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tips_unknown(client, admin_token):
|
||||
"""测试获取不支持类型的技巧 — 404。"""
|
||||
resp = await client.get("/api/v1/db-types/sqlite/tips", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_types_require_auth(client):
|
||||
"""测试未认证请求被拒绝。"""
|
||||
resp = await client.get("/api/v1/db-types")
|
||||
|
||||
assert resp.status_code in (401, 403)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_structure_consistency(client, admin_token):
|
||||
"""测试所有类型的列表条目结构一致。"""
|
||||
resp = await client.get("/api/v1/db-types", headers={"Authorization": "Bearer " + admin_token})
|
||||
|
||||
data = resp.json()
|
||||
required_fields = {"type", "name", "icon", "description", "category", "version", "supported_operations", "security_rules_count"}
|
||||
for item in data["items"]:
|
||||
assert required_fields.issubset(set(item.keys())), \
|
||||
"Missing fields: " + str(required_fields - set(item.keys()))
|
||||
Reference in new issue
Block a user