diff --git a/cmd/ui/src/components/SigmaChart.tsx b/cmd/ui/src/components/SigmaChart.tsx index 683ff92d44..b0d9271c26 100644 --- a/cmd/ui/src/components/SigmaChart.tsx +++ b/cmd/ui/src/components/SigmaChart.tsx @@ -36,7 +36,6 @@ import { Box } from '@mui/material'; import { GraphNodes } from 'js-client-library'; import { GraphButtonProps, SearchCurrentNodes } from 'bh-shared-ui'; import { SigmaNodeEventPayload } from 'sigma/sigma'; -import ContextMenu from 'src/views/Explore/ContextMenu/ContextMenu'; interface SigmaChartProps { rankDirection: RankDirection; @@ -51,8 +50,7 @@ interface SigmaChartProps { onClickEdge: (id: string, relatedFindingType?: string | null) => void; onClickStage: () => void; edgeReducer: (edge: string, data: Attributes, graph: AbstractGraph) => Attributes; - anchorPosition: { x: number; y: number }; - onRightClickNode: (event: SigmaNodeEventPayload) => void; + handleContextMenu: (event: SigmaNodeEventPayload) => void; } const SigmaChart: FC> = ({ @@ -68,8 +66,7 @@ const SigmaChart: FC> = ({ onClickEdge, onClickStage, edgeReducer, - anchorPosition, - onRightClickNode, + handleContextMenu, }) => { return (
> = ({ onClickEdge={onClickEdge} onClickStage={onClickStage} edgeReducer={edgeReducer} - onRightClickNode={onRightClickNode} + onRightClickNode={handleContextMenu} /> {isCurrentSearchOpen && ( @@ -125,7 +122,6 @@ const SigmaChart: FC> = ({ )} -
); diff --git a/cmd/ui/src/ducks/assetgroups/reducer.ts b/cmd/ui/src/ducks/assetgroups/reducer.ts index f436a2288f..c61fa84849 100644 --- a/cmd/ui/src/ducks/assetgroups/reducer.ts +++ b/cmd/ui/src/ducks/assetgroups/reducer.ts @@ -17,6 +17,7 @@ import { produce } from 'immer'; import * as actions from './actions'; import * as types from './types'; +import { AppState } from 'src/store'; const INITIAL_STATE: types.AssetGroupsState = { assetGroups: [], @@ -63,4 +64,12 @@ const asssetGroupReducer = (state: types.AssetGroupsState = INITIAL_STATE, actio }); }; +export const selectTierZeroAssetGroupId = (state: AppState) => { + return state.assetgroups.assetGroups.find((assetGroup) => assetGroup.tag === 'admin_tier_0')?.id; +}; + +export const selectOwnedAssetGroupId = (state: AppState) => { + return state.assetgroups.assetGroups.find((assetGroup) => assetGroup.tag === 'owned')?.id; +}; + export default asssetGroupReducer; diff --git a/cmd/ui/src/ducks/explore/actions.ts b/cmd/ui/src/ducks/explore/actions.ts index 35dfa4d43f..a0fe96dd52 100644 --- a/cmd/ui/src/ducks/explore/actions.ts +++ b/cmd/ui/src/ducks/explore/actions.ts @@ -125,3 +125,10 @@ export const saveResponseForExport = (payload: Items): types.GraphActionTypes => payload, }; }; + +export const toggleTierZeroNode = (nodeId: string): types.GraphActionTypes => { + return { + type: types.TOGGLE_TIER_ZERO_NODE, + nodeId, + }; +}; diff --git a/cmd/ui/src/ducks/explore/reducer.ts b/cmd/ui/src/ducks/explore/reducer.ts index b943073fe5..998833cd78 100644 --- a/cmd/ui/src/ducks/explore/reducer.ts +++ b/cmd/ui/src/ducks/explore/reducer.ts @@ -48,6 +48,14 @@ const graphDataReducer = (state = initialGraphDataState, action: types.GraphActi draft.init = action.payload; } else if (action.type === types.SAVE_RESPONSE_FOR_EXPORT) { draft.export = action.payload; + } else if (action.type === types.TOGGLE_TIER_ZERO_NODE) { + const systemTags = state.chartProps.items[action.nodeId].data.system_tags; + // remove the tier zero tag from the node + if (systemTags === 'admin_tier_0') { + draft.chartProps.items[action.nodeId].data.system_tags = ''; + } else { + draft.chartProps.items[action.nodeId].data.system_tags = 'admin_tier_0'; + } } return draft; }); diff --git a/cmd/ui/src/ducks/explore/types.ts b/cmd/ui/src/ducks/explore/types.ts index f472e89426..efcdd5a01b 100644 --- a/cmd/ui/src/ducks/explore/types.ts +++ b/cmd/ui/src/ducks/explore/types.ts @@ -28,6 +28,8 @@ const REMOVE_NODES = 'app/explore/REMOVENODE'; const SAVE_RESPONSE_FOR_EXPORT = 'app/explore/SAVE_RESPONSE_FOR_EXPORT'; +const TOGGLE_TIER_ZERO_NODE = 'app/explore/TOGGLE_TIER_ZERO_NODE'; + export { SET_GRAPH_LOADING, GRAPH_START, @@ -38,6 +40,7 @@ export { REMOVE_NODES, GRAPH_INIT, SAVE_RESPONSE_FOR_EXPORT, + TOGGLE_TIER_ZERO_NODE, }; export enum GraphEndpoints {} @@ -97,6 +100,11 @@ interface SaveResponseForExportAction { payload: Items; } +interface ToggleTierZeroNodeAction { + type: typeof TOGGLE_TIER_ZERO_NODE; + nodeId: string; +} + export type GraphActionTypes = | SetGraphLoadingAction | GraphStartAction @@ -106,7 +114,8 @@ export type GraphActionTypes = | AddNodeAction | RemoveNodeAction | GraphInitAction - | SaveResponseForExportAction; + | SaveResponseForExportAction + | ToggleTierZeroNodeAction; export interface NodeInfoRequest { type: typeof GRAPH_START; diff --git a/cmd/ui/src/views/Explore/ContextMenu/AssetGroupMenuItem.test.tsx b/cmd/ui/src/views/Explore/ContextMenu/AssetGroupMenuItem.test.tsx new file mode 100644 index 0000000000..db93501d51 --- /dev/null +++ b/cmd/ui/src/views/Explore/ContextMenu/AssetGroupMenuItem.test.tsx @@ -0,0 +1,270 @@ +// Copyright 2023 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { act } from 'react-dom/test-utils'; +import { render, screen } from 'src/test-utils'; +import userEvent from '@testing-library/user-event'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; +import AssetGroupMenuItem from './AssetGroupMenuItem'; +import { apiClient } from 'bh-shared-ui'; + +describe('AssetGroupMenuItem', async () => { + const tierZeroAssetGroup = { id: '1', name: 'high value' }; + const ownedAssetGroup = { id: '2', name: 'owned' }; + + const getEntityInfoTestProps = () => ({ + entityinfo: { + selectedNode: { + name: 'foo', + id: '1234', + type: 'User', + }, + }, + }); + + const getAssetGroupTestProps = ({ isTierZero }: { isTierZero: boolean }) => ({ + assetgroups: { + assetGroups: isTierZero + ? [{ tag: 'admin_tier_0', id: tierZeroAssetGroup.id }] + : [{ tag: 'owned', id: ownedAssetGroup.id }], + }, + }); + + describe('adding to an asset group', () => { + const server = setupServer( + rest.get('/api/v2/asset-groups/:assetGroupId/members', (req, res, ctx) => { + // handle `tier zero` requests + if (req.params.assetGroupId === tierZeroAssetGroup.id) { + return res( + ctx.json({ + data: { + members: [], + }, + }) + ); + } else if (req.params.assetGroupId === ownedAssetGroup.id) { + // handle `owned` requests + return res( + ctx.json({ + data: { + // members: [{ custom_member: true }], + members: [], + }, + }) + ); + } else { + return res(ctx.json({})); + } + }), + rest.post('/api/v2/asset-groups/:assetGroupId/selectors', (req, res, ctx) => { + return res(ctx.json({})); + }) + ); + + beforeAll(() => server.listen()); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => server.close()); + + it('handles adding to tier zero asset group', async () => { + await act(async () => { + render( + , + { + initialState: { + ...getEntityInfoTestProps(), + ...getAssetGroupTestProps({ isTierZero: true }), + }, + } + ); + }); + + const user = userEvent.setup(); + const addToHighValueSpy = vi.spyOn(apiClient, 'updateAssetGroupSelector'); + + const addToHighValueButton = screen.getByRole('menuitem', { name: /add to high value/i }); + expect(addToHighValueButton).toBeInTheDocument(); + + await user.click(addToHighValueButton); + + const confirmationDialog = screen.getByRole('dialog', { name: /confirm selection/i }); + expect(confirmationDialog).toBeInTheDocument(); + + const applyButton = screen.getByRole('button', { name: /ok/i }); + await user.click(applyButton); + + expect(addToHighValueSpy).toHaveBeenCalledTimes(1); + expect(addToHighValueSpy).toHaveBeenCalledWith(tierZeroAssetGroup.id, [ + { + action: 'add', + selector_name: '1234', + sid: '1234', + }, + ]); + }); + + it('handles adding to non-tier-zero asset group', async () => { + await act(async () => { + render(, { + initialState: { + ...getEntityInfoTestProps(), + ...getAssetGroupTestProps({ isTierZero: false }), + }, + }); + }); + + const user = userEvent.setup(); + const addToAssetGroupSpy = vi.spyOn(apiClient, 'updateAssetGroupSelector'); + + const addButton = screen.getByRole('menuitem', { name: /add to owned/i }); + expect(addButton).toBeInTheDocument(); + + await user.click(addButton); + + expect(addToAssetGroupSpy).toHaveBeenCalledTimes(1); + expect(addToAssetGroupSpy).toHaveBeenCalledWith(ownedAssetGroup.id, [ + { + action: 'add', + selector_name: '1234', + sid: '1234', + }, + ]); + }); + + it('renders null if network fails to return valid asset group membership list', async () => { + render(, {}); + + expect(document.body.firstChild).toBeEmptyDOMElement(); + }); + }); + + describe('removing from an asset group', () => { + const server = setupServer( + rest.get('/api/v2/asset-groups/:assetGroupId/members', (req, res, ctx) => { + // handle `tier zero` requests + if (req.params.assetGroupId === tierZeroAssetGroup.id) { + return res( + ctx.json({ + data: { + members: [{ custom_member: true }], + }, + }) + ); + } else if (req.params.assetGroupId === ownedAssetGroup.id) { + // handle `owned` requests + return res( + ctx.json({ + data: { + members: [{ custom_member: true }], + }, + }) + ); + } else { + return res(ctx.json({})); + } + }), + rest.post('/api/v2/asset-groups/:assetGroupId/selectors', (req, res, ctx) => { + return res(ctx.json({})); + }) + ); + + beforeAll(() => server.listen()); + + afterEach(() => { + server.resetHandlers(); + }); + + afterAll(() => server.close()); + + it('handles removing from a tier zero asset group', async () => { + await act(async () => { + await render( + , + { + initialState: { + ...getEntityInfoTestProps(), + ...getAssetGroupTestProps({ isTierZero: true }), + }, + } + ); + }); + + const user = userEvent.setup(); + const removeFromAssetGroupSpy = vi.spyOn(apiClient, 'updateAssetGroupSelector'); + + const removeButton = screen.getByRole('menuitem', { name: /remove from high value/i }); + expect(removeButton).toBeInTheDocument(); + + await user.click(removeButton); + + const confirmationDialog = screen.getByRole('dialog', { name: /confirm selection/i }); + expect(confirmationDialog).toBeInTheDocument(); + + const applyButton = screen.getByRole('button', { name: /ok/i }); + await user.click(applyButton); + + expect(removeFromAssetGroupSpy).toHaveBeenCalledTimes(1); + expect(removeFromAssetGroupSpy).toHaveBeenCalledWith(tierZeroAssetGroup.id, [ + { + action: 'remove', + selector_name: '1234', + sid: '1234', + }, + ]); + }); + + it('handles removing from a non-tier-zero asset group', async () => { + await act(async () => { + await render( + , + { + initialState: { + ...getEntityInfoTestProps(), + ...getAssetGroupTestProps({ isTierZero: false }), + }, + } + ); + }); + + const user = userEvent.setup(); + const removeFromAssetGroupSpy = vi.spyOn(apiClient, 'updateAssetGroupSelector'); + + const removeButton = screen.getByRole('menuitem', { name: /remove from owned/i }); + expect(removeButton).toBeInTheDocument(); + + await user.click(removeButton); + + expect(removeFromAssetGroupSpy).toHaveBeenCalledTimes(1); + expect(removeFromAssetGroupSpy).toHaveBeenCalledWith(ownedAssetGroup.id, [ + { + action: 'remove', + selector_name: '1234', + sid: '1234', + }, + ]); + }); + }); +}); diff --git a/cmd/ui/src/views/Explore/ContextMenu/AssetGroupMenuItem.tsx b/cmd/ui/src/views/Explore/ContextMenu/AssetGroupMenuItem.tsx new file mode 100644 index 0000000000..b954403692 --- /dev/null +++ b/cmd/ui/src/views/Explore/ContextMenu/AssetGroupMenuItem.tsx @@ -0,0 +1,155 @@ +// Copyright 2023 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { Button, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem } from '@mui/material'; +import { apiClient, useNotifications } from 'bh-shared-ui'; +import { FC, useState } from 'react'; +import { useMutation, useQuery } from 'react-query'; +import { useSelector } from 'react-redux'; +import { selectTierZeroAssetGroupId } from 'src/ducks/assetgroups/reducer'; +import { toggleTierZeroNode } from 'src/ducks/explore/actions'; +import { AppState, useAppDispatch } from 'src/store'; + +const AssetGroupMenuItem: FC<{ assetGroupId: string; assetGroupName: string }> = ({ assetGroupId, assetGroupName }) => { + const { addNotification } = useNotifications(); + const dispatch = useAppDispatch(); + + const [open, setOpen] = useState(false); + + const selectedNode = useSelector((state: AppState) => state.entityinfo.selectedNode); + const tierZeroAssetGroupId = useSelector(selectTierZeroAssetGroupId); + + const isMenuItemForTierZero = assetGroupId === tierZeroAssetGroupId; + + const mutation = useMutation({ + mutationFn: ({ nodeId, action }: { nodeId: string; action: 'add' | 'remove' }) => { + return apiClient.updateAssetGroupSelector(assetGroupId, [ + { + selector_name: nodeId, + sid: nodeId, + action, + }, + ]); + }, + onSuccess: () => { + if (selectedNode?.graphId && isMenuItemForTierZero) { + dispatch(toggleTierZeroNode(selectedNode.graphId)); + } + + addNotification('Update successful.', 'AssetGroupUpdateSuccess'); + }, + onError: (error: any) => { + console.error(error); + addNotification('Unknown error, group was not updated', 'AssetGroupUpdateError'); + }, + }); + + const { data: assetGroupMembers } = useQuery(['listAssetGroupMembers', assetGroupId], () => + apiClient + .listAssetGroupMembers(assetGroupId, undefined, { + params: { + object_id: `object_id=eq:${selectedNode?.id}`, + }, + }) + .then((res) => res.data.data?.members) + ); + + const handleAddToAssetGroup = () => { + if (selectedNode) { + mutation.mutate({ nodeId: selectedNode.id, action: 'add' }); + } + }; + + const handleRemoveFromAssetGroup = () => { + if (selectedNode) { + mutation.mutate({ nodeId: selectedNode.id, action: 'remove' }); + } + }; + + const handleOpenConfirmation = (e: React.MouseEvent) => { + e.stopPropagation(); + setOpen(true); + }; + + const handleCloseConfirmation = () => { + setOpen(false); + }; + + // error state, data didn't load + if (!assetGroupMembers) { + return null; + } + + // selected node is not a member of the group + if (assetGroupMembers.length === 0) { + return ( + <> + + Add to {assetGroupName} + + {isMenuItemForTierZero ? ( + + ) : null} + + ); + } + + // selected node is a custom member of the group + if (assetGroupMembers.length === 1 && assetGroupMembers[0].custom_member) { + return ( + <> + + Remove from {assetGroupName} + + {isMenuItemForTierZero ? ( + setOpen(false)} + handleApply={handleRemoveFromAssetGroup} + open={open} + dialogContent={`Are you sure you want to remove this node from ${assetGroupName}? This action will initiate an analysis run to update group membership.`} + /> + ) : null} + + ); + } +}; + +const ConfirmationDialog: FC<{ + open: boolean; + handleCancel: () => void; + handleApply: () => void; + dialogContent: string; +}> = ({ open, handleApply, handleCancel, dialogContent }) => { + return ( + + Confirm Selection + {dialogContent} + + + + + + ); +}; + +export default AssetGroupMenuItem; diff --git a/cmd/ui/src/views/Explore/ContextMenu/ContextMenu.test.tsx b/cmd/ui/src/views/Explore/ContextMenu/ContextMenu.test.tsx index 4ca0113fa4..aeb03b7568 100644 --- a/cmd/ui/src/views/Explore/ContextMenu/ContextMenu.test.tsx +++ b/cmd/ui/src/views/Explore/ContextMenu/ContextMenu.test.tsx @@ -19,11 +19,26 @@ import { render, screen, waitFor } from 'src/test-utils'; import userEvent from '@testing-library/user-event'; import ContextMenu from './ContextMenu'; import * as actions from 'src/ducks/searchbar/actions'; +import { setupServer } from 'msw/node'; +import { rest } from 'msw'; describe('ContextMenu', async () => { + const server = setupServer( + rest.get('/api/v2/asset-groups/:assetGroupId/members', (req, res, ctx) => { + return res( + ctx.json({ + data: { + members: [], + }, + }) + ); + }) + ); + + beforeAll(() => server.listen()); beforeEach(async () => { await act(async () => { - render(, { + render(, { initialState: { entityinfo: { selectedNode: { @@ -32,17 +47,31 @@ describe('ContextMenu', async () => { type: 'User', }, }, + assetgroups: { + assetGroups: [ + { tag: 'owned', id: 1 }, + { tag: 'admin_tier_0', id: 2 }, + ], + }, }, }); }); }); + afterEach(() => { + server.resetHandlers(); + }); + afterAll(() => server.close()); it('renders', () => { const startNodeOption = screen.getByRole('menuitem', { name: /set as starting node/i }); const endNodeOption = screen.getByRole('menuitem', { name: /set as ending node/i }); + const addToHighValueOption = screen.getByRole('menuitem', { name: /add to high value/i }); + const addToOwnedOption = screen.getByRole('menuitem', { name: /add to owned/i }); expect(startNodeOption).toBeInTheDocument(); expect(endNodeOption).toBeInTheDocument(); + expect(addToHighValueOption).toBeInTheDocument(); + expect(addToOwnedOption).toBeInTheDocument(); }); it('handles setting a start node', async () => { @@ -75,7 +104,7 @@ describe('ContextMenu', async () => { }); }); - it('opens a submenu when user clicks `Copy`', async () => { + it('opens a submenu when user hovers over `Copy`', async () => { const user = userEvent.setup(); const copyOption = screen.getByRole('menuitem', { name: /copy/i }); @@ -102,18 +131,4 @@ describe('ContextMenu', async () => { expect(screen.queryByText(/cypher/i)).not.toBeInTheDocument(); }); }); - - it('handles copying a display name', async () => { - const user = userEvent.setup(); - - const copyOption = screen.getByRole('menuitem', { name: /copy/i }); - await user.click(copyOption); - - // the tooltip container and the menu item for `display name` have the same accesible name, so return the second element here (which is the menu item) - const displayNameOption = screen.getAllByRole('menuitem', { name: /display name/i })[1]; - await user.click(displayNameOption); - - const clipboardText = await navigator.clipboard.readText(); - expect(clipboardText).toBe('foo'); - }); }); diff --git a/cmd/ui/src/views/Explore/ContextMenu/ContextMenu.tsx b/cmd/ui/src/views/Explore/ContextMenu/ContextMenu.tsx index 889f383446..f0bd66fd5d 100644 --- a/cmd/ui/src/views/Explore/ContextMenu/ContextMenu.tsx +++ b/cmd/ui/src/views/Explore/ContextMenu/ContextMenu.tsx @@ -14,32 +14,26 @@ // // SPDX-License-Identifier: Apache-2.0 -import { Menu, MenuItem, Tooltip, TooltipProps, styled, tooltipClasses } from '@mui/material'; -import { useNotifications } from 'bh-shared-ui'; -import { FC, useEffect, useState } from 'react'; +import { Menu, MenuItem } from '@mui/material'; + +import { FC } from 'react'; import { useSelector } from 'react-redux'; import { destinationNodeSelected, sourceNodeSelected, tabChanged } from 'src/ducks/searchbar/actions'; import { AppState, useAppDispatch } from 'src/store'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { faCaretRight } from '@fortawesome/free-solid-svg-icons'; - -const ContextMenu: FC<{ anchorPosition?: { x: number; y: number } }> = ({ anchorPosition }) => { +import { selectOwnedAssetGroupId, selectTierZeroAssetGroupId } from 'src/ducks/assetgroups/reducer'; +import AssetGroupMenuItem from './AssetGroupMenuItem'; +import CopyMenuItem from './CopyMenuItem'; + +const ContextMenu: FC<{ contextMenu: { mouseX: number; mouseY: number } | null; handleClose: () => void }> = ({ + contextMenu, + handleClose, +}) => { const dispatch = useAppDispatch(); - const [open, setOpen] = useState(false); const selectedNode = useSelector((state: AppState) => state.entityinfo.selectedNode); - useEffect(() => { - if (anchorPosition) { - setOpen(true); - } else { - setOpen(false); - } - }, [anchorPosition]); - - const handleClick = () => { - setOpen(false); - }; + const ownedAssetGroupId = useSelector(selectOwnedAssetGroupId); + const tierZeroAssetGroupId = useSelector(selectTierZeroAssetGroupId); const handleSetStartingNode = () => { if (selectedNode) { @@ -69,73 +63,18 @@ const ContextMenu: FC<{ anchorPosition?: { x: number; y: number } }> = ({ anchor return ( + onClick={handleClose}> Set as starting node Set as ending node - - - ); -}; -const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( - -))(({ theme }) => ({ - [`& .${tooltipClasses.tooltip}`]: { - color: 'black', - backgroundColor: theme.palette.common.white, - padding: 0, - paddingTop: '0.5rem', - paddingBottom: '0.5rem', - boxShadow: theme.shadows[8], - }, -})); + + -const CopyMenuItem = () => { - const { addNotification } = useNotifications(); - - const selectedNode = useSelector((state: AppState) => state.entityinfo.selectedNode); - - const handleDisplayName = () => { - if (selectedNode) { - navigator.clipboard.writeText(selectedNode.name); - addNotification(`Display name copied to clipboard`, 'copyToClipboard'); - } - }; - - const handleObjectId = () => { - if (selectedNode) { - navigator.clipboard.writeText(selectedNode.id); - addNotification(`Object ID name copied to clipboard`, 'copyToClipboard'); - } - }; - - const handleCypher = () => { - if (selectedNode) { - const cypher = `MATCH (n:${selectedNode.type}) WHERE n.objectid = '${selectedNode.id}' RETURN n`; - navigator.clipboard.writeText(cypher); - addNotification(`Cypher copied to clipboard`, 'copyToClipboard'); - } - }; - - return ( -
- - Display Name - Object ID - Cypher - - }> - e.stopPropagation()}> - Copy - - -
+ + ); }; diff --git a/cmd/ui/src/views/Explore/ContextMenu/CopyMenuItem.test.tsx b/cmd/ui/src/views/Explore/ContextMenu/CopyMenuItem.test.tsx new file mode 100644 index 0000000000..a84827abab --- /dev/null +++ b/cmd/ui/src/views/Explore/ContextMenu/CopyMenuItem.test.tsx @@ -0,0 +1,53 @@ +// Copyright 2023 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { render } from 'src/test-utils'; +import CopyMenuItem from './CopyMenuItem'; + +describe('CopyMenuItem', () => { + const selectedNode = { + name: 'foo', + }; + + beforeEach(() => { + render(, { + initialState: { + entityinfo: { + selectedNode, + }, + }, + }); + }); + + it('handles copying a display name', async () => { + const user = userEvent.setup(); + + const copyOption = screen.getByRole('menuitem', { name: /copy/i }); + await user.hover(copyOption); + + const tooltip = await screen.findByRole('tooltip'); + expect(tooltip).toBeInTheDocument(); + + // the tooltip container and the menu item for `display name` have the same accesible name, so return the second element here (which is the menu item) + const displayNameOption = screen.getAllByRole('menuitem', { name: /display name/i })[1]; + await user.click(displayNameOption); + + const clipboardText = await navigator.clipboard.readText(); + expect(clipboardText).toBe(selectedNode.name); + }); +}); diff --git a/cmd/ui/src/views/Explore/ContextMenu/CopyMenuItem.tsx b/cmd/ui/src/views/Explore/ContextMenu/CopyMenuItem.tsx new file mode 100644 index 0000000000..2d47dddb61 --- /dev/null +++ b/cmd/ui/src/views/Explore/ContextMenu/CopyMenuItem.tsx @@ -0,0 +1,84 @@ +// Copyright 2023 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +import { faCaretRight } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { MenuItem, Tooltip, TooltipProps, styled, tooltipClasses } from '@mui/material'; +import { useNotifications } from 'bh-shared-ui'; +import { useSelector } from 'react-redux'; +import { AppState } from 'src/store'; + +const StyledTooltip = styled(({ className, ...props }: TooltipProps) => ( + +))(({ theme }) => ({ + [`& .${tooltipClasses.tooltip}`]: { + color: 'black', + backgroundColor: theme.palette.common.white, + padding: 0, + paddingTop: '0.5rem', + paddingBottom: '0.5rem', + boxShadow: theme.shadows[8], + marginLeft: '2px !important', + }, +})); + +const CopyMenuItem = () => { + const { addNotification } = useNotifications(); + + const selectedNode = useSelector((state: AppState) => state.entityinfo.selectedNode); + + const handleCopyDisplayName = () => { + if (selectedNode) { + navigator.clipboard.writeText(selectedNode.name); + addNotification(`Display name copied to clipboard`, 'copyToClipboard'); + } + }; + + const handleCopyObjectId = () => { + if (selectedNode) { + navigator.clipboard.writeText(selectedNode.id); + addNotification(`Object ID name copied to clipboard`, 'copyToClipboard'); + } + }; + + const handleCopyCypher = () => { + if (selectedNode) { + const cypher = `MATCH (n:${selectedNode.type}) WHERE n.objectid = '${selectedNode.id}' RETURN n`; + navigator.clipboard.writeText(cypher); + addNotification(`Cypher copied to clipboard`, 'copyToClipboard'); + } + }; + + return ( +
+ + Display Name + Object ID + Cypher + + }> + e.stopPropagation()}> + Copy + + +
+ ); +}; + +export default CopyMenuItem; diff --git a/cmd/ui/src/views/Explore/GraphView.tsx b/cmd/ui/src/views/Explore/GraphView.tsx index 944902116b..d881478bee 100644 --- a/cmd/ui/src/views/Explore/GraphView.tsx +++ b/cmd/ui/src/views/Explore/GraphView.tsx @@ -51,6 +51,7 @@ import EntityInfoPanel from 'src/views/Explore/EntityInfo/EntityInfoPanel'; import ExploreSearch from 'src/views/Explore/ExploreSearch'; import usePrompt from 'src/views/Explore/NavigationAlert'; import { initGraphEdges, initGraphNodes } from 'src/views/Explore/utils'; +import ContextMenu from './ContextMenu/ContextMenu'; const GraphView: FC = () => { /* Hooks */ @@ -67,7 +68,7 @@ const GraphView: FC = () => { const [currentSearchOpen, toggleCurrentSearch] = useToggle(false); const { data, isLoading, isError } = useAvailableDomains(); - const [anchorPosition, setAnchorPosition] = useState<{ x: number; y: number } | undefined>(undefined); + const [contextMenu, setContextMenu] = useState<{ mouseX: number; mouseY: number } | null>(null); useEffect(() => { let items: any = graphState.chartProps.items; @@ -177,14 +178,18 @@ const GraphView: FC = () => { findNodeAndSelect(id); }; - const onRightClickNode = (event: SigmaNodeEventPayload) => { - setAnchorPosition({ x: event.event.x, y: event.event.y }); + const handleContextMenu = (event: SigmaNodeEventPayload) => { + setContextMenu(contextMenu === null ? { mouseX: event.event.x, mouseY: event.event.y } : null); const nodeId = event.node; findNodeAndSelect(nodeId); }; + const handleCloseContextMenu = () => { + setContextMenu(null); + }; + return ( { nonLayoutButtons={nonLayoutButtons} isCurrentSearchOpen={currentSearchOpen} toggleCurrentSearch={toggleCurrentSearch} - anchorPosition={anchorPosition} - onRightClickNode={onRightClickNode} + handleContextMenu={handleContextMenu} /> + + + this.baseClient.put(`/api/v2/asset-groups/${assetGroupId}/selectors`, selectorChangeset, options); @@ -119,7 +121,18 @@ class BHEAPIClient { listAssetGroupCollections = (assetGroupId: string, options?: types.RequestOptions) => this.baseClient.get(`/api/v2/asset-groups/${assetGroupId}/collections`, options); - listAssetGroups = (options?: types.RequestOptions) => this.baseClient.get('/api/v2/asset-groups', options); + listAssetGroupMembers = ( + assetGroupId: string, + params?: types.AssetGroupMemberParams, + options?: types.RequestOptions + ) => + this.baseClient.get( + `/api/v2/asset-groups/${assetGroupId}/members`, + Object.assign({ params }, options) + ); + + listAssetGroups = (options?: types.RequestOptions) => + this.baseClient.get('/api/v2/asset-groups', options); /* analysis */ getComboTreeGraph = (domainId: string, nodeId: string | null = null, options?: types.RequestOptions) => diff --git a/packages/javascript/js-client-library/src/responses.ts b/packages/javascript/js-client-library/src/responses.ts index 382120b53a..893f03a794 100644 --- a/packages/javascript/js-client-library/src/responses.ts +++ b/packages/javascript/js-client-library/src/responses.ts @@ -65,6 +65,38 @@ export type NewAuthToken = AuthToken & { export type CreateAuthTokenResponse = BasicResponse; +export type AssetGroupSelector = TimestampFields & { + id: number; + asset_group_id: number; + name: string; + selector: string; + system_selector: boolean; +}; + +export type AssetGroup = TimestampFields & { + id: number; + name: string; + tag: string; + member_count: number; + system_group: boolean; + Selectors: AssetGroupSelector[]; +}; + +export type AssetGroupMember = { + asset_group_id: number; + custom_member: boolean; + environment_id: string; + environment_kind: string; + kinds: string[]; + name: string; + object_id: string; + primary_kind: string; +}; + +export type AssetGroupResponse = BasicResponse<{ asset_groups: AssetGroup[] }>; + +export type AssetGroupMembersResponse = PaginatedResponse<{ members: AssetGroupMember[] }>; + export type SavedQuery = { id: number; name: string; diff --git a/packages/javascript/js-client-library/src/types.ts b/packages/javascript/js-client-library/src/types.ts index 1b1d89edd8..c16387ab18 100644 --- a/packages/javascript/js-client-library/src/types.ts +++ b/packages/javascript/js-client-library/src/types.ts @@ -33,6 +33,20 @@ export interface CreateAssetGroupSelectorRequest { sid: string; } +export interface UpdateAssetGroupSelectorRequest { + selector_name: string; + sid: string; + action: 'add' | 'remove'; +} + +export interface AssetGroupMemberParams { + environment_kind?: string; + environment_id?: string; + primary_kind?: string; + skip?: number; + limit?: number; +} + export interface CreateSharpHoundClientRequest { domain_controller: string; name: string;