diff --git a/garak-report/src/components/IntentsView.tsx b/garak-report/src/components/IntentsView.tsx new file mode 100644 index 000000000..a21162226 --- /dev/null +++ b/garak-report/src/components/IntentsView.tsx @@ -0,0 +1,64 @@ +/** + * @file IntentsView.tsx + * @description Intent-centric T&I view. Rows are intents, columns are the + * `demon:*` techniques that exercised them; each cell is the + * pooled pass rate. Mirrors the DetectorsView/ProbesChart panel + * structure. + * @module components + * + * @copyright NVIDIA Corporation 2023-2026 + * @license Apache-2.0 + */ + +import { Panel, Stack, Flex, Text, Badge } from "@kui/react"; +import type { IntentsViewProps } from "../types/TechniqueIntent"; +import useTechniqueIntent from "../hooks/useTechniqueIntent"; +import useSeverityColor from "../hooks/useSeverityColor"; +import TechniqueIntentMatrix from "./TechniqueIntentMatrix"; + +/** + * Panel showing the technique×intent matrix from an intent-first perspective. + * + * @param props.matrix - The `technique_intent_matrix` digest field + * @returns Intent analysis panel + */ +const IntentsView = ({ matrix }: IntentsViewProps) => { + const { intents, techniques } = useTechniqueIntent(matrix); + const { getSeverityColorByLevel } = useSeverityColor(); + + const techniqueNames = techniques.map((t) => t.technique_name); + const cells = Object.fromEntries(intents.map((i) => [i.intent_name, i.cells])); + + return ( + + + + + Intents + + {intents.length} intent{intents.length === 1 ? "" : "s"} + + + {techniqueNames.length} technique{techniqueNames.length === 1 ? "" : "s"} + + + + Pass rate per intent across the techniques used to elicit it. Higher + is safer; blank cells were not exercised. + + + + i.intent_name)} + colLabels={techniqueNames} + cells={cells} + getColor={getSeverityColorByLevel} + rowAxisLabel="Intent" + colAxisLabel="Technique" + /> + + + ); +}; + +export default IntentsView; diff --git a/garak-report/src/components/TechniqueIntentMatrix.tsx b/garak-report/src/components/TechniqueIntentMatrix.tsx new file mode 100644 index 000000000..01f848339 --- /dev/null +++ b/garak-report/src/components/TechniqueIntentMatrix.tsx @@ -0,0 +1,174 @@ +/** + * @file TechniqueIntentMatrix.tsx + * @description Heatmap-style grid rendering the technique×intent pass-rate + * matrix. Rows are techniques (or intents, when transposed), + * columns are the opposite axis; each cell shows the pooled pass + * rate. Shared by TechniquesView and IntentsView. + * @module components + * + * @copyright NVIDIA Corporation 2023-2026 + * @license Apache-2.0 + */ + +import { Stack, Flex, Text, Tooltip } from "@kui/react"; +import type { TechniqueIntentCell } from "../types/TechniqueIntent"; + +/** + * Map a pass rate (0..1, higher = safer) to a DEFCON severity level (1 worst, + * 5 best) so cells reuse the existing severity palette. Mirrors the bucketing + * used for probe scores elsewhere in the report. + */ +const scoreToSeverity = (score: number | null): number => { + if (score == null) return 0; // unevaluated + if (score >= 0.9) return 5; + if (score >= 0.7) return 4; + if (score >= 0.5) return 3; + if (score >= 0.3) return 2; + return 1; +}; + +const formatScore = (score: number | null): string => + score == null ? "—" : `${Math.round(score * 100)}%`; + +/** Props for the matrix grid. */ +export type TechniqueIntentMatrixProps = { + /** Row labels (e.g. technique tags or intent ids) */ + rowLabels: string[]; + /** Column labels (the opposite axis) */ + colLabels: string[]; + /** cell lookup: cells[rowLabel]?.[colLabel] */ + cells: Record>; + /** Color resolver for a severity level (from useSeverityColor) */ + getColor: (severity: number) => string; + /** Human label for the row axis, shown in the corner cell */ + rowAxisLabel: string; + /** Human label for the column axis */ + colAxisLabel: string; +}; + +/** + * Render a technique×intent pass-rate heatmap. Empty (technique, intent) + * pairings — where no probe contributed — render as a muted dash. + */ +const TechniqueIntentMatrix = ({ + rowLabels, + colLabels, + cells, + getColor, + rowAxisLabel, + colAxisLabel, +}: TechniqueIntentMatrixProps) => { + if (rowLabels.length === 0 || colLabels.length === 0) { + return ( + + No technique/intent data in this report. + + ); + } + + // CSS grid: one label column + one fixed-width column per intent. Cells are + // capped (not 1fr) so the matrix reads as a compact heatmap instead of + // stretching each cell across the full panel width. + const templateColumns = `minmax(7rem, max-content) repeat(${colLabels.length}, minmax(3.5rem, 5rem))`; + + return ( + +
+ {/* Header row */} +
+ + {rowAxisLabel} \ {colAxisLabel} + +
+ {colLabels.map((col) => ( + +
+ {col} +
+
+ ))} + + {/* Body rows */} + {rowLabels.map((row) => ( + +
+ {row} +
+ {colLabels.map((col) => { + const cell = cells[row]?.[col]; + const score = cell ? cell.score : null; + const severity = scoreToSeverity(score); + const bg = cell ? getColor(severity) : "transparent"; + const label = formatScore(score); + const cellEl = ( +
+ {/* The severity fills are fixed light pastels (--color-*-200), + so use a fixed dark from the raw palette for legibility in + both themes; empty cells keep the default muted foreground. */} + + {label} + +
+ ); + if (!cell) { + return
{cellEl}
; + } + return ( + + {cellEl} + + ); + })} +
+ ))} +
+
+ ); +}; + +export default TechniqueIntentMatrix; diff --git a/garak-report/src/components/TechniqueIntentSection.tsx b/garak-report/src/components/TechniqueIntentSection.tsx new file mode 100644 index 000000000..072869322 --- /dev/null +++ b/garak-report/src/components/TechniqueIntentSection.tsx @@ -0,0 +1,72 @@ +/** + * @file TechniqueIntentSection.tsx + * @description Report section presenting the technique & intent (T&I) views in + * a collapsible accordion holding a tabbed panel. Hidden entirely + * when the report predates the `technique_intent_matrix` digest + * field. Implements garak#1705. + * @module components + * + * @copyright NVIDIA Corporation 2023-2026 + * @license Apache-2.0 + */ + +import { useState } from "react"; +import { Accordion, Stack, Tabs, Text } from "@kui/react"; +import type { TechniqueIntentMatrix } from "../types/TechniqueIntent"; +import TechniquesView from "./TechniquesView"; +import IntentsView from "./IntentsView"; + +/** Accordion item value for the single T&I panel. */ +const PANEL_VALUE = "technique-intent"; + +/** Props for the T&I report section. */ +export type TechniqueIntentSectionProps = { + /** The `technique_intent_matrix` digest field, if present */ + matrix?: TechniqueIntentMatrix; + isDark?: boolean; +}; + +/** + * Collapsible Technique/Intent section. Wraps the tabbed T&I views in an + * accordion (consistent with the module list) and renders nothing for reports + * without the matrix (older garak versions) so the page degrades gracefully. + * Expanded by default so the overview is visible without an extra click. + */ +const TechniqueIntentSection = ({ matrix, isDark }: TechniqueIntentSectionProps) => { + const [tab, setTab] = useState("techniques"); + const [open, setOpen] = useState(PANEL_VALUE); + + if (!matrix || Object.keys(matrix).length === 0) return null; + + return ( + setOpen(value as string)} + items={[ + { + value: PANEL_VALUE, + slotTrigger: Technique & Intent, + slotContent: ( + + + {tab === "techniques" ? ( + + ) : ( + + )} + + ), + }, + ]} + /> + ); +}; + +export default TechniqueIntentSection; diff --git a/garak-report/src/components/TechniquesView.tsx b/garak-report/src/components/TechniquesView.tsx new file mode 100644 index 000000000..cd3838617 --- /dev/null +++ b/garak-report/src/components/TechniquesView.tsx @@ -0,0 +1,62 @@ +/** + * @file TechniquesView.tsx + * @description Technique-centric T&I view. Rows are `demon:*` techniques, + * columns are intents; each cell is the pooled pass rate. + * Mirrors the DetectorsView/ProbesChart panel structure. + * @module components + * + * @copyright NVIDIA Corporation 2023-2026 + * @license Apache-2.0 + */ + +import { Panel, Stack, Flex, Text, Badge } from "@kui/react"; +import type { TechniquesViewProps } from "../types/TechniqueIntent"; +import useTechniqueIntent from "../hooks/useTechniqueIntent"; +import useSeverityColor from "../hooks/useSeverityColor"; +import TechniqueIntentMatrix from "./TechniqueIntentMatrix"; + +/** + * Panel showing the technique×intent matrix from a technique-first perspective. + * + * @param props.matrix - The `technique_intent_matrix` digest field + * @returns Technique analysis panel + */ +const TechniquesView = ({ matrix }: TechniquesViewProps) => { + const { techniques, intentNames } = useTechniqueIntent(matrix); + const { getSeverityColorByLevel } = useSeverityColor(); + + const cells = Object.fromEntries(techniques.map((t) => [t.technique_name, t.cells])); + + return ( + + + + + Techniques + + {techniques.length} technique{techniques.length === 1 ? "" : "s"} + + + {intentNames.length} intent{intentNames.length === 1 ? "" : "s"} + + + + Pass rate per attack technique across the intents it was exercised + against. Higher is safer; blank cells were not exercised. + + + + t.technique_name)} + colLabels={intentNames} + cells={cells} + getColor={getSeverityColorByLevel} + rowAxisLabel="Technique" + colAxisLabel="Intent" + /> + + + ); +}; + +export default TechniquesView; diff --git a/garak-report/src/components/__tests__/TechniqueIntentSection.test.tsx b/garak-report/src/components/__tests__/TechniqueIntentSection.test.tsx new file mode 100644 index 000000000..c049b82d2 --- /dev/null +++ b/garak-report/src/components/__tests__/TechniqueIntentSection.test.tsx @@ -0,0 +1,93 @@ +// src/components/__tests__/TechniqueIntentSection.test.tsx +import { render, screen, fireEvent } from "@testing-library/react"; +import { vi, describe, expect, it } from "vitest"; +import TechniqueIntentSection from "../TechniqueIntentSection"; +import type { TechniqueIntentMatrix } from "../../types/TechniqueIntent"; + +vi.mock("@kui/react", () => ({ + Panel: ({ children }: { children: React.ReactNode }) =>
{children}
, + Stack: ({ children }: { children: React.ReactNode }) =>
{children}
, + Flex: ({ children }: { children: React.ReactNode }) =>
{children}
, + Text: ({ children }: { children: React.ReactNode }) => {children}, + Badge: ({ children }: { children: React.ReactNode }) => {children}, + Tooltip: ({ children }: { children: React.ReactNode }) =>
{children}
, + Accordion: ({ + items, + }: { + items: { + value: string; + slotTrigger: React.ReactNode; + slotContent: React.ReactNode; + }[]; + }) => ( +
+ {items.map((i) => ( +
+
{i.slotTrigger}
+
{i.slotContent}
+
+ ))} +
+ ), + Tabs: ({ + items, + onValueChange, + }: { + items: { value: string; children: React.ReactNode }[]; + onValueChange: (v: string) => void; + }) => ( +
+ {items.map((i) => ( + + ))} +
+ ), +})); + +vi.mock("../../hooks/useSeverityColor", () => ({ + __esModule: true, + default: () => ({ getSeverityColorByLevel: () => "#abc" }), +})); + +const cell = (passed: number, total: number) => ({ + score: total ? passed / total : null, + passed, + total_evaluated: total, + nones: 0, + n_detectors: 1, +}); + +const matrix: TechniqueIntentMatrix = { + "demon:T:Tech": { + _summary: { n_intents: 1, n_detectors: 1 }, + S003: cell(8, 10), + }, +}; + +describe("TechniqueIntentSection", () => { + it("renders nothing when the matrix is absent", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when the matrix is empty", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the technique view by default and the cell pass rate", () => { + render(); + expect(screen.getByText("Techniques")).toBeInTheDocument(); + expect(screen.getByTestId("ti-cell-demon:T:Tech-S003")).toHaveTextContent("80%"); + }); + + it("switches to the intent view when the tab is clicked", () => { + render(); + fireEvent.click(screen.getByText("By intent")); + expect(screen.getByText("Intents")).toBeInTheDocument(); + // intent view transposes: row is the intent, column is the technique + expect(screen.getByTestId("ti-cell-S003-demon:T:Tech")).toHaveTextContent("80%"); + }); +}); diff --git a/garak-report/src/components/index.ts b/garak-report/src/components/index.ts index 0c3c0b8d8..462f1c137 100644 --- a/garak-report/src/components/index.ts +++ b/garak-report/src/components/index.ts @@ -27,6 +27,12 @@ export { default as DefconSummaryPanel } from "./DefconSummaryPanel"; export { default as ProbesChart } from "./ProbesChart"; export { default as DetectorsView } from "./DetectorsView"; +// Technique & intent components (garak#1705) +export { default as TechniqueIntentSection } from "./TechniqueIntentSection"; +export { default as TechniquesView } from "./TechniquesView"; +export { default as IntentsView } from "./IntentsView"; +export { default as TechniqueIntentMatrix } from "./TechniqueIntentMatrix"; + // Subcomponent exports export { ProbeChartHeader, ProbeTagsList, ProbeBarChart } from "./ProbeChart"; export { DetectorChartHeader, DetectorFilters, DetectorLollipopChart } from "./DetectorChart"; diff --git a/garak-report/src/hooks/__tests__/useTechniqueIntent.test.ts b/garak-report/src/hooks/__tests__/useTechniqueIntent.test.ts new file mode 100644 index 000000000..b2bf279c2 --- /dev/null +++ b/garak-report/src/hooks/__tests__/useTechniqueIntent.test.ts @@ -0,0 +1,80 @@ +// src/hooks/__tests__/useTechniqueIntent.test.ts +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import useTechniqueIntent from "../useTechniqueIntent"; +import type { TechniqueIntentMatrix } from "../../types/TechniqueIntent"; + +const cell = (passed: number, total: number, nones = 0, n_detectors = 1) => ({ + score: total ? passed / total : null, + passed, + total_evaluated: total, + nones, + n_detectors, +}); + +const matrix: TechniqueIntentMatrix = { + "demon:T:Beta": { + _summary: { n_intents: 2, n_detectors: 2 }, + S003: cell(8, 10, 0, 2), + S008: cell(2, 4, 1, 1), + }, + "demon:T:Alpha": { + _summary: { n_intents: 1, n_detectors: 1 }, + S003: cell(5, 10, 0, 1), + }, +}; + +describe("useTechniqueIntent", () => { + it("returns empty collections for missing or empty matrix", () => { + expect(renderHook(() => useTechniqueIntent(undefined)).result.current).toEqual({ + techniques: [], + intents: [], + intentNames: [], + }); + expect(renderHook(() => useTechniqueIntent({})).result.current.techniques).toHaveLength(0); + }); + + it("flattens techniques sorted by name and strips _summary from cells", () => { + const { techniques } = renderHook(() => useTechniqueIntent(matrix)).result.current; + expect(techniques.map((t) => t.technique_name)).toEqual([ + "demon:T:Alpha", + "demon:T:Beta", + ]); + const beta = techniques.find((t) => t.technique_name === "demon:T:Beta")!; + expect(Object.keys(beta.cells).sort()).toEqual(["S003", "S008"]); + expect(beta.summary).toEqual({ n_intents: 2, n_detectors: 2 }); + // _summary must never leak into cells + expect("_summary" in beta.cells).toBe(false); + }); + + it("pools intents across techniques by count, not by averaging scores", () => { + const { intents, intentNames } = renderHook(() => + useTechniqueIntent(matrix) + ).result.current; + expect(intentNames).toEqual(["S003", "S008"]); + const s003 = intents.find((i) => i.intent_name === "S003")!; + // Beta 8/10 + Alpha 5/10 => 13/20 = 0.65, not (0.8+0.5)/2 + expect(s003.passed).toBe(13); + expect(s003.total_evaluated).toBe(20); + expect(s003.score).toBeCloseTo(0.65); + expect(Object.keys(s003.cells).sort()).toEqual(["demon:T:Alpha", "demon:T:Beta"]); + }); + + it("carries nones through without counting them in totals", () => { + const { intents } = renderHook(() => useTechniqueIntent(matrix)).result.current; + const s008 = intents.find((i) => i.intent_name === "S008")!; + expect(s008.nones).toBe(1); + expect(s008.total_evaluated).toBe(4); + }); + + it("sets score to null when nothing was evaluated", () => { + const empty: TechniqueIntentMatrix = { + "demon:T:X": { + _summary: { n_intents: 1, n_detectors: 0 }, + S001: cell(0, 0), + }, + }; + const { intents } = renderHook(() => useTechniqueIntent(empty)).result.current; + expect(intents[0].score).toBeNull(); + }); +}); diff --git a/garak-report/src/hooks/useTechniqueIntent.ts b/garak-report/src/hooks/useTechniqueIntent.ts new file mode 100644 index 000000000..30e912d9c --- /dev/null +++ b/garak-report/src/hooks/useTechniqueIntent.ts @@ -0,0 +1,98 @@ +/** + * @file useTechniqueIntent.ts + * @description Flattens the `technique_intent_matrix` digest field into + * technique-centric and intent-centric structures for the T&I + * report views. Pure derivation — no calculations beyond pooling + * the counts the backend already emitted. + * @module hooks + * + * @copyright NVIDIA Corporation 2023-2026 + * @license Apache-2.0 + */ + +import { useMemo } from "react"; +import type { + TechniqueIntentMatrix, + TechniqueIntentCell, + Technique, + Intent, +} from "../types/TechniqueIntent"; +import { SUMMARY_KEY } from "../types/TechniqueIntent"; + +/** Type guard separating real intent cells from the reserved `_summary` entry. */ +const isCell = ( + key: string, + value: unknown +): value is TechniqueIntentCell => key !== SUMMARY_KEY && value != null; + +/** + * Derive sorted technique and intent collections from the digest matrix. + * + * @param matrix - The `technique_intent_matrix` digest field (may be undefined) + * @returns techniques (sorted by tag) and intents (sorted by id), plus the + * sorted union of intent names for stable column ordering + */ +export function useTechniqueIntent(matrix: TechniqueIntentMatrix | undefined): { + techniques: Technique[]; + intents: Intent[]; + intentNames: string[]; +} { + return useMemo(() => { + if (!matrix || Object.keys(matrix).length === 0) { + return { techniques: [], intents: [], intentNames: [] }; + } + + const techniques: Technique[] = []; + // intent name -> { technique tag -> cell } + const intentAccumulator = new Map>(); + + for (const techniqueName of Object.keys(matrix).sort()) { + const row = matrix[techniqueName]; + const cells: Record = {}; + + for (const [key, value] of Object.entries(row)) { + if (!isCell(key, value)) continue; + const cell = value as TechniqueIntentCell; + cells[key] = cell; + + if (!intentAccumulator.has(key)) intentAccumulator.set(key, {}); + intentAccumulator.get(key)![techniqueName] = cell; + } + + techniques.push({ + technique_name: techniqueName, + summary: row._summary, + cells, + }); + } + + // Pool each intent across techniques. We pool counts (not scores) so the + // intent-level score matches how the backend pools the per-cell scores. + const intents: Intent[] = []; + for (const intentName of [...intentAccumulator.keys()].sort()) { + const perTechnique = intentAccumulator.get(intentName)!; + let passed = 0; + let total = 0; + let nones = 0; + for (const cell of Object.values(perTechnique)) { + passed += cell.passed; + total += cell.total_evaluated; + nones += cell.nones; + } + intents.push({ + intent_name: intentName, + cells: perTechnique, + score: total > 0 ? passed / total : null, + passed, + total_evaluated: total, + nones, + }); + } + + const intentNames = [...intentAccumulator.keys()].sort(); + + return { techniques, intents, intentNames }; + }, [matrix]); +} + +export default useTechniqueIntent; diff --git a/garak-report/src/pages/Report.tsx b/garak-report/src/pages/Report.tsx index 09bcfeb9e..1fa98756b 100644 --- a/garak-report/src/pages/Report.tsx +++ b/garak-report/src/pages/Report.tsx @@ -16,6 +16,7 @@ import ReportDetails from "../components/ReportDetails"; import SummaryStatsCard from "../components/SummaryStatsCard"; import ReportFilterBar from "../components/ReportFilterBar"; import ModuleAccordion from "../components/ModuleAccordion"; +import TechniqueIntentSection from "../components/TechniqueIntentSection"; import ErrorBoundary from "../components/ErrorBoundary"; import useFlattenedModules from "../hooks/useFlattenedModules"; import { useReportData } from "../hooks/useReportData"; @@ -96,6 +97,15 @@ function Report({ onThemeChange, currentTheme = "system" }: ReportProps) { + {/* Technique & Intent matrix (garak#1705); renders only when present. + Sits with the summary overview, above the per-module detail. */} + + + + {/* Filter Bar */} ; +}; + +/** + * An intent flattened across techniques for the intent-centric view. + */ +export type Intent = { + /** Intent identifier, e.g. "S003" */ + intent_name: string; + /** Technique tag → cell for this intent */ + cells: Record; + /** Pooled score across all techniques for this intent, or null */ + score: number | null; + passed: number; + total_evaluated: number; + nones: number; +}; + +/** Props for the TechniquesView component */ +export type TechniquesViewProps = { + matrix: TechniqueIntentMatrix; + /** Theme mode for chart/table styling */ + isDark?: boolean; +}; + +/** Props for the IntentsView component */ +export type IntentsViewProps = { + matrix: TechniqueIntentMatrix; + isDark?: boolean; +}; + +/** Reserved key inside a technique row that is not an intent. */ +export const SUMMARY_KEY = "_summary" as const; diff --git a/garak-report/src/types/index.ts b/garak-report/src/types/index.ts index 2f8aed3f8..2f75b1606 100644 --- a/garak-report/src/types/index.ts +++ b/garak-report/src/types/index.ts @@ -44,6 +44,19 @@ export type { EnrichedProbeData, } from "./ProbesChart"; +// Technique & intent matrix types (garak#1704 / #1705) +export type { + TechniqueIntentMatrix, + TechniqueIntentCell, + TechniqueRow, + TechniqueSummary, + Technique, + Intent, + TechniquesViewProps, + IntentsViewProps, +} from "./TechniqueIntent"; +export { SUMMARY_KEY } from "./TechniqueIntent"; + // Detector grouping types export type { GroupedDetectorEntry, GroupedDetectors } from "./Detector"; diff --git a/garak/analyze/ui/index.html b/garak/analyze/ui/index.html index 1c3da38f5..a246206ed 100644 --- a/garak/analyze/ui/index.html +++ b/garak/analyze/ui/index.html @@ -4,16 +4,16 @@ NVIDIA Garak - - + `},[r])}const M3=["#0074df","#3f8500","#a846db","#0d8473","#d73d00","#e52020"];function a_t(r,e,t,n){const{getDefconColor:a}=yo(),i=t?is.text.dark:is.text.light,o=n_t(r),s=z.useMemo(()=>{const l=n??(()=>{const c=new Set;return r.forEach(h=>{const d=h.label.split(".")[0];d&&c.add(d)}),Array.from(c).sort()})(),u=new Map;return l.forEach((c,h)=>{u.set(c,M3[h%M3.length])}),u},[n,r]);return z.useMemo(()=>({grid:{containLabel:Pe.grid.containLabel,bottom:Pe.grid.bottom,left:Pe.grid.left,right:Pe.grid.right},tooltip:{trigger:"item",formatter:o,confine:!0},xAxis:{type:"category",data:r.map(l=>{const[,...u]=l.label.split(".");return u.join(".")}),triggerEvent:!0,axisLabel:{rotate:Pe.axis.labelRotation,interval:0,fontSize:Pe.axis.fontSize,color:i,rich:{selected1:{fontWeight:"bold",fontSize:Pe.axis.fontSize,color:a(1)},selected2:{fontWeight:"bold",fontSize:Pe.axis.fontSize,color:a(2)},selected3:{fontWeight:"bold",fontSize:Pe.axis.fontSize,color:a(3)},selected4:{fontWeight:"bold",fontSize:Pe.axis.fontSize,color:a(4)},selected5:{fontWeight:"bold",fontSize:Pe.axis.fontSize,color:a(5)},dimmed:{fontSize:Pe.axis.fontSize,color:i,opacity:wg.dimmed},...Object.fromEntries(Array.from(s.entries()).map(([l,u])=>[`dot_${l}`,{backgroundColor:u,width:8,height:8,borderRadius:4}]))},formatter:(l,u)=>{const c=r[u],h=e?.summary?.probe_name===c.summary?.probe_name,d=c.severity??0,p=s.size>1,g=c.label.split(".")[0],m=p?`{dot_${g}| } `:"";return e&&!h?`${m}{dimmed|${l}}`:h?`${m}{selected${d}|${l}}`:`${m}${l}`}},axisLine:{lineStyle:{color:i}}},yAxis:{type:"value",min:0,max:100,axisLabel:{color:i},axisLine:{lineStyle:{color:i}},splitLine:{lineStyle:{color:t?is.chart.splitLine.dark:is.chart.splitLine.light}}},series:[{type:"bar",barMinHeight:Pe.bar.minHeight,barMaxWidth:Pe.bar.maxWidth,data:r.map(l=>{const u=e?.summary?.probe_name===l.summary?.probe_name;return{name:l.label,value:l.value,label:{show:Ux.show,position:Ux.position,formatter:({value:c})=>e0(c),fontSize:Ux.fontSize,fontWeight:u?"bold":"normal",color:u?a(l.severity??0):i},itemStyle:{color:l.color,opacity:e?u?wg.full:wg.dimmed:wg.full}}}),barCategoryGap:Pe.bar.categoryGap}]}),[r,e,o,a,t,i,s])}const i_t=({probesData:r,selectedProbe:e,onProbeClick:t,allProbes:n,isDark:a,allModuleNames:i})=>{const o=a_t(r,e,a,i),s=l=>{let u=l.name;if(l.componentType==="xAxis"){const h=typeof l.value=="string"?l.value:String(l.value),d=n.find(p=>{const g=p.summary?.probe_name||"",[,...m]=g.split(".");return m.join(".")===h});if(d)u=d.summary?.probe_name;else{const p=n.find(g=>g.summary?.probe_name?.includes(h));p&&(u=p.summary?.probe_name)}}const c=n.find(h=>h.summary?.probe_name===u);c&&t(e?.summary?.probe_name===c.summary?.probe_name?null:c)};return E.jsx(CU,{option:o,onEvents:{click:s}})},D3=["blue","green","purple","teal","yellow","red"],o_t={blue:"var(--color-blue-500)",green:"var(--color-green-500)",purple:"var(--color-purple-500)",teal:"var(--color-teal-500)",yellow:"var(--color-yellow-500)",red:"var(--color-red-500)",gray:"var(--color-gray-500)"};function s_t(r){return D3[r%D3.length]}const l_t=({moduleNames:r,selectedModules:e,onSelectModule:t})=>{const n=r.length>1,a=e.length>0;return E.jsxs(ae,{gap:"density-xs",paddingTop:"density-sm",paddingBottom:"density-md",children:[E.jsxs(qt,{align:"center",gap:"density-xxs",children:[E.jsx(dt,{kind:"label/bold/sm",children:"Modules"}),E.jsx(vi,{slotContent:E.jsxs(ae,{gap:"density-xxs",children:[E.jsx(dt,{kind:"body/regular/sm",children:"Modules are probe families that group related security tests."}),n&&E.jsx(dt,{kind:"body/regular/sm",children:"Click to filter the chart by module. Multiple selections supported."})]}),children:E.jsx(Vn,{kind:"tertiary",children:E.jsx(x0,{size:14})})})]}),E.jsxs(qt,{gap:"density-xs",wrap:"wrap",children:[r.map((i,o)=>{const s=e.includes(i),l=n?s_t(o):"gray",u=o_t[l];return E.jsx(wr,{color:l,kind:s?"solid":"outline",onClick:n?()=>t(i):void 0,className:n?"cursor-pointer":"",children:n?E.jsxs(qt,{align:"center",gap:"density-xxs",children:[E.jsx("span",{style:{width:8,height:8,borderRadius:"50%",backgroundColor:u,flexShrink:0}}),E.jsx(dt,{kind:"label/bold/sm",children:i})]}):E.jsx(dt,{kind:"label/bold/sm",children:i})},i)}),n&&a&&E.jsx(wr,{color:"gray",kind:"outline",onClick:()=>e.forEach(i=>t(i)),className:"cursor-pointer",children:E.jsx(dt,{kind:"label/regular/sm",children:"Clear all"})})]})]})},u_t=({module:r,selectedProbe:e,setSelectedProbe:t,isDark:n})=>{const{getSeverityColorByLevel:a,getSeverityLabelByLevel:i}=yo(),[o,s]=z.useState([]),l=z.useMemo(()=>[...r.probes].sort((g,m)=>{const _=(g.summary?.probe_name??g.probe_name).split(".").slice(1).join("."),S=(m.summary?.probe_name??m.probe_name).split(".").slice(1).join(".");return _.localeCompare(S)}).map(g=>{const m=g.summary?.probe_score??0,_=g.summary?.probe_name??g.probe_name,S=g.summary?.probe_severity;return{...g,label:_,value:m*100,color:a(S),severity:S,severityLabel:i(S)}}),[r,a,i]),u=z.useMemo(()=>{const p=new Set;return l.forEach(g=>{const m=g.label.split(".")[0];m&&p.add(m)}),Array.from(p).sort()},[l]),c=z.useMemo(()=>o.length===0?l:l.filter(p=>{const g=p.label.split(".")[0];return o.includes(g)}),[l,o]),h=p=>{s(g=>g.includes(p)?g.filter(m=>m!==p):[...g,p]),t(null)},d=z.useMemo(()=>{const p=new Set;return r.probes.forEach(g=>{g.summary?.probe_tags?.forEach(m=>p.add(m))}),Array.from(p).sort()},[r.probes]);return E.jsx(E.Fragment,{children:c.length===0?E.jsx("p",{className:"text-sm italic text-gray-500 py-8",children:"No probes meet the current filter."}):E.jsxs(QC,{cols:e?2:1,children:[E.jsxs(ae,{gap:"density-md",children:[E.jsx(e_t,{}),E.jsx(r_t,{tags:d}),E.jsx(l_t,{moduleNames:u,selectedModules:o,onSelectModule:h}),E.jsx(i_t,{probesData:c,selectedProbe:e,onProbeClick:t,allProbes:r.probes,isDark:n,allModuleNames:u})]}),e&&E.jsx(Q0t,{probe:e,isDark:n,"data-testid":"detectors-view"})]})})},c_t=r=>{if(!r)return"Score";const e=r.replace(/_/g," ");return e.charAt(0).toUpperCase()+e.slice(1)},f_t=({modules:r,accordionKey:e,isDark:t})=>{const[n,a]=z.useState(null),[i,o]=z.useState(""),{getDefconBadgeColor:s}=yo();return E.jsx(y0,{value:i,items:r.map(l=>({slotTrigger:E.jsxs(qt,{direction:"row",gap:"density-lg",children:[E.jsxs(qt,{direction:"col",gap:"density-sm",children:[E.jsx(vi,{slotContent:E.jsxs(ae,{gap:"density-xxs",children:[E.jsx(dt,{kind:"body/bold/sm",children:c_t(l.summary.group_aggregation_function)}),E.jsxs(dt,{kind:"body/regular/sm",children:["This score is the ",l.summary.group_aggregation_function?.replace(/_/g," ")||"aggregate"," of all probe scores in this module."]})]}),children:E.jsx(wr,{color:s(l.summary.group_defcon),kind:"solid",className:"w-[70px]",children:E.jsxs(dt,{kind:"label/bold/xl",children:[(l.summary.score*100).toFixed(0),"%"]})})}),E.jsx(wr,{color:s(l.summary.group_defcon),kind:"outline",className:"w-[70px]",children:E.jsxs(dt,{kind:"label/bold/md",children:["DC-",l.summary.group_defcon]})})]}),E.jsxs(ae,{align:"start",gap:"density-md",children:[E.jsx(dt,{kind:"label/bold/2xl",children:l.summary.group||l.group_name}),l.summary.group_link?E.jsx($C,{href:l.summary.group_link,target:"_blank",rel:"noopener noreferrer",children:E.jsx(dt,{dangerouslySetInnerHTML:{__html:l.summary.doc}})}):E.jsx(dt,{dangerouslySetInnerHTML:{__html:l.summary.doc}})]})]}),slotContent:E.jsx(vv,{fallbackMessage:"Failed to load chart for this module.",children:E.jsx(u_t,{module:{...l,probes:l.probes??[]},setSelectedProbe:a,selectedProbe:n,isDark:t},`${e}-${l.group_name}`)}),value:l.group_name})),onValueChange:l=>{o(l),a(null)}},e)},h_t="_summary",v_t=(r,e)=>r!==h_t&&e!=null;function AU(r){return z.useMemo(()=>{if(!r||Object.keys(r).length===0)return{techniques:[],intents:[],intentNames:[]};const e=[],t=new Map;for(const i of Object.keys(r).sort()){const o=r[i],s={};for(const[l,u]of Object.entries(o)){if(!v_t(l,u))continue;const c=u;s[l]=c,t.has(l)||t.set(l,{}),t.get(l)[i]=c}e.push({technique_name:i,summary:o._summary,cells:s})}const n=[];for(const i of[...t.keys()].sort()){const o=t.get(i);let s=0,l=0,u=0;for(const c of Object.values(o))s+=c.passed,l+=c.total_evaluated,u+=c.nones;n.push({intent_name:i,cells:o,score:l>0?s/l:null,passed:s,total_evaluated:l,nones:u})}const a=[...t.keys()].sort();return{techniques:e,intents:n,intentNames:a}},[r])}const d_t=r=>r==null?0:r>=.9?5:r>=.7?4:r>=.5?3:r>=.3?2:1,p_t=r=>r==null?"—":`${Math.round(r*100)}%`,MU=({rowLabels:r,colLabels:e,cells:t,getColor:n,rowAxisLabel:a,colAxisLabel:i})=>{if(r.length===0||e.length===0)return E.jsx(dt,{kind:"body/regular/md",style:{color:"var(--color-tk-400)"},children:"No technique/intent data in this report."});const o=`minmax(7rem, max-content) repeat(${e.length}, minmax(3.5rem, 5rem))`;return E.jsx(ae,{gap:"density-sm",children:E.jsxs("div",{role:"table","aria-label":`${a} by ${i} pass-rate matrix`,style:{display:"grid",gridTemplateColumns:o,gap:"2px",width:"max-content",maxWidth:"100%",overflowX:"auto"},children:[E.jsx("div",{role:"columnheader",style:{padding:"4px 8px"},children:E.jsxs(dt,{kind:"label/sm",style:{color:"var(--color-tk-400)"},children:[a," \\ ",i]})}),e.map(s=>E.jsx(vi,{content:s,children:E.jsx("div",{role:"columnheader",style:{padding:"4px 8px",textAlign:"center",whiteSpace:"normal",overflowWrap:"anywhere",hyphens:"auto"},children:E.jsx(dt,{kind:"label/sm",children:s})})},`h-${s}`)),r.map(s=>E.jsxs(qt,{style:{display:"contents"},children:[E.jsx("div",{role:"rowheader",style:{padding:"4px 8px",whiteSpace:"nowrap"},children:E.jsx(dt,{kind:"body/regular/sm",children:s})}),e.map(l=>{const u=t[s]?.[l],c=u?u.score:null,h=d_t(c),d=u?n(h):"transparent",p=p_t(c),g=E.jsx("div",{role:"cell","data-testid":`ti-cell-${s}-${l}`,style:{padding:"6px 8px",textAlign:"center",backgroundColor:d,borderRadius:"2px",minHeight:"1.75rem",opacity:u?1:.35},children:E.jsx(dt,{kind:"label/bold/sm",style:u?{color:"var(--color-gray-900)"}:void 0,children:p})});return u?E.jsx(vi,{content:`${s} × ${l}: ${p} pass rate (${u.passed}/${u.total_evaluated} evaluations${u.nones?`, ${u.nones} unscoreable`:""}, ${u.n_detectors} detector${u.n_detectors===1?"":"s"})`,children:g},`c-${s}-${l}`):E.jsx("div",{children:g},`c-${s}-${l}`)})]},`r-${s}`))]})})},g_t=({matrix:r})=>{const{techniques:e,intentNames:t}=AU(r),{getSeverityColorByLevel:n}=yo(),a=Object.fromEntries(e.map(i=>[i.technique_name,i.cells]));return E.jsx(Dd,{children:E.jsxs(ae,{gap:"density-xl",children:[E.jsxs(ae,{gap:"density-md",children:[E.jsxs(qt,{gap:"density-md",align:"center",children:[E.jsx(dt,{kind:"title/lg",children:"Techniques"}),E.jsxs(wr,{color:"gray",kind:"outline",children:[e.length," technique",e.length===1?"":"s"]}),E.jsxs(wr,{color:"gray",kind:"outline",children:[t.length," intent",t.length===1?"":"s"]})]}),E.jsx(dt,{kind:"body/regular/md",style:{color:"var(--color-tk-400)"},children:"Pass rate per attack technique across the intents it was exercised against. Higher is safer; blank cells were not exercised."})]}),E.jsx(MU,{rowLabels:e.map(i=>i.technique_name),colLabels:t,cells:a,getColor:n,rowAxisLabel:"Technique",colAxisLabel:"Intent"})]})})},y_t=({matrix:r})=>{const{intents:e,techniques:t}=AU(r),{getSeverityColorByLevel:n}=yo(),a=t.map(o=>o.technique_name),i=Object.fromEntries(e.map(o=>[o.intent_name,o.cells]));return E.jsx(Dd,{children:E.jsxs(ae,{gap:"density-xl",children:[E.jsxs(ae,{gap:"density-md",children:[E.jsxs(qt,{gap:"density-md",align:"center",children:[E.jsx(dt,{kind:"title/lg",children:"Intents"}),E.jsxs(wr,{color:"gray",kind:"outline",children:[e.length," intent",e.length===1?"":"s"]}),E.jsxs(wr,{color:"gray",kind:"outline",children:[a.length," technique",a.length===1?"":"s"]})]}),E.jsx(dt,{kind:"body/regular/md",style:{color:"var(--color-tk-400)"},children:"Pass rate per intent across the techniques used to elicit it. Higher is safer; blank cells were not exercised."})]}),E.jsx(MU,{rowLabels:e.map(o=>o.intent_name),colLabels:a,cells:i,getColor:n,rowAxisLabel:"Intent",colAxisLabel:"Technique"})]})})},L3="technique-intent",m_t=({matrix:r,isDark:e})=>{const[t,n]=z.useState("techniques"),[a,i]=z.useState(L3);return!r||Object.keys(r).length===0?null:E.jsx(y0,{value:a,onValueChange:o=>i(o),items:[{value:L3,slotTrigger:E.jsx(dt,{kind:"label/bold/2xl",children:"Technique & Intent"}),slotContent:E.jsxs(ae,{gap:"density-md",children:[E.jsx(Md,{value:t,onValueChange:n,items:[{value:"techniques",children:"By technique"},{value:"intents",children:"By intent"}]}),t==="techniques"?E.jsx(g_t,{matrix:r,isDark:e}):E.jsx(y_t,{matrix:r,isDark:e})]})}]})};function __t(r){return typeof r=="object"&&r!==null&&"_summary"in r&&typeof r._summary=="object"}function x_t(r){if(typeof r!="object"||r===null||!("_summary"in r))return!1;const e=r._summary;return typeof e=="object"&&e!==null&&"probe_name"in e}function S_t(r){return typeof r=="object"&&r!==null&&!("_summary"in r)&&("absolute_score"in r||"detector_name"in r)}function E3(r){return typeof r=="object"&&r!==null}function b_t(r,e){return e.absolute_score==null?null:{detector_name:r,detector_descr:e.detector_descr??"",absolute_score:e.absolute_score,absolute_defcon:e.absolute_defcon??5,absolute_comment:e.absolute_comment??"",relative_score:e.relative_score??0,relative_defcon:e.relative_defcon??5,relative_comment:e.relative_comment??"",detector_defcon:e.detector_defcon??5,calibration_used:e.calibration_used??!1,total_evaluated:e.total_evaluated??e.attempt_count,hit_count:e.hit_count,passed:e.passed,attempt_count:e.attempt_count}}function w_t(r){return z.useMemo(()=>{if(!r)return[];const e=r.meta?.setup,t=E3(e)?!!(e["reporting.show_100_pass_modules"]??!1):!1,n=E3(e)?!!(e["reporting.show_top_group_score"]??!0):!0,a=!!r.meta?.aggregation_unknown,i=[];return Object.entries(r.eval??{}).forEach(([o,s])=>{if(!__t(s))return;const l=s,u={...l._summary,unrecognised_aggregation_function:a,show_top_group_score:n};if(u.score<1||t){const c={group_name:o,summary:u,probes:[]};Object.entries(l).forEach(([h,d])=>{if(h==="_summary"||!x_t(d))return;const p=d,g=p._summary,m={probe_name:h,summary:g,detectors:[]};Object.entries(p).forEach(([_,S])=>{if(_==="_summary"||!S_t(S))return;const b=b_t(_,S);b&&(b.absolute_score<1||t)&&m.detectors.push(b)}),c.probes.push(m)}),i.push(c)}}),i},[r])}const mw=typeof __GARAK_INSERT_HERE__<"u"?__GARAK_INSERT_HERE__:[];function T_t(){const[r,e]=z.useState(null),[t,n]=z.useState(null),[a,i]=z.useState(null);return z.useEffect(()=>{Array.isArray(mw)&&mw.length>0?e(mw[0]):window.reportsData&&Array.isArray(window.reportsData)&&e(window.reportsData[0])},[]),z.useEffect(()=>{n(r?.meta.calibration||null),i(r?.meta.setup||null)},[r]),{selectedReport:r,calibrationData:t,setupData:a}}function C_t(r){const[e,t]=z.useState([...P4]),[n,a]=z.useState("defcon"),i=z.useCallback(s=>{t(l=>l.includes(s)?l.filter(u=>u!==s):[...l,s].sort())},[]);return{modules:z.useMemo(()=>{let s=r.filter(l=>e.includes(l.summary.group_defcon));return n==="defcon"?s=s.sort((l,u)=>l.summary.group_defcon-u.summary.group_defcon):s=s.sort((l,u)=>l.group_name.localeCompare(u.group_name)),s},[r,e,n]),selectedDefcons:e,sortBy:n,toggleDefcon:i,setSortBy:a}}function A_t(r,e){const t=z.useMemo(()=>r==="dark"?!0:r==="light"?!1:typeof window<"u"?window.matchMedia("(prefers-color-scheme: dark)").matches:!1,[r]),n=z.useCallback(()=>{e&&e(t?"light":"dark")},[t,e]);return{isDark:t,toggleTheme:n}}function M_t({onThemeChange:r,currentTheme:e="system"}){const{selectedReport:t,calibrationData:n,setupData:a}=T_t(),i=w_t(t),{modules:o,selectedDefcons:s,sortBy:l,toggleDefcon:u,setSortBy:c}=C_t(i),{isDark:h,toggleTheme:d}=A_t(e,r);return z.useEffect(()=>{const p=t?.meta?.target_name||t?.meta?.model_name||a?.["plugins.model_name"]||null;return document.title=p?`NVIDIA Garak - ${p}`:"NVIDIA Garak",()=>{document.title="NVIDIA Garak"}},[t?.meta?.target_name,t?.meta?.model_name,a]),t?E.jsxs(qt,{direction:"col",style:{minHeight:"100vh"},children:[E.jsx(cK,{onThemeToggle:d,isDark:h}),E.jsxs(qt,{direction:"col",style:{flex:1},children:[E.jsxs(QC,{cols:{base:1,md:2},gap:"density-lg",padding:"density-lg",children:[E.jsx(vv,{fallbackMessage:"Failed to load report details. Please refresh the page.",children:E.jsx(gK,{setupData:a,calibrationData:n,meta:t.meta})}),E.jsx(vv,{fallbackMessage:"Failed to load summary statistics. Please refresh the page.",children:E.jsx(yK,{modules:i})})]}),E.jsx(vv,{fallbackMessage:"Failed to load the technique & intent matrix.",children:E.jsx(m_t,{matrix:t.technique_intent_matrix,isDark:h})}),E.jsx(_K,{selectedDefcons:s,onToggleDefcon:u,sortBy:l,onSortChange:c}),o.length>0?E.jsx(vv,{fallbackMessage:"Failed to load modules. Please refresh the page.",children:E.jsx(f_t,{modules:o,accordionKey:t?.meta.run_uuid??"default",isDark:h})}):E.jsx(_0,{slotMedia:E.jsx("i",{className:"nv-icons-line-warning"}),slotHeading:"No modules found in this report",slotSubheading:"Try changing the filters or sorting options"})]}),E.jsx(K$,{})]}):E.jsx(qt,{style:{height:"100vh",width:"100vw"},align:"center",justify:"center",children:E.jsx(T4,{size:"medium",description:"Loading reports..."})})}function D_t(){const[r,e]=z.useState("system");z.useEffect(()=>{const n=localStorage.getItem("kui-theme");n&&e(n)},[]);const t=n=>{e(n),localStorage.setItem("kui-theme",n)};return z.useEffect(()=>{const n=document.documentElement;if(n.classList.remove("nv-dark","nv-light"),r==="dark")n.classList.add("nv-dark");else if(r==="light")n.classList.add("nv-light");else if(r==="system"){const a=window.matchMedia("(prefers-color-scheme: dark)").matches;n.classList.add(a?"nv-dark":"nv-light")}},[r]),E.jsx(I4,{theme:r,children:E.jsx(M_t,{onThemeChange:t,currentTheme:r})})}nY.createRoot(document.getElementById("root")).render(E.jsx(z.StrictMode,{children:E.jsx(D_t,{})})); +