Compare commits

...

2 Commits

Author SHA1 Message Date
kitos 13055b24a2 feat: Phase 4 - MITRE ATT&CK sync and scheduled job (T-018, T-019)
- Add MITRE sync service via TAXII 2.0 with GitHub fallback
- Upsert attack-pattern objects into techniques table (691 techniques)
- Detect name/description changes and flag review_required on re-sync
- Add APScheduler background job running every 24h
- Add POST /system/sync-mitre endpoint (admin only)
- Add GET /system/scheduler-status endpoint (admin only)
- Configure logging for scheduler and sync visibility
- Update README with new endpoints and project structure
2026-02-06 15:28:53 +01:00
kitos a93c81674d feat: Phase 3 - CRUD core for Techniques, Tests and Evidence (T-014 to T-017)
- Add Pydantic schemas for Technique, Test and Evidence
- Add CRUD endpoints for Techniques (list with filters, detail, create, update, review)
- Add CRUD endpoints for Tests (create, detail, update, validate, reject)
- Add evidence upload with SHA-256 integrity and presigned download URLs
- Add MinIO/S3 storage client with bucket auto-creation on startup
- Add status_service to recalculate technique coverage from test results
- Add require_any_role RBAC dependency for multi-role authorization
- Update README with API endpoints reference and project structure
2026-02-06 13:52:27 +01:00
16 changed files with 1340 additions and 6 deletions
+55 -5
View File
@@ -4,7 +4,7 @@ Aegis is a comprehensive platform for tracking and managing security coverage ag
## Features
- **MITRE ATT&CK Integration**: Automatic synchronization with the MITRE ATT&CK framework via TAXII
- **MITRE ATT&CK Integration**: Automatic synchronization with the MITRE ATT&CK framework via TAXII (with GitHub fallback), scheduled every 24h
- **Coverage Tracking**: Track validation status for each technique (validated, partial, not covered, in progress)
- **Test Management**: Document and manage security tests with full audit trail
- **Evidence Storage**: Secure evidence file storage with SHA256 integrity verification
@@ -89,6 +89,44 @@ Once the backend is running, access the interactive API documentation at:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
## API Endpoints
### Auth
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| POST | `/api/v1/auth/login` | Public | Obtain JWT token |
| GET | `/api/v1/auth/me` | Authenticated | Current user profile |
### Techniques
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| GET | `/api/v1/techniques` | Authenticated | List all (filters: `?tactic=`, `?status=`, `?review_required=`) |
| GET | `/api/v1/techniques/{mitre_id}` | Authenticated | Detail with associated tests |
| POST | `/api/v1/techniques` | Admin | Create technique |
| PATCH | `/api/v1/techniques/{mitre_id}` | Admin | Update technique fields |
| PATCH | `/api/v1/techniques/{mitre_id}/review` | Lead, Admin | Mark as reviewed |
### Tests
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| POST | `/api/v1/tests` | Red Tech, Admin | Create test (state=draft) |
| GET | `/api/v1/tests/{id}` | Authenticated | Detail with evidences |
| PATCH | `/api/v1/tests/{id}` | Creator, Admin | Update (only draft/rejected) |
| POST | `/api/v1/tests/{id}/validate` | Lead, Admin | Validate + recalculate technique status |
| POST | `/api/v1/tests/{id}/reject` | Lead, Admin | Reject test |
### Evidence
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| POST | `/api/v1/tests/{test_id}/evidence` | Authenticated | Upload evidence file (SHA-256 verified) |
| GET | `/api/v1/evidence/{id}` | Authenticated | Get metadata + presigned download URL |
### System
| Method | Route | Auth | Description |
|--------|-------|------|-------------|
| POST | `/api/v1/system/sync-mitre` | Admin | Manually trigger MITRE ATT&CK sync |
| GET | `/api/v1/system/scheduler-status` | Admin | Background scheduler health & job list |
## Project Structure
```
@@ -117,14 +155,26 @@ Aegis/
│ │ ├── intel.py # Threat intelligence items
│ │ ├── audit.py # Audit logging
│ │ └── enums.py # Shared enumerations
│ ├── storage.py # MinIO/S3 client (upload, presigned URLs)
│ ├── schemas/ # Pydantic request/response schemas
│ │ ── auth.py # LoginRequest, TokenResponse, UserOut
│ │ ── auth.py # LoginRequest, TokenResponse, UserOut
│ │ ├── technique.py # TechniqueCreate/Update/Out/Summary
│ │ ├── test.py # TestCreate/Update/Out/Validate
│ │ └── evidence.py # EvidenceOut
│ ├── routers/ # API endpoint routers
│ │ ── auth.py # POST /auth/login, GET /auth/me
│ │ ── auth.py # POST /auth/login, GET /auth/me
│ │ ├── techniques.py # CRUD techniques (list, detail, create, update, review)
│ │ ├── tests.py # CRUD tests (create, detail, update, validate, reject)
│ │ ├── evidence.py # Upload evidence, presigned download
│ │ └── system.py # MITRE sync trigger, scheduler status
│ ├── dependencies/ # FastAPI dependencies (DI)
│ │ └── auth.py # get_current_user, require_role (RBAC)
│ │ └── auth.py # get_current_user, require_role, require_any_role
│ ├── jobs/ # Background scheduled jobs
│ │ └── mitre_sync_job.py # APScheduler job: sync MITRE every 24h
│ └── services/ # Business logic services
── audit_service.py
── audit_service.py
│ ├── status_service.py # Recalculate technique status from tests
│ └── mitre_sync_service.py # MITRE ATT&CK sync via TAXII / GitHub
└── frontend/ # React frontend (coming soon)
```
+21
View File
@@ -87,3 +87,24 @@ def require_role(required_role: str):
return current_user
return role_checker
def require_any_role(*roles: str):
"""Return a FastAPI dependency that enforces **any** of the given *roles*.
Admins always pass. Usage example::
@router.patch("/resource", dependencies=[Depends(require_any_role("red_lead", "blue_lead"))])
"""
async def role_checker(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role != "admin" and current_user.role not in roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return current_user
return role_checker
View File
+53
View File
@@ -0,0 +1,53 @@
"""Scheduled job for periodic MITRE ATT&CK synchronisation.
Uses APScheduler's ``BackgroundScheduler`` to run :func:`sync_mitre` every
24 hours. The job manages its own database session (created on entry,
closed in ``finally``) so it is fully independent from FastAPI's
request-scoped sessions.
"""
import logging
from apscheduler.schedulers.background import BackgroundScheduler
from app.database import SessionLocal
from app.services.mitre_sync_service import sync_mitre
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Module-level scheduler instance
# ---------------------------------------------------------------------------
scheduler = BackgroundScheduler()
def _run_mitre_sync() -> None:
"""Execute a MITRE sync inside its own DB session."""
logger.info("Scheduled MITRE sync job starting...")
db = SessionLocal()
try:
summary = sync_mitre(db)
logger.info("Scheduled MITRE sync job finished — %s", summary)
except Exception:
logger.exception("Scheduled MITRE sync job failed")
finally:
db.close()
def start_scheduler() -> None:
"""Register the MITRE sync job and start the background scheduler.
The job runs every **24 hours**. It does **not** fire immediately on
startup — the first execution happens 24 h after the application boots.
"""
scheduler.add_job(
_run_mitre_sync,
trigger="interval",
hours=24,
id="mitre_sync",
name="MITRE ATT&CK sync (every 24h)",
replace_existing=True,
)
scheduler.start()
logger.info("MITRE sync scheduler started (interval=24h)")
+30 -1
View File
@@ -1,9 +1,34 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routers import auth as auth_router
from app.routers import techniques as techniques_router
from app.routers import tests as tests_router
from app.routers import evidence as evidence_router
from app.routers import system as system_router
from app.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler
app = FastAPI(title="Attack Coverage Platform")
# ── Logging ───────────────────────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(name)s%(message)s",
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup / shutdown logic."""
ensure_bucket_exists()
start_scheduler()
yield
# Graceful shutdown of the background scheduler
scheduler.shutdown(wait=False)
app = FastAPI(title="Attack Coverage Platform", lifespan=lifespan)
# ── CORS ──────────────────────────────────────────────────────────────────
app.add_middleware(
@@ -16,6 +41,10 @@ app.add_middleware(
# ── Routers ──────────────────────────────────────────────────────────────
app.include_router(auth_router.router, prefix="/api/v1")
app.include_router(techniques_router.router, prefix="/api/v1")
app.include_router(tests_router.router, prefix="/api/v1")
app.include_router(evidence_router.router, prefix="/api/v1")
app.include_router(system_router.router, prefix="/api/v1")
@app.get("/health")
+132
View File
@@ -0,0 +1,132 @@
"""Evidence upload and download router."""
import hashlib
import uuid as _uuid
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user
from app.models.evidence import Evidence
from app.models.test import Test
from app.models.user import User
from app.schemas.evidence import EvidenceOut
from app.services.audit_service import log_action
from app.storage import get_presigned_url, upload_file
router = APIRouter(tags=["evidence"])
# ---------------------------------------------------------------------------
# POST /tests/{test_id}/evidence — upload
# ---------------------------------------------------------------------------
@router.post(
"/tests/{test_id}/evidence",
response_model=EvidenceOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_evidence(
test_id: _uuid.UUID,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Upload a file as evidence for the given test.
Steps:
1. Read file content and compute SHA-256.
2. Build an object key ``{test_id}/{uuid}_{filename}``.
3. Upload to MinIO.
4. Persist an :class:`Evidence` row in the database.
5. Write an audit-log entry.
"""
# Verify the parent test exists
test = db.query(Test).filter(Test.id == test_id).first()
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
# 1. Read content + hash
content = await file.read()
sha256 = hashlib.sha256(content).hexdigest()
# 2. Object key
file_name = file.filename or "unnamed"
key = f"{test_id}/{_uuid.uuid4()}_{file_name}"
# 3. Upload to MinIO
upload_file(content, key)
# 4. Persist metadata
evidence = Evidence(
test_id=test_id,
file_name=file_name,
file_path=key,
sha256_hash=sha256,
uploaded_by=current_user.id,
)
db.add(evidence)
db.commit()
db.refresh(evidence)
# 5. Audit
log_action(
db,
user_id=current_user.id,
action="upload_evidence",
entity_type="evidence",
entity_id=evidence.id,
details={
"file_name": file_name,
"sha256": sha256,
"test_id": str(test_id),
},
)
# Build response with download URL
return _evidence_to_out(evidence)
# ---------------------------------------------------------------------------
# GET /evidence/{id} — presigned download URL
# ---------------------------------------------------------------------------
@router.get("/evidence/{evidence_id}", response_model=EvidenceOut)
def get_evidence(
evidence_id: _uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return evidence metadata together with a presigned download URL."""
evidence = db.query(Evidence).filter(Evidence.id == evidence_id).first()
if evidence is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Evidence not found",
)
return _evidence_to_out(evidence)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _evidence_to_out(evidence: Evidence) -> EvidenceOut:
"""Convert an ORM ``Evidence`` to the API schema, injecting a presigned URL."""
return EvidenceOut(
id=evidence.id,
test_id=evidence.test_id,
file_name=evidence.file_name,
sha256_hash=evidence.sha256_hash,
uploaded_by=evidence.uploaded_by,
uploaded_at=evidence.uploaded_at,
download_url=get_presigned_url(evidence.file_path),
)
+58
View File
@@ -0,0 +1,58 @@
"""System-level endpoints (admin only).
Provides manual triggers for background operations such as the MITRE
ATT&CK synchronisation, and scheduler health introspection.
"""
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import require_role
from app.models.user import User
from app.services.mitre_sync_service import sync_mitre
from app.jobs.mitre_sync_job import scheduler
router = APIRouter(prefix="/system", tags=["system"])
@router.post("/sync-mitre")
def trigger_mitre_sync(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Manually trigger a MITRE ATT&CK synchronisation.
**Requires** the ``admin`` role.
Returns a JSON object with the sync summary including the count of
new and updated techniques.
"""
summary = sync_mitre(db)
return {
"message": "MITRE sync completed",
"new": summary["created"],
"updated": summary["updated"],
}
@router.get("/scheduler-status")
def scheduler_status(
current_user: User = Depends(require_role("admin")),
):
"""Return the current state of the background scheduler.
**Requires** the ``admin`` role.
"""
jobs = scheduler.get_jobs()
return {
"running": scheduler.running,
"jobs": [
{
"id": job.id,
"name": job.name,
"next_run_time": str(job.next_run_time) if job.next_run_time else None,
}
for job in jobs
],
}
+206
View File
@@ -0,0 +1,206 @@
"""CRUD router for MITRE ATT&CK Techniques."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.enums import TechniqueStatus
from app.models.technique import Technique
from app.models.user import User
from app.schemas.technique import (
TechniqueCreate,
TechniqueOut,
TechniqueSummary,
TechniqueUpdate,
)
from app.services.audit_service import log_action
router = APIRouter(prefix="/techniques", tags=["techniques"])
# ---------------------------------------------------------------------------
# GET /techniques — list (with optional filters)
# ---------------------------------------------------------------------------
@router.get("", response_model=list[TechniqueSummary])
def list_techniques(
tactic: str | None = Query(None, description="Filter by tactic name"),
status_global: TechniqueStatus | None = Query(
None, alias="status", description="Filter by global status"
),
review_required: bool | None = Query(None, description="Filter by review flag"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return a lightweight list of techniques, optionally filtered."""
query = db.query(Technique)
if tactic is not None:
query = query.filter(Technique.tactic == tactic)
if status_global is not None:
query = query.filter(Technique.status_global == status_global)
if review_required is not None:
query = query.filter(Technique.review_required == review_required)
return query.order_by(Technique.mitre_id).all()
# ---------------------------------------------------------------------------
# GET /techniques/{mitre_id} — detail (with tests)
# ---------------------------------------------------------------------------
@router.get("/{mitre_id}", response_model=TechniqueOut)
def get_technique(
mitre_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return full details for a single technique, including its tests."""
technique = (
db.query(Technique)
.options(joinedload(Technique.tests))
.filter(Technique.mitre_id == mitre_id)
.first()
)
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique {mitre_id} not found",
)
return technique
# ---------------------------------------------------------------------------
# POST /techniques — create (admin only)
# ---------------------------------------------------------------------------
@router.post(
"",
response_model=TechniqueOut,
status_code=status.HTTP_201_CREATED,
)
def create_technique(
payload: TechniqueCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Create a new technique manually."""
# Ensure mitre_id is unique
existing = (
db.query(Technique).filter(Technique.mitre_id == payload.mitre_id).first()
)
if existing is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Technique with mitre_id '{payload.mitre_id}' already exists",
)
technique = Technique(**payload.model_dump())
db.add(technique)
db.commit()
db.refresh(technique)
log_action(
db,
user_id=current_user.id,
action="create_technique",
entity_type="technique",
entity_id=technique.id,
details={"mitre_id": technique.mitre_id, "name": technique.name},
)
return technique
# ---------------------------------------------------------------------------
# PATCH /techniques/{mitre_id} — update (admin only)
# ---------------------------------------------------------------------------
@router.patch("/{mitre_id}", response_model=TechniqueOut)
def update_technique(
mitre_id: str,
payload: TechniqueUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Update one or more fields of an existing technique."""
technique = (
db.query(Technique).filter(Technique.mitre_id == mitre_id).first()
)
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique {mitre_id} not found",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(technique, field, value)
db.commit()
db.refresh(technique)
log_action(
db,
user_id=current_user.id,
action="update_technique",
entity_type="technique",
entity_id=technique.id,
details={"mitre_id": mitre_id, "updated_fields": list(update_data.keys())},
)
return technique
# ---------------------------------------------------------------------------
# PATCH /techniques/{mitre_id}/review — mark as reviewed (leads + admin)
# ---------------------------------------------------------------------------
@router.patch("/{mitre_id}/review", response_model=TechniqueOut)
def review_technique(
mitre_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Mark a technique as reviewed.
Sets ``review_required`` to *False* and records the current timestamp
in ``last_review_date``.
"""
technique = (
db.query(Technique).filter(Technique.mitre_id == mitre_id).first()
)
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique {mitre_id} not found",
)
technique.review_required = False
technique.last_review_date = datetime.utcnow()
db.commit()
db.refresh(technique)
log_action(
db,
user_id=current_user.id,
action="review_technique",
entity_type="technique",
entity_id=technique.id,
details={"mitre_id": mitre_id},
)
return technique
+248
View File
@@ -0,0 +1,248 @@
"""CRUD router for security Tests."""
import uuid
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.enums import TestState
from app.models.technique import Technique
from app.models.test import Test
from app.models.user import User
from app.schemas.test import TestCreate, TestOut, TestUpdate, TestValidate
from app.services.audit_service import log_action
from app.services.status_service import recalculate_technique_status
router = APIRouter(prefix="/tests", tags=["tests"])
# ---------------------------------------------------------------------------
# POST /tests — create (red_tech or admin)
# ---------------------------------------------------------------------------
@router.post(
"",
response_model=TestOut,
status_code=status.HTTP_201_CREATED,
)
def create_test(
payload: TestCreate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")),
):
"""Create a new test linked to an existing technique.
The ``created_by`` field is set automatically to the current user and
``state`` defaults to *draft*.
"""
# Verify the parent technique exists
technique = db.query(Technique).filter(Technique.id == payload.technique_id).first()
if technique is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Technique with id '{payload.technique_id}' not found",
)
test = Test(
**payload.model_dump(),
created_by=current_user.id,
state=TestState.draft,
)
db.add(test)
db.commit()
db.refresh(test)
log_action(
db,
user_id=current_user.id,
action="create_test",
entity_type="test",
entity_id=test.id,
details={"name": test.name, "technique_id": str(test.technique_id)},
)
return test
# ---------------------------------------------------------------------------
# GET /tests/{id} — detail (with evidences)
# ---------------------------------------------------------------------------
@router.get("/{test_id}", response_model=TestOut)
def get_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return full details for a single test, including its evidences."""
test = (
db.query(Test)
.options(joinedload(Test.evidences))
.filter(Test.id == test_id)
.first()
)
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
return test
# ---------------------------------------------------------------------------
# PATCH /tests/{id} — update (creator or admin, only in draft/rejected)
# ---------------------------------------------------------------------------
@router.patch("/{test_id}", response_model=TestOut)
def update_test(
test_id: uuid.UUID,
payload: TestUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update one or more fields of an existing test.
Only the original creator or an admin can update.
The test must be in ``draft`` or ``rejected`` state.
"""
test = db.query(Test).filter(Test.id == test_id).first()
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
# Ownership / admin check
if current_user.role != "admin" and test.created_by != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
# State guard
if test.state not in (TestState.draft, TestState.rejected):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Cannot update a test in '{test.state.value}' state (must be draft or rejected)",
)
update_data = payload.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(test, field, value)
db.commit()
db.refresh(test)
log_action(
db,
user_id=current_user.id,
action="update_test",
entity_type="test",
entity_id=test.id,
details={"updated_fields": list(update_data.keys())},
)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/validate — validate (leads + admin)
# ---------------------------------------------------------------------------
@router.post("/{test_id}/validate", response_model=TestOut)
def validate_test(
test_id: uuid.UUID,
payload: TestValidate,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Mark a test as validated.
Sets ``state`` to *validated*, records ``validated_by`` / ``validated_at``,
stores the ``result``, and recalculates the parent technique's global status.
"""
test = (
db.query(Test)
.options(joinedload(Test.technique))
.filter(Test.id == test_id)
.first()
)
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
test.state = TestState.validated
test.result = payload.result
test.validated_by = current_user.id
test.validated_at = datetime.utcnow()
db.commit()
db.refresh(test)
# Recalculate the parent technique's global status
technique = test.technique
recalculate_technique_status(db, technique)
log_action(
db,
user_id=current_user.id,
action="validate_test",
entity_type="test",
entity_id=test.id,
details={
"result": payload.result.value,
"technique_id": str(test.technique_id),
},
)
return test
# ---------------------------------------------------------------------------
# POST /tests/{id}/reject — reject (leads + admin)
# ---------------------------------------------------------------------------
@router.post("/{test_id}/reject", response_model=TestOut)
def reject_test(
test_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Reject a test, setting its state to *rejected*."""
test = db.query(Test).filter(Test.id == test_id).first()
if test is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Test not found",
)
test.state = TestState.rejected
db.commit()
db.refresh(test)
log_action(
db,
user_id=current_user.id,
action="reject_test",
entity_type="test",
entity_id=test.id,
details={"technique_id": str(test.technique_id)},
)
return test
+38
View File
@@ -0,0 +1,38 @@
"""Pydantic schemas — re-exported for convenient imports."""
from app.schemas.auth import LoginRequest, TokenResponse, UserOut
from app.schemas.technique import (
TechniqueCreate,
TechniqueOut,
TechniqueSummary,
TechniqueUpdate,
)
from app.schemas.test import (
TestCreate,
TestOut,
TestUpdate,
TestValidate,
)
from app.schemas.evidence import EvidenceOut
__all__ = [
# Auth
"LoginRequest",
"TokenResponse",
"UserOut",
# Technique
"TechniqueCreate",
"TechniqueOut",
"TechniqueSummary",
"TechniqueUpdate",
# Test
"TestCreate",
"TestOut",
"TestUpdate",
"TestValidate",
# Evidence
"EvidenceOut",
]
+23
View File
@@ -0,0 +1,23 @@
"""Pydantic schemas for Evidence endpoints."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
class EvidenceOut(BaseModel):
"""Representation of an evidence record returned by the API.
``download_url`` is a presigned URL generated at response time.
"""
id: uuid.UUID
test_id: uuid.UUID
file_name: str
sha256_hash: str
uploaded_by: uuid.UUID | None = None
uploaded_at: datetime | None = None
download_url: str | None = None
model_config = ConfigDict(from_attributes=True)
+69
View File
@@ -0,0 +1,69 @@
"""Pydantic schemas for Technique endpoints."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import TechniqueStatus
# ── Create ──────────────────────────────────────────────────────────
class TechniqueCreate(BaseModel):
"""Payload for creating a new technique."""
mitre_id: str
name: str
description: str | None = None
tactic: str | None = None
platforms: list[str] | None = None
# ── Update ──────────────────────────────────────────────────────────
class TechniqueUpdate(BaseModel):
"""Payload for partially updating an existing technique.
Every field is optional so callers send only what changed."""
name: str | None = None
description: str | None = None
tactic: str | None = None
platforms: list[str] | None = None
status_global: TechniqueStatus | None = None
# ── Read (full) ─────────────────────────────────────────────────────
class TechniqueOut(BaseModel):
"""Complete representation returned by the API."""
id: uuid.UUID
mitre_id: str
name: str
description: str | None = None
tactic: str | None = None
platforms: list[str] | None = None
mitre_version: str | None = None
mitre_last_modified: datetime | None = None
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
model_config = ConfigDict(from_attributes=True)
# ── Read (summary) ──────────────────────────────────────────────────
class TechniqueSummary(BaseModel):
"""Lightweight representation used in list endpoints."""
id: uuid.UUID
mitre_id: str
name: str
tactic: str | None = None
status_global: TechniqueStatus = TechniqueStatus.not_evaluated
model_config = ConfigDict(from_attributes=True)
+67
View File
@@ -0,0 +1,67 @@
"""Pydantic schemas for Test endpoints."""
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict
from app.models.enums import TestResult, TestState
# ── Create ──────────────────────────────────────────────────────────
class TestCreate(BaseModel):
"""Payload for creating a new test."""
technique_id: uuid.UUID
name: str
description: str | None = None
platform: str | None = None
procedure_text: str | None = None
tool_used: str | None = None
# ── Update ──────────────────────────────────────────────────────────
class TestUpdate(BaseModel):
"""Payload for partially updating an existing test.
Every field is optional so callers send only what changed."""
name: str | None = None
description: str | None = None
platform: str | None = None
procedure_text: str | None = None
tool_used: str | None = None
result: TestResult | None = None
# ── Read (full) ─────────────────────────────────────────────────────
class TestOut(BaseModel):
"""Complete representation returned by the API."""
id: uuid.UUID
technique_id: uuid.UUID
name: str
description: str | None = None
platform: str | None = None
procedure_text: str | None = None
tool_used: str | None = None
execution_date: datetime | None = None
created_by: uuid.UUID | None = None
result: TestResult | None = None
state: TestState = TestState.draft
validated_by: uuid.UUID | None = None
validated_at: datetime | None = None
created_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
# ── Validate ────────────────────────────────────────────────────────
class TestValidate(BaseModel):
"""Payload sent by a reviewer to validate / reject a test."""
result: TestResult
comments: str | None = None
+247
View File
@@ -0,0 +1,247 @@
"""Service for synchronizing MITRE ATT&CK techniques via TAXII 2.0.
Connects to the official MITRE CTI TAXII server, fetches the Enterprise
ATT&CK collection, and upserts attack-pattern objects into the local
``techniques`` table. Falls back to the MITRE CTI GitHub repository
when the TAXII server is unreachable.
"""
import logging
from datetime import datetime
import requests as _requests
from sqlalchemy.orm import Session
from taxii2client.v20 import Server as TaxiiServer
from app.models.technique import Technique
from app.models.enums import TechniqueStatus
from app.services.audit_service import log_action
logger = logging.getLogger(__name__)
TAXII_SERVER_URL = "https://cti-taxii.mitre.org/taxii/"
MITRE_SOURCE_NAME = "mitre-attack"
GITHUB_ENTERPRISE_URL = (
"https://raw.githubusercontent.com/mitre/cti/master/"
"enterprise-attack/enterprise-attack.json"
)
def _extract_mitre_id(external_references: list) -> str | None:
"""Return the MITRE ATT&CK ID (e.g. ``T1059.001``) from external_references."""
if not external_references:
return None
for ref in external_references:
if ref.get("source_name") == MITRE_SOURCE_NAME:
return ref.get("external_id")
return None
def _extract_tactics(kill_chain_phases: list) -> str | None:
"""Return a comma-separated string of tactic phase names."""
if not kill_chain_phases:
return None
tactics = [
phase.get("phase_name")
for phase in kill_chain_phases
if phase.get("kill_chain_name") == "mitre-attack"
]
return ", ".join(tactics) if tactics else None
def _extract_platforms(stix_object: dict) -> list:
"""Return the list of platforms from the STIX object."""
return stix_object.get("x_mitre_platforms", [])
def _extract_version(stix_object: dict) -> str | None:
"""Return the MITRE ATT&CK version string."""
return stix_object.get("x_mitre_version")
def _extract_last_modified(stix_object: dict) -> datetime | None:
"""Return the ``modified`` timestamp as a datetime, or None."""
modified = stix_object.get("modified")
if modified is None:
return None
if isinstance(modified, datetime):
return modified
try:
return datetime.fromisoformat(modified.replace("Z", "+00:00"))
except (ValueError, AttributeError):
return None
def _fetch_attack_patterns_taxii() -> list[dict]:
"""Connect to the MITRE TAXII server and return all attack-pattern objects."""
logger.info("Connecting to MITRE TAXII server at %s", TAXII_SERVER_URL)
server = TaxiiServer(TAXII_SERVER_URL)
api_root = server.api_roots[0]
collection = api_root.collections[0] # Enterprise ATT&CK
logger.info(
"Fetching objects from collection '%s' (id=%s)",
collection.title,
collection.id,
)
bundle = collection.get_objects()
objects = bundle.get("objects", [])
attack_patterns = [
obj for obj in objects if obj.get("type") == "attack-pattern"
]
logger.info("Retrieved %d attack-pattern objects via TAXII", len(attack_patterns))
return attack_patterns
def _fetch_attack_patterns_github() -> list[dict]:
"""Fallback: fetch Enterprise ATT&CK bundle from the MITRE CTI GitHub repo."""
logger.info("Fetching Enterprise ATT&CK bundle from GitHub (%s)", GITHUB_ENTERPRISE_URL)
resp = _requests.get(GITHUB_ENTERPRISE_URL, timeout=120)
resp.raise_for_status()
bundle = resp.json()
objects = bundle.get("objects", [])
attack_patterns = [
obj for obj in objects if obj.get("type") == "attack-pattern"
]
logger.info("Retrieved %d attack-pattern objects via GitHub", len(attack_patterns))
return attack_patterns
def _fetch_attack_patterns() -> list[dict]:
"""Return all attack-pattern objects, trying TAXII first then GitHub."""
try:
return _fetch_attack_patterns_taxii()
except Exception as exc:
logger.warning(
"TAXII server unavailable (%s), falling back to GitHub mirror",
exc,
)
return _fetch_attack_patterns_github()
def sync_mitre(db: Session) -> dict:
"""Synchronize MITRE ATT&CK techniques into the local database.
Parameters
----------
db : Session
Active SQLAlchemy database session.
Returns
-------
dict
Summary with keys ``created``, ``updated``, ``unchanged``, ``skipped``.
"""
attack_patterns = _fetch_attack_patterns()
# Pre-load existing techniques keyed by mitre_id for fast lookup
existing_techniques: dict[str, Technique] = {
t.mitre_id: t for t in db.query(Technique).all()
}
created = 0
updated = 0
unchanged = 0
skipped = 0
for obj in attack_patterns:
# ------------------------------------------------------------------
# Skip revoked / deprecated objects
# ------------------------------------------------------------------
if obj.get("revoked", False) or obj.get("x_mitre_deprecated", False):
skipped += 1
continue
mitre_id = _extract_mitre_id(obj.get("external_references", []))
if not mitre_id:
skipped += 1
continue
name = obj.get("name", "")
description = obj.get("description", "")
tactic = _extract_tactics(obj.get("kill_chain_phases", []))
platforms = _extract_platforms(obj)
version = _extract_version(obj)
last_modified = _extract_last_modified(obj)
is_subtechnique = "." in mitre_id
parent_mitre_id = mitre_id.split(".")[0] if is_subtechnique else None
existing = existing_techniques.get(mitre_id)
if existing is None:
# ---- Create new technique ----
technique = Technique(
mitre_id=mitre_id,
name=name,
description=description,
tactic=tactic,
platforms=platforms,
mitre_version=version,
mitre_last_modified=last_modified,
is_subtechnique=is_subtechnique,
parent_mitre_id=parent_mitre_id,
status_global=TechniqueStatus.not_evaluated,
review_required=False,
)
db.add(technique)
existing_techniques[mitre_id] = technique
created += 1
else:
# ---- Update if name or description changed ----
changes = False
if existing.name != name:
existing.name = name
changes = True
if (existing.description or "") != (description or ""):
existing.description = description
changes = True
# Always keep metadata up-to-date (does not trigger review)
existing.tactic = tactic
existing.platforms = platforms
existing.mitre_version = version
existing.mitre_last_modified = last_modified
existing.is_subtechnique = is_subtechnique
existing.parent_mitre_id = parent_mitre_id
if changes:
existing.review_required = True
updated += 1
else:
unchanged += 1
# Single commit for the whole batch
db.commit()
summary = {
"created": created,
"updated": updated,
"unchanged": unchanged,
"skipped": skipped,
}
logger.info(
"MITRE sync complete — created=%d, updated=%d, unchanged=%d, skipped=%d",
created,
updated,
unchanged,
skipped,
)
# Audit log (system action → user_id=None)
log_action(
db,
user_id=None,
action="mitre_sync",
entity_type="technique",
entity_id=None,
details=summary,
)
return summary
+36
View File
@@ -0,0 +1,36 @@
"""Service for recalculating the global status of a Technique
based on the state and result of its associated tests."""
from sqlalchemy.orm import Session
from app.models.enums import TechniqueStatus
from app.models.technique import Technique
def recalculate_technique_status(db: Session, technique: Technique) -> None:
"""Recompute ``technique.status_global`` from its tests and commit.
Rules
-----
- No tests → ``not_evaluated``
- Any test not yet ``validated`` → ``in_progress``
- All validated and all ``detected`` → ``validated``
- All validated and any ``partially_detected`` → ``partial``
- Otherwise → ``not_covered``
"""
tests = technique.tests
if not tests:
technique.status_global = TechniqueStatus.not_evaluated
elif any(t.state != "validated" for t in tests):
technique.status_global = TechniqueStatus.in_progress
else:
results = [t.result for t in tests]
if all(r == "detected" for r in results):
technique.status_global = TechniqueStatus.validated
elif any(r == "partially_detected" for r in results):
technique.status_global = TechniqueStatus.partial
else:
technique.status_global = TechniqueStatus.not_covered
db.commit()
+57
View File
@@ -0,0 +1,57 @@
"""MinIO / S3-compatible object-storage helpers.
Provides thin wrappers around boto3 for bucket management, file upload
and presigned-URL generation.
"""
import boto3
from botocore.exceptions import ClientError
from app.config import settings
# ---------------------------------------------------------------------------
# Shared client (module-level singleton)
# ---------------------------------------------------------------------------
_client = boto3.client(
"s3",
endpoint_url=f"http://{settings.MINIO_ENDPOINT}",
aws_access_key_id=settings.MINIO_ACCESS_KEY,
aws_secret_access_key=settings.MINIO_SECRET_KEY,
region_name="us-east-1", # MinIO ignores this but boto3 requires it
)
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def ensure_bucket_exists() -> None:
"""Create the evidence bucket if it does not already exist."""
try:
_client.head_bucket(Bucket=settings.MINIO_BUCKET)
except ClientError:
_client.create_bucket(Bucket=settings.MINIO_BUCKET)
def upload_file(content: bytes, key: str) -> str:
"""Upload *content* to the evidence bucket under *key*.
Returns the key that was written (same as the input).
"""
_client.put_object(
Bucket=settings.MINIO_BUCKET,
Key=key,
Body=content,
)
return key
def get_presigned_url(key: str, expiration: int = 3600) -> str:
"""Return a presigned GET URL for *key* valid for *expiration* seconds."""
return _client.generate_presigned_url(
"get_object",
Params={"Bucket": settings.MINIO_BUCKET, "Key": key},
ExpiresIn=expiration,
)