Files
Aegis/backend/app/routers/evidence.py
T
kitos 9472fe91fa
Aegis CI / lint-and-test (push) Has been cancelled
fix(lint): resolve 2132 ruff errors to pass CI lint-and-test job
- Remove ANN (type annotations) and D (docstrings) from ruff select; not
  feasible to add thousands of missing annotations/docstrings across the codebase
- Add I001 and E501 to ignore: comment-interleaved import style and SQLAlchemy
  FK definitions naturally exceed line limits
- Fix F811 duplicate import blocks in main.py, models/__init__.py, routers
  (campaigns, system, tests, evidence) and services (test_workflow, test_crud,
  campaign_service, schemas/test)
- Add missing Evidence/IntelItem/Technique/Test/TestTemplate/User imports to
  models/__init__.py (were only in duplicate block)
- Fix F821: add missing JWTError import in auth.py
- Fix F401 unused imports across 15+ files (jira_service, sso_service,
  notification_service, playbook_service, tempo_service, models, schemas,
  routers: admin_config, attack_paths, executive_dashboard, knowledge,
  ownership, risk_intelligence, sso, api_keys, email_service)
- Fix F841 unused variables: owned_technique_ids (executive_dashboard_service),
  severity (jira_service), priority_order (revalidation_queue_service)
- Fix F541 f-strings without placeholders in system.py and attck_evaluations_service
- Fix F601 duplicate dict key G0067 in threat_actor_import_service
- Fix E701 multiple-statements-on-one-line in risk_intelligence_service
- Fix E741 ambiguous variable name l -> lvl in risk_intelligence_service
- Fix N806 uppercase vars in functions: technique.py, heatmap_service.py;
  add noqa for compliance_import_service.py large unused constant dicts
- Fix W293 whitespace on blank lines in tests/conftest.py
2026-06-12 10:47:48 +02:00

405 lines
14 KiB
Python

