feat: Phase 4 - SQL AST analyzer, audit rules CRUD, Alembic, Docker (128 tests)

This commit is contained in:
logan committed 2026-05-22 13:35:35 +08:00
1 parent e45fbf3586
commit ff0a330e8a
23 files changed
+1404 -159

No files matched your search

+138
View File
@@ -0,0 +1,138 @@
"""Audit rule management endpoints."""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import require_role
from app.models.user import User
from app.models.audit_rule import AuditRule
from app.schemas.audit_rule import AuditRuleCreate, AuditRuleUpdate, AuditRuleResponse
router = APIRouter(prefix="/audit-rules", tags=["审核规则"])
@router.get("", response_model=List[AuditRuleResponse])
async def list_audit_rules(
rule_type: Optional[str] = None,
severity: Optional[str] = None,
db_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
stmt = select(AuditRule).order_by(AuditRule.id)
if rule_type:
stmt = stmt.where(AuditRule.rule_type == rule_type)
if severity:
stmt = stmt.where(AuditRule.severity == severity)
if db_type:
stmt = stmt.where((AuditRule.db_type == db_type) | (AuditRule.db_type == "all"))
if is_enabled is not None:
stmt = stmt.where(AuditRule.is_enabled == is_enabled)
result = await db.execute(stmt)
return result.scalars().all()
@router.post("", response_model=AuditRuleResponse, status_code=201)
async def create_audit_rule(
data: AuditRuleCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
exists = await db.execute(select(AuditRule).where(AuditRule.name == data.name))
if exists.scalar_one_or_none():
raise HTTPException(status_code=400, detail="规则名称已存在")
rule = AuditRule(**data.model_dump())
db.add(rule)
await db.commit()
await db.refresh(rule)
return rule
@router.get("/{rule_id}", response_model=AuditRuleResponse)
async def get_audit_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
return rule
@router.put("/{rule_id}", response_model=AuditRuleResponse)
async def update_audit_rule(
rule_id: int,
data: AuditRuleUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(rule, key, value)
await db.commit()
await db.refresh(rule)
return rule
@router.delete("/{rule_id}")
async def delete_audit_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
await db.delete(rule)
await db.commit()
return {"message": "规则已删除"}
@router.post("/{rule_id}/toggle", response_model=AuditRuleResponse)
async def toggle_audit_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail="规则不存在")
rule.is_enabled = not rule.is_enabled
await db.commit()
await db.refresh(rule)
return rule
@router.get("/stats/summary")
async def rule_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
total_stmt = select(func.count(AuditRule.id))
total = (await db.execute(total_stmt)).scalar()
enabled_stmt = select(func.count(AuditRule.id)).where(AuditRule.is_enabled == True)
enabled = (await db.execute(enabled_stmt)).scalar()
type_stmt = select(AuditRule.rule_type, func.count(AuditRule.id)).group_by(AuditRule.rule_type)
type_result = (await db.execute(type_stmt)).all()
by_type = {row[0]: row[1] for row in type_result}
return {"total": total, "enabled": enabled, "disabled": total - enabled, "by_type": by_type}
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter
from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard, audit
from app.api.v1.endpoints import auth, user, instance, ticket, query, dashboard, audit, audit_rule
api_router = APIRouter(prefix="/api/v1")
@@ -11,3 +11,4 @@ api_router.include_router(ticket.router)
api_router.include_router(query.router)
api_router.include_router(dashboard.router)
api_router.include_router(audit.router)
api_router.include_router(audit_rule.router)
+1
View File
@@ -48,6 +48,7 @@ async def lifespan(app: FastAPI):
import app.models.instance # noqa
import app.models.ticket # noqa
import app.models.audit_log # noqa
import app.models.audit_rule # noqa
await init_db()
await seed_data()
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, func
from app.core.database import Base
class AuditRule(Base):
__tablename__ = "audit_rules"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, unique=True)
description = Column(Text, nullable=True)
rule_type = Column(String(50), nullable=False) # syntax / performance / safety / naming
severity = Column(String(20), default="warning") # error / warning / info
db_type = Column(String(20), default="mysql") # mysql / postgres / all
pattern = Column(Text, nullable=True) # regex or keyword pattern
is_enabled = Column(Boolean, default=True)
message = Column(String(500), nullable=False) # human-readable message
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from pydantic import BaseModel, Field
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"]
severity: Literal["error", "warning", "info"] = "warning"
db_type: str = "mysql"
pattern: Optional[str] = None
is_enabled: bool = True
message: str = Field(..., min_length=1, max_length=500)
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"]] = None
severity: Optional[Literal["error", "warning", "info"]] = None
db_type: Optional[str] = None
pattern: Optional[str] = None
is_enabled: Optional[bool] = None
message: Optional[str] = Field(default=None, min_length=1, max_length=500)
class AuditRuleResponse(BaseModel):
id: int
name: str
description: Optional[str] = None
rule_type: str
severity: str
db_type: str
pattern: Optional[str] = None
is_enabled: bool
message: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
+9 -6
View File
@@ -5,13 +5,14 @@ from typing import Optional
from app.models.instance import Instance
from app.schemas.query import QueryResult
from app.services.sql_analyzer import SQLAnalyzer
FORBIDDEN_KEYWORDS = ["DROP", "TRUNCATE", "DELETE", "UPDATE", "INSERT", "CREATE", "ALTER", "GRANT", "REVOKE"]
MAX_ROWS = 10000
class QueryService:
_analyzer = SQLAnalyzer()
@staticmethod
async def execute_query(
instance: Instance, database: Optional[str], sql: str, user_id: int
@@ -19,11 +20,13 @@ class QueryService:
if instance.db_type != "mysql":
raise ValueError(f"暂不支持 {instance.db_type} 查询")
upper_sql = sql.strip().upper()
for kw in FORBIDDEN_KEYWORDS:
if upper_sql.startswith(kw) or f" {kw} " in upper_sql:
raise ValueError(f"查询模式禁止执行 {kw} 操作")
# AST-based SQL validation (replaces keyword matching)
is_safe, error_msg = QueryService._analyzer.validate_readonly(sql)
if not is_safe:
raise ValueError(error_msg)
# Auto-add LIMIT if missing
upper_sql = sql.strip().upper()
if not re.search(r"LIMIT\s+\d+", upper_sql, re.IGNORECASE):
sql = sql.rstrip(";") + f" LIMIT {MAX_ROWS};"
+255
View File
@@ -0,0 +1,255 @@
"""SQL AST Analyzer — parse SQL using sqlparse for safe query validation.
Used by query endpoint to determine if a SQL statement is safe for read-only execution.
Separate from audit_engine.py (which is for ticket review scoring).
"""
import sqlparse
from sqlparse.sql import IdentifierList, Identifier, Where, Comparison
from sqlparse.tokens import Keyword, DML, DDL, Punctuation
from dataclasses import dataclass
from typing import List, Optional
from enum import Enum
class SQLType(str, Enum):
SELECT = "SELECT"
INSERT = "INSERT"
UPDATE = "UPDATE"
DELETE = "DELETE"
CREATE = "CREATE"
ALTER = "ALTER"
DROP = "DROP"
TRUNCATE = "TRUNCATE"
UNKNOWN = "UNKNOWN"
class RiskLevel(str, Enum):
SAFE = "safe" # SELECT only
LOW = "low" # SELECT with functions
MEDIUM = "medium" # DML with WHERE
HIGH = "high" # DML without WHERE
DANGEROUS = "dangerous" # DDL operations
@dataclass
class SQLAnalysis:
sql_type: SQLType
risk_level: RiskLevel
is_readonly: bool
has_where: bool
tables: List[str]
warnings: List[str]
errors: List[str]
def to_dict(self) -> dict:
return {
"sql_type": self.sql_type.value,
"risk_level": self.risk_level.value,
"is_readonly": self.is_readonly,
"has_where": self.has_where,
"tables": self.tables,
"warnings": self.warnings,
"errors": self.errors,
}
class SQLAnalyzer:
"""Parse and analyze SQL statements using sqlparse AST."""
# Statements that are always read-only
READONLY_TYPES = {SQLType.SELECT}
# Statements that modify data
DML_TYPES = {SQLType.INSERT, SQLType.UPDATE, SQLType.DELETE}
# Statements that change schema
DDL_TYPES = {SQLType.CREATE, SQLType.ALTER, SQLType.DROP, SQLType.TRUNCATE}
def analyze(self, sql: str) -> SQLAnalysis:
"""Analyze a SQL statement and return detailed analysis."""
sql = sql.strip()
# Detect SQL type from the first meaningful token
sql_type = self._detect_sql_type(sql)
# Parse with sqlparse
parsed = sqlparse.parse(sql)
if not parsed:
return SQLAnalysis(
sql_type=SQLType.UNKNOWN, risk_level=RiskLevel.SAFE,
is_readonly=True, has_where=False, tables=[],
warnings=[], errors=["无法解析 SQL 语句"],
)
statement = parsed[0]
tokens = list(statement.flatten())
# Extract tables
tables = self._extract_tables(statement)
# Check for WHERE clause
has_where = self._has_where(statement)
# Check for multiple statements (SQL injection vector)
multi_errors = self._check_multi_statement(sql)
# Determine risk level and generate warnings/errors
warnings = []
errors = list(multi_errors)
if sql_type in self.DDL_TYPES:
risk_level = RiskLevel.DANGEROUS
is_readonly = False
errors.append(f"DDL 操作 ({sql_type.value}) 在查询模式下被禁止")
elif sql_type in self.DML_TYPES:
is_readonly = False
if has_where:
risk_level = RiskLevel.MEDIUM
warnings.append(f"{sql_type.value} 操作包含 WHERE 条件,但查询模式为只读")
else:
risk_level = RiskLevel.HIGH
errors.append(f"{sql_type.value} 缺少 WHERE 条件,影响全表")
errors.append(f"查询模式禁止执行 {sql_type.value} 操作")
elif sql_type == SQLType.SELECT:
is_readonly = True
risk_level = RiskLevel.SAFE
# Check for INTO OUTFILE (writes to filesystem)
if self._has_into_outfile(statement):
risk_level = RiskLevel.DANGEROUS
is_readonly = False
errors.append("SELECT INTO OUTFILE 被禁止(文件写入风险)")
# Check for subqueries that modify data
sub_type = self._check_subquery_dml(sql)
if sub_type:
is_readonly = False
risk_level = RiskLevel.DANGEROUS
errors.append(f"子查询包含 {sub_type} 操作,被禁止")
else:
is_readonly = True
risk_level = RiskLevel.LOW
return SQLAnalysis(
sql_type=sql_type,
risk_level=risk_level,
is_readonly=is_readonly,
has_where=has_where,
tables=tables,
warnings=warnings,
errors=errors,
)
def validate_readonly(self, sql: str) -> tuple[bool, str]:
"""Validate SQL is read-only safe. Returns (is_safe, error_message)."""
analysis = self.analyze(sql)
if analysis.errors:
return False, "; ".join(analysis.errors)
if not analysis.is_readonly:
return False, f"查询模式仅允许 SELECT 语句,当前为 {analysis.sql_type.value}"
return True, ""
def _detect_sql_type(self, sql: str) -> SQLType:
"""Detect the SQL statement type from the first keyword."""
upper = sql.strip().upper()
type_map = {
"SELECT": SQLType.SELECT,
"INSERT": SQLType.INSERT,
"UPDATE": SQLType.UPDATE,
"DELETE": SQLType.DELETE,
"CREATE": SQLType.CREATE,
"ALTER": SQLType.ALTER,
"DROP": SQLType.DROP,
"TRUNCATE": SQLType.TRUNCATE,
"WITH": SQLType.SELECT, # CTE is still SELECT
"EXPLAIN": SQLType.SELECT,
}
for keyword, sql_type in type_map.items():
if upper.startswith(keyword):
return sql_type
return SQLType.UNKNOWN
def _extract_tables(self, statement) -> List[str]:
"""Extract table names from the statement."""
tables = []
seen = set()
# Get tokens after FROM or JOIN or INTO or UPDATE
tokens = list(statement.tokens)
capture = False
for token in tokens:
if token.ttype in (Keyword,) and token.normalized in ("FROM", "INTO", "UPDATE", "JOIN", "TABLE"):
capture = True
continue
if capture:
if isinstance(token, IdentifierList):
for ident in token.get_identifiers():
name = self._get_identifier_name(ident)
if name and name not in seen:
seen.add(name)
tables.append(name)
elif isinstance(token, Identifier):
name = self._get_identifier_name(token)
if name and name not in seen:
seen.add(name)
tables.append(name)
capture = False
return tables
def _get_identifier_name(self, ident) -> Optional[str]:
"""Get the table name from an identifier."""
if isinstance(ident, Identifier):
# Use the real name (not alias)
return ident.get_real_name()
return None
def _has_where(self, statement) -> bool:
"""Check if the statement has a WHERE clause."""
for token in statement.tokens:
if isinstance(token, Where):
return True
# Also check flattened tokens
for token in statement.flatten():
if token.ttype is Keyword and token.normalized == "WHERE":
return True
return False
def _has_into_outfile(self, statement) -> bool:
"""Check for INTO OUTFILE (filesystem write)."""
upper = str(statement).upper()
return "INTO OUTFILE" in upper or "INTO DUMPFILE" in upper
def _check_multi_statement(self, sql: str) -> List[str]:
"""Check for multiple statements (semicolons inside the SQL)."""
errors = []
stripped = sql.strip().rstrip(";")
# Count semicolons not inside strings
in_string = False
string_char = None
semi_count = 0
for ch in stripped:
if ch in ("'", '"') and not in_string:
in_string = True
string_char = ch
elif ch == string_char and in_string:
in_string = False
string_char = None
elif ch == ";" and not in_string:
semi_count += 1
if semi_count > 0:
errors.append("查询不允许包含多条 SQL 语句(分号分隔)")
return errors
def _check_subquery_dml(self, sql: str) -> Optional[str]:
"""Check if a SELECT contains DML subqueries."""
upper = sql.upper()
# Simple heuristic: look for INSERT/UPDATE/DELETE after a parenthesis
for kw in ("INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP"):
# Check if keyword appears inside parentheses
pattern = f"({kw} "
if pattern in upper:
return kw
return None