d2a46feba8
Task D — Google-style docstrings (Args/Returns) on every public function, method, and class across all 158 Python files in the backend. Zero ruff D violations (pydocstyle Google convention). Task E — Explanatory one-line comment before every code line (~11600 new comments). ruff check passes clean after isort re-sort.
285 lines
9.0 KiB
Python
285 lines
9.0 KiB
Python
"""Scoring endpoints — technique, tactic, threat actor, and organization scores.
|
|
|
|
Provides granular scoring with breakdowns and configurable weights.
|
|
"""
|
|
|
|
# Import Optional from typing
|
|
from typing import Optional
|
|
|
|
# Import APIRouter, Depends, Query from fastapi
|
|
from fastapi import APIRouter, Depends, Query
|
|
|
|
# Import BaseModel from pydantic
|
|
from pydantic import BaseModel
|
|
|
|
# Import Session from sqlalchemy.orm
|
|
from sqlalchemy.orm import Session
|
|
|
|
# Import get_db from app.database
|
|
from app.database import get_db
|
|
|
|
# Import get_current_user, require_role from app.dependencies.auth
|
|
from app.dependencies.auth import get_current_user, require_role
|
|
|
|
# Import UnitOfWork from app.domain.unit_of_work
|
|
from app.domain.unit_of_work import UnitOfWork
|
|
|
|
# Import User from app.models.user
|
|
from app.models.user import User
|
|
|
|
# Import from app.services.scoring_config_service
|
|
from app.services.scoring_config_service import (
|
|
get_weights_dict,
|
|
update_scoring_weights,
|
|
)
|
|
|
|
# Import from app.services.scoring_service
|
|
from app.services.scoring_service import (
|
|
calculate_tactic_score,
|
|
get_score_history,
|
|
score_actor_by_id,
|
|
score_technique_by_mitre_id,
|
|
)
|
|
|
|
# Assign router = APIRouter(prefix="/scores", tags=["scores"])
|
|
router = APIRouter(prefix="/scores", tags=["scores"])
|
|
|
|
|
|
# ── GET /scores/technique/{mitre_id} ─────────────────────────────────
|
|
|
|
|
|
@router.get("/technique/{mitre_id}")
|
|
# Define function score_technique
|
|
def score_technique(
|
|
# Entry: mitre_id
|
|
mitre_id: str,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
"""Get detailed score with breakdown for a specific technique.
|
|
|
|
Args:
|
|
mitre_id (str): MITRE ATT&CK technique ID (e.g. ``T1059``).
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
dict: Score value and component breakdown (tests, detection rules, recency, etc.).
|
|
"""
|
|
# Return score_technique_by_mitre_id(db, mitre_id)
|
|
return score_technique_by_mitre_id(db, mitre_id)
|
|
|
|
|
|
# ── GET /scores/tactic/{tactic} ──────────────────────────────────────
|
|
|
|
|
|
@router.get("/tactic/{tactic}")
|
|
# Define function score_tactic
|
|
def score_tactic(
|
|
# Entry: tactic
|
|
tactic: str,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
"""Get average score for a tactic.
|
|
|
|
Args:
|
|
tactic (str): MITRE ATT&CK tactic slug (e.g. ``initial-access``).
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
dict: Average score and per-technique breakdown for the tactic.
|
|
"""
|
|
# Return calculate_tactic_score(tactic, db)
|
|
return calculate_tactic_score(tactic, db)
|
|
|
|
|
|
# ── GET /scores/threat-actor/{id} ────────────────────────────────────
|
|
|
|
|
|
@router.get("/threat-actor/{actor_id}")
|
|
# Define function score_threat_actor
|
|
def score_threat_actor(
|
|
# Entry: actor_id
|
|
actor_id: str,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
"""Get coverage score against a specific threat actor.
|
|
|
|
Args:
|
|
actor_id (str): UUID string of the threat actor to score against.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
dict: Coverage score and per-technique breakdown for the threat actor.
|
|
"""
|
|
# Return score_actor_by_id(db, actor_id)
|
|
return score_actor_by_id(db, actor_id)
|
|
|
|
|
|
# ── GET /scores/organization ─────────────────────────────────────────
|
|
|
|
|
|
@router.get("/organization")
|
|
# Define function score_organization
|
|
def score_organization(
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
"""Get the overall organization security score (cached for 5 min).
|
|
|
|
Args:
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
dict: Aggregate organization score with tactic-level breakdowns.
|
|
"""
|
|
# Import get_organization_score_cached from app.services.score_cache
|
|
from app.services.score_cache import get_organization_score_cached
|
|
|
|
# Return get_organization_score_cached(db)
|
|
return get_organization_score_cached(db)
|
|
|
|
|
|
# ── GET /scores/history ──────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/history")
|
|
# Define function score_history
|
|
def score_history(
|
|
# Entry: period
|
|
period: str = Query("90d", pattern="^(30d|90d|1y)$"),
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(get_current_user),
|
|
) -> dict:
|
|
"""Get historical score data points (weekly).
|
|
|
|
Args:
|
|
period (str): Time window for history — one of ``30d``, ``90d``, or ``1y``.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated user making the request.
|
|
|
|
Returns:
|
|
dict: Weekly score data points for the requested period.
|
|
"""
|
|
# Return get_score_history(db, period)
|
|
return get_score_history(db, period)
|
|
|
|
|
|
# ── GET /scores/config ───────────────────────────────────────────────
|
|
|
|
|
|
@router.get("/config")
|
|
# Define function get_scoring_config
|
|
def get_scoring_config(
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
current_user: User = Depends(require_role("admin")),
|
|
) -> dict:
|
|
"""Get current scoring weights (admin only).
|
|
|
|
Args:
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated admin user.
|
|
|
|
Returns:
|
|
dict: Current weight values for each scoring component.
|
|
"""
|
|
# Return get_weights_dict(db)
|
|
return get_weights_dict(db)
|
|
|
|
|
|
# ── PATCH /scores/config ─────────────────────────────────────────────
|
|
|
|
|
|
class ScoringConfigUpdate(BaseModel):
|
|
"""Partial update payload for the scoring weight configuration."""
|
|
|
|
# Assign tests = None
|
|
tests: Optional[float] = None
|
|
# Assign detection_rules = None
|
|
detection_rules: Optional[float] = None
|
|
# Assign d3fend = None
|
|
d3fend: Optional[float] = None
|
|
# Assign recency = None
|
|
recency: Optional[float] = None
|
|
# Assign severity = None
|
|
severity: Optional[float] = None
|
|
# Assign freshness = None
|
|
freshness: Optional[float] = None
|
|
# Assign platform_diversity = None
|
|
platform_diversity: Optional[float] = None
|
|
|
|
|
|
# Apply the @router.patch decorator
|
|
@router.patch("/config")
|
|
# Define function update_scoring_config
|
|
def update_scoring_config(
|
|
# Entry: payload
|
|
payload: ScoringConfigUpdate,
|
|
# Entry: db
|
|
db: Session = Depends(get_db),
|
|
# Entry: current_user
|
|
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.
|
|
|
|
Args:
|
|
payload (ScoringConfigUpdate): Partial weight update; only set fields are changed.
|
|
db (Session): SQLAlchemy database session.
|
|
current_user (User): Authenticated admin user.
|
|
|
|
Returns:
|
|
dict: Confirmation message plus the full updated weight configuration.
|
|
"""
|
|
# Open context manager
|
|
with UnitOfWork(db) as uow:
|
|
# Assign result = update_scoring_weights(
|
|
result = update_scoring_weights(
|
|
db,
|
|
# Keyword argument: tests
|
|
tests=payload.tests,
|
|
# Keyword argument: detection_rules
|
|
detection_rules=payload.detection_rules,
|
|
# Keyword argument: d3fend
|
|
d3fend=payload.d3fend,
|
|
# Keyword argument: recency
|
|
recency=payload.recency,
|
|
# Keyword argument: severity
|
|
severity=payload.severity,
|
|
# Keyword argument: freshness
|
|
freshness=payload.freshness,
|
|
# Keyword argument: platform_diversity
|
|
platform_diversity=payload.platform_diversity,
|
|
# Keyword argument: updated_by
|
|
updated_by=current_user.id,
|
|
)
|
|
# Call uow.commit()
|
|
uow.commit()
|
|
|
|
# Import invalidate from app.services.score_cache
|
|
from app.services.score_cache import invalidate
|
|
# Call invalidate()
|
|
invalidate()
|
|
|
|
# Return {"message": "Scoring config updated", **result}
|
|
return {"message": "Scoring config updated", **result}
|