Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/docs/usage/attack-chaining/attack-path-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ detail panel to keep the working area clear.
6. Toggle between the **graph view** and the **table view** (top-right) if you prefer reviewing endpoints as a
sortable list.
7. Use the fullscreen toggle for a larger working area on complex chains.
8. Use the **Export as PNG** button (next to the fullscreen toggle, in the graph view) to download the map as an
image. The export always contains the **whole** graph — every node, whatever the current zoom or pan — so it can
be dropped into a report or a debrief as-is.

### Reading the legend

Expand Down
1 change: 1 addition & 0 deletions openaev-front/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"final-form": "4.20.10",
"final-form-arrays": "4.0.1",
"html-react-parser": "6.1.7",
"html-to-image": "1.11.13",
"http-proxy-middleware": "4.2.0",
"immutable": "5.1.9",
"ipaddr.js": "^2.2.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import type * as ReactRouter from 'react-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import SimulationAttackPath from '../../../../../../admin/components/simulations/simulation/attack_path/SimulationAttackPath';
import { MESSAGING$ } from '../../../../../../utils/Environment';
import { type AppAbility } from '../../../../../../utils/permissions/ability';
import { AbilityContext } from '../../../../../../utils/permissions/permissionsContext';
import type * as Utils from '../../../../../../utils/utils';

