feat(phase-25): add detection rule associations, checklist UI and evaluation workflow (T-215, T-216)

This commit is contained in:
2026-02-09 16:44:35 +01:00
parent cd124b655b
commit f4de12d8ab
9 changed files with 970 additions and 0 deletions

View File

@@ -0,0 +1,66 @@
"""add_detection_rule_associations
Revision ID: b012detectionassoc
Revises: b011defensive
Create Date: 2026-02-09 17:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
# revision identifiers, used by Alembic.
revision: str = 'b012detectionassoc'
down_revision: Union[str, Sequence[str], None] = 'b011defensive'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Create test_template_detection_rules and test_detection_results tables."""
# test_template_detection_rules (template ↔ detection rule association)
op.create_table(
'test_template_detection_rules',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('test_template_id', UUID(as_uuid=True),
sa.ForeignKey('test_templates.id', ondelete='CASCADE'), nullable=True),
sa.Column('detection_rule_id', UUID(as_uuid=True),
sa.ForeignKey('detection_rules.id', ondelete='CASCADE'), nullable=False),
sa.Column('is_primary', sa.Boolean(), server_default='false'),
)
op.create_index('ix_ttdr_template', 'test_template_detection_rules', ['test_template_id'])
op.create_index('ix_ttdr_rule', 'test_template_detection_rules', ['detection_rule_id'])
op.create_unique_constraint('uq_template_detection_rule', 'test_template_detection_rules',
['test_template_id', 'detection_rule_id'])
# test_detection_results (per-test, per-rule evaluation results)
op.create_table(
'test_detection_results',
sa.Column('id', UUID(as_uuid=True), primary_key=True),
sa.Column('test_id', UUID(as_uuid=True),
sa.ForeignKey('tests.id', ondelete='CASCADE'), nullable=False),
sa.Column('detection_rule_id', UUID(as_uuid=True),
sa.ForeignKey('detection_rules.id', ondelete='CASCADE'), nullable=False),
sa.Column('triggered', sa.Boolean(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('evaluated_by', UUID(as_uuid=True),
sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('evaluated_at', sa.DateTime(), nullable=True),
)
op.create_index('ix_tdr_test', 'test_detection_results', ['test_id'])
op.create_index('ix_tdr_rule', 'test_detection_results', ['detection_rule_id'])
def downgrade() -> None:
"""Drop test_detection_results and test_template_detection_rules tables."""
op.drop_index('ix_tdr_rule', table_name='test_detection_results')
op.drop_index('ix_tdr_test', table_name='test_detection_results')
op.drop_table('test_detection_results')
op.drop_constraint('uq_template_detection_rule', 'test_template_detection_rules', type_='unique')
op.drop_index('ix_ttdr_rule', table_name='test_template_detection_rules')
op.drop_index('ix_ttdr_template', table_name='test_template_detection_rules')
op.drop_table('test_template_detection_rules')

View File

@@ -21,6 +21,7 @@ from app.routers import reports as reports_router
from app.routers import data_sources as data_sources_router
from app.routers import threat_actors as threat_actors_router
from app.routers import d3fend as d3fend_router
from app.routers import detection_rules as detection_rules_router
from app.storage import ensure_bucket_exists
from app.jobs.mitre_sync_job import start_scheduler, scheduler
@@ -66,6 +67,7 @@ app.include_router(reports_router.router, prefix="/api/v1")
app.include_router(data_sources_router.router, prefix="/api/v1")
app.include_router(threat_actors_router.router, prefix="/api/v1")
app.include_router(d3fend_router.router, prefix="/api/v1")
app.include_router(detection_rules_router.router, prefix="/api/v1")
@app.get("/health")

View File

@@ -11,6 +11,8 @@ from app.models.data_source import DataSource
from app.models.detection_rule import DetectionRule
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
from app.models.test_template_detection_rule import TestTemplateDetectionRule
from app.models.test_detection_result import TestDetectionResult
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
__all__ = [
@@ -18,5 +20,6 @@ __all__ = [
"IntelItem", "AuditLog", "Notification", "DataSource",
"DetectionRule", "ThreatActor", "ThreatActorTechnique",
"DefensiveTechnique", "DefensiveTechniqueMapping",
"TestTemplateDetectionRule", "TestDetectionResult",
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
]

View File

@@ -0,0 +1,55 @@
"""TestDetectionResult — tracks which detection rules triggered during a test.
When the Blue Team evaluates a test, they mark each associated detection
rule as triggered / not triggered / not applicable, along with notes.
"""
import uuid
from datetime import datetime
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Index
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class TestDetectionResult(Base):
"""
Per-test, per-rule evaluation result.
- ``triggered`` = True: rule detected the attack
- ``triggered`` = False: rule did NOT detect the attack
- ``triggered`` = None: not yet evaluated
"""
__tablename__ = "test_detection_results"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
test_id = Column(
UUID(as_uuid=True),
ForeignKey("tests.id", ondelete="CASCADE"),
nullable=False,
)
detection_rule_id = Column(
UUID(as_uuid=True),
ForeignKey("detection_rules.id", ondelete="CASCADE"),
nullable=False,
)
triggered = Column(Boolean, nullable=True) # None = not evaluated
notes = Column(Text, nullable=True)
evaluated_by = Column(
UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
evaluated_at = Column(DateTime, nullable=True)
# Relationships
test = relationship("Test")
detection_rule = relationship("DetectionRule")
evaluator = relationship("User", foreign_keys=[evaluated_by])
__table_args__ = (
Index('ix_tdr_test', 'test_id'),
Index('ix_tdr_rule', 'detection_rule_id'),
)

View File

@@ -0,0 +1,50 @@
"""TestTemplateDetectionRule — links test templates to detection rules.
Enables the Blue Team to see which detection rules should fire
for a given test template / attack procedure.
"""
import uuid
from datetime import datetime
from sqlalchemy import Column, Boolean, ForeignKey, Index, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.database import Base
class TestTemplateDetectionRule(Base):
"""
Association between a test template and a detection rule.
Auto-generated by matching mitre_technique_id, or manually curated.
``is_primary`` marks rules with severity >= high as primary detections.
"""
__tablename__ = "test_template_detection_rules"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
test_template_id = Column(
UUID(as_uuid=True),
ForeignKey("test_templates.id", ondelete="CASCADE"),
nullable=True,
)
detection_rule_id = Column(
UUID(as_uuid=True),
ForeignKey("detection_rules.id", ondelete="CASCADE"),
nullable=False,
)
is_primary = Column(Boolean, default=False)
# Relationships
test_template = relationship("TestTemplate")
detection_rule = relationship("DetectionRule")
__table_args__ = (
Index('ix_ttdr_template', 'test_template_id'),
Index('ix_ttdr_rule', 'detection_rule_id'),
UniqueConstraint(
'test_template_id', 'detection_rule_id',
name='uq_template_detection_rule',
),
)

View File

@@ -0,0 +1,370 @@
"""Detection rules endpoints — listing, filtering, and template association.
Provides endpoints for browsing detection rules, querying rules by technique,
and managing the template ↔ detection rule associations.
"""
import logging
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func
from sqlalchemy.orm import Session
from app.database import get_db
from app.dependencies.auth import get_current_user, require_role, require_any_role
from app.models.user import User
from app.models.detection_rule import DetectionRule
from app.models.test_template import TestTemplate
from app.models.test_template_detection_rule import TestTemplateDetectionRule
from app.models.test_detection_result import TestDetectionResult
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/detection-rules", tags=["detection-rules"])
# ---------------------------------------------------------------------------
# GET /detection-rules — List with filters
# ---------------------------------------------------------------------------
@router.get("")
def list_detection_rules(
technique: Optional[str] = Query(None, description="Filter by MITRE technique ID"),
source: Optional[str] = Query(None, description="Filter by source (sigma, elastic, splunk, custom)"),
severity: Optional[str] = Query(None),
search: Optional[str] = Query(None),
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),
):
"""List detection rules with optional filters and pagination."""
query = db.query(DetectionRule).filter(DetectionRule.is_active == True) # noqa: E712
if technique:
query = query.filter(DetectionRule.mitre_technique_id == technique)
if source:
query = query.filter(DetectionRule.source == source)
if severity:
query = query.filter(DetectionRule.severity == severity)
if search:
pattern = f"%{search}%"
query = query.filter(
DetectionRule.title.ilike(pattern)
| DetectionRule.description.ilike(pattern)
)
total = query.count()
items = query.order_by(DetectionRule.mitre_technique_id, DetectionRule.title).offset(offset).limit(limit).all()
return {
"total": total,
"offset": offset,
"limit": limit,
"items": [
{
"id": str(r.id),
"mitre_technique_id": r.mitre_technique_id,
"title": r.title,
"description": r.description,
"source": r.source,
"source_url": r.source_url,
"rule_format": r.rule_format,
"severity": r.severity,
"platforms": r.platforms or [],
"log_sources": r.log_sources,
"is_active": r.is_active,
}
for r in items
],
}
# ---------------------------------------------------------------------------
# GET /test-templates/{id}/detection-rules — Rules for a template
# ---------------------------------------------------------------------------
@router.get("/for-template/{template_id}")
def get_detection_rules_for_template(
template_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get detection rules associated with a test template."""
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
if not template:
raise HTTPException(status_code=404, detail="Test template not found")
associations = (
db.query(TestTemplateDetectionRule)
.filter(TestTemplateDetectionRule.test_template_id == template_id)
.all()
)
rules = []
for assoc in associations:
r = assoc.detection_rule
rules.append({
"id": str(r.id),
"mitre_technique_id": r.mitre_technique_id,
"title": r.title,
"description": r.description,
"source": r.source,
"source_url": r.source_url,
"rule_content": r.rule_content,
"rule_format": r.rule_format,
"severity": r.severity,
"platforms": r.platforms or [],
"log_sources": r.log_sources,
"is_primary": assoc.is_primary,
})
return {
"template_id": str(template.id),
"template_name": template.name,
"mitre_technique_id": template.mitre_technique_id,
"rules": rules,
"total": len(rules),
}
# ---------------------------------------------------------------------------
# POST /detection-rules/auto-associate — Auto-link templates ↔ rules
# ---------------------------------------------------------------------------
@router.post("/auto-associate")
def auto_associate_detection_rules(
db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""Auto-associate test templates with detection rules by MITRE technique ID.
For each active template, find all active detection rules for the same
technique and create associations. Rules with severity >= high are marked
as primary.
"""
templates = db.query(TestTemplate).filter(TestTemplate.is_active == True).all() # noqa: E712
rules = db.query(DetectionRule).filter(DetectionRule.is_active == True).all() # noqa: E712
# Index rules by technique
rules_by_technique: dict[str, list] = {}
for rule in rules:
tid = rule.mitre_technique_id
if tid not in rules_by_technique:
rules_by_technique[tid] = []
rules_by_technique[tid].append(rule)
created = 0
skipped = 0
high_severities = {"high", "critical"}
for template in templates:
matching_rules = rules_by_technique.get(template.mitre_technique_id, [])
for rule in matching_rules:
# Check if association already exists
existing = (
db.query(TestTemplateDetectionRule)
.filter(
TestTemplateDetectionRule.test_template_id == template.id,
TestTemplateDetectionRule.detection_rule_id == rule.id,
)
.first()
)
if existing:
skipped += 1
continue
is_primary = (rule.severity or "").lower() in high_severities
assoc = TestTemplateDetectionRule(
test_template_id=template.id,
detection_rule_id=rule.id,
is_primary=is_primary,
)
db.add(assoc)
created += 1
db.commit()
total = db.query(TestTemplateDetectionRule).count()
return {
"created": created,
"skipped": skipped,
"total_associations": total,
}
# ---------------------------------------------------------------------------
# GET /detection-rules/for-test/{test_id} — Rules + results for a test
# ---------------------------------------------------------------------------
@router.get("/for-test/{test_id}")
def get_detection_rules_for_test(
test_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Get detection rules relevant to a test, along with their evaluation results.
Finds rules by matching the test's technique_id to detection rules,
and returns any existing evaluation results.
"""
from app.models.test import Test
from app.models.technique import Technique
test = db.query(Test).filter(Test.id == test_id).first()
if not test:
raise HTTPException(status_code=404, detail="Test not found")
technique = db.query(Technique).filter(Technique.id == test.technique_id).first()
if not technique:
raise HTTPException(status_code=404, detail="Technique not found")
# Get detection rules for this technique
rules = (
db.query(DetectionRule)
.filter(
DetectionRule.mitre_technique_id == technique.mitre_id,
DetectionRule.is_active == True, # noqa: E712
)
.order_by(DetectionRule.severity.desc(), DetectionRule.title)
.all()
)
# Get existing results for this test
existing_results = (
db.query(TestDetectionResult)
.filter(TestDetectionResult.test_id == test_id)
.all()
)
results_map = {str(r.detection_rule_id): r for r in existing_results}
items = []
triggered_count = 0
evaluated_count = 0
for rule in rules:
result = results_map.get(str(rule.id))
triggered = result.triggered if result else None
notes = result.notes if result else None
evaluated_at = result.evaluated_at.isoformat() if result and result.evaluated_at else None
if triggered is not None:
evaluated_count += 1
if triggered:
triggered_count += 1
items.append({
"id": str(rule.id),
"mitre_technique_id": rule.mitre_technique_id,
"title": rule.title,
"description": rule.description,
"source": rule.source,
"source_url": rule.source_url,
"rule_content": rule.rule_content,
"rule_format": rule.rule_format,
"severity": rule.severity,
"platforms": rule.platforms or [],
"log_sources": rule.log_sources,
"triggered": triggered,
"notes": notes,
"evaluated_at": evaluated_at,
"result_id": str(result.id) if result else None,
})
return {
"test_id": str(test.id),
"mitre_technique_id": technique.mitre_id,
"rules": items,
"total": len(items),
"evaluated": evaluated_count,
"triggered": triggered_count,
"detection_rate": round(triggered_count / evaluated_count * 100, 1) if evaluated_count > 0 else 0,
}
# ---------------------------------------------------------------------------
# POST /detection-rules/evaluate — Save detection result for a rule
# ---------------------------------------------------------------------------
@router.post("/evaluate")
def evaluate_detection_rule(
payload: dict,
db: Session = Depends(get_db),
current_user: User = Depends(require_any_role("blue_tech", "blue_lead")),
):
"""Save or update the evaluation result for a detection rule on a test.
Body:
{
"test_id": "...",
"detection_rule_id": "...",
"triggered": true | false | null,
"notes": "optional notes"
}
"""
test_id = payload.get("test_id")
detection_rule_id = payload.get("detection_rule_id")
triggered = payload.get("triggered")
notes = payload.get("notes")
if not test_id or not detection_rule_id:
raise HTTPException(status_code=400, detail="test_id and detection_rule_id are required")
# Check test exists
from app.models.test import Test
test = db.query(Test).filter(Test.id == test_id).first()
if not test:
raise HTTPException(status_code=404, detail="Test not found")
# Check rule exists
rule = db.query(DetectionRule).filter(DetectionRule.id == detection_rule_id).first()
if not rule:
raise HTTPException(status_code=404, detail="Detection rule not found")
# Upsert result
existing = (
db.query(TestDetectionResult)
.filter(
TestDetectionResult.test_id == test_id,
TestDetectionResult.detection_rule_id == detection_rule_id,
)
.first()
)
if existing:
existing.triggered = triggered
existing.notes = notes
existing.evaluated_by = current_user.id
existing.evaluated_at = datetime.utcnow()
db.commit()
db.refresh(existing)
return {
"id": str(existing.id),
"triggered": existing.triggered,
"notes": existing.notes,
"evaluated_at": existing.evaluated_at.isoformat() if existing.evaluated_at else None,
}
else:
result = TestDetectionResult(
test_id=test_id,
detection_rule_id=detection_rule_id,
triggered=triggered,
notes=notes,
evaluated_by=current_user.id,
evaluated_at=datetime.utcnow(),
)
db.add(result)
db.commit()
db.refresh(result)
return {
"id": str(result.id),
"triggered": result.triggered,
"notes": result.notes,
"evaluated_at": result.evaluated_at.isoformat() if result.evaluated_at else None,
}

View File

@@ -0,0 +1,89 @@
import client from "./client";
export interface DetectionRuleItem {
id: string;
mitre_technique_id: string;
title: string;
description: string | null;
source: string;
source_url: string | null;
rule_content?: string;
rule_format: string;
severity: string | null;
platforms: string[];
log_sources: Record<string, string> | null;
is_primary?: boolean;
is_active?: boolean;
// Evaluation fields (from for-test endpoint)
triggered?: boolean | null;
notes?: string | null;
evaluated_at?: string | null;
result_id?: string | null;
}
export interface DetectionRulesForTest {
test_id: string;
mitre_technique_id: string;
rules: DetectionRuleItem[];
total: number;
evaluated: number;
triggered: number;
detection_rate: number;
}
export interface EvaluatePayload {
test_id: string;
detection_rule_id: string;
triggered: boolean | null;
notes?: string;
}
export interface EvaluateResult {
id: string;
triggered: boolean | null;
notes: string | null;
evaluated_at: string | null;
}
/** List detection rules with optional filters. */
export async function listDetectionRules(params?: {
technique?: string;
source?: string;
severity?: string;
search?: string;
offset?: number;
limit?: number;
}): Promise<{ total: number; items: DetectionRuleItem[] }> {
const { data } = await client.get("/detection-rules", { params });
return data;
}
/** Get detection rules for a specific test (with evaluation results). */
export async function getDetectionRulesForTest(testId: string): Promise<DetectionRulesForTest> {
const { data } = await client.get<DetectionRulesForTest>(`/detection-rules/for-test/${testId}`);
return data;
}
/** Get detection rules for a specific template. */
export async function getDetectionRulesForTemplate(
templateId: string
): Promise<{ template_id: string; rules: DetectionRuleItem[]; total: number }> {
const { data } = await client.get(`/detection-rules/for-template/${templateId}`);
return data;
}
/** Evaluate a detection rule for a test. */
export async function evaluateDetectionRule(payload: EvaluatePayload): Promise<EvaluateResult> {
const { data } = await client.post<EvaluateResult>("/detection-rules/evaluate", payload);
return data;
}
/** Trigger auto-association of templates ↔ detection rules (admin). */
export async function autoAssociateDetectionRules(): Promise<{
created: number;
skipped: number;
total_associations: number;
}> {
const { data } = await client.post("/detection-rules/auto-associate");
return data;
}

View File

@@ -0,0 +1,321 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Loader2,
CheckCircle,
XCircle,
MinusCircle,
ChevronDown,
ChevronRight,
ExternalLink,
Shield,
} from "lucide-react";
import {
getDetectionRulesForTest,
evaluateDetectionRule,
type DetectionRuleItem,
} from "../../api/detection-rules";
import type { User } from "../../types/models";
const severityColors: Record<string, string> = {
critical: "bg-red-900/50 text-red-400 border-red-500/30",
high: "bg-orange-900/50 text-orange-400 border-orange-500/30",
medium: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30",
low: "bg-blue-900/50 text-blue-400 border-blue-500/30",
informational: "bg-gray-800/50 text-gray-400 border-gray-600/30",
};
const sourceColors: Record<string, string> = {
sigma: "bg-purple-900/50 text-purple-400 border-purple-500/30",
elastic: "bg-cyan-900/50 text-cyan-400 border-cyan-500/30",
splunk: "bg-green-900/50 text-green-400 border-green-500/30",
custom: "bg-gray-800/50 text-gray-400 border-gray-600/30",
};
interface Props {
testId: string;
user: User | null;
canEdit: boolean;
}
export default function DetectionRuleChecklist({ testId, user, canEdit }: Props) {
const queryClient = useQueryClient();
const [expandedRules, setExpandedRules] = useState<Set<string>>(new Set());
const [editingNotes, setEditingNotes] = useState<Record<string, string>>({});
const { data, isLoading, error } = useQuery({
queryKey: ["detection-rules-for-test", testId],
queryFn: () => getDetectionRulesForTest(testId),
enabled: !!testId,
});
const evaluateMutation = useMutation({
mutationFn: evaluateDetectionRule,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["detection-rules-for-test", testId] });
},
});
const toggleExpanded = (ruleId: string) => {
setExpandedRules((prev) => {
const next = new Set(prev);
if (next.has(ruleId)) next.delete(ruleId);
else next.add(ruleId);
return next;
});
};
const handleEvaluate = (ruleId: string, triggered: boolean | null) => {
evaluateMutation.mutate({
test_id: testId,
detection_rule_id: ruleId,
triggered,
notes: editingNotes[ruleId],
});
};
const handleNotesChange = (ruleId: string, notes: string) => {
setEditingNotes((prev) => ({ ...prev, [ruleId]: notes }));
};
const handleNotesSave = (ruleId: string, triggered: boolean | null) => {
evaluateMutation.mutate({
test_id: testId,
detection_rule_id: ruleId,
triggered: triggered,
notes: editingNotes[ruleId] ?? "",
});
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-gray-500" />
</div>
);
}
if (error || !data) {
return null;
}
if (data.rules.length === 0) {
return (
<div className="rounded-lg border border-gray-700 bg-gray-800/30 p-4 text-center">
<Shield className="mx-auto h-8 w-8 text-gray-600" />
<p className="mt-2 text-sm text-gray-400">No detection rules available for this technique.</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Summary bar */}
<div className="flex items-center justify-between rounded-lg border border-gray-700 bg-gray-800/50 p-3">
<div className="flex items-center gap-4">
<div className="text-sm text-gray-300">
<span className="font-semibold text-white">{data.triggered}</span>
<span className="text-gray-500"> / </span>
<span className="font-semibold text-white">{data.total}</span>
<span className="ml-1 text-gray-400">rules triggered</span>
</div>
{data.evaluated > 0 && (
<span className="rounded-full bg-cyan-900/50 border border-cyan-500/30 px-2 py-0.5 text-xs font-medium text-cyan-400">
{data.detection_rate}% detection rate
</span>
)}
</div>
<div className="text-xs text-gray-500">
{data.evaluated} / {data.total} evaluated
</div>
</div>
{/* Progress bar */}
<div className="h-2 w-full rounded-full bg-gray-800 overflow-hidden">
<div className="flex h-full">
{data.total > 0 && (
<>
<div
className="bg-green-500 transition-all"
style={{ width: `${(data.triggered / data.total) * 100}%` }}
/>
<div
className="bg-red-500 transition-all"
style={{
width: `${((data.evaluated - data.triggered) / data.total) * 100}%`,
}}
/>
</>
)}
</div>
</div>
{/* Rules list */}
<div className="space-y-2">
{data.rules.map((rule) => {
const isExpanded = expandedRules.has(rule.id);
const notesDraft = editingNotes[rule.id] ?? rule.notes ?? "";
return (
<div
key={rule.id}
className="rounded-lg border border-gray-700 bg-gray-800/30 overflow-hidden"
>
{/* Rule header */}
<div className="flex items-center gap-3 p-3">
{/* Expand toggle */}
<button
onClick={() => toggleExpanded(rule.id)}
className="shrink-0 text-gray-500 hover:text-gray-300"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
{/* Status icon */}
{rule.triggered === true && <CheckCircle className="h-4 w-4 shrink-0 text-green-400" />}
{rule.triggered === false && <XCircle className="h-4 w-4 shrink-0 text-red-400" />}
{rule.triggered == null && <MinusCircle className="h-4 w-4 shrink-0 text-gray-500" />}
{/* Rule info */}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-200 truncate">{rule.title}</p>
</div>
{/* Badges */}
<div className="flex shrink-0 items-center gap-1.5">
{rule.severity && (
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
severityColors[rule.severity] || severityColors.informational
}`}
>
{rule.severity}
</span>
)}
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-medium ${
sourceColors[rule.source] || sourceColors.custom
}`}
>
{rule.source}
</span>
</div>
{/* Evaluate buttons */}
{canEdit && (
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => handleEvaluate(rule.id, true)}
disabled={evaluateMutation.isPending}
className={`rounded p-1 transition-colors ${
rule.triggered === true
? "bg-green-900/50 text-green-400"
: "text-gray-500 hover:bg-green-900/30 hover:text-green-400"
}`}
title="Triggered"
>
<CheckCircle className="h-4 w-4" />
</button>
<button
onClick={() => handleEvaluate(rule.id, false)}
disabled={evaluateMutation.isPending}
className={`rounded p-1 transition-colors ${
rule.triggered === false
? "bg-red-900/50 text-red-400"
: "text-gray-500 hover:bg-red-900/30 hover:text-red-400"
}`}
title="Not Triggered"
>
<XCircle className="h-4 w-4" />
</button>
<button
onClick={() => handleEvaluate(rule.id, null)}
disabled={evaluateMutation.isPending}
className={`rounded p-1 transition-colors ${
rule.triggered === null && rule.result_id
? "bg-gray-700 text-gray-300"
: "text-gray-500 hover:bg-gray-700 hover:text-gray-300"
}`}
title="Not Applicable"
>
<MinusCircle className="h-4 w-4" />
</button>
</div>
)}
</div>
{/* Expanded content */}
{isExpanded && (
<div className="border-t border-gray-700 p-3 space-y-3">
{rule.description && (
<p className="text-xs text-gray-400">{rule.description}</p>
)}
{/* Rule content */}
{rule.rule_content && (
<div>
<p className="mb-1 text-[10px] font-medium uppercase text-gray-500">
Rule Content ({rule.rule_format})
</p>
<pre className="max-h-48 overflow-auto rounded bg-gray-900 p-3 font-mono text-xs text-gray-300">
{rule.rule_content}
</pre>
</div>
)}
{/* Source link */}
{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"
>
View source
<ExternalLink className="h-3 w-3" />
</a>
)}
{/* Notes */}
{canEdit ? (
<div>
<label className="mb-1 block text-[10px] font-medium uppercase text-gray-500">
Notes
</label>
<div className="flex gap-2">
<input
value={notesDraft}
onChange={(e) => handleNotesChange(rule.id, e.target.value)}
placeholder="Add evaluation notes..."
className="flex-1 rounded border border-gray-700 bg-gray-900 px-2 py-1.5 text-xs text-gray-200 placeholder-gray-500 focus:border-indigo-500 focus:outline-none"
/>
<button
onClick={() => handleNotesSave(rule.id, rule.triggered)}
disabled={evaluateMutation.isPending}
className="shrink-0 rounded bg-indigo-600 px-2 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
>
Save
</button>
</div>
</div>
) : (
rule.notes && (
<div>
<p className="text-[10px] font-medium uppercase text-gray-500">Notes</p>
<p className="mt-0.5 text-xs text-gray-400">{rule.notes}</p>
</div>
)
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -24,6 +24,7 @@ import type {
} from "../../types/models";
import { RED_EDITABLE_STATES, BLUE_EDITABLE_STATES } from "../../types/models";
import { getDefensesForTechnique } from "../../api/d3fend";
import DetectionRuleChecklist from "./DetectionRuleChecklist";
import EvidenceUpload from "../EvidenceUpload";
import EvidenceList from "../EvidenceList";
@@ -337,6 +338,19 @@ export default function TeamTabs({
/>
</div>
{/* Detection Rule Checklist */}
<div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-medium text-gray-300">
<ShieldCheck className="h-4 w-4 text-indigo-400" />
Detection Rule Evaluation
</h3>
<DetectionRuleChecklist
testId={test.id}
user={user}
canEdit={canEditBlue}
/>
</div>
{/* Recommended Detection Approaches (D3FEND) */}
{d3fendData && d3fendData.defenses.length > 0 && (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-900/10 p-4">