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:
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 { 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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user