diff --git a/backend/app/services/technique_query_service.py b/backend/app/services/technique_query_service.py index 6e9e049..ea5cebf 100644 --- a/backend/app/services/technique_query_service.py +++ b/backend/app/services/technique_query_service.py @@ -5,11 +5,15 @@ from sqlalchemy.orm import Session, joinedload from app.domain.errors import EntityNotFoundError from app.models.technique import Technique +from app.models.detection_rule import DetectionRule from app.services.d3fend_import_service import get_defenses_for_technique +# Severity sort order for detection rules (most critical first) +_SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3, "informational": 4, None: 5} + def get_technique_detail(db: Session, mitre_id: str) -> dict: - """Fetch full technique details including tests and D3FEND defenses.""" + """Fetch full technique details including tests, detection rules, and D3FEND defenses.""" technique = ( db.query(Technique) .options(joinedload(Technique.tests)) @@ -18,7 +22,22 @@ def get_technique_detail(db: Session, mitre_id: str) -> dict: ) if technique is None: raise EntityNotFoundError("Technique", mitre_id) + defenses = get_defenses_for_technique(db, technique.id) + + detection_rules = ( + db.query(DetectionRule) + .filter( + DetectionRule.mitre_technique_id == mitre_id, + DetectionRule.is_active == True, # noqa: E712 + ) + .all() + ) + # Sort by severity (critical first), then alphabetically by title + detection_rules.sort( + key=lambda r: (_SEVERITY_ORDER.get(r.severity, 5), (r.title or "").lower()) + ) + return { "id": str(technique.id), "mitre_id": technique.mitre_id, @@ -44,5 +63,20 @@ def get_technique_detail(db: Session, mitre_id: str) -> dict: } for t in technique.tests ], + "detection_rules": [ + { + "id": str(r.id), + "title": r.title, + "description": r.description, + "source": r.source, + "source_id": r.source_id, + "source_url": r.source_url, + "rule_format": r.rule_format, + "severity": r.severity, + "platforms": r.platforms or [], + "false_positive_rate": r.false_positive_rate, + } + for r in detection_rules + ], "d3fend_defenses": defenses, } diff --git a/frontend/src/pages/TechniqueDetailPage.tsx b/frontend/src/pages/TechniqueDetailPage.tsx index 8a545ea..8adb4a0 100644 --- a/frontend/src/pages/TechniqueDetailPage.tsx +++ b/frontend/src/pages/TechniqueDetailPage.tsx @@ -18,6 +18,7 @@ import { FlaskConical, ChevronDown, ChevronUp, + Radar, } from "lucide-react"; import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques"; import { getTemplatesByTechnique } from "../api/test-templates"; @@ -345,6 +346,9 @@ export default function TechniqueDetailPage() { )} + {/* Detection Rules Section */} + + {/* Available Test Templates Section */}
@@ -465,6 +469,154 @@ export default function TechniqueDetailPage() { ); } +// ── Detection Rules Section ─────────────────────────────────────────── + +interface DetectionRule { + id: string; + title: string; + description: string | null; + source: string; + source_id: string | null; + source_url: string | null; + rule_format: string; + severity: string | null; + platforms: string[]; + false_positive_rate: string | null; +} + +const severityColors: Record = { + critical: "bg-red-900/40 text-red-400 border-red-500/30", + high: "bg-orange-900/40 text-orange-400 border-orange-500/30", + medium: "bg-yellow-900/40 text-yellow-400 border-yellow-500/30", + low: "bg-blue-900/40 text-blue-400 border-blue-500/30", + informational: "bg-gray-800/60 text-gray-400 border-gray-600/30", +}; + +const sourceColors: Record = { + sigma: "bg-purple-900/40 text-purple-400 border-purple-500/30", + elastic: "bg-blue-900/40 text-blue-400 border-blue-500/30", + splunk: "bg-orange-900/40 text-orange-400 border-orange-500/30", + custom: "bg-cyan-900/40 text-cyan-400 border-cyan-500/30", +}; + +function DetectionRulesSection({ rules }: { rules: DetectionRule[] }) { + const [expandedId, setExpandedId] = useState(null); + + return ( +
+
+

+ + Detection Rules +

+ 0 + ? "border-cyan-500/30 bg-cyan-900/40 text-cyan-400" + : "border-gray-600/30 bg-gray-800/50 text-gray-500" + }`} + > + {rules.length} rule{rules.length !== 1 ? "s" : ""} + +
+ + {rules.length === 0 ? ( +
+ +

No detection rules linked to this technique.

+
+ ) : ( +
+ {rules.map((rule) => { + const isExpanded = expandedId === rule.id; + const sevColor = severityColors[rule.severity ?? ""] ?? severityColors.informational; + const srcColor = sourceColors[rule.source] ?? sourceColors.custom; + return ( +
setExpandedId(isExpanded ? null : rule.id)} + > +
+ {/* Severity dot */} +
+ {/* Title */} + + {rule.title} + + {/* Badges */} +
+ {rule.severity && ( + + {rule.severity} + + )} + + {rule.source} + + + {rule.rule_format.replace("_yaml", "").toUpperCase()} + + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {isExpanded && ( +
e.stopPropagation()} + > + {rule.description && ( +

{rule.description}

+ )} +
+ {rule.rule_format && ( + Format: {rule.rule_format} + )} + {rule.false_positive_rate && ( + False positives: {rule.false_positive_rate} + )} + {rule.platforms && rule.platforms.length > 0 && ( + Platforms: {rule.platforms.join(", ")} + )} + {rule.source_id && ( + Source ID: {rule.source_id} + )} +
+ {rule.source_url && ( + + + View source rule + + )} +
+ )} +
+ ); + })} +
+ )} +
+ ); +} + // ── D3FEND Section ──────────────────────────────────────────────────── function D3FENDSection({ defenses }: { defenses: Array<{