From c2e9c687f4c0789da59805e6ae7a528b556bae9e Mon Sep 17 00:00:00 2001 From: Kitos Date: Tue, 10 Feb 2026 13:22:23 +0100 Subject: [PATCH] fix: D3FEND expandable cards, System page cleanup, and multi-source improvements - Make D3FEND defense cards clickable with expandable details and external link - Fix D3FEND URLs to use PascalCase technique names matching the ontology - Remove duplicate Import Atomic Red Team from System page (use Data Sources) - Add bulk Activate All / Deactivate All buttons with confirmation modal - Fix template admin list to show both active and inactive templates - Add PATCH /test-templates/bulk-activate backend endpoint - Auto-seed data sources on container startup via entrypoint.sh - Fix SigmaHQ, CALDERA, GTFOBins import issues - Register D3FEND sync handler in data sources router - Add CIS Controls v8 compliance framework import - Expand Test Catalog source filters (CALDERA, LOLBAS, GTFOBins) - Campaign Generate from Threat Actor now opens actor selector modal - Add coverage snapshot creation button to Comparison page - Update README with accurate data source and feature documentation --- README.md | 29 +-- backend/app/routers/compliance.py | 15 +- backend/app/routers/data_sources.py | 2 +- backend/app/routers/test_templates.py | 40 +++- backend/app/seed_data_sources.py | 13 +- .../app/services/caldera_import_service.py | 17 +- .../app/services/compliance_import_service.py | 153 ++++++++++++++ backend/app/services/d3fend_import_service.py | 41 +++- backend/app/services/lolbas_import_service.py | 17 +- backend/app/services/sigma_import_service.py | 6 +- backend/entrypoint.sh | 3 + frontend/src/api/compliance.ts | 6 + frontend/src/api/test-templates.ts | 25 ++- frontend/src/pages/CampaignsPage.tsx | 110 +++++++++- frontend/src/pages/ComparisonPage.tsx | 64 +++++- frontend/src/pages/CompliancePage.tsx | 59 +++++- frontend/src/pages/SystemPage.tsx | 165 +++++++-------- frontend/src/pages/TechniqueDetailPage.tsx | 194 +++++++++++------- frontend/src/pages/TestCatalogPage.tsx | 16 +- 19 files changed, 778 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index cba44c9..74b58d5 100644 --- a/README.md +++ b/README.md @@ -13,19 +13,19 @@ Aegis is a comprehensive platform for tracking and managing security coverage ag - **Role-Based Access Control** — Granular permissions for 6 roles (admin, red_tech, blue_tech, red_lead, blue_lead, viewer) ### Enhanced (V2) -- **Test Template Catalog** — Import from Atomic Red Team, create custom templates, instantiate tests +- **Test Template Catalog** — Import from Atomic Red Team, CALDERA, LOLBAS, GTFOBins; create custom templates; bulk activate/deactivate - **In-App Notifications** — Real-time notification bell with polling and automatic state-change alerts - **Reports & Export** — Coverage summary, test results, and remediation reports in JSON and CSV - **Remediation Tracking** — Step-by-step remediation assignments with status tracking - **Metrics Dashboard** — Pipeline funnel, team activity, validation rates ### Advanced (V3) -- **Multi-Source Data Import** — Sigma, Elastic, CALDERA, LOLBAS, D3FEND, MITRE CTI threat actors, compliance mappings +- **Multi-Source Data Import** — Sigma, CALDERA, LOLBAS, GTFOBins, D3FEND, MITRE CTI threat actors, compliance mappings (NIST 800-53, CIS Controls v8) - **Detection Rule Tracking** — Import and evaluate Sigma/Elastic detection rules per test - **ATT&CK Heatmap** — Interactive Navigator-style heatmap with layers, filters, and export - **Threat Actor Intelligence** — Track intrusion sets and their technique coverage - **Campaign Management** — Group tests into campaigns with dependencies, scheduling, and recurring execution -- **Compliance Mapping** — Map NIST 800-53 controls to ATT&CK techniques with gap analysis +- **Compliance Mapping** — Map NIST 800-53 and CIS Controls v8 to ATT&CK techniques with gap analysis - **Granular Scoring** — 0–100 scoring for techniques, tactics, actors, and organization with configurable weights - **Operational Metrics** — MTTD, MTTR, detection efficacy, alert fidelity, coverage velocity - **Executive Dashboard** — High-level KPIs for leadership (leads + admin) @@ -141,17 +141,20 @@ Password: admin123 ### Importing Data Sources -After initial setup, populate the platform with data: +After initial setup, the entrypoint script automatically seeds the initial data sources (Atomic Red Team, SigmaHQ, CALDERA, LOLBAS, GTFOBins, D3FEND). You can then sync each source from the UI: + +1. Navigate to **System > Data Sources** in the admin panel +2. Click **Sync** on each data source to import its content +3. Trigger a **MITRE ATT&CK Sync** from the **System > MITRE Sync** page + +Alternatively, use the API: ```bash -# 1. Sync MITRE ATT&CK techniques +# Sync MITRE ATT&CK techniques curl -X POST http://localhost:8000/api/v1/system/sync-mitre -H "Authorization: Bearer $TOKEN" -# 2. Import test templates from Atomic Red Team -curl -X POST http://localhost:8000/api/v1/system/import-atomic-red-team -H "Authorization: Bearer $TOKEN" - -# 3. Import additional sources via the Data Sources admin page -# Navigate to System → Data Sources in the UI +# Sync all data sources at once +curl -X POST http://localhost:8000/api/v1/data-sources/sync-all -H "Authorization: Bearer $TOKEN" ``` See [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md) for detailed instructions on all data sources. @@ -203,8 +206,8 @@ Or at runtime via the admin API — see [docs/SCORING.md](docs/SCORING.md). 📈 Comparison (leads + admin) 📄 Reports ⚙️ System (admin only) - ├─ Data Sources - ├─ MITRE Sync + ├─ Data Sources (sync Atomic, Sigma, CALDERA, LOLBAS, GTFOBins, D3FEND) + ├─ MITRE Sync (ATT&CK sync, intel scan, template management) ├─ Users └─ Audit Log ``` @@ -223,7 +226,7 @@ Interactive API documentation available at: | Auth | `/api/v1/auth` | Login, get current user | | Techniques | `/api/v1/techniques` | CRUD, list with filters, mark reviewed | | Tests | `/api/v1/tests` | Full Red/Blue workflow, remediation, retest chain | -| Test Templates | `/api/v1/test-templates` | CRUD, import, stats, toggle active | +| Test Templates | `/api/v1/test-templates` | CRUD, stats, toggle active, bulk activate/deactivate | | Evidence | `/api/v1/tests/{id}/evidence` | Upload evidence, get presigned URLs | | Campaigns | `/api/v1/campaigns` | CRUD, scheduling, history | | Threat Actors | `/api/v1/threat-actors` | CRUD, technique mappings | diff --git a/backend/app/routers/compliance.py b/backend/app/routers/compliance.py index b8bfd5d..090f4f0 100644 --- a/backend/app/routers/compliance.py +++ b/backend/app/routers/compliance.py @@ -24,7 +24,10 @@ from app.models.technique import Technique from app.models.test_template import TestTemplate from app.models.threat_actor import ThreatActorTechnique from app.services.scoring_service import calculate_technique_score -from app.services.compliance_import_service import import_nist_800_53_mappings +from app.services.compliance_import_service import ( + import_nist_800_53_mappings, + import_cis_controls_v8_mappings, +) router = APIRouter(prefix="/compliance", tags=["compliance"]) @@ -378,3 +381,13 @@ def import_nist( """Import NIST 800-53 Rev 5 mappings (admin only).""" result = import_nist_800_53_mappings(db) return result + + +@router.post("/import/cis-controls-v8") +def import_cis( + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Import CIS Controls v8 mappings (admin only).""" + result = import_cis_controls_v8_mappings(db) + return result diff --git a/backend/app/routers/data_sources.py b/backend/app/routers/data_sources.py index 6109ee2..12425bb 100644 --- a/backend/app/routers/data_sources.py +++ b/backend/app/routers/data_sources.py @@ -40,7 +40,7 @@ def _get_sync_handler(source_name: str): "caldera": ("app.services.caldera_import_service", "sync"), "elastic_rules": ("app.services.elastic_import_service", "sync"), "mitre_cti": ("app.services.threat_actor_import_service", "sync"), - # d3fend added in later phases + "d3fend": ("app.services.d3fend_import_service", "sync"), } if source_name not in handlers: diff --git a/backend/app/routers/test_templates.py b/backend/app/routers/test_templates.py index f36e9f3..45f0e69 100644 --- a/backend/app/routers/test_templates.py +++ b/backend/app/routers/test_templates.py @@ -55,13 +55,16 @@ def list_templates( severity: Optional[str] = Query(None, description="Filter by severity (low, medium, high, critical)"), mitre_technique_id: Optional[str] = Query(None, description="Filter by MITRE technique ID"), search: Optional[str] = Query(None, description="Search in name and description"), + is_active: Optional[bool] = Query(None, description="Filter by active status (true/false). Omit to return all."), offset: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=200), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return a paginated, filterable list of test templates.""" - query = db.query(TestTemplate).filter(TestTemplate.is_active == True) # noqa: E712 + query = db.query(TestTemplate) + if is_active is not None: + query = query.filter(TestTemplate.is_active == is_active) # noqa: E712 if source: query = query.filter(TestTemplate.source == source) @@ -137,6 +140,41 @@ def template_stats( } +# --------------------------------------------------------------------------- +# PATCH /test-templates/bulk-activate — activate/deactivate all (admin) +# --------------------------------------------------------------------------- + + +@router.patch("/bulk-activate") +def bulk_activate_templates( + activate: bool = Query(True, description="True to activate all, False to deactivate all"), + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +): + """Set all templates to active or inactive. Admin only.""" + count = ( + db.query(TestTemplate) + .filter(TestTemplate.is_active != activate) + .update({TestTemplate.is_active: activate}) + ) + db.commit() + + log_action( + db, + user_id=current_user.id, + action="bulk_activate_templates" if activate else "bulk_deactivate_templates", + entity_type="test_template", + entity_id=None, + details={"affected": count, "is_active": activate}, + ) + + return { + "detail": f"{'Activated' if activate else 'Deactivated'} {count} templates", + "affected": count, + "is_active": activate, + } + + # --------------------------------------------------------------------------- # GET /test-templates/by-technique/{mitre_id} # --------------------------------------------------------------------------- diff --git a/backend/app/seed_data_sources.py b/backend/app/seed_data_sources.py index d8ef621..0c4b723 100644 --- a/backend/app/seed_data_sources.py +++ b/backend/app/seed_data_sources.py @@ -41,8 +41,8 @@ INITIAL_SOURCES = [ "3 000+ rules with MITRE ATT&CK mappings.", "sync_frequency": "weekly", "config": { - "zip_url": "https://github.com/SigmaHQ/sigma/archive/refs/heads/main.zip", - "root_prefix": "sigma-main", + "zip_url": "https://github.com/SigmaHQ/sigma/archive/refs/heads/master.zip", + "root_prefix": "sigma-master", "rules_dir": "rules", }, }, @@ -78,13 +78,14 @@ INITIAL_SOURCES = [ "name": "caldera", "display_name": "MITRE CALDERA", "type": "attack_procedure", - "url": "https://github.com/mitre/caldera", + "url": "https://github.com/mitre/stockpile", "description": "Automated adversary emulation platform by MITRE. " - "400+ abilities (executable actions) mapped to ATT&CK.", + "400+ abilities (executable actions) mapped to ATT&CK " + "(via the Stockpile plugin).", "sync_frequency": "monthly", "config": { - "zip_url": "https://github.com/mitre/caldera/archive/refs/heads/master.zip", - "root_prefix": "caldera-master", + "zip_url": "https://github.com/mitre/stockpile/archive/refs/heads/master.zip", + "root_prefix": "stockpile-master", "abilities_dir": "data/abilities", }, }, diff --git a/backend/app/services/caldera_import_service.py b/backend/app/services/caldera_import_service.py index 9c9bfd3..613c7d3 100644 --- a/backend/app/services/caldera_import_service.py +++ b/backend/app/services/caldera_import_service.py @@ -44,12 +44,12 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- CALDERA_ZIP_URL = ( - "https://github.com/mitre/caldera" + "https://github.com/mitre/stockpile" "/archive/refs/heads/master.zip" ) _DOWNLOAD_TIMEOUT = 300 -_ZIP_ROOT_PREFIX = "caldera-master" +_ZIP_ROOT_PREFIX = "stockpile-master" # --------------------------------------------------------------------------- @@ -144,10 +144,17 @@ def _parse_abilities(abilities_dir: Path) -> list[dict]: logger.debug("Failed to parse %s: %s", yaml_path, exc) continue + # Stockpile YAML files may contain YAML lists of abilities + # (e.g. [- id: ..., - id: ...]) or single-document dicts. + # Flatten everything into individual ability dicts. + abilities: list[dict] = [] for data in data_list: - if not isinstance(data, dict): - continue + if isinstance(data, dict): + abilities.append(data) + elif isinstance(data, list): + abilities.extend(d for d in data if isinstance(d, dict)) + for data in abilities: ability_id = data.get("id", "") if not ability_id: continue @@ -193,7 +200,7 @@ def _parse_abilities(abilities_dir: Path) -> list[dict]: "tool_suggested": executor_str, "attack_procedure": commands[:4000] if commands else None, "atomic_test_id": f"caldera:{ability_id}", - "source_url": f"https://github.com/mitre/caldera/tree/master/data/abilities/{tactic}", + "source_url": f"https://github.com/mitre/stockpile/tree/master/data/abilities/{tactic}", }) logger.info("Parsed %d CALDERA abilities total", len(results)) diff --git a/backend/app/services/compliance_import_service.py b/backend/app/services/compliance_import_service.py index ce8f763..7dd0b41 100644 --- a/backend/app/services/compliance_import_service.py +++ b/backend/app/services/compliance_import_service.py @@ -329,6 +329,159 @@ def _import_sample_nist_mappings(db: Session, framework: ComplianceFramework) -> } +def import_cis_controls_v8_mappings(db: Session) -> dict: + """Import CIS Controls v8 with ATT&CK technique mappings. + + Uses a curated set of CIS Controls mapped to MITRE ATT&CK techniques + based on the CIS Controls Navigator and official documentation. + + Returns a summary dict with counts. + """ + # ── 1. Create or get framework ──────────────────────────────── + framework = ( + db.query(ComplianceFramework) + .filter(ComplianceFramework.name == "CIS Controls v8") + .first() + ) + + if not framework: + framework = ComplianceFramework( + name="CIS Controls v8", + version="8", + description="Center for Internet Security Critical Security Controls Version 8 — " + "a prioritized set of 18 security safeguards organized by Implementation Groups (IG1, IG2, IG3).", + url="https://www.cisecurity.org/controls/v8", + is_active=True, + ) + db.add(framework) + db.flush() + logger.info("Created CIS Controls v8 framework") + else: + logger.info("CIS Controls v8 framework already exists") + + # ── 2. Control definitions with ATT&CK mappings ─────────────── + CIS_CONTROLS = [ + {"control_id": "CIS-1", "title": "Inventory and Control of Enterprise Assets", + "category": "IG1 — Basic", + "techniques": ["T1595", "T1590", "T1018", "T1082"]}, + {"control_id": "CIS-2", "title": "Inventory and Control of Software Assets", + "category": "IG1 — Basic", + "techniques": ["T1518", "T1072", "T1195"]}, + {"control_id": "CIS-3", "title": "Data Protection", + "category": "IG1 — Basic", + "techniques": ["T1005", "T1114", "T1560", "T1048", "T1041"]}, + {"control_id": "CIS-4", "title": "Secure Configuration of Enterprise Assets and Software", + "category": "IG1 — Basic", + "techniques": ["T1574", "T1546", "T1112", "T1543"]}, + {"control_id": "CIS-5", "title": "Account Management", + "category": "IG1 — Basic", + "techniques": ["T1078", "T1136", "T1098", "T1087"]}, + {"control_id": "CIS-6", "title": "Access Control Management", + "category": "IG1 — Basic", + "techniques": ["T1078", "T1548", "T1134", "T1021"]}, + {"control_id": "CIS-7", "title": "Continuous Vulnerability Management", + "category": "IG2 — Foundational", + "techniques": ["T1190", "T1203", "T1068", "T1210"]}, + {"control_id": "CIS-8", "title": "Audit Log Management", + "category": "IG2 — Foundational", + "techniques": ["T1562", "T1070", "T1059"]}, + {"control_id": "CIS-9", "title": "Email and Web Browser Protections", + "category": "IG2 — Foundational", + "techniques": ["T1566", "T1204", "T1189", "T1598"]}, + {"control_id": "CIS-10", "title": "Malware Defenses", + "category": "IG2 — Foundational", + "techniques": ["T1059", "T1204", "T1027", "T1140", "T1497"]}, + {"control_id": "CIS-11", "title": "Data Recovery", + "category": "IG1 — Basic", + "techniques": ["T1486", "T1490", "T1561"]}, + {"control_id": "CIS-12", "title": "Network Infrastructure Management", + "category": "IG2 — Foundational", + "techniques": ["T1557", "T1071", "T1572", "T1571"]}, + {"control_id": "CIS-13", "title": "Network Monitoring and Defense", + "category": "IG2 — Foundational", + "techniques": ["T1071", "T1048", "T1041", "T1105", "T1572"]}, + {"control_id": "CIS-14", "title": "Security Awareness and Skills Training", + "category": "IG1 — Basic", + "techniques": ["T1566", "T1204", "T1598"]}, + {"control_id": "CIS-15", "title": "Service Provider Management", + "category": "IG2 — Foundational", + "techniques": ["T1199", "T1195"]}, + {"control_id": "CIS-16", "title": "Application Software Security", + "category": "IG2 — Foundational", + "techniques": ["T1190", "T1059", "T1203"]}, + {"control_id": "CIS-17", "title": "Incident Response Management", + "category": "IG2 — Foundational", + "techniques": ["T1059", "T1547", "T1053"]}, + {"control_id": "CIS-18", "title": "Penetration Testing", + "category": "IG3 — Organizational", + "techniques": ["T1595", "T1046", "T1190", "T1059"]}, + ] + + # Build technique lookup + all_techniques = {t.mitre_id: t for t in db.query(Technique).all()} + + existing_controls = { + c.control_id: c + for c in db.query(ComplianceControl) + .filter(ComplianceControl.framework_id == framework.id) + .all() + } + + existing_mappings = set() + for m in ( + db.query(ComplianceControlMapping) + .join(ComplianceControl) + .filter(ComplianceControl.framework_id == framework.id) + .all() + ): + existing_mappings.add((str(m.compliance_control_id), str(m.technique_id))) + + controls_created = 0 + mappings_created = 0 + + for item in CIS_CONTROLS: + if item["control_id"] in existing_controls: + control = existing_controls[item["control_id"]] + else: + control = ComplianceControl( + framework_id=framework.id, + control_id=item["control_id"], + title=item["title"], + category=item["category"], + ) + db.add(control) + db.flush() + existing_controls[item["control_id"]] = control + controls_created += 1 + + for mitre_id in item["techniques"]: + technique = all_techniques.get(mitre_id) + if not technique: + continue + key = (str(control.id), str(technique.id)) + if key in existing_mappings: + continue + mapping = ComplianceControlMapping( + compliance_control_id=control.id, + technique_id=technique.id, + ) + db.add(mapping) + existing_mappings.add(key) + mappings_created += 1 + + db.commit() + + summary = { + "framework": framework.name, + "controls_created": controls_created, + "controls_existing": len(existing_controls) - controls_created, + "mappings_created": mappings_created, + "total_controls": len(existing_controls), + } + logger.info(f"CIS Controls v8 import complete: {summary}") + return summary + + def _get_nist_category(family_code: str) -> str: """Map NIST 800-53 family code to category name.""" categories = { diff --git a/backend/app/services/d3fend_import_service.py b/backend/app/services/d3fend_import_service.py index 50f18f4..aec04a3 100644 --- a/backend/app/services/d3fend_import_service.py +++ b/backend/app/services/d3fend_import_service.py @@ -20,7 +20,7 @@ logger = logging.getLogger(__name__) D3FEND_ALL_URL = "https://d3fend.mitre.org/api/technique/api-all.json" D3FEND_MAPPING_URL = "https://d3fend.mitre.org/api/offensive-technique/{attack_id}.json" -D3FEND_BASE_URL = "https://d3fend.mitre.org/technique/d3f:{d3fend_id}" +D3FEND_BASE_URL = "https://d3fend.mitre.org/technique/d3f:{technique_name}" # ── Tactic extraction helpers ──────────────────────────────────────── @@ -139,7 +139,8 @@ def import_d3fend_techniques(db: Session) -> dict[str, int]: .filter(DefensiveTechnique.d3fend_id == tech_data["d3fend_id"]) .first() ) - d3fend_url = D3FEND_BASE_URL.format(d3fend_id=tech_data["d3fend_id"]) + technique_name = tech_data["name"].replace(" ", "") + d3fend_url = D3FEND_BASE_URL.format(technique_name=technique_name) if existing: existing.name = tech_data["name"] @@ -416,7 +417,8 @@ def _import_d3fend_fallback(db: Session) -> dict[str, int]: .filter(DefensiveTechnique.d3fend_id == d3fend_id) .first() ) - d3fend_url = D3FEND_BASE_URL.format(d3fend_id=d3fend_id) + technique_name = tech_data["name"].replace(" ", "") + d3fend_url = D3FEND_BASE_URL.format(technique_name=technique_name) if existing: existing.name = tech_data["name"] @@ -605,6 +607,39 @@ def import_d3fend_mappings(db: Session) -> dict[str, int]: return {"created": created, "skipped": skipped, "total": total} +def sync(db: Session) -> dict: + """Sync D3FEND techniques and ATT&CK mappings. + + Called by the Data Sources router when the user clicks Sync for D3FEND. + Returns a flat summary dict suitable for ``last_sync_stats``. + """ + from app.models.data_source import DataSource + from datetime import datetime + + tech_result = import_d3fend_techniques(db) + mapping_result = import_d3fend_mappings(db) + + summary = { + "techniques_created": tech_result.get("created", 0), + "techniques_updated": tech_result.get("updated", 0), + "techniques_total": tech_result.get("total", 0), + "mappings_created": mapping_result.get("created", 0), + "mappings_skipped": mapping_result.get("skipped", 0), + "mappings_total": mapping_result.get("total", 0), + } + + # Update DataSource record + ds = db.query(DataSource).filter(DataSource.name == "d3fend").first() + if ds: + ds.last_sync_at = datetime.utcnow() + ds.last_sync_status = "success" + ds.last_sync_stats = summary + db.commit() + + logger.info("D3FEND sync complete — %s", summary) + return summary + + def get_defenses_for_technique(db: Session, technique_id) -> list[dict]: """Get all D3FEND defensive techniques mapped to a given ATT&CK technique.""" mappings = ( diff --git a/backend/app/services/lolbas_import_service.py b/backend/app/services/lolbas_import_service.py index fc31e1d..52e22a9 100644 --- a/backend/app/services/lolbas_import_service.py +++ b/backend/app/services/lolbas_import_service.py @@ -68,6 +68,8 @@ _GTFOBINS_FUNCTION_MAP: dict[str, str] = { "non-interactive-bind-shell": "T1059", "file-upload": "T1105", "file-download": "T1105", + "upload": "T1105", + "download": "T1105", "file-write": "T1105", "file-read": "T1005", "library-load": "T1129", @@ -201,8 +203,11 @@ def _parse_gtfobins(root_dir: Path) -> list[dict]: logger.warning("GTFOBins directory not found at %s", gtfobins_root) return results - md_files = sorted(gtfobins_root.glob("*.md")) - logger.info("GTFOBins: Found %d markdown files", len(md_files)) + md_files = sorted( + f for f in gtfobins_root.iterdir() + if f.is_file() and f.suffix in (".md", "") + ) + logger.info("GTFOBins: Found %d files", len(md_files)) for md_path in md_files: binary_name = md_path.stem # e.g. "awk" @@ -259,8 +264,12 @@ def _parse_gtfobins(root_dir: Path) -> list[dict]: def _extract_front_matter(content: str) -> dict | None: - """Extract YAML front-matter from a markdown file.""" - match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL) + """Extract YAML front-matter from a markdown/GTFOBins file. + + Supports both ``---/---`` (standard front-matter) and ``---/...`` + (YAML document-end marker used by GTFOBins). + """ + match = re.match(r"^---\s*\n(.*?)\n(?:---|\.\.\.)", content, re.DOTALL) if not match: return None try: diff --git a/backend/app/services/sigma_import_service.py b/backend/app/services/sigma_import_service.py index adb68f2..ca09912 100644 --- a/backend/app/services/sigma_import_service.py +++ b/backend/app/services/sigma_import_service.py @@ -46,11 +46,11 @@ logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- SIGMA_ZIP_URL = ( - "https://github.com/SigmaHQ/sigma/archive/refs/heads/main.zip" + "https://github.com/SigmaHQ/sigma/archive/refs/heads/master.zip" ) _DOWNLOAD_TIMEOUT = 300 -_ZIP_ROOT_PREFIX = "sigma-main" +_ZIP_ROOT_PREFIX = "sigma-master" # Regex to extract MITRE ATT&CK technique IDs from Sigma tags # e.g. "attack.t1059.001" → "T1059.001" @@ -170,7 +170,7 @@ def _parse_sigma_rules(rules_dir: Path) -> list[dict]: # Create one entry per technique for tech_id in technique_ids: source_url = ( - f"https://github.com/SigmaHQ/sigma/blob/main/" + f"https://github.com/SigmaHQ/sigma/blob/master/" f"{relative_path.replace(chr(92), '/')}" ) results.append({ diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 7923f32..f3fd48d 100644 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -7,5 +7,8 @@ alembic upgrade head echo "=== Seeding admin user ===" python -m app.seed +echo "=== Seeding data sources ===" +python -m app.seed_data_sources + echo "=== Starting uvicorn ===" exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload diff --git a/frontend/src/api/compliance.ts b/frontend/src/api/compliance.ts index 8b0d071..b6e16ef 100644 --- a/frontend/src/api/compliance.ts +++ b/frontend/src/api/compliance.ts @@ -114,3 +114,9 @@ export async function importNistMappings(): Promise> { const { data } = await client.post("/compliance/import/nist-800-53"); return data; } + +/** Import CIS Controls v8 mappings (admin). */ +export async function importCisMappings(): Promise> { + const { data } = await client.post("/compliance/import/cis-controls-v8"); + return data; +} diff --git a/frontend/src/api/test-templates.ts b/frontend/src/api/test-templates.ts index ce2ec18..e1ee5ba 100644 --- a/frontend/src/api/test-templates.ts +++ b/frontend/src/api/test-templates.ts @@ -9,6 +9,7 @@ export interface TemplateFilters { severity?: string; mitre_technique_id?: string; search?: string; + is_active?: boolean; offset?: number; limit?: number; } @@ -51,6 +52,8 @@ export async function getTemplates( if (filters?.mitre_technique_id) params.append("mitre_technique_id", filters.mitre_technique_id); if (filters?.search) params.append("search", filters.search); + // Default to active-only for catalog; admin uses getAllTemplates without this filter + params.append("is_active", filters?.is_active !== undefined ? String(filters.is_active) : "true"); if (filters?.offset !== undefined) params.append("offset", String(filters.offset)); if (filters?.limit !== undefined) @@ -125,7 +128,8 @@ export async function toggleTemplateActive( // ── All templates (include inactive, for admin) ──────────────────── -/** Fetch all templates including inactive ones (for admin management). */ +/** Fetch all templates including inactive ones (for admin management). + * Does NOT filter by is_active so the backend returns all templates. */ export async function getAllTemplates( filters?: TemplateFilters, ): Promise { @@ -135,6 +139,7 @@ export async function getAllTemplates( if (filters?.search) params.append("search", filters.search); if (filters?.offset !== undefined) params.append("offset", String(filters.offset)); if (filters?.limit !== undefined) params.append("limit", String(filters.limit)); + // Explicitly don't pass is_active so backend returns ALL templates const { data } = await client.get( `/test-templates${params.toString() ? `?${params}` : ""}`, @@ -142,6 +147,24 @@ export async function getAllTemplates( return data; } +// ── Bulk activate/deactivate (admin) ────────────────────────────── + +export interface BulkActivateResponse { + detail: string; + affected: number; + is_active: boolean; +} + +/** Activate or deactivate all templates. Admin only. */ +export async function bulkActivateTemplates( + activate: boolean, +): Promise { + const { data } = await client.patch( + `/test-templates/bulk-activate?activate=${activate}`, + ); + return data; +} + // ── Import Atomic Red Team ───────────────────────────────────────── /** Trigger Atomic Red Team import. Admin only. */ diff --git a/frontend/src/pages/CampaignsPage.tsx b/frontend/src/pages/CampaignsPage.tsx index cc7bd94..337cdc8 100644 --- a/frontend/src/pages/CampaignsPage.tsx +++ b/frontend/src/pages/CampaignsPage.tsx @@ -11,7 +11,8 @@ import { Filter, Target, } from "lucide-react"; -import { listCampaigns, createCampaign, type CampaignSummary } from "../api/campaigns"; +import { listCampaigns, createCampaign, generateCampaignFromThreatActor, type CampaignSummary } from "../api/campaigns"; +import { getThreatActors, type ThreatActorSummary } from "../api/threat-actors"; import { useAuth } from "../context/AuthContext"; const statusColors: Record = { @@ -46,6 +47,8 @@ export default function CampaignsPage() { search: "", }); const [showCreateForm, setShowCreateForm] = useState(false); + const [showActorSelector, setShowActorSelector] = useState(false); + const [actorSearch, setActorSearch] = useState(""); const [newCampaign, setNewCampaign] = useState({ name: "", description: "", @@ -75,6 +78,22 @@ export default function CampaignsPage() { }, }); + // Threat actor selector data + const { data: actorsData, isLoading: isLoadingActors } = useQuery({ + queryKey: ["threat-actors-for-campaign", actorSearch], + queryFn: () => getThreatActors({ search: actorSearch || undefined, limit: 50 }), + enabled: showActorSelector, + }); + + const generateMutation = useMutation({ + mutationFn: (actorId: string) => generateCampaignFromThreatActor(actorId), + onSuccess: (campaign) => { + queryClient.invalidateQueries({ queryKey: ["campaigns"] }); + setShowActorSelector(false); + navigate(`/campaigns/${campaign.id}`); + }, + }); + const formatDate = (dateStr: string | null) => { if (!dateStr) return ""; return new Date(dateStr).toLocaleDateString("en-US", { @@ -98,7 +117,7 @@ export default function CampaignsPage() { {canCreate && ( <> + )) + ) : ( +
+ No threat actors found +
+ )} + + + {/* Error */} + {generateMutation.isError && ( +
+ + {(generateMutation.error as Error)?.message || "Failed to generate campaign"} +
+ )} + + {/* Loading indicator */} + {generateMutation.isPending && ( +
+ + Generating campaign... +
+ )} + + {/* Cancel */} +
+ +
+ + + )} + {/* Campaign grid */} {isLoading ? (
diff --git a/frontend/src/pages/ComparisonPage.tsx b/frontend/src/pages/ComparisonPage.tsx index a8a4f8e..63a56e5 100644 --- a/frontend/src/pages/ComparisonPage.tsx +++ b/frontend/src/pages/ComparisonPage.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from "react"; import { useNavigate } from "react-router-dom"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { Loader2, AlertCircle, @@ -9,12 +9,14 @@ import { Minus, GitCompareArrows, Camera, + Plus, TrendingUp, TrendingDown, } from "lucide-react"; import { listSnapshots, compareSnapshots, + createSnapshot, type SnapshotSummary, type SnapshotComparison, } from "../api/snapshots"; @@ -91,9 +93,12 @@ function MetricCard({ export default function ComparisonPage() { const navigate = useNavigate(); + const queryClient = useQueryClient(); const [snapA, setSnapA] = useState(""); const [snapB, setSnapB] = useState(""); const [activeTab, setActiveTab] = useState("improved"); + const [showNameInput, setShowNameInput] = useState(false); + const [snapshotName, setSnapshotName] = useState(""); // Fetch all snapshots for the dropdowns const { data: snapshotsData, isLoading: isLoadingSnapshots } = useQuery({ @@ -101,6 +106,18 @@ export default function ComparisonPage() { queryFn: () => listSnapshots({ limit: 200 }), }); + // Create snapshot mutation + const createSnapshotMutation = useMutation({ + mutationFn: (name?: string) => createSnapshot(name), + onSuccess: (newSnapshot) => { + queryClient.invalidateQueries({ queryKey: ["snapshots"] }); + setShowNameInput(false); + setSnapshotName(""); + // Auto-select the new snapshot as Snapshot B + setSnapB(newSnapshot.id); + }, + }); + const snapshots = snapshotsData?.items || []; // Comparison query @@ -149,6 +166,51 @@ export default function ComparisonPage() {

Compare coverage snapshots over time

+ + {/* Create Snapshot */} +
+ {showNameInput ? ( + <> + setSnapshotName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") createSnapshotMutation.mutate(snapshotName || undefined); + if (e.key === "Escape") { setShowNameInput(false); setSnapshotName(""); } + }} + placeholder="Snapshot name (optional)" + autoFocus + className="rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none w-56" + /> + + + + ) : ( + + )} +
{/* Snapshot selectors */} diff --git a/frontend/src/pages/CompliancePage.tsx b/frontend/src/pages/CompliancePage.tsx index d33b7c5..14e9ff5 100644 --- a/frontend/src/pages/CompliancePage.tsx +++ b/frontend/src/pages/CompliancePage.tsx @@ -1,17 +1,23 @@ import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { Loader2, AlertCircle, Download, FileText } from "lucide-react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Loader2, AlertCircle, Download, FileText, Plus } from "lucide-react"; import { getComplianceFrameworks, getFrameworkStatus, downloadComplianceCSV, + importNistMappings, + importCisMappings, type ComplianceFrameworkSummary, } from "../api/compliance"; +import { useAuth } from "../context/AuthContext"; import ComplianceGauge from "../components/compliance/ComplianceGauge"; import ControlsTable from "../components/compliance/ControlsTable"; export default function CompliancePage() { const [selectedFrameworkId, setSelectedFrameworkId] = useState(null); + const queryClient = useQueryClient(); + const { user } = useAuth(); + const isAdmin = user?.role === "admin"; // Fetch available frameworks const { @@ -59,6 +65,24 @@ export default function CompliancePage() { URL.revokeObjectURL(url); }; + const importNist = useMutation({ + mutationFn: importNistMappings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["compliance-frameworks"] }); + queryClient.invalidateQueries({ queryKey: ["compliance-status"] }); + }, + }); + + const importCis = useMutation({ + mutationFn: importCisMappings, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["compliance-frameworks"] }); + queryClient.invalidateQueries({ queryKey: ["compliance-status"] }); + }, + }); + + const isImporting = importNist.isPending || importCis.isPending; + if (isLoading && !frameworkStatus) { return (
@@ -171,6 +195,35 @@ export default function CompliancePage() {
)} + {/* Import buttons for admin */} + {isAdmin && ( +
+ Import frameworks: + + + {(importNist.isSuccess || importCis.isSuccess) && ( + Import complete + )} + {(importNist.isError || importCis.isError) && ( + Import failed + )} +
+ )} + {/* Controls table */} {controls.length > 0 ? ( @@ -179,7 +232,7 @@ export default function CompliancePage() {

- No compliance data available. Import a compliance framework from the System page. + No compliance data available. Use the import buttons above to load a compliance framework.

) diff --git a/frontend/src/pages/SystemPage.tsx b/frontend/src/pages/SystemPage.tsx index 2fbad39..48b9f15 100644 --- a/frontend/src/pages/SystemPage.tsx +++ b/frontend/src/pages/SystemPage.tsx @@ -13,7 +13,6 @@ import { Shield, Search, FlaskConical, - Download, Plus, ToggleLeft, ToggleRight, @@ -28,12 +27,11 @@ import { type IntelScanResponse, } from "../api/system"; import { - importAtomicTests, getTemplateStats, getAllTemplates, createTemplate, toggleTemplateActive, - type ImportAtomicResponse, + bulkActivateTemplates, type TemplateStats, type CreateTemplatePayload, } from "../api/test-templates"; @@ -43,8 +41,8 @@ export default function SystemPage() { const queryClient = useQueryClient(); const [syncResult, setSyncResult] = useState(null); const [intelResult, setIntelResult] = useState(null); - const [importResult, setImportResult] = useState(null); const [showCreateForm, setShowCreateForm] = useState(false); + const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null); // ── Existing queries ───────────────────────────────────────────── const { @@ -71,7 +69,7 @@ export default function SystemPage() { isLoading: templatesLoading, } = useQuery({ queryKey: ["templates-admin"], - queryFn: () => getAllTemplates({ limit: 100 }), + queryFn: () => getAllTemplates({ limit: 200 }), }); // ── Mutations ──────────────────────────────────────────────────── @@ -92,12 +90,12 @@ export default function SystemPage() { }, }); - const importAtomicMutation = useMutation({ - mutationFn: importAtomicTests, - onSuccess: (data) => { - setImportResult(data); - queryClient.invalidateQueries({ queryKey: ["template-stats"] }); + const bulkActivateMutation = useMutation({ + mutationFn: (activate: boolean) => bulkActivateTemplates(activate), + onSuccess: () => { + setBulkConfirm(null); queryClient.invalidateQueries({ queryKey: ["templates-admin"] }); + queryClient.invalidateQueries({ queryKey: ["template-stats"] }); queryClient.invalidateQueries({ queryKey: ["test-templates"] }); }, }); @@ -281,70 +279,8 @@ export default function SystemPage() { TEMPLATE ADMINISTRATION (T-124) ──────────────────────────────────────────────────────────────── */} - {/* Import Atomic Red Team + Stats */} -
- {/* Import Atomic Red Team */} -
-
-
- -
-
-

Import Atomic Red Team

-

- Import test templates from the Atomic Red Team repository by Red Canary, mapped to MITRE ATT&CK techniques. -

- - {importResult && ( -
-
- - Import Complete -
-
-
- Imported: - {importResult.imported} -
-
- Skipped: - {importResult.skipped} -
-
- Parsed: - {importResult.total_parsed} -
-
-
- )} - - {importAtomicMutation.isError && ( -
-
- - - Import failed: {(importAtomicMutation.error as Error)?.message} - -
-
- )} - - -
-
-
- + {/* Template Catalog Stats */} +
{/* Template Catalog Stats */}
@@ -433,20 +369,85 @@ export default function SystemPage() { /> )} + {/* Bulk Activate Confirmation Modal */} + {bulkConfirm && ( +
+
+

+ {bulkConfirm === "activate" ? "Activate All Templates" : "Deactivate All Templates"} +

+

+ {bulkConfirm === "activate" + ? "This will activate ALL templates in the catalog, including previously deactivated ones. All templates will become available for test creation." + : "This will deactivate ALL templates in the catalog. No templates will be available for test creation until reactivated."} +

+

+ This action affects all {templateStats?.total || 0} templates. +

+
+ + +
+
+
+ )} + {/* Templates Management Table */}
-
+

Manage Templates

- +
+ + + +
{templatesLoading ? ( diff --git a/frontend/src/pages/TechniqueDetailPage.tsx b/frontend/src/pages/TechniqueDetailPage.tsx index 03d30e4..5ba72e0 100644 --- a/frontend/src/pages/TechniqueDetailPage.tsx +++ b/frontend/src/pages/TechniqueDetailPage.tsx @@ -16,6 +16,8 @@ import { AlertTriangle, BookOpen, FlaskConical, + ChevronDown, + ChevronUp, } from "lucide-react"; import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques"; import { getTemplatesByTechnique } from "../api/test-templates"; @@ -410,76 +412,7 @@ export default function TechniqueDetailPage() { {/* Recommended Defenses (D3FEND) */} {technique.d3fend_defenses && technique.d3fend_defenses.length > 0 && ( -
-
-

- - Recommended Defenses (D3FEND) -

- - {technique.d3fend_defenses.length} countermeasure{technique.d3fend_defenses.length !== 1 ? "s" : ""} - -
- - {/* Group by tactic */} - {(() => { - const grouped: Record = {}; - for (const def of technique.d3fend_defenses!) { - const tactic = def.tactic || "Other"; - if (!grouped[tactic]) grouped[tactic] = []; - grouped[tactic].push(def); - } - const tacticColors: Record = { - Detect: "border-blue-500/30 bg-blue-900/20 text-blue-400", - Harden: "border-emerald-500/30 bg-emerald-900/20 text-emerald-400", - Isolate: "border-purple-500/30 bg-purple-900/20 text-purple-400", - Deceive: "border-amber-500/30 bg-amber-900/20 text-amber-400", - Evict: "border-red-500/30 bg-red-900/20 text-red-400", - Model: "border-cyan-500/30 bg-cyan-900/20 text-cyan-400", - }; - - return Object.entries(grouped).map(([tactic, defenses]) => ( -
-

- {tactic} -

-
- {defenses!.map((def) => ( -
-
-
-

- {def.d3fend_id} - {def.name} -

- {def.description && ( -

{def.description}

- )} -
- {def.d3fend_url && ( - - - - )} -
-
- ))} -
-
- )); - })()} -
+ )} {/* Intel Items Section */} @@ -525,3 +458,124 @@ export default function TechniqueDetailPage() {
); } + +// ── D3FEND Section ──────────────────────────────────────────────────── + +function D3FENDSection({ defenses }: { defenses: Array<{ + id: string; + d3fend_id: string; + name: string; + description?: string | null; + tactic?: string | null; + d3fend_url?: string | null; +}> }) { + const [expandedId, setExpandedId] = useState(null); + + const grouped: Record = {}; + for (const def of defenses) { + const tactic = def.tactic || "Other"; + if (!grouped[tactic]) grouped[tactic] = []; + grouped[tactic].push(def); + } + + const tacticColors: Record = { + Detect: "border-blue-500/30 bg-blue-900/20 text-blue-400", + Harden: "border-emerald-500/30 bg-emerald-900/20 text-emerald-400", + Isolate: "border-purple-500/30 bg-purple-900/20 text-purple-400", + Deceive: "border-amber-500/30 bg-amber-900/20 text-amber-400", + Evict: "border-red-500/30 bg-red-900/20 text-red-400", + Model: "border-cyan-500/30 bg-cyan-900/20 text-cyan-400", + }; + + const tacticDescriptions: Record = { + Detect: "Techniques for identifying adversary activity through monitoring and analysis.", + Harden: "Techniques for strengthening systems to reduce the attack surface.", + Isolate: "Techniques for containing threats by limiting communication and access.", + Deceive: "Techniques that use deception to mislead adversaries.", + Evict: "Techniques for removing adversary presence from systems.", + Model: "Techniques for understanding and mapping the environment.", + }; + + return ( +
+
+

+ + Recommended Defenses (D3FEND) +

+ + {defenses.length} countermeasure{defenses.length !== 1 ? "s" : ""} + +
+ + {Object.entries(grouped).map(([tactic, defs]) => ( +
+

+ {tactic} +

+ {tacticDescriptions[tactic] && ( +

{tacticDescriptions[tactic]}

+ )} +
+ {defs.map((def) => { + const isExpanded = expandedId === def.id; + return ( +
setExpandedId(isExpanded ? null : def.id)} + > +
+
+

+ {def.d3fend_id} + {def.name} +

+
+ {isExpanded ? ( + + ) : ( + + )} +
+ + {isExpanded && ( +
+ {def.description ? ( +

{def.description}

+ ) : ( +

No description available.

+ )} +
+ + Tactic: {def.tactic || "Unknown"} + + + ID: {def.d3fend_id} + +
+ {def.d3fend_url && ( + e.stopPropagation()} + className="inline-flex items-center gap-1.5 text-xs text-cyan-400 hover:text-cyan-300 hover:underline mt-1" + > + + View on MITRE D3FEND + + )} +
+ )} +
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/pages/TestCatalogPage.tsx b/frontend/src/pages/TestCatalogPage.tsx index 9c64c5c..14a3077 100644 --- a/frontend/src/pages/TestCatalogPage.tsx +++ b/frontend/src/pages/TestCatalogPage.tsx @@ -22,6 +22,9 @@ const PAGE_SIZE = 12; const SOURCE_OPTIONS = [ { value: "", label: "All Sources" }, { value: "atomic_red_team", label: "Atomic Red Team" }, + { value: "caldera", label: "MITRE CALDERA" }, + { value: "lolbas", label: "LOLBAS (Windows)" }, + { value: "gtfobins", label: "GTFOBins (Linux)" }, { value: "custom", label: "Custom" }, ]; @@ -45,9 +48,20 @@ const PLATFORM_OPTIONS = [ const SOURCE_BADGE: Record = { atomic_red_team: "bg-red-900/50 text-red-400 border-red-500/30", + caldera: "bg-purple-900/50 text-purple-400 border-purple-500/30", + lolbas: "bg-amber-900/50 text-amber-400 border-amber-500/30", + gtfobins: "bg-green-900/50 text-green-400 border-green-500/30", custom: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30", }; +const SOURCE_LABEL: Record = { + atomic_red_team: "Atomic", + caldera: "CALDERA", + lolbas: "LOLBAS", + gtfobins: "GTFOBins", + custom: "Custom", +}; + const SEVERITY_BADGE: Record = { low: "bg-blue-900/50 text-blue-400 border-blue-500/30", medium: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30", @@ -302,7 +316,7 @@ function TemplateCard({ SOURCE_BADGE[template.source] || "bg-gray-800/50 text-gray-400 border-gray-600/30" }`} > - {template.source === "atomic_red_team" ? "Atomic" : template.source} + {SOURCE_LABEL[template.source] || template.source} {/* Platform */}