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

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 { useNavigate } from "react-router-dom";
import {
@@ -8,18 +8,27 @@ import {
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",
review_required:"bg-orange-500/10 text-orange-400 border-orange-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",
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> = {
@@ -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() {
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -48,6 +338,8 @@ export default function ReviewQueuePage() {
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 }),
@@ -61,7 +353,6 @@ export default function ReviewQueuePage() {
},
});
// Group by tactic for a cleaner layout
const byTactic = useMemo(() => {
if (!techniques) return [];
const map = new Map<string, TechniqueSummary[]>();
@@ -101,7 +392,7 @@ export default function ReviewQueuePage() {
<div>
<h1 className="text-2xl font-bold text-white">Technique Review Queue</h1>
<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>
</div>
</div>
@@ -121,14 +412,21 @@ export default function ReviewQueuePage() {
</div>
</div>
{/* What does this mean? */}
{/* Explanation */}
{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">
<span className="font-semibold">What does this mean?</span>{" "}
The MITRE ATT&CK sync detected that these techniques were updated in the official
ATT&CK dataset. A lead or admin should review each one to confirm the change has
been acknowledged before marking it as reviewed.
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>
)}
@@ -147,78 +445,85 @@ export default function ReviewQueuePage() {
{/* Table grouped by tactic */}
{byTactic.map(([tactic, items]) => (
<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">
<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="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800 text-xs uppercase tracking-wider text-gray-500">
<th className="px-5 py-3 text-left">MITRE ID</th>
<th className="px-5 py-3 text-left">Name</th>
<th className="px-5 py-3 text-left">Coverage</th>
<th className="px-5 py-3 text-left">Action</th>
</tr>
</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"
<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)}
>
<td className="px-5 py-3">
<span className="font-mono text-xs font-semibold text-cyan-400">
{tech.mitre_id}
</span>
</td>
<td className="px-5 py-3 text-gray-200 max-w-xs">
<span className="line-clamp-1">{tech.name}</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>
{/* Expand chevron */}
<span className="shrink-0 text-gray-600">
{isExpanded
? <ChevronUp className="h-4 w-4" />
: <ChevronDown className="h-4 w-4" />
}
</span>
{/* Mark as reviewed — leads/admin only */}
{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>
</td>
</tr>
))}
</tbody>
</table>
{/* 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>
))}