diff --git a/.dockerignore b/.dockerignore index 3ed92d2..28a1b76 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,6 +4,7 @@ __pycache__/ *.py[cod] .pytest_cache/ +.ruff_cache/ logs/ docs/ diff --git a/.gitignore b/.gitignore index ecf95cb..c9b5504 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ __pycache__/ *.py[cod] .pytest_cache/ +.ruff_cache/ # Runtime logs /logs/ diff --git a/app/core/config.py b/app/core/config.py index 1dd7e6f..348d24c 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -25,7 +25,7 @@ class Settings(BaseSettings): approval_api_actor: str = ActorValue.APPROVER cors_origins: list[str] = Field(default_factory=lambda: ["*"]) - database_url: str = "mysql+pymysql://root:password@127.0.0.1:3306/company_ai?charset=utf8mb4" + database_url: str = "sqlite:///./company_ai.db" legacy_database_url: str | None = None legacy_project_query: str | None = None legacy_allowed_queries: dict[str, str] = Field(default_factory=dict) diff --git a/app/core/database.py b/app/core/database.py index 2e41d9b..e559571 100644 --- a/app/core/database.py +++ b/app/core/database.py @@ -23,6 +23,16 @@ LegacySessionLocal = ( else None ) +__all__ = [ + "Base", + "LegacySessionLocal", + "SessionLocal", + "engine", + "get_db", + "get_legacy_db", + "legacy_engine", +] + def get_db() -> Generator[Session, None, None]: """Yield an application database session for FastAPI dependencies.""" diff --git a/app/modules/approvals/schemas.py b/app/modules/approvals/schemas.py index f1f8988..b8b427b 100644 --- a/app/modules/approvals/schemas.py +++ b/app/modules/approvals/schemas.py @@ -16,7 +16,6 @@ class ApprovalCreate(BaseModel): class ApprovalDecision(BaseModel): - approver: str comment: str | None = None diff --git a/app/modules/audit/constants.py b/app/modules/audit/constants.py index c9a8e96..da5840f 100644 --- a/app/modules/audit/constants.py +++ b/app/modules/audit/constants.py @@ -39,3 +39,23 @@ class AuditTargetType(StrEnum): class AuditStatus(StrEnum): SUCCESS = "success" + + +AUDIT_REDACTED_VALUE = "[REDACTED]" +AUDIT_SENSITIVE_KEYS = frozenset( + { + "authorization", + "api_key", + "apikey", + "access_token", + "tenant_access_token", + "token", + "secret", + "password", + "openclaw_gateway_token", + "hermes_api_key", + "direct_llm_api_key", + "feishu_app_secret", + "feishu_verification_token", + } +) diff --git a/app/modules/audit/service.py b/app/modules/audit/service.py index 81a5383..ded98b3 100644 --- a/app/modules/audit/service.py +++ b/app/modules/audit/service.py @@ -5,10 +5,28 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.core.pagination import bounded_limit +from app.modules.audit.constants import AUDIT_REDACTED_VALUE, AUDIT_SENSITIVE_KEYS from app.modules.audit.models import AuditLog from app.modules.audit.schemas import AuditLogCreate +def _redact(value: Any) -> Any: + if isinstance(value, dict): + safe: dict[str, Any] = {} + for key, item in value.items(): + key_text = str(key) + if key_text.lower() in AUDIT_SENSITIVE_KEYS: + safe[key_text] = AUDIT_REDACTED_VALUE + else: + safe[key_text] = _redact(item) + return safe + if isinstance(value, list): + return [_redact(item) for item in value] + if isinstance(value, tuple): + return [_redact(item) for item in value] + return value + + def _dump(value: Any | None) -> str | None: """Serialize audit payloads while preserving existing strings.""" @@ -16,7 +34,7 @@ def _dump(value: Any | None) -> str | None: return None if isinstance(value, str): return value - return json.dumps(value, ensure_ascii=False, default=str) + return json.dumps(_redact(value), ensure_ascii=False, default=str) class AuditService: diff --git a/app/modules/legacy_mysql/service.py b/app/modules/legacy_mysql/service.py index 0f9e0fe..75a9446 100644 --- a/app/modules/legacy_mysql/service.py +++ b/app/modules/legacy_mysql/service.py @@ -175,7 +175,13 @@ class LegacyMySQLService: self._ensure_readonly(sql) engine = self._ensure_engine() params = dict(params or {}) - params.setdefault("limit", bounded_limit(limit)) + try: + params["limit"] = bounded_limit(params.get("limit", limit)) + except (TypeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Invalid readonly query limit", + ) from exc limited_sql = sql if " limit " not in sql.lower(): limited_sql = f"{sql.rstrip(';')} LIMIT :limit" diff --git a/environment.yml b/environment.yml index 303f7a4..34f1b59 100644 --- a/environment.yml +++ b/environment.yml @@ -21,3 +21,4 @@ dependencies: - cryptography==44.0.0 - pandas==2.2.3 - pytest==8.3.4 + - ruff==0.8.4 diff --git a/pyproject.toml b/pyproject.toml index 815ee71..d107b9d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,10 @@ requires-python = ">=3.11" line-length = 100 target-version = "py311" +[tool.ruff.lint.per-file-ignores] +"scripts/verify_smoke.py" = ["E402"] +"tests/test_smoke.py" = ["E402"] + [tool.pytest.ini_options] pythonpath = ["."] testpaths = ["tests"] diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c0efac5..f614d9d 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -33,6 +33,7 @@ from app.core.database import Base, engine from app.core.pagination import bounded_limit, bounded_offset from app.core.security import require_api_key, require_approval_api_key from app.main import _allow_cors_credentials, app +from app.modules.audit.constants import AUDIT_REDACTED_VALUE from app.modules.legacy_mysql.service import LegacyMySQLService from app.modules.reports.constants import ( LifecycleAttentionKey, @@ -108,6 +109,12 @@ def test_feishu_webhook_routes_message_event() -> None: assert data["handled"] is True assert data["result"]["command"] == "risk_summary" + logs_response = client.get("/api/v1/audit/logs", headers=headers) + assert logs_response.status_code == 200 + audit_payload = json.dumps(logs_response.json(), ensure_ascii=False) + assert "test-feishu-token" not in audit_payload + assert AUDIT_REDACTED_VALUE in audit_payload + def test_api_key_and_feishu_webhook_fail_closed(monkeypatch) -> None: monkeypatch.setenv("API_KEY", "") @@ -195,7 +202,7 @@ def test_approval_gate_for_high_risk_update() -> None: approve_create_response = client.post( f"/api/v1/approvals/{create_ticket_id}/approve", headers=approval_headers, - json={"approver": "spoofed-manager", "comment": "ok"}, + json={"comment": "ok"}, ) assert approve_create_response.status_code == 200 assert approve_create_response.json()["approver"] == "approval-manager" @@ -606,3 +613,55 @@ def test_legacy_readonly_query_requires_allowlist(monkeypatch) -> None: finally: monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) get_settings.cache_clear() + + +def test_legacy_readonly_query_clamps_param_limit(monkeypatch) -> None: + captured: dict[str, dict] = {} + + class FakeResult: + def mappings(self) -> "FakeResult": + return self + + def all(self) -> list: + return [] + + class FakeConnection: + def __enter__(self) -> "FakeConnection": + return self + + def __exit__(self, exc_type, exc, traceback) -> None: + return None + + def execute(self, statement, params): + captured["params"] = params + return FakeResult() + + class FakeEngine: + def connect(self) -> FakeConnection: + return FakeConnection() + + monkeypatch.setenv("LEGACY_PROJECT_QUERY", "SELECT id FROM projects LIMIT :limit") + get_settings.cache_clear() + monkeypatch.setattr( + LegacyMySQLService, + "_ensure_engine", + staticmethod(lambda: FakeEngine()), + ) + try: + result = LegacyMySQLService(None).execute_readonly( + "SELECT id FROM projects LIMIT :limit", + {"limit": 9999}, + limit=9999, + ) + assert result["row_count"] == 0 + assert captured["params"]["limit"] == 500 + + with pytest.raises(HTTPException) as exc_info: + LegacyMySQLService(None).execute_readonly( + "SELECT id FROM projects LIMIT :limit", + {"limit": "invalid"}, + ) + assert exc_info.value.status_code == 422 + finally: + monkeypatch.delenv("LEGACY_PROJECT_QUERY", raising=False) + get_settings.cache_clear()