Compare commits

..

3 Commits

Author SHA1 Message Date
kitos a4a2adccee feat(phase-39): role-based access control overhaul + forced password change
Aegis CI / lint-and-test (push) Has been cancelled
- Add must_change_password field to User model with migration b023

- Add POST /auth/change-password endpoint with password policy validation

- Add require_password_changed dependency to block requests until password is changed

- Add ChangePasswordModal with live password policy checklist (forced on first login)

- Show password policy in CreateUserModal and EditUserModal

- Fix backend permissions: tests, campaigns, templates, reports, evidence, worklogs

- red_tech/blue_tech: execute only, cannot create tests/campaigns/templates

- red_lead/blue_lead: create/edit tests/campaigns/templates, generate reports, no system access

- viewer: read-only everywhere, can generate reports

- Fix frontend role checks across TestDetailPage, TestDetailHeader, TeamTabs, TestsPage, CampaignsPage, CampaignDetailPage, Sidebar
2026-02-18 10:37:02 +01:00
kitos 8f764d8e39 fix: auto-detect kill chain phase when adding tests to custom campaigns 2026-02-17 17:53:15 +01:00
kitos 222979574a feat(phase-38): automatic intelligence — OSINT enrichment + stale coverage detection
Tarea 4.1 — OSINT Enrichment:
- Add OsintItem model with source_type, severity, CVSS metadata, review flag
- Add Alembic migration b022 with osint_items table and optimized indexes
- Add osint_enrichment_service with NVD API integration, deduplication, rate limiting
- Add OSINT router: GET /osint/items, /osint/summary, /osint/technique/{id}
- Add POST /osint/items/{id}/review to mark items as reviewed
- Add POST /osint/enrich/{technique_id} for manual single-technique enrichment
- Techniques with new CVEs are automatically flagged review_required=True
- Register weekly enrichment job in APScheduler
- Add NVD_API_KEY config setting for optional increased rate limits

Tarea 4.2 — Stale Coverage Detection:
- Add stale_detection_service that flags techniques with no validated test
  in the last N days, or never-validated but with a coverage status
