Files
Aegis/backend/app/routers/scores.py
T
kitos 9ff0f04ba3 refactor(types): add comprehensive type annotations across backend Python codebase
Enable ANN rules in ruff.toml (flake8-annotations) and resolve all 221 violations:

ANN201/ANN202 — return types on 168 public/private functions:
- All 28 FastAPI routers: endpoints annotated with dict/list/specific schema/
  StreamingResponse/FileResponse/JSONResponse as appropriate
- main.py: lifespan→AsyncGenerator[None,None], exception handlers→JSONResponse
- database.py: get_db→Generator[Session,None,None], proxy methods→correct types
- middleware/request_context.py: dispatch→Response with Callable call_next type

ANN001/ANN002/ANN003 — 32 missing argument types:
- seed_demo.py: all db parameters typed as Session
- domain/unit_of_work.py: __aexit__ exc_type/exc_val/exc_tb typed with TracebackType
- services: audit_service user_id→UUID|None, heatmap_service query/model/builder,
  notification_service test→Test, tempo_service test→Test/user→User,
  test_workflow_service test_id→UUID, campaign_crud **fields→object,
  test_crud **fields→object (4 sites)

ANN401 — 16 Any usages resolved:
- Domain entities (campaign/technique/threat_actor/test_entity): replaced Any with
  actual ORM types via TYPE_CHECKING guards to avoid circular imports
- detection_rule_service: test_id/detection_rule_id/evaluator_id→UUID
- score_cache: kept Any with # noqa: ANN401 (genuinely generic cache)
- jira_service/tempo_service: kept Any with # noqa: ANN401 (lazy optional deps)
- d3fend_import_service: _to_str(v: Any) kept with # noqa: ANN401

ANN204/ANN205/ANN206 — special/static/class methods:
- database.py proxy __call__/__getattr__: *args: object/**kwargs: object
- schemas/test.py model_validate: obj→object, **kwargs→object
- sa_technique_repository._int_type→type

All 439 unit tests pass. ruff check app/ → All checks passed!
2026-06-11 11:06:54 +02:00

150 lines
4.8 KiB
Python

"""Scoring endpoints — technique, tactic, threat actor, and organization scores.
Provides granular scoring with breakdowns and configurable weights.
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role
from app.domain.unit_of_work import UnitOfWork
from app.models.user import User
from app.services.scoring_config_service import (
get_weights_dict,
update_scoring_weights,
)
from app.services.scoring_service import (
calculate_tactic_score,
get_score_history,
score_actor_by_id,
score_technique_by_mitre_id,
)
router = APIRouter(prefix="/scores", tags=["scores"])
# ── GET /scores/technique/{mitre_id} ─────────────────────────────────
@router.get("/technique/{mitre_id}")
def score_technique(
mitre_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Get detailed score with breakdown for a specific technique."""
return score_technique_by_mitre_id(db, mitre_id)
# ── GET /scores/tactic/{tactic} ──────────────────────────────────────
@router.get("/tactic/{tactic}")
def score_tactic(
tactic: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Get average score for a tactic."""
return calculate_tactic_score(tactic, db)
# ── GET /scores/threat-actor/{id} ────────────────────────────────────
@router.get("/threat-actor/{actor_id}")
def score_threat_actor(
actor_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Get coverage score against a specific threat actor."""
return score_actor_by_id(db, actor_id)
# ── GET /scores/organization ─────────────────────────────────────────
@router.get("/organization")
def score_organization(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Get the overall organization security score (cached for 5 min)."""
from app.services.score_cache import get_organization_score_cached
return get_organization_score_cached(db)
# ── GET /scores/history ──────────────────────────────────────────────
@router.get("/history")
def score_history(
period: str = Query("90d", pattern="^(30d|90d|1y)$"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Get historical score data points (weekly)."""
return get_score_history(db, period)
# ── GET /scores/config ───────────────────────────────────────────────
@router.get("/config")
def get_scoring_config(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
) -> dict:
"""Get current scoring weights (admin only)."""
return get_weights_dict(db)
# ── PATCH /scores/config ─────────────────────────────────────────────
class ScoringConfigUpdate(BaseModel):
tests: Optional[float] = None
detection_rules: Optional[float] = None
d3fend: Optional[float] = None
recency: Optional[float] = None
severity: Optional[float] = None
freshness: Optional[float] = None
platform_diversity: Optional[float] = None
@router.patch("/config")
def update_scoring_config(
payload: ScoringConfigUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
) -> dict:
"""Update scoring weights (admin only).
Weights are persisted in the database and survive restarts.
Validation enforces that all weights are non-negative and sum to 100.
"""
with UnitOfWork(db) as uow:
result = update_scoring_weights(
db,
tests=payload.tests,
detection_rules=payload.detection_rules,
d3fend=payload.d3fend,
recency=payload.recency,
severity=payload.severity,
freshness=payload.freshness,
platform_diversity=payload.platform_diversity,
updated_by=current_user.id,
)
uow.commit()
from app.services.score_cache import invalidate
invalidate()
return {"message": "Scoring config updated", **result}