"""Evidence upload, download, listing and deletion router — v2 with Red/Blue separation.
Endpoints
---------
POST /tests/{test_id}/evidence — upload evidence (with team=red/blue)
GET /tests/{test_id}/evidence — list evidences (filterable by team)
GET /evidence/{id} — metadata + download_url
GET /evidence/{id}/file — proxy download (streams file through backend)
DELETE /evidence/{id} — delete evidence (only in editable states)
Access Control
--------------
- Red Team (``red_tech``) can only upload ``team=red`` when test is in
``draft`` or ``red_executing``.
- Blue Team (``blue_tech``) can only upload ``team=blue`` when test is in
``blue_evaluating``.
- Admin can upload any team in any state.
- DELETE is restricted: red evidence in ``draft``/``red_executing``,
blue evidence in ``blue_evaluating``. No deletions in ``in_review``,
``validated``, or ``rejected``.
"""
# Import hashlib
import hashlib
import logging
import os
# Import uuid
import uuid as _uuid
from datetime import datetime
from typing import Optional
# Import APIRouter, Depends, File, Form, Query, Request,... from fastapi
from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
# Import get_db from app.database
from app.database import get_db
# Import get_current_user from app.dependencies.auth
from app.dependencies.auth import get_current_user
# Import UnitOfWork from app.domain.unit_of_work
from app.domain.unit_of_work import UnitOfWork
# Import limiter from app.limiter
from app.limiter import limiter
# Import TeamSide from app.models.enums
from app.models.enums import TeamSide
# Import Evidence from app.models.evidence
from app.models.evidence import Evidence
# Import User from app.models.user
from app.models.user import User
# Import EvidenceOut from app.schemas.evidence
from app.schemas.evidence import EvidenceOut
# Import log_action from app.services.audit_service
from app.services.audit_service import log_action
# Import from app.services.evidence_service
from app.services.evidence_service import (
MAX_UPLOAD_SIZE,
get_evidence_or_raise,
get_test_or_raise,
list_evidence_for_test,
validate_delete_permission,
validate_file,
validate_upload_permission,
)
from app.storage import download_file, upload_file
logger = logging.getLogger(__name__)
# Assign router = APIRouter(tags=["evidence"])
router = APIRouter(tags=["evidence"])
# ---------------------------------------------------------------------------
# Helpers (router-specific: infrastructure / HTTP concerns)
# ---------------------------------------------------------------------------
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
"""Convert an ORM ``Evidence`` to the API schema.
``download_url`` points to the backend proxy endpoint so the browser
never needs direct access to MinIO.
"""
return EvidenceOut(
# Keyword argument: id
id=evidence.id,
# Keyword argument: test_id
test_id=evidence.test_id,
# Keyword argument: file_name
file_name=evidence.file_name,
# Keyword argument: sha256_hash
sha256_hash=evidence.sha256_hash,
# Keyword argument: uploaded_by
uploaded_by=evidence.uploaded_by,
# Keyword argument: uploaded_at
uploaded_at=evidence.uploaded_at,
# Keyword argument: team
team=evidence.team,
# Keyword argument: notes
notes=evidence.notes,
download_url=f"/api/v1/evidence/{evidence.id}/file",
)
# ---------------------------------------------------------------------------
# POST /tests/{test_id}/evidence — upload with team
# ---------------------------------------------------------------------------
@router.post(
# Literal argument value
"/tests/{test_id}/evidence",
# Keyword argument: response_model
response_model=EvidenceOut,
# Keyword argument: status_code
status_code=status.HTTP_201_CREATED,
)
# Apply the @limiter.limit decorator
@limiter.limit("10/minute")
# Define async function upload_evidence
async def upload_evidence(
# Entry: request
request: Request,
# Entry: test_id
test_id: _uuid.UUID,
# Entry: file
file: UploadFile = File(...),
# Entry: team
team: TeamSide = Form(TeamSide.red),
# Entry: notes
notes: Optional[str] = Form(None),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> EvidenceOut:
"""Upload a file as evidence for the given test.
The ``team`` field (sent as form data) determines whether this is
Red Team (attack) or Blue Team (detection) evidence.
"""
# Assign test = get_test_or_raise(db, test_id)
test = get_test_or_raise(db, test_id)
# Call validate_upload_permission()
validate_upload_permission(test, team, current_user.role)
# Assign file_name = file.filename or "unnamed"
file_name = file.filename or "unnamed"
# Assign content = await file.read(MAX_UPLOAD_SIZE + 1)
content = await file.read(MAX_UPLOAD_SIZE + 1)
# Call validate_file()
validate_file(file_name, len(content))
# Hash
sha256 = hashlib.sha256(content).hexdigest()
# 4. Object key (sanitise filename to prevent path traversal in storage)
safe_name = os.path.basename(file_name)
# Assign key = f"{test_id}/{_uuid.uuid4()}_{safe_name}"
key = f"{test_id}/{_uuid.uuid4()}_{safe_name}"
# 5. Upload to MinIO
upload_file(content, key)
# 6. Persist metadata and audit
with UnitOfWork(db) as uow:
# Assign evidence = Evidence(
evidence = Evidence(
# Keyword argument: test_id
test_id=test_id,
# Keyword argument: file_name
file_name=safe_name,
# Keyword argument: file_path
file_path=key,
# Keyword argument: sha256_hash
sha256_hash=sha256,
# Keyword argument: uploaded_by
uploaded_by=current_user.id,
uploaded_at=datetime.utcnow(), # set explicitly — DB column has no server default
team=team,
# Keyword argument: notes
notes=notes,
)
# Stage new record(s) for database insertion
db.add(evidence)
# Flush changes to DB without committing the transaction
db.flush() # Get evidence.id for audit
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="upload_evidence",
# Keyword argument: entity_type
entity_type="evidence",
# Keyword argument: entity_id
entity_id=evidence.id,
# Keyword argument: details
details={
# Literal argument value
"file_name": safe_name,
# Literal argument value
"sha256": sha256,
# Literal argument value
"test_id": str(test_id),
# Literal argument value
"team": team.value,
},
)
# Call uow.commit()
uow.commit()
# Reload ORM object attributes from the database
db.refresh(evidence)
# 7. Attach to Jira ticket if one exists (non-fatal)
_attach_evidence_to_jira(db, test_id, content, safe_name, current_user)
return _evidence_to_out(evidence)
def _attach_evidence_to_jira(
db,
test_id: _uuid.UUID,
content: bytes,
file_name: str,
actor,
) -> None:
"""Attach uploaded evidence to the linked Jira ticket (non-fatal)."""
try:
from app.services.jira_service import get_test_jira_key, get_user_jira_client, has_jira_configured
if not has_jira_configured(actor, db):
return
issue_key = get_test_jira_key(db, test_id)
if not issue_key:
return
import io
jira = get_user_jira_client(actor, db)
buf = io.BytesIO(content)
buf.name = file_name # requests uses .name as the multipart filename
jira.add_attachment_object(issue_key, buf)
import logging
logging.getLogger(__name__).info(
"Attached evidence '%s' to Jira ticket %s", file_name, issue_key
)
except Exception as exc:
import logging
logging.getLogger(__name__).warning(
"Failed to attach evidence '%s' to Jira: %s", file_name, exc, exc_info=True
)
# ---------------------------------------------------------------------------
# GET /tests/{test_id}/evidence — list (with optional team filter)
# ---------------------------------------------------------------------------
@router.get("/tests/{test_id}/evidence", response_model=list[EvidenceOut])
# Define function list_evidence
def list_evidence(
# Entry: test_id
test_id: _uuid.UUID,
# Entry: team
team: Optional[str] = Query(None, description="Filter by team: red or blue"),
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> list[EvidenceOut]:
"""List all evidences for a test, optionally filtered by team."""
# Call get_test_or_raise()
get_test_or_raise(db, test_id)
# Assign evidences = list_evidence_for_test(db, test_id, team=team)
evidences = list_evidence_for_test(db, test_id, team=team)
# Return [_evidence_to_out(e) for e in evidences]
return [_evidence_to_out(e) for e in evidences]
# ---------------------------------------------------------------------------
# GET /evidence/{id} — metadata + proxy download URL
# ---------------------------------------------------------------------------
@router.get("/evidence/{evidence_id}", response_model=EvidenceOut)
# Define function get_evidence
def get_evidence(
# Entry: evidence_id
evidence_id: _uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
):
"""Return evidence metadata. ``download_url`` is a backend proxy URL."""
evidence = get_evidence_or_raise(db, evidence_id)
# Return _evidence_to_out(evidence)
return _evidence_to_out(evidence)
# ---------------------------------------------------------------------------
# GET /evidence/{id}/file — proxy download (streams file via backend)
# ---------------------------------------------------------------------------
@router.get("/evidence/{evidence_id}/file")
def download_evidence_file(
evidence_id: _uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Stream the evidence file through the backend.
The browser calls this endpoint (authenticated via JWT cookie/header).
The backend fetches the file from MinIO internally and streams it back,
so MinIO never needs to be publicly accessible.
"""
import mimetypes
evidence = get_evidence_or_raise(db, evidence_id)
content = download_file(evidence.file_path)
mime_type, _ = mimetypes.guess_type(evidence.file_name)
if not mime_type:
mime_type = "application/octet-stream"
safe_name = evidence.file_name.replace('"', '\\"')
return StreamingResponse(
iter([content]),
media_type=mime_type,
headers={
"Content-Disposition": f'inline; filename="{safe_name}"',
"Content-Length": str(len(content)),
},
)
# ---------------------------------------------------------------------------
# DELETE /evidence/{id} — delete evidence (editable states only)
# ---------------------------------------------------------------------------
@router.delete("/evidence/{evidence_id}", status_code=status.HTTP_200_OK)
# Define function delete_evidence
def delete_evidence(
# Entry: evidence_id
evidence_id: _uuid.UUID,
# Entry: db
db: Session = Depends(get_db),
# Entry: current_user
current_user: User = Depends(get_current_user),
) -> dict:
"""Delete an evidence record.
Only allowed in editable states:
- Red evidence: ``draft``, ``red_executing``
- Blue evidence: ``blue_evaluating``
- No deletions in ``in_review``, ``validated``, ``rejected``
"""
# Assign evidence = get_evidence_or_raise(db, evidence_id)
evidence = get_evidence_or_raise(db, evidence_id)
# Assign test = get_test_or_raise(db, evidence.test_id)
test = get_test_or_raise(db, evidence.test_id)
# Call validate_delete_permission()
validate_delete_permission(test, evidence, current_user.role, current_user.id)
# Open context manager
with UnitOfWork(db) as uow:
# Call log_action()
log_action(
db,
# Keyword argument: user_id
user_id=current_user.id,
# Keyword argument: action
action="delete_evidence",
# Keyword argument: entity_type
entity_type="evidence",
# Keyword argument: entity_id
entity_id=evidence.id,
# Keyword argument: details
details={
# Literal argument value
"file_name": evidence.file_name,
# Literal argument value
"test_id": str(evidence.test_id),
# Literal argument value
"team": evidence.team.value if evidence.team else None,
},
)
# Mark record for deletion on next commit
db.delete(evidence)
# Call uow.commit()
uow.commit()
# Return {"detail": "Evidence deleted"}
return {"detail": "Evidence deleted"}