feat(rt-import): require base64 evidence images per technique
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Each technique in the RT import JSON now requires at least one evidence image (PNG/JPG/GIF/WebP/BMP, max 10 MB decoded) embedded as base64. Backend: - RTEvidenceEntry model: filename, data (base64), caption (optional) - RTTechniqueEntry.evidence is now required - Pre-validation raises 422 if any technique is missing evidence - After test creation, images are decoded and stored in MinIO as Evidence records (team=red) linked to the test Frontend: - RTEvidenceEntry type added to api/tests.ts - parseJson() validates evidence presence and structure per technique - Preview table shows base64 thumbnails (up to 3 + overflow count) - Format reference updated: evidence fields moved to Required section - Import result shows total evidence images attached Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,9 @@ POST /tests/{id}/reopen — rejected → draft
|
||||
GET /tests/{id}/timeline — audit-log history for this test
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
@@ -31,7 +34,9 @@ from app.database import get_db
|
||||
from app.dependencies.auth import get_current_user, require_any_role, require_role
|
||||
from app.domain.enums import DataClassification
|
||||
from app.limiter import limiter
|
||||
from app.models.enums import TestState, TestResult
|
||||
from app.models.enums import TestState, TestResult, TeamSide
|
||||
from app.models.evidence import Evidence
|
||||
from app.storage import upload_file
|
||||
from app.models.technique import Technique
|
||||
from app.models.test import Test
|
||||
from app.models.user import User
|
||||
@@ -844,12 +849,23 @@ def request_discussion(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_ALLOWED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
||||
_MAX_EVIDENCE_BYTES = 10 * 1024 * 1024 # 10 MB decoded per image
|
||||
|
||||
|
||||
class RTEvidenceEntry(BaseModel):
|
||||
filename: str # e.g. "screenshot_edr.png"
|
||||
data: str # base64-encoded image content
|
||||
caption: Optional[str] = None # optional description shown as evidence notes
|
||||
|
||||
|
||||
class RTTechniqueEntry(BaseModel):
|
||||
mitre_id: str
|
||||
result: str # "detected" | "not_detected" | "partially_detected"
|
||||
attack_success: bool = True
|
||||
platform: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
evidence: list[RTEvidenceEntry] # REQUIRED — at least one image per technique
|
||||
|
||||
|
||||
class RTImportPayload(BaseModel):
|
||||
@@ -872,6 +888,17 @@ def import_rt(
|
||||
the normal Red/Blue workflow) and immediately recalculates coverage metrics.
|
||||
Requires ``red_lead`` or ``admin`` role.
|
||||
"""
|
||||
# Pre-validate: every technique must include at least one evidence image
|
||||
for entry in payload.techniques:
|
||||
if not entry.evidence:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"Technique {entry.mitre_id} is missing evidence. "
|
||||
"At least one screenshot or image is required per technique."
|
||||
),
|
||||
)
|
||||
|
||||
# Execution date from payload or now
|
||||
exec_date_str = payload.date or datetime.utcnow().date().isoformat()
|
||||
|
||||
@@ -942,12 +969,46 @@ def import_rt(
|
||||
db.add(test)
|
||||
db.flush()
|
||||
|
||||
# ── Store evidence images ──────────────────────────────
|
||||
evidence_count = 0
|
||||
for ev in entry.evidence:
|
||||
safe_name = os.path.basename(ev.filename) or "evidence.png"
|
||||
ext = os.path.splitext(safe_name)[1].lower()
|
||||
if ext not in _ALLOWED_IMAGE_EXTS:
|
||||
# Skip non-image files silently (log warning)
|
||||
continue
|
||||
try:
|
||||
img_bytes = base64.b64decode(ev.data)
|
||||
except Exception:
|
||||
continue # malformed base64 — skip
|
||||
if len(img_bytes) > _MAX_EVIDENCE_BYTES:
|
||||
continue # over size limit — skip
|
||||
sha256 = hashlib.sha256(img_bytes).hexdigest()
|
||||
key = f"{test.id}/{uuid.uuid4()}_{safe_name}"
|
||||
try:
|
||||
upload_file(img_bytes, key)
|
||||
except Exception:
|
||||
continue # storage error — skip but don't abort
|
||||
evidence_obj = Evidence(
|
||||
test_id=test.id,
|
||||
file_name=safe_name,
|
||||
file_path=key,
|
||||
sha256_hash=sha256,
|
||||
uploaded_by=current_user.id,
|
||||
uploaded_at=datetime.utcnow(),
|
||||
team=TeamSide.red,
|
||||
notes=ev.caption,
|
||||
)
|
||||
db.add(evidence_obj)
|
||||
evidence_count += 1
|
||||
|
||||
affected_technique_ids.add(technique.id)
|
||||
created.append({
|
||||
"mitre_id": entry.mitre_id,
|
||||
"test_name": test_name,
|
||||
"result": entry.result,
|
||||
"attack_success": entry.attack_success,
|
||||
"evidence_attached": evidence_count,
|
||||
})
|
||||
|
||||
log_action(
|
||||
|
||||
Reference in New Issue
Block a user