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>
533 lines
21 KiB
TypeScript
533 lines
21 KiB
TypeScript
import { useState, useMemo } from "react";
|
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { useNavigate } from "react-router-dom";
|
|
import {
|
|
Loader2,
|
|
AlertCircle,
|
|
ClipboardCheck,
|
|
CheckCircle,
|
|
ExternalLink,
|
|
RefreshCw,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Rss,
|
|
BookOpen,
|
|
X,
|
|
Globe,
|
|
} from "lucide-react";
|
|
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 { useAuth } from "../context/AuthContext";
|
|
|
|
/* ── helpers ─────────────────────────────────────────────────────── */
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
validated: "bg-green-500/10 text-green-400 border-green-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",
|
|
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",
|
|
};
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
validated: "Validated",
|
|
partial: "Partial",
|
|
in_progress: "In Progress",
|
|
not_covered: "Not Covered",
|
|
not_evaluated: "Not Evaluated",
|
|
};
|
|
|
|
function formatDate(dateStr: string | null | undefined) {
|
|
if (!dateStr) return "—";
|
|
const utc = dateStr.endsWith("Z") || dateStr.includes("+") ? dateStr : dateStr + "Z";
|
|
return new Date(utc).toLocaleDateString("es-ES", {
|
|
year: "numeric", month: "short", day: "numeric",
|
|
});
|
|
}
|
|
|
|
/* ── 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() {
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { user } = useAuth();
|
|
|
|
const canReview =
|
|
user?.role === "admin" ||
|
|
user?.role === "red_lead" ||
|
|
user?.role === "blue_lead";
|
|
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
const { data: techniques, isLoading, error, refetch } = useQuery({
|
|
queryKey: ["techniques", "review-queue"],
|
|
queryFn: () => getTechniques({ review_required: true }),
|
|
});
|
|
|
|
const reviewMutation = useMutation({
|
|
mutationFn: (mitreId: string) => markTechniqueReviewed(mitreId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["techniques", "review-queue"] });
|
|
queryClient.invalidateQueries({ queryKey: ["techniques"] });
|
|
},
|
|
});
|
|
|
|
const byTactic = useMemo(() => {
|
|
if (!techniques) return [];
|
|
const map = new Map<string, TechniqueSummary[]>();
|
|
for (const t of techniques) {
|
|
const tactic = t.tactic || "Unknown";
|
|
if (!map.has(tactic)) map.set(tactic, []);
|
|
map.get(tactic)!.push(t);
|
|
}
|
|
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
|
}, [techniques]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin text-amber-400" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
|
<AlertCircle className="h-10 w-10 text-red-400" />
|
|
<p className="text-red-400">Failed to load review queue</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const total = techniques?.length ?? 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<ClipboardCheck className="h-7 w-7 text-amber-400" />
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Technique Review Queue</h1>
|
|
<p className="mt-0.5 text-sm text-gray-400">
|
|
Techniques flagged for review — click a row to see intel articles
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{total > 0 && (
|
|
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-sm font-medium text-amber-400">
|
|
{total} pending
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => refetch()}
|
|
className="flex items-center gap-1.5 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-400 hover:bg-gray-700 hover:text-white transition-colors"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Explanation */}
|
|
{total > 0 && (
|
|
<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">
|
|
<span className="font-semibold">What does this mean?</span>{" "}
|
|
Techniques appear here when:
|
|
</p>
|
|
<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>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty state */}
|
|
{total === 0 && (
|
|
<div className="rounded-xl border border-gray-800 bg-gray-900 p-16 text-center">
|
|
<CheckCircle className="mx-auto mb-3 h-12 w-12 text-green-500/50" />
|
|
<p className="text-lg font-medium text-white">All caught up!</p>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
No techniques are currently flagged for review.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Table grouped by tactic */}
|
|
{byTactic.map(([tactic, items]) => (
|
|
<div key={tactic} className="rounded-xl border border-gray-800 bg-gray-900">
|
|
<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>
|
|
<span className="text-xs text-gray-500">{items.length}</span>
|
|
</div>
|
|
|
|
<div className="divide-y divide-gray-800/50">
|
|
{items.map((tech) => {
|
|
const isExpanded = expandedId === tech.mitre_id;
|
|
return (
|
|
<div key={tech.mitre_id}>
|
|
{/* Main row */}
|
|
<div
|
|
className="flex items-center gap-3 px-5 py-3 cursor-pointer hover:bg-gray-800/30 transition-colors"
|
|
onClick={() => setExpandedId(isExpanded ? null : tech.mitre_id)}
|
|
>
|
|
{/* Expand chevron */}
|
|
<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}
|
|
</span>
|
|
|
|
{/* Name */}
|
|
<span className="flex-1 text-sm text-gray-200 line-clamp-1 min-w-0">
|
|
{tech.name}
|
|
</span>
|
|
|
|
{/* Coverage */}
|
|
<span
|
|
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_LABELS[tech.status_global] ?? tech.status_global}
|
|
</span>
|
|
|
|
{/* Actions — stop propagation so row click doesn't interfere */}
|
|
<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>
|
|
);
|
|
}
|