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
This commit is contained in:
2026-02-10 13:22:23 +01:00
parent 8032b67fab
commit c2e9c687f4
19 changed files with 778 additions and 197 deletions

View File

@@ -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) - **Role-Based Access Control** — Granular permissions for 6 roles (admin, red_tech, blue_tech, red_lead, blue_lead, viewer)
### Enhanced (V2) ### 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 - **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 - **Reports & Export** — Coverage summary, test results, and remediation reports in JSON and CSV
- **Remediation Tracking** — Step-by-step remediation assignments with status tracking - **Remediation Tracking** — Step-by-step remediation assignments with status tracking
- **Metrics Dashboard** — Pipeline funnel, team activity, validation rates - **Metrics Dashboard** — Pipeline funnel, team activity, validation rates
### Advanced (V3) ### 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 - **Detection Rule Tracking** — Import and evaluate Sigma/Elastic detection rules per test
- **ATT&CK Heatmap** — Interactive Navigator-style heatmap with layers, filters, and export - **ATT&CK Heatmap** — Interactive Navigator-style heatmap with layers, filters, and export
- **Threat Actor Intelligence** — Track intrusion sets and their technique coverage - **Threat Actor Intelligence** — Track intrusion sets and their technique coverage
- **Campaign Management** — Group tests into campaigns with dependencies, scheduling, and recurring execution - **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** — 0100 scoring for techniques, tactics, actors, and organization with configurable weights - **Granular Scoring** — 0100 scoring for techniques, tactics, actors, and organization with configurable weights
- **Operational Metrics** — MTTD, MTTR, detection efficacy, alert fidelity, coverage velocity - **Operational Metrics** — MTTD, MTTR, detection efficacy, alert fidelity, coverage velocity
- **Executive Dashboard** — High-level KPIs for leadership (leads + admin) - **Executive Dashboard** — High-level KPIs for leadership (leads + admin)
@@ -141,17 +141,20 @@ Password: admin123
### Importing Data Sources ### 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 ```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" curl -X POST http://localhost:8000/api/v1/system/sync-mitre -H "Authorization: Bearer $TOKEN"
# 2. Import test templates from Atomic Red Team # Sync all data sources at once
curl -X POST http://localhost:8000/api/v1/system/import-atomic-red-team -H "Authorization: Bearer $TOKEN" curl -X POST http://localhost:8000/api/v1/data-sources/sync-all -H "Authorization: Bearer $TOKEN"
# 3. Import additional sources via the Data Sources admin page
# Navigate to System → Data Sources in the UI
``` ```
See [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md) for detailed instructions on all data sources. 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) 📈 Comparison (leads + admin)
📄 Reports 📄 Reports
⚙️ System (admin only) ⚙️ System (admin only)
├─ Data Sources ├─ Data Sources (sync Atomic, Sigma, CALDERA, LOLBAS, GTFOBins, D3FEND)
├─ MITRE Sync ├─ MITRE Sync (ATT&CK sync, intel scan, template management)
├─ Users ├─ Users
└─ Audit Log └─ Audit Log
``` ```
@@ -223,7 +226,7 @@ Interactive API documentation available at:
| Auth | `/api/v1/auth` | Login, get current user | | Auth | `/api/v1/auth` | Login, get current user |
| Techniques | `/api/v1/techniques` | CRUD, list with filters, mark reviewed | | Techniques | `/api/v1/techniques` | CRUD, list with filters, mark reviewed |
| Tests | `/api/v1/tests` | Full Red/Blue workflow, remediation, retest chain | | 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 | | Evidence | `/api/v1/tests/{id}/evidence` | Upload evidence, get presigned URLs |
| Campaigns | `/api/v1/campaigns` | CRUD, scheduling, history | | Campaigns | `/api/v1/campaigns` | CRUD, scheduling, history |
| Threat Actors | `/api/v1/threat-actors` | CRUD, technique mappings | | Threat Actors | `/api/v1/threat-actors` | CRUD, technique mappings |

View File