- Configurable threshold via STALE_THRESHOLD_DAYS setting (default 365)
- Register daily stale detection job in APScheduler
- Only flags techniques not already marked review_required
2026-02-17 17:47:47 +01:00
33 changed files with 954 additions and 75 deletions
@@ -0,0 +1,47 @@
"""add_osint_items
Revision ID: b022osintitems
Revises: b021phasetiming
Create Date: 2026-02-17 22:00:00.000000
Add osint_items table for OSINT enrichment data linked to techniques.
"""
from alembic import op
revision = "b022osintitems"
down_revision = "b021phasetiming"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS osint_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
technique_id UUID NOT NULL REFERENCES techniques(id),
source_type VARCHAR(50) NOT NULL,
source_url TEXT NOT NULL,
title VARCHAR(500) NOT NULL,
description TEXT,
severity VARCHAR(20),
discovered_at TIMESTAMP NOT NULL DEFAULT now(),
reviewed BOOLEAN NOT NULL DEFAULT false,
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE INDEX IF NOT EXISTS ix_osint_items_technique_id
ON osint_items (technique_id);
CREATE INDEX IF NOT EXISTS ix_osint_items_source_type
ON osint_items (source_type);
CREATE INDEX IF NOT EXISTS ix_osint_items_reviewed
ON osint_items (reviewed) WHERE NOT reviewed;
""")
def downgrade() -> None:
op.execute("""
DROP TABLE IF EXISTS osint_items CASCADE;
""")
@@ -0,0 +1,30 @@
"""add_must_change_password
Revision ID: b023mustchgpwd
Revises: b022osintitems
Create Date: 2026-02-17 23:00:00.000000
Add must_change_password column to users table to force password
change on first login.
"""
from alembic import op
revision = "b023mustchgpwd"
down_revision = "b022osintitems"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
ALTER TABLE users
ADD COLUMN IF NOT EXISTS must_change_password BOOLEAN DEFAULT true;
""")
def downgrade() -> None:
op.execute("""
ALTER TABLE users
DROP COLUMN IF EXISTS must_change_password;
""")
+4
View File
@@ -58,6 +58,10 @@ class Settings(BaseSettings):
TEMPO_API_TOKEN: str = "" TEMPO_API_TOKEN: str = ""
TEMPO_DEFAULT_WORK_TYPE: str = "Red Team" TEMPO_DEFAULT_WORK_TYPE: str = "Red Team"
# ── OSINT / Intelligence ────────────────────────────────────────
NVD_API_KEY: str = "" # optional; increases NVD rate limit from 5/30s to 50/30s
STALE_THRESHOLD_DAYS: int = 365 # days before coverage is considered stale
# ── Reporting ───────────────────────────────────────────────────── # ── Reporting ─────────────────────────────────────────────────────
REPORT_TEMPLATES_DIR: str = "app/templates/reports" REPORT_TEMPLATES_DIR: str = "app/templates/reports"
REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports" REPORT_OUTPUT_DIR: str = "/tmp/aegis_reports"
+16
View File
@@ -91,6 +91,22 @@ async def get_current_user(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
async def require_password_changed(
current_user: User = Depends(get_current_user),
) -> User:
"""Block all requests when the user still needs to change their password.
Only ``/auth/change-password`` and ``/auth/me`` are exempt — those
endpoints do **not** depend on this function.
"""
if getattr(current_user, "must_change_password", False):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="PASSWORD_CHANGE_REQUIRED",
)
return current_user
def require_role(required_role: str): def require_role(required_role: str):
"""Return a FastAPI dependency that enforces *required_role*. """Return a FastAPI dependency that enforces *required_role*.
+46 -1
View File
@@ -21,6 +21,8 @@ from app.services.notification_service import cleanup_old_notifications
from app.services.snapshot_service import create_snapshot, cleanup_old_snapshots from app.services.snapshot_service import create_snapshot, cleanup_old_snapshots
from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns from app.services.campaign_scheduler_service import check_and_run_recurring_campaigns
from app.jobs.jira_sync_job import sync_all_jira_links from app.jobs.jira_sync_job import sync_all_jira_links
from app.services.osint_enrichment_service import enrich_all_techniques
from app.services.stale_detection_service import detect_stale_coverage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -108,6 +110,32 @@ def _run_intel_scan() -> None:
db.close() db.close()
def _run_osint_enrichment() -> None:
"""Execute weekly OSINT enrichment inside its own DB session."""
logger.info("Scheduled OSINT enrichment job starting...")
db = SessionLocal()
try:
total = enrich_all_techniques(db)
logger.info("OSINT enrichment finished — %d new items", total)
except Exception:
logger.exception("OSINT enrichment job failed")
finally:
db.close()
def _run_stale_detection() -> None:
"""Execute daily stale coverage detection inside its own DB session."""
logger.info("Scheduled stale coverage detection starting...")
db = SessionLocal()
try:
count = detect_stale_coverage(db)
logger.info("Stale detection finished — %d techniques flagged", count)
except Exception:
logger.exception("Stale coverage detection job failed")
finally:
db.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Scheduler bootstrap # Scheduler bootstrap
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -173,9 +201,26 @@ def start_scheduler() -> None:
name="Jira link sync (hourly)", name="Jira link sync (hourly)",
replace_existing=True, replace_existing=True,
) )
scheduler.add_job(
_run_osint_enrichment,
trigger="interval",
weeks=1,
id="osint_enrichment",
name="OSINT enrichment (weekly)",
replace_existing=True,
)
scheduler.add_job(
_run_stale_detection,
trigger="interval",
hours=24,
id="stale_detection",
name="Stale coverage detection (daily)",
replace_existing=True,
)
scheduler.start() scheduler.start()
logger.info( logger.info(
"Background scheduler started — mitre_sync (24h), intel_scan (7d), " "Background scheduler started — mitre_sync (24h), intel_scan (7d), "
"notification_cleanup (24h), weekly_snapshot (Sundays 00:00), " "notification_cleanup (24h), weekly_snapshot (Sundays 00:00), "
"recurring_campaigns (daily), jira_sync (1h)" "recurring_campaigns (daily), jira_sync (1h), "
"osint_enrichment (weekly), stale_detection (daily)"
) )
+2
View File
@@ -37,6 +37,7 @@ from app.routers import worklogs as worklogs_router
from app.routers import professional_reports as professional_reports_router from app.routers import professional_reports as professional_reports_router
from app.routers import analytics as analytics_router from app.routers import analytics as analytics_router
from app.routers import advanced_metrics as advanced_metrics_router from app.routers import advanced_metrics as advanced_metrics_router
from app.routers import osint as osint_router
from app.domain.exceptions import DomainException from app.domain.exceptions import DomainException
from app.middleware.error_handler import domain_exception_handler from app.middleware.error_handler import domain_exception_handler
from app.storage import ensure_bucket_exists from app.storage import ensure_bucket_exists
@@ -120,6 +121,7 @@ app.include_router(worklogs_router.router, prefix="/api/v1")
app.include_router(professional_reports_router.router, prefix="/api/v1") app.include_router(professional_reports_router.router, prefix="/api/v1")
app.include_router(analytics_router.router, prefix="/api/v1") app.include_router(analytics_router.router, prefix="/api/v1")
app.include_router(advanced_metrics_router.router, prefix="/api/v1") app.include_router(advanced_metrics_router.router, prefix="/api/v1")
app.include_router(osint_router.router, prefix="/api/v1")
@app.get("/health", include_in_schema=False) @app.get("/health", include_in_schema=False)
+2 -1
View File
@@ -18,6 +18,7 @@ from app.models.compliance import ComplianceFramework, ComplianceControl, Compli
from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState from app.models.coverage_snapshot import CoverageSnapshot, SnapshotTechniqueState
from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection from app.models.jira_link import JiraLink, JiraLinkEntityType, JiraSyncDirection
from app.models.worklog import Worklog from app.models.worklog import Worklog
from app.models.osint_item import OsintItem
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
__all__ = [ __all__ = [
@@ -30,6 +31,6 @@ __all__ = [
"ComplianceFramework", "ComplianceControl", "ComplianceControlMapping", "ComplianceFramework", "ComplianceControl", "ComplianceControlMapping",
"CoverageSnapshot", "SnapshotTechniqueState", "CoverageSnapshot", "SnapshotTechniqueState",
"JiraLink", "JiraLinkEntityType", "JiraSyncDirection", "JiraLink", "JiraLinkEntityType", "JiraSyncDirection",
"Worklog", "Worklog", "OsintItem",
"TechniqueStatus", "TestState", "TestResult", "TeamSide", "TechniqueStatus", "TestState", "TestResult", "TeamSide",
] ]
+40
View File
@@ -0,0 +1,40 @@
"""OSINT enrichment items — CVEs, blogs, PoCs, and advisories linked to techniques."""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from app.database import Base
class OsintItem(Base):
"""Represents an OSINT data point (CVE, blog, PoC, advisory) associated
with a MITRE ATT&CK technique.
Used by the enrichment pipeline to surface relevant threat intelligence
for each technique, flagging those that need review.
"""
__tablename__ = "osint_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
technique_id = Column(
UUID(as_uuid=True),
ForeignKey("techniques.id"),
nullable=False,
index=True,
)
source_type = Column(String(50), nullable=False) # "cve", "blog", "poc", "advisory"
source_url = Column(Text, nullable=False)
title = Column(String(500), nullable=False)
description = Column(Text, nullable=True)
severity = Column(String(20), nullable=True) # CRITICAL, HIGH, MEDIUM, LOW, UNKNOWN
discovered_at = Column(DateTime, default=datetime.utcnow, nullable=False)
reviewed = Column(Boolean, default=False)
metadata_ = Column("metadata", JSONB, default={})
# ── Relationships ─────────────────────────────────────────────────
technique = relationship("Technique", backref="osint_items")
+1
View File
@@ -27,5 +27,6 @@ class User(Base):
hashed_password = Column(String, nullable=False) hashed_password = Column(String, nullable=False)
role = Column(String, nullable=False, default="viewer") role = Column(String, nullable=False, default="viewer")
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
must_change_password = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
last_login = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True)
+32 -1
View File
@@ -17,12 +17,13 @@ from sqlalchemy.orm import Session
from jose import jwt, JWTError from jose import jwt, JWTError
from app.auth import verify_password, create_access_token, blacklist_token from app.auth import verify_password, hash_password, create_access_token, blacklist_token
from app.config import settings from app.config import settings
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user from app.dependencies.auth import get_current_user
from app.models.user import User from app.models.user import User
from app.schemas.auth import TokenResponse, UserOut from app.schemas.auth import TokenResponse, UserOut
from app.schemas.user import PasswordChange
# Rate limiter instance (shares backend state via app.state.limiter) # Rate limiter instance (shares backend state via app.state.limiter)
limiter = Limiter(key_func=get_remote_address) limiter = Limiter(key_func=get_remote_address)
@@ -137,3 +138,33 @@ def logout(
def read_current_user(current_user: User = Depends(get_current_user)): def read_current_user(current_user: User = Depends(get_current_user)):
"""Return the profile of the currently authenticated user.""" """Return the profile of the currently authenticated user."""
return current_user return current_user
# ---------------------------------------------------------------------------
# POST /auth/change-password
# ---------------------------------------------------------------------------
@router.post("/change-password")
def change_password(
body: PasswordChange,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Change the current user's password.
Requires the current password for verification. On success the
``must_change_password`` flag is cleared so the user can proceed
normally.
"""
if not verify_password(body.current_password, current_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Current password is incorrect",
)
current_user.hashed_password = hash_password(body.new_password)
current_user.must_change_password = False
db.commit()
return {"detail": "Password changed successfully"}
+16 -8
View File
@@ -24,6 +24,7 @@ from app.services.campaign_service import (
validate_no_circular_dependency, validate_no_circular_dependency,
get_campaign_progress, get_campaign_progress,
generate_campaign_from_threat_actor, generate_campaign_from_threat_actor,
TACTIC_TO_PHASE,
) )
from app.services.campaign_scheduler_service import calculate_next_run from app.services.campaign_scheduler_service import calculate_next_run
from app.services.notification_service import create_notification from app.services.notification_service import create_notification
@@ -197,7 +198,7 @@ def list_campaigns(
def create_campaign( def create_campaign(
payload: CampaignCreate, payload: CampaignCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Create a new campaign.""" """Create a new campaign."""
campaign = Campaign( campaign = Campaign(
@@ -253,7 +254,7 @@ def update_campaign(
campaign_id: str, campaign_id: str,
payload: CampaignUpdate, payload: CampaignUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Update a campaign. Only allowed in draft or active state.""" """Update a campaign. Only allowed in draft or active state."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
@@ -298,7 +299,7 @@ def add_test_to_campaign(
campaign_id: str, campaign_id: str,
payload: AddTestPayload, payload: AddTestPayload,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Add a test to a campaign with optional ordering and dependency.""" """Add a test to a campaign with optional ordering and dependency."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
@@ -331,13 +332,20 @@ def add_test_to_campaign(
if depends_on: if depends_on:
validate_no_circular_dependency(db, uuid.UUID(campaign_id), ct_id, depends_on) validate_no_circular_dependency(db, uuid.UUID(campaign_id), ct_id, depends_on)
# Auto-detect kill chain phase from the test's technique tactic if not provided
phase = payload.phase
if not phase and test.technique_id:
technique = db.query(Technique).filter(Technique.id == test.technique_id).first()
if technique and technique.tactic:
phase = TACTIC_TO_PHASE.get(technique.tactic, None)
campaign_test = CampaignTest( campaign_test = CampaignTest(
id=ct_id, id=ct_id,
campaign_id=campaign_id, campaign_id=campaign_id,
test_id=payload.test_id, test_id=payload.test_id,
order_index=order_index, order_index=order_index,
depends_on=depends_on, depends_on=depends_on,
phase=payload.phase, phase=phase,
) )
db.add(campaign_test) db.add(campaign_test)
db.commit() db.commit()
@@ -362,7 +370,7 @@ def remove_test_from_campaign(
campaign_id: str, campaign_id: str,
campaign_test_id: str, campaign_test_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Remove a test from a campaign.""" """Remove a test from a campaign."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
@@ -406,7 +414,7 @@ def remove_test_from_campaign(
def activate_campaign( def activate_campaign(
campaign_id: str, campaign_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Activate a campaign, moving it from draft to active.""" """Activate a campaign, moving it from draft to active."""
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first() campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
@@ -516,7 +524,7 @@ def get_campaign_progress_endpoint(
def generate_campaign_from_actor( def generate_campaign_from_actor(
actor_id: str, actor_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech", "admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Auto-generate a campaign from a threat actor's uncovered techniques. """Auto-generate a campaign from a threat actor's uncovered techniques.
@@ -550,7 +558,7 @@ def schedule_campaign(
campaign_id: str, campaign_id: str,
payload: SchedulePayload, payload: SchedulePayload,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Configure or update the recurrence schedule for a campaign. """Configure or update the recurrence schedule for a campaign.
+6 -8
View File
@@ -98,11 +98,10 @@ def _validate_upload_permission(
return return
if team == TeamSide.red: if team == TeamSide.red:
# Only red_tech can upload red evidence if user.role not in ("red_tech", "red_lead"):
if user.role != "red_tech":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only red_tech or admin can upload red evidence", detail="Only red_tech, red_lead or admin can upload red evidence",
) )
if test.state not in _RED_EDITABLE_STATES: if test.state not in _RED_EDITABLE_STATES:
raise HTTPException( raise HTTPException(
@@ -111,11 +110,10 @@ def _validate_upload_permission(
f"(allowed in: draft, red_executing)", f"(allowed in: draft, red_executing)",
) )
elif team == TeamSide.blue: elif team == TeamSide.blue:
# Only blue_tech can upload blue evidence if user.role not in ("blue_tech", "blue_lead"):
if user.role != "blue_tech":
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only blue_tech or admin can upload blue evidence", detail="Only blue_tech, blue_lead or admin can upload blue evidence",
) )
if test.state not in _BLUE_EDITABLE_STATES: if test.state not in _BLUE_EDITABLE_STATES:
raise HTTPException( raise HTTPException(
@@ -150,7 +148,7 @@ def _validate_delete_permission(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete red evidence outside draft/red_executing", detail="Cannot delete red evidence outside draft/red_executing",
) )
if user.role != "red_tech" and evidence.uploaded_by != user.id: if user.role not in ("red_tech", "red_lead") and evidence.uploaded_by != user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this evidence", detail="Not enough permissions to delete this evidence",
@@ -161,7 +159,7 @@ def _validate_delete_permission(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot delete blue evidence outside blue_evaluating", detail="Cannot delete blue evidence outside blue_evaluating",
) )
if user.role != "blue_tech" and evidence.uploaded_by != user.id: if user.role not in ("blue_tech", "blue_lead") and evidence.uploaded_by != user.id:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions to delete this evidence", detail="Not enough permissions to delete this evidence",
+197
View File
@@ -0,0 +1,197 @@
"""OSINT enrichment endpoints — view, review, and trigger enrichment of
OSINT items (CVEs, advisories, etc.) linked to techniques.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_any_role
from app.models.osint_item import OsintItem
from app.models.technique import Technique
from app.models.user import User
from app.services.osint_enrichment_service import (
enrich_technique_with_cves,
get_osint_items_for_technique,
mark_osint_reviewed,
get_unreviewed_count,
)
router = APIRouter(prefix="/osint", tags=["osint"])
# ── Schemas ──────────────────────────────────────────────────────────
class OsintItemOut(BaseModel):
id: str
technique_id: str
source_type: str
source_url: str
title: str
description: str | None
severity: str | None
discovered_at: str | None
reviewed: bool
metadata_: dict | None = None
class Config:
from_attributes = True
# ── Endpoints ────────────────────────────────────────────────────────
@router.get("/items")
def list_osint_items(
technique_id: UUID | None = Query(None),
source_type: str | None = Query(None),
reviewed: bool | None = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""List OSINT items with optional filters."""
query = db.query(OsintItem)
if technique_id:
query = query.filter(OsintItem.technique_id == technique_id)
if source_type:
query = query.filter(OsintItem.source_type == source_type)
if reviewed is not None:
query = query.filter(OsintItem.reviewed == reviewed)
total = query.count()
items = (
query.order_by(OsintItem.discovered_at.desc())
.offset(offset)
.limit(limit)
.all()
)
return {
"total": total,
"items": [
{
"id": str(item.id),
"technique_id": str(item.technique_id),
"source_type": item.source_type,
"source_url": item.source_url,
"title": item.title,
"description": item.description,
"severity": item.severity,
"discovered_at": item.discovered_at.isoformat() if item.discovered_at else None,
"reviewed": item.reviewed,
"metadata": item.metadata_,
}
for item in items
],
}
@router.get("/summary")
def osint_summary(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Summary statistics for OSINT items."""
from sqlalchemy import func
total = db.query(func.count(OsintItem.id)).scalar() or 0
unreviewed = get_unreviewed_count(db)
by_severity = dict(
db.query(OsintItem.severity, func.count(OsintItem.id))
.group_by(OsintItem.severity)
.all()
)
by_type = dict(
db.query(OsintItem.source_type, func.count(OsintItem.id))
.group_by(OsintItem.source_type)
.all()
)
techniques_with_items = (
db.query(func.count(func.distinct(OsintItem.technique_id))).scalar() or 0
)
return {
"total_items": total,
"unreviewed": unreviewed,
"techniques_with_items": techniques_with_items,
"by_severity": by_severity,
"by_type": by_type,
}
@router.post("/items/{item_id}/review")
def review_osint_item(
item_id: UUID,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Mark an OSINT item as reviewed."""
item = mark_osint_reviewed(db, str(item_id))
if not item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="OSINT item not found",
)
return {"id": str(item.id), "reviewed": True}
@router.post("/enrich/{technique_id}")
def trigger_technique_enrichment(
technique_id: UUID,
db: Session = Depends(get_db),
user: User = Depends(require_any_role("red_lead", "blue_lead")),
):
"""Manually trigger OSINT enrichment for a single technique."""
technique = db.query(Technique).filter(Technique.id == technique_id).first()
if not technique:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Technique not found",
)
count = enrich_technique_with_cves(db, technique)
return {
"technique_id": str(technique.id),
"mitre_id": technique.mitre_id,
"new_items": count,
}
@router.get("/technique/{technique_id}")
def get_technique_osint(
technique_id: UUID,
source_type: str | None = Query(None),
reviewed: bool | None = Query(None),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
):
"""Get all OSINT items for a specific technique."""
items = get_osint_items_for_technique(
db,
str(technique_id),
source_type=source_type,
reviewed=reviewed,
)
return [
{
"id": str(item.id),
"source_type": item.source_type,
"source_url": item.source_url,
"title": item.title,
"description": item.description,
"severity": item.severity,
"discovered_at": item.discovered_at.isoformat() if item.discovered_at else None,
"reviewed": item.reviewed,
"metadata": item.metadata_,
}
for item in items
]
+3 -3
View File
@@ -25,7 +25,7 @@ def generate_purple_report(
campaign_id: UUID, campaign_id: UUID,
format: str = Query("pdf", pattern="^(pdf|docx|html)$"), format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(require_any_role("red_lead", "blue_lead")), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
): ):
"""Generate a Purple Team campaign assessment report.""" """Generate a Purple Team campaign assessment report."""
filepath = report_generation_service.generate_purple_campaign_report( filepath = report_generation_service.generate_purple_campaign_report(
@@ -42,7 +42,7 @@ def generate_purple_report(
def generate_coverage_report( def generate_coverage_report(
format: str = Query("pdf", pattern="^(pdf|docx|html)$"), format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
): ):
"""Generate an organization-wide MITRE ATT&CK coverage report.""" """Generate an organization-wide MITRE ATT&CK coverage report."""
filepath = report_generation_service.generate_coverage_report( filepath = report_generation_service.generate_coverage_report(
@@ -59,7 +59,7 @@ def generate_coverage_report(
def generate_executive_report( def generate_executive_report(
format: str = Query("pdf", pattern="^(pdf|docx|html)$"), format: str = Query("pdf", pattern="^(pdf|docx|html)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(require_any_role("red_lead", "blue_lead")), user: User = Depends(require_any_role("red_lead", "blue_lead", "viewer")),
): ):
"""Generate an executive security summary report.""" """Generate an executive security summary report."""
filepath = report_generation_service.generate_executive_summary( filepath = report_generation_service.generate_executive_summary(
+12 -12
View File
@@ -30,7 +30,7 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user, require_role from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.test_template import TestTemplate from app.models.test_template import TestTemplate
from app.models.user import User from app.models.user import User
from app.schemas.test_template import ( from app.schemas.test_template import (
@@ -103,7 +103,7 @@ def list_templates(
@router.get("/stats") @router.get("/stats")
def template_stats( def template_stats(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Return catalog statistics: totals by source, platform, active/inactive.""" """Return catalog statistics: totals by source, platform, active/inactive."""
@@ -151,9 +151,9 @@ def template_stats(
def bulk_activate_templates( def bulk_activate_templates(
activate: bool = Query(True, description="True to activate all, False to deactivate all"), activate: bool = Query(True, description="True to activate all, False to deactivate all"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Set all templates to active or inactive. Admin only.""" """Set all templates to active or inactive."""
count = ( count = (
db.query(TestTemplate) db.query(TestTemplate)
.filter(TestTemplate.is_active != activate) .filter(TestTemplate.is_active != activate)
@@ -235,9 +235,9 @@ def get_template(
def create_template( def create_template(
payload: TestTemplateCreate, payload: TestTemplateCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Create a custom test template. Admin only.""" """Create a custom test template."""
template = TestTemplate(**payload.model_dump()) template = TestTemplate(**payload.model_dump())
db.add(template) db.add(template)
db.commit() db.commit()
@@ -269,9 +269,9 @@ def update_template(
template_id: uuid.UUID, template_id: uuid.UUID,
payload: TestTemplateCreate, payload: TestTemplateCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Update fields of an existing test template. Admin only.""" """Update fields of an existing test template."""
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
if template is None: if template is None:
raise HTTPException( raise HTTPException(
@@ -307,9 +307,9 @@ def update_template(
def toggle_template_active( def toggle_template_active(
template_id: uuid.UUID, template_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Toggle a template between active and inactive. Admin only.""" """Toggle a template between active and inactive."""
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
if template is None: if template is None:
raise HTTPException( raise HTTPException(
@@ -342,9 +342,9 @@ def toggle_template_active(
def delete_template( def delete_template(
template_id: uuid.UUID, template_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Soft-delete a test template by setting ``is_active=False``. Admin only.""" """Soft-delete a test template by setting ``is_active=False``."""
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first() template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
if template is None: if template is None:
raise HTTPException( raise HTTPException(
+13 -13
View File
@@ -143,7 +143,7 @@ def list_tests(
def create_test( def create_test(
payload: TestCreate, payload: TestCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Create a new test linked to an existing technique. """Create a new test linked to an existing technique.
@@ -190,7 +190,7 @@ def create_test(
def create_test_from_template( def create_test_from_template(
payload: TestTemplateInstantiate, payload: TestTemplateInstantiate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Instantiate a real Test from an existing TestTemplate. """Instantiate a real Test from an existing TestTemplate.
@@ -289,11 +289,11 @@ def update_test(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestUpdate, payload: TestUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Update one or more fields of an existing test. """Update one or more fields of an existing test.
Only the original creator or an admin can update. Only leads or admins can update general test fields.
The test must be in ``draft`` or ``rejected`` state. The test must be in ``draft`` or ``rejected`` state.
""" """
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -343,7 +343,7 @@ def update_test_red(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestRedUpdate, payload: TestRedUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_tech", "red_lead")),
): ):
"""Red Team updates their fields (allowed in ``draft`` and ``red_executing``).""" """Red Team updates their fields (allowed in ``draft`` and ``red_executing``)."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -387,7 +387,7 @@ def update_test_blue(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestBlueUpdate, payload: TestBlueUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech")), current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
): ):
"""Blue Team updates their fields (allowed only in ``blue_evaluating``).""" """Blue Team updates their fields (allowed only in ``blue_evaluating``)."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -430,7 +430,7 @@ def update_test_blue(
def start_execution( def start_execution(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_tech", "red_lead")),
): ):
"""Move a test from ``draft`` to ``red_executing``.""" """Move a test from ``draft`` to ``red_executing``."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -448,7 +448,7 @@ def start_execution(
def submit_red( def submit_red(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("red_tech")), current_user: User = Depends(require_any_role("red_tech", "red_lead")),
): ):
"""Red Team finalises — move from ``red_executing`` to ``blue_evaluating``.""" """Red Team finalises — move from ``red_executing`` to ``blue_evaluating``."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -466,7 +466,7 @@ def submit_red(
def submit_blue( def submit_blue(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech")), current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
): ):
"""Blue Team finalises — move from ``blue_evaluating`` to ``in_review``.""" """Blue Team finalises — move from ``blue_evaluating`` to ``in_review``."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -484,7 +484,7 @@ def submit_blue(
def pause_timer( def pause_timer(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
): ):
"""Pause the running timer for the current phase (red_executing or blue_evaluating).""" """Pause the running timer for the current phase (red_executing or blue_evaluating)."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -502,7 +502,7 @@ def pause_timer(
def resume_timer( def resume_timer(
test_id: uuid.UUID, test_id: uuid.UUID,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
): ):
"""Resume the paused timer for the current phase.""" """Resume the paused timer for the current phase."""
test = _get_test_or_404(db, test_id) test = _get_test_or_404(db, test_id)
@@ -595,9 +595,9 @@ def update_remediation(
test_id: uuid.UUID, test_id: uuid.UUID,
payload: TestRemediationUpdate, payload: TestRemediationUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(require_any_role("red_lead", "blue_lead")),
): ):
"""Update remediation fields on a test (any authenticated user). """Update remediation fields on a test.
When ``remediation_status`` transitions to ``'completed'``, an automatic When ``remediation_status`` transitions to ``'completed'``, an automatic
re-test is created (subject to ``MAX_RETEST_COUNT``). re-test is created (subject to ``MAX_RETEST_COUNT``).
+2 -2
View File
@@ -9,7 +9,7 @@ from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.dependencies.auth import get_current_user from app.dependencies.auth import get_current_user, require_any_role
from app.domain.exceptions import EntityNotFoundError from app.domain.exceptions import EntityNotFoundError
from app.models.user import User from app.models.user import User
from app.models.worklog import Worklog from app.models.worklog import Worklog
@@ -56,7 +56,7 @@ class WorklogOut(BaseModel):
def create( def create(
body: WorklogCreate, body: WorklogCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: User = Depends(get_current_user), user: User = Depends(require_any_role("red_tech", "blue_tech", "red_lead", "blue_lead")),
): ):
"""Create a manually-logged worklog entry.""" """Create a manually-logged worklog entry."""
wl = worklog_service.create_worklog( wl = worklog_service.create_worklog(
+1
View File
@@ -28,6 +28,7 @@ class UserOut(BaseModel):
email: str | None = None email: str | None = None
role: str role: str
is_active: bool is_active: bool
must_change_password: bool = True
class Config: class Config:
from_attributes = True from_attributes = True
+13
View File
@@ -83,6 +83,18 @@ class UserUpdate(BaseModel):
# ── Read (full) ───────────────────────────────────────────────────── # ── Read (full) ─────────────────────────────────────────────────────
class PasswordChange(BaseModel):
"""Payload for changing the current user's password."""
current_password: str
new_password: str
@field_validator("new_password")
@classmethod
def new_password_strength(cls, v: str) -> str:
return _validate_password_strength(v)
class UserOut(BaseModel): class UserOut(BaseModel):
"""Complete representation returned by the API.""" """Complete representation returned by the API."""
@@ -91,6 +103,7 @@ class UserOut(BaseModel):
email: str | None = None email: str | None = None
role: str role: str
is_active: bool is_active: bool
must_change_password: bool = True
created_at: datetime | None = None created_at: datetime | None = None
last_login: datetime | None = None last_login: datetime | None = None
@@ -0,0 +1,191 @@
"""OSINT enrichment service — automatically discovers CVEs, advisories, and
related intelligence for MITRE ATT&CK techniques using the NVD API.
Designed to run as a weekly background job. Respects NVD rate limits
(5 requests per 30 seconds without an API key, 50/30s with a key).
"""
import logging
import time
import requests
from sqlalchemy.orm import Session
from app.config import settings
from app.models.osint_item import OsintItem
from app.models.technique import Technique
logger = logging.getLogger(__name__)
NVD_API_BASE = "https://services.nvd.nist.gov/rest/json/cves/2.0"
NVD_RATE_LIMIT_BATCH = 5
NVD_RATE_LIMIT_WAIT = 31 # seconds to wait after each batch
def enrich_technique_with_cves(db: Session, technique: Technique) -> int:
"""Search for CVEs related to a technique via the NVD API.
Uses the technique name as a keyword search. Deduplicates against
existing OsintItems so re-runs are safe.
Returns the number of new CVEs added.
"""
try:
headers = {}
if getattr(settings, "NVD_API_KEY", ""):
headers["apiKey"] = settings.NVD_API_KEY
params = {
"keywordSearch": technique.name,
"resultsPerPage": 10,
}
resp = requests.get(
NVD_API_BASE,
params=params,
headers=headers,
timeout=30,
)
if resp.status_code != 200:
logger.warning(
"NVD API error for %s: HTTP %d",
technique.mitre_id,
resp.status_code,
)
return 0
data = resp.json()
count = 0
for vuln in data.get("vulnerabilities", []):
cve = vuln.get("cve", {})
cve_id = cve.get("id")
if not cve_id:
continue
# Deduplicate
exists = (
db.query(OsintItem.id)
.filter(
OsintItem.technique_id == technique.id,
OsintItem.source_url.contains(cve_id),
)
.first()
)
if exists:
continue
descriptions = cve.get("descriptions", [])
desc = next(
(d["value"] for d in descriptions if d["lang"] == "en"), ""
)
# Extract CVSS severity
metrics = cve.get("metrics", {})
cvss_v31 = metrics.get("cvssMetricV31", [])
cvss_v30 = metrics.get("cvssMetricV30", [])
cvss_entry = (cvss_v31[0] if cvss_v31 else cvss_v30[0]) if (cvss_v31 or cvss_v30) else {}
cvss_data = cvss_entry.get("cvssData", {}) if cvss_entry else {}
severity = cvss_data.get("baseSeverity", "UNKNOWN")
score = cvss_data.get("baseScore")
item = OsintItem(
technique_id=technique.id,
source_type="cve",
source_url=f"https://nvd.nist.gov/vuln/detail/{cve_id}",
title=cve_id,
description=desc[:500] if desc else None,
severity=severity,
metadata_={"cvss_score": score, "cve_id": cve_id},
)
db.add(item)
count += 1
if count > 0:
technique.review_required = True
db.commit()
logger.info("Added %d CVEs for %s", count, technique.mitre_id)
return count
except requests.RequestException as e:
logger.error(
"HTTP error during OSINT enrichment for %s: %s",
technique.mitre_id,
e,
)
return 0
except Exception as e:
logger.error(
"OSINT enrichment failed for %s: %s",
technique.mitre_id,
e,
exc_info=True,
)
return 0
def enrich_all_techniques(db: Session) -> int:
"""Enrich all techniques with CVE data from NVD.
Rate-limited: processes *NVD_RATE_LIMIT_BATCH* techniques, then
sleeps for *NVD_RATE_LIMIT_WAIT* seconds to stay under NVD limits.
Returns total number of new OSINT items added.
"""
techniques = db.query(Technique).order_by(Technique.mitre_id).all()
total = 0
logger.info(
"Starting OSINT enrichment for %d techniques...",
len(techniques),
)
for i, tech in enumerate(techniques):
total += enrich_technique_with_cves(db, tech)
# Rate limiting: wait after every batch
if (i + 1) % NVD_RATE_LIMIT_BATCH == 0 and (i + 1) < len(techniques):
logger.debug(
"Rate limit pause after %d techniques (%ds)...",
i + 1,
NVD_RATE_LIMIT_WAIT,
)
time.sleep(NVD_RATE_LIMIT_WAIT)
logger.info(
"OSINT enrichment complete — %d new items across %d techniques",
total,
len(techniques),
)
return total
def get_osint_items_for_technique(
db: Session,
technique_id: str,
source_type: str | None = None,
reviewed: bool | None = None,
) -> list[OsintItem]:
"""Retrieve OSINT items for a technique with optional filters."""
query = db.query(OsintItem).filter(OsintItem.technique_id == technique_id)
if source_type:
query = query.filter(OsintItem.source_type == source_type)
if reviewed is not None:
query = query.filter(OsintItem.reviewed == reviewed)
return query.order_by(OsintItem.discovered_at.desc()).all()
def mark_osint_reviewed(db: Session, item_id: str) -> OsintItem | None:
"""Mark an OSINT item as reviewed."""
item = db.query(OsintItem).filter(OsintItem.id == item_id).first()
if item:
item.reviewed = True
db.commit()
db.refresh(item)
return item
def get_unreviewed_count(db: Session) -> int:
"""Return the total number of unreviewed OSINT items."""
return db.query(OsintItem).filter(OsintItem.reviewed == False).count() # noqa: E712
@@ -0,0 +1,78 @@
"""Stale coverage detection — marks techniques whose last validated test
is older than a configurable threshold.
This is the simple version. The full Decay Engine (Fase 8) will replace
this with a multi-factor, configurable decay model with confidence scores.
"""
import logging
from datetime import datetime, timedelta
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.config import settings
from app.models.technique import Technique
from app.models.test import Test
logger = logging.getLogger(__name__)
STALE_THRESHOLD_DAYS = getattr(settings, "STALE_THRESHOLD_DAYS", 365)
def detect_stale_coverage(db: Session) -> int:
"""Scan all techniques and flag those with stale coverage.
A technique is considered stale when:
- It has a status other than ``not_evaluated``, AND
- Its most recent *validated* test is older than *STALE_THRESHOLD_DAYS*, OR
- It has never had a validated test (but has been manually marked as
covered/partial).
Returns the number of newly-flagged techniques.
"""
cutoff = datetime.utcnow() - timedelta(days=STALE_THRESHOLD_DAYS)
# Subquery: latest validated test date per technique
latest_test = (
db.query(
Test.technique_id,
func.max(Test.created_at).label("last_tested"),
)
.filter(Test.state == "validated")
.group_by(Test.technique_id)
.subquery()
)
# Find techniques that are stale
stale_techniques = (
db.query(Technique)
.outerjoin(latest_test, Technique.id == latest_test.c.technique_id)
.filter(
# Either tested before cutoff, or never tested at all
(latest_test.c.last_tested < cutoff)
| (latest_test.c.last_tested.is_(None))
)
.filter(
# Only flag techniques that have a real status (not never-evaluated ones)
Technique.status_global != "not_evaluated"
)
.all()
)
count = 0
for tech in stale_techniques:
if not tech.review_required:
tech.review_required = True
count += 1
logger.info("Marked %s as stale coverage", tech.mitre_id)
if count > 0:
db.commit()
logger.info(
"Stale coverage detection complete — %d techniques flagged", count
)
else:
logger.info("Stale coverage detection complete — no new stale techniques")
return count
+11
View File
@@ -34,3 +34,14 @@ export async function getMe(): Promise<User> {
const { data } = await client.get<User>("/auth/me"); const { data } = await client.get<User>("/auth/me");
return data; return data;
} }
/** Change the current user's password. */
export async function changePassword(
currentPassword: string,
newPassword: string,
): Promise<void> {
await client.post("/auth/change-password", {
current_password: currentPassword,
new_password: newPassword,
});
}
@@ -0,0 +1,148 @@
import { useState, useMemo } from "react";
import { changePassword } from "../api/auth";
interface PasswordRule {
label: string;
test: (pw: string) => boolean;
}
const PASSWORD_RULES: PasswordRule[] = [
{ label: "At least 12 characters", test: (pw) => pw.length >= 12 },
{ label: "At least one uppercase letter", test: (pw) => /[A-Z]/.test(pw) },
{ label: "At least one lowercase letter", test: (pw) => /[a-z]/.test(pw) },
{ label: "At least one digit", test: (pw) => /[0-9]/.test(pw) },
{
label: "At least one special character (!@#$%^&*…)",
test: (pw) => /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?`~]/.test(pw),
},
];
export function PasswordPolicyChecklist({ password }: { password: string }) {
return (
<ul className="mt-2 space-y-1 text-xs">
{PASSWORD_RULES.map((rule) => {
const ok = password.length > 0 && rule.test(password);
return (
<li key={rule.label} className="flex items-center gap-1.5">
<span className={ok ? "text-green-400" : "text-gray-500"}>
{ok ? "✓" : "○"}
</span>
<span className={ok ? "text-green-300" : "text-gray-400"}>
{rule.label}
</span>
</li>
);
})}
</ul>
);
}
interface Props {
onSuccess: () => void;
isForced?: boolean;
}
export default function ChangePasswordModal({ onSuccess, isForced }: Props) {
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [confirmPw, setConfirmPw] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const allRulesPass = useMemo(
() => PASSWORD_RULES.every((r) => r.test(newPw)),
[newPw],
);
const canSubmit =
currentPw.length > 0 &&
allRulesPass &&
newPw === confirmPw &&
!loading;
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!canSubmit) return;
setLoading(true);
setError(null);
try {
await changePassword(currentPw, newPw);
onSuccess();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { detail?: string } } })?.response?.data
?.detail ?? "Failed to change password";
setError(msg);
} finally {
setLoading(false);
}
}
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-sm">
<form
onSubmit={handleSubmit}
className="w-full max-w-md rounded-xl border border-gray-700 bg-gray-900 p-6 shadow-2xl"
>
<h2 className="mb-1 text-lg font-semibold text-white">
Change Password
</h2>
{isForced && (
<p className="mb-4 text-sm text-amber-400">
You must change your password before continuing.
</p>
)}
{error && (
<div className="mb-3 rounded bg-red-900/50 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
<label className="mb-1 block text-sm text-gray-400">
Current password
</label>
<input
type="password"
className="mb-4 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
value={currentPw}
onChange={(e) => setCurrentPw(e.target.value)}
autoFocus
/>
<label className="mb-1 block text-sm text-gray-400">
New password
</label>
<input
type="password"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
/>
<PasswordPolicyChecklist password={newPw} />
<label className="mb-1 mt-4 block text-sm text-gray-400">
Confirm new password
</label>
<input
type="password"
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-white focus:border-cyan-500 focus:outline-none"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
{confirmPw.length > 0 && newPw !== confirmPw && (
<p className="mt-1 text-xs text-red-400">Passwords do not match</p>
)}
<button
type="submit"
disabled={!canSubmit}
className="mt-6 w-full rounded-lg bg-cyan-600 py-2.5 text-sm font-medium text-white transition-colors hover:bg-cyan-500 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Changing…" : "Change Password"}
</button>
</form>
</div>
);
}
+2 -2
View File
@@ -33,7 +33,7 @@ interface NavItem {
const mainLinks: NavItem[] = [ const mainLinks: NavItem[] = [
{ to: "/dashboard", label: "Dashboard", icon: LayoutDashboard }, { to: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead"] }, { to: "/executive-dashboard", label: "Executive Dashboard", icon: Gauge, roles: ["admin", "red_lead", "blue_lead", "viewer"] },
{ to: "/matrix", label: "ATT&CK Matrix", icon: Grid3X3 }, { to: "/matrix", label: "ATT&CK Matrix", icon: Grid3X3 },
{ {
to: "/tests", to: "/tests",
@@ -48,7 +48,7 @@ const mainLinks: NavItem[] = [
{ to: "/campaigns", label: "Campaigns", icon: Zap }, { to: "/campaigns", label: "Campaigns", icon: Zap },
{ to: "/threat-actors", label: "Threat Actors", icon: Crosshair }, { to: "/threat-actors", label: "Threat Actors", icon: Crosshair },
{ to: "/compliance", label: "Compliance", icon: ShieldCheck }, { to: "/compliance", label: "Compliance", icon: ShieldCheck },
{ to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead"] }, { to: "/comparison", label: "Comparison", icon: GitCompareArrows, roles: ["admin", "red_lead", "blue_lead", "viewer"] },
{ to: "/reports", label: "Reports", icon: BarChart3 }, { to: "/reports", label: "Reports", icon: BarChart3 },
]; ];
@@ -119,11 +119,11 @@ export default function TeamTabs({
const canEditRed = const canEditRed =
RED_EDITABLE_STATES.includes(test.state) && RED_EDITABLE_STATES.includes(test.state) &&
(role === "red_tech" || role === "admin"); (role === "red_tech" || role === "red_lead" || role === "admin");
const canEditBlue = const canEditBlue =
BLUE_EDITABLE_STATES.includes(test.state) && BLUE_EDITABLE_STATES.includes(test.state) &&
(role === "blue_tech" || role === "admin"); (role === "blue_tech" || role === "blue_lead" || role === "admin");
// ── Red Team Tab ───────────────────────────────────────────────── // ── Red Team Tab ─────────────────────────────────────────────────
@@ -91,10 +91,10 @@ export default function TestDetailHeader({
const renderActions = () => { const renderActions = () => {
const buttons: React.ReactNode[] = []; const buttons: React.ReactNode[] = [];
// Red Tech in draft -> Start Execution // Red Team in draft -> Start Execution
if ( if (
test.state === "draft" && test.state === "draft" &&
(role === "red_tech" || role === "admin") (role === "red_tech" || role === "red_lead" || role === "admin")
) { ) {
buttons.push( buttons.push(
<button <button
@@ -109,10 +109,10 @@ export default function TestDetailHeader({
); );
} }
// Red Tech in red_executing -> Submit to Blue Team // Red Team in red_executing -> Submit to Blue Team
if ( if (
test.state === "red_executing" && test.state === "red_executing" &&
(role === "red_tech" || role === "admin") (role === "red_tech" || role === "red_lead" || role === "admin")
) { ) {
buttons.push( buttons.push(
<button <button
@@ -127,10 +127,10 @@ export default function TestDetailHeader({
); );
} }
// Blue Tech in blue_evaluating -> Submit for Review // Blue Team in blue_evaluating -> Submit for Review
if ( if (
test.state === "blue_evaluating" && test.state === "blue_evaluating" &&
(role === "blue_tech" || role === "admin") (role === "blue_tech" || role === "blue_lead" || role === "admin")
) { ) {
buttons.push( buttons.push(
<button <button
@@ -245,8 +245,8 @@ export default function TestDetailHeader({
// ── Live timer ─────────────────────────────────────────────────── // ── Live timer ───────────────────────────────────────────────────
const canControlTimer = const canControlTimer =
(test.state === "red_executing" && (role === "red_tech" || role === "admin")) || (test.state === "red_executing" && (role === "red_tech" || role === "red_lead" || role === "admin")) ||
(test.state === "blue_evaluating" && (role === "blue_tech" || role === "admin")); (test.state === "blue_evaluating" && (role === "blue_tech" || role === "blue_lead" || role === "admin"));
const renderLiveTimer = () => { const renderLiveTimer = () => {
if (test.state === "red_executing" && test.red_started_at) { if (test.state === "red_executing" && test.red_started_at) {
+17 -9
View File
@@ -12,6 +12,7 @@ import {
getMe, getMe,
} from "../api/auth"; } from "../api/auth";
import type { User } from "../types/models"; import type { User } from "../types/models";
import ChangePasswordModal from "../components/ChangePasswordModal";
/* ── Context shape ────────────────────────────────────────────────── */ /* ── Context shape ────────────────────────────────────────────────── */
@@ -31,18 +32,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
// On mount — try to hydrate the user from the existing HttpOnly cookie. const refreshUser = useCallback(async () => {
// If no valid cookie exists the /auth/me call will 401 and we stay try {
// unauthenticated — no localStorage involved. const me = await getMe();
useEffect(() => { setUser(me);
getMe() } catch {
.then(setUser) setUser(null);
.catch(() => setUser(null)) }
.finally(() => setIsLoading(false));
}, []); }, []);
useEffect(() => {
refreshUser().finally(() => setIsLoading(false));
}, [refreshUser]);
const login = useCallback(async (username: string, password: string) => { const login = useCallback(async (username: string, password: string) => {
// The backend sets the HttpOnly cookie automatically
await apiLogin(username, password); await apiLogin(username, password);
const me = await getMe(); const me = await getMe();
setUser(me); setUser(me);
@@ -53,6 +56,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(null); setUser(null);
}, []); }, []);
const mustChangePassword = user?.must_change_password === true;
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{
@@ -64,6 +69,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}} }}
> >
{children} {children}
{mustChangePassword && (
<ChangePasswordModal isForced onSuccess={refreshUser} />
)}
</AuthContext.Provider> </AuthContext.Provider>
); );
} }
+1 -1
View File
@@ -70,7 +70,7 @@ export default function CampaignDetailPage() {
}; };
const role = user?.role ?? ""; const role = user?.role ?? "";
const canManage = role === "admin" || role === "red_tech"; const canManage = role === "admin" || role === "red_lead" || role === "blue_lead";
const canComplete = role === "admin" || role === "red_lead"; const canComplete = role === "admin" || role === "red_lead";
const { const {
+1 -1
View File
@@ -56,7 +56,7 @@ export default function CampaignsPage() {
target_platform: "", target_platform: "",
}); });
const canCreate = user?.role === "admin" || user?.role === "red_tech"; const canCreate = user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
const { data, isLoading, error } = useQuery({ const { data, isLoading, error } = useQuery({
queryKey: ["campaigns", filters], queryKey: ["campaigns", filters],
+2 -2
View File
@@ -344,10 +344,10 @@ export default function TestDetailPage() {
const role = user?.role ?? ""; const role = user?.role ?? "";
const canSaveRed = const canSaveRed =
(test.state === "draft" || test.state === "red_executing") && (test.state === "draft" || test.state === "red_executing") &&
(role === "red_tech" || role === "admin"); (role === "red_tech" || role === "red_lead" || role === "admin");
const canSaveBlue = const canSaveBlue =
test.state === "blue_evaluating" && test.state === "blue_evaluating" &&
(role === "blue_tech" || role === "admin"); (role === "blue_tech" || role === "blue_lead" || role === "admin");
// ── Render ───────────────────────────────────────────────────── // ── Render ─────────────────────────────────────────────────────
+1 -1
View File
@@ -75,7 +75,7 @@ export default function TestsPage() {
const { user } = useAuth(); const { user } = useAuth();
const canCreate = const canCreate =
user?.role === "admin" || user?.role === "red_tech"; user?.role === "admin" || user?.role === "red_lead" || user?.role === "blue_lead";
// ── Filter state ────────────────────────────────────────────────── // ── Filter state ──────────────────────────────────────────────────
const [stateFilter, setStateFilter] = useState<TestState | "">(""); const [stateFilter, setStateFilter] = useState<TestState | "">("");
+8
View File
@@ -12,6 +12,7 @@ import {
Edit, Edit,
} from "lucide-react"; } from "lucide-react";
import { getUsers, createUser, updateUser, type UserOut, type UserCreatePayload } from "../api/users"; import { getUsers, createUser, updateUser, type UserOut, type UserCreatePayload } from "../api/users";
import { PasswordPolicyChecklist } from "../components/ChangePasswordModal";
const ROLES = [ const ROLES = [
{ value: "viewer", label: "Viewer" }, { value: "viewer", label: "Viewer" },
@@ -323,7 +324,11 @@ function CreateUserModal({ onClose, onSubmit, isSubmitting, error }: CreateUserM
errors.password ? "border-red-500" : "border-gray-700" errors.password ? "border-red-500" : "border-gray-700"
}`} }`}
/> />
<PasswordPolicyChecklist password={formData.password} />
{errors.password && <p className="mt-1 text-sm text-red-400">{errors.password}</p>} {errors.password && <p className="mt-1 text-sm text-red-400">{errors.password}</p>}
<p className="mt-1 text-xs text-amber-400/70">
The user will be required to change this password on first login.
</p>
</div> </div>
<div> <div>
@@ -446,6 +451,9 @@ function EditUserModal({ user, onClose, onSubmit, isSubmitting, error }: EditUse
className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200" className="mt-1 w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-gray-200"
placeholder="••••••••" placeholder="••••••••"
/> />
{formData.password.length > 0 && (
<PasswordPolicyChecklist password={formData.password} />
)}
</div> </div>
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">
+1
View File
@@ -6,6 +6,7 @@ export interface User {
id: string; id: string;
username: string; username: string;
role: string; role: string;
must_change_password?: boolean;
} }
// ── Techniques ───────────────────────────────────────────────────── // ── Techniques ─────────────────────────────────────────────────────