delegate check improvements and process table ui updates

This commit is contained in:
its-a-feature
2026-05-10 19:38:19 -05:00
parent d662928b3f
commit 0fdaf9992c
28 changed files with 2872 additions and 921 deletions
+4 -1
View File
@@ -633,8 +633,11 @@ export function App(props) {
const preferences = useReactiveVar(mePreferences);
const [loadingPreference, setLoadingPreferences] = React.useState(true);
const [themeMode, themeToggler] = useDarkMode();
const themePalette = preferences?.palette;
const themeFontFamily = preferences?.fontFamily;
const theme = React.useMemo(
() => {
const preferences = {palette: themePalette, fontFamily: themeFontFamily};
try{
return createTheme({
transitions: {
@@ -864,7 +867,7 @@ export function App(props) {
...getModernThemeAdditions(themeMode, operatorSettingDefaults),
})
}
},[themeMode, preferences]
},[themeMode, themePalette, themeFontFamily]
);
const mountedRef = React.useRef(true);
const [openRefreshDialog, setOpenRefreshDialog] = React.useState(false);
@@ -38,6 +38,7 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
}
};
const selectedClass = data.items[rowIndex][columnIndex]?.props?.rowData?.selected ? "selectedCallback" : "";
const customRowClass = data.items[rowIndex][columnIndex]?.props?.rowData?.rowClassName || "";
const rowFirstCellClass = columnIndex === 0 ? classes.rowFirstCell : "";
const rowLastCellClass = columnIndex === data.items[rowIndex].length - 1 ? classes.rowLastCell : "";
const rowInteractiveClass = data.onRowClick ? classes.rowInteractive : "";
@@ -107,7 +108,7 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
);
return (
<div style={{...style, ...cellStyle, ...rowStyle}}
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass} ${rowFirstCellClass} ${rowLastCellClass} ${rowInteractiveClass}`}
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass} ${customRowClass} ${rowFirstCellClass} ${rowLastCellClass} ${rowInteractiveClass}`}
onDoubleClick={handleDoubleClick}
onClick={handleClick}
onMouseEnter={onMouseEnter}
@@ -345,7 +345,7 @@ const ResizableGridWrapper = ({
});
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current, setColumnWidthsAndRef]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -416,7 +416,7 @@ const ResizableGridWrapper = ({
activeResizeRef.current = null;
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, currentResize.columnIndex);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
setResizingColumnIndex(-1);
cleanupResizeListeners();
@@ -470,7 +470,7 @@ const ResizableGridWrapper = ({
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, columnIndex);
if(didUpdateWidths){
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
}
}, [columns, headerNameKey, items, name, setColumnWidthsAndRef, updateSetting]);
@@ -485,7 +485,7 @@ const ResizableGridWrapper = ({
});
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, 0);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
}, [columns, headerNameKey, items, name, setColumnWidthsAndRef, updateSetting]);
const resetColumnWidths = useCallback( () => {
@@ -496,7 +496,7 @@ const ResizableGridWrapper = ({
});
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, 0);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
}, [AutoSizerProps.width, columns, name, scrollbarWidth, setColumnWidthsAndRef, updateSetting]);
const headerContextMenuOptions = React.useMemo( () => {
@@ -17,6 +17,23 @@ export const GetComputedFontSize = () => {
const fontSizeString = window.getComputedStyle(element).fontSize;
return parseFloat(fontSizeString);
}
let latestPreferenceSnapshot = null;
const getPreferenceSnapshot = () => {
return latestPreferenceSnapshot || mePreferences() || {};
};
const areSettingValuesEqual = (first, second) => {
if(first === second){
return true;
}
if(typeof first !== "object" || typeof second !== "object" || first === null || second === null){
return false;
}
try{
return JSON.stringify(first) === JSON.stringify(second);
}catch(error){
return false;
}
};
/*
setting_name options:
taskingDisplayFields
@@ -40,7 +57,7 @@ export function useGetMythicSetting({setting_name, default_value}){
return setting;
}
export function GetMythicSetting({setting_name, default_value}){
const preferences = mePreferences();
const preferences = getPreferenceSnapshot();
return preferences?.[setting_name] === undefined ? default_value : preferences?.[setting_name];
}
export function useSetMythicSetting() {
@@ -54,25 +71,33 @@ export function useSetMythicSetting() {
}
});
return [
({setting_name, value}) => {
if(mePreferences()?.[setting_name] !== value){
({setting_name, value, broadcast=true}) => {
const currentPreferences = getPreferenceSnapshot();
if(!areSettingValuesEqual(currentPreferences?.[setting_name], value)){
const updatedPreferences = {
...mePreferences(),
...currentPreferences,
[setting_name]: value,
};
mePreferences(updatedPreferences);
latestPreferenceSnapshot = updatedPreferences;
if(broadcast){
mePreferences(updatedPreferences);
}
updateSetting({variables: {preferences: updatedPreferences}});
}
},
({settings}) => {
({settings, broadcast=true}) => {
const updatedPreferences = {
...mePreferences(),
...getPreferenceSnapshot(),
...settings,
};
mePreferences(updatedPreferences);
latestPreferenceSnapshot = updatedPreferences;
if(broadcast){
mePreferences(updatedPreferences);
}
updateSetting({variables: {preferences: updatedPreferences}});
},
() => {
latestPreferenceSnapshot = operatorSettingDefaults;
mePreferences(operatorSettingDefaults);
updateSetting({variables: {preferences: operatorSettingDefaults}});
}
@@ -52,7 +52,7 @@ import {useQuery, gql} from '@apollo/client';
import ConfirmationNumberIcon from '@mui/icons-material/ConfirmationNumber';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import AssignmentIcon from '@mui/icons-material/Assignment';
import {GetMythicSetting, useGetMythicSetting, useSetMythicSetting} from "./MythicComponents/MythicSavedUserSetting";
import {GetMythicSetting, useSetMythicSetting} from "./MythicComponents/MythicSavedUserSetting";
import AddCircleIcon from '@mui/icons-material/AddCircle';
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import MoreHorizIcon from '@mui/icons-material/MoreHoriz';
@@ -591,8 +591,7 @@ const AllSettingOptions = [
"BrowserScripts"
].sort();
const TopAppBarVerticalAdjustShortcutsDialog = ({onClose}) => {
const sideShortcuts = useGetMythicSetting({setting_name: "sideShortcuts", default_value: defaultShortcuts})
const TopAppBarVerticalAdjustShortcutsDialog = ({onClose, onSave, sideShortcuts}) => {
const [currentShortcuts, setCurrentShortcuts] = React.useState(sideShortcuts);
const [updateSetting] = useSetMythicSetting();
const reset = () => {
@@ -620,7 +619,8 @@ const TopAppBarVerticalAdjustShortcutsDialog = ({onClose}) => {
setCurrentShortcuts(newShortcuts);
}
const onUpdate = () => {
updateSetting({setting_name: "sideShortcuts", value: currentShortcuts});
updateSetting({setting_name: "sideShortcuts", value: currentShortcuts, broadcast: false});
onSave(currentShortcuts);
snackActions.success("Updated shortcuts!");
onClose();
}
@@ -720,11 +720,12 @@ export function TopAppBarVertical(props) {
const me = props.me;
const navigate = useNavigate();
const initialNavBarOpen = GetMythicSetting({setting_name: 'navBarOpen', default_value: operatorSettingDefaults.navBarOpen});
const initialSideShortcuts = GetMythicSetting({setting_name: "sideShortcuts", default_value: defaultShortcuts});
const [updateSetting] = useSetMythicSetting();
const [menuOpen, setMenuOpen] = React.useState(initialNavBarOpen);
const [sideShortcuts, setSideShortcuts] = React.useState(initialSideShortcuts);
const [openExtra, setOpenExtra] = React.useState(false);
const [openEditDialog, setOpenEditDialog ] = React.useState(false);
const sideShortcuts = useGetMythicSetting({setting_name: "sideShortcuts", default_value: defaultShortcuts})
const [serverVersion, setServerVersion] = React.useState("...");
const [serverName, setServerName] = React.useState("...");
useQuery(GET_SETTINGS, {fetchPolicy: "no-cache",
@@ -736,12 +737,13 @@ export function TopAppBarVertical(props) {
const toggleDrawerOpen = (e) => {
e.preventDefault();
e.stopPropagation();
setMenuOpen(!menuOpen);
updateSetting({setting_name: "navBarOpen", value: !menuOpen})
const nextMenuOpen = !menuOpen;
setMenuOpen(nextMenuOpen);
updateSetting({setting_name: "navBarOpen", value: nextMenuOpen, broadcast: false});
};
const handleDrawerClose = () => {
setMenuOpen(false);
updateSetting({setting_name: "navBarOpen", value: false})
updateSetting({setting_name: "navBarOpen", value: false, broadcast: false})
}
const handleToggleExtra = () => {
setOpenExtra(!openExtra);
@@ -875,6 +877,8 @@ export function TopAppBarVertical(props) {
<MythicDialog open={openEditDialog} fullWidth={true} maxWidth={"sm"}
onClose={()=>{setOpenEditDialog(false);}}
innerDialog={<TopAppBarVerticalAdjustShortcutsDialog
sideShortcuts={sideShortcuts}
onSave={setSideShortcuts}
onClose={()=>{setOpenEditDialog(false);}} />}
/>
}
@@ -385,7 +385,7 @@ function CallbacksTablePreMemo(props){
const eventingDataRef = React.useRef({});
const [openEventingDialog, setOpenEventingDialog] = React.useState(false);
const [openEventingMultipleDialog, setOpenEventingMultipleDialog] = React.useState(false);
const [updateSetting] = useSetMythicSetting();
const [updateSetting, updateSettings] = useSetMythicSetting();
const onUpdateTrigger = (newTriggerValue) => {
updateTrigger({variables: {callback_display_id: openTriggerDialog.display_id, trigger_on_checkin_after_time: newTriggerValue}})
setOpenTriggerDialog({...openTriggerDialog, open: false});
@@ -1039,7 +1039,7 @@ function CallbacksTablePreMemo(props){
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
try{
updateSetting({setting_name: "callbacks_table_filter_options", value: nextFilterOptions});
updateSetting({setting_name: "callbacks_table_filter_options", value: nextFilterOptions, broadcast: false});
}catch(error){
console.log("failed to save filter options");
}
@@ -1083,10 +1083,15 @@ function CallbacksTablePreMemo(props){
newHidden.push(newOrder[i].name);
}
}
updateSetting({setting_name: "callbacks_table_column_order", value: newOrder.map(c => c.name)});
setColumnOrder(newOrder);
setColumnVisibility({visible: newVisible, hidden: newHidden});
updateSetting({setting_name: "callbacks_table_columns", value: newVisible});
updateSettings({
settings: {
callbacks_table_column_order: newOrder.map(c => c.name),
callbacks_table_columns: newVisible,
},
broadcast: false,
});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -304,8 +304,6 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
const [localRowData, setLocalRowData] = React.useState(rowData);
const initialCallbackGraphEdges = useContext(CallbackGraphEdgesContext);
const onOpenTab = useContext(OnOpenTabContext);
const [egressActive, setEgressActive] = React.useState(true);
const [hasEgressRoute, setHasEgressRoute] = React.useState(true);
const [openC2Dialog, setOpenC2Dialog] = React.useState(false);
const [callbackgraphedges, setCallbackgraphedges] = React.useState([]);
const [callbackgraphedgesAll, setCallbackgraphedgesAll] = React.useState([]);
@@ -313,19 +311,6 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
event.stopPropagation();
setOpenC2Dialog(true);
}
useEffect( () => {
const routes = callbackgraphedgesAll.filter( (edge) => {
if(!edge.c2profile.is_p2p && edge.source.id === localRowData.id && edge.destination.id === localRowData.id){
return true;
}
return false;
}).length;
if(routes > 0 && !hasEgressRoute){
setHasEgressRoute(true);
}else if(routes === 0 && hasEgressRoute){
setHasEgressRoute(false);
}
}, [callbackgraphedgesAll, localRowData]);
useEffect( () => {
const getEdges = (activeOnly) => {
//update our aggregate of callbackgraphedges for both src and dst that involve us
@@ -378,21 +363,6 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
setCallbackgraphedgesAll(myEdges);
}, [initialCallbackGraphEdges, localRowData]);
useEffect( () => {
//determine if there are any active routes left at all
//console.log(localRowData.display_id, callbackgraphedges)
const activeRoutes = callbackgraphedges.filter( (edge) => {
if(!edge.c2profile.is_p2p && edge.end_timestamp === null){
return true;
}
return false
});
if(activeRoutes.length === 0){
setEgressActive(false);
}else{
setEgressActive(true);
}
}, [callbackgraphedges]);
useEffect( () => {
if(rowData.id !== localRowData.id){
setLocalRowData(rowData);
@@ -401,15 +371,26 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
return null
}
const c2ButtonClass = `mythic-callback-iconButton mythic-callback-cellIconButton ${egressActive && hasEgressRoute ? "mythic-callback-cellIconButtonSuccess" : "mythic-callback-cellIconButtonError"}`;
const c2Tooltip = hasEgressRoute ?
(egressActive ? "View C2 path information" : "No active egress route. View C2 path information") :
"No egress route detected. View C2 path information";
const directEgressRoutes = callbackgraphedgesAll.filter((edge) =>
!edge.c2profile?.is_p2p &&
edge.source.id === localRowData.id &&
edge.destination.id === localRowData.id
);
const hasDirectEgressRoute = directEgressRoutes.length > 0;
const directEgressActive = directEgressRoutes.some((edge) => edge.end_timestamp === null);
const p2pRouteActive = callbackgraphedges.some((edge) =>
!edge.c2profile?.is_p2p && edge.end_timestamp === null
);
const c2RouteActive = hasDirectEgressRoute ? directEgressActive : p2pRouteActive;
const c2ButtonClass = `mythic-callback-iconButton mythic-callback-cellIconButton ${c2RouteActive ? "mythic-callback-cellIconButtonSuccess" : "mythic-callback-cellIconButtonError"}`;
const c2Tooltip = hasDirectEgressRoute ?
(directEgressActive ? "Direct C2 route active. View C2 path information" : "Direct C2 route inactive. View C2 path information") :
(p2pRouteActive ? "Active P2P route to Mythic. View C2 path information" : "No active route to Mythic. View C2 path information");
return (
<div className="mythic-callback-cellInline mythic-callback-cellInlineCenter">
<MythicStyledTooltip title={c2Tooltip}>
<IconButton className={c2ButtonClass} onClick={onOpenC2Dialog}>
{hasEgressRoute ?
{hasDirectEgressRoute ?
<WifiIcon fontSize="small" /> :
<InsertLinkTwoToneIcon fontSize="small" />
}
@@ -24,7 +24,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import PlaylistRemoveIcon from '@mui/icons-material/PlaylistRemove';
import {useGetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {GetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
import {loadedCommandsQuery} from "./CallbacksTabsProcessBrowser";
import {getSkewedNow} from "../../utilities/Time";
@@ -772,9 +772,9 @@ const FileBrowserTableTop = ({
tabInfo,
treeConfig
}) => {
const autoTaskLsOnEmptyDirectories = useGetMythicSetting({
const [autoTaskLsOnEmptyDirectories, setAutoTaskLsOnEmptyDirectories] = React.useState(() => GetMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories", default_value: false
});
}));
const [updateMythicSetting] = useSetMythicSetting();
const [openEditHostDialog, setOpenEditHostDialog] = React.useState(false);
const [fullPath, setFullPath] = React.useState('');
@@ -948,8 +948,15 @@ const FileBrowserTableTop = ({
}});
};
const onToggleAutoTaskLsOnEmptyDirectories = () => {
updateMythicSetting({setting_name: "autoTaskLsOnEmptyDirectories", value: !autoTaskLsOnEmptyDirectories});
if(autoTaskLsOnEmptyDirectories){
const nextAutoTaskLsOnEmptyDirectories = !autoTaskLsOnEmptyDirectories;
setAutoTaskLsOnEmptyDirectories(nextAutoTaskLsOnEmptyDirectories);
autoTaskLsOnEmptyDirectoriesRef.current = nextAutoTaskLsOnEmptyDirectories;
updateMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories",
value: nextAutoTaskLsOnEmptyDirectories,
broadcast: false,
});
if(!nextAutoTaskLsOnEmptyDirectories){
snackActions.info("No longer auto issuing listings for empty paths");
} else {
snackActions.success("Now starting to auto issue listings for empty paths");
@@ -54,7 +54,7 @@ const updateFileComment = gql`
export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
const theme = useTheme();
const [updateSetting] = useSetMythicSetting();
const [updateSetting, updateSettings] = useSetMythicSetting();
const [allData, setAllData] = React.useState([]);
const [openReorderDialog, setOpenReorderDialog] = React.useState(false);
const [openContextMenu, setOpenContextMenu] = React.useState(false);
@@ -185,7 +185,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
try{
updateSetting({setting_name: `${props.treeConfig.name}_browser_filter_options`, value: nextFilterOptions});
updateSetting({setting_name: `${props.treeConfig.name}_browser_filter_options`, value: nextFilterOptions, broadcast: false});
}catch(error){
console.log("failed to save filter options");
}
@@ -687,10 +687,15 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
snackActions.error("Can't update to show no fields");
return;
}
updateSetting({setting_name: `${props.treeConfig.name}_browser_column_order`, value: newOrder.map(c => c.name)});
setColumnOrder(newOrder);
setColumnVisibility({visible: newVisible, hidden: newHidden});
updateSetting({setting_name: `${props.treeConfig.name}_browser_table_columns`, value: newVisible});
updateSettings({
settings: {
[`${props.treeConfig.name}_browser_column_order`]: newOrder.map(c => c.name),
[`${props.treeConfig.name}_browser_table_columns`]: newVisible,
},
broadcast: false,
});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -26,7 +26,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import {b64DecodeUnicode} from "./ResponseDisplay";
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import PlaylistRemoveIcon from '@mui/icons-material/PlaylistRemove';
import {useGetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {GetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
import {loadedCommandsQuery} from "./CallbacksTabsProcessBrowser";
import {getSkewedNow} from "../../utilities/Time";
@@ -772,9 +772,9 @@ const FileBrowserTableTop = ({
tabInfo,
baseUIFeature
}) => {
const autoTaskLsOnEmptyDirectories = useGetMythicSetting({
const [autoTaskLsOnEmptyDirectories, setAutoTaskLsOnEmptyDirectories] = React.useState(() => GetMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories", default_value: false
});
}));
const [updateMythicSetting] = useSetMythicSetting();
const [fullPath, setFullPath] = React.useState('');
const selectedToken = React.useRef("Default Token");
@@ -831,16 +831,15 @@ const FileBrowserTableTop = ({
return;
}
if(selectedFolderData.id !== ""){
if(history[0]?.full_path_text !== selectedFolderData.full_path_text){
// always add newest things to the bottom of the stack
setHistory([selectedFolderData, ...history]);
if(history.length > 20){
// pop off from the top (the oldest) if we get more than 50
setHistory([selectedFolderData, ...history.splice(history.length-1, 1)])
setHistory((currentHistory) => {
if(currentHistory[0]?.full_path_text === selectedFolderData.full_path_text){
return currentHistory;
}
}
return [selectedFolderData, ...currentHistory].slice(0, 20);
});
setHistoryIndex(0);
}
}, [selectedFolderData, history])
}, [selectedFolderData])
const moveIndexToPreviousListing = () => {
// we're getting closer to the end of the historyRef.current, the oldest listing
if(historyIndex >= history.length -1){
@@ -886,7 +885,14 @@ const FileBrowserTableTop = ({
}
};
const onToggleAutoTaskLsOnEmptyDirectories = () => {
updateMythicSetting({setting_name: "autoTaskLsOnEmptyDirectories", value: !autoTaskLsOnEmptyDirectories});
const nextAutoTaskSetting = !autoTaskLsOnEmptyDirectories;
setAutoTaskLsOnEmptyDirectories(nextAutoTaskSetting);
autoTaskLsOnEmptyDirectoriesRef.current = nextAutoTaskSetting;
updateMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories",
value: nextAutoTaskSetting,
broadcast: false,
});
if(autoTaskLsOnEmptyDirectories){
snackActions.info("No longer auto issuing listings for empty paths");
} else {
@@ -96,7 +96,7 @@ const columnDefaults = [
];
export const CallbacksTabsFileBrowserTable = (props) => {
const theme = useTheme();
const [updateSetting] = useSetMythicSetting();
const [updateSetting, updateSettings] = useSetMythicSetting();
const [loading, setLoading] = React.useState(true);
const [allData, setAllData] = React.useState([]);
const [openContextMenu, setOpenContextMenu] = React.useState(false);
@@ -207,7 +207,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
try{
updateSetting({setting_name: `file_browser_filter_options`, value: nextFilterOptions});
updateSetting({setting_name: `file_browser_filter_options`, value: nextFilterOptions, broadcast: false});
}catch(error){
console.log("failed to save filter options");
}
@@ -414,11 +414,15 @@ export const CallbacksTabsFileBrowserTable = (props) => {
snackActions.error("Can't update to show no fields");
return;
}
console.log("newOrder", newOrder)
updateSetting({setting_name: `file_browser_column_order`, value: newOrder.map(c => c.name)});
setColumnOrder(newOrder);
setColumnVisibility({visible: newVisible, hidden: newHidden});
updateSetting({setting_name: `file_browser_table_columns`, value: newVisible});
updateSettings({
settings: {
file_browser_column_order: newOrder.map(c => c.name),
file_browser_table_columns: newVisible,
},
broadcast: false,
});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -1,9 +1,8 @@
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useEffect, useRef} from 'react';
import {gql, useQuery, useSubscription, useLazyQuery } from '@apollo/client';
import React, {useEffect} from 'react';
import {gql, useQuery, useSubscription } from '@apollo/client';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {useTheme} from '@mui/material/styles';
import Grid from '@mui/material/Grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import IconButton from '@mui/material/IconButton';
import {CallbacksTabsProcessBrowserTable} from './CallbacksTabsProcessBrowserTable';
@@ -13,14 +12,18 @@ import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip'
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import InputLabel from '@mui/material/InputLabel';
import Input from '@mui/material/Input';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import {ViewCallbackMythicTreeGroupsDialog} from "./ViewCallbackMythicTreeGroupsDialog";
import WidgetsIcon from '@mui/icons-material/Widgets';
import {snackActions} from "../../utilities/Snackbar";
import { Backdrop, Typography } from '@mui/material';
import {CircularProgress} from '@mui/material';
import ExpandIcon from '@mui/icons-material/Expand';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import {useMythicLazyQuery} from "../../utilities/useMythicLazyQuery";
const treeFragment = gql`
@@ -145,7 +148,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
const theme = useTheme();
const fromNow = React.useRef((new Date()));
const [backdropOpen, setBackdropOpen] = React.useState(false);
const [expandOrCollapseAll, setExpandOrCollapseAll] = React.useState(false);
const [expandOrCollapseAll, setExpandOrCollapseAll] = React.useState(true);
const treeRootDataRef = React.useRef({}); // hold all the actual data
const [treeAdjMtx, setTreeAdjMtx] = React.useState({}); // hold the simple adjacency matrix for parent/child relationships
const [openTaskingButton, setOpenTaskingButton] = React.useState(false);
@@ -154,6 +157,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
const [showDeletedFiles, setShowDeletedFiles] = React.useState(false);
const [selectedHost, setSelectedHost] = React.useState("");
const [selectedGroup, setSelectedGroup] = React.useState("");
const [quickFilter, setQuickFilter] = React.useState("");
const loadedCommandsRef = React.useRef({});
const loadingCommandsRef = React.useRef(false);
const getNewMatrix = () => {
@@ -350,9 +354,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
callback_display_id: tabInfo["displayID"]});
setOpenTaskingButton(true);
}
const onTaskRowAction = ({process_id, architecture, uifeature, openDialog, getConfirmation, callback_id, callback_display_id}) => {
const onTaskRowAction = ({process_id, architecture, uifeature, openDialog, getConfirmation, callback_id, callback_display_id, display_id}) => {
taskingData.current = {"parameters": {host: selectedHost, process_id, architecture},
"ui_feature": uifeature, openDialog, getConfirmation, callback_id, callback_display_id};
"ui_feature": uifeature, openDialog, getConfirmation, callback_id, callback_display_id: callback_display_id || display_id};
setOpenTaskingButton(true);
}
const toggleShowDeletedFiles = (showStatus) => {
@@ -434,6 +438,8 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
hostOptions={treeRootDataRef.current[selectedGroup] || {}}
expandOrCollapseAll={expandOrCollapseAll}
setExpandOrCollapseAll={setExpandOrCollapseAll}
quickFilter={quickFilter}
setQuickFilter={setQuickFilter}
/>
<CallbacksTabsProcessBrowserTable
showDeletedFiles={showDeletedFiles}
@@ -443,6 +449,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
treeAdjMatrix={treeAdjMtx}
host={selectedHost}
group={selectedGroup}
quickFilter={quickFilter}
expandOrCollapseAll={expandOrCollapseAll}
onTaskRowAction={onTaskRowAction}
getLoadedCommandForUIFeature={getLoadedCommandForUIFeature}
@@ -473,13 +480,12 @@ const ProcessBrowserTableTop = ({
hostOptions,
groupOptions,
expandOrCollapseAll,
setExpandOrCollapseAll
setExpandOrCollapseAll,
quickFilter,
setQuickFilter
}) => {
const theme = useTheme();
const [showDeletedFiles, setLocalShowDeletedFiles] = React.useState(false);
const [openViewGroupsDialog, setOpenViewGroupDialog] = React.useState(false);
const inputRef = useRef(null);
const inputGroupRef = useRef(null);
const onLocalListFilesButton = () => {
onListFilesButton()
}
@@ -496,58 +502,104 @@ const ProcessBrowserTableTop = ({
const onLocalExpandOrCollapseAllButton = () => {
setExpandOrCollapseAll(!expandOrCollapseAll);
}
const onClearQuickFilter = () => {
setQuickFilter("");
}
return (
<Grid container spacing={0} style={{paddingTop: "10px"}}>
<Grid size={12}>
<FormControl style={{width: "30%"}}>
<InputLabel ref={inputGroupRef}>Available Groups</InputLabel>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={group}
onChange={handleGroupChange}
input={<Input style={{width: "100%"}}/>}
endAdornment={
<React.Fragment>
<MythicStyledTooltip title="View Callbacks associated with this group">
<IconButton style={{padding: "3px", paddingRight: "25px"}} onClick={() => {setOpenViewGroupDialog(true);}} size="large"><WidgetsIcon style={{color: theme.palette.info.main}}/></IconButton>
</MythicStyledTooltip>
</React.Fragment>
}
>
{Object.keys(groupOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
<FormControl style={{width: "70%"}}>
<InputLabel ref={inputRef}>Available Hosts</InputLabel>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={host}
onChange={handleChange}
input={<Input style={{width: "100%"}}/>}
endAdornment={
<React.Fragment>
<MythicStyledTooltip title="Task Callback to List Processes">
<IconButton style={{padding: "3px"}} onClick={onLocalListFilesButton} size="large"><RefreshIcon style={{color: theme.palette.info.main}}/></IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={expandOrCollapseAll ? "Collapse all processes" : "Expand all processes"} >
<IconButton style={{padding: "3px"}}
onClick={onLocalExpandOrCollapseAllButton} size={"large"}>
<ExpandIcon color={expandOrCollapseAll ? "info" : "success"} />
<div className="mythic-file-browser-tableTop mythic-process-browser-tableTop">
<div className="mythic-process-browser-toolbar">
<div className="mythic-process-browser-control mythic-process-browser-controlGroup">
<span className="mythic-process-browser-controlLabel">Group</span>
<FormControl size="small" className="mythic-process-browser-selectControl">
<Select
value={group}
onChange={handleGroupChange}
displayEmpty
className="mythic-process-browser-select"
>
{Object.keys(groupOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
<MythicStyledTooltip title="View callbacks associated with this group">
<IconButton
className="mythic-file-browser-iconButton mythic-file-browser-hoverInfo"
onClick={() => {setOpenViewGroupDialog(true);}}
size="small">
<WidgetsIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<div style={{paddingRight: "20px"}} />
</React.Fragment>
}
>
{Object.keys(hostOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
</div>
<div className="mythic-process-browser-control mythic-process-browser-controlHost">
<span className="mythic-process-browser-controlLabel">Host</span>
<FormControl size="small" className="mythic-process-browser-selectControl">
<Select
value={host}
onChange={handleChange}
displayEmpty
className="mythic-process-browser-select"
>
{Object.keys(hostOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
</div>
<TextField
className="mythic-process-browser-searchInput"
size="small"
value={quickFilter}
onChange={(event) => setQuickFilter(event.target.value)}
placeholder="Filter name, PID, user, arch, session, command..."
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: quickFilter ? (
<InputAdornment position="end">
<IconButton
className="mythic-file-browser-iconButton mythic-file-browser-hoverError"
onClick={onClearQuickFilter}
size="small">
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : null
}}
/>
<div className="mythic-file-browser-toolbarGroup mythic-process-browser-actions">
<MythicStyledTooltip title="Task current callback to list processes">
<IconButton
className="mythic-file-browser-iconButton mythic-file-browser-hoverInfo"
onClick={onLocalListFilesButton}
size="small">
<RefreshIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={expandOrCollapseAll ? "Collapse all processes" : "Expand all processes"} >
<IconButton
className={`mythic-file-browser-iconButton mythic-file-browser-hoverInfo ${expandOrCollapseAll ? "mythic-file-browser-activeSuccess" : ""}`}
onClick={onLocalExpandOrCollapseAllButton}
size="small">
<ExpandIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={showDeletedFiles ? 'Hide deleted processes' : 'Show deleted processes'}>
<IconButton
className={`mythic-file-browser-iconButton mythic-file-browser-hoverWarning ${showDeletedFiles ? "mythic-file-browser-activeWarning" : ""}`}
onClick={onLocalToggleShowDeletedFiles}
size="small">
{showDeletedFiles ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</MythicStyledTooltip>
</div>
{openViewGroupsDialog &&
<MythicDialog
fullWidth={true}
@@ -560,7 +612,7 @@ const ProcessBrowserTableTop = ({
}
/>
}
</Grid>
</Grid>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,34 +1,32 @@
import React from 'react';
import TableRow from '@mui/material/TableRow';
import Table from '@mui/material/Table';
import TableCell from '@mui/material/TableCell';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import {useQuery, gql} from '@apollo/client';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import {Button} from '@mui/material';
import {Backdrop, Button, CircularProgress, IconButton} from '@mui/material';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TableSortLabel from '@mui/material/TableSortLabel';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import VisibilityIcon from '@mui/icons-material/Visibility';
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import { useTheme } from '@mui/material/styles';
import LayersIcon from '@mui/icons-material/Layers';
import Paper from '@mui/material/Paper';
import {IconButton, Typography} from '@mui/material';
import {MythicDialog} from "../../MythicComponents/MythicDialog";
import LinearProgress from '@mui/material/LinearProgress';
import { Backdrop } from '@mui/material';
import {CircularProgress} from '@mui/material';
import {snackActions} from "../../utilities/Snackbar";
import {MythicAgentSVGIcon} from "../../MythicComponents/MythicAgentSVGIcon";
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
import {MythicTableEmptyState} from "../../MythicComponents/MythicStateDisplay";
import {MythicClientSideTablePagination, useMythicClientPagination} from "../../MythicComponents/MythicTablePagination";
const getCallbackMythicTreeGroups = gql`
query getCallbackMythicTreeGroups($group_name: [String!]!) {
callback(where: {mythictree_groups: {_contains: $group_name}}, order_by: {id: asc}) {
id
display_id
user
host
@@ -48,6 +46,7 @@ query getCallbackMythicTreeGroups($group_name: [String!]!) {
const getAllCallbackMythicTreeGroups = gql`
query getCallbackMythicTreeGroups {
callback(order_by: {id: asc}) {
id
display_id
user
host
@@ -65,8 +64,169 @@ query getCallbackMythicTreeGroups {
}
}
`;
const normalizeCallbackIP = (callback) => {
try{
const parsed = JSON.parse(callback.ip);
if(Array.isArray(parsed) && parsed.length > 0){
return {...callback, ip: parsed[0]};
}
return {...callback, ip: ""};
}catch(error){
return {...callback};
}
};
const compareValues = (left, right) => {
if(left === right){
return 0;
}
if(left === undefined || left === null){
return -1;
}
if(right === undefined || right === null){
return 1;
}
if(typeof left === "number" && typeof right === "number"){
return left - right;
}
return String(left).localeCompare(String(right), undefined, {numeric: true, sensitivity: "base"});
};
const callbackGroupColumns = [
{field: "status", headerName: "", width: "5rem", disableSort: true},
{field: "display_id", headerName: "Callback", width: "6rem", sortValue: (row) => row.display_id},
{field: "user", headerName: "User", sortValue: (row) => row.user},
{field: "host", headerName: "Host", sortValue: (row) => row.host},
{field: "domain", headerName: "Domain", sortValue: (row) => row.domain},
{field: "ip", headerName: "IP", sortValue: (row) => row.ip},
{field: "pid", headerName: "PID", width: "5rem", sortValue: (row) => row.pid},
{field: "description", headerName: "Description", sortValue: (row) => row.description},
];
const CallbackGroupStatusCell = ({callback}) => {
const payloadType = callback.payload?.payloadtype?.name || "unknown";
return (
<div className="mythic-tree-groups-callback-icons">
<MythicStyledTooltip title={callback.active ? "Callback is active" : "Callback is not active"}>
<span className={`mythic-tree-groups-status ${callback.active ? "mythic-tree-groups-statusActive" : "mythic-tree-groups-statusInactive"}`}>
{callback.active ? <VisibilityIcon fontSize="small" /> : <VisibilityOffIcon fontSize="small" />}
</span>
</MythicStyledTooltip>
<MythicStyledTooltip title={payloadType}>
<span className="mythic-tree-groups-agent-icon">
<MythicAgentSVGIcon payload_type={payloadType} style={{width: "28px", height: "28px"}} />
</span>
</MythicStyledTooltip>
</div>
);
};
const CallbackGroupCellValue = ({value, className = ""}) => (
<span className={`mythic-tree-groups-table-value ${className}`.trim()} title={value || ""}>
{value || "-"}
</span>
);
const getSafeTableId = (value) => String(value || "tree-group-callbacks").replace(/[^a-zA-Z0-9_-]/g, "-");
const CallbackGroupTable = ({callbacks, emptyMessage, tableId}) => {
const [orderBy, setOrderBy] = React.useState("display_id");
const [order, setOrder] = React.useState("asc");
const safeTableId = getSafeTableId(tableId);
const sortedCallbacks = React.useMemo(() => {
const sortColumn = callbackGroupColumns.find((column) => column.field === orderBy);
if(!sortColumn || sortColumn.disableSort){
return callbacks;
}
const direction = order === "desc" ? -1 : 1;
return [...callbacks].sort((left, right) => direction * compareValues(sortColumn.sortValue(left), sortColumn.sortValue(right)));
}, [callbacks, order, orderBy]);
const pagination = useMythicClientPagination({
items: sortedCallbacks,
resetKey: `${safeTableId}:${orderBy}:${order}`,
rowsPerPage: 25,
});
const handleSort = (column) => {
if(column.disableSort){
return;
}
if(orderBy === column.field){
setOrder((current) => current === "asc" ? "desc" : "asc");
}else{
setOrderBy(column.field);
setOrder("asc");
}
};
return (
<>
<TableContainer
className="mythicElement mythic-dialog-table-wrap mythic-fixed-row-table-wrap mythic-tree-groups-table-wrap"
data-testid={`${safeTableId}-scroll-region`}
id={`${safeTableId}-scroll-region`}
role="region"
>
<Table aria-label="Callback tree group table" data-testid={safeTableId} id={safeTableId} stickyHeader size="small" style={{height: "auto"}}>
<TableHead>
<TableRow>
{callbackGroupColumns.map((column) => (
<MythicStyledTableCell key={column.field} sortDirection={orderBy === column.field ? order : false} style={{width: column.width}}>
{column.disableSort ? column.headerName : (
<TableSortLabel
active={orderBy === column.field}
direction={orderBy === column.field ? order : "asc"}
onClick={() => handleSort(column)}
>
{column.headerName}
</TableSortLabel>
)}
</MythicStyledTableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{sortedCallbacks.length === 0 ? (
<MythicTableEmptyState
colSpan={callbackGroupColumns.length}
compact
description={emptyMessage}
minHeight={150}
title="No callbacks"
/>
) : (
pagination.pageData.map((callback) => (
<TableRow hover key={callback.id || `callback-group-${callback.display_id}-${callback.host}`}>
<MythicStyledTableCell>
<CallbackGroupStatusCell callback={callback} />
</MythicStyledTableCell>
<MythicStyledTableCell>
<CallbackGroupCellValue className="mythic-tree-groups-callback-id" value={`#${callback.display_id}`} />
</MythicStyledTableCell>
<MythicStyledTableCell>
<CallbackGroupCellValue value={callback.user} />
</MythicStyledTableCell>
<MythicStyledTableCell>
<CallbackGroupCellValue value={callback.host} />
</MythicStyledTableCell>
<MythicStyledTableCell>
<CallbackGroupCellValue value={callback.domain} />
</MythicStyledTableCell>
<MythicStyledTableCell>
<CallbackGroupCellValue value={callback.ip} />
</MythicStyledTableCell>
<MythicStyledTableCell>
<CallbackGroupCellValue value={callback.pid === undefined || callback.pid === null ? "" : `${callback.pid}`} />
</MythicStyledTableCell>
<MythicStyledTableCell>
<CallbackGroupCellValue className="mythic-tree-groups-description-cell" value={callback.description} />
</MythicStyledTableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
<MythicClientSideTablePagination
id={`${safeTableId}-pagination`}
pagination={pagination}
/>
</>
);
};
export function ViewCallbackMythicTreeGroupsDialog(props){
const theme = useTheme();
const [backdropOpen, setBackdropOpen] = React.useState(true);
const [groups, setGroups] = React.useState([]);
const [openViewAllCallbacksDialog, setOpenViewAllCallbacksDialog] = React.useState(false);
@@ -77,19 +237,7 @@ export function ViewCallbackMythicTreeGroupsDialog(props){
fetchPolicy: "no-cache",
variables: {group_name: [props.group_name]},
onCompleted: data => {
const groupData = data.callback.map( c => {
try{
let cIP = JSON.parse(c.ip);
if(cIP.length > 0){
return {...c, ip:cIP[0] };
}
return {...c, ip: ""};
}catch(error){
return {...c};
}
})
const groupData = data.callback.map(normalizeCallbackIP);
setGroups(groupData);
setBackdropOpen(false);
}
@@ -99,67 +247,34 @@ export function ViewCallbackMythicTreeGroupsDialog(props){
}, [groups]);
return (
<React.Fragment>
<DialogTitle id="form-dialog-title" style={{display: "flex", justifyContent: "space-between"}}>
Viewing Callbacks for group: {props.group_name}
<MythicStyledTooltip title="View all groups" >
<IconButton size="small" onClick={()=>{setOpenViewAllCallbacksDialog(true);}} style={{color: theme.palette.info.main}} variant="contained"><LayersIcon/></IconButton>
</MythicStyledTooltip>
<DialogTitle id="form-dialog-title" className="mythic-tree-groups-title">
<div className="mythic-dialog-title-row">
<div className="mythic-tree-groups-title-copy">
<span>Callbacks for {props.group_name}</span>
<span>These callbacks contribute aggregated process data for this group.</span>
</div>
<MythicStyledTooltip title="View all groups" >
<IconButton
className="mythic-file-browser-iconButton mythic-file-browser-hoverInfo"
size="small"
onClick={()=>{setOpenViewAllCallbacksDialog(true);}}>
<LayersIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
</div>
</DialogTitle>
<div style={{paddingLeft: "20px"}}>
All of these callbacks are contributing data that's aggregated together for the "{props.group_name}" group.
</div>
<DialogContent dividers={true} style={{padding: 0}}>
<DialogContent dividers={true} className="mythic-tree-groups-content">
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={false}>
<CircularProgress color="inherit" />
</Backdrop>
<TableContainer className="mythicElement">
<Table stickyHeader={true} size="small" style={{ "overflowWrap": "break-word", width: "100%"}}>
<TableHead>
<TableRow>
<TableCell></TableCell>
<TableCell>Callback</TableCell>
<TableCell>User</TableCell>
<TableCell>Host</TableCell>
<TableCell>Domain</TableCell>
<TableCell>IP</TableCell>
<TableCell>PID</TableCell>
<TableCell>Description</TableCell>
</TableRow>
</TableHead>
<TableBody>
{groups.map( (a, i) => (
<TableRow key={'array' + props.group_name + i} hover>
<MythicStyledTableCell style={{width: "90px"}}>
{!a.active ?
<MythicStyledTooltip title={"Callback is not active"}>
<VisibilityOffIcon style={{color: theme.palette.error.main, marginRight: "15px"}}/>
</MythicStyledTooltip>
:
<MythicStyledTooltip title={"Callback is active"}>
<VisibilityIcon style={{color: theme.palette.success.main, marginRight: "15px"}}/>
</MythicStyledTooltip>
}
<MythicStyledTooltip title={a.payload.payloadtype.name}>
<MythicAgentSVGIcon payload_type={a.payload.payloadtype.name} style={{width: "35px", height: "35px"}} />
</MythicStyledTooltip>
</MythicStyledTableCell>
<MythicStyledTableCell>{a.display_id}</MythicStyledTableCell>
<MythicStyledTableCell>{a.user}</MythicStyledTableCell>
<MythicStyledTableCell>{a.host}</MythicStyledTableCell>
<MythicStyledTableCell>{a.domain}</MythicStyledTableCell>
<MythicStyledTableCell style={{wordBreak: "break-all"}}>{a.ip}</MythicStyledTableCell>
<MythicStyledTableCell >{a.pid}</MythicStyledTableCell>
<MythicStyledTableCell style={{wordBreak: "break-all"}}>{a.description}</MythicStyledTableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<CallbackGroupTable
callbacks={groups}
emptyMessage={`No callbacks are contributing to ${props.group_name}.`}
tableId={`tree-group-${props.group_name}-callbacks`}
/>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<Button onClick={props.onClose}>
Close
</Button>
</DialogActions>
@@ -179,37 +294,24 @@ export function ViewCallbackMythicTreeGroupsDialog(props){
}
export function ViewAllCallbackMythicTreeGroupsDialog(props){
const theme = useTheme();
const [groups, setGroups] = React.useState([]);
const {loading} = useQuery(getAllCallbackMythicTreeGroups, {
fetchPolicy: "no-cache",
onCompleted: data => {
let groupDict = {};
const callbacks = data.callback.map( c => {
try{
let cIP = JSON.parse(c.ip);
if(cIP.length > 0){
return {...c, ip:cIP[0] };
}
return {...c, ip: ""};
}catch(error){
return {...c};
}
});
const callbacks = data.callback.map(normalizeCallbackIP);
for(let i = 0; i < callbacks.length; i++){
if (callbacks[i].mythictree_groups.length > 0){
for(let j = 0; j < callbacks[i].mythictree_groups.length; j++){
if(callbacks[i].mythictree_groups[j] === "Default"){
const treeGroups = callbacks[i].mythictree_groups || [];
if (treeGroups.length > 0){
for(let j = 0; j < treeGroups.length; j++){
if(treeGroups[j] === "Default"){
continue;
}
if(groupDict[callbacks[i].mythictree_groups[j]] === undefined){
groupDict[callbacks[i].mythictree_groups[j]] = [];
if(groupDict[treeGroups[j]] === undefined){
groupDict[treeGroups[j]] = [];
}
groupDict[callbacks[i].mythictree_groups[j]].push(callbacks[i]);
groupDict[treeGroups[j]].push(callbacks[i]);
}
} else {
}
}
const keys = Object.keys(groupDict).sort();
@@ -229,71 +331,40 @@ export function ViewAllCallbackMythicTreeGroupsDialog(props){
}
return (
<React.Fragment>
<DialogTitle id="form-dialog-title">Viewing Callbacks for every group
<DialogTitle id="form-dialog-title" className="mythic-tree-groups-title">Callback tree groups
</DialogTitle>
<div style={{paddingLeft: "20px"}}>
Callbacks with no groups or with only the "Default" group are not shown.
</div>
<DialogContent dividers={true} style={{paddingLeft: 0, paddingRight: 0}}>
{groups.map( (g, i) => (
<div key={g.group}>
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
{g.group}
</Typography>
</Paper>
<TableContainer className="mythicElement">
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word", width: "100%"}}>
<TableHead>
<TableRow>
<MythicStyledTableCell></MythicStyledTableCell>
<MythicStyledTableCell>Callback</MythicStyledTableCell>
<MythicStyledTableCell>User</MythicStyledTableCell>
<MythicStyledTableCell>Host</MythicStyledTableCell>
<MythicStyledTableCell>Domain</MythicStyledTableCell>
<MythicStyledTableCell>IP</MythicStyledTableCell>
<MythicStyledTableCell>PID</MythicStyledTableCell>
<MythicStyledTableCell>Description</MythicStyledTableCell>
</TableRow>
</TableHead>
<TableBody>
{g.callbacks.map( (a, i) => (
<TableRow key={'array' + g.group + i} hover>
<MythicStyledTableCell style={{width: "90px"}}>
{!a.active ?
<MythicStyledTooltip title={"Callback is not active"}>
<VisibilityOffIcon style={{color: theme.palette.error.main, marginRight: "15px"}}/>
</MythicStyledTooltip>
:
<MythicStyledTooltip title={"Callback is active"}>
<VisibilityIcon style={{color: theme.palette.success.main, marginRight: "15px"}}/>
</MythicStyledTooltip>
}
<MythicStyledTooltip title={a.payload.payloadtype.name}>
<MythicAgentSVGIcon payload_type={a.payload.payloadtype.name} style={{width: "35px", height: "35px"}} />
</MythicStyledTooltip>
</MythicStyledTableCell>
<MythicStyledTableCell>{a.display_id}</MythicStyledTableCell>
<MythicStyledTableCell>{a.user}</MythicStyledTableCell>
<MythicStyledTableCell>{a.host}</MythicStyledTableCell>
<MythicStyledTableCell>{a.domain}</MythicStyledTableCell>
<MythicStyledTableCell style={{wordBreak: "break-all"}}>{a.ip}</MythicStyledTableCell>
<MythicStyledTableCell >{a.pid}</MythicStyledTableCell>
<MythicStyledTableCell style={{wordBreak: "break-all"}}>{a.description}</MythicStyledTableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<DialogContent dividers={true} className="mythic-tree-groups-content">
<div className="mythic-tree-groups-help">
Callbacks with no groups or with only the "Default" group are not shown.
</div>
{groups.length === 0 ? (
<div className="mythic-tree-groups-empty">
No callback tree groups found.
</div>
))}
) : (
groups.map( (g, i) => (
<div key={g.group} className="mythic-dialog-section mythic-tree-groups-section">
<div className="mythic-dialog-section-header">
<div>
<div className="mythic-dialog-section-title">
{g.group}
</div>
<div className="mythic-dialog-section-description">
{g.callbacks.length} callback{g.callbacks.length === 1 ? "" : "s"}
</div>
</div>
</div>
<CallbackGroupTable
callbacks={g.callbacks}
emptyMessage={`No callbacks are contributing to ${g.group}.`}
tableId={`tree-group-${g.group}-callbacks`}
/>
</div>
))
)}
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<Button onClick={props.onClose}>
Close
</Button>
</DialogActions>
@@ -1983,7 +1983,7 @@ const CustomDashboard = ({me, setLoading, loading, editing}) => {
setDashboards(dashboards.toSpliced(i, 1));
}
React.useEffect( () => {
updateSetting({setting_name: "customDashboardElements", value: dashboards});
updateSetting({setting_name: "customDashboardElements", value: dashboards, broadcast: false});
}, [dashboards]);
const getDashboardElement = React.useCallback( (e, i, j) => {
switch(e){
@@ -2122,7 +2122,7 @@ export function CallbacksCard({me}) {
return;
}
setDashboard(value);
updateSetting({setting_name: "dashboard", value: value});
updateSetting({setting_name: "dashboard", value: value, broadcast: false});
setLoading(true);
}
return (
@@ -26,9 +26,9 @@ export function SettingsOperatorExperimentalUIConfigDialog(props) {
const onAccept = () => {
if(newResponseStreamLimit < 0){
updateSetting({setting_name: "experiment-responseStreamLimit", value: 0});
updateSetting({setting_name: "experiment-responseStreamLimit", value: 0, broadcast: false});
}else{
updateSetting({setting_name: "experiment-responseStreamLimit", value: newResponseStreamLimit});
updateSetting({setting_name: "experiment-responseStreamLimit", value: newResponseStreamLimit, broadcast: false});
}
snackActions.success("Updated settings!");
props.onClose();
+607
View File
@@ -330,6 +330,592 @@ tspan {
background-color: ${(props) => alpha(props.theme.palette.error.main, props.theme.palette.mode === "dark" ? 0.2 : 0.12)};
color: ${(props) => props.theme.palette.error.main};
}
.mythic-process-browser-tableTop {
padding: 0.5rem 0.6rem;
}
.mythic-process-browser-toolbar {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
min-width: 0;
width: 100%;
}
.mythic-process-browser-control {
align-items: center;
background-color: ${(props) => alpha(props.theme.palette.text.primary, props.theme.palette.mode === "dark" ? 0.045 : 0.035)};
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
display: inline-flex;
gap: 0.35rem;
min-height: 32px;
min-width: 0;
padding: 0.15rem 0.18rem 0.15rem 0.55rem;
}
.mythic-process-browser-controlGroup {
flex: 1 1 18rem;
max-width: 30rem;
}
.mythic-process-browser-controlHost {
flex: 1 1 16rem;
max-width: 28rem;
padding-right: 0.35rem;
}
.mythic-process-browser-searchInput {
flex: 2 1 24rem;
min-width: 16rem !important;
}
.mythic-process-browser-searchInput .MuiInputBase-root {
background-color: ${(props) => props.theme.palette.background.paper};
font-size: 0.78rem;
min-height: 32px;
}
.mythic-process-browser-searchInput .MuiInputBase-input {
padding-bottom: 6px;
padding-top: 6px;
}
.mythic-process-browser-searchInput .MuiInputAdornment-root {
color: ${(props) => props.theme.palette.text.secondary};
}
.mythic-process-browser-controlLabel {
color: ${(props) => props.theme.palette.text.secondary};
flex: 0 0 auto;
font-size: 0.72rem;
font-weight: 750;
}
.mythic-process-browser-selectControl {
flex: 1 1 auto;
min-width: 8rem !important;
}
.mythic-process-browser-select.MuiInputBase-root {
background-color: ${(props) => props.theme.palette.background.paper};
font-size: 0.78rem;
min-height: 28px;
}
.mythic-process-browser-select .MuiSelect-select {
min-height: 0 !important;
overflow: hidden;
padding-bottom: 5px;
padding-top: 5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-process-browser-actions {
margin-left: auto;
}
.mythic-process-browser-table-shell {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
overflow: hidden;
position: relative;
width: 100%;
}
.mythic-process-browser-grid-shell {
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow: hidden;
}
.mythic-process-summary-strip {
align-items: center;
background-color: ${(props) => alpha(props.theme.palette.background.paper, props.theme.palette.mode === "dark" ? 0.94 : 0.98)};
border-bottom: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
display: flex;
flex: 0 0 auto;
flex-wrap: nowrap;
gap: 0.35rem;
min-height: 34px;
overflow-x: auto;
overflow-y: hidden;
padding: 0.35rem 0.6rem;
}
.mythic-process-summary-chip,
.mythic-process-indicator {
align-items: center;
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.text.secondary};
display: inline-flex;
flex: 0 0 auto;
font-size: 0.68rem;
font-weight: 800;
gap: 0.2rem;
line-height: 1;
max-width: 12rem;
min-height: 20px;
overflow: hidden;
padding: 0.22rem 0.4rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-process-summary-chip-muted {
opacity: 0.58;
}
.mythic-process-summary-chip-info,
.mythic-process-indicator-info {
background-color: ${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.16 : 0.08)};
border-color: ${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.42 : 0.28)};
color: ${(props) => props.theme.palette.info.main};
}
.mythic-process-summary-chip-warning,
.mythic-process-indicator-warning {
background-color: ${(props) => alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.18 : 0.1)};
border-color: ${(props) => alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.46 : 0.3)};
color: ${(props) => props.theme.palette.warning.main};
}
.mythic-process-indicator-deleted {
background-color: ${(props) => alpha(props.theme.palette.error.main, props.theme.palette.mode === "dark" ? 0.16 : 0.08)};
border-color: ${(props) => alpha(props.theme.palette.error.main, props.theme.palette.mode === "dark" ? 0.42 : 0.28)};
color: ${(props) => props.theme.palette.error.main};
}
.mythic-process-indicator {
font-size: 0.62rem;
min-height: 18px;
padding: 0.18rem 0.32rem;
}
.mythic-process-indicator svg {
font-size: 0.78rem;
}
.mythic-process-match-chips {
align-items: center;
display: inline-flex;
flex: 0 0 auto;
gap: 0.18rem;
min-width: 0;
}
.mythic-process-match-chip {
align-items: center;
background-color: ${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.18 : 0.1)};
border: 1px solid ${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.45 : 0.3)};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.info.main};
display: inline-flex;
flex: 0 0 auto;
font-size: 0.6rem;
font-weight: 850;
line-height: 1;
min-height: 17px;
padding: 0.16rem 0.28rem;
}
.mythic-process-match-chip-ancestor {
background-color: ${(props) => alpha(props.theme.palette.text.primary, props.theme.palette.mode === "dark" ? 0.08 : 0.05)};
border-color: ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
color: ${(props) => props.theme.palette.text.secondary};
}
.mythic-process-inspector {
background-color: ${(props) => props.theme.surfaces?.muted || props.theme.palette.background.paper};
border-top: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
box-shadow: ${(props) => props.theme.palette.mode === "dark" ? "0 -10px 24px rgba(0,0,0,0.22)" : "0 -10px 24px rgba(15,23,42,0.06)"};
flex: 0 0 auto;
min-width: 0;
padding: 0.55rem 0.65rem 0.65rem;
}
.mythic-process-inspector-header {
align-items: center;
display: flex;
gap: 0.75rem;
justify-content: space-between;
min-width: 0;
}
.mythic-process-inspector-title {
align-items: center;
color: ${(props) => props.theme.palette.text.primary};
display: flex;
flex: 1 1 auto;
font-size: 0.86rem;
font-weight: 800;
gap: 0.35rem;
min-width: 0;
}
.mythic-process-inspector-title > span:first-of-type {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-process-inspector-title svg {
color: ${(props) => props.theme.palette.text.secondary};
flex: 0 0 auto;
}
.mythic-process-inspector-actions {
align-items: center;
display: flex;
flex: 0 0 auto;
gap: 0.3rem;
}
.mythic-process-inspector-body {
display: grid;
gap: 0.65rem;
grid-template-columns: minmax(22rem, 1.25fr) minmax(18rem, 1fr);
margin-top: 0.5rem;
min-width: 0;
}
.mythic-process-inspector-details {
display: grid;
gap: 0.35rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
min-width: 0;
}
.mythic-process-inspector-detail {
background-color: ${(props) => props.theme.palette.background.paper};
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
min-width: 0;
padding: 0.34rem 0.45rem;
}
.mythic-process-inspector-detailWide {
grid-column: span 2;
}
.mythic-process-inspector-detail span {
color: ${(props) => props.theme.palette.text.secondary};
display: block;
font-size: 0.66rem;
font-weight: 800;
line-height: 1.1;
}
.mythic-process-inspector-detail strong {
color: ${(props) => props.theme.palette.text.primary};
display: block;
font-size: 0.76rem;
font-weight: 700;
line-height: 1.25;
margin-top: 0.16rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-process-inspector-side {
display: flex;
flex-direction: column;
gap: 0.4rem;
min-width: 0;
}
.mythic-process-inspector-tags {
align-items: center;
background-color: ${(props) => props.theme.palette.background.paper};
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
display: flex;
gap: 0.35rem;
min-height: 32px;
min-width: 0;
overflow: hidden;
padding: 0.28rem 0.35rem;
}
.mythic-process-inspector-tags .MuiIconButton-root {
align-items: center !important;
background-color: ${(props) => props.theme.palette.mode === "dark" ? "rgba(255,255,255,0.045)" : "rgba(0,0,0,0.025)"} !important;
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.text.secondary} !important;
display: inline-flex !important;
flex: 0 0 auto;
height: 22px;
justify-content: center !important;
padding: 0 !important;
width: 22px;
}
.mythic-process-inspector-tags .MuiIconButton-root:hover {
background-color: ${(props) => alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.16 : 0.09)} !important;
color: ${(props) => props.theme.palette.primary.main} !important;
}
.mythic-process-inspector-command {
background-color: ${(props) => props.theme.palette.background.paper};
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.text.primary};
font-family: ${(props) => props.theme.typography.fontFamilyMonospace || "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace"};
font-size: 0.72rem;
line-height: 1.35;
max-height: 3.3rem;
min-height: 2.1rem;
overflow: auto;
padding: 0.4rem 0.5rem;
white-space: pre-wrap;
word-break: break-word;
}
.mythic-process-name-cell,
.mythic-process-string-cell,
.mythic-process-tags-cell {
align-items: center;
display: flex;
height: 100%;
min-width: 0;
overflow: hidden;
width: 100%;
}
.mythic-process-name-cell {
gap: 0.25rem;
}
.mythic-process-row-deleted {
color: ${(props) => props.theme.palette.text.disabled};
text-decoration: line-through;
}
.mythic-process-indent {
flex: 0 0 auto;
height: 1px;
}
.mythic-process-expand-button.MuiIconButton-root {
align-items: center;
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.text.secondary};
display: inline-flex;
flex: 0 0 auto;
height: 22px;
justify-content: center;
padding: 0;
transition: background-color 120ms ease, color 120ms ease;
width: 22px;
}
.mythic-process-expand-button.MuiIconButton-root:hover {
background-color: ${(props) => alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.16 : 0.09)};
color: ${(props) => props.theme.palette.primary.main};
}
.mythic-process-expand-spacer {
flex: 0 0 22px;
height: 22px;
}
.mythic-process-name-icon {
color: ${(props) => props.theme.palette.text.secondary};
flex: 0 0 auto;
opacity: 0.85;
}
.mythic-process-name-text,
.mythic-process-string-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
}
.mythic-process-name-text {
min-width: 0;
}
.mythic-process-string-cell {
color: ${(props) => props.theme.palette.text.primary};
font-size: 0.78rem;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.mythic-process-tags-cell {
gap: 0.35rem;
}
.mythic-process-tags-cell .MuiIconButton-root {
align-items: center !important;
background-color: ${(props) => props.theme.palette.mode === "dark" ? "rgba(255,255,255,0.045)" : "rgba(0,0,0,0.025)"} !important;
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.text.secondary} !important;
display: inline-flex !important;
flex: 0 0 auto;
float: none !important;
height: 22px;
justify-content: center !important;
padding: 0 !important;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
width: 22px;
}
.mythic-process-tags-cell .MuiIconButton-root:hover {
background-color: ${(props) => alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.16 : 0.09)} !important;
border-color: ${(props) => alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.44 : 0.3)};
color: ${(props) => props.theme.palette.primary.main} !important;
}
.mythic-process-tags-cell .MuiIconButton-root svg {
font-size: 0.92rem;
}
.mythic-process-tags-list {
align-items: center;
display: flex;
flex: 1 1 auto;
flex-wrap: nowrap;
gap: 0.2rem;
min-width: 0;
overflow: hidden;
}
.mythic-process-tags-list .MuiChip-root {
flex: 0 0 auto;
float: none !important;
height: 18px !important;
max-width: 5.5rem;
}
.mythic-process-tags-list .MuiChip-label {
font-size: 0.68rem;
font-weight: 800;
min-width: 0;
overflow: hidden !important;
padding-left: 0.35rem;
padding-right: 0.35rem;
text-overflow: ellipsis;
}
.mythic-process-action-button.MuiIconButton-root {
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.text.secondary};
height: 24px;
padding: 0;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
width: 24px;
}
.mythic-process-action-button.MuiIconButton-root:hover,
.mythic-process-action-button.MuiIconButton-root[aria-expanded="true"] {
background-color: ${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.18 : 0.1)};
border-color: ${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.46 : 0.3)};
color: ${(props) => props.theme.palette.info.main};
}
.mythic-process-menu-icon {
align-items: center;
color: ${(props) => props.theme.palette.text.secondary};
display: inline-flex;
flex: 0 0 1.25rem;
justify-content: center;
margin-right: 0.45rem;
}
.mythic-process-menu-icon svg,
.mythic-process-menu-icon .svg-inline--fa {
font-size: 0.95rem;
}
.mythic-process-menu-icon-success {
color: ${(props) => props.theme.palette.success.main};
}
.mythic-process-menu-icon-warning {
color: ${(props) => props.theme.palette.warning.main};
}
.mythic-process-menu-icon-error {
color: ${(props) => props.theme.palette.error.main};
}
@media (max-width: 1100px) {
.mythic-process-inspector-body {
grid-template-columns: 1fr;
}
.mythic-process-inspector-details {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.mythic-tree-groups-title.MuiDialogTitle-root {
background: ${getSectionHeaderGradient};
border-bottom: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary};
font-size: 0.98rem;
font-weight: 800;
padding: 0.75rem 0.9rem;
position: relative;
}
.mythic-tree-groups-title.MuiDialogTitle-root::before {
background: ${getSectionHeaderAccent};
content: "";
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 3px;
}
.mythic-tree-groups-title-copy {
display: flex;
flex: 1 1 18rem;
flex-direction: column;
gap: 0.14rem;
min-width: 0;
}
.mythic-tree-groups-title-copy span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-tree-groups-title-copy span:last-child,
.mythic-tree-groups-help {
color: ${(props) => props.theme.palette.text.secondary};
font-size: 0.76rem;
font-weight: 550;
line-height: 1.35;
}
.mythic-tree-groups-content.MuiDialogContent-root {
background-color: ${(props) => props.theme.palette.background.default};
padding: 0.75rem;
}
.mythic-tree-groups-content .MuiBackdrop-root {
background-color: ${(props) => alpha(props.theme.palette.background.default, props.theme.palette.mode === "dark" ? 0.7 : 0.52)};
}
.mythic-tree-groups-help {
background-color: ${(props) => props.theme.surfaces?.muted || props.theme.palette.background.paper};
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
margin-bottom: 0.65rem;
padding: 0.55rem 0.7rem;
}
.mythic-tree-groups-section + .mythic-tree-groups-section {
margin-top: 0.65rem;
}
.mythic-tree-groups-table-wrap {
max-height: min(48vh, 32rem);
overflow: auto;
}
.mythic-tree-groups-section .mythic-tree-groups-table-wrap {
max-height: min(36vh, 24rem);
}
.mythic-tree-groups-callback-icons {
align-items: center;
display: flex;
flex: 0 0 auto;
gap: 0.4rem;
}
.mythic-tree-groups-status {
align-items: center;
background-color: ${(props) => props.theme.palette.mode === "dark" ? "rgba(255,255,255,0.045)" : "rgba(0,0,0,0.025)"};
border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.text.secondary};
display: inline-flex;
height: 26px;
justify-content: center;
width: 26px;
}
.mythic-tree-groups-statusActive {
background-color: ${(props) => alpha(props.theme.palette.success.main, props.theme.palette.mode === "dark" ? 0.16 : 0.09)};
border-color: ${(props) => alpha(props.theme.palette.success.main, props.theme.palette.mode === "dark" ? 0.42 : 0.28)};
color: ${(props) => props.theme.palette.success.main};
}
.mythic-tree-groups-statusInactive {
background-color: ${(props) => alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.18 : 0.1)};
border-color: ${(props) => alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.46 : 0.32)};
color: ${(props) => props.theme.palette.warning.main};
}
.mythic-tree-groups-agent-icon {
align-items: center;
display: inline-flex;
height: 30px;
justify-content: center;
width: 30px;
}
.mythic-tree-groups-callback-id {
color: ${(props) => props.theme.palette.text.primary};
font-size: 0.84rem;
font-weight: 800;
}
.mythic-tree-groups-table-value {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-tree-groups-description-cell {
max-width: 24rem;
}
.mythic-tree-groups-empty {
align-items: center;
background-color: ${(props) => props.theme.palette.mode === "dark" ? "rgba(255,255,255,0.035)" : "rgba(0,0,0,0.02)"};
border: 1px dashed ${(props) => props.theme.table?.border || props.theme.borderColor};
border-radius: ${(props) => props.theme.shape.borderRadius}px;
color: ${(props) => props.theme.palette.text.secondary};
display: flex;
font-size: 0.8rem;
justify-content: center;
min-height: 6rem;
padding: 0.9rem;
text-align: center;
}
.mythic-tasking-composer {
background-color: ${(props) => alpha(props.theme.palette.background.paper, props.theme.palette.mode === "dark" ? 0.96 : 0.98)};
border-top: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
@@ -1706,6 +2292,27 @@ tspan {
opacity: 1;
width: 4px;
}
.MythicResizableGrid-cell.mythic-process-filter-match-row {
background-image: linear-gradient(0deg,
${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.16 : 0.09)},
${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.16 : 0.09)}) !important;
border-bottom-color: ${(props) => alpha(props.theme.palette.info.main, props.theme.palette.mode === "dark" ? 0.46 : 0.3)};
}
.MythicResizableGrid-cell.mythic-process-filter-ancestor-row {
background-image: linear-gradient(0deg,
${(props) => alpha(props.theme.palette.text.primary, props.theme.palette.mode === "dark" ? 0.055 : 0.035)},
${(props) => alpha(props.theme.palette.text.primary, props.theme.palette.mode === "dark" ? 0.055 : 0.035)}) !important;
}
.MythicResizableGrid-cell.MythicResizableGrid-rowFirstCell.mythic-process-filter-match-row::before {
background-color: ${(props) => props.theme.palette.info.main};
opacity: 1;
width: 4px;
}
.MythicResizableGrid-cell.MythicResizableGrid-rowFirstCell.mythic-process-filter-ancestor-row::before {
background-color: ${(props) => props.theme.palette.text.secondary};
opacity: 0.35;
width: 3px;
}
.MythicResizableGrid-cell.MythicResizableGrid-rowLastCell {
border-right-color: ${(props) => props.theme.table?.border || props.theme.borderColor};
}
+1 -1
View File
@@ -100,7 +100,7 @@ func Initialize() {
InvalidateOperationEventLogCacheMap()
go listenForOperationsMessages()
go listenForWriteDownloadChunkToLocalDisk()
go listenForFileBrowserData()
go listenForMythicTreeData()
go listenForAsyncAgentMessagePostResponseContent()
go updateCheckinTimeEverySecond()
for {
@@ -270,8 +270,9 @@ func getDelegateProxyMessages(callbackID int, agentUUIDLength int, updateCheckin
return nil
}
delegateMessages := []delegateMessageResponse{}
// get a list of all the other callbacks with proxy ports open
if callbackIds := proxyPorts.GetOtherCallbackIds(callbackID); len(callbackIds) > 0 {
// get callbacks that have proxy data or interactive tasking waiting so we
// avoid route lookups for idle proxy ports on every check-in.
if callbackIds := proxyPorts.GetOtherCallbackIdsWithPendingData(callbackID); len(callbackIds) > 0 {
// check if there's a route between our callback and the callback with a task
for _, targetCallbackId := range callbackIds {
if routablePath := callbackGraph.GetBFSPath(callbackID, targetCallbackId); routablePath != nil && len(routablePath) > 0 {
@@ -627,16 +627,10 @@ func handleAgentMessagePostResponse(incoming *map[string]interface{}, uUIDInfo *
currentTask.Stderr += *agentMessage.Responses[i].Stderr
}
if agentMessage.Responses[i].FileBrowser != nil {
// do it in the background - the agent doesn't need the result of this directly
FileBrowserChannel <- FileBrowserChannelMessage{
Task: &currentTask,
NewFileBrowserData: agentMessage.Responses[i].FileBrowser,
APITokenID: 0,
}
//go HandleAgentMessagePostResponseFileBrowser(currentTask, agentMessage.Responses[i].FileBrowser, 0)
enqueueMythicTreeFileBrowserResponse(currentTask, agentMessage.Responses[i].FileBrowser, 0)
}
if agentMessage.Responses[i].Processes != nil {
go HandleAgentMessagePostResponseProcesses(currentTask, agentMessage.Responses[i].Processes, 0)
enqueueMythicTreeProcessResponse(currentTask, agentMessage.Responses[i].Processes, 0)
}
if agentMessage.Responses[i].RemovedFiles != nil {
go handleAgentMessagePostResponseRemovedFiles(currentTask, agentMessage.Responses[i].RemovedFiles)
@@ -682,7 +676,7 @@ func handleAgentMessagePostResponse(incoming *map[string]interface{}, uUIDInfo *
go handleAgentMessagePostResponseEvent(currentTask, agentMessage.Responses[i].Events)
}
if agentMessage.Responses[i].CustomBrowser != nil {
go handleAgentMessagePostResponseCustomBrowser(currentTask, agentMessage.Responses[i].CustomBrowser)
enqueueMythicTreeCustomBrowserResponse(currentTask, agentMessage.Responses[i].CustomBrowser)
}
// this section always happens
reflectBackOtherKeys(&mythicResponse, &agentMessage.Responses[i].Other)
@@ -702,10 +696,7 @@ func handleAgentMessagePostResponse(incoming *map[string]interface{}, uUIDInfo *
}
if transitionedToCompleted {
go CheckAndProcessTaskCompletionHandlers(currentTask.ID)
FileBrowserChannel <- FileBrowserChannelMessage{
Task: &currentTask,
NewFileBrowserData: nil,
}
enqueueMythicTreeFileBrowserFlush(currentTask, 0)
go emitTaskLog(currentTask.ID)
go func(task databaseStructs.Task) {
EventingChannel <- EventNotification{
@@ -1934,23 +1925,10 @@ type FileBrowserChannelMessage struct {
APITokenID int
}
var fileBrowserUpdateDeletedChannel = make(chan FileBrowserChannelMessage)
var FileBrowserChannel = make(chan FileBrowserChannelMessage, 1000)
// fileBrowserUpdateDeletedChannel is buffered so slower update_deleted cleanup
// does not block the shared MythicTree ingest workers from accepting more work.
var fileBrowserUpdateDeletedChannel = make(chan FileBrowserChannelMessage, defaultAgentMessagePostResponseQueueSize)
func listenForFileBrowserData() {
go listenForFileBrowserUpdateDeleted()
for {
select {
case message := <-FileBrowserChannel:
//logging.LogInfo("[*] got message from FileBrowserChannel", "task", message.Task.ID, "completed", message.Task.Completed,
// "nil data", message.NewFileBrowserData == nil)
err := HandleAgentMessagePostResponseFileBrowser(*message.Task, message.NewFileBrowserData, message.APITokenID)
if err != nil {
logging.LogError(err, "Failed to handle file browser post response")
}
}
}
}
func HandleAgentMessagePostResponseFileBrowser(task databaseStructs.Task, fileBrowser *agentMessagePostResponseFileBrowser,
apitokensId int) error {
// given a FileBrowser object, need to insert it into database and potentially insert parents along the way
@@ -2084,10 +2062,8 @@ func listenForFileBrowserUpdateDeleted() {
if err != nil {
logging.LogError(err, "Failed to group file browser update_deleted entries")
} else {
for _, group := range groups {
if err := reconcileFileBrowserUpdateDeletedGroup(updateMessage.Task, group, updateMessage.APITokenID); err != nil {
logging.LogError(err, "Failed to update file browser children")
}
if err := reconcileFileBrowserUpdateDeletedGroups(updateMessage.Task, groups, updateMessage.APITokenID); err != nil {
logging.LogError(err, "Failed to update file browser children")
}
}
delete(fileBrowserUpdateDeletedCache, updateMessage.Task.ID)
@@ -2097,22 +2073,26 @@ func listenForFileBrowserUpdateDeleted() {
}
func HandleAgentMessagePostResponseProcesses(task databaseStructs.Task, processes *[]agentMessagePostResponseProcesses,
apitokensId int) error {
if processes == nil || len(*processes) == 0 {
return nil
}
// process data is also represented in a tree format with a full path of the process_id
updateDeleted := false
for indx, _ := range *processes {
for indx := range *processes {
if (*processes)[indx].UpdateDeleted != nil && *(*processes)[indx].UpdateDeleted {
updateDeleted = true
}
}
if len(*processes) == 0 {
return nil
}
// Get the host (default to callback.Host if per-process host not specified)
host := task.Callback.Host
if (*processes)[0].Host != nil && *(*processes)[0].Host != "" {
host = strings.ToUpper(*(*processes)[0].Host)
}
if updateDeleted {
if err := ensureTaskCallbackMythicTreeGroups(&task); err != nil {
logging.LogError(err, "Failed to fetch callback mythictree groups for process update_deleted")
return err
}
idsToDelete := []int{}
var existingTreeEntries []databaseStructs.MythicTree
err := database.DB.Select(&existingTreeEntries, `SELECT
@@ -2156,29 +2136,9 @@ func HandleAgentMessagePostResponseProcesses(task databaseStructs.Task, processe
logging.LogError(err, "Failed to upsert process MythicTree entries")
return err
}
// delete all the ids marked for deletion
if len(idsToDelete) > 0 {
deleteQuery, args, err := sqlx.Named(`UPDATE mythictree SET
deleted=true, "timestamp"=now(), task_id=:task_id
WHERE id IN (:ids)`, map[string]interface{}{
"ids": idsToDelete,
"task_id": task.ID,
})
if err != nil {
logging.LogError(err, "Failed to make named statement when updated deleted process status")
return err
}
deleteQuery, args, err = sqlx.In(deleteQuery, args...)
if err != nil {
logging.LogError(err, "Failed to do sqlx.In")
return err
}
deleteQuery = database.DB.Rebind(deleteQuery)
_, err = database.DB.Exec(deleteQuery, args...)
if err != nil {
logging.LogError(err, "Failed to exec sqlx.IN modified statement")
return err
}
if err := deleteMythicTreeNodes(task.ID, idsToDelete, nil); err != nil {
logging.LogError(err, "Failed to update deleted process status")
return err
}
} else {
treeNodesToUpsert := make([]*databaseStructs.MythicTree, 0, len(*processes))
@@ -2590,6 +2550,8 @@ func handleAgentMessagePostResponseCustomBrowser(task databaseStructs.Task, agen
logging.LogError(errors.New("unknown custom browser"), "Failed to get custom browser", "browser", agentCustomBrowser.BrowserName)
return
}
customBrowserUpdateDeleted := agentCustomBrowser.UpdateDeleted && customBrowserData.Type == databaseStructs.TREE_TYPE_FILE
updateDeletedReconciliation := mythicTreeChildrenReconciliation{}
for _, entry := range *agentCustomBrowser.Entries {
pathData, err := utils.SplitCustomPath(entry.ParentPath, entry.Name, customBrowserData.Separator)
if err != nil {
@@ -2650,103 +2612,46 @@ func handleAgentMessagePostResponseCustomBrowser(task databaseStructs.Task, agen
newTree.CallbackID.Valid = true
newTree.CallbackID.Int64 = int64(task.Callback.ID)
treeNodesToUpsert = append(treeNodesToUpsert, &newTree)
if agentCustomBrowser.UpdateDeleted {
if customBrowserData.Type == databaseStructs.TREE_TYPE_FILE {
// we need to iterate over the children for this entry and potentially remove any that the database know of but that aren't in our `files` list
var existingTreeEntries []databaseStructs.MythicTree
err = database.DB.Select(&existingTreeEntries, `SELECT
id, "name", success, full_path, parent_path, operation_id, host, tree_type, callback_id
FROM mythictree WHERE
parent_path=$1 AND operation_id=$2 AND host=$3 AND tree_type=$4`,
fullPath, task.OperationID, pathData.Host, customBrowserData.Name)
if err != nil {
logging.LogError(err, "Failed to fetch existing children when trying to update deleted")
continue
}
incomingChildrenByName := make(map[string]agentMessagePostResponseCustomBrowserChildren)
if entry.Children != nil {
for _, newEntry := range *entry.Children {
incomingChildrenByName[newEntry.Name] = newEntry
}
}
namesToDeleteAndUpdate := make(map[string]struct{}, len(existingTreeEntries)+len(incomingChildrenByName))
for _, existingEntry := range existingTreeEntries {
if newEntry, ok := incomingChildrenByName[string(existingEntry.Name)]; ok {
namesToDeleteAndUpdate[newEntry.Name] = struct{}{}
newTreeChild := databaseStructs.MythicTree{
Host: pathData.Host,
TaskID: task.ID,
OperationID: task.OperationID,
Name: []byte(newEntry.Name),
ParentPath: existingEntry.ParentPath,
FullPath: existingEntry.FullPath,
TreeType: customBrowserData.Name,
CanHaveChildren: newEntry.CanHaveChildren,
DisplayPath: []byte(newEntry.DisplayPath),
Deleted: false,
Success: existingEntry.Success,
ID: existingEntry.ID,
Os: newTree.Os,
}
newTreeChild.Metadata = GetMythicJSONTextFromStruct(newEntry.Metadata)
newTreeChild.CallbackID.Valid = true
newTreeChild.CallbackID.Int64 = int64(task.Callback.ID)
treeNodesToUpsert = append(treeNodesToUpsert, &newTreeChild)
} else {
namesToDeleteAndUpdate[string(existingEntry.Name)] = struct{}{}
existingEntry.Deleted = true
existingEntry.TaskID = task.ID
deleteTreeNode(existingEntry, true)
}
}
// now all existing ones have been updated or deleted, so it's time to add new ones
for name, newEntry := range incomingChildrenByName {
if _, ok := namesToDeleteAndUpdate[name]; !ok {
newTreeChild := databaseStructs.MythicTree{
Host: pathData.Host,
TaskID: task.ID,
OperationID: task.OperationID,
Name: []byte(newEntry.Name),
DisplayPath: []byte(newEntry.DisplayPath),
ParentPath: fullPath,
TreeType: customBrowserData.Name,
CanHaveChildren: newEntry.CanHaveChildren,
Deleted: false,
Os: newTree.Os,
}
newTreeChild.FullPath = treeNodeGetFullPath(fullPath, []byte(newEntry.Name), []byte(pathData.PathSeparator), customBrowserData.Name)
newTreeChild.Metadata = GetMythicJSONTextFromStruct(newEntry.Metadata)
newTreeChild.CallbackID.Valid = true
newTreeChild.CallbackID.Int64 = int64(task.Callback.ID)
treeNodesToUpsert = append(treeNodesToUpsert, &newTreeChild)
}
}
if customBrowserUpdateDeleted {
updateDeletedReconciliation.TreeNodesToUpsert = append(updateDeletedReconciliation.TreeNodesToUpsert, treeNodesToUpsert...)
childReconciliation, err := buildMythicTreeChildrenReconciliation(
&task,
pathData.Host,
fullPath,
pathData.PathSeparator,
customBrowserData.Name,
newTree.Os,
customBrowserChildrenByName(entry.Children),
0,
false)
if err != nil {
logging.LogError(err, "Failed to build custom browser update_deleted changes")
continue
}
} else if entry.Children != nil {
appendMythicTreeChildrenReconciliation(&updateDeletedReconciliation, childReconciliation)
continue
}
if !agentCustomBrowser.UpdateDeleted && entry.Children != nil {
// we're not automatically updating deleted children, so just iterate over the files and insert/update them
for _, newEntry := range *entry.Children {
newTreeChild := databaseStructs.MythicTree{
Host: pathData.Host,
TaskID: task.ID,
OperationID: task.OperationID,
Name: []byte(newEntry.Name),
DisplayPath: []byte(newEntry.DisplayPath),
ParentPath: fullPath,
TreeType: customBrowserData.Name,
CanHaveChildren: newEntry.CanHaveChildren,
Deleted: false,
Os: newTree.Os,
}
newTreeChild.FullPath = treeNodeGetFullPath(fullPath, []byte(newEntry.Name), []byte(pathData.PathSeparator), customBrowserData.Name)
newTreeChild.Metadata = GetMythicJSONTextFromStruct(newEntry.Metadata)
newTreeChild.CallbackID.Valid = true
newTreeChild.CallbackID.Int64 = int64(task.Callback.ID)
treeNodesToUpsert = append(treeNodesToUpsert, &newTreeChild)
treeNodesToUpsert = append(treeNodesToUpsert, buildCustomBrowserChildMythicTreeNode(
task,
pathData.Host,
fullPath,
pathData.PathSeparator,
customBrowserData.Name,
newEntry,
newTree.Os,
0))
}
}
if err := upsertMythicTreeNodes(treeNodesToUpsert); err != nil {
logging.LogError(err, "Failed to upsert custom browser MythicTree entries")
}
}
if customBrowserUpdateDeleted {
if err := applyMythicTreeChildrenReconciliation(task.ID, updateDeletedReconciliation); err != nil {
logging.LogError(err, "Failed to apply custom browser update_deleted changes")
}
}
}
@@ -118,12 +118,11 @@ func isCallbackStreaming(callbackID int) bool {
}
type interceptProxyToAgentMessage struct {
MessagesToAgent chan proxyToAgentMessage
InteractiveMessagesToAgent chan agentMessagePostResponseInteractive
Message proxyToAgentMessage
InteractiveMessage agentMessagePostResponseInteractive
ProxyType string
CallbackID int
CallbackPort *callbackPortUsage
Message proxyToAgentMessage
InteractiveMessage agentMessagePostResponseInteractive
ProxyType string
CallbackID int
}
var interceptProxyToAgentMessageChan = make(chan interceptProxyToAgentMessage, 2000)
@@ -185,20 +184,21 @@ func interceptProxyDataToAgentForPushC2() {
if !attemptedToSend {
//logging.LogInfo("Couldn't send to push c2, saving to msg")
// we don't have a PushC2 client available, so save it like normal
if msg.CallbackPort == nil {
logging.LogError(nil, "dropping proxy message because callback port is missing", "type", msg.ProxyType)
continue
}
switch msg.ProxyType {
case CALLBACK_PORT_TYPE_INTERACTIVE:
select {
case msg.InteractiveMessagesToAgent <- msg.InteractiveMessage:
default:
if !msg.CallbackPort.queueInteractiveDataForAgent(msg.InteractiveMessage) {
logging.LogError(nil, "dropping interactive message because channel is full", "type", msg.ProxyType, "len(msg.interactiveMessagesToAgent)", len(msg.CallbackPort.interactiveMessagesToAgent))
}
//.LogInfo("saved to msg")
case CALLBACK_PORT_TYPE_SOCKS:
fallthrough
case CALLBACK_PORT_TYPE_RPORTFWD:
select {
case msg.MessagesToAgent <- msg.Message:
default:
logging.LogError(nil, "dropping message because channel is full", "type", msg.ProxyType, "len(msg.MessagesToAgent)", len(msg.MessagesToAgent))
if !msg.CallbackPort.queueProxyDataForAgent(msg.Message) {
logging.LogError(nil, "dropping message because channel is full", "type", msg.ProxyType, "len(msg.messagesToAgent)", len(msg.CallbackPort.messagesToAgent))
}
}
}
@@ -352,6 +352,29 @@ func (s *submittedTasksForAgents) getInteractiveTasksForCallbackId(callbackId in
defer s.RUnlock()
return cloneTaskIDs(s.interactiveTasksByCallbackID[callbackId])
}
func (s *submittedTasksForAgents) hasInteractiveTasksForCallbackId(callbackId int) bool {
s.RLock()
defer s.RUnlock()
return len(s.interactiveTasksByCallbackID[callbackId]) > 0
}
func (s *submittedTasksForAgents) getCallbackIdsWithInteractiveTasks(callbackIds []int) map[int]bool {
var callbacksWithInteractiveTasks map[int]bool
if len(callbackIds) == 0 {
return callbacksWithInteractiveTasks
}
s.RLock()
defer s.RUnlock()
for _, callbackId := range callbackIds {
if len(s.interactiveTasksByCallbackID[callbackId]) == 0 {
continue
}
if callbacksWithInteractiveTasks == nil {
callbacksWithInteractiveTasks = make(map[int]bool)
}
callbacksWithInteractiveTasks[callbackId] = true
}
return callbacksWithInteractiveTasks
}
func (s *submittedTasksForAgents) getOtherCallbackIds(callbackId int) []int {
callbacks := []int{}
s.RLock()
+372 -35
View File
@@ -1,6 +1,7 @@
package rabbitmq
import (
"bytes"
"database/sql"
"fmt"
"strconv"
@@ -10,6 +11,7 @@ import (
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
"github.com/its-a-feature/Mythic/logging"
"github.com/its-a-feature/Mythic/utils"
"github.com/jmoiron/sqlx"
)
// MythicTree ingest can happen on busy callback paths for file browser,
@@ -33,6 +35,8 @@ const upsertMythicTreeNodeQuery = `INSERT INTO mythictree
success=(mythictree.success OR :success)
RETURNING id`
const mythicTreeDeleteBatchSize = 500
// mythicTreeNodeKey represents the database uniqueness key used by the
// MythicTree upsert. The callback validity flag is included so an invalid
// callback id cannot accidentally collapse with callback_id=0.
@@ -48,7 +52,37 @@ type mythicTreeNodeKey struct {
type fileBrowserUpdateDeletedGroup struct {
PathData utils.AnalyzedPath
FullPath []byte
ChildrenByName map[string]agentMessagePostResponseFileBrowserChildren
ChildrenByName map[string]mythicTreeChildNodeData
}
// mythicTreeChildNodeData is the normalized child payload used by both file
// browser children and file-like custom browser children before they are mapped
// to MythicTree database rows.
type mythicTreeChildNodeData struct {
Name string
DisplayPath []byte
CanHaveChildren bool
Metadata databaseStructs.MythicJSONText
}
// mythicTreeCascadeDeleteTarget describes one missing file-like node whose
// descendants should be marked deleted along with the node itself.
type mythicTreeCascadeDeleteTarget struct {
Host string
OperationID int
TreeType string
CallbackID int64
CallbackIDValid bool
FullPath []byte
PathSeparator string
}
// mythicTreeChildrenReconciliation holds the DB writes calculated for an
// update_deleted parent path without applying them immediately.
type mythicTreeChildrenReconciliation struct {
TreeNodesToUpsert []*databaseStructs.MythicTree
IDsToDelete []int
CascadeDeleteTargets []mythicTreeCascadeDeleteTarget
}
// upsertMythicTreeNode is the compatibility wrapper for callers that still need
@@ -107,6 +141,175 @@ func upsertMythicTreeNodes(treeNodes []*databaseStructs.MythicTree) error {
return nil
}
// deleteMythicTreeNodes marks MythicTree rows deleted in bulk. File-like trees
// can pass cascade targets so descendants are deleted with chunked SQL updates
// instead of one deleteTreeNode call per missing child.
func deleteMythicTreeNodes(taskID int, idsToDelete []int, cascadeTargets []mythicTreeCascadeDeleteTarget) error {
idsToDelete = dedupeMythicTreeDeleteIDs(idsToDelete)
cascadeTargets = dedupeMythicTreeCascadeDeleteTargets(cascadeTargets)
if len(idsToDelete) == 0 && len(cascadeTargets) == 0 {
return nil
}
transaction, err := database.DB.Beginx()
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = transaction.Rollback()
}
}()
for start := 0; start < len(cascadeTargets); start += mythicTreeDeleteBatchSize {
end := start + mythicTreeDeleteBatchSize
if end > len(cascadeTargets) {
end = len(cascadeTargets)
}
if err = deleteMythicTreeDescendantBatch(transaction, taskID, cascadeTargets[start:end]); err != nil {
return err
}
}
for start := 0; start < len(idsToDelete); start += mythicTreeDeleteBatchSize {
end := start + mythicTreeDeleteBatchSize
if end > len(idsToDelete) {
end = len(idsToDelete)
}
if err = deleteMythicTreeIDBatch(transaction, taskID, idsToDelete[start:end]); err != nil {
return err
}
}
if err = transaction.Commit(); err != nil {
return err
}
committed = true
return nil
}
// deleteMythicTreeIDBatch marks exact MythicTree IDs deleted inside the caller's
// transaction.
func deleteMythicTreeIDBatch(transaction *sqlx.Tx, taskID int, idsToDelete []int) error {
if len(idsToDelete) == 0 {
return nil
}
deleteQuery, args, err := sqlx.Named(`UPDATE mythictree SET
deleted=true, "timestamp"=now(), task_id=:task_id
WHERE id IN (:ids)`, map[string]interface{}{
"ids": idsToDelete,
"task_id": taskID,
})
if err != nil {
return err
}
deleteQuery, args, err = sqlx.In(deleteQuery, args...)
if err != nil {
return err
}
deleteQuery = database.DB.Rebind(deleteQuery)
_, err = transaction.Exec(deleteQuery, args...)
return err
}
// deleteMythicTreeDescendantBatch marks descendants of missing file-like nodes
// deleted in one query. It uses exact parent_path plus separator-aware byte
// prefix matching so /tmp/foo does not delete /tmp/foobar, and paths containing
// SQL wildcard characters are treated as literal bytes.
func deleteMythicTreeDescendantBatch(transaction *sqlx.Tx, taskID int, cascadeTargets []mythicTreeCascadeDeleteTarget) error {
if len(cascadeTargets) == 0 {
return nil
}
args := map[string]interface{}{
"task_id": taskID,
}
clauses := make([]string, 0, len(cascadeTargets))
for index, target := range cascadeTargets {
callbackClause := "callback_id IS NULL"
if target.CallbackIDValid {
callbackKey := fmt.Sprintf("callback_id_%d", index)
callbackClause = "callback_id=:" + callbackKey
args[callbackKey] = target.CallbackID
}
args[fmt.Sprintf("host_%d", index)] = target.Host
args[fmt.Sprintf("operation_id_%d", index)] = target.OperationID
args[fmt.Sprintf("tree_type_%d", index)] = target.TreeType
args[fmt.Sprintf("child_parent_path_%d", index)] = cloneByteSliceForDatabase(target.FullPath)
args[fmt.Sprintf("descendant_parent_path_%d", index)] = getMythicTreeDescendantParentPathPrefix(target.FullPath, target.PathSeparator)
clauses = append(clauses, fmt.Sprintf(
`(host=:host_%d AND operation_id=:operation_id_%d AND tree_type=:tree_type_%d AND %s AND (parent_path=:child_parent_path_%d OR substring(parent_path from 1 for length(:descendant_parent_path_%d))=:descendant_parent_path_%d))`,
index, index, index, callbackClause, index, index, index))
}
_, err := transaction.NamedExec(`UPDATE mythictree SET
deleted=true, "timestamp"=now(), task_id=:task_id
WHERE `+strings.Join(clauses, " OR "), args)
return err
}
// getMythicTreeDescendantParentPathPrefix returns the byte prefix for
// descendant parent paths below a deleted node.
func getMythicTreeDescendantParentPathPrefix(fullPath []byte, pathSeparator string) []byte {
prefix := cloneByteSliceForDatabase(fullPath)
separatorBytes := []byte(pathSeparator)
if len(separatorBytes) > 0 && len(prefix) > 0 && !bytes.HasSuffix(prefix, separatorBytes) {
prefix = append(prefix, separatorBytes...)
}
return prefix
}
// dedupeMythicTreeDeleteIDs avoids redundant exact-row updates when multiple
// reconciliation groups point at the same row.
func dedupeMythicTreeDeleteIDs(idsToDelete []int) []int {
if len(idsToDelete) == 0 {
return idsToDelete
}
deduped := make([]int, 0, len(idsToDelete))
seen := make(map[int]struct{}, len(idsToDelete))
for _, id := range idsToDelete {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
deduped = append(deduped, id)
}
return deduped
}
// dedupeMythicTreeCascadeDeleteTargets removes duplicate cascade scopes before
// building the chunked SQL OR clauses.
func dedupeMythicTreeCascadeDeleteTargets(targets []mythicTreeCascadeDeleteTarget) []mythicTreeCascadeDeleteTarget {
if len(targets) == 0 {
return targets
}
deduped := make([]mythicTreeCascadeDeleteTarget, 0, len(targets))
seen := make(map[string]struct{}, len(targets))
for _, target := range targets {
key := strings.Join([]string{
target.Host,
strconv.Itoa(target.OperationID),
target.TreeType,
strconv.FormatBool(target.CallbackIDValid),
strconv.FormatInt(target.CallbackID, 10),
string(target.FullPath),
target.PathSeparator,
}, "\x00")
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
target.FullPath = cloneByteSliceForDatabase(target.FullPath)
deduped = append(deduped, target)
}
return deduped
}
// ensureTaskCallbackMythicTreeGroups lazily fills callback mythictree_groups for
// direct RPC/webhook paths that only loaded the callback id. Agent message
// post-response tasks already include the groups in their initial task query.
func ensureTaskCallbackMythicTreeGroups(task *databaseStructs.Task) error {
if task == nil || len(task.Callback.MythicTreeGroups) > 0 || task.Callback.ID <= 0 {
return nil
}
return database.DB.Get(&task.Callback.MythicTreeGroups, `SELECT mythictree_groups FROM callback WHERE id=$1`, task.Callback.ID)
}
// normalizeMythicTreeNodesForBatch applies required-field defaults while
// preserving the old per-row behavior for invalid entries: skip the bad node and
// continue writing the rest of the batch.
@@ -279,21 +482,33 @@ func buildMythicTreeParentNodes(pathData utils.AnalyzedPath, displayPathData uti
// exact MythicTree row shape used by both normal file browser handling and
// update_deleted reconciliation.
func buildFileBrowserChildMythicTreeNode(task databaseStructs.Task, host string, parentFullPath []byte, pathSeparator string, child agentMessagePostResponseFileBrowserChildren, os string, apitokensId int) *databaseStructs.MythicTree {
return buildMythicTreeChildNode(task, host, parentFullPath, pathSeparator, databaseStructs.TREE_TYPE_FILE, fileBrowserChildNodeData(child), os, apitokensId)
}
// buildCustomBrowserChildMythicTreeNode maps file-like custom browser children
// into the same MythicTree child shape used by file browser responses.
func buildCustomBrowserChildMythicTreeNode(task databaseStructs.Task, host string, parentFullPath []byte, pathSeparator string, treeType string, child agentMessagePostResponseCustomBrowserChildren, os string, apitokensId int) *databaseStructs.MythicTree {
return buildMythicTreeChildNode(task, host, parentFullPath, pathSeparator, treeType, customBrowserChildNodeData(child), os, apitokensId)
}
// buildMythicTreeChildNode owns the common child-row mechanics for file browser
// and file-like custom browser data: path construction, callback/api-token
// attribution, and metadata assignment.
func buildMythicTreeChildNode(task databaseStructs.Task, host string, parentFullPath []byte, pathSeparator string, treeType string, child mythicTreeChildNodeData, os string, apitokensId int) *databaseStructs.MythicTree {
newTreeChild := databaseStructs.MythicTree{
Host: host,
TaskID: task.ID,
OperationID: task.OperationID,
Name: []byte(child.Name),
ParentPath: parentFullPath,
TreeType: databaseStructs.TREE_TYPE_FILE,
CanHaveChildren: !child.IsFile,
TreeType: treeType,
CanHaveChildren: child.CanHaveChildren,
Deleted: false,
Os: os,
DisplayPath: []byte{},
DisplayPath: child.DisplayPath,
}
newTreeChild.FullPath = treeNodeGetFullPath(parentFullPath, []byte(child.Name), []byte(pathSeparator), databaseStructs.TREE_TYPE_FILE)
fileMetaData := addChildFilePermissions(&child)
newTreeChild.Metadata = GetMythicJSONTextFromStruct(fileMetaData)
newTreeChild.FullPath = treeNodeGetFullPath(parentFullPath, []byte(child.Name), []byte(pathSeparator), treeType)
newTreeChild.Metadata = child.Metadata
newTreeChild.CallbackID.Valid = true
newTreeChild.CallbackID.Int64 = int64(task.Callback.ID)
if apitokensId > 0 {
@@ -303,6 +518,41 @@ func buildFileBrowserChildMythicTreeNode(task databaseStructs.Task, host string,
return &newTreeChild
}
// customBrowserChildNodeData keeps custom-browser display path and metadata
// mapping separate from the shared child-node construction logic.
func customBrowserChildNodeData(child agentMessagePostResponseCustomBrowserChildren) mythicTreeChildNodeData {
return mythicTreeChildNodeData{
Name: child.Name,
DisplayPath: []byte(child.DisplayPath),
CanHaveChildren: child.CanHaveChildren,
Metadata: GetMythicJSONTextFromStruct(child.Metadata),
}
}
// fileBrowserChildNodeData keeps the file-browser-specific metadata conversion
// separate from the common MythicTree child builder.
func fileBrowserChildNodeData(child agentMessagePostResponseFileBrowserChildren) mythicTreeChildNodeData {
return mythicTreeChildNodeData{
Name: child.Name,
CanHaveChildren: !child.IsFile,
Metadata: GetMythicJSONTextFromStruct(addChildFilePermissions(&child)),
}
}
// customBrowserChildrenByName converts a custom browser child slice into the
// common reconciliation map. The last entry for a duplicate child name wins,
// matching the existing single-row upsert behavior.
func customBrowserChildrenByName(children *[]agentMessagePostResponseCustomBrowserChildren) map[string]mythicTreeChildNodeData {
if children == nil {
return map[string]mythicTreeChildNodeData{}
}
childrenByName := make(map[string]mythicTreeChildNodeData, len(*children))
for _, child := range *children {
childrenByName[child.Name] = customBrowserChildNodeData(child)
}
return childrenByName
}
// groupFileBrowserUpdateDeletedEntries keeps chunked listings for the same
// directory together while preventing entries for different directories in the
// same task from being reconciled against the first path only.
@@ -328,14 +578,14 @@ func groupFileBrowserUpdateDeletedEntries(task *databaseStructs.Task, fileBrowse
groups = append(groups, fileBrowserUpdateDeletedGroup{
PathData: pathData,
FullPath: fullPath,
ChildrenByName: make(map[string]agentMessagePostResponseFileBrowserChildren),
ChildrenByName: make(map[string]mythicTreeChildNodeData),
})
}
if fileBrowser.Files == nil {
continue
}
for _, newEntry := range *fileBrowser.Files {
groups[groupIndex].ChildrenByName[newEntry.Name] = newEntry
groups[groupIndex].ChildrenByName[newEntry.Name] = fileBrowserChildNodeData(newEntry)
}
}
return groups, nil
@@ -372,63 +622,150 @@ func getFileBrowserUpdateDeletedPath(task *databaseStructs.Task, fileBrowser *ag
// mark DB-only children deleted. Each group is independent so a task can report
// multiple directories without cross-contaminating their child sets.
func reconcileFileBrowserUpdateDeletedGroup(task *databaseStructs.Task, group fileBrowserUpdateDeletedGroup, apitokensId int) error {
var existingTreeEntries []databaseStructs.MythicTree
err := database.DB.Select(&existingTreeEntries, `SELECT
id, "name", success, full_path, parent_path, operation_id, host, tree_type, callback_id, os, display_path
FROM mythictree WHERE
parent_path=$1 AND operation_id=$2 AND host=$3 AND tree_type=$4`,
group.FullPath, task.OperationID, group.PathData.Host, databaseStructs.TREE_TYPE_FILE)
return reconcileFileBrowserUpdateDeletedGroups(task, []fileBrowserUpdateDeletedGroup{group}, apitokensId)
}
// reconcileFileBrowserUpdateDeletedGroups batches update_deleted writes across
// all file-browser chunks for a task completion flush.
func reconcileFileBrowserUpdateDeletedGroups(task *databaseStructs.Task, groups []fileBrowserUpdateDeletedGroup, apitokensId int) error {
reconciliation := mythicTreeChildrenReconciliation{}
for _, group := range groups {
groupReconciliation, err := buildMythicTreeChildrenReconciliation(
task,
group.PathData.Host,
group.FullPath,
group.PathData.PathSeparator,
databaseStructs.TREE_TYPE_FILE,
getOSTypeBasedOnPathSeparator(group.PathData.PathSeparator, databaseStructs.TREE_TYPE_FILE),
group.ChildrenByName,
apitokensId,
true)
if err != nil {
return err
}
appendMythicTreeChildrenReconciliation(&reconciliation, groupReconciliation)
}
return applyMythicTreeChildrenReconciliation(task.ID, reconciliation)
}
// reconcileMythicTreeChildren is the single-parent convenience wrapper around
// the batch reconciliation builder and applier.
func reconcileMythicTreeChildren(task *databaseStructs.Task, host string, parentFullPath []byte, pathSeparator string, treeType string, os string, childrenByName map[string]mythicTreeChildNodeData, apitokensId int, preserveExistingDisplayPathWhenEmpty bool) error {
reconciliation, err := buildMythicTreeChildrenReconciliation(task, host, parentFullPath, pathSeparator, treeType, os, childrenByName, apitokensId, preserveExistingDisplayPathWhenEmpty)
if err != nil {
return err
}
namesToDeleteAndUpdate := make(map[string]struct{}, len(existingTreeEntries)+len(group.ChildrenByName))
treeNodesToUpsert := make([]*databaseStructs.MythicTree, 0, len(group.ChildrenByName))
return applyMythicTreeChildrenReconciliation(task.ID, reconciliation)
}
// buildMythicTreeChildrenReconciliation calculates update_deleted changes for
// one parent path without applying them. It scopes existing rows the same way
// process update_deleted does: same operation/host/tree and overlapping
// callback mythictree_groups. Callers can combine many parent paths and then
// apply one batch of upserts/deletes.
func buildMythicTreeChildrenReconciliation(task *databaseStructs.Task, host string, parentFullPath []byte, pathSeparator string, treeType string, os string, childrenByName map[string]mythicTreeChildNodeData, apitokensId int, preserveExistingDisplayPathWhenEmpty bool) (mythicTreeChildrenReconciliation, error) {
reconciliation := mythicTreeChildrenReconciliation{
TreeNodesToUpsert: make([]*databaseStructs.MythicTree, 0, len(childrenByName)),
}
if err := ensureTaskCallbackMythicTreeGroups(task); err != nil {
return reconciliation, err
}
var existingTreeEntries []databaseStructs.MythicTree
err := database.DB.Select(&existingTreeEntries, `SELECT
mythictree.id, mythictree."name", mythictree.success, mythictree.full_path, mythictree.parent_path,
mythictree.operation_id, mythictree.host, mythictree.tree_type, mythictree.callback_id,
mythictree.os, mythictree.display_path
FROM mythictree
JOIN callback ON mythictree.callback_id = callback.id
WHERE
mythictree.parent_path=$1 AND mythictree.operation_id=$2 AND mythictree.host=$3 AND
mythictree.tree_type=$4 AND callback.mythictree_groups && $5`,
parentFullPath, task.OperationID, host, treeType, task.Callback.MythicTreeGroups)
if err != nil {
return reconciliation, err
}
namesToDeleteAndUpdate := make(map[string]struct{}, len(existingTreeEntries)+len(childrenByName))
for _, existingEntry := range existingTreeEntries {
if newEntry, ok := group.ChildrenByName[string(existingEntry.Name)]; ok {
if newEntry, ok := childrenByName[string(existingEntry.Name)]; ok {
namesToDeleteAndUpdate[newEntry.Name] = struct{}{}
displayPath := newEntry.DisplayPath
if preserveExistingDisplayPathWhenEmpty && len(displayPath) == 0 {
displayPath = existingEntry.DisplayPath
}
newTreeChild := databaseStructs.MythicTree{
Host: group.PathData.Host,
Host: host,
TaskID: task.ID,
OperationID: task.OperationID,
Name: []byte(newEntry.Name),
ParentPath: existingEntry.ParentPath,
FullPath: existingEntry.FullPath,
TreeType: databaseStructs.TREE_TYPE_FILE,
CanHaveChildren: !newEntry.IsFile,
TreeType: treeType,
CanHaveChildren: newEntry.CanHaveChildren,
Deleted: false,
Success: existingEntry.Success,
ID: existingEntry.ID,
Os: existingEntry.Os,
DisplayPath: existingEntry.DisplayPath,
DisplayPath: displayPath,
CallbackID: existingEntry.CallbackID,
}
fileMetaData := addChildFilePermissions(&newEntry)
newTreeChild.Metadata = GetMythicJSONTextFromStruct(fileMetaData)
if newTreeChild.Os == "" {
newTreeChild.Os = os
}
newTreeChild.Metadata = newEntry.Metadata
if !newTreeChild.CallbackID.Valid {
newTreeChild.CallbackID.Valid = true
newTreeChild.CallbackID.Int64 = int64(task.Callback.ID)
}
treeNodesToUpsert = append(treeNodesToUpsert, &newTreeChild)
if apitokensId > 0 {
newTreeChild.APITokensID.Valid = true
newTreeChild.APITokensID.Int64 = int64(apitokensId)
}
reconciliation.TreeNodesToUpsert = append(reconciliation.TreeNodesToUpsert, &newTreeChild)
} else {
namesToDeleteAndUpdate[string(existingEntry.Name)] = struct{}{}
existingEntry.Deleted = true
existingEntry.TaskID = task.ID
deleteTreeNode(existingEntry, true)
reconciliation.IDsToDelete = append(reconciliation.IDsToDelete, existingEntry.ID)
reconciliation.CascadeDeleteTargets = append(reconciliation.CascadeDeleteTargets, mythicTreeCascadeDeleteTarget{
Host: existingEntry.Host,
OperationID: existingEntry.OperationID,
TreeType: existingEntry.TreeType,
CallbackID: existingEntry.CallbackID.Int64,
CallbackIDValid: existingEntry.CallbackID.Valid,
FullPath: existingEntry.FullPath,
PathSeparator: pathSeparator,
})
}
}
for name, newEntry := range group.ChildrenByName {
for name, newEntry := range childrenByName {
if _, ok := namesToDeleteAndUpdate[name]; !ok {
treeNodesToUpsert = append(treeNodesToUpsert, buildFileBrowserChildMythicTreeNode(
reconciliation.TreeNodesToUpsert = append(reconciliation.TreeNodesToUpsert, buildMythicTreeChildNode(
*task,
group.PathData.Host,
group.FullPath,
group.PathData.PathSeparator,
host,
parentFullPath,
pathSeparator,
treeType,
newEntry,
getOSTypeBasedOnPathSeparator(group.PathData.PathSeparator, databaseStructs.TREE_TYPE_FILE),
os,
apitokensId))
}
}
return upsertMythicTreeNodes(treeNodesToUpsert)
return reconciliation, nil
}
// appendMythicTreeChildrenReconciliation merges independently calculated
// update_deleted changes so callers can issue one write batch.
func appendMythicTreeChildrenReconciliation(target *mythicTreeChildrenReconciliation, incoming mythicTreeChildrenReconciliation) {
target.TreeNodesToUpsert = append(target.TreeNodesToUpsert, incoming.TreeNodesToUpsert...)
target.IDsToDelete = append(target.IDsToDelete, incoming.IDsToDelete...)
target.CascadeDeleteTargets = append(target.CascadeDeleteTargets, incoming.CascadeDeleteTargets...)
}
// applyMythicTreeChildrenReconciliation writes all queued update_deleted
// upserts first, then marks stale exact rows and descendants deleted in bulk.
func applyMythicTreeChildrenReconciliation(taskID int, reconciliation mythicTreeChildrenReconciliation) error {
if err := upsertMythicTreeNodes(reconciliation.TreeNodesToUpsert); err != nil {
return err
}
return deleteMythicTreeNodes(taskID, reconciliation.IDsToDelete, reconciliation.CascadeDeleteTargets)
}
// buildProcessMythicTreeNode centralizes process-to-MythicTree normalization so
@@ -137,6 +137,85 @@ func TestBuildFileBrowserChildMythicTreeNodeDoesNotAliasSiblingFullPaths(t *test
}
}
func TestBuildCustomBrowserChildMythicTreeNodePreservesDisplayPath(t *testing.T) {
task := databaseStructs.Task{
ID: 5,
OperationID: 6,
}
task.Callback.ID = 7
child := agentMessagePostResponseCustomBrowserChildren{
Name: "123",
DisplayPath: "sshd",
CanHaveChildren: true,
Metadata: map[string]interface{}{"name": "sshd"},
}
node := buildCustomBrowserChildMythicTreeNode(task, "HOST", []byte("/processes"), "/", databaseStructs.TREE_TYPE_FILE, child, "linux", 0)
if string(node.FullPath) != "/processes/123" {
t.Fatalf("expected custom child full path to include its name, got %q", string(node.FullPath))
}
if string(node.DisplayPath) != "sshd" {
t.Fatalf("expected custom child display path to be preserved, got %q", string(node.DisplayPath))
}
if !node.CanHaveChildren {
t.Fatalf("expected custom child can_have_children to be preserved")
}
if node.Metadata.StructValue()["name"] != "sshd" {
t.Fatalf("expected custom child metadata to be preserved, got %#v", node.Metadata.StructValue())
}
}
func TestCustomBrowserChildrenByNameUsesLastDuplicate(t *testing.T) {
children := []agentMessagePostResponseCustomBrowserChildren{
{Name: "alpha", DisplayPath: "old"},
{Name: "alpha", DisplayPath: "new"},
{Name: "bravo", DisplayPath: "bravo"},
}
childrenByName := customBrowserChildrenByName(&children)
if len(childrenByName) != 2 {
t.Fatalf("expected duplicate custom child names to collapse to two entries, got %d", len(childrenByName))
}
if string(childrenByName["alpha"].DisplayPath) != "new" {
t.Fatalf("expected duplicate custom child to keep last display path, got %q", string(childrenByName["alpha"].DisplayPath))
}
}
func TestMythicTreeDescendantParentPathPrefixIsSeparatorAware(t *testing.T) {
if prefix := getMythicTreeDescendantParentPathPrefix([]byte("/tmp/foo"), "/"); string(prefix) != "/tmp/foo/" {
t.Fatalf("expected slash descendant prefix to include separator, got %q", string(prefix))
}
if prefix := getMythicTreeDescendantParentPathPrefix([]byte("/tmp/foo/"), "/"); string(prefix) != "/tmp/foo/" {
t.Fatalf("expected existing trailing separator to be preserved, got %q", string(prefix))
}
if prefix := getMythicTreeDescendantParentPathPrefix([]byte(`C:\Windows`), `\`); string(prefix) != `C:\Windows\` {
t.Fatalf("expected windows descendant prefix to include separator, got %q", string(prefix))
}
if prefix := getMythicTreeDescendantParentPathPrefix([]byte(`/tmp/100%_literal`), `/`); string(prefix) != `/tmp/100%_literal/` {
t.Fatalf("expected SQL wildcard bytes to remain literal, got %q", string(prefix))
}
}
func TestDedupeMythicTreeCascadeDeleteTargets(t *testing.T) {
targets := []mythicTreeCascadeDeleteTarget{
{Host: "HOST", OperationID: 1, TreeType: databaseStructs.TREE_TYPE_FILE, CallbackID: 2, CallbackIDValid: true, FullPath: []byte("/tmp/alpha"), PathSeparator: "/"},
{Host: "HOST", OperationID: 1, TreeType: databaseStructs.TREE_TYPE_FILE, CallbackID: 2, CallbackIDValid: true, FullPath: []byte("/tmp/alpha"), PathSeparator: "/"},
{Host: "HOST", OperationID: 1, TreeType: databaseStructs.TREE_TYPE_FILE, CallbackID: 3, CallbackIDValid: true, FullPath: []byte("/tmp/alpha"), PathSeparator: "/"},
}
deduped := dedupeMythicTreeCascadeDeleteTargets(targets)
if len(deduped) != 2 {
t.Fatalf("expected duplicate cascade targets to collapse to two entries, got %d", len(deduped))
}
targets[0].FullPath[0] = 'X'
if string(deduped[0].FullPath) != "/tmp/alpha" {
t.Fatalf("expected deduped cascade target to own its full path bytes, got %q", string(deduped[0].FullPath))
}
}
func TestGroupFileBrowserUpdateDeletedEntriesMergesChunksForSameDirectory(t *testing.T) {
task := databaseStructs.Task{}
task.Callback.Host = "HOST"
@@ -0,0 +1,151 @@
package rabbitmq
import (
"sync"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
"github.com/its-a-feature/Mythic/logging"
)
// mythicTreeIngestMessageKind tells the shared MythicTree worker how to route
// one queued ingest message without needing separate queues for each browser
// type.
type mythicTreeIngestMessageKind int
const (
mythicTreeIngestFileBrowser mythicTreeIngestMessageKind = iota
mythicTreeIngestFileBrowserFlush
mythicTreeIngestProcess
mythicTreeIngestCustomBrowser
)
// mythicTreeIngestMessage is the bounded-queue payload for all MythicTree write
// paths. Only the field that matches Kind is populated.
type mythicTreeIngestMessage struct {
Kind mythicTreeIngestMessageKind
Task databaseStructs.Task
FileBrowser *agentMessagePostResponseFileBrowser
Processes *[]agentMessagePostResponseProcesses
CustomBrowser *agentMessagePostResponseCustomBrowser
APITokenID int
}
var mythicTreeWorkerChannels []chan mythicTreeIngestMessage
var mythicTreeIngestWorkersOnce sync.Once
// listenForMythicTreeData initializes one bounded worker pool for MythicTree
// ingest. File browser, process browser, and custom browser all write the same
// table, so one sharded queue gives us one place to control DB pressure during
// high-rate callback check-ins.
func listenForMythicTreeData() {
mythicTreeIngestWorkersOnce.Do(initializeMythicTreeIngestWorkers)
}
// initializeMythicTreeIngestWorkers starts the update_deleted coordinator and a
// sharded worker pool sized from the existing post-response worker settings.
func initializeMythicTreeIngestWorkers() {
go listenForFileBrowserUpdateDeleted()
workerCount := getAgentMessagePostResponseWorkerCount()
queueSize := getAgentMessagePostResponseQueueSize()
mythicTreeWorkerChannels = make([]chan mythicTreeIngestMessage, workerCount)
for i := 0; i < workerCount; i++ {
mythicTreeWorkerChannels[i] = make(chan mythicTreeIngestMessage, queueSize)
go listenForMythicTreeWorker(i, mythicTreeWorkerChannels[i])
}
}
// enqueueMythicTreeFileBrowserResponse queues a single file browser response so
// agent post-response processing is not blocked on MythicTree DB writes.
func enqueueMythicTreeFileBrowserResponse(task databaseStructs.Task, fileBrowser *agentMessagePostResponseFileBrowser, apitokensId int) {
enqueueMythicTreeIngestMessage(mythicTreeIngestMessage{
Kind: mythicTreeIngestFileBrowser,
Task: task,
FileBrowser: fileBrowser,
APITokenID: apitokensId,
})
}
// enqueueMythicTreeFileBrowserFlush queues the completion marker that tells the
// file-browser update_deleted coordinator it can reconcile cached chunks.
func enqueueMythicTreeFileBrowserFlush(task databaseStructs.Task, apitokensId int) {
enqueueMythicTreeIngestMessage(mythicTreeIngestMessage{
Kind: mythicTreeIngestFileBrowserFlush,
Task: task,
APITokenID: apitokensId,
})
}
// enqueueMythicTreeProcessResponse queues process MythicTree data on the shared
// ingest pool, preserving the direct RPC path for callers that need it.
func enqueueMythicTreeProcessResponse(task databaseStructs.Task, processes *[]agentMessagePostResponseProcesses, apitokensId int) {
if processes == nil {
return
}
enqueueMythicTreeIngestMessage(mythicTreeIngestMessage{
Kind: mythicTreeIngestProcess,
Task: task,
Processes: processes,
APITokenID: apitokensId,
})
}
// enqueueMythicTreeCustomBrowserResponse queues custom-browser MythicTree data
// on the same bounded pool as file and process browser data.
func enqueueMythicTreeCustomBrowserResponse(task databaseStructs.Task, customBrowser *agentMessagePostResponseCustomBrowser) {
if customBrowser == nil {
return
}
enqueueMythicTreeIngestMessage(mythicTreeIngestMessage{
Kind: mythicTreeIngestCustomBrowser,
Task: task,
CustomBrowser: customBrowser,
})
}
// enqueueMythicTreeIngestMessage lazily starts the pool and consistently shards
// all MythicTree work for a task to the same worker. That keeps per-task flush
// ordering intact while still allowing unrelated tasks to run in parallel.
func enqueueMythicTreeIngestMessage(msg mythicTreeIngestMessage) {
mythicTreeIngestWorkersOnce.Do(initializeMythicTreeIngestWorkers)
workerID := getMythicTreeWorkerID(msg.Task.ID, len(mythicTreeWorkerChannels))
mythicTreeWorkerChannels[workerID] <- msg
}
// getMythicTreeWorkerID maps task IDs to worker shards. Keeping this tiny and
// deterministic makes it easy to test ordering assumptions.
func getMythicTreeWorkerID(taskID int, workerCount int) int {
if workerCount <= 1 {
return 0
}
workerID := taskID % workerCount
if workerID < 0 {
return 0
}
return workerID
}
// listenForMythicTreeWorker serializes MythicTree writes for a shard and
// dispatches to the existing handlers so their request-specific semantics stay
// unchanged.
func listenForMythicTreeWorker(workerID int, input <-chan mythicTreeIngestMessage) {
for msg := range input {
switch msg.Kind {
case mythicTreeIngestFileBrowser:
if err := HandleAgentMessagePostResponseFileBrowser(msg.Task, msg.FileBrowser, msg.APITokenID); err != nil {
logging.LogError(err, "Failed to handle file browser MythicTree post response", "worker_id", workerID, "task_id", msg.Task.ID)
}
case mythicTreeIngestFileBrowserFlush:
if err := HandleAgentMessagePostResponseFileBrowser(msg.Task, nil, msg.APITokenID); err != nil {
logging.LogError(err, "Failed to flush file browser MythicTree post response", "worker_id", workerID, "task_id", msg.Task.ID)
}
case mythicTreeIngestProcess:
if err := HandleAgentMessagePostResponseProcesses(msg.Task, msg.Processes, msg.APITokenID); err != nil {
logging.LogError(err, "Failed to handle process MythicTree post response", "worker_id", workerID, "task_id", msg.Task.ID)
}
case mythicTreeIngestCustomBrowser:
handleAgentMessagePostResponseCustomBrowser(msg.Task, msg.CustomBrowser)
default:
logging.LogError(nil, "Unknown MythicTree ingest message kind", "worker_id", workerID, "task_id", msg.Task.ID, "kind", msg.Kind)
}
}
}
@@ -0,0 +1,15 @@
package rabbitmq
import "testing"
func TestGetMythicTreeWorkerID(t *testing.T) {
if workerID := getMythicTreeWorkerID(10, 8); workerID != 2 {
t.Fatalf("expected task 10 to shard to worker 2, got %d", workerID)
}
if workerID := getMythicTreeWorkerID(-1, 8); workerID != 0 {
t.Fatalf("expected negative task id to shard to worker 0, got %d", workerID)
}
if workerID := getMythicTreeWorkerID(10, 0); workerID != 0 {
t.Fatalf("expected zero workers to safely shard to worker 0, got %d", workerID)
}
}
+197 -26
View File
@@ -72,6 +72,8 @@ type callbackPortUsage struct {
bytesSentToAgent atomic.Int64
lastFlushedBytesReceivedFromAgent atomic.Int64
lastFlushedBytesSentToAgent atomic.Int64
pendingMessagesToAgent atomic.Int64
pendingInteractiveMessagesToAgent atomic.Int64
acceptedConnections *[]*acceptedConnection
messagesToAgent chan proxyToAgentMessage
interactiveMessagesToAgent chan agentMessagePostResponseInteractive
@@ -352,6 +354,76 @@ func cloneCallbackPortUsages(ports []*callbackPortUsage) []*callbackPortUsage {
return append([]*callbackPortUsage(nil), ports...)
}
// queueProxyDataForAgent records that a SOCKS/RPFWD message is pending before
// placing it on the buffered channel. GetProxyData uses the counter as a cheap
// fast path so frequent agent polls do not allocate and drain empty queues.
func (p *callbackPortUsage) queueProxyDataForAgent(message proxyToAgentMessage) bool {
p.pendingMessagesToAgent.Add(1)
select {
case p.messagesToAgent <- message:
return true
default:
p.pendingMessagesToAgent.Add(-1)
return false
}
}
// queueInteractiveDataForAgent mirrors queueProxyDataForAgent for interactive
// terminal data. Interactive task lookup remains separate because it can create
// outbound data even when the channel itself is empty.
func (p *callbackPortUsage) queueInteractiveDataForAgent(message agentMessagePostResponseInteractive) bool {
p.pendingInteractiveMessagesToAgent.Add(1)
select {
case p.interactiveMessagesToAgent <- message:
return true
default:
p.pendingInteractiveMessagesToAgent.Add(-1)
return false
}
}
func (p *callbackPortUsage) hasPendingProxyDataForAgent() bool {
return p.pendingMessagesToAgent.Load() > 0 || len(p.messagesToAgent) > 0
}
func (p *callbackPortUsage) hasPendingInteractiveDataForAgent() bool {
return p.pendingInteractiveMessagesToAgent.Load() > 0 || len(p.interactiveMessagesToAgent) > 0
}
func (p *callbackPortUsage) hasPendingDataForAgent() bool {
switch p.PortType {
case CALLBACK_PORT_TYPE_INTERACTIVE:
return p.hasPendingInteractiveDataForAgent()
case CALLBACK_PORT_TYPE_SOCKS:
fallthrough
case CALLBACK_PORT_TYPE_RPORTFWD:
return p.hasPendingProxyDataForAgent()
default:
return false
}
}
func callbackPortUsagesHavePendingData(ports []*callbackPortUsage) bool {
for _, port := range ports {
if port.hasPendingDataForAgent() {
return true
}
}
return false
}
func decrementPositiveAtomicCounter(counter *atomic.Int64) {
for {
current := counter.Load()
if current <= 0 {
return
}
if counter.CompareAndSwap(current, current-1) {
return
}
}
}
func clampCallbackPortByteCount(value int64) int64 {
if value < 0 {
return 0
@@ -488,6 +560,43 @@ func (c *callbackPortsInUse) getPortsForCallbackByType(callbackID int, portTypes
return portsByType
}
// getPortsWithPendingDataForCallbackByType returns only ports that have data
// buffered for the agent. The common no-data poll avoids cloning every active
// port slice and avoids calling the drain methods that allocate response slices.
func (c *callbackPortsInUse) getPortsWithPendingDataForCallbackByType(callbackID int, portTypes []CallbackPortType) map[CallbackPortType][]*callbackPortUsage {
var portsByType map[CallbackPortType][]*callbackPortUsage
appendPendingPort := func(portType CallbackPortType, port *callbackPortUsage) {
if portsByType == nil {
portsByType = make(map[CallbackPortType][]*callbackPortUsage, len(portTypes))
}
portsByType[portType] = append(portsByType[portType], port)
}
c.RLock()
defer c.RUnlock()
if c.portsByCallbackIDAndType == nil {
for _, port := range c.ports {
if port.CallbackID != callbackID || !port.hasPendingDataForAgent() {
continue
}
for _, portType := range portTypes {
if port.PortType == portType {
appendPendingPort(portType, port)
break
}
}
}
return portsByType
}
for _, portType := range portTypes {
for _, port := range c.portsByCallbackIDAndType[callbackID][portType] {
if port.hasPendingDataForAgent() {
appendPendingPort(portType, port)
}
}
}
return portsByType
}
func (c *callbackPortsInUse) findPortForRemoval(callbackId int, portType CallbackPortType, localPort int, operationId int, remoteIP string, remotePort int,
username string, password string) *callbackPortUsage {
c.RLock()
@@ -654,7 +763,7 @@ func (c *callbackPortsInUse) GetDataForCallbackIdAllTypes(callbackId int) (callb
func (c *callbackPortsInUse) getDataForCallbackIdPortTypes(callbackId int, portTypes []CallbackPortType) (callbackProxyData, error) {
proxyData := callbackProxyData{}
portsByType := c.getPortsForCallbackByType(callbackId, portTypes)
portsByType := c.getPortsWithPendingDataForCallbackByType(callbackId, portTypes)
for _, portType := range portTypes {
ports := portsByType[portType]
switch portType {
@@ -662,7 +771,7 @@ func (c *callbackPortsInUse) getDataForCallbackIdPortTypes(callbackId int, portT
for _, port := range ports {
proxyData.Interactive = append(proxyData.Interactive, port.GetData().([]agentMessagePostResponseInteractive)...)
}
if len(ports) == 0 {
if len(ports) == 0 && submittedTasksAwaitingFetching.hasInteractiveTasksForCallbackId(callbackId) {
newInteractiveData, err := handleAgentMessageGetInteractiveTasking(callbackId)
if err != nil {
logging.LogError(err, "Failed to fetch interactive tasks")
@@ -730,6 +839,60 @@ func (c *callbackPortsInUse) GetOtherCallbackIds(callbackId int) []int {
}
return callbackIds
}
// GetOtherCallbackIdsWithPendingData returns other callbacks that have proxy
// data worth routing through delegates. This keeps the delegate path from doing
// BFS work for idle proxy ports while still preserving interactive tasking,
// which is delivered through this same proxy/delegate response path.
func (c *callbackPortsInUse) GetOtherCallbackIdsWithPendingData(callbackId int) []int {
var candidateCallbackIds []int
var pendingCallbackIds map[int]bool
markPendingCallbackId := func(currentCallbackID int) {
if pendingCallbackIds == nil {
pendingCallbackIds = make(map[int]bool)
}
pendingCallbackIds[currentCallbackID] = true
}
c.RLock()
if c.portsByCallbackID == nil {
callbackPortsByID := make(map[int][]*callbackPortUsage)
for _, port := range c.ports {
if port.CallbackID == callbackId {
continue
}
if len(callbackPortsByID[port.CallbackID]) == 0 {
candidateCallbackIds = append(candidateCallbackIds, port.CallbackID)
}
callbackPortsByID[port.CallbackID] = append(callbackPortsByID[port.CallbackID], port)
}
for _, currentCallbackID := range candidateCallbackIds {
if callbackPortUsagesHavePendingData(callbackPortsByID[currentCallbackID]) {
markPendingCallbackId(currentCallbackID)
}
}
} else {
for _, currentCallbackID := range c.callbacksWithPorts {
if currentCallbackID == callbackId {
continue
}
candidateCallbackIds = append(candidateCallbackIds, currentCallbackID)
if callbackPortUsagesHavePendingData(c.portsByCallbackID[currentCallbackID]) {
markPendingCallbackId(currentCallbackID)
}
}
}
c.RUnlock()
callbackIdsWithInteractiveTasks := submittedTasksAwaitingFetching.getCallbackIdsWithInteractiveTasks(candidateCallbackIds)
callbackIds := make([]int, 0, len(pendingCallbackIds)+len(callbackIdsWithInteractiveTasks))
for _, currentCallbackID := range candidateCallbackIds {
if pendingCallbackIds[currentCallbackID] || callbackIdsWithInteractiveTasks[currentCallbackID] {
callbackIds = append(callbackIds, currentCallbackID)
}
}
return callbackIds
}
func (c *callbackPortsInUse) GetDataForCallbackId(callbackId int, portType string) (interface{}, error) {
return c.GetDataForCallbackIdPortType(callbackId, portType)
}
@@ -989,10 +1152,14 @@ func (p *callbackPortUsage) GetData() interface{} {
return nil
}
func (p *callbackPortUsage) GetProxyData() []proxyToAgentMessage {
if !p.hasPendingProxyDataForAgent() {
return nil
}
messagesToSendToAgent := make([]proxyToAgentMessage, len(p.messagesToAgent))
for i := 0; i < len(messagesToSendToAgent); i++ {
select {
case messagesToSendToAgent[i] = <-p.messagesToAgent:
decrementPositiveAtomicCounter(&p.pendingMessagesToAgent)
//logging.LogDebug("Agent picking up msg from Mythic", "serverID", messagesToSendToAgent[i].ServerID, "exit", messagesToSendToAgent[i].IsExit)
default:
//logging.LogDebug("returning set of messages to agent from Mythic", "msgs", messagesToSendToAgent)
@@ -1003,10 +1170,14 @@ func (p *callbackPortUsage) GetProxyData() []proxyToAgentMessage {
return messagesToSendToAgent
}
func (p *callbackPortUsage) GetInteractiveData() []agentMessagePostResponseInteractive {
if !p.hasPendingInteractiveDataForAgent() && !submittedTasksAwaitingFetching.hasInteractiveTasksForCallbackId(p.CallbackID) {
return nil
}
messagesToSendToAgent := make([]agentMessagePostResponseInteractive, len(p.interactiveMessagesToAgent))
for i := 0; i < len(messagesToSendToAgent); i++ {
select {
case messagesToSendToAgent[i] = <-p.interactiveMessagesToAgent:
decrementPositiveAtomicCounter(&p.pendingInteractiveMessagesToAgent)
//logging.LogDebug("Got message from Mythic to agent", "TaskID", messagesToSendToAgent[i].TaskUUID)
default:
//logging.LogDebug("returning set of messages to agent from Mythic", "msgs", messagesToSendToAgent)
@@ -1099,9 +1270,9 @@ func (p *callbackPortUsage) manageConnections() {
ServerID: removeCon.ServerID,
Port: p.LocalPort,
},
MessagesToAgent: p.messagesToAgent,
ProxyType: p.PortType,
CallbackID: p.CallbackID,
CallbackPort: p,
ProxyType: p.PortType,
CallbackID: p.CallbackID,
}:
default:
}
@@ -1130,9 +1301,9 @@ func (p *callbackPortUsage) manageConnections() {
ServerID: newMsg.ServerID,
Port: p.LocalPort,
},
MessagesToAgent: p.messagesToAgent,
ProxyType: p.PortType,
CallbackID: p.CallbackID,
CallbackPort: p,
ProxyType: p.PortType,
CallbackID: p.CallbackID,
}
*/
@@ -1162,9 +1333,9 @@ func (p *callbackPortUsage) manageConnections() {
ServerID: newMsg.ServerID,
Port: p.LocalPort,
},
MessagesToAgent: p.messagesToAgent,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
CallbackPort: p,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
}
go SendAllOperationsMessage(fmt.Sprintf("Failed to connect to %s:%d for new rpfwd message", p.RemoteIP, p.RemotePort),
p.OperationID, "rpfwd", database.MESSAGE_LEVEL_INFO, true)
@@ -1756,9 +1927,9 @@ func (p *callbackPortUsage) handleSocksConnections() {
ServerID: newUDPConnection.ServerID,
Port: p.LocalPort,
},
MessagesToAgent: p.messagesToAgent,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
CallbackPort: p,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
}
p.addBytesSentToAgent(int64(newUDPConnectionBufLength))
go func(remoteAddr net.Addr) {
@@ -1800,9 +1971,9 @@ func (p *callbackPortUsage) handleSocksConnections() {
ServerID: newUDPConnection.ServerID,
Port: p.LocalPort,
},
MessagesToAgent: p.messagesToAgent,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
CallbackPort: p,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
}
}
}
@@ -1821,9 +1992,9 @@ func (p *callbackPortUsage) handleSocksConnections() {
ServerID: newConnection.ServerID,
Port: p.LocalPort,
},
MessagesToAgent: p.messagesToAgent,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
CallbackPort: p,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
}
p.addBytesSentToAgent(int64(length))
}
@@ -1897,9 +2068,9 @@ func (p *callbackPortUsage) handleRpfwdConnections(newConnection *acceptedConnec
ServerID: newConnection.ServerID,
Port: p.LocalPort,
},
MessagesToAgent: p.messagesToAgent,
ProxyType: p.PortType,
CallbackID: p.CallbackID,
CallbackPort: p,
ProxyType: p.PortType,
CallbackID: p.CallbackID,
}
p.addBytesSentToAgent(int64(length))
//fmt.Printf("Message sent to p.messagesToAgent channel for chan %d\n", newConnection.ServerID)
@@ -2001,9 +2172,9 @@ func (p *callbackPortUsage) handleInteractiveConnections() {
TaskUUID: taskUUID,
MessageType: 0,
},
InteractiveMessagesToAgent: p.interactiveMessagesToAgent,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
CallbackPort: p,
CallbackID: p.CallbackID,
ProxyType: p.PortType,
}
p.addBytesSentToAgent(int64(length))
//fmt.Printf("Message sent to p.messagesToAgent channel for chan %d\n", newConnection.ServerID)
@@ -33,6 +33,37 @@ func removeTestCallbackPort(registry *callbackPortsInUse, port *callbackPortUsag
registry.removePortLocked(port)
}
func replaceSubmittedTasksForProxyTest(t *testing.T, tasks []submittedTask) {
t.Helper()
submittedTasksAwaitingFetching.Lock()
oldTasks := submittedTasksAwaitingFetching.Tasks
oldTasksByCallbackID := submittedTasksAwaitingFetching.tasksByCallbackID
oldInteractiveTasksByCallbackID := submittedTasksAwaitingFetching.interactiveTasksByCallbackID
oldCallbacksWithTasks := submittedTasksAwaitingFetching.callbacksWithTasks
oldCallbacksWithInteractiveTasks := submittedTasksAwaitingFetching.callbacksWithInteractiveTasks
oldTaskByID := submittedTasksAwaitingFetching.taskByID
taskArray := make([]submittedTask, 0, len(tasks))
submittedTasksAwaitingFetching.Tasks = &taskArray
submittedTasksAwaitingFetching.resetTaskIndexesLocked()
for _, task := range tasks {
submittedTasksAwaitingFetching.addTaskLocked(task)
}
submittedTasksAwaitingFetching.Unlock()
t.Cleanup(func() {
submittedTasksAwaitingFetching.Lock()
submittedTasksAwaitingFetching.Tasks = oldTasks
submittedTasksAwaitingFetching.tasksByCallbackID = oldTasksByCallbackID
submittedTasksAwaitingFetching.interactiveTasksByCallbackID = oldInteractiveTasksByCallbackID
submittedTasksAwaitingFetching.callbacksWithTasks = oldCallbacksWithTasks
submittedTasksAwaitingFetching.callbacksWithInteractiveTasks = oldCallbacksWithInteractiveTasks
submittedTasksAwaitingFetching.taskByID = oldTaskByID
submittedTasksAwaitingFetching.Unlock()
})
}
func TestCallbackPortsInUseIndexesCallbacksAndPortTypes(t *testing.T) {
registry := callbackPortsInUse{}
socksPort := newTestCallbackPort(10, CALLBACK_PORT_TYPE_SOCKS, 7001, 1)
@@ -67,9 +98,15 @@ func TestCallbackPortsInUseCombinedDataFetchUsesIndexedPorts(t *testing.T) {
addTestCallbackPort(&registry, rpfwdPort)
addTestCallbackPort(&registry, interactivePort)
socksPort.messagesToAgent <- proxyToAgentMessage{ServerID: 1, Message: []byte("socks")}
rpfwdPort.messagesToAgent <- proxyToAgentMessage{ServerID: 2, Message: []byte("rpfwd")}
interactivePort.interactiveMessagesToAgent <- agentMessagePostResponseInteractive{TaskUUID: "interactive"}
if !socksPort.queueProxyDataForAgent(proxyToAgentMessage{ServerID: 1, Message: []byte("socks")}) {
t.Fatal("failed to queue socks message")
}
if !rpfwdPort.queueProxyDataForAgent(proxyToAgentMessage{ServerID: 2, Message: []byte("rpfwd")}) {
t.Fatal("failed to queue rpfwd message")
}
if !interactivePort.queueInteractiveDataForAgent(agentMessagePostResponseInteractive{TaskUUID: "interactive"}) {
t.Fatal("failed to queue interactive message")
}
proxyData, err := registry.GetDataForCallbackIdAllTypes(10)
if err != nil {
@@ -94,6 +131,101 @@ func TestCallbackPortsInUseCombinedDataFetchUsesIndexedPorts(t *testing.T) {
}
}
func TestCallbackPortsInUsePendingDelegateCallbacksSkipIdlePorts(t *testing.T) {
replaceSubmittedTasksForProxyTest(t, nil)
registry := callbackPortsInUse{}
currentPort := newTestCallbackPort(10, CALLBACK_PORT_TYPE_SOCKS, 7001, 1)
idlePort := newTestCallbackPort(20, CALLBACK_PORT_TYPE_SOCKS, 7002, 2)
pendingPort := newTestCallbackPort(30, CALLBACK_PORT_TYPE_RPORTFWD, 7003, 3)
addTestCallbackPort(&registry, currentPort)
addTestCallbackPort(&registry, idlePort)
addTestCallbackPort(&registry, pendingPort)
if callbackIDs := registry.GetOtherCallbackIdsWithPendingData(10); len(callbackIDs) != 0 {
t.Fatalf("expected no delegate proxy callbacks before data is queued, got %#v", callbackIDs)
}
if !pendingPort.queueProxyDataForAgent(proxyToAgentMessage{ServerID: 1, Message: []byte("rpfwd")}) {
t.Fatal("failed to queue rpfwd message")
}
if callbackIDs := registry.GetOtherCallbackIdsWithPendingData(10); !slices.Equal(callbackIDs, []int{30}) {
t.Fatalf("expected only callback 30 to have pending delegate proxy data, got %#v", callbackIDs)
}
_ = pendingPort.GetProxyData()
if callbackIDs := registry.GetOtherCallbackIdsWithPendingData(10); len(callbackIDs) != 0 {
t.Fatalf("expected no delegate proxy callbacks after data is drained, got %#v", callbackIDs)
}
}
func TestCallbackPortsInUsePendingDelegateCallbacksIncludeInteractiveTasks(t *testing.T) {
replaceSubmittedTasksForProxyTest(t, []submittedTask{
{TaskID: 100, CallbackID: 30, IsInteractiveTask: true},
{TaskID: 101, CallbackID: 40, IsInteractiveTask: true},
})
registry := callbackPortsInUse{}
currentPort := newTestCallbackPort(10, CALLBACK_PORT_TYPE_SOCKS, 7001, 1)
idlePort := newTestCallbackPort(20, CALLBACK_PORT_TYPE_SOCKS, 7002, 2)
interactiveTaskPort := newTestCallbackPort(30, CALLBACK_PORT_TYPE_SOCKS, 7003, 3)
addTestCallbackPort(&registry, currentPort)
addTestCallbackPort(&registry, idlePort)
addTestCallbackPort(&registry, interactiveTaskPort)
if callbackIDs := registry.GetOtherCallbackIdsWithPendingData(10); !slices.Equal(callbackIDs, []int{30}) {
t.Fatalf("expected only callback 30 to be eligible from interactive tasking, got %#v", callbackIDs)
}
}
func TestCallbackPortUsagePendingDataTracksQueuedMessages(t *testing.T) {
port := newTestCallbackPort(10, CALLBACK_PORT_TYPE_SOCKS, 7001, 1)
if port.hasPendingProxyDataForAgent() {
t.Fatal("expected no pending data before queueing")
}
if !port.queueProxyDataForAgent(proxyToAgentMessage{ServerID: 1, Message: []byte("socks")}) {
t.Fatal("failed to queue proxy message")
}
if got := port.pendingMessagesToAgent.Load(); got != 1 {
t.Fatalf("expected one pending proxy message, got %d", got)
}
messages := port.GetProxyData()
if len(messages) != 1 || string(messages[0].Message) != "socks" {
t.Fatalf("expected queued proxy message to drain, got %#v", messages)
}
if got := port.pendingMessagesToAgent.Load(); got != 0 {
t.Fatalf("expected pending proxy count to drain to zero, got %d", got)
}
if port.hasPendingProxyDataForAgent() {
t.Fatal("expected no pending proxy data after drain")
}
}
func TestCallbackPortUsagePendingDataTracksInteractiveMessages(t *testing.T) {
port := newTestCallbackPort(10, CALLBACK_PORT_TYPE_INTERACTIVE, 7001, 1)
if port.hasPendingInteractiveDataForAgent() {
t.Fatal("expected no pending interactive data before queueing")
}
if !port.queueInteractiveDataForAgent(agentMessagePostResponseInteractive{TaskUUID: "interactive"}) {
t.Fatal("failed to queue interactive message")
}
if got := port.pendingInteractiveMessagesToAgent.Load(); got != 1 {
t.Fatalf("expected one pending interactive message, got %d", got)
}
messages := port.GetInteractiveData()
if len(messages) != 1 || messages[0].TaskUUID != "interactive" {
t.Fatalf("expected queued interactive message to drain, got %#v", messages)
}
if got := port.pendingInteractiveMessagesToAgent.Load(); got != 0 {
t.Fatalf("expected pending interactive count to drain to zero, got %d", got)
}
if port.hasPendingInteractiveDataForAgent() {
t.Fatal("expected no pending interactive data after drain")
}
}
func TestCallbackPortUsageByteCountersAccumulateWithoutChannels(t *testing.T) {
port := newTestCallbackPort(10, CALLBACK_PORT_TYPE_SOCKS, 7001, 1)
port.initializeByteCounters(10, 20)