@@ -24,7 +24,10 @@ from app.models.technique import Technique
from app.models.test_template import TestTemplate from app.models.test_template import TestTemplate
from app.models.threat_actor import ThreatActorTechnique from app.models.threat_actor import ThreatActorTechnique
from app.services.scoring_service import calculate_technique_score 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"]) router = APIRouter(prefix="/compliance", tags=["compliance"])
@@ -378,3 +381,13 @@ def import_nist(
"""Import NIST 800-53 Rev 5 mappings (admin only).""" """Import NIST 800-53 Rev 5 mappings (admin only)."""
result = import_nist_800_53_mappings(db) result = import_nist_800_53_mappings(db)
return result 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

View File

@@ -40,7 +40,7 @@ def _get_sync_handler(source_name: str):
"caldera": ("app.services.caldera_import_service", "sync"), "caldera": ("app.services.caldera_import_service", "sync"),
"elastic_rules": ("app.services.elastic_import_service", "sync"), "elastic_rules": ("app.services.elastic_import_service", "sync"),
"mitre_cti": ("app.services.threat_actor_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: if source_name not in handlers:

View File

@@ -55,13 +55,16 @@ def list_templates(
severity: Optional[str] = Query(None, description="Filter by severity (low, medium, high, critical)"), 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"), mitre_technique_id: Optional[str] = Query(None, description="Filter by MITRE technique ID"),
search: Optional[str] = Query(None, description="Search in name and description"), 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), offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200), limit: int = Query(50, ge=1, le=200),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Return a paginated, filterable list of test templates.""" """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: if source:
query = query.filter(TestTemplate.source == 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} # GET /test-templates/by-technique/{mitre_id}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -41,8 +41,8 @@ INITIAL_SOURCES = [
"3 000+ rules with MITRE ATT&CK mappings.", "3 000+ rules with MITRE ATT&CK mappings.",
"sync_frequency": "weekly", "sync_frequency": "weekly",
"config": { "config": {
"zip_url": "https://github.com/SigmaHQ/sigma/archive/refs/heads/main.zip", "zip_url": "https://github.com/SigmaHQ/sigma/archive/refs/heads/master.zip",
"root_prefix": "sigma-main", "root_prefix": "sigma-master",
"rules_dir": "rules", "rules_dir": "rules",
}, },
}, },
@@ -78,13 +78,14 @@ INITIAL_SOURCES = [
"name": "caldera", "name": "caldera",
"display_name": "MITRE CALDERA", "display_name": "MITRE CALDERA",
"type": "attack_procedure", "type": "attack_procedure",
"url": "https://github.com/mitre/caldera", "url": "https://github.com/mitre/stockpile",
"description": "Automated adversary emulation platform by MITRE. " "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", "sync_frequency": "monthly",
"config": { "config": {
"zip_url": "https://github.com/mitre/caldera/archive/refs/heads/master.zip", "zip_url": "https://github.com/mitre/stockpile/archive/refs/heads/master.zip",
"root_prefix": "caldera-master", "root_prefix": "stockpile-master",
"abilities_dir": "data/abilities", "abilities_dir": "data/abilities",
}, },
}, },

View File

@@ -44,12 +44,12 @@ logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
CALDERA_ZIP_URL = ( CALDERA_ZIP_URL = (
"https://github.com/mitre/caldera" "https://github.com/mitre/stockpile"
"/archive/refs/heads/master.zip" "/archive/refs/heads/master.zip"
) )
_DOWNLOAD_TIMEOUT = 300 _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) logger.debug("Failed to parse %s: %s", yaml_path, exc)
continue 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: for data in data_list:
if not isinstance(data, dict): if isinstance(data, dict):
continue 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", "") ability_id = data.get("id", "")
if not ability_id: if not ability_id:
continue continue
@@ -193,7 +200,7 @@ def _parse_abilities(abilities_dir: Path) -> list[dict]:
"tool_suggested": executor_str, "tool_suggested": executor_str,
"attack_procedure": commands[:4000] if commands else None, "attack_procedure": commands[:4000] if commands else None,
"atomic_test_id": f"caldera:{ability_id}", "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)) logger.info("Parsed %d CALDERA abilities total", len(results))

View File

