feat(intel): major intel scan improvements + Review Queue integration
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:
- intel_service: remove 50-technique limit (scan all techniques), improve
pattern matching with word boundaries (\bT1059\b), raise min name length
to 8 chars to reduce false positives, skip entries with empty titles
- technique_query_service: add intel_items to get_technique_detail() so
the technique page now shows recent threat intel articles (last 20)
- New GET /intel/items endpoint with optional technique_id filter
Frontend:
- New api/intel.ts with listIntelItems()
- ReviewQueuePage: complete redesign
* Expandable rows — click a technique to see its intel articles inline
* IntelPanel component fetches articles per technique on expand
* 'Create Template from Intel' button opens pre-filled modal:
name (from article title), source_url (article link), technique_id
User reads the article and fills the attack procedure
* Updated explanation text: lists all 3 reasons a technique can be flagged
(MITRE update / intel scan / new template or detection rule)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ from app.routers import advanced_metrics as advanced_metrics_router
|
|||||||
from app.routers import osint as osint_router
|
from app.routers import osint as osint_router
|
||||||
from app.routers import webhooks as webhooks_router
|
from app.routers import webhooks as webhooks_router
|
||||||
from app.routers import detection_lifecycle as detection_lifecycle_router
|
from app.routers import detection_lifecycle as detection_lifecycle_router
|
||||||
|
from app.routers import intel as intel_router
|
||||||
from app.routers import ownership as ownership_router
|
from app.routers import ownership as ownership_router
|
||||||
from app.routers import attack_paths as attack_paths_router
|
from app.routers import attack_paths as attack_paths_router
|
||||||
from app.routers import knowledge as knowledge_router
|
from app.routers import knowledge as knowledge_router
|
||||||
@@ -145,6 +146,7 @@ app.include_router(heatmap_router.router, prefix="/api/v1")
|
|||||||
app.include_router(scores_router.router, prefix="/api/v1")
|
app.include_router(scores_router.router, prefix="/api/v1")
|
||||||
app.include_router(operational_metrics_router.router, prefix="/api/v1")
|
app.include_router(operational_metrics_router.router, prefix="/api/v1")
|
||||||
app.include_router(compliance_router.router, prefix="/api/v1")
|
app.include_router(compliance_router.router, prefix="/api/v1")
|
||||||
|
app.include_router(intel_router.router, prefix="/api/v1")
|
||||||
app.include_router(snapshots_router.router, prefix="/api/v1")
|
app.include_router(snapshots_router.router, prefix="/api/v1")
|
||||||
app.include_router(jira_router.router, prefix="/api/v1")
|
app.include_router(jira_router.router, prefix="/api/v1")
|
||||||
app.include_router(worklogs_router.router, prefix="/api/v1")
|
app.include_router(worklogs_router.router, prefix="/api/v1")
|
||||||
|
|||||||
54
backend/app/routers/intel.py
Normal file
54
backend/app/routers/intel.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
"""Intel items endpoints — list and manage threat intelligence items."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies.auth import get_current_user
|
||||||
|
from app.models.intel import IntelItem
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/intel", tags=["intel"])
|
||||||
|
|
||||||
|
|
||||||
|
class IntelItemOut(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
technique_id: Optional[uuid.UUID] = None
|
||||||
|
url: str
|
||||||
|
title: Optional[str] = None
|
||||||
|
source: Optional[str] = None
|
||||||
|
detected_at: Optional[str] = None
|
||||||
|
reviewed: bool
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items", response_model=list[IntelItemOut])
|
||||||
|
def list_intel_items(
|
||||||
|
technique_id: Optional[uuid.UUID] = Query(None, description="Filter by technique"),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
"""List threat intelligence items, optionally filtered by technique."""
|
||||||
|
query = db.query(IntelItem).order_by(IntelItem.detected_at.desc())
|
||||||
|
if technique_id:
|
||||||
|
query = query.filter(IntelItem.technique_id == technique_id)
|
||||||
|
items = query.limit(limit).all()
|
||||||
|
return [
|
||||||
|
IntelItemOut(
|
||||||
|
id=item.id,
|
||||||
|
technique_id=item.technique_id,
|
||||||
|
url=item.url,
|
||||||
|
title=item.title,
|
||||||
|
source=item.source,
|
||||||
|
detected_at=item.detected_at.isoformat() if item.detected_at else None,
|
||||||
|
reviewed=item.reviewed,
|
||||||
|
)
|
||||||
|
for item in items
|
||||||
|
]
|
||||||
@@ -57,8 +57,9 @@ RSS_FEEDS: list[dict[str, str]] = [
|
|||||||
# Timeout for each feed request (seconds)
|
# Timeout for each feed request (seconds)
|
||||||
_FEED_TIMEOUT = 15
|
_FEED_TIMEOUT = 15
|
||||||
|
|
||||||
# Maximum number of techniques to scan (to keep MVP fast)
|
# Minimum technique name length for name-based matching
|
||||||
_MAX_TECHNIQUES = 50
|
# Short names ("Kill", "BITS") produce too many false positives
|
||||||
|
_MIN_NAME_LEN = 8
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -118,19 +119,36 @@ def _fetch_feed(url: str) -> list[dict[str, str]]:
|
|||||||
return entries
|
return entries
|
||||||
|
|
||||||
|
|
||||||
def _build_patterns(technique: Technique) -> list[re.Pattern]:
|
def _build_patterns(technique: Technique) -> tuple[list[re.Pattern], list[re.Pattern]]:
|
||||||
"""Build regex patterns to search feed content for a given technique."""
|
"""Build regex patterns for a technique.
|
||||||
patterns: list[re.Pattern] = []
|
|
||||||
|
|
||||||
mitre_id = re.escape(technique.mitre_id)
|
Returns two lists:
|
||||||
patterns.append(re.compile(mitre_id, re.IGNORECASE))
|
- ``id_patterns``: MITRE ID patterns (high confidence, word-boundary matched)
|
||||||
|
- ``name_patterns``: technique name patterns (lower confidence, long names only)
|
||||||
|
"""
|
||||||
|
id_patterns: list[re.Pattern] = []
|
||||||
|
name_patterns: list[re.Pattern] = []
|
||||||
|
|
||||||
# Technique name — match if the full name appears
|
# MITRE ID with word boundaries so T1059 doesn't partially match T1059.001
|
||||||
if technique.name and len(technique.name) > 4:
|
mitre_id_escaped = re.escape(technique.mitre_id)
|
||||||
|
id_patterns.append(re.compile(rf"\b{mitre_id_escaped}\b", re.IGNORECASE))
|
||||||
|
|
||||||
|
# Technique name — only for distinctly long names to reduce false positives
|
||||||
|
if technique.name and len(technique.name) >= _MIN_NAME_LEN:
|
||||||
name_escaped = re.escape(technique.name)
|
name_escaped = re.escape(technique.name)
|
||||||
patterns.append(re.compile(name_escaped, re.IGNORECASE))
|
name_patterns.append(re.compile(rf"\b{name_escaped}\b", re.IGNORECASE))
|
||||||
|
|
||||||
return patterns
|
return id_patterns, name_patterns
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_matches(
|
||||||
|
entry: dict[str, str],
|
||||||
|
id_patterns: list[re.Pattern],
|
||||||
|
name_patterns: list[re.Pattern],
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if any pattern matches the entry's title or description."""
|
||||||
|
text = f"{entry.get('title', '')} {entry.get('description', '')}"
|
||||||
|
return any(p.search(text) for p in id_patterns + name_patterns)
|
||||||
|
|
||||||
|
|
||||||
def _entry_matches(entry: dict[str, str], patterns: list[re.Pattern]) -> bool:
|
def _entry_matches(entry: dict[str, str], patterns: list[re.Pattern]) -> bool:
|
||||||
@@ -160,11 +178,10 @@ def scan_intel(db: Session) -> dict:
|
|||||||
"""
|
"""
|
||||||
logger.info("Intel scan starting...")
|
logger.info("Intel scan starting...")
|
||||||
|
|
||||||
# 1. Load techniques (limit for MVP speed)
|
# 1. Load all active techniques
|
||||||
techniques = (
|
techniques = (
|
||||||
db.query(Technique)
|
db.query(Technique)
|
||||||
.order_by(Technique.mitre_id)
|
.order_by(Technique.mitre_id)
|
||||||
.limit(_MAX_TECHNIQUES)
|
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
logger.info("Scanning %d techniques against %d feeds", len(techniques), len(RSS_FEEDS))
|
logger.info("Scanning %d techniques against %d feeds", len(techniques), len(RSS_FEEDS))
|
||||||
@@ -192,10 +209,14 @@ def scan_intel(db: Session) -> dict:
|
|||||||
techniques_flagged: set[str] = set()
|
techniques_flagged: set[str] = set()
|
||||||
|
|
||||||
for technique in techniques:
|
for technique in techniques:
|
||||||
patterns = _build_patterns(technique)
|
id_patterns, name_patterns = _build_patterns(technique)
|
||||||
|
|
||||||
for feed_name, entry in all_entries:
|
for feed_name, entry in all_entries:
|
||||||
if not _entry_matches(entry, patterns):
|
if not _entry_matches(entry, id_patterns, name_patterns):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip entries with no title (low-quality)
|
||||||
|
if not entry.get("title", "").strip():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
url = entry.get("link", "").strip()
|
url = entry.get("link", "").strip()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session, joinedload
|
|||||||
from app.domain.errors import EntityNotFoundError
|
from app.domain.errors import EntityNotFoundError
|
||||||
from app.models.technique import Technique
|
from app.models.technique import Technique
|
||||||
from app.models.detection_rule import DetectionRule
|
from app.models.detection_rule import DetectionRule
|
||||||
|
from app.models.intel import IntelItem
|
||||||
from app.services.d3fend_import_service import get_defenses_for_technique
|
from app.services.d3fend_import_service import get_defenses_for_technique
|
||||||
|
|
||||||
# Severity sort order for detection rules (most critical first)
|
# Severity sort order for detection rules (most critical first)
|
||||||
@@ -25,6 +26,15 @@ def get_technique_detail(db: Session, mitre_id: str) -> dict:
|
|||||||
|
|
||||||
defenses = get_defenses_for_technique(db, technique.id)
|
defenses = get_defenses_for_technique(db, technique.id)
|
||||||
|
|
||||||
|
# Recent intel items for this technique (newest 20)
|
||||||
|
intel_items = (
|
||||||
|
db.query(IntelItem)
|
||||||
|
.filter(IntelItem.technique_id == technique.id)
|
||||||
|
.order_by(IntelItem.detected_at.desc())
|
||||||
|
.limit(20)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
detection_rules = (
|
detection_rules = (
|
||||||
db.query(DetectionRule)
|
db.query(DetectionRule)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -79,4 +89,15 @@ def get_technique_detail(db: Session, mitre_id: str) -> dict:
|
|||||||
for r in detection_rules
|
for r in detection_rules
|
||||||
],
|
],
|
||||||
"d3fend_defenses": defenses,
|
"d3fend_defenses": defenses,
|
||||||
|
"intel_items": [
|
||||||
|
{
|
||||||
|
"id": str(item.id),
|
||||||
|
"url": item.url,
|
||||||
|
"title": item.title,
|
||||||
|
"source": item.source,
|
||||||
|
"detected_at": item.detected_at.isoformat() if item.detected_at else None,
|
||||||
|
"reviewed": item.reviewed,
|
||||||
|
}
|
||||||
|
for item in intel_items
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
22
frontend/src/api/intel.ts
Normal file
22
frontend/src/api/intel.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import client from "./client";
|
||||||
|
|
||||||
|
export interface IntelItem {
|
||||||
|
id: string;
|
||||||
|
technique_id: string | null;
|
||||||
|
url: string;
|
||||||
|
title: string | null;
|
||||||
|
source: string | null;
|
||||||
|
detected_at: string | null;
|
||||||
|
reviewed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch intel items, optionally filtered by technique UUID. */
|
||||||
|
export async function listIntelItems(
|
||||||
|
techniqueId?: string,
|
||||||
|
limit = 50,
|
||||||
|
): Promise<IntelItem[]> {
|
||||||
|
const params: Record<string, string | number> = { limit };
|
||||||
|
if (techniqueId) params.technique_id = techniqueId;
|
||||||
|
const { data } = await client.get<IntelItem[]>("/intel/items", { params });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
@@ -8,18 +8,27 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
Rss,
|
||||||
|
BookOpen,
|
||||||
|
X,
|
||||||
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getTechniques, markTechniqueReviewed } from "../api/techniques";
|
import { getTechniques, markTechniqueReviewed } from "../api/techniques";
|
||||||
|
import { listIntelItems, type IntelItem } from "../api/intel";
|
||||||
|
import { createTemplate } from "../api/test-templates";
|
||||||
import type { TechniqueSummary } from "../api/techniques";
|
import type { TechniqueSummary } from "../api/techniques";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
validated: "bg-green-500/10 text-green-400 border-green-500/20",
|
validated: "bg-green-500/10 text-green-400 border-green-500/20",
|
||||||
partial: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
partial: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||||
in_progress: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
in_progress: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||||
not_covered: "bg-red-500/10 text-red-400 border-red-500/20",
|
not_covered: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||||
not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-600/20",
|
not_evaluated: "bg-gray-500/10 text-gray-400 border-gray-600/20",
|
||||||
review_required:"bg-orange-500/10 text-orange-400 border-orange-500/20",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABELS: Record<string, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
@@ -38,6 +47,287 @@ function formatDate(dateStr: string | null | undefined) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Create-template-from-intel modal ─────────────────────────────── */
|
||||||
|
|
||||||
|
function IntelTemplateModal({
|
||||||
|
intel,
|
||||||
|
techniqueMitreId,
|
||||||
|
techniqueName,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
intel: IntelItem;
|
||||||
|
techniqueMitreId: string;
|
||||||
|
techniqueName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState(intel.title ?? `Test for ${techniqueMitreId}`);
|
||||||
|
const [description, setDescription] = useState(
|
||||||
|
intel.title ? `Based on: ${intel.title}` : ""
|
||||||
|
);
|
||||||
|
const [platform, setPlatform] = useState("");
|
||||||
|
const [attackProcedure, setAttackProcedure] = useState("");
|
||||||
|
const [expectedDetection, setExpectedDetection] = useState("");
|
||||||
|
const [severity, setSeverity] = useState("medium");
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createTemplate({
|
||||||
|
mitre_technique_id: techniqueMitreId,
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
platform: platform.trim() || undefined,
|
||||||
|
attack_procedure: attackProcedure.trim() || undefined,
|
||||||
|
expected_detection: expectedDetection.trim() || undefined,
|
||||||
|
source_url: intel.url,
|
||||||
|
severity,
|
||||||
|
source: "custom",
|
||||||
|
}),
|
||||||
|
onSuccess: () => onSaved(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="max-h-[92vh] w-full max-w-2xl overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-gray-800 bg-gray-900 px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BookOpen className="h-5 w-5 text-cyan-400" />
|
||||||
|
<h3 className="text-lg font-semibold text-white">Create Template from Intel</h3>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="rounded p-1 text-gray-400 hover:bg-gray-800 hover:text-white">
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-5">
|
||||||
|
{/* Source article banner */}
|
||||||
|
<div className="flex items-start gap-3 rounded-lg border border-indigo-500/20 bg-indigo-900/10 px-4 py-3">
|
||||||
|
<Rss className="mt-0.5 h-4 w-4 shrink-0 text-indigo-400" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-indigo-300">
|
||||||
|
Source: {intel.source ?? "Unknown"} · {formatDate(intel.detected_at)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm text-gray-300 line-clamp-2">{intel.title}</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={intel.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="shrink-0 flex items-center gap-1 rounded border border-indigo-500/30 px-2 py-1 text-[10px] text-indigo-400 hover:bg-indigo-900/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
Open article
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Technique banner */}
|
||||||
|
<div className="rounded-lg border border-cyan-500/20 bg-cyan-900/10 px-4 py-2.5 text-xs text-cyan-400">
|
||||||
|
Technique: <span className="font-mono font-semibold">{techniqueMitreId}</span>
|
||||||
|
<span className="ml-2 text-gray-400">{techniqueName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Template Name <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform + Severity */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Platform</label>
|
||||||
|
<input
|
||||||
|
value={platform}
|
||||||
|
onChange={(e) => setPlatform(e.target.value)}
|
||||||
|
placeholder="windows, linux, macos…"
|
||||||
|
className="w-full 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Severity</label>
|
||||||
|
<select
|
||||||
|
value={severity}
|
||||||
|
onChange={(e) => setSeverity(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full 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"
|
||||||
|
placeholder="Describe what this template tests…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attack Procedure */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Attack Procedure
|
||||||
|
<span className="ml-2 text-xs text-gray-500">(read the article and fill this in)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={attackProcedure}
|
||||||
|
onChange={(e) => setAttackProcedure(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="w-full rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 font-mono text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
|
||||||
|
placeholder="Steps to reproduce the attack described in the article…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expected Detection */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-300">
|
||||||
|
Expected Detection
|
||||||
|
<span className="ml-2 text-xs text-gray-500">(Blue Team reference)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={expectedDetection}
|
||||||
|
onChange={(e) => setExpectedDetection(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="w-full 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"
|
||||||
|
placeholder="What alerts / logs should Blue Team look for?"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveMutation.isError && (
|
||||||
|
<div className="rounded-lg border border-red-500/30 bg-red-900/20 p-3 text-sm text-red-400">
|
||||||
|
{(saveMutation.error as Error)?.message || "Failed to create template"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="sticky bottom-0 flex justify-end gap-3 border-t border-gray-800 bg-gray-900 px-6 py-4">
|
||||||
|
<button onClick={onClose} className="rounded-lg border border-gray-700 px-4 py-2 text-sm text-gray-400 hover:bg-gray-800">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => saveMutation.mutate()}
|
||||||
|
disabled={!name.trim() || saveMutation.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 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <BookOpen className="h-4 w-4" />}
|
||||||
|
Save Template
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Intel items panel (expanded row) ────────────────────────────── */
|
||||||
|
|
||||||
|
function IntelPanel({
|
||||||
|
tech,
|
||||||
|
canReview,
|
||||||
|
}: {
|
||||||
|
tech: TechniqueSummary;
|
||||||
|
canReview: boolean;
|
||||||
|
}) {
|
||||||
|
const [createFromIntel, setCreateFromIntel] = useState<IntelItem | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: items = [], isLoading } = useQuery({
|
||||||
|
queryKey: ["intel-items", tech.id],
|
||||||
|
queryFn: () => listIntelItems(tech.id),
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTemplateSaved = () => {
|
||||||
|
setCreateFromIntel(null);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["intel-items", tech.id] });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 px-5 py-3 text-xs text-gray-500">
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Loading intel items…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-3 text-xs text-gray-500">
|
||||||
|
No intel items found for this technique.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="divide-y divide-gray-800/50 border-t border-gray-800">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="flex items-start gap-3 px-5 py-3 bg-gray-950/30 hover:bg-gray-800/20 transition-colors">
|
||||||
|
<Rss className="mt-0.5 h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-200 line-clamp-1">
|
||||||
|
{item.title ?? item.url}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">
|
||||||
|
{item.source && <span className="text-indigo-400/70">{item.source} · </span>}
|
||||||
|
{formatDate(item.detected_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
<a
|
||||||
|
href={item.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 rounded border border-gray-700 px-2 py-1 text-[10px] text-gray-400 hover:border-gray-600 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
Article
|
||||||
|
</a>
|
||||||
|
{canReview && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateFromIntel(item)}
|
||||||
|
className="flex items-center gap-1 rounded border border-cyan-500/30 bg-cyan-500/10 px-2 py-1 text-[10px] text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-3 w-3" />
|
||||||
|
Create Template
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createFromIntel && (
|
||||||
|
<IntelTemplateModal
|
||||||
|
intel={createFromIntel}
|
||||||
|
techniqueMitreId={tech.mitre_id}
|
||||||
|
techniqueName={tech.name}
|
||||||
|
onClose={() => setCreateFromIntel(null)}
|
||||||
|
onSaved={handleTemplateSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main page ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export default function ReviewQueuePage() {
|
export default function ReviewQueuePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -48,6 +338,8 @@ export default function ReviewQueuePage() {
|
|||||||
user?.role === "red_lead" ||
|
user?.role === "red_lead" ||
|
||||||
user?.role === "blue_lead";
|
user?.role === "blue_lead";
|
||||||
|
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const { data: techniques, isLoading, error, refetch } = useQuery({
|
const { data: techniques, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ["techniques", "review-queue"],
|
queryKey: ["techniques", "review-queue"],
|
||||||
queryFn: () => getTechniques({ review_required: true }),
|
queryFn: () => getTechniques({ review_required: true }),
|
||||||
@@ -61,7 +353,6 @@ export default function ReviewQueuePage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Group by tactic for a cleaner layout
|
|
||||||
const byTactic = useMemo(() => {
|
const byTactic = useMemo(() => {
|
||||||
if (!techniques) return [];
|
if (!techniques) return [];
|
||||||
const map = new Map<string, TechniqueSummary[]>();
|
const map = new Map<string, TechniqueSummary[]>();
|
||||||
@@ -101,7 +392,7 @@ export default function ReviewQueuePage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-white">Technique Review Queue</h1>
|
<h1 className="text-2xl font-bold text-white">Technique Review Queue</h1>
|
||||||
<p className="mt-0.5 text-sm text-gray-400">
|
<p className="mt-0.5 text-sm text-gray-400">
|
||||||
Techniques updated in MITRE ATT&CK that need to be reviewed
|
Techniques flagged for review — click a row to see intel articles
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,14 +412,21 @@ export default function ReviewQueuePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* What does this mean? */}
|
{/* Explanation */}
|
||||||
{total > 0 && (
|
{total > 0 && (
|
||||||
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4">
|
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 p-4 space-y-2">
|
||||||
<p className="text-sm text-amber-300">
|
<p className="text-sm text-amber-300">
|
||||||
<span className="font-semibold">What does this mean?</span>{" "}
|
<span className="font-semibold">What does this mean?</span>{" "}
|
||||||
The MITRE ATT&CK sync detected that these techniques were updated in the official
|
Techniques appear here when:
|
||||||
ATT&CK dataset. A lead or admin should review each one to confirm the change has
|
</p>
|
||||||
been acknowledged before marking it as reviewed.
|
<ul className="ml-4 list-disc space-y-1 text-xs text-amber-400/80">
|
||||||
|
<li>The MITRE ATT&CK sync detected an update to the technique</li>
|
||||||
|
<li>The Threat Intel Scan found a new article about this technique in a security feed</li>
|
||||||
|
<li>A new detection rule or test template was added for this technique</li>
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-amber-400/70">
|
||||||
|
Expand a row to see the intel articles. If relevant, create a test template from the article.
|
||||||
|
Once reviewed, click <span className="font-semibold">Mark Reviewed</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -147,48 +445,53 @@ export default function ReviewQueuePage() {
|
|||||||
{/* Table grouped by tactic */}
|
{/* Table grouped by tactic */}
|
||||||
{byTactic.map(([tactic, items]) => (
|
{byTactic.map(([tactic, items]) => (
|
||||||
<div key={tactic} className="rounded-xl border border-gray-800 bg-gray-900">
|
<div key={tactic} className="rounded-xl border border-gray-800 bg-gray-900">
|
||||||
{/* Tactic header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-3">
|
<div className="flex items-center justify-between border-b border-gray-800 px-5 py-3">
|
||||||
<h2 className="text-sm font-semibold capitalize text-gray-300">{tactic}</h2>
|
<h2 className="text-sm font-semibold capitalize text-gray-300">{tactic}</h2>
|
||||||
<span className="text-xs text-gray-500">{items.length}</span>
|
<span className="text-xs text-gray-500">{items.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="divide-y divide-gray-800/50">
|
||||||
<table className="w-full text-sm">
|
{items.map((tech) => {
|
||||||
<thead>
|
const isExpanded = expandedId === tech.mitre_id;
|
||||||
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
|
return (
|
||||||
<th className="px-5 py-3 text-left">MITRE ID</th>
|
<div key={tech.mitre_id}>
|
||||||
<th className="px-5 py-3 text-left">Name</th>
|
{/* Main row */}
|
||||||
<th className="px-5 py-3 text-left">Coverage</th>
|
<div
|
||||||
<th className="px-5 py-3 text-left">Action</th>
|
className="flex items-center gap-3 px-5 py-3 cursor-pointer hover:bg-gray-800/30 transition-colors"
|
||||||
</tr>
|
onClick={() => setExpandedId(isExpanded ? null : tech.mitre_id)}
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-gray-800/50">
|
|
||||||
{items.map((tech) => (
|
|
||||||
<tr
|
|
||||||
key={tech.mitre_id}
|
|
||||||
className="hover:bg-gray-800/30 transition-colors"
|
|
||||||
>
|
>
|
||||||
<td className="px-5 py-3">
|
{/* Expand chevron */}
|
||||||
<span className="font-mono text-xs font-semibold text-cyan-400">
|
<span className="shrink-0 text-gray-600">
|
||||||
|
{isExpanded
|
||||||
|
? <ChevronUp className="h-4 w-4" />
|
||||||
|
: <ChevronDown className="h-4 w-4" />
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* MITRE ID */}
|
||||||
|
<span className="w-20 shrink-0 font-mono text-xs font-semibold text-cyan-400">
|
||||||
{tech.mitre_id}
|
{tech.mitre_id}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3 text-gray-200 max-w-xs">
|
{/* Name */}
|
||||||
<span className="line-clamp-1">{tech.name}</span>
|
<span className="flex-1 text-sm text-gray-200 line-clamp-1 min-w-0">
|
||||||
</td>
|
{tech.name}
|
||||||
<td className="px-5 py-3">
|
</span>
|
||||||
|
|
||||||
|
{/* Coverage */}
|
||||||
<span
|
<span
|
||||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
className={`shrink-0 inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||||
STATUS_COLORS[tech.status_global] ?? STATUS_COLORS.not_evaluated
|
STATUS_COLORS[tech.status_global] ?? STATUS_COLORS.not_evaluated
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{STATUS_LABELS[tech.status_global] ?? tech.status_global}
|
{STATUS_LABELS[tech.status_global] ?? tech.status_global}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3">
|
{/* Actions — stop propagation so row click doesn't interfere */}
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
{/* View detail */}
|
className="flex shrink-0 items-center gap-1.5"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
|
onClick={() => navigate(`/techniques/${tech.mitre_id}`)}
|
||||||
className="flex items-center gap-1 rounded-lg border border-gray-700 px-2.5 py-1.5 text-xs text-gray-400 hover:border-gray-600 hover:text-white transition-colors"
|
className="flex items-center gap-1 rounded-lg border border-gray-700 px-2.5 py-1.5 text-xs text-gray-400 hover:border-gray-600 hover:text-white transition-colors"
|
||||||
@@ -197,8 +500,6 @@ export default function ReviewQueuePage() {
|
|||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
View
|
View
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Mark as reviewed — leads/admin only */}
|
|
||||||
{canReview && (
|
{canReview && (
|
||||||
<button
|
<button
|
||||||
onClick={() => reviewMutation.mutate(tech.mitre_id)}
|
onClick={() => reviewMutation.mutate(tech.mitre_id)}
|
||||||
@@ -214,11 +515,15 @@ export default function ReviewQueuePage() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
|
||||||
))}
|
{/* Expanded intel panel */}
|
||||||
</tbody>
|
{isExpanded && (
|
||||||
</table>
|
<IntelPanel tech={tech} canReview={canReview} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user