feat(intel): major intel scan improvements + Review Queue integration
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:
kitos
2026-05-29 16:04:30 +02:00
parent 07c6164ceb
commit b39a4fec14
6 changed files with 519 additions and 94 deletions

View File

@@ -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")

View 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
]

View File

@@ -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()

View File

@@ -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
View 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;
}

View File

@@ -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,78 +445,85 @@ 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">
{tech.mitre_id} {isExpanded
</span> ? <ChevronUp className="h-4 w-4" />
</td> : <ChevronDown className="h-4 w-4" />
<td className="px-5 py-3 text-gray-200 max-w-xs"> }
<span className="line-clamp-1">{tech.name}</span> </span>
</td>
<td className="px-5 py-3">
<span
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
STATUS_COLORS[tech.status_global] ?? STATUS_COLORS.not_evaluated
}`}
>
{STATUS_LABELS[tech.status_global] ?? tech.status_global}
</span>
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
{/* View detail */}
<button
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"
title="Open technique"
>
<ExternalLink className="h-3 w-3" />
View
</button>
{/* Mark as reviewed — leads/admin only */} {/* MITRE ID */}
{canReview && ( <span className="w-20 shrink-0 font-mono text-xs font-semibold text-cyan-400">
<button {tech.mitre_id}
onClick={() => reviewMutation.mutate(tech.mitre_id)} </span>
disabled={reviewMutation.isPending && reviewMutation.variables === tech.mitre_id}
className="flex items-center gap-1 rounded-lg bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors" {/* Name */}
> <span className="flex-1 text-sm text-gray-200 line-clamp-1 min-w-0">
{reviewMutation.isPending && reviewMutation.variables === tech.mitre_id ? ( {tech.name}
<Loader2 className="h-3 w-3 animate-spin" /> </span>
) : (
<CheckCircle className="h-3 w-3" /> {/* Coverage */}
)} <span
Mark Reviewed className={`shrink-0 inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
</button> STATUS_COLORS[tech.status_global] ?? STATUS_COLORS.not_evaluated
)} }`}
</div> >
</td> {STATUS_LABELS[tech.status_global] ?? tech.status_global}
</tr> </span>
))}
</tbody> {/* Actions — stop propagation so row click doesn't interfere */}
</table> <div
className="flex shrink-0 items-center gap-1.5"
onClick={(e) => e.stopPropagation()}
>
<button
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"
title="Open technique"
>
<ExternalLink className="h-3 w-3" />
View
</button>
{canReview && (
<button
onClick={() => reviewMutation.mutate(tech.mitre_id)}
disabled={reviewMutation.isPending && reviewMutation.variables === tech.mitre_id}
className="flex items-center gap-1 rounded-lg bg-amber-600 px-2.5 py-1.5 text-xs font-medium text-white hover:bg-amber-500 disabled:opacity-50 transition-colors"
>
{reviewMutation.isPending && reviewMutation.variables === tech.mitre_id ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<CheckCircle className="h-3 w-3" />
)}
Mark Reviewed
</button>
)}
</div>
</div>
{/* Expanded intel panel */}
{isExpanded && (
<IntelPanel tech={tech} canReview={canReview} />
)}
</div>
);
})}
</div> </div>
</div> </div>
))} ))}