Files
Aegis/backend/app/domain/entities/technique.py
T
kitos 394d5d9056 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!

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 17:04:51 +02:00

168 lines
5.7 KiB
Python

"""TechniqueEntity — pure domain object for a MITRE ATT&CK technique.
Owns the status recalculation logic that was previously in
``status_service.py``. Has **no** dependency on FastAPI, SQLAlchemy,
or any infrastructure concern.
Usage::
entity = TechniqueEntity.from_orm(technique_orm_model)
entity.recalculate_status(test_states_and_results)
entity.mark_reviewed()
entity.apply_to(technique_orm_model)
"""
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING
from app.domain.enums import TechniqueStatus, TestResult, TestState
from app.domain.value_objects.mitre_id import MitreId
if TYPE_CHECKING:
from app.models.technique import Technique as TechniqueORM
@dataclass(frozen=True)
class _TestSnapshot:
"""Minimal read-only view of a test for status calculation."""
state: TestState
detection_result: str | None
@dataclass
class TechniqueEntity:
"""Pure domain representation of a MITRE ATT&CK technique."""
id: uuid.UUID
mitre_id: str
name: str
tactic: str | None = None
description: str | None = None
platforms: list[str] = field(default_factory=list)
is_subtechnique: bool = False
parent_mitre_id: str | None = None
status_global: TechniqueStatus = TechniqueStatus.not_evaluated
review_required: bool = False
last_review_date: datetime | None = None
mitre_version: str | None = None
mitre_last_modified: datetime | None = None
# -- Factory -----------------------------------------------------------
@classmethod
def create(
cls,
*,
mitre_id: str,
name: str,
tactic: str | None = None,
description: str | None = None,
platforms: list[str] | None = None,
) -> TechniqueEntity:
"""Create a new technique, validating the MITRE ID format."""
validated_id = MitreId(mitre_id)
return cls(
id=uuid.uuid4(),
mitre_id=validated_id.value,
name=name,
tactic=tactic,
description=description,
platforms=platforms or [],
is_subtechnique=validated_id.is_subtechnique,
parent_mitre_id=validated_id.parent_id,
status_global=TechniqueStatus.not_evaluated,
)
@classmethod
def from_orm(cls, model: TechniqueORM) -> TechniqueEntity:
"""Build a TechniqueEntity from a SQLAlchemy Technique model."""
raw_status = model.status_global
if raw_status is None:
status = TechniqueStatus.not_evaluated
elif isinstance(raw_status, TechniqueStatus):
status = raw_status
else:
status = TechniqueStatus(raw_status)
return cls(
id=model.id,
mitre_id=model.mitre_id,
name=model.name,
tactic=model.tactic,
description=model.description,
platforms=model.platforms or [],
is_subtechnique=model.is_subtechnique or False,
parent_mitre_id=model.parent_mitre_id,
status_global=status,
review_required=model.review_required or False,
last_review_date=model.last_review_date,
mitre_version=getattr(model, "mitre_version", None),
mitre_last_modified=getattr(model, "mitre_last_modified", None),
)
def apply_to(self, model: TechniqueORM) -> None:
"""Copy mutable fields back onto the ORM model."""
model.status_global = self.status_global
model.review_required = self.review_required
model.last_review_date = self.last_review_date
# -- Business logic ----------------------------------------------------
def recalculate_status(
self,
test_snapshots: list[tuple[str, str | None]],
) -> TechniqueStatus:
"""Recompute ``status_global`` from a list of (state, detection_result) pairs.
Rules (v2):
1. No tests -> not_evaluated
2. All validated -> inspect detection results:
- All detected -> validated
- Any partially_detected -> partial
- Otherwise -> not_covered
3. Some validated, others in progress -> partial
4. All in intermediate states -> in_progress
Returns the new status (also set on the entity).
"""
tests = [
_TestSnapshot(
state=s if isinstance(s, TestState) else TestState(s),
detection_result=dr,
)
for s, dr in test_snapshots
]
if not tests:
self.status_global = TechniqueStatus.not_evaluated
elif all(t.state == TestState.validated for t in tests):
results = [t.detection_result for t in tests if t.detection_result]
if results and all(r == TestResult.detected or r == "detected" for r in results):
self.status_global = TechniqueStatus.validated
elif any(
r == TestResult.partially_detected or r == "partially_detected"
for r in results
):
self.status_global = TechniqueStatus.partial
else:
self.status_global = TechniqueStatus.not_covered
elif any(t.state == TestState.validated for t in tests):
self.status_global = TechniqueStatus.partial
else:
self.status_global = TechniqueStatus.in_progress
return self.status_global
def mark_reviewed(self) -> None:
"""Mark the technique as reviewed, clearing the review flag."""
self.review_required = False
self.last_review_date = datetime.utcnow()
def flag_for_review(self) -> None:
"""Flag the technique as needing review."""
self.review_required = True