feat(phase-25): add detection rule associations, checklist UI and evaluation workflow (T-215, T-216)
This commit is contained in:
@@ -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')
|
||||||
@@ -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 data_sources as data_sources_router
|
||||||
from app.routers import threat_actors as threat_actors_router
|
from app.routers import threat_actors as threat_actors_router
|
||||||
from app.routers import d3fend as d3fend_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.storage import ensure_bucket_exists
|
||||||
from app.jobs.mitre_sync_job import start_scheduler, scheduler
|
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(data_sources_router.router, prefix="/api/v1")
|
||||||
app.include_router(threat_actors_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(d3fend_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(detection_rules_router.router, prefix="/api/v1")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from app.models.data_source import DataSource
|
|||||||
from app.models.detection_rule import DetectionRule
|
from app.models.detection_rule import DetectionRule
|
||||||
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
from app.models.threat_actor import ThreatActor, ThreatActorTechnique
|
||||||
from app.models.defensive_technique import DefensiveTechnique, DefensiveTechniqueMapping
|
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
|
from app.models.enums import TechniqueStatus, TestState, TestResult, TeamSide
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -18,5 +20,6 @@ __all__ = [
|
|||||||
"IntelItem", "AuditLog", "Notification", "DataSource",
|
"IntelItem", "AuditLog", "Notification", "DataSource",
|
||||||
"DetectionRule", "ThreatActor", "ThreatActorTechnique",
|
"DetectionRule", "ThreatActor", "ThreatActorTechnique",
|
||||||
"DefensiveTechnique", "DefensiveTechniqueMapping",
|
"DefensiveTechnique", "DefensiveTechniqueMapping",
|
||||||
|
"TestTemplateDetectionRule", "TestDetectionResult",
|
||||||
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
"TechniqueStatus", "TestState", "TestResult", "TeamSide",
|
||||||
]
|
]
|
||||||
|
|||||||
55
backend/app/models/test_detection_result.py
Normal file
55
backend/app/models/test_detection_result.py
Normal 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'),
|
||||||
|
)
|
||||||
50
backend/app/models/test_template_detection_rule.py
Normal file
50
backend/app/models/test_template_detection_rule.py
Normal 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',
|
||||||
|
),
|
||||||
|
)
|
||||||
370
backend/app/routers/detection_rules.py
Normal file
370
backend/app/routers/detection_rules.py
Normal 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,
|
||||||
|
}
|
||||||
89
frontend/src/api/detection-rules.ts
Normal file
89
frontend/src/api/detection-rules.ts
Normal 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;
|
||||||
|
}
|
||||||
321
frontend/src/components/test-detail/DetectionRuleChecklist.tsx
Normal file
321
frontend/src/components/test-detail/DetectionRuleChecklist.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
} from "../../types/models";
|
} from "../../types/models";
|
||||||
import { RED_EDITABLE_STATES, BLUE_EDITABLE_STATES } from "../../types/models";
|
import { RED_EDITABLE_STATES, BLUE_EDITABLE_STATES } from "../../types/models";
|
||||||
import { getDefensesForTechnique } from "../../api/d3fend";
|
import { getDefensesForTechnique } from "../../api/d3fend";
|
||||||
|
import DetectionRuleChecklist from "./DetectionRuleChecklist";
|
||||||
import EvidenceUpload from "../EvidenceUpload";
|
import EvidenceUpload from "../EvidenceUpload";
|
||||||
import EvidenceList from "../EvidenceList";
|
import EvidenceList from "../EvidenceList";
|
||||||
|
|
||||||
@@ -337,6 +338,19 @@ export default function TeamTabs({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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) */}
|
{/* Recommended Detection Approaches (D3FEND) */}
|
||||||
{d3fendData && d3fendData.defenses.length > 0 && (
|
{d3fendData && d3fendData.defenses.length > 0 && (
|
||||||
<div className="rounded-lg border border-emerald-500/20 bg-emerald-900/10 p-4">
|
<div className="rounded-lg border border-emerald-500/20 bg-emerald-900/10 p-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user