@@ -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: def _get_nist_category(family_code: str) -> str:
"""Map NIST 800-53 family code to category name.""" """Map NIST 800-53 family code to category name."""
categories = { categories = {

View File

@@ -20,7 +20,7 @@ logger = logging.getLogger(__name__)
D3FEND_ALL_URL = "https://d3fend.mitre.org/api/technique/api-all.json" 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_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 ──────────────────────────────────────── # ── Tactic extraction helpers ────────────────────────────────────────
@@ -139,7 +139,8 @@ def import_d3fend_techniques(db: Session) -> dict[str, int]:
.filter(DefensiveTechnique.d3fend_id == tech_data["d3fend_id"]) .filter(DefensiveTechnique.d3fend_id == tech_data["d3fend_id"])
.first() .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: if existing:
existing.name = tech_data["name"] existing.name = tech_data["name"]
@@ -416,7 +417,8 @@ def _import_d3fend_fallback(db: Session) -> dict[str, int]:
.filter(DefensiveTechnique.d3fend_id == d3fend_id) .filter(DefensiveTechnique.d3fend_id == d3fend_id)
.first() .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: if existing:
existing.name = tech_data["name"] 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} 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]: def get_defenses_for_technique(db: Session, technique_id) -> list[dict]:
"""Get all D3FEND defensive techniques mapped to a given ATT&CK technique.""" """Get all D3FEND defensive techniques mapped to a given ATT&CK technique."""
mappings = ( mappings = (

View File

@@ -68,6 +68,8 @@ _GTFOBINS_FUNCTION_MAP: dict[str, str] = {
"non-interactive-bind-shell": "T1059", "non-interactive-bind-shell": "T1059",
"file-upload": "T1105", "file-upload": "T1105",
"file-download": "T1105", "file-download": "T1105",
"upload": "T1105",
"download": "T1105",
"file-write": "T1105", "file-write": "T1105",
"file-read": "T1005", "file-read": "T1005",
"library-load": "T1129", "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) logger.warning("GTFOBins directory not found at %s", gtfobins_root)
return results return results
md_files = sorted(gtfobins_root.glob("*.md")) md_files = sorted(
logger.info("GTFOBins: Found %d markdown files", len(md_files)) 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: for md_path in md_files:
binary_name = md_path.stem # e.g. "awk" 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: def _extract_front_matter(content: str) -> dict | None:
"""Extract YAML front-matter from a markdown file.""" """Extract YAML front-matter from a markdown/GTFOBins file.
match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
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: if not match:
return None return None
try: try:

View File

@@ -46,11 +46,11 @@ logger = logging.getLogger(__name__)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SIGMA_ZIP_URL = ( 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 _DOWNLOAD_TIMEOUT = 300
_ZIP_ROOT_PREFIX = "sigma-main" _ZIP_ROOT_PREFIX = "sigma-master"
# Regex to extract MITRE ATT&CK technique IDs from Sigma tags # Regex to extract MITRE ATT&CK technique IDs from Sigma tags
# e.g. "attack.t1059.001" → "T1059.001" # 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 # Create one entry per technique
for tech_id in technique_ids: for tech_id in technique_ids:
source_url = ( source_url = (
f"https://github.com/SigmaHQ/sigma/blob/main/" f"https://github.com/SigmaHQ/sigma/blob/master/"
f"{relative_path.replace(chr(92), '/')}" f"{relative_path.replace(chr(92), '/')}"
) )
results.append({ results.append({

View File

@@ -7,5 +7,8 @@ alembic upgrade head
echo "=== Seeding admin user ===" echo "=== Seeding admin user ==="
python -m app.seed python -m app.seed
echo "=== Seeding data sources ==="
python -m app.seed_data_sources
echo "=== Starting uvicorn ===" echo "=== Starting uvicorn ==="
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

@@ -114,3 +114,9 @@ export async function importNistMappings(): Promise<Record<string, unknown>> {
const { data } = await client.post("/compliance/import/nist-800-53"); const { data } = await client.post("/compliance/import/nist-800-53");
return data; return data;
} }
/** Import CIS Controls v8 mappings (admin). */
export async function importCisMappings(): Promise<Record<string, unknown>> {
const { data } = await client.post("/compliance/import/cis-controls-v8");
return data;
}

View File

@@ -9,6 +9,7 @@ export interface TemplateFilters {
severity?: string; severity?: string;
mitre_technique_id?: string; mitre_technique_id?: string;
search?: string; search?: string;
is_active?: boolean;
offset?: number; offset?: number;
limit?: number; limit?: number;
} }
@@ -51,6 +52,8 @@ export async function getTemplates(
if (filters?.mitre_technique_id) if (filters?.mitre_technique_id)
params.append("mitre_technique_id", filters.mitre_technique_id); params.append("mitre_technique_id", filters.mitre_technique_id);
if (filters?.search) params.append("search", filters.search); 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) if (filters?.offset !== undefined)
params.append("offset", String(filters.offset)); params.append("offset", String(filters.offset));
if (filters?.limit !== undefined) if (filters?.limit !== undefined)
@@ -125,7 +128,8 @@ export async function toggleTemplateActive(
// ── All templates (include inactive, for admin) ──────────────────── // ── 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( export async function getAllTemplates(
filters?: TemplateFilters, filters?: TemplateFilters,
): Promise<TestTemplate[]> { ): Promise<TestTemplate[]> {
@@ -135,6 +139,7 @@ export async function getAllTemplates(
if (filters?.search) params.append("search", filters.search); if (filters?.search) params.append("search", filters.search);
if (filters?.offset !== undefined) params.append("offset", String(filters.offset)); if (filters?.offset !== undefined) params.append("offset", String(filters.offset));
if (filters?.limit !== undefined) params.append("limit", String(filters.limit)); 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<TestTemplate[]>( const { data } = await client.get<TestTemplate[]>(
`/test-templates${params.toString() ? `?${params}` : ""}`, `/test-templates${params.toString() ? `?${params}` : ""}`,
@@ -142,6 +147,24 @@ export async function getAllTemplates(
return data; 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<BulkActivateResponse> {
const { data } = await client.patch<BulkActivateResponse>(
`/test-templates/bulk-activate?activate=${activate}`,
);
return data;
}
// ── Import Atomic Red Team ───────────────────────────────────────── // ── Import Atomic Red Team ─────────────────────────────────────────
/** Trigger Atomic Red Team import. Admin only. */ /** Trigger Atomic Red Team import. Admin only. */

View File

@@ -11,7 +11,8 @@ import {
Filter, Filter,
Target, Target,
} from "lucide-react"; } 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"; import { useAuth } from "../context/AuthContext";
const statusColors: Record<string, string> = { const statusColors: Record<string, string> = {
@@ -46,6 +47,8 @@ export default function CampaignsPage() {
search: "", search: "",
}); });
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [showActorSelector, setShowActorSelector] = useState(false);
const [actorSearch, setActorSearch] = useState("");
const [newCampaign, setNewCampaign] = useState({ const [newCampaign, setNewCampaign] = useState({
name: "", name: "",
description: "", 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) => { const formatDate = (dateStr: string | null) => {
if (!dateStr) return ""; if (!dateStr) return "";
return new Date(dateStr).toLocaleDateString("en-US", { return new Date(dateStr).toLocaleDateString("en-US", {
@@ -98,7 +117,7 @@ export default function CampaignsPage() {
{canCreate && ( {canCreate && (
<> <>
<button <button
onClick={() => navigate("/threat-actors")} onClick={() => setShowActorSelector(true)}
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-500/20 transition-colors" className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/10 px-4 py-2 text-sm font-medium text-red-400 hover:bg-red-500/20 transition-colors"
> >
<Crosshair className="h-4 w-4" /> <Crosshair className="h-4 w-4" />
@@ -226,6 +245,93 @@ export default function CampaignsPage() {
</div> </div>
)} )}
{/* Threat Actor Selector Modal */}
{showActorSelector && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-1 text-lg font-semibold text-white">Generate Campaign from Threat Actor</h2>
<p className="mb-4 text-sm text-gray-400">
Select a threat actor to auto-generate a campaign with tests for their uncovered techniques.
</p>
{/* Search */}
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
value={actorSearch}
onChange={(e) => setActorSearch(e.target.value)}
placeholder="Search threat actors..."
className="w-full rounded-lg border border-gray-700 bg-gray-800 pl-10 pr-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
{/* Actor list */}
<div className="max-h-72 overflow-y-auto rounded-lg border border-gray-800">
{isLoadingActors ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-cyan-400" />
</div>
) : actorsData && actorsData.items.length > 0 ? (
actorsData.items.map((actor) => (
<button
key={actor.id}
onClick={() => generateMutation.mutate(actor.id)}
disabled={generateMutation.isPending}
className="flex w-full items-center justify-between px-4 py-3 text-left hover:bg-gray-800/50 transition-colors border-b border-gray-800/50 last:border-b-0 disabled:opacity-50"
>
<div>
<div className="flex items-center gap-2">
<Crosshair className="h-3.5 w-3.5 text-red-400" />
<span className="text-sm font-medium text-white">{actor.name}</span>
{actor.country && (
<span className="text-xs text-gray-500">{actor.country}</span>
)}
</div>
<div className="mt-0.5 flex items-center gap-3 text-xs text-gray-500">
<span>{actor.technique_count} techniques</span>
<span>Coverage: {actor.coverage_pct}%</span>
</div>
</div>
<Target className="h-4 w-4 text-gray-600" />
</button>
))
) : (
<div className="py-8 text-center text-sm text-gray-500">
No threat actors found
</div>
)}
</div>
{/* Error */}
{generateMutation.isError && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2 text-xs text-red-400">
<AlertCircle className="h-3.5 w-3.5" />
{(generateMutation.error as Error)?.message || "Failed to generate campaign"}
</div>
)}
{/* Loading indicator */}
{generateMutation.isPending && (
<div className="mt-3 flex items-center gap-2 text-sm text-cyan-400">
<Loader2 className="h-4 w-4 animate-spin" />
Generating campaign...
</div>
)}
{/* Cancel */}
<div className="mt-4 flex justify-end">
<button
onClick={() => { setShowActorSelector(false); setActorSearch(""); }}
disabled={generateMutation.isPending}
className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Campaign grid */} {/* Campaign grid */}
{isLoading ? ( {isLoading ? (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">

View File

@@ -1,6 +1,6 @@
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Loader2, Loader2,
AlertCircle, AlertCircle,
@@ -9,12 +9,14 @@ import {
Minus, Minus,
GitCompareArrows, GitCompareArrows,
Camera, Camera,
Plus,
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
} from "lucide-react"; } from "lucide-react";
import { import {
listSnapshots, listSnapshots,
compareSnapshots, compareSnapshots,
createSnapshot,
type SnapshotSummary, type SnapshotSummary,
type SnapshotComparison, type SnapshotComparison,
} from "../api/snapshots"; } from "../api/snapshots";
@@ -91,9 +93,12 @@ function MetricCard({
export default function ComparisonPage() { export default function ComparisonPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const [snapA, setSnapA] = useState<string>(""); const [snapA, setSnapA] = useState<string>("");
const [snapB, setSnapB] = useState<string>(""); const [snapB, setSnapB] = useState<string>("");
const [activeTab, setActiveTab] = useState<Tab>("improved"); const [activeTab, setActiveTab] = useState<Tab>("improved");
const [showNameInput, setShowNameInput] = useState(false);
const [snapshotName, setSnapshotName] = useState("");
// Fetch all snapshots for the dropdowns // Fetch all snapshots for the dropdowns
const { data: snapshotsData, isLoading: isLoadingSnapshots } = useQuery({ const { data: snapshotsData, isLoading: isLoadingSnapshots } = useQuery({
@@ -101,6 +106,18 @@ export default function ComparisonPage() {
queryFn: () => listSnapshots({ limit: 200 }), 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 || []; const snapshots = snapshotsData?.items || [];
// Comparison query // Comparison query
@@ -149,6 +166,51 @@ export default function ComparisonPage() {
<p className="text-sm text-gray-400">Compare coverage snapshots over time</p> <p className="text-sm text-gray-400">Compare coverage snapshots over time</p>
</div> </div>
</div> </div>
{/* Create Snapshot */}
<div className="flex items-center gap-2">
{showNameInput ? (
<>
<input
value={snapshotName}
onChange={(e) => 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"
/>
<button
onClick={() => createSnapshotMutation.mutate(snapshotName || undefined)}
disabled={createSnapshotMutation.isPending}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors disabled:opacity-50"
>
{createSnapshotMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Camera className="h-4 w-4" />
)}
Save
</button>
<button
onClick={() => { setShowNameInput(false); setSnapshotName(""); }}
className="rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-400 hover:bg-gray-800 transition-colors"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setShowNameInput(true)}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Plus className="h-4 w-4" />
Create Snapshot
</button>
)}
</div>
</div> </div>
{/* Snapshot selectors */} {/* Snapshot selectors */}

View File

@@ -1,17 +1,23 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Loader2, AlertCircle, Download, FileText } from "lucide-react"; import { Loader2, AlertCircle, Download, FileText, Plus } from "lucide-react";
import { import {
getComplianceFrameworks, getComplianceFrameworks,
getFrameworkStatus, getFrameworkStatus,
downloadComplianceCSV, downloadComplianceCSV,
importNistMappings,
importCisMappings,
type ComplianceFrameworkSummary, type ComplianceFrameworkSummary,
} from "../api/compliance"; } from "../api/compliance";
import { useAuth } from "../context/AuthContext";
import ComplianceGauge from "../components/compliance/ComplianceGauge"; import ComplianceGauge from "../components/compliance/ComplianceGauge";
import ControlsTable from "../components/compliance/ControlsTable"; import ControlsTable from "../components/compliance/ControlsTable";
export default function CompliancePage() { export default function CompliancePage() {
const [selectedFrameworkId, setSelectedFrameworkId] = useState<string | null>(null); const [selectedFrameworkId, setSelectedFrameworkId] = useState<string | null>(null);
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdmin = user?.role === "admin";
// Fetch available frameworks // Fetch available frameworks
const { const {
@@ -59,6 +65,24 @@ export default function CompliancePage() {
URL.revokeObjectURL(url); 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) { if (isLoading && !frameworkStatus) {
return ( return (
<div className="flex h-64 items-center justify-center"> <div className="flex h-64 items-center justify-center">
@@ -171,6 +195,35 @@ export default function CompliancePage() {
</div> </div>
)} )}
{/* Import buttons for admin */}
{isAdmin && (
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-gray-500">Import frameworks:</span>
<button
onClick={() => importNist.mutate()}
disabled={isImporting}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white transition-colors disabled:opacity-50"
>
{importNist.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
NIST 800-53
</button>
<button
onClick={() => importCis.mutate()}
disabled={isImporting}
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs font-medium text-gray-300 hover:border-cyan-500/50 hover:text-white transition-colors disabled:opacity-50"
>
{importCis.isPending ? <Loader2 className="h-3 w-3 animate-spin" /> : <Plus className="h-3 w-3" />}
CIS Controls v8
</button>
{(importNist.isSuccess || importCis.isSuccess) && (
<span className="text-xs text-green-400">Import complete</span>
)}
{(importNist.isError || importCis.isError) && (
<span className="text-xs text-red-400">Import failed</span>
)}
</div>
)}
{/* Controls table */} {/* Controls table */}
{controls.length > 0 ? ( {controls.length > 0 ? (
<ControlsTable controls={controls} /> <ControlsTable controls={controls} />
@@ -179,7 +232,7 @@ export default function CompliancePage() {
<div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-8 text-center">
<AlertCircle className="mx-auto h-10 w-10 text-gray-600" /> <AlertCircle className="mx-auto h-10 w-10 text-gray-600" />
<p className="mt-3 text-gray-400"> <p className="mt-3 text-gray-400">
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.
</p> </p>
</div> </div>
) )

View File

@@ -13,7 +13,6 @@ import {
Shield, Shield,
Search, Search,
FlaskConical, FlaskConical,
Download,
Plus, Plus,
ToggleLeft, ToggleLeft,
ToggleRight, ToggleRight,
@@ -28,12 +27,11 @@ import {
type IntelScanResponse, type IntelScanResponse,
} from "../api/system"; } from "../api/system";
import { import {
importAtomicTests,
getTemplateStats, getTemplateStats,
getAllTemplates, getAllTemplates,
createTemplate, createTemplate,
toggleTemplateActive, toggleTemplateActive,
type ImportAtomicResponse, bulkActivateTemplates,
type TemplateStats, type TemplateStats,
type CreateTemplatePayload, type CreateTemplatePayload,
} from "../api/test-templates"; } from "../api/test-templates";
@@ -43,8 +41,8 @@ export default function SystemPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [syncResult, setSyncResult] = useState<SyncMitreResponse | null>(null); const [syncResult, setSyncResult] = useState<SyncMitreResponse | null>(null);
const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null); const [intelResult, setIntelResult] = useState<IntelScanResponse | null>(null);
const [importResult, setImportResult] = useState<ImportAtomicResponse | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [bulkConfirm, setBulkConfirm] = useState<"activate" | "deactivate" | null>(null);
// ── Existing queries ───────────────────────────────────────────── // ── Existing queries ─────────────────────────────────────────────
const { const {
@@ -71,7 +69,7 @@ export default function SystemPage() {
isLoading: templatesLoading, isLoading: templatesLoading,
} = useQuery({ } = useQuery({
queryKey: ["templates-admin"], queryKey: ["templates-admin"],
queryFn: () => getAllTemplates({ limit: 100 }), queryFn: () => getAllTemplates({ limit: 200 }),
}); });
// ── Mutations ──────────────────────────────────────────────────── // ── Mutations ────────────────────────────────────────────────────
@@ -92,12 +90,12 @@ export default function SystemPage() {
}, },
}); });
const importAtomicMutation = useMutation({ const bulkActivateMutation = useMutation({
mutationFn: importAtomicTests, mutationFn: (activate: boolean) => bulkActivateTemplates(activate),
onSuccess: (data) => { onSuccess: () => {
setImportResult(data); setBulkConfirm(null);
queryClient.invalidateQueries({ queryKey: ["template-stats"] });
queryClient.invalidateQueries({ queryKey: ["templates-admin"] }); queryClient.invalidateQueries({ queryKey: ["templates-admin"] });
queryClient.invalidateQueries({ queryKey: ["template-stats"] });
queryClient.invalidateQueries({ queryKey: ["test-templates"] }); queryClient.invalidateQueries({ queryKey: ["test-templates"] });
}, },
}); });
@@ -281,70 +279,8 @@ export default function SystemPage() {
TEMPLATE ADMINISTRATION (T-124) TEMPLATE ADMINISTRATION (T-124)
──────────────────────────────────────────────────────────────── */} ──────────────────────────────────────────────────────────────── */}
{/* Import Atomic Red Team + Stats */} {/* Template Catalog Stats */}
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-1">
{/* Import Atomic Red Team */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start gap-4">
<div className="rounded-lg bg-red-500/10 p-3">
<Download className="h-6 w-6 text-red-400" />
</div>
<div className="flex-1">
<h2 className="text-lg font-semibold text-white">Import Atomic Red Team</h2>
<p className="mt-1 text-sm text-gray-400">
Import test templates from the Atomic Red Team repository by Red Canary, mapped to MITRE ATT&CK techniques.
</p>
{importResult && (
<div className="mt-4 rounded-lg border border-green-500/30 bg-green-900/20 p-3">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium text-green-400">Import Complete</span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-sm">
<div>
<span className="text-gray-400">Imported:</span>
<span className="ml-1 font-medium text-white">{importResult.imported}</span>
</div>
<div>
<span className="text-gray-400">Skipped:</span>
<span className="ml-1 font-medium text-white">{importResult.skipped}</span>
</div>
<div>
<span className="text-gray-400">Parsed:</span>
<span className="ml-1 font-medium text-white">{importResult.total_parsed}</span>
</div>
</div>
</div>
)}
{importAtomicMutation.isError && (
<div className="mt-4 rounded-lg border border-red-500/30 bg-red-900/20 p-3">
<div className="flex items-center gap-2">
<XCircle className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">
Import failed: {(importAtomicMutation.error as Error)?.message}
</span>
</div>
</div>
)}
<button
onClick={() => importAtomicMutation.mutate()}
disabled={importAtomicMutation.isPending}
className="mt-4 flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500 disabled:opacity-50 transition-colors"
>
{importAtomicMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
{importAtomicMutation.isPending ? "Importing..." : "Import Now"}
</button>
</div>
</div>
</div>
{/* Template Catalog Stats */} {/* Template Catalog Stats */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
@@ -433,20 +369,85 @@ export default function SystemPage() {
/> />
)} )}
{/* Bulk Activate Confirmation Modal */}
{bulkConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md rounded-xl border border-gray-700 bg-gray-900 p-6 shadow-xl">
<h3 className="text-lg font-semibold text-white">
{bulkConfirm === "activate" ? "Activate All Templates" : "Deactivate All Templates"}
</h3>
<p className="mt-2 text-sm text-gray-400">
{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."}
</p>
<p className="mt-2 text-sm font-medium text-yellow-400">
This action affects all {templateStats?.total || 0} templates.
</p>
<div className="mt-4 flex items-center justify-end gap-3">
<button
onClick={() => setBulkConfirm(null)}
className="rounded-lg border border-gray-700 bg-gray-800 px-4 py-2 text-sm font-medium text-gray-300 hover:border-gray-600 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={() => bulkActivateMutation.mutate(bulkConfirm === "activate")}
disabled={bulkActivateMutation.isPending}
className={`flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white transition-colors disabled:opacity-50 ${
bulkConfirm === "activate"
? "bg-green-600 hover:bg-green-500"
: "bg-red-600 hover:bg-red-500"
}`}
>
{bulkActivateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : bulkConfirm === "activate" ? (
<ToggleRight className="h-4 w-4" />
) : (
<ToggleLeft className="h-4 w-4" />
)}
{bulkActivateMutation.isPending
? "Processing..."
: bulkConfirm === "activate"
? "Activate All"
: "Deactivate All"}
</button>
</div>
</div>
</div>
)}
{/* Templates Management Table */} {/* Templates Management Table */}
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold text-white flex items-center gap-2"> <h2 className="text-lg font-semibold text-white flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-cyan-400" /> <FlaskConical className="h-5 w-5 text-cyan-400" />
Manage Templates Manage Templates
</h2> </h2>
<button <div className="flex items-center gap-2">
onClick={() => setShowCreateForm(!showCreateForm)} <button
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors" onClick={() => setBulkConfirm("activate")}
> className="flex items-center gap-1.5 rounded-lg border border-green-500/30 bg-green-900/20 px-3 py-2 text-sm font-medium text-green-400 hover:bg-green-900/40 transition-colors"
<Plus className="h-4 w-4" /> >
Create Custom Template <ToggleRight className="h-4 w-4" />
</button> Activate All
</button>
<button
onClick={() => setBulkConfirm("deactivate")}
className="flex items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-900/20 px-3 py-2 text-sm font-medium text-red-400 hover:bg-red-900/40 transition-colors"
>
<ToggleLeft className="h-4 w-4" />
Deactivate All
</button>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-3 py-2 text-sm font-medium text-white hover:bg-cyan-500 transition-colors"
>
<Plus className="h-4 w-4" />
Create Custom
</button>
</div>
</div> </div>
{templatesLoading ? ( {templatesLoading ? (

View File

@@ -16,6 +16,8 @@ import {
AlertTriangle, AlertTriangle,
BookOpen, BookOpen,
FlaskConical, FlaskConical,
ChevronDown,
ChevronUp,
} from "lucide-react"; } from "lucide-react";
import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques"; import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques";
import { getTemplatesByTechnique } from "../api/test-templates"; import { getTemplatesByTechnique } from "../api/test-templates";
@@ -410,76 +412,7 @@ export default function TechniqueDetailPage() {
{/* Recommended Defenses (D3FEND) */} {/* Recommended Defenses (D3FEND) */}
{technique.d3fend_defenses && technique.d3fend_defenses.length > 0 && ( {technique.d3fend_defenses && technique.d3fend_defenses.length > 0 && (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <D3FENDSection defenses={technique.d3fend_defenses} />
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="h-5 w-5 text-emerald-400" />
Recommended Defenses (D3FEND)
</h2>
<span className="rounded-full bg-emerald-900/50 border border-emerald-500/30 px-2.5 py-0.5 text-xs font-medium text-emerald-400">
{technique.d3fend_defenses.length} countermeasure{technique.d3fend_defenses.length !== 1 ? "s" : ""}
</span>
</div>
{/* Group by tactic */}
{(() => {
const grouped: Record<string, typeof technique.d3fend_defenses> = {};
for (const def of technique.d3fend_defenses!) {
const tactic = def.tactic || "Other";
if (!grouped[tactic]) grouped[tactic] = [];
grouped[tactic].push(def);
}
const tacticColors: Record<string, string> = {
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]) => (
<div key={tactic} className="mb-4 last:mb-0">
<h3 className="mb-2 text-sm font-medium text-gray-400 uppercase tracking-wide">
{tactic}
</h3>
<div className="grid gap-2 sm:grid-cols-2">
{defenses!.map((def) => (
<div
key={def.id}
className={`rounded-lg border p-3 transition-colors hover:border-gray-600 ${
tacticColors[tactic] || "border-gray-700 bg-gray-800/30 text-gray-300"
}`}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-200">
<span className="font-mono text-xs text-gray-500 mr-1.5">{def.d3fend_id}</span>
{def.name}
</p>
{def.description && (
<p className="mt-1 text-xs text-gray-400 line-clamp-2">{def.description}</p>
)}
</div>
{def.d3fend_url && (
<a
href={def.d3fend_url}
target="_blank"
rel="noopener noreferrer"
className="ml-2 shrink-0 text-gray-500 hover:text-cyan-400"
title="View in D3FEND"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
</div>
))}
</div>
</div>
));
})()}
</div>
)} )}
{/* Intel Items Section */} {/* Intel Items Section */}
@@ -525,3 +458,124 @@ export default function TechniqueDetailPage() {
</div> </div>
); );
} }
// ── 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<string | null>(null);
const grouped: Record<string, typeof defenses> = {};
for (const def of defenses) {
const tactic = def.tactic || "Other";
if (!grouped[tactic]) grouped[tactic] = [];
grouped[tactic].push(def);
}
const tacticColors: Record<string, string> = {
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<string, string> = {
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 (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Shield className="h-5 w-5 text-emerald-400" />
Recommended Defenses (D3FEND)
</h2>
<span className="rounded-full bg-emerald-900/50 border border-emerald-500/30 px-2.5 py-0.5 text-xs font-medium text-emerald-400">
{defenses.length} countermeasure{defenses.length !== 1 ? "s" : ""}
</span>
</div>
{Object.entries(grouped).map(([tactic, defs]) => (
<div key={tactic} className="mb-4 last:mb-0">
<h3 className="mb-1 text-sm font-medium text-gray-400 uppercase tracking-wide">
{tactic}
</h3>
{tacticDescriptions[tactic] && (
<p className="mb-2 text-xs text-gray-500">{tacticDescriptions[tactic]}</p>
)}
<div className="grid gap-2 sm:grid-cols-2">
{defs.map((def) => {
const isExpanded = expandedId === def.id;
return (
<div
key={def.id}
className={`rounded-lg border p-3 transition-all cursor-pointer ${
isExpanded ? "ring-1 ring-gray-600" : ""
} ${tacticColors[tactic] || "border-gray-700 bg-gray-800/30 text-gray-300"}`}
onClick={() => setExpandedId(isExpanded ? null : def.id)}
>
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-200 flex items-center gap-1.5">
<span className="font-mono text-xs text-gray-500">{def.d3fend_id}</span>
{def.name}
</p>
</div>
{isExpanded ? (
<ChevronUp className="ml-2 h-4 w-4 shrink-0 text-gray-500" />
) : (
<ChevronDown className="ml-2 h-4 w-4 shrink-0 text-gray-500" />
)}
</div>
{isExpanded && (
<div className="mt-3 space-y-2 border-t border-gray-700/50 pt-3">
{def.description ? (
<p className="text-xs text-gray-300 leading-relaxed">{def.description}</p>
) : (
<p className="text-xs text-gray-500 italic">No description available.</p>
)}
<div className="flex items-center gap-3 pt-1">
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium text-gray-400 border border-gray-700">
Tactic: {def.tactic || "Unknown"}
</span>
<span className="rounded bg-gray-800 px-2 py-0.5 text-[10px] font-medium text-gray-400 border border-gray-700">
ID: {def.d3fend_id}
</span>
</div>
{def.d3fend_url && (
<a
href={def.d3fend_url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1.5 text-xs text-cyan-400 hover:text-cyan-300 hover:underline mt-1"
>
<ExternalLink className="h-3 w-3" />
View on MITRE D3FEND
</a>
)}
</div>
)}
</div>
);
})}
</div>
</div>
))}
</div>
);
}

View File

@@ -22,6 +22,9 @@ const PAGE_SIZE = 12;
const SOURCE_OPTIONS = [ const SOURCE_OPTIONS = [
{ value: "", label: "All Sources" }, { value: "", label: "All Sources" },
{ value: "atomic_red_team", label: "Atomic Red Team" }, { 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" }, { value: "custom", label: "Custom" },
]; ];
@@ -45,9 +48,20 @@ const PLATFORM_OPTIONS = [
const SOURCE_BADGE: Record<string, string> = { const SOURCE_BADGE: Record<string, string> = {
atomic_red_team: "bg-red-900/50 text-red-400 border-red-500/30", 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", custom: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
}; };
const SOURCE_LABEL: Record<string, string> = {
atomic_red_team: "Atomic",
caldera: "CALDERA",
lolbas: "LOLBAS",
gtfobins: "GTFOBins",
custom: "Custom",
};
const SEVERITY_BADGE: Record<string, string> = { const SEVERITY_BADGE: Record<string, string> = {
low: "bg-blue-900/50 text-blue-400 border-blue-500/30", low: "bg-blue-900/50 text-blue-400 border-blue-500/30",
medium: "bg-yellow-900/50 text-yellow-400 border-yellow-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" 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}
</span> </span>
{/* Platform */} {/* Platform */}