feat(frontend): render markdown in description and summary fields
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
- New shared MarkdownText component (react-markdown + remark-gfm) that renders links, bold, italic, lists, code, blockquotes. External links open in a new tab with rel=noopener. - Applied to: technique description, threat actor description, test description, campaign description, detection rule descriptions, D3FEND defense descriptions, red/blue summaries and validation notes. - procedure_text (code/commands) stays in <pre> — not processed as MD. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,8 +15,10 @@
|
|||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
"react-router-dom": "^7.13.0",
|
"react-router-dom": "^7.13.0",
|
||||||
"recharts": "^2.15.4"
|
"recharts": "^2.15.4",
|
||||||
|
"remark-gfm": "^4.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
|||||||
108
frontend/src/components/MarkdownText.tsx
Normal file
108
frontend/src/components/MarkdownText.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* MarkdownText — renders a markdown string as styled HTML.
|
||||||
|
*
|
||||||
|
* Uses react-markdown + remark-gfm (tables, strikethrough, etc.).
|
||||||
|
* External links always open in a new tab with rel="noopener noreferrer".
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* <MarkdownText content={technique.description} />
|
||||||
|
* <MarkdownText content={test.red_summary} className="text-sm text-gray-400" />
|
||||||
|
*/
|
||||||
|
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import type { Components } from "react-markdown";
|
||||||
|
|
||||||
|
interface MarkdownTextProps {
|
||||||
|
content: string | null | undefined;
|
||||||
|
/** Extra classes on the wrapper div */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const components: Components = {
|
||||||
|
// Links: always open external URLs in a new tab
|
||||||
|
a: ({ href, children, ...props }) => (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-cyan-400 underline underline-offset-2 hover:text-cyan-300 transition-colors"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
// Paragraphs: keep existing text colour, add spacing
|
||||||
|
p: ({ children }) => (
|
||||||
|
<p className="mb-2 last:mb-0 leading-relaxed">{children}</p>
|
||||||
|
),
|
||||||
|
// Bold
|
||||||
|
strong: ({ children }) => (
|
||||||
|
<strong className="font-semibold text-gray-100">{children}</strong>
|
||||||
|
),
|
||||||
|
// Italic
|
||||||
|
em: ({ children }) => (
|
||||||
|
<em className="italic text-gray-300">{children}</em>
|
||||||
|
),
|
||||||
|
// Inline code
|
||||||
|
code: ({ children, className }) => {
|
||||||
|
const isBlock = className?.startsWith("language-");
|
||||||
|
if (isBlock) {
|
||||||
|
return (
|
||||||
|
<code className="block rounded-md bg-gray-800 px-3 py-2 font-mono text-xs text-gray-200 whitespace-pre-wrap">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<code className="rounded bg-gray-800 px-1 py-0.5 font-mono text-xs text-cyan-300">
|
||||||
|
{children}
|
||||||
|
</code>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Code blocks
|
||||||
|
pre: ({ children }) => (
|
||||||
|
<pre className="my-2 overflow-x-auto rounded-md bg-gray-800 p-3 text-xs">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
),
|
||||||
|
// Unordered lists
|
||||||
|
ul: ({ children }) => (
|
||||||
|
<ul className="mb-2 list-disc pl-5 space-y-0.5">{children}</ul>
|
||||||
|
),
|
||||||
|
// Ordered lists
|
||||||
|
ol: ({ children }) => (
|
||||||
|
<ol className="mb-2 list-decimal pl-5 space-y-0.5">{children}</ol>
|
||||||
|
),
|
||||||
|
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||||
|
// Headings (rarely appear in descriptions but handle them gracefully)
|
||||||
|
h1: ({ children }) => (
|
||||||
|
<h1 className="mb-2 text-base font-semibold text-white">{children}</h1>
|
||||||
|
),
|
||||||
|
h2: ({ children }) => (
|
||||||
|
<h2 className="mb-1.5 text-sm font-semibold text-white">{children}</h2>
|
||||||
|
),
|
||||||
|
h3: ({ children }) => (
|
||||||
|
<h3 className="mb-1 text-sm font-medium text-gray-200">{children}</h3>
|
||||||
|
),
|
||||||
|
// Blockquotes
|
||||||
|
blockquote: ({ children }) => (
|
||||||
|
<blockquote className="my-2 border-l-2 border-cyan-500/40 pl-3 text-gray-400 italic">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
),
|
||||||
|
// Horizontal rule
|
||||||
|
hr: () => <hr className="my-3 border-gray-700" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MarkdownText({ content, className }: MarkdownTextProps) {
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||||
|
{content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import MarkdownText from "../MarkdownText";
|
||||||
import {
|
import {
|
||||||
Shield,
|
Shield,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
@@ -205,7 +206,7 @@ export default function TeamTabs({
|
|||||||
placeholder="Summarize the Red Team findings..."
|
placeholder="Summarize the Red Team findings..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-400">{test.red_summary || "No summary yet."}</p>
|
<MarkdownText content={test.red_summary || "No summary yet."} className="text-sm text-gray-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -250,7 +251,7 @@ export default function TeamTabs({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{test.red_validation_notes && (
|
{test.red_validation_notes && (
|
||||||
<p className="mt-2 text-sm text-gray-400">{test.red_validation_notes}</p>
|
<MarkdownText content={test.red_validation_notes} className="mt-2 text-sm text-gray-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -317,7 +318,7 @@ export default function TeamTabs({
|
|||||||
placeholder="Summarize the Blue Team analysis..."
|
placeholder="Summarize the Blue Team analysis..."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-gray-400">{test.blue_summary || "No summary yet."}</p>
|
<MarkdownText content={test.blue_summary || "No summary yet."} className="text-sm text-gray-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -424,7 +425,7 @@ export default function TeamTabs({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{test.blue_validation_notes && (
|
{test.blue_validation_notes && (
|
||||||
<p className="mt-2 text-sm text-gray-400">{test.blue_validation_notes}</p>
|
<MarkdownText content={test.blue_validation_notes} className="mt-2 text-sm text-gray-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -461,7 +462,7 @@ export default function TeamTabs({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-medium uppercase text-gray-500">Summary</dt>
|
<dt className="text-xs font-medium uppercase text-gray-500">Summary</dt>
|
||||||
<dd className="mt-0.5 text-sm text-gray-300">{test.red_summary || "N/A"}</dd>
|
<dd className="mt-0.5"><MarkdownText content={test.red_summary || "N/A"} className="text-sm text-gray-300" /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-medium uppercase text-gray-500">Evidence Files</dt>
|
<dt className="text-xs font-medium uppercase text-gray-500">Evidence Files</dt>
|
||||||
@@ -512,7 +513,7 @@ export default function TeamTabs({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-medium uppercase text-gray-500">Summary</dt>
|
<dt className="text-xs font-medium uppercase text-gray-500">Summary</dt>
|
||||||
<dd className="mt-0.5 text-sm text-gray-300">{test.blue_summary || "N/A"}</dd>
|
<dd className="mt-0.5"><MarkdownText content={test.blue_summary || "N/A"} className="text-sm text-gray-300" /></dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-medium uppercase text-gray-500">Evidence Files</dt>
|
<dt className="text-xs font-medium uppercase text-gray-500">Evidence Files</dt>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import MarkdownText from "../components/MarkdownText";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -236,7 +237,7 @@ export default function CampaignDetailPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{campaign.description && (
|
{campaign.description && (
|
||||||
<p className="mt-1 text-sm text-gray-400">{campaign.description}</p>
|
<MarkdownText content={campaign.description} className="mt-1 text-sm text-gray-400" />
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
|
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-gray-500">
|
||||||
{campaign.threat_actor_name && (
|
{campaign.threat_actor_name && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import MarkdownText from "../components/MarkdownText";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -182,9 +183,10 @@ export default function TechniqueDetailPage() {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="lg:col-span-2 rounded-xl border border-gray-800 bg-gray-900 p-6">
|
<div className="lg:col-span-2 rounded-xl border border-gray-800 bg-gray-900 p-6">
|
||||||
<h2 className="mb-3 text-lg font-semibold text-white">Description</h2>
|
<h2 className="mb-3 text-lg font-semibold text-white">Description</h2>
|
||||||
<p className="text-sm text-gray-400 leading-relaxed whitespace-pre-wrap">
|
<MarkdownText
|
||||||
{technique.description || "No description available."}
|
content={technique.description || "No description available."}
|
||||||
</p>
|
className="text-sm text-gray-400 leading-relaxed"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata */}
|
{/* Metadata */}
|
||||||
@@ -579,7 +581,7 @@ function DetectionRulesSection({ rules }: { rules: DetectionRule[] }) {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{rule.description && (
|
{rule.description && (
|
||||||
<p className="text-xs text-gray-400 leading-relaxed">{rule.description}</p>
|
<MarkdownText content={rule.description} className="text-xs text-gray-400 leading-relaxed" />
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-3 text-xs text-gray-500">
|
<div className="flex flex-wrap gap-3 text-xs text-gray-500">
|
||||||
{rule.rule_format && (
|
{rule.rule_format && (
|
||||||
@@ -702,7 +704,7 @@ function D3FENDSection({ defenses }: { defenses: Array<{
|
|||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="mt-3 space-y-2 border-t border-gray-700/50 pt-3">
|
<div className="mt-3 space-y-2 border-t border-gray-700/50 pt-3">
|
||||||
{def.description ? (
|
{def.description ? (
|
||||||
<p className="text-xs text-gray-300 leading-relaxed">{def.description}</p>
|
<MarkdownText content={def.description} className="text-xs text-gray-300 leading-relaxed" />
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500 italic">No description available.</p>
|
<p className="text-xs text-gray-500 italic">No description available.</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import MarkdownText from "../components/MarkdownText";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { Loader2, AlertCircle, ArrowLeft } from "lucide-react";
|
import { Loader2, AlertCircle, ArrowLeft } from "lucide-react";
|
||||||
@@ -441,8 +442,8 @@ export default function TestDetailPage() {
|
|||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-xs font-medium uppercase text-gray-500">Description</dt>
|
<dt className="text-xs font-medium uppercase text-gray-500">Description</dt>
|
||||||
<dd className="mt-1 text-sm text-gray-300">
|
<dd className="mt-1">
|
||||||
{test.description || "\u2014"}
|
<MarkdownText content={test.description || "\u2014"} className="text-sm text-gray-300" />
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
|
import MarkdownText from "../components/MarkdownText";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -263,9 +264,7 @@ export default function ThreatActorDetailPage() {
|
|||||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||||
Description
|
Description
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm leading-relaxed text-gray-300 whitespace-pre-line">
|
<MarkdownText content={actor.description} className="text-sm leading-relaxed text-gray-300" />
|
||||||
{actor.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user