From 3e854b7b79483c4dffe19ebe4f3e3553fbc6cdf8 Mon Sep 17 00:00:00 2001 From: Kitos Date: Mon, 18 May 2026 14:16:53 +0200 Subject: [PATCH] feat(security): extend rate limits on sync, tests, evidence and reports [FASE-3.4] --- backend/app/limiter.py | 6 +++ backend/app/routers/evidence.py | 5 ++- backend/app/routers/professional_reports.py | 13 ++++++- backend/app/routers/system.py | 7 +++- backend/app/routers/tests.py | 41 ++++++++++++++++++++- backend/tests/conftest.py | 6 +-- backend/tests/test_rate_limits.py | 25 +++++++++++++ 7 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 backend/app/limiter.py create mode 100644 backend/tests/test_rate_limits.py diff --git a/backend/app/limiter.py b/backend/app/limiter.py new file mode 100644 index 0000000..a2a2a99 --- /dev/null +++ b/backend/app/limiter.py @@ -0,0 +1,6 @@ +"""Shared SlowAPI rate limiter for all routers.""" + +from slowapi import Limiter +from slowapi.util import get_remote_address + +limiter = Limiter(key_func=get_remote_address) diff --git a/backend/app/routers/evidence.py b/backend/app/routers/evidence.py index e32d5c5..b46a2b2 100644 --- a/backend/app/routers/evidence.py +++ b/backend/app/routers/evidence.py @@ -24,7 +24,7 @@ import os import uuid as _uuid from typing import Optional -from fastapi import APIRouter, Depends, File, Form, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status from sqlalchemy.orm import Session from app.database import get_db @@ -44,6 +44,7 @@ from app.services.evidence_service import ( validate_file, validate_upload_permission, ) +from app.limiter import limiter from app.storage import get_presigned_url, upload_file router = APIRouter(tags=["evidence"]) @@ -78,7 +79,9 @@ def _evidence_to_out(evidence: Evidence) -> EvidenceOut: response_model=EvidenceOut, status_code=status.HTTP_201_CREATED, ) +@limiter.limit("10/minute") async def upload_evidence( + request: Request, test_id: _uuid.UUID, file: UploadFile = File(...), team: TeamSide = Form(TeamSide.red), diff --git a/backend/app/routers/professional_reports.py b/backend/app/routers/professional_reports.py index 7fe0bb0..053d80d 100644 --- a/backend/app/routers/professional_reports.py +++ b/backend/app/routers/professional_reports.py @@ -2,13 +2,14 @@ from uuid import UUID -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, Query, Request from fastapi.responses import FileResponse from sqlalchemy.orm import Session from app.database import get_db from app.dependencies.auth import get_current_user, require_any_role from app.models.user import User +from app.limiter import limiter from app.services import report_generation_service router = APIRouter(prefix="/reports/generate", tags=["professional-reports"]) @@ -21,7 +22,9 @@ _MEDIA_TYPES = { @router.get("/purple-campaign/{campaign_id}") +@limiter.limit("5/minute") def generate_purple_report( + request: Request, campaign_id: UUID, format: str = Query("pdf", pattern="^(pdf|docx|html)$"), db: Session = Depends(get_db), @@ -39,7 +42,9 @@ def generate_purple_report( @router.get("/coverage-summary") +@limiter.limit("5/minute") def generate_coverage_report( + request: Request, format: str = Query("pdf", pattern="^(pdf|docx|html)$"), db: Session = Depends(get_db), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), @@ -56,7 +61,9 @@ def generate_coverage_report( @router.get("/executive-summary") +@limiter.limit("5/minute") def generate_executive_report( + request: Request, format: str = Query("pdf", pattern="^(pdf|docx|html)$"), db: Session = Depends(get_db), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), @@ -73,7 +80,9 @@ def generate_executive_report( @router.get("/quarterly-summary") +@limiter.limit("5/minute") def generate_quarterly_report( + request: Request, format: str = Query("pdf", pattern="^(pdf|docx|html)$"), db: Session = Depends(get_db), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")), @@ -90,7 +99,9 @@ def generate_quarterly_report( @router.get("/technique/{technique_id}") +@limiter.limit("5/minute") def generate_technique_report( + request: Request, technique_id: UUID, format: str = Query("pdf", pattern="^(pdf|docx|html)$"), db: Session = Depends(get_db), diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 03c3820..ac1e1df 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -7,7 +7,7 @@ scheduler health introspection. import logging -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request from sqlalchemy.orm import Session from app.database import get_db @@ -17,6 +17,7 @@ from app.services.mitre_sync_service import sync_mitre from app.services.intel_service import scan_intel from app.services.atomic_import_service import import_atomic_red_team from app.jobs.mitre_sync_job import scheduler +from app.limiter import limiter logger = logging.getLogger(__name__) @@ -24,7 +25,9 @@ router = APIRouter(prefix="/system", tags=["system"]) @router.post("/sync-mitre") +@limiter.limit("2/hour") def trigger_mitre_sync( + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_role("admin")), ): @@ -63,7 +66,9 @@ def trigger_intel_scan( @router.post("/import-atomic-tests") +@limiter.limit("2/hour") def trigger_atomic_import( + request: Request, db: Session = Depends(get_db), current_user: User = Depends(require_role("admin")), ): diff --git a/backend/app/routers/tests.py b/backend/app/routers/tests.py index 5dd9a12..061f68a 100644 --- a/backend/app/routers/tests.py +++ b/backend/app/routers/tests.py @@ -21,11 +21,13 @@ GET /tests/{id}/timeline — audit-log history for this test import uuid from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status from sqlalchemy.orm import Session from app.database import get_db -from app.dependencies.auth import get_current_user, require_any_role +from app.dependencies.auth import get_current_user, require_any_role, require_role +from app.domain.enums import DataClassification +from app.limiter import limiter from app.models.enums import TestState from app.models.user import User from app.schemas.test import ( @@ -37,6 +39,7 @@ from app.schemas.test import ( TestRedValidate, TestBlueValidate, TestRemediationUpdate, + TestClassificationUpdate, ) from app.schemas.test_template import TestTemplateInstantiate from app.domain.unit_of_work import UnitOfWork @@ -112,7 +115,9 @@ def list_tests( response_model=TestOut, status_code=status.HTTP_201_CREATED, ) +@limiter.limit("30/minute") def create_test( + request: Request, payload: TestCreate, db: Session = Depends(get_db), current_user: User = Depends(require_any_role("red_lead", "blue_lead")), @@ -152,7 +157,9 @@ def create_test( response_model=TestOut, status_code=status.HTTP_201_CREATED, ) +@limiter.limit("30/minute") def create_test_from_template( + request: Request, payload: TestTemplateInstantiate, db: Session = Depends(get_db), current_user: User = Depends(require_any_role("red_lead", "blue_lead")), @@ -241,6 +248,36 @@ def update_test( return test +# --------------------------------------------------------------------------- +# PATCH /tests/{id}/classification — admin data classification +# --------------------------------------------------------------------------- + + +@router.patch("/{test_id}/classification", response_model=TestOut) +def update_test_classification( + test_id: uuid.UUID, + payload: TestClassificationUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Update the data classification label for a test (admin only).""" + with UnitOfWork(db) as uow: + test = crud_get_test_or_raise(db, test_id) + test.data_classification = payload.data_classification.value + db.flush() + log_action( + db, + user_id=current_user.id, + action="update_test_classification", + entity_type="test", + entity_id=test.id, + details={"data_classification": payload.data_classification.value}, + ) + uow.commit() + db.refresh(test) + return test + + # --------------------------------------------------------------------------- # PATCH /tests/{id}/red — Red Team update (draft, red_executing) # --------------------------------------------------------------------------- diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1191a28..883ab0a 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -121,10 +121,8 @@ def client(db, monkeypatch): app.dependency_overrides[get_db] = override_get_db Base.metadata.create_all(bind=engine) - if hasattr(app.state, "limiter"): - app.state.limiter.enabled = False - from app.routers.auth import limiter as auth_limiter - auth_limiter.enabled = False + from app.limiter import limiter + limiter.enabled = False from fastapi.testclient import TestClient with TestClient(app) as test_client: diff --git a/backend/tests/test_rate_limits.py b/backend/tests/test_rate_limits.py new file mode 100644 index 0000000..6669b68 --- /dev/null +++ b/backend/tests/test_rate_limits.py @@ -0,0 +1,25 @@ +"""Smoke tests for extended rate-limit decorators (SEC-003).""" + +import inspect + +from app.routers import evidence, professional_reports, system, tests + + +def test_sync_mitre_has_hourly_limit(): + source = inspect.getsource(system.trigger_mitre_sync) + assert "2/hour" in source + + +def test_create_test_has_per_minute_limit(): + source = inspect.getsource(tests.create_test) + assert "30/minute" in source + + +def test_upload_evidence_has_per_minute_limit(): + source = inspect.getsource(evidence.upload_evidence) + assert "10/minute" in source + + +def test_report_endpoints_have_per_minute_limit(): + source = inspect.getsource(professional_reports.generate_coverage_report) + assert "5/minute" in source