feat(techniques): show detection rules on technique detail page
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Backend: - technique_query_service.get_technique_detail() now queries DetectionRule by mitre_technique_id == mitre_id (same field the heatmap uses) - Rules sorted: critical → high → medium → low → informational, then alphabetically - Returns: id, title, description, source, source_url, rule_format, severity, platforms, false_positive_rate Frontend: - New DetectionRulesSection component with expandable rows per rule - Color-coded severity dots and badges (red/orange/yellow/blue/gray) - Source badges (sigma=purple, elastic=blue, splunk=orange, custom=cyan) - Shows format, false positive rate, platforms, source link on expand - Empty state when no rules exist Fixes: T1189 showed green in heatmap but no rules on detail page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detection Rules Section */}
|
||||
<DetectionRulesSection rules={(technique as any).detection_rules ?? []} />
|
||||
|
||||
{/* Available Test Templates Section */}
|
||||
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
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<string | null>(null);
|
||||
|
||||
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="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<Radar className="h-5 w-5 text-cyan-400" />
|
||||
Detection Rules
|
||||
</h2>
|
||||
<span
|
||||
className={`rounded-full border px-2.5 py-0.5 text-xs font-medium ${
|
||||
rules.length > 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" : ""}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rules.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500">
|
||||
<Radar className="mb-2 h-8 w-8 text-gray-700" />
|
||||
<p className="text-sm">No detection rules linked to this technique.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rules.map((rule) => {
|
||||
const isExpanded = expandedId === rule.id;
|
||||
const sevColor = severityColors[rule.severity ?? ""] ?? severityColors.informational;
|
||||
const srcColor = sourceColors[rule.source] ?? sourceColors.custom;
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="rounded-lg border border-gray-800 bg-gray-800/30 transition-colors hover:border-gray-700 cursor-pointer"
|
||||
onClick={() => setExpandedId(isExpanded ? null : rule.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3 p-3">
|
||||
{/* Severity dot */}
|
||||
<div
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
rule.severity === "critical" ? "bg-red-400"
|
||||
: rule.severity === "high" ? "bg-orange-400"
|
||||
: rule.severity === "medium" ? "bg-yellow-400"
|
||||
: rule.severity === "low" ? "bg-blue-400"
|
||||
: "bg-gray-600"
|
||||
}`}
|
||||
/>
|
||||
{/* Title */}
|
||||
<span className="flex-1 min-w-0 truncate text-sm font-medium text-gray-200">
|
||||
{rule.title}
|
||||
</span>
|
||||
{/* Badges */}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{rule.severity && (
|
||||
<span className={`rounded border px-1.5 py-0.5 text-[10px] font-semibold uppercase ${sevColor}`}>
|
||||
{rule.severity}
|
||||
</span>
|
||||
)}
|
||||
<span className={`rounded border px-1.5 py-0.5 text-[10px] font-medium uppercase ${srcColor}`}>
|
||||
{rule.source}
|
||||
</span>
|
||||
<span className="rounded border border-gray-700 bg-gray-800 px-1.5 py-0.5 text-[10px] text-gray-500">
|
||||
{rule.rule_format.replace("_yaml", "").toUpperCase()}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-3.5 w-3.5 text-gray-500" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="border-t border-gray-700/50 px-4 py-3 space-y-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{rule.description && (
|
||||
<p className="text-xs text-gray-400 leading-relaxed">{rule.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-3 text-xs text-gray-500">
|
||||
{rule.rule_format && (
|
||||
<span>Format: <span className="text-gray-300">{rule.rule_format}</span></span>
|
||||
)}
|
||||
{rule.false_positive_rate && (
|
||||
<span>False positives: <span className="text-gray-300 capitalize">{rule.false_positive_rate}</span></span>
|
||||
)}
|
||||
{rule.platforms && rule.platforms.length > 0 && (
|
||||
<span>Platforms: <span className="text-gray-300">{rule.platforms.join(", ")}</span></span>
|
||||
)}
|
||||
{rule.source_id && (
|
||||
<span>Source ID: <span className="font-mono text-gray-300">{rule.source_id}</span></span>
|
||||
)}
|
||||
</div>
|
||||
{rule.source_url && (
|
||||
<a
|
||||
href={rule.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-cyan-400 hover:underline"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View source rule
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── D3FEND Section ────────────────────────────────────────────────────
|
||||
|
||||
function D3FENDSection({ defenses }: { defenses: Array<{
|
||||
|
||||
Reference in New Issue
Block a user