feat: add forensic sample analysis

This commit is contained in:
Christian Krakau-Louis
2026-05-23 13:46:32 +02:00
parent 5445508a71
commit e781b32a01
7 changed files with 523 additions and 24 deletions
+105 -20
View File
@@ -9,6 +9,7 @@ from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.domain import Domain
from app.models.report import ForensicReport
from app.services.forensic_analysis import analyze_forensic_report, summarize_forensic_samples
from app.services.forensic_parser import ForensicParser, MAX_FORENSIC_REPORT_SIZE
from app.services.forensic_persistence import (
forensic_report_exists,
@@ -22,6 +23,45 @@ logger = logging.getLogger(__name__)
router = APIRouter()
class ForensicSampleAnalysisResponse(BaseModel):
id: int
report_id: str
domain: Optional[str] = None
source_ip: Optional[str] = None
auth_failure: str
delivery_result: Optional[str] = None
priority: str
diagnosis: str
recommendations: List[str] = Field(default_factory=list)
signals: List[str] = Field(default_factory=list)
authentication_results: Dict[str, str] = Field(default_factory=dict)
dkim_domain: Optional[str] = None
mail_from_domain: Optional[str] = None
privacy_note: str
class ForensicAnalysisGroupResponse(BaseModel):
key: str
domain: str
source_ip: str
auth_failure: str
delivery_result: str
count: int
priority: str
latest_arrival: Optional[str] = None
diagnosis: str
recommendations: List[str] = Field(default_factory=list)
class ForensicAnalysisResponse(BaseModel):
total: int
priority_counts: Dict[str, int] = Field(default_factory=dict)
failure_counts: Dict[str, int] = Field(default_factory=dict)
result_counts: Dict[str, int] = Field(default_factory=dict)
groups: List[ForensicAnalysisGroupResponse] = Field(default_factory=list)
samples: List[ForensicSampleAnalysisResponse] = Field(default_factory=list)
class ForensicReportResponse(BaseModel):
id: int
report_id: str
@@ -44,6 +84,7 @@ class ForensicReportResponse(BaseModel):
original_date: Optional[str] = None
feedback_headers: Dict[str, Any] = Field(default_factory=dict)
processed_at: Optional[str] = None
analysis: Optional[ForensicSampleAnalysisResponse] = None
class ForensicListResponse(BaseModel):
@@ -79,6 +120,35 @@ def _validate_upload(file: UploadFile, content: bytes) -> None:
)
def _filtered_forensic_query(
db: Session,
*,
domain: Optional[str] = None,
source_ip: Optional[str] = None,
auth_failure: Optional[str] = None,
delivery_result: Optional[str] = None,
):
query = db.query(ForensicReport).options(selectinload(ForensicReport.domain))
if domain:
normalized = domain.lower()
query = query.outerjoin(Domain).filter(
(Domain.name == normalized) | (ForensicReport.reported_domain == normalized)
)
if source_ip:
query = query.filter(ForensicReport.source_ip == source_ip.strip())
if auth_failure:
query = query.filter(ForensicReport.auth_failure == auth_failure.strip().lower())
if delivery_result:
query = query.filter(ForensicReport.delivery_result == delivery_result.strip().lower())
return query
def _response_for_row(row: ForensicReport, redaction_policy) -> ForensicReportResponse:
data = forensic_report_to_dict(row, redaction_policy=redaction_policy)
data["analysis"] = analyze_forensic_report(row)
return ForensicReportResponse(**data)
@router.post("/upload", response_model=ForensicUploadResponse)
async def upload_forensic_report(
file: UploadFile = File(...),
@@ -138,19 +208,13 @@ async def list_forensic_reports(
_auth: dict = Depends(require_admin_auth),
):
"""List stored forensic reports, newest first."""
query = db.query(ForensicReport).options(selectinload(ForensicReport.domain))
if domain:
normalized = domain.lower()
query = query.outerjoin(Domain).filter(
(Domain.name == normalized) | (ForensicReport.reported_domain == normalized)
)
if source_ip:
query = query.filter(ForensicReport.source_ip == source_ip.strip())
if auth_failure:
query = query.filter(ForensicReport.auth_failure == auth_failure.strip().lower())
if delivery_result:
query = query.filter(ForensicReport.delivery_result == delivery_result.strip().lower())
query = _filtered_forensic_query(
db,
domain=domain,
source_ip=source_ip,
auth_failure=auth_failure,
delivery_result=delivery_result,
)
total = query.count()
rows = (
query.order_by(ForensicReport.arrival_date.desc().nullslast(), ForensicReport.id.desc())
@@ -165,15 +229,36 @@ async def list_forensic_reports(
page=page,
page_size=page_size,
total_pages=total_pages,
reports=[
ForensicReportResponse(
**forensic_report_to_dict(row, redaction_policy=redaction_policy)
)
for row in rows
],
reports=[_response_for_row(row, redaction_policy) for row in rows],
)
@router.get("/analysis", response_model=ForensicAnalysisResponse)
async def analyze_forensic_reports(
domain: Optional[str] = Query(default=None),
source_ip: Optional[str] = Query(default=None),
auth_failure: Optional[str] = Query(default=None),
delivery_result: Optional[str] = Query(default=None),
page_size: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
):
"""Summarize stored forensic samples into operator investigation groups."""
query = _filtered_forensic_query(
db,
domain=domain,
source_ip=source_ip,
auth_failure=auth_failure,
delivery_result=delivery_result,
)
rows = (
query.order_by(ForensicReport.arrival_date.desc().nullslast(), ForensicReport.id.desc())
.limit(page_size)
.all()
)
return ForensicAnalysisResponse(**summarize_forensic_samples(rows))
@router.get("/{report_id}", response_model=ForensicReportResponse)
async def get_forensic_report(
report_id: int,
@@ -192,4 +277,4 @@ async def get_forensic_report(
status_code=status.HTTP_404_NOT_FOUND, detail="Forensic report not found"
)
redaction_policy = get_forensic_redaction_policy(db)
return ForensicReportResponse(**forensic_report_to_dict(row, redaction_policy=redaction_policy))
return _response_for_row(row, redaction_policy)