feat(frontend): render markdown in description and summary fields
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:
kitos
2026-05-29 08:38:53 +02:00
parent a8542512b4
commit db208b9f5c
7 changed files with 132 additions and 18 deletions

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

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import MarkdownText from "../MarkdownText";
import {
Shield,
ShieldCheck,
@@ -205,7 +206,7 @@ export default function TeamTabs({
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>
@@ -250,7 +251,7 @@ export default function TeamTabs({
</span>
</div>
{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>
)}
@@ -317,7 +318,7 @@ export default function TeamTabs({
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>
@@ -424,7 +425,7 @@ export default function TeamTabs({
</span>
</div>
{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>
)}
@@ -461,7 +462,7 @@ export default function TeamTabs({
</div>
<div>
<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>
<dt className="text-xs font-medium uppercase text-gray-500">Evidence Files</dt>
@@ -512,7 +513,7 @@ export default function TeamTabs({
</div>
<div>
<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>
<dt className="text-xs font-medium uppercase text-gray-500">Evidence Files</dt>