type FlowProps = {
focusRequest?: { nodeId: string };
Expand All @@ -18,6 +20,8 @@ type FlowProps = {
onEndpointClick?: (nodeId: string, ref?: string, label?: string) => void;
onInjectorSelect?: (injectorId: string, label?: string) => void;
onFindingClusterClick?: (clusterId: string, typeFindings: string | undefined, injectorId: string | undefined, endpointRef: string | undefined, kind: 'header' | 'overflow' | 'typeOverflow') => void;
exportRequest?: number;
onExportDone?: (png: Blob | null) => void;
};

// Capture the props AttackPathCanvas receives (mocked so the canvas is not instantiated), and stub
Expand All @@ -31,6 +35,7 @@ const mocks = vi.hoisted(() => ({
fetchEndpointRelations: vi.fn(),
fetchFindingsByCategory: vi.fn(),
fetchExecutionDetail: vi.fn(),
download: vi.fn(),
flowProps: { current: null as FlowProps | null },
// Read by the useEnterpriseEdition mock below; a test flips it to exercise the unlicensed path.
licenceValidated: true,
Expand All @@ -47,6 +52,16 @@ vi.mock('../../../../../../actions/attack-path/attack-path-actions', () => ({
fetchExecutionDetail: mocks.fetchExecutionDetail,
}));

// Only the download is stubbed: the test asserts what the export hands to the browser, not the
// anchor dance the helper does.
vi.mock('../../../../../../utils/utils', async (importOriginal) => {
const actual = await importOriginal<typeof Utils>();
return {
...actual,
download: mocks.download,
};
});

vi.mock('../../../../../../components/i18n', () => ({
useFormatter: () => ({
t: (s: string) => s,
Expand Down Expand Up @@ -412,6 +427,38 @@ describe('SimulationAttackPath findings drawer + cross-focus', () => {
expect(mocks.flowProps.current?.fitRequest ?? 0).toBeGreaterThan(0);
expect(screen.queryByText(/Executions/)).toBeNull();
});

it('exports the graph as a PNG and reports a failed capture instead of downloading nothing', async () => {
const notifyError = vi.spyOn(MESSAGING$, 'notifyError').mockImplementation(() => {});
setup();
await screen.findByTestId('attack-path-flow');

// The capture belongs to the canvas (it owns the world geometry and the culling), so the button
// only asks for one: a nonce the canvas turns into a blob.
const exportButton = screen.getByRole('button', { name: 'Export as PNG' });
fireEvent.click(exportButton);
await waitFor(() => expect(mocks.flowProps.current?.exportRequest ?? 0).toBeGreaterThan(0));
// A second click while the capture runs would queue a redundant one.
expect((exportButton as HTMLButtonElement).disabled).toBe(true);

// The returned image is downloaded under a name carrying the simulation it comes from.
const png = new Blob(['png'], { type: 'image/png' });
await act(async () => {
mocks.flowProps.current?.onExportDone?.(png);
});
expect(mocks.download).toHaveBeenCalledWith(png, 'attack-path-sim-1.png', 'image/png');
expect((exportButton as HTMLButtonElement).disabled).toBe(false);

// A failed capture must say so: silently doing nothing reads as a broken button.
mocks.download.mockClear();
fireEvent.click(exportButton);
await act(async () => {
mocks.flowProps.current?.onExportDone?.(null);
});
expect(mocks.download).not.toHaveBeenCalled();
expect(notifyError).toHaveBeenCalledWith('Error while exporting the attack path');
notifyError.mockRestore();
});
});

// The live path (issue 6647): one snapshot then a 3 s delta poll. Timers are faked so the cadence, the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { createTheme, ThemeProvider } from '@mui/material/styles';
import { act, cleanup, render } from '@testing-library/react';
import { type ReactNode } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { AP_FLOW_NODE_TYPE, type AttackPathFlowNode } from '../../../../../../../admin/components/simulations/simulation/attack_path/attack-path-flow-helpers';
import AttackPathCanvas from '../../../../../../../admin/components/simulations/simulation/attack_path/canvas/AttackPathCanvas';
import { computeCardRects, computeContentBounds } from '../../../../../../../admin/components/simulations/simulation/attack_path/canvas/canvas-geometry';

const mocks = vi.hoisted(() => ({ toBlob: vi.fn() }));

// html-to-image needs a real browser (foreignObject + canvas), so the DOM capture itself is stubbed:
// what this test pins is the contract the canvas offers to it — the frame of the WHOLE graph.
vi.mock('html-to-image', () => ({ toBlob: mocks.toBlob }));

vi.mock('../../../../../../../components/i18n', () => ({ useFormatter: () => ({ t: (s: string) => s }) }));

const wrapper = ({ children }: { children: ReactNode }) => (
<ThemeProvider theme={createTheme()}>{children}</ThemeProvider>
);

// Two cards far apart, so an export that only framed the visible camera would crop one out.
const nodes: AttackPathFlowNode[] = [{
id: 'NODE_ASSET|host-x',
type: AP_FLOW_NODE_TYPE.asset,
position: {
x: 0,
y: 0,
},
data: { label: 'CORP-HOST' },
}, {
id: 'NODE_FINDING|cred-1',
type: AP_FLOW_NODE_TYPE.finding,
position: {
x: 1600,
y: 900,
},
data: { label: 'admin:••••' },
}];

const settleExport = async (render: () => void) => {
await act(async () => {
render();
await new Promise((resolve) => {
requestAnimationFrame(() => setTimeout(resolve, 0));
});
});
};

describe('AttackPathCanvas PNG export', () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});

it('captures the whole graph, not the part the camera happens to show', async () => {
const png = new Blob(['png'], { type: 'image/png' });
mocks.toBlob.mockResolvedValue(png);
const onExportDone = vi.fn();

const { rerender } = render(
<AttackPathCanvas nodes={nodes} edges={[]} exportRequest={0} onExportDone={onExportDone} />,
{ wrapper },
);
expect(mocks.toBlob).not.toHaveBeenCalled();

// A new nonce asks for a capture: the canvas mounts the culled cards, then reads the DOM one
// frame later.
await settleExport(() => rerender(
<AttackPathCanvas nodes={nodes} edges={[]} exportRequest={1} onExportDone={onExportDone} />,
));

expect(mocks.toBlob).toHaveBeenCalledTimes(1);
const [, options] = mocks.toBlob.mock.calls[0];
// The frame is the whole laid-out world plus the export margin — the far card included, which a
// capture of the visible camera could not contain.
const world = computeContentBounds(computeCardRects(nodes));
expect(options.width).toBe(world.width + 96);
expect(options.height).toBe(world.height + 96);
expect(options.width).toBeGreaterThan(400);
expect(options.height).toBeGreaterThan(400);
// The live camera transform is replaced by the framing one, so pan/zoom cannot crop the image.
expect(options.style.transform).toBe('translate(48px, 48px)');
expect(options.backgroundColor).toBeTruthy();

expect(onExportDone).toHaveBeenCalledWith(png);
});

it('reports a failed capture rather than handing back a broken image', async () => {
mocks.toBlob.mockRejectedValue(new Error('boom'));
const onExportDone = vi.fn();

const { rerender } = render(
<AttackPathCanvas nodes={nodes} edges={[]} exportRequest={0} onExportDone={onExportDone} />,
{ wrapper },
);
await settleExport(() => rerender(
<AttackPathCanvas nodes={nodes} edges={[]} exportRequest={1} onExportDone={onExportDone} />,
));

expect(onExportDone).toHaveBeenCalledWith(null);
});

it('does not replay the last capture when the canvas is remounted', async () => {
mocks.toBlob.mockResolvedValue(new Blob(['png'], { type: 'image/png' }));
const onExportDone = vi.fn();

// Mounting with a nonce already spent (leaving and coming back to the graph view) must not
// download a second image behind the user's back.
await settleExport(() => {
render(
<AttackPathCanvas nodes={nodes} edges={[]} exportRequest={7} onExportDone={onExportDone} />,
{ wrapper },
);
});

expect(mocks.toBlob).not.toHaveBeenCalled();
expect(onExportDone).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AccountTreeOutlined, ArrowBackOutlined, FilterAltOffOutlined, FullscreenExitOutlined, FullscreenOutlined, HelpOutline, LocalFireDepartment, MoreHorizOutlined, SearchOutlined, TableRowsOutlined } from '@mui/icons-material';
import { Autocomplete, Box, Button, ButtonBase, ListItemButton, Paper, Popover, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material';
import { AccountTreeOutlined, ArrowBackOutlined, FilterAltOffOutlined, FullscreenExitOutlined, FullscreenOutlined, HelpOutline, ImageOutlined, LocalFireDepartment, MoreHorizOutlined, SearchOutlined, TableRowsOutlined } from '@mui/icons-material';
import { Autocomplete, Box, Button, ButtonBase, CircularProgress, IconButton, ListItemButton, Paper, Popover, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography } from '@mui/material';
import { alpha, useTheme } from '@mui/material/styles';
import { type FunctionComponent, type MouseEvent, type ReactNode, useState } from 'react';

Expand Down Expand Up @@ -184,6 +184,9 @@ interface Props {
// Fullscreen toggle.
fullscreen: boolean;
onToggleFullscreen: () => void;
// PNG export of the whole graph (graph view only; omitted when there is nothing to export).
onExportPng?: () => void;
exportingPng?: boolean;
// Search (endpoint / injector / finding type).
searchOptions: SearchOption[];
searchInput: string;
Expand Down Expand Up @@ -218,6 +221,8 @@ const AttackPathHeader: FunctionComponent<Props> = ({
onViewChange,
fullscreen,
onToggleFullscreen,
onExportPng,
exportingPng,
searchOptions,
searchInput,
onSearchInputChange,
Expand Down Expand Up @@ -637,6 +642,28 @@ const AttackPathHeader: FunctionComponent<Props> = ({
<Tooltip title={t('Table')}><TableRowsOutlined fontSize="small" /></Tooltip>
</ToggleButton>
</ToggleButtonGroup>
{/* Action, not a state: an IconButton with the segmented controls' outline so the band still
reads as one family. Only the graph can be rasterized — the table has its own CSV export. */}
{view === 'graph' && onExportPng && (
<Tooltip title={t('Export as PNG')}>
<span>
<IconButton
size="small"
aria-label={t('Export as PNG')}
onClick={onExportPng}
disabled={exportingPng}
sx={{
width: CONTROL_HEIGHT,
height: CONTROL_HEIGHT,
borderRadius: 1,
border: `1px solid ${theme.palette.divider}`,
}}
>
{exportingPng ? <CircularProgress size={16} /> : <ImageOutlined fontSize="small" />}
</IconButton>
</span>
</Tooltip>
)}
{/* Standalone ToggleButton so fullscreen reads as part of the same segmented family. */}
<ToggleButton
size="small"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
} from '../../../../../utils/api-types';
import { MESSAGING$ } from '../../../../../utils/Environment';
import useRemainingViewportHeight from '../../../../../utils/hooks/useRemainingViewportHeight';
import { download } from '../../../../../utils/utils';
import ChainingUpdatedBanner from '../../../chaining/ChainingUpdatedBanner';
import useSnapshotUpdated from '../../../chaining/useSnapshotUpdated';
import attackPathStatusColor from './attack-path-colors';
Expand Down Expand Up @@ -2522,6 +2523,25 @@ const SimulationAttackPath = ({ scenarioExerciseIds, scenarioId, hideLaunchCta =
return () => window.removeEventListener('keydown', onKey);
}, [fullscreen]);

// PNG export of the graph. The capture itself belongs to the canvas (it owns the world geometry
// and the off-screen culling), so the button only fires a nonce and waits for the blob back.
const [exportNonce, setExportNonce] = useState(0);
const [exportingPng, setExportingPng] = useState(false);
const requestPngExport = useCallback(() => {
setExportingPng(true);
setExportNonce(n => n + 1);
}, []);
const onPngExported = useCallback((png: Blob | null) => {
setExportingPng(false);
if (!png) {
MESSAGING$.notifyError(t('Error while exporting the attack path'));
return;
}
const name = metaById.get(simulationId)?.exercise_name || simulationId;
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
download(png, `attack-path-${slug || 'graph'}.png`, 'image/png');
}, [metaById, simulationId, t]);

// Free-text search input (endpoint / injector / finding type), used by the search autocomplete.
const [searchInput, setSearchInput] = useState('');

Expand Down Expand Up @@ -2731,6 +2751,8 @@ const SimulationAttackPath = ({ scenarioExerciseIds, scenarioId, hideLaunchCta =
onViewChange={setView}
fullscreen={fullscreen}
onToggleFullscreen={() => setFullscreen(f => !f)}
onExportPng={graphHasContent ? requestPngExport : undefined}
exportingPng={exportingPng}
searchOptions={searchOptions}
searchInput={searchInput}
onSearchInputChange={setSearchInput}
Expand Down Expand Up @@ -2877,6 +2899,8 @@ const SimulationAttackPath = ({ scenarioExerciseIds, scenarioId, hideLaunchCta =
pursuitActive={pursuitActive && !pathFinding}
showMiniMap={!pathFinding && nodes.length > 40}
legend={<AttackPathLegend collapseSignal={legendCollapseNonce} />}
exportRequest={exportNonce}
onExportDone={onPngExported}
/>
)}
</Paper>
Expand Down
Loading
Loading