feat: Phase 4 - SQL AST analyzer, audit rules CRUD, Alembic, Docker (128 tests)
This commit is contained in:
1 parent
e45fbf3586
commit
ff0a330e8a
23 files changed
+1404
-159
No files matched your search
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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};"
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user