diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..c19149b --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,6 @@ +venv/ +__pycache__/ +*.pyc +.pytest_cache/ +*.db +alembic/versions/__pycache__/ diff --git a/backend/Dockerfile b/backend/Dockerfile index 48df504..53a6724 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,15 +2,30 @@ FROM python:3.11-slim WORKDIR /app +# Install system deps RUN apt-get update && apt-get install -y --no-install-recommends \ - gcc libmariadb-dev libpq-dev && \ + gcc libffi-dev && \ rm -rf /var/lib/apt/lists/* +# Install Python deps COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +# Copy app COPY . . +# Default env +ENV DATABASE_URL="sqlite+aiosqlite:///./data/sqlite3.db" +ENV SECRET_KEY="change-me-in-production" +ENV ADMIN_USERNAME="admin" +ENV ADMIN_PASSWORD="changeme" +ENV ENV="production" +ENV DEBUG="false" + +# Create data dir +RUN mkdir -p /app/data + EXPOSE 9000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "9000", "--reload"] +# Run alembic + uvicorn +CMD ["sh", "-c", "alembic upgrade head && python -m uvicorn app.main:app --host 0.0.0.0 --port 9000"] diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..0791816 --- /dev/null +++ b/backend/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..61eda46 --- /dev/null +++ b/backend/alembic/env.py @@ -0,0 +1,61 @@ +from logging.config import fileConfig +import os +import sys + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from app.core.database import Base +from app.core.config import get_settings + +# Import all models so Alembic can detect them +from app.models.user import User # noqa +from app.models.instance import Instance # noqa +from app.models.ticket import Ticket # noqa +from app.models.audit_log import AuditLog # noqa +from app.models.audit_rule import AuditRule # noqa + +config = context.config + +# Override sqlalchemy.url from settings +config.set_main_option("sqlalchemy.url", get_settings().DATABASE_URL.replace("+aiosqlite", "")) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/backend/alembic/versions/5c8b75f38c7e_initial_schema.py b/backend/alembic/versions/5c8b75f38c7e_initial_schema.py new file mode 100644 index 0000000..90e2d61 --- /dev/null +++ b/backend/alembic/versions/5c8b75f38c7e_initial_schema.py @@ -0,0 +1,32 @@ +"""initial_schema + +Revision ID: 5c8b75f38c7e +Revises: +Create Date: 2026-05-22 13:32:02.584732 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5c8b75f38c7e' +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/backend/app/api/v1/endpoints/audit_rule.py b/backend/app/api/v1/endpoints/audit_rule.py new file mode 100644 index 0000000..c6a4664 --- /dev/null +++ b/backend/app/api/v1/endpoints/audit_rule.py @@ -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} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index c8bdeb4..8f1b0fd 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/main.py b/backend/app/main.py index d187871..092e2de 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/audit_rule.py b/backend/app/models/audit_rule.py new file mode 100644 index 0000000..55ed2fe --- /dev/null +++ b/backend/app/models/audit_rule.py @@ -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()) diff --git a/backend/app/schemas/audit_rule.py b/backend/app/schemas/audit_rule.py new file mode 100644 index 0000000..7c02372 --- /dev/null +++ b/backend/app/schemas/audit_rule.py @@ -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 diff --git a/backend/app/services/query_service.py b/backend/app/services/query_service.py index 0d2223c..2a4b870 100644 --- a/backend/app/services/query_service.py +++ b/backend/app/services/query_service.py @@ -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};" diff --git a/backend/app/services/sql_analyzer.py b/backend/app/services/sql_analyzer.py new file mode 100644 index 0000000..832cffb --- /dev/null +++ b/backend/app/services/sql_analyzer.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt index e31f553..a644bd6 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,15 +1,12 @@ -fastapi==0.115.6 -uvicorn[standard]==0.34.0 -sqlalchemy==2.0.36 -aiosqlite==0.20.0 -python-jose[cryptography]==3.3.0 -passlib[bcrypt]==1.7.4 -bcrypt==4.1.2 -python-multipart==0.0.20 -pydantic==2.10.4 -pydantic-settings==2.7.1 -httpx==0.28.1 -pymysql==1.1.1 -aiomysql==0.2.0 -cryptography==44.0.0 -greenlet==3.1.1 +fastapi>=0.104.0 +uvicorn[standard]>=0.24.0 +sqlalchemy[asyncio]>=2.0.23 +aiosqlite>=0.19.0 +pydantic>=2.5.0 +pydantic-settings>=2.1.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +python-multipart>=0.0.6 +cryptography>=41.0.0 +sqlparse>=0.4.4 +alembic>=1.13.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 64bf446..ff74c3d 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -11,6 +11,7 @@ from app.models.user import User from app.models.instance import Instance from app.models.ticket import Ticket from app.models.audit_log import AuditLog +from app.models.audit_rule import AuditRule SQLALCHEMY_DATABASE_URL = "sqlite+aiosqlite://" diff --git a/backend/tests/test_audit_rules.py b/backend/tests/test_audit_rules.py new file mode 100644 index 0000000..3f042cc --- /dev/null +++ b/backend/tests/test_audit_rules.py @@ -0,0 +1,165 @@ +"""Tests for Audit Rule CRUD API (Phase 4.2).""" +import pytest +from tests.conftest import auth_header + + +class TestListAuditRules: + @pytest.mark.asyncio + async def test_list_rules_admin(self, client, admin_token): + resp = await client.get("/api/v1/audit-rules", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) + + @pytest.mark.asyncio + async def test_list_rules_dba(self, client, dba_token): + resp = await client.get("/api/v1/audit-rules", headers=auth_header(dba_token)) + assert resp.status_code == 200 + + @pytest.mark.asyncio + async def test_list_rules_developer_forbidden(self, client, developer_token): + resp = await client.get("/api/v1/audit-rules", headers=auth_header(developer_token)) + assert resp.status_code == 403 + + +class TestCreateAuditRule: + @pytest.mark.asyncio + async def test_create_rule_success(self, client, admin_token): + resp = await client.post( + "/api/v1/audit-rules", + json={ + "name": "no_select_star", + "description": "禁止 SELECT *", + "rule_type": "performance", + "severity": "warning", + "db_type": "mysql", + "pattern": r"SELECT\s+\*", + "message": "禁止使用 SELECT *,请明确指定字段", + }, + headers=auth_header(admin_token), + ) + assert resp.status_code == 201 + data = resp.json() + assert data["name"] == "no_select_star" + assert data["rule_type"] == "performance" + assert data["is_enabled"] is True + + @pytest.mark.asyncio + async def test_create_duplicate_name(self, client, admin_token): + payload = { + "name": "dup_rule", + "rule_type": "safety", + "severity": "error", + "message": "test", + } + await client.post("/api/v1/audit-rules", json=payload, headers=auth_header(admin_token)) + resp = await client.post("/api/v1/audit-rules", json=payload, headers=auth_header(admin_token)) + assert resp.status_code == 400 + + @pytest.mark.asyncio + async def test_create_rule_non_admin(self, client, dba_token): + resp = await client.post( + "/api/v1/audit-rules", + json={"name": "dba_rule", "rule_type": "syntax", "severity": "info", "message": "test"}, + headers=auth_header(dba_token), + ) + assert resp.status_code == 403 + + +class TestGetAuditRule: + @pytest.mark.asyncio + async def test_get_rule(self, client, admin_token): + create = await client.post( + "/api/v1/audit-rules", + json={"name": "get_test", "rule_type": "naming", "severity": "info", "message": "test"}, + headers=auth_header(admin_token), + ) + rule_id = create.json()["id"] + resp = await client.get(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert resp.json()["name"] == "get_test" + + @pytest.mark.asyncio + async def test_get_nonexistent_rule(self, client, admin_token): + resp = await client.get("/api/v1/audit-rules/99999", headers=auth_header(admin_token)) + assert resp.status_code == 404 + + +class TestUpdateAuditRule: + @pytest.mark.asyncio + async def test_update_rule(self, client, admin_token): + create = await client.post( + "/api/v1/audit-rules", + json={"name": "update_test", "rule_type": "safety", "severity": "warning", "message": "old msg"}, + headers=auth_header(admin_token), + ) + rule_id = create.json()["id"] + resp = await client.put( + f"/api/v1/audit-rules/{rule_id}", + json={"severity": "error", "message": "new msg"}, + headers=auth_header(admin_token), + ) + assert resp.status_code == 200 + assert resp.json()["severity"] == "error" + assert resp.json()["message"] == "new msg" + + +class TestDeleteAuditRule: + @pytest.mark.asyncio + async def test_delete_rule(self, client, admin_token): + create = await client.post( + "/api/v1/audit-rules", + json={"name": "delete_test", "rule_type": "syntax", "severity": "info", "message": "test"}, + headers=auth_header(admin_token), + ) + rule_id = create.json()["id"] + resp = await client.delete(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token)) + assert resp.status_code == 200 + + # Verify deleted + get = await client.get(f"/api/v1/audit-rules/{rule_id}", headers=auth_header(admin_token)) + assert get.status_code == 404 + + +class TestToggleAuditRule: + @pytest.mark.asyncio + async def test_toggle_rule(self, client, admin_token): + create = await client.post( + "/api/v1/audit-rules", + json={"name": "toggle_test", "rule_type": "safety", "severity": "warning", "message": "test"}, + headers=auth_header(admin_token), + ) + rule_id = create.json()["id"] + assert create.json()["is_enabled"] is True + + # Toggle off + resp = await client.post(f"/api/v1/audit-rules/{rule_id}/toggle", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert resp.json()["is_enabled"] is False + + # Toggle back on + resp = await client.post(f"/api/v1/audit-rules/{rule_id}/toggle", headers=auth_header(admin_token)) + assert resp.status_code == 200 + assert resp.json()["is_enabled"] is True + + +class TestRuleStats: + @pytest.mark.asyncio + async def test_rule_stats(self, client, admin_token): + # Create some rules + await client.post( + "/api/v1/audit-rules", + json={"name": "stats_rule_1", "rule_type": "safety", "severity": "error", "message": "test"}, + headers=auth_header(admin_token), + ) + await client.post( + "/api/v1/audit-rules", + json={"name": "stats_rule_2", "rule_type": "performance", "severity": "warning", "message": "test"}, + headers=auth_header(admin_token), + ) + + resp = await client.get("/api/v1/audit-rules/stats/summary", headers=auth_header(admin_token)) + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 2 + assert data["enabled"] >= 2 + assert "by_type" in data diff --git a/backend/tests/test_sql_analyzer.py b/backend/tests/test_sql_analyzer.py new file mode 100644 index 0000000..ec33faa --- /dev/null +++ b/backend/tests/test_sql_analyzer.py @@ -0,0 +1,157 @@ +"""Tests for SQL AST Analyzer (Phase 4.1).""" +import pytest +from app.services.sql_analyzer import SQLAnalyzer, SQLType, RiskLevel + + +analyzer = SQLAnalyzer() + + +class TestSQLTypeDetection: + def test_select(self): + result = analyzer.analyze("SELECT id, name FROM users WHERE id = 1;") + assert result.sql_type == SQLType.SELECT + + def test_insert(self): + result = analyzer.analyze("INSERT INTO users (name) VALUES ('test');") + assert result.sql_type == SQLType.INSERT + + def test_update(self): + result = analyzer.analyze("UPDATE users SET name = 'test' WHERE id = 1;") + assert result.sql_type == SQLType.UPDATE + + def test_delete(self): + result = analyzer.analyze("DELETE FROM users WHERE id = 1;") + assert result.sql_type == SQLType.DELETE + + def test_drop_table(self): + result = analyzer.analyze("DROP TABLE users;") + assert result.sql_type == SQLType.DROP + + def test_truncate(self): + result = analyzer.analyze("TRUNCATE TABLE logs;") + assert result.sql_type == SQLType.TRUNCATE + + def test_alter_table(self): + result = analyzer.analyze("ALTER TABLE users ADD COLUMN age INT;") + assert result.sql_type == SQLType.ALTER + + def test_create_table(self): + result = analyzer.analyze("CREATE TABLE test (id INT PRIMARY KEY);") + assert result.sql_type == SQLType.CREATE + + def test_cte_select(self): + result = analyzer.analyze("WITH cte AS (SELECT 1) SELECT * FROM cte;") + assert result.sql_type == SQLType.SELECT + + def test_explain(self): + result = analyzer.analyze("EXPLAIN SELECT * FROM users;") + assert result.sql_type == SQLType.SELECT + + +class TestReadonlyValidation: + def test_select_is_safe(self): + safe, msg = analyzer.validate_readonly("SELECT id FROM users LIMIT 10;") + assert safe is True + + def test_insert_not_safe(self): + safe, msg = analyzer.validate_readonly("INSERT INTO users (name) VALUES ('test');") + assert safe is False + assert "INSERT" in msg + + def test_update_not_safe(self): + safe, msg = analyzer.validate_readonly("UPDATE users SET name = 'test' WHERE id = 1;") + assert safe is False + + def test_delete_not_safe(self): + safe, msg = analyzer.validate_readonly("DELETE FROM users WHERE id = 1;") + assert safe is False + + def test_drop_not_safe(self): + safe, msg = analyzer.validate_readonly("DROP TABLE users;") + assert safe is False + + def test_truncate_not_safe(self): + safe, msg = analyzer.validate_readonly("TRUNCATE TABLE logs;") + assert safe is False + + def test_alter_not_safe(self): + safe, msg = analyzer.validate_readonly("ALTER TABLE users ADD COLUMN age INT;") + assert safe is False + + def test_create_not_safe(self): + safe, msg = analyzer.validate_readonly("CREATE TABLE test (id INT);") + assert safe is False + + +class TestRiskLevels: + def test_select_is_safe_risk(self): + result = analyzer.analyze("SELECT * FROM users;") + assert result.risk_level == RiskLevel.SAFE + + def test_dml_with_where_is_medium(self): + result = analyzer.analyze("UPDATE users SET name = 'a' WHERE id = 1;") + assert result.risk_level == RiskLevel.MEDIUM + + def test_dml_without_where_is_high(self): + result = analyzer.analyze("UPDATE users SET name = 'a';") + assert result.risk_level == RiskLevel.HIGH + + def test_ddl_is_dangerous(self): + result = analyzer.analyze("DROP TABLE users;") + assert result.risk_level == RiskLevel.DANGEROUS + + +class TestSecurityChecks: + def test_multi_statement_rejected(self): + safe, msg = analyzer.validate_readonly("SELECT 1; DROP TABLE users;") + assert safe is False + assert "多条" in msg or "分号" in msg + + def test_into_outfile_rejected(self): + safe, msg = analyzer.validate_readonly("SELECT * INTO OUTFILE '/tmp/data.txt' FROM users;") + assert safe is False + assert "OUTFILE" in msg + + def test_subquery_dml_rejected(self): + safe, msg = analyzer.validate_readonly("SELECT * FROM (DELETE FROM users) AS t;") + assert safe is False + + def test_normal_subquery_ok(self): + safe, msg = analyzer.validate_readonly("SELECT * FROM (SELECT id FROM users) AS t;") + assert safe is True + + def test_semicolon_in_string_ok(self): + safe, msg = analyzer.validate_readonly("SELECT 'hello;world' FROM users;") + assert safe is True + + +class TestAnalysis: + def test_has_where(self): + result = analyzer.analyze("SELECT * FROM users WHERE id = 1;") + assert result.has_where is True + + def test_no_where(self): + result = analyzer.analyze("SELECT * FROM users;") + assert result.has_where is False + + def test_to_dict(self): + result = analyzer.analyze("SELECT id FROM users;") + d = result.to_dict() + assert "sql_type" in d + assert "risk_level" in d + assert "is_readonly" in d + assert d["sql_type"] == "SELECT" + assert d["is_readonly"] is True + + def test_complex_select(self): + result = analyzer.analyze(""" + SELECT u.id, u.name, o.total + FROM users u + JOIN orders o ON u.id = o.user_id + WHERE u.status = 'active' + ORDER BY o.total DESC + LIMIT 50; + """) + assert result.sql_type == SQLType.SELECT + assert result.is_readonly is True + assert result.has_where is True diff --git a/docker-compose.yml b/docker-compose.yml index 04adcec..5865806 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,31 +2,29 @@ version: '3.8' services: backend: - build: - context: ./backend - dockerfile: Dockerfile + build: ./backend + container_name: sql-audit-backend + restart: unless-stopped ports: - "9000:9000" - environment: - - ENV=development - - DEBUG=true - - SECRET_KEY=dev-secret-key-change-in-production - - DATABASE_URL=sqlite+aiosqlite:///./sqlite3.db volumes: - - ./backend:/app - - backend_data:/app/data + - backend-data:/app/data + environment: + - DATABASE_URL=sqlite+aiosqlite:///./data/sqlite3.db + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - ADMIN_USERNAME=${ADMIN_USERNAME:-admin} + - ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme} + - ENV=production + - DEBUG=false frontend: - build: - context: ./frontend - dockerfile: Dockerfile + build: ./frontend + container_name: sql-audit-frontend + restart: unless-stopped ports: - - "5173:5173" - volumes: - - ./frontend:/app - - /app/node_modules + - "80:80" depends_on: - backend volumes: - backend_data: + backend-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..ac33e3c --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +.git/ diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 8781490..c45c5f7 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,12 +1,13 @@ -FROM node:20-alpine +FROM node:20-alpine AS build WORKDIR /app - -COPY package.json . -RUN npm install - +COPY package*.json ./ +RUN npm ci COPY . . +RUN npm run build -EXPOSE 5173 - -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..0146993 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:9000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } +} diff --git a/frontend/src/pages/AuditRulesPage.tsx b/frontend/src/pages/AuditRulesPage.tsx index 15f7b7b..7260f29 100644 --- a/frontend/src/pages/AuditRulesPage.tsx +++ b/frontend/src/pages/AuditRulesPage.tsx @@ -1,134 +1,288 @@ -import { useState } from 'react'; -import { Plus, Search, Edit2, Trash2, Shield, AlertTriangle, AlertCircle, Info, CheckCircle, ToggleLeft, ToggleRight, FileCode } from 'lucide-react'; -import { Card, CardContent } from '@/components/ui/card'; -import { auditRules, dbTypeColorMap } from '@/lib/mock'; +import { useState, useEffect } from 'react'; +import request from '../api/request'; -const levelConfig = { - error: { icon: AlertTriangle, color: 'text-red-400 bg-red-400/10', label: '错误' }, - warning: { icon: AlertCircle, color: 'text-yellow-400 bg-yellow-400/10', label: '警告' }, - info: { icon: Info, color: 'text-blue-400 bg-blue-400/10', label: '提示' }, +interface AuditRule { + id: number; + name: string; + description: string | null; + rule_type: string; + severity: string; + db_type: string; + pattern: string | null; + is_enabled: boolean; + message: string; + created_at: string; + updated_at: string; +} + +const RULE_TYPES = [ + { value: 'syntax', label: '语法检查', color: 'bg-blue-100 text-blue-700' }, + { value: 'performance', label: '性能优化', color: 'bg-yellow-100 text-yellow-700' }, + { value: 'safety', label: '安全检查', color: 'bg-red-100 text-red-700' }, + { value: 'naming', label: '命名规范', color: 'bg-green-100 text-green-700' }, +]; + +const SEVERITY_MAP: Record = { + error: { label: '错误', color: 'bg-red-500 text-white' }, + warning: { label: '警告', color: 'bg-yellow-500 text-white' }, + info: { label: '提示', color: 'bg-blue-500 text-white' }, }; export default function AuditRulesPage() { - const [search, setSearch] = useState(''); - const [levelFilter, setLevelFilter] = useState(''); - const [rules, setRules] = useState(auditRules); - - const filtered = rules.filter(r => { - if (levelFilter && r.level !== levelFilter) return false; - if (search && !r.name.includes(search) && !r.description.includes(search)) return false; - return true; + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState({ rule_type: '', severity: '', is_enabled: '' }); + const [showCreate, setShowCreate] = useState(false); + const [stats, setStats] = useState(null); + const [form, setForm] = useState({ + name: '', description: '', rule_type: 'safety', severity: 'warning', + db_type: 'mysql', pattern: '', is_enabled: true, message: '', }); - const toggleRule = (id: string) => { - setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r)); - }; + useEffect(() => { fetchRules(); fetchStats(); }, []); + + async function fetchRules() { + try { + setLoading(true); + const params = new URLSearchParams(); + if (filter.rule_type) params.set('rule_type', filter.rule_type); + if (filter.severity) params.set('severity', filter.severity); + if (filter.is_enabled !== '') params.set('is_enabled', filter.is_enabled); + const res = await request.get('/audit-rules', { params }); + setRules(res.data); + } catch { /* */ } finally { setLoading(false); } + } + + async function fetchStats() { + try { + const res = await request.get('/audit-rules/stats/summary'); + setStats(res.data); + } catch { /* */ } + } + + async function handleCreate() { + try { + await request.post('/audit-rules', form); + setShowCreate(false); + setForm({ name: '', description: '', rule_type: 'safety', severity: 'warning', db_type: 'mysql', pattern: '', is_enabled: true, message: '' }); + fetchRules(); + fetchStats(); + } catch (err: any) { + alert(err.response?.data?.detail || '创建失败'); + } + } + + async function handleToggle(rule: AuditRule) { + try { + await request.post('/audit-rules/' + rule.id + '/toggle'); + fetchRules(); + fetchStats(); + } catch { /* */ } + } + + async function handleDelete(id: number) { + if (!confirm('确认删除此规则?')) return; + try { + await request.delete('/audit-rules/' + id); + fetchRules(); + fetchStats(); + } catch { /* */ } + } + + const getTypeInfo = (type: string) => RULE_TYPES.find(t => t.value === type) || RULE_TYPES[0]; + const getSeverityInfo = (sev: string) => SEVERITY_MAP[sev] || SEVERITY_MAP.info; return ( -
+
+ {/* Header */}
-

SQL 审核规则

-

配置 SQL 审核规则,确保数据变更安全合规

-
-
- - +

审核规则

+

管理 SQL 审核引擎的检查规则

+
-
- {[ - { label: '规则总数', value: rules.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' }, - { label: '已启用', value: rules.filter(r => r.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' }, - { label: '错误级', value: rules.filter(r => r.level === 'error').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' }, - { label: '警告级', value: rules.filter(r => r.level === 'warning').length, icon: AlertCircle, color: 'text-yellow-400', bg: 'bg-yellow-400/10' }, - ].map((s, i) => ( - - -
-
{s.value}
{s.label}
-
-
- ))} -
- -
-
- - setSearch(e.target.value)} /> + {/* Stats */} + {stats && ( +
+ {[ + { label: '总规则数', value: stats.total, color: 'text-gray-900' }, + { label: '已启用', value: stats.enabled, color: 'text-green-600' }, + { label: '已禁用', value: stats.disabled, color: 'text-gray-400' }, + { label: '类型', value: Object.keys(stats.by_type || {}).length, color: 'text-blue-600' }, + ].map(s => ( +
+

{s.label}

+

{s.value}

+
+ ))}
- setFilter({ ...filter, rule_type: e.target.value })} + className="px-3 py-2 border border-gray-200 rounded-lg text-sm" + > + + {RULE_TYPES.map(t => )} + + + +
- - -
- - - - - - - - - - - - - - {filtered.map(rule => { - const lc = levelConfig[rule.level]; - const LIcon = lc.icon; - return ( - - - - - - - - - - ); - })} - -
规则ID规则名称级别适用数据库描述状态操作
{rule.id}{rule.name} - - {lc.label} - - -
- {rule.dbTypes.map(dt => ( - {dt.toUpperCase()} - ))} -
-
{rule.description} - - -
- - -
-
+ {/* Create Modal */} + {showCreate && ( +
+
+

新建审核规则

+
+ setForm({ ...form, name: e.target.value })} + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" + /> +