block list updates, user interact eventing prompts

This commit is contained in:
its-a-feature
2026-05-26 14:41:39 -07:00
parent 98472409a2
commit 0348656564
41 changed files with 1995 additions and 484 deletions
@@ -1,4 +1,6 @@
import {createPortal} from 'react-dom';
export const reorder = (
list,
startIndex,
@@ -8,4 +10,11 @@ export const reorder = (
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
};
export const MythicDraggablePortal = ({children, isDragging}) => {
if(isDragging && typeof document !== "undefined"){
return createPortal(children, document.body);
}
return children;
};
@@ -2,13 +2,13 @@ import React from 'react';
import {snackActions} from "../utilities/Snackbar";
import {MythicStyledTooltip} from "./MythicStyledTooltip";
import {MythicDialog} from "./MythicDialog";
import {Link} from '@mui/material';
import {gql} from '@apollo/client';
import {b64DecodeUnicode} from "../pages/Callbacks/ResponseDisplay";
import {PreviewFileMediaDialog} from "./PreviewFileMedia";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {useMythicLazyQuery} from "../utilities/useMythicLazyQuery";
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
export const getfileInformationQuery = gql`
query getFileInformation($file_id: String!){
@@ -85,9 +85,9 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
style={{height: "20px", position: "relative", cursor: "pointer", display: "inline-block"}}
onClick={onPreviewMedia} />
</MythicStyledTooltip>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + fileData.agent_file_id}>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + fileData.agent_file_id}>
{fileData.display_link === "" ? window.location.origin + "/direct/download/" + fileData.agent_file_id : fileData.display_link}
</Link>
</FileDownloadLinkWithAuth>
{openPreviewMediaDialog &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openPreviewMediaDialog}
onClose={onClose}
@@ -99,4 +99,4 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
}
</>
)
}
}
@@ -1,7 +1,7 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import { Link } from '@mui/material';
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
const PREFIX = 'MythicSnackDownload';
@@ -77,9 +77,9 @@ export const MythicSnackDownload = (props) => {
</Typography>
<React.Fragment>
<Typography gutterBottom>File ready for download</Typography>
<Link color="textPrimary" download={true} href={"/direct/download/" + props.file_id} target="_blank">
<FileDownloadLinkWithAuth color="textPrimary" download={true} href={"/direct/download/" + props.file_id} target="_blank">
Download here
</Link>
</FileDownloadLinkWithAuth>
</React.Fragment>
</Root>
);
@@ -66,6 +66,7 @@ const MythicTextField = ({
disabled = false,
marginTop = "5px",
InputProps = {},
inputProps = {},
inputLabelProps = {},
multiline = false,
maxRows = 10,
@@ -104,6 +105,9 @@ const MythicTextField = ({
handleChange(event);
}
}
const resolvedAutoComplete = autoComplete === undefined ? "new-password" : (
typeof autoComplete === "string" ? autoComplete : (autoComplete ? "on" : "off")
);
return (
<Root style={{width: width ? width + "rem" : "100%", display: inline ? "inline-block": "",}}>
@@ -118,7 +122,7 @@ const MythicTextField = ({
autoFocus={autoFocus}
variant={variant}
data-lpignore={true}
autoComplete={autoComplete === undefined ? "new-password" : (autoComplete ? "on" : "off")}
autoComplete={resolvedAutoComplete}
disabled={disabled}
required={requiredValue}
InputLabelProps={inputLabelProps}
@@ -128,6 +132,7 @@ const MythicTextField = ({
type={type}
onWheel={ event => event.target.blur() }
InputProps={{...InputProps, spellCheck: false}}
inputProps={{...inputProps, autoComplete: resolvedAutoComplete}}
helperText={localError ? errorText : helperText}
style={{
padding:0,
@@ -71,7 +71,7 @@ import {
DragDropContext,
Droppable,
} from "@hello-pangea/dnd";
import {reorder} from "./MythicComponents/MythicDraggableList";
import {MythicDraggablePortal, reorder} from "./MythicComponents/MythicDraggableList";
import { useNavigate } from 'react-router-dom';
import LogoutIcon from '@mui/icons-material/Logout';
import TuneIcon from '@mui/icons-material/Tune';
@@ -757,7 +757,7 @@ const TopAppBarVerticalAdjustShortcutsDialog = ({onClose, onSave, sideShortcuts}
return (
<React.Fragment>
<DialogTitle id="form-dialog-title">Configure Side Shortcuts</DialogTitle>
<DialogContent dividers={true} sx={{p: 0}}>
<DialogContent dividers={true} sx={{p: 0, overflow: "hidden"}}>
<MythicDialogBody sx={{height: "min(70vh, 42rem)", p: 1}}>
<MythicDialogSection
title="Side Shortcuts"
@@ -783,40 +783,47 @@ const TopAppBarVerticalAdjustShortcutsDialog = ({onClose, onSave, sideShortcuts}
<div className="mythic-reorder-list" ref={provided.innerRef} {...provided.droppableProps}>
{currentShortcuts.map((c, i) => (
<Draggable key={c + i} draggableId={`shortcut-${c}-${i}`} index={i}>
{(provided2, snapshot) => (
<div
ref={provided2.innerRef}
className={`mythic-reorder-row${snapshot.isDragging ? " mythic-reorder-row-dragging" : ""}`}
{...provided2.draggableProps}
>
<span className="mythic-reorder-drag-handle" {...provided2.dragHandleProps}>
<DragHandleIcon fontSize="small" />
</span>
<div className="mythic-reorder-row-main">
<Select
className="mythic-reorder-select"
fullWidth
size="small"
value={c}
onChange={(e) => onChangeShortcutValue(e, i)}
>
{AllSettingOptions.map((opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
))}
</Select>
{(provided2, snapshot) => {
const row = (
<div
ref={provided2.innerRef}
className={`mythic-reorder-row${snapshot.isDragging ? " mythic-reorder-row-dragging" : ""}`}
{...provided2.draggableProps}
>
<span className="mythic-reorder-drag-handle" {...provided2.dragHandleProps}>
<DragHandleIcon fontSize="small" />
</span>
<div className="mythic-reorder-row-main">
<Select
className="mythic-reorder-select"
fullWidth
size="small"
value={c}
onChange={(e) => onChangeShortcutValue(e, i)}
>
{AllSettingOptions.map((opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
))}
</Select>
</div>
<div className="mythic-reorder-row-actions">
<IconButton
aria-label={`Remove ${c}`}
className="mythic-table-row-icon-action mythic-table-row-icon-action-hover-danger"
size="small"
onClick={() => removeShortcut(i)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</div>
</div>
<div className="mythic-reorder-row-actions">
<IconButton
aria-label={`Remove ${c}`}
className="mythic-table-row-icon-action mythic-table-row-icon-action-hover-danger"
size="small"
onClick={() => removeShortcut(i)}
>
<DeleteIcon fontSize="small" />
</IconButton>
</div>
</div>
)}
);
return (
<MythicDraggablePortal isDragging={snapshot.isDragging}>
{row}
</MythicDraggablePortal>
);
}}
</Draggable>
))}
{provided.placeholder}
@@ -2,7 +2,7 @@ import React from 'react';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import {reorder} from "../../MythicComponents/MythicDraggableList";
import {MythicDraggablePortal, reorder} from "../../MythicComponents/MythicDraggableList";
import {
Draggable,
DragDropContext,
@@ -50,7 +50,7 @@ export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onCl
return (
<React.Fragment>
<DialogTitle id="form-dialog-title">Column Layout</DialogTitle>
<DialogContent dividers={true} sx={{p: 0}}>
<DialogContent dividers={true} sx={{p: 0, overflow: "hidden"}}>
<MythicDialogBody sx={{height: "min(70vh, 42rem)", p: 1}}>
<MythicDialogSection
title="Columns"
@@ -94,34 +94,41 @@ export const DraggableList = ({ items, onDragEnd, onToggleVisibility }) => {
export const DraggableListItem = ({ item, index, onToggleVisibility }) => {
return (
<Draggable draggableId={item.key} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
className={`mythic-reorder-row${snapshot.isDragging ? " mythic-reorder-row-dragging" : ""}${item.visible ? "" : " mythic-reorder-row-disabled"}`}
{...provided.draggableProps}
>
<span className="mythic-reorder-drag-handle" {...provided.dragHandleProps}>
<DragHandleIcon fontSize="small" />
</span>
<div className="mythic-reorder-row-main">
<span className="mythic-reorder-row-title">{item.name}</span>
{(provided, snapshot) => {
const row = (
<div
ref={provided.innerRef}
className={`mythic-reorder-row${snapshot.isDragging ? " mythic-reorder-row-dragging" : ""}${item.visible ? "" : " mythic-reorder-row-disabled"}`}
{...provided.draggableProps}
>
<span className="mythic-reorder-drag-handle" {...provided.dragHandleProps}>
<DragHandleIcon fontSize="small" />
</span>
<div className="mythic-reorder-row-main">
<span className="mythic-reorder-row-title">{item.name}</span>
</div>
<div className="mythic-reorder-row-actions">
<IconButton
aria-label={item.visible ? `Hide ${item.name}` : `Show ${item.name}`}
className={`mythic-table-row-icon-action ${item.visible ? "mythic-table-row-icon-action-hover-danger" : "mythic-table-row-icon-action-hover-info"}`}
size="small"
onClick={() => onToggleVisibility(index)}
>
{item.visible ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</div>
</div>
<div className="mythic-reorder-row-actions">
<IconButton
aria-label={item.visible ? `Hide ${item.name}` : `Show ${item.name}`}
className={`mythic-table-row-icon-action ${item.visible ? "mythic-table-row-icon-action-hover-danger" : "mythic-table-row-icon-action-hover-info"}`}
size="small"
onClick={() => onToggleVisibility(index)}
>
{item.visible ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</div>
</div>
)}
);
return (
<MythicDraggablePortal isDragging={snapshot.isDragging}>
{row}
</MythicDraggablePortal>
);
}}
</Draggable>
);
};
@@ -33,6 +33,7 @@ import PublicIcon from '@mui/icons-material/Public';
import {payloadsCallbackAllowed} from "../Payloads/Payloads";
import Switch from '@mui/material/Switch';
import {MythicSectionHeader} from "../../MythicComponents/MythicPageHeader";
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
const GET_Payload_Details = gql`
query GetCallbackDetails($callback_id: Int!) {
@@ -383,9 +384,9 @@ export function DetailedCallbackTable(props){
<TableRow hover>
<TableCell>Download URL</TableCell>
<TableCell style={{display: "flex", alignItems: "center"}}>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}>
{window.location.origin + "/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}
</Link>
</FileDownloadLinkWithAuth>
<MythicStyledTooltip title={"Host Payload Through C2"} >
<PublicIcon color={"info"} style={{marginLeft: "20px", cursor: "pointer"}} onClick={()=>{setOpenHostDialog(true);}} />
</MythicStyledTooltip>
@@ -13,6 +13,7 @@ import {MythicDialog} from "../../MythicComponents/MythicDialog";
import {PreviewFileMediaDialog} from "../../MythicComponents/PreviewFileMedia";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
export function DownloadHistoryDialog(props){
const [history, setHistory] = React.useState([]);
@@ -59,7 +60,7 @@ export function DownloadHistoryDialog(props){
{hist.deleted ? (
<Typography variant="body2" style={{wordBreak: "break-all"}}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</Typography>
) : (
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + hist.agent_file_id}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</Link>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + hist.agent_file_id}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</FileDownloadLinkWithAuth>
)
}
</div>
@@ -63,7 +63,7 @@ import {Backdrop, CircularProgress} from '@mui/material';
// eslint-disable-next-line import/no-webpack-loader-syntax
import sqlWasm from "!!file-loader?name=sql-wasm-[contenthash].wasm!sql.js/dist/sql-wasm.wasm";
import {copyStringToClipboard} from "../../utilities/Clipboard";
import {FileDownloadButtonWithAuth} from "../../utilities/FileDownloadWithAuth";
import {FileDownloadButtonWithAuth, FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
import {ImageWithAuth} from "../../utilities/ImageWithAuth";
export const modeOptions = ["csharp", "golang", "html", "json", "markdown", "ruby", "python", "java",
@@ -332,9 +332,9 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
<Typography variant={"h4"} >
{fileData.message}
</Typography>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + agent_file_id} >
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + agent_file_id} >
{"Download here"}
</Link>
</FileDownloadLinkWithAuth>
</div>
)
}
@@ -120,6 +120,20 @@ const ParameterEmptyInline = ({children}) => (
{children}
</Box>
);
const getCredentialChoiceIndex = (choices, credential) => {
if(!credential || choices.length === 0){
return -1;
}
if(credential.id !== undefined && credential.id !== null){
return choices.findIndex((choice) => choice.id === credential.id);
}
return choices.findIndex((choice) =>
choice.account === credential.account &&
choice.realm === credential.realm &&
choice.type === credential.type &&
choice.credential_text === credential.credential_text
);
};
export function TaskParametersDialogRow(props){
const [value, setValue] = React.useState('');
const currentParameterGroup = React.useRef(props.parameterGroupName);
@@ -494,14 +508,26 @@ export function TaskParametersDialogRow(props){
}
if(props.type === "CredentialJson"){
//console.log("updating choiceOptions from useEffect in dialog row: ", [...props.choices])
setChoiceOptions([...props.choices]);
const credentialChoices = [...props.choices];
setChoiceOptions(credentialChoices);
if(updateToLatestCredential.current){
setValue(0);
props.onChange(props.name, {...props.choices[0]}, false);
updateToLatestCredential.current = false;
}
if(value === ""){
setValue(0);
if(credentialChoices.length > 0){
setValue(0);
props.onChange(props.name, {...credentialChoices[0]}, false);
}else{
setValue("");
}
updateToLatestCredential.current = false;
}else{
const selectedCredentialIndex = getCredentialChoiceIndex(credentialChoices, props.value);
if(selectedCredentialIndex >= 0){
setValue(selectedCredentialIndex);
}else if(credentialChoices.length > 0){
setValue(0);
props.onChange(props.name, {...credentialChoices[0]}, false);
}else{
setValue("");
}
}
}
if(props.dynamic_query_function === null && value===""){
@@ -553,7 +579,7 @@ export function TaskParametersDialogRow(props){
}
const onChangeCredentialJSONValue = (evt) => {
setValue(evt.target.value);
props.onChange(props.name, ChoiceOptions[evt.target.value], false);
props.onChange(props.name, {...ChoiceOptions[evt.target.value]}, false);
}
const onChangeChooseMultiple = (event) => {
const { value:options } = event.target;
+274 -5
View File
@@ -40,6 +40,7 @@ import SendIcon from '@mui/icons-material/Send';
import SmartToyTwoToneIcon from '@mui/icons-material/SmartToyTwoTone';
import StopCircleIcon from '@mui/icons-material/StopCircle';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import {MythicDialog} from "../../MythicComponents/MythicDialog";
import {MythicPageBody} from "../../MythicComponents/MythicPageBody";
import {MythicPageHeader, MythicPageHeaderChip} from "../../MythicComponents/MythicPageHeader";
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
@@ -47,6 +48,7 @@ import {MythicConfirmDialog} from "../../MythicComponents/MythicConfirmDialog";
import {MeContext} from "../../App";
import {snackActions} from "../../utilities/Snackbar";
import {getSkewedNow} from "../../utilities/Time";
import {EventStepUserInteractionDialog} from "../Eventing/EventStepRender";
const CHAT_MESSAGE_LIMIT = 250;
const CHAT_REQUEST_LIMIT = 50;
@@ -98,6 +100,7 @@ fragment ChatMessageFields on chat_message {
author_type
sender_display_name
message
metadata
edited
deleted
status
@@ -315,6 +318,16 @@ mutation MarkChatRead($channel_id: Int!, $last_read_message_id: Int) {
}
`;
const REFRESH_SPECIAL_MESSAGE = gql`
mutation RefreshSpecialMessage($message_id: Int!) {
chatRefreshSpecialMessage(message_id: $message_id) {
status
error
message_id
}
}
`;
const CHAT_SEARCH = gql`
query ChatSearch($query: String!, $channel_id: Int, $limit: Int, $offset: Int) {
chatSearch(query: $query, channel_id: $channel_id, limit: $limit, offset: $offset) {
@@ -611,6 +624,152 @@ const MarkdownMessage = ({message}) => {
);
};
const CHAT_SPECIAL_TYPE_EVENTING_USER_INTERACTION = "eventing_user_interaction";
const getChatMessageMetadata = (message) => {
const metadata = message?.metadata || {};
return metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata : {};
};
const getEventingInteractionSnapshot = (message) => {
const metadata = getChatMessageMetadata(message);
if(metadata.special_type !== CHAT_SPECIAL_TYPE_EVENTING_USER_INTERACTION){
return null;
}
const snapshot = metadata.eventing_user_interaction || {};
return snapshot && typeof snapshot === "object" && !Array.isArray(snapshot) ? snapshot : {};
};
const getChatEventingPrompt = (snapshot) => {
if(snapshot.approval_required && snapshot.approval_prompt){
return snapshot.approval_prompt;
}
if(snapshot.input_required && snapshot.input_prompt){
return snapshot.input_prompt;
}
if(snapshot.approval_required && snapshot.input_required){
return "Approval and input are required before this step can continue.";
}
if(snapshot.approval_required){
return "Approval is required before this step can continue.";
}
return "Input is required before this step can continue.";
};
const chatEventingStatusLabels = {
awaiting_approval: "Awaiting approval",
input_needed: "Input needed",
queued: "Queued",
running: "Running",
success: "Success",
error: "Error",
cancelled: "Cancelled",
skipped: "Skipped",
};
const getChatEventingStatusText = (snapshot) => {
if(snapshot.status === "awaiting_approval"){
return "Awaiting approval";
}
if(snapshot.status === "input_needed"){
return "Input needed";
}
return chatEventingStatusLabels[snapshot.status] || snapshot.status || "Unknown";
};
const getChatEventingStateClass = (snapshot) => {
switch(snapshot.status){
case "success":
return "success";
case "error":
case "cancelled":
return "error";
case "running":
return "running";
case "queued":
return "queued";
case "awaiting_approval":
case "input_needed":
return "waiting";
default:
return snapshot.waiting ? "waiting" : "neutral";
}
};
const ChatEventingUserInteractionCard = ({message, me, onRefresh, onReview, refreshing}) => {
const metadata = getChatMessageMetadata(message);
const snapshot = getEventingInteractionSnapshot(message) || {};
const waiting = Boolean(snapshot.waiting);
const statusText = getChatEventingStatusText(snapshot);
const stateClass = getChatEventingStateClass(snapshot);
const stepName = snapshot.step_name || `Step ${snapshot.eventstepinstance_id || ""}`.trim();
const requirementText = [
snapshot.approval_required ? "approval" : null,
snapshot.input_required ? `${snapshot.input_count || 0} input${snapshot.input_count === 1 ? "" : "s"}` : null,
].filter(Boolean).join(" + ");
const refreshedAt = metadata.refreshed_at || snapshot.user_interaction_updated_at;
const detailItems = [
snapshot.step_action ? {label: "Action", value: snapshot.step_action} : null,
requirementText ? {label: "Needs", value: requirementText} : null,
snapshot.run_operator_username ? {label: "Run as", value: snapshot.run_operator_username} : null,
snapshot.resolved_by_username ? {label: "Resolved by", value: snapshot.resolved_by_username} : null,
].filter(Boolean);
return (
<Box className={`mythic-chat-special-card mythic-chat-eventing-card mythic-chat-eventing-card-${stateClass}`.trim()}>
<Box className="mythic-chat-special-card-header">
<Box className="mythic-chat-special-card-title-wrap">
<Typography className="mythic-chat-special-card-title" variant="subtitle2">{stepName}</Typography>
<Typography className="mythic-chat-special-card-subtitle" variant="caption">Eventing user interaction</Typography>
</Box>
<Chip
size="small"
className={`mythic-chat-special-status mythic-chat-special-status-${stateClass}`.trim()}
label={statusText}
variant="outlined"
/>
</Box>
<Box className="mythic-chat-special-card-prompt">{getChatEventingPrompt(snapshot)}</Box>
<Box className="mythic-chat-special-card-details">
{detailItems.map((item) => (
<span className="mythic-chat-special-card-detail" key={`${message.id}-${item.label}`}>
<span className="mythic-chat-special-card-detail-label">{item.label}</span>
<span className="mythic-chat-special-card-detail-value">{item.value}</span>
</span>
))}
</Box>
<Box className="mythic-chat-special-card-footer">
<Typography className="mythic-chat-special-card-refresh-time" variant="caption">
{refreshedAt ? `Refreshed ${formatTimestamp(refreshedAt, me?.user?.view_utc_time)}` : ""}
</Typography>
<Box className="mythic-chat-special-card-actions">
<MythicStyledTooltip title="Refresh">
<span>
<IconButton
aria-label="Refresh eventing interaction"
className="mythic-chat-special-refresh-button"
disabled={refreshing}
onClick={() => onRefresh(message)}
size="small"
>
<RestartAltIcon fontSize="small" />
</IconButton>
</span>
</MythicStyledTooltip>
<Button
size="small"
variant="contained"
className="mythic-table-row-action mythic-table-row-action-hover-success"
disabled={!waiting}
onClick={() => onReview(message)}
>
Review
</Button>
</Box>
</Box>
</Box>
);
};
const channelDisplayName = (channel) => `# ${channel?.name || ""}`;
const parseChatContainerModels = (container) => {
@@ -857,11 +1016,13 @@ const ChannelButton = ({channel, selected, unread, onSelect}) => {
);
};
const MessageBubble = ({message, request, me, onEdit, onDelete, onCancel, onRetry, editing, editText, setEditText, saveEdit, cancelEdit}) => {
const MessageBubble = ({message, request, me, onEdit, onDelete, onCancel, onRetry, onRefreshSpecial, onReviewSpecial, refreshingSpecialMessageID, editing, editText, setEditText, saveEdit, cancelEdit}) => {
const theme = useTheme();
const isMine = message.operator_id === me?.user?.user_id;
const isAI = message.author_type === "ai";
const isSystem = message.author_type === "system";
const eventingInteractionSnapshot = getEventingInteractionSnapshot(message);
const eventingInteractionStateClass = eventingInteractionSnapshot ? getChatEventingStateClass(eventingInteractionSnapshot) : "";
const canEdit = isMine && message.author_type === "operator" && !message.deleted;
const canDelete = !message.deleted && (isMine || message.author_type !== "operator");
const streaming = message.status === "pending" || message.status === "streaming";
@@ -875,7 +1036,7 @@ const MessageBubble = ({message, request, me, onEdit, onDelete, onCancel, onRetr
return (
<Box className={`mythic-chat-message-row ${isMine ? "mythic-chat-message-row-mine" : ""}`}>
<Box
className={`mythic-chat-message ${isAI ? "mythic-chat-message-ai" : ""} ${isSystem ? "mythic-chat-message-system" : ""}`}
className={`mythic-chat-message ${isAI ? "mythic-chat-message-ai" : ""} ${isSystem ? "mythic-chat-message-system" : ""} ${eventingInteractionSnapshot ? `mythic-chat-message-special-eventing mythic-chat-message-special-eventing-${eventingInteractionStateClass}` : ""}`.trim()}
sx={{
"--mythic-chat-markdown-border": softBorderColor,
"--mythic-chat-markdown-surface": markdownSurface,
@@ -939,6 +1100,14 @@ const MessageBubble = ({message, request, me, onEdit, onDelete, onCancel, onRetr
<Button size="small" variant="contained" onClick={saveEdit}>Save</Button>
</Box>
</Box>
) : eventingInteractionSnapshot ? (
<ChatEventingUserInteractionCard
message={message}
me={me}
onRefresh={onRefreshSpecial}
onReview={onReviewSpecial}
refreshing={refreshingSpecialMessageID === message.id}
/>
) : (
<MarkdownMessage message={message.message} />
)}
@@ -1369,7 +1538,19 @@ export function Chat({me}) {
const [searchQuery, setSearchQuery] = React.useState("");
const [editingID, setEditingID] = React.useState(null);
const [editText, setEditText] = React.useState("");
const [reviewMessage, setReviewMessage] = React.useState(null);
const [refreshingSpecialMessageID, setRefreshingSpecialMessageID] = React.useState(null);
const messagesContainerRef = React.useRef(null);
const messagesEndRef = React.useRef(null);
const messagesNearBottomRef = React.useRef(true);
const messagesScrollStateRef = React.useRef({
channelID: null,
messageIDs: new Set(),
lastMessageID: null,
lastMessageUpdatedAt: null,
lastMessageStatus: null,
lastMessageLength: 0,
});
const streamStart = React.useRef(getSkewedNow().toISOString());
const [baseChannels, setBaseChannels] = React.useState([]);
const [allChatContainers, setAllChatContainers] = React.useState([]);
@@ -1602,6 +1783,14 @@ export function Chat({me}) {
onError: (error) => snackActions.error(error.message),
});
const [markRead] = useMutation(MARK_READ);
const [refreshSpecialMessage] = useMutation(REFRESH_SPECIAL_MESSAGE, {
onCompleted: (data) => {
if(data.chatRefreshSpecialMessage.status !== "success"){
snackActions.error(data.chatRefreshSpecialMessage.error);
}
},
onError: (error) => snackActions.error(error.message),
});
const [runSearch, {data: searchData, loading: searchLoading}] = useLazyQuery(CHAT_SEARCH, {
fetchPolicy: "no-cache",
onCompleted: (data) => {
@@ -1612,10 +1801,54 @@ export function Chat({me}) {
onError: (error) => snackActions.error(error.message),
});
const updateMessagesNearBottom = React.useCallback(() => {
const messagesContainer = messagesContainerRef.current;
if(!messagesContainer){
messagesNearBottomRef.current = true;
return;
}
messagesNearBottomRef.current = messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < 80;
}, []);
React.useEffect(() => {
messagesEndRef.current?.scrollIntoView({block: "end"});
const lastMessage = messages[messages.length - 1];
if(selectedChannelID && lastMessage){
const lastMessageMatchesChannel = Boolean(lastMessage && lastMessage.channel_id === selectedChannelID);
const previousScrollState = messagesScrollStateRef.current;
const messageIDs = new Set(messages.map((message) => message.id));
const channelChanged = previousScrollState.channelID !== selectedChannelID;
const hasNewMessages = messages.some((message) => !previousScrollState.messageIDs.has(message.id));
const lastMessageChanged = Boolean(lastMessage &&
previousScrollState.lastMessageID === lastMessage.id &&
(
previousScrollState.lastMessageUpdatedAt !== lastMessage.updated_at ||
previousScrollState.lastMessageStatus !== lastMessage.status ||
previousScrollState.lastMessageLength !== (lastMessage.message || "").length
)
);
const shouldScrollToBottom = Boolean(
lastMessageMatchesChannel &&
(
channelChanged ||
hasNewMessages ||
(lastMessageChanged && messagesNearBottomRef.current)
)
);
messagesScrollStateRef.current = {
channelID: selectedChannelID,
messageIDs,
lastMessageID: lastMessage?.id || null,
lastMessageUpdatedAt: lastMessage?.updated_at || null,
lastMessageStatus: lastMessage?.status || null,
lastMessageLength: (lastMessage?.message || "").length,
};
if(shouldScrollToBottom){
messagesEndRef.current?.scrollIntoView({block: "end"});
messagesNearBottomRef.current = true;
}
if(selectedChannelID && lastMessageMatchesChannel){
markRead({variables: {channel_id: selectedChannelID, last_read_message_id: lastMessage.id}}).catch(() => {});
}
}, [messages, selectedChannelID, markRead]);
@@ -1728,6 +1961,18 @@ export function Chat({me}) {
setSelectedChannelID(result.channel_id);
setSearchOpen(false);
};
const refreshChatSpecialMessage = React.useCallback((message) => {
if(!message?.id){
return Promise.resolve();
}
setRefreshingSpecialMessageID(message.id);
return refreshSpecialMessage({variables: {message_id: message.id}})
.finally(() => setRefreshingSpecialMessageID(null));
}, [refreshSpecialMessage]);
const reviewChatSpecialMessage = (message) => {
setReviewMessage(message);
};
const reviewSnapshot = getEventingInteractionSnapshot(reviewMessage);
const metaChips = (
<>
<MythicPageHeaderChip label={`${channels.filter((channel) => !channel.archived).length} active`} />
@@ -1848,7 +2093,7 @@ export function Chat({me}) {
}
</Box>
</Box>
<Box className="mythic-chat-messages">
<Box className="mythic-chat-messages" ref={messagesContainerRef} onScroll={updateMessagesNearBottom}>
{!selectedChannel ? (
<ChatEmptyState
icon={<ForumTwoToneIcon fontSize="large" />}
@@ -1871,6 +2116,9 @@ export function Chat({me}) {
onDelete={(messageID) => deleteMessage({variables: {message_id: messageID}})}
onCancel={(requestID) => cancelRequest({variables: {request_id: requestID}})}
onRetry={(requestID) => retryRequest({variables: {request_id: requestID}})}
onRefreshSpecial={refreshChatSpecialMessage}
onReviewSpecial={reviewChatSpecialMessage}
refreshingSpecialMessageID={refreshingSpecialMessageID}
editing={editingID === message.id}
editText={editText}
setEditText={setEditText}
@@ -1983,6 +2231,27 @@ export function Chat({me}) {
onSelectResult={selectSearchResult}
viewUTCTime={currentMe?.user?.view_utc_time}
/>
{reviewMessage && reviewSnapshot &&
<MythicDialog
fullWidth={true}
maxWidth="md"
open={Boolean(reviewMessage)}
onClose={() => {
refreshChatSpecialMessage(reviewMessage).catch(() => {});
setReviewMessage(null);
}}
innerDialog={
<EventStepUserInteractionDialog
onClose={() => {
refreshChatSpecialMessage(reviewMessage).catch(() => {});
setReviewMessage(null);
}}
onResolved={() => refreshChatSpecialMessage(reviewMessage)}
selectedEventGroupInstance={reviewSnapshot.eventgroupinstance_id}
selectedStep={{id: reviewSnapshot.eventstepinstance_id}}
/>}
/>
}
</MythicPageBody>
);
}
@@ -12,7 +12,8 @@ import { toast } from 'react-toastify';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {Button, Link, Typography} from '@mui/material';
import {Button, Typography} from '@mui/material';
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
const PREFIX = 'PayloadSubscriptionNotification';
@@ -119,9 +120,9 @@ const SnackMessage = (props) => {
{props.payloadData.build_phase === "success" &&
<React.Fragment>
<Typography gutterBottom>Agent ready for download</Typography>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" target="_blank" href={"/direct/download/" + props.file_id}>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" target="_blank" href={"/direct/download/" + props.file_id}>
Download here
</Link>
</FileDownloadLinkWithAuth>
</React.Fragment>
}
</div>
@@ -583,7 +583,7 @@ const inputOptionsData = {
description: "this is a completely custom input that's whatever you desire"
}
}
const userInteractionInputTypes = ["string", "number", "boolean", "json"];
const userInteractionInputTypes = ["string", "number", "boolean", "json", "ChooseOne"];
const userInteractionBotApprovalApprovers = {
operator: {
label: "Any operator",
@@ -606,6 +606,9 @@ const getDefaultUserInteractionConfig = () => ({
input_prompt: "",
inputs: [],
});
const getUserInteractionOutputSourceOptions = (localInputOptions = []) => {
return (localInputOptions || []).filter((option) => option.includes(".")).sort();
}
const normalizeUserInteractionConfig = (config) => {
const normalizedConfig = {...getDefaultUserInteractionConfig(), ...(config || {})};
const approvalPolicy = normalizedConfig.approval_policy && typeof normalizedConfig.approval_policy === "object" ? normalizedConfig.approval_policy : {};
@@ -615,27 +618,21 @@ const normalizeUserInteractionConfig = (config) => {
approver: Object.keys(userInteractionBotApprovalApprovers).includes(botContext.approver) ? botContext.approver : "operator",
},
};
if(!Array.isArray(normalizedConfig.inputs)){
if(normalizedConfig.inputs && typeof normalizedConfig.inputs === "object"){
normalizedConfig.inputs = Object.entries(normalizedConfig.inputs).map(([name, value]) => {
if(value && typeof value === "object" && !Array.isArray(value)){
return {name, ...value};
}
return {name, type: "string", required: false, description: "", default_value: value || ""};
});
}else{
normalizedConfig.inputs = [];
}
}
normalizedConfig.inputs = normalizedConfig.inputs.map((input) => ({
name: input?.name || "",
type: input?.type || "string",
required: Boolean(input?.required),
description: input?.description || "",
default_value: input?.default_value === undefined || input?.default_value === null ? "" : (
normalizedConfig.inputs = normalizedConfig.inputs.map((input) => {
const inputType = userInteractionInputTypes.find((type) => type.toLowerCase() === (input?.type || "string").toLowerCase()) || "string";
const defaultValue = input?.default_value === undefined || input?.default_value === null ? "" : (
typeof input.default_value === "string" ? input.default_value : JSON.stringify(input.default_value)
),
}));
);
return {
name: input?.name || "",
type: inputType,
required: Boolean(input?.required),
description: input?.description || "",
default_value: defaultValue,
default_value_source: input.default_value_source || "custom",
choices: input?.choices || [],
};
});
return normalizedConfig;
}
const hasUserInteractionConfig = (config) => {
@@ -2527,29 +2524,114 @@ const actionOptionsData = {
}
}
const EventingStepUserInteraction = ({config, onChange}) => {
const EventingUserInteractionSourcePicker = ({localInputOptions, onChange, default_value, default_value_source}) => {
const outputSourceOptions = getUserInteractionOutputSourceOptions(localInputOptions);
const sourceOptions = default_value_source !== "custom" && !outputSourceOptions.includes(default_value_source) ? [default_value_source, ...outputSourceOptions] : outputSourceOptions;
const onChangeSourceType = (event) => {
const nextType = event.target.value;
if(nextType === "custom"){
onChange(nextType, "");
}else{
onChange(nextType, nextType);
}
}
return (
<div className="mythic-eventing-user-input-source-cell">
<TextField
label={`Default Value Source`}
select
size="small"
style={{ width: "100%" }}
value={default_value_source}
onChange={onChangeSourceType}
>
<MenuItem key={`custom`} value="custom">custom</MenuItem>
{sourceOptions.map((option) => (
<MenuItem key={`${option}`} value={option}>{option}</MenuItem>
))}
</TextField>
{default_value_source !== "custom" &&
<div className="mythic-eventing-step-helper-text">
Resolved from {default_value} when the step pauses.
</div>
}
</div>
)
}
const EventingUserInteractionChoicesEditor = ({input, index, updateInputFields}) => {
const choices = input.choices;
const setChoices = (nextChoices) => {
updateInputFields(index, {
choices: nextChoices,
});
}
const addChoice = () => {
setChoices([...choices, ""]);
}
const removeChoice = (choiceIndex) => {
const nextChoices = [...choices];
nextChoices.splice(choiceIndex, 1);
setChoices(nextChoices);
}
const updateChoice = (choiceIndex, value) => {
const nextChoices = [...choices];
nextChoices[choiceIndex] = value;
setChoices(nextChoices);
}
return (
<div className="mythic-eventing-user-input-choices">
<div className="mythic-eventing-user-input-choice-list">
{choices.length === 0 &&
<EventingStepEmptyInline>No choices configured.</EventingStepEmptyInline>
}
{choices.map((choice, choiceIndex) => (
<div className="mythic-eventing-user-input-choice-row" key={`choice-${index}-${choiceIndex}`}>
<IconButton className="mythic-table-row-icon-action mythic-table-row-icon-action-hover-danger" size="small" onClick={() => removeChoice(choiceIndex)}>
<DeleteIcon fontSize="small" />
</IconButton>
<MythicTextField
placeholder={`Choice ${choiceIndex + 1}`}
onChange={(name, value) => updateChoice(choiceIndex, value)}
value={choice}
marginBottom="0px"
/>
</div>
))}
<Button className="mythic-table-row-action mythic-table-row-action-hover-success" onClick={addChoice} size="small" variant="outlined" startIcon={<AddCircleIcon fontSize="small" />}>
Add choice
</Button>
</div>
</div>
)
}
const EventingStepUserInteraction = ({config, localInputOptions, onChange}) => {
const userInteraction = normalizeUserInteractionConfig(config);
const updateUserInteraction = (updates) => {
onChange({...userInteraction, ...updates});
}
const updateInputField = (index, fieldName, value) => {
const updateInputFields = (index, updates) => {
const nextInputs = userInteraction.inputs.map((input, i) => {
if(i !== index){
return {...input};
}
return {...input, [fieldName]: value};
return {...input, ...updates};
});
updateUserInteraction({inputs: nextInputs});
}
const updateInputField = (index, fieldName, value) => {
updateInputFields(index, {[fieldName]: value});
}
const addInputField = () => {
updateUserInteraction({
input_required: true,
input_required: false,
inputs: [...userInteraction.inputs, {
name: "",
type: "string",
required: true,
required: false,
description: "",
default_value: "",
default_value_source: "custom",
choices: [],
}],
});
}
@@ -2642,11 +2724,19 @@ const EventingStepUserInteraction = ({config, onChange}) => {
<div className="mythic-eventing-step-list-content">
<div className="mythic-eventing-step-field-grid">
<MythicTextField
name={"Name"}
placeholder="Input name"
onChange={(name, value) => updateInputField(index, "name", value)}
value={input.name}
marginBottom="0px"
/>
<MythicTextField
name={"Description"}
placeholder="Description"
onChange={(name, value) => updateInputField(index, "description", value)}
value={input.description}
marginBottom="0px"
/>
<FormControl sx={{ display: "inline-block", width: "100%" }} size="small">
<TextField
label="Type"
@@ -2654,26 +2744,51 @@ const EventingStepUserInteraction = ({config, onChange}) => {
size="small"
style={{ width: "100%" }}
value={input.type}
onChange={(event) => updateInputField(index, "type", event.target.value)}
onChange={(event) => {
const nextType = event.target.value;
updateInputFields(index, {
type: nextType,
...(nextType === "ChooseOne" ?
{
default_value: "",
choices: input.default_value_source === "custom" ? input.choices : []
} :
{choices: [] }
)
});
}}
>
{userInteractionInputTypes.map((type) => (
<MenuItem key={`user-interaction-type-${type}`} value={type}>{type}</MenuItem>
))}
</TextField>
</FormControl>
<EventingUserInteractionSourcePicker
localInputOptions={localInputOptions}
default_value={input.default_value}
default_value_source={input.default_value_source}
onChange={(nextSource, nextValue) => updateInputFields(index, {
default_value_source: nextSource,
default_value: nextValue,
})}
/>
</div>
{input.type !== "ChooseOne" && input.default_value_source === "custom" &&
<MythicTextField
placeholder="Description"
onChange={(name, value) => updateInputField(index, "description", value)}
value={input.description}
marginBottom="0px"
/>
<MythicTextField
placeholder="Default value"
name={"Default Value"}
placeholder={"Default Value"}
onChange={(name, value) => updateInputField(index, "default_value", value)}
value={input.default_value}
marginBottom="0px"
/>
</div>
}
{input.type === "ChooseOne" && input.default_value_source === "custom" &&
<EventingUserInteractionChoicesEditor
input={input}
index={index}
updateInputFields={updateInputFields}
/>
}
<label className="mythic-eventing-step-switch-row mythic-eventing-step-switch-row-compact">
<span className="mythic-eventing-step-switch-copy">
<span className="mythic-eventing-step-switch-title">Required</span>
@@ -2896,7 +3011,7 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
title="User interaction"
description="Pause before execution to request approval, runtime input, or both."
>
<EventingStepUserInteraction config={userInteraction} onChange={onChangeUserInteraction} />
<EventingStepUserInteraction config={userInteraction} localInputOptions={localInputOptions} onChange={onChangeUserInteraction} />
</EventingStepConfigSection>
<EventingStepConfigSection
className="mythic-eventing-step-config-section-wide"
@@ -21,10 +21,11 @@ import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import DeleteIcon from '@mui/icons-material/Delete';
import {IconButton, Link, Typography} from '@mui/material';
import {IconButton, Typography} from '@mui/material';
import {MythicConfirmDialog} from "../../MythicComponents/MythicConfirmDialog";
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import {MythicTableEmptyState} from "../../MythicComponents/MythicStateDisplay";
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
export function EventFileManageDialog({onClose, selectedEventGroup}) {
@@ -142,7 +143,7 @@ function EventFileManageDialogTableRow({eventFile}) {
{eventFile.deleted ? (
<Typography variant="body2" style={{wordBreak: "break-all"}}>{b64DecodeUnicode(eventFile.filename_text)}</Typography>
) : (
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + eventFile.agent_file_id}>{b64DecodeUnicode(eventFile.filename_text)}</Link>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + eventFile.agent_file_id}>{b64DecodeUnicode(eventFile.filename_text)}</FileDownloadLinkWithAuth>
)
}
</MythicStyledTableCell>
@@ -6,6 +6,8 @@ import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import Button from '@mui/material/Button';
import {Link} from '@mui/material';
import MenuItem from '@mui/material/MenuItem';
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
import createLayout, {getSourcePosition, getTargetPosition} from "../Callbacks/C2PathDialog";
import {ReactFlow,
applyEdgeChanges,
@@ -576,11 +578,60 @@ const getInteractionInputDefault = (field) => {
if(defaultValue === undefined || defaultValue === null){
return "";
}
if(field?.type === "ChooseOne"){
const serializedDefault = serializeUserInteractionValue(defaultValue);
const matchingChoice = getUserInteractionChoiceOptions(field).find((choice) => choice.serializedValue === serializedDefault);
return matchingChoice?.serializedValue || "";
}
if(typeof defaultValue === "string"){
return defaultValue;
}
return JSON.stringify(defaultValue);
}
const serializeUserInteractionValue = (value) => {
if(value === undefined || value === null){
return "";
}
if(typeof value === "string"){
return value;
}
try{
return JSON.stringify(value);
}catch(error){
return String(value);
}
}
const getUserInteractionChoiceOptions = (field) => {
const choices = field?.choices || field?.options || [];
const normalizeChoice = (choice, index) => {
if(choice && typeof choice === "object" && !Array.isArray(choice)){
const hasValue = Object.prototype.hasOwnProperty.call(choice, "value");
const value = hasValue ? choice.value : choice;
const label = choice.label === undefined || choice.label === null ? serializeUserInteractionValue(value) : String(choice.label);
return {
label,
value,
serializedValue: serializeUserInteractionValue(value),
};
}
return {
label: choice === undefined || choice === null ? `Choice ${index + 1}` : String(choice),
value: choice,
serializedValue: serializeUserInteractionValue(choice),
};
}
if(Array.isArray(choices)){
return choices.map(normalizeChoice);
}
if(choices && typeof choices === "object"){
return Object.entries(choices).map(([label, value]) => ({
label,
value,
serializedValue: serializeUserInteractionValue(value),
}));
}
return [];
}
const coerceUserInteractionInputs = (fields, values) => {
const coercedValues = {};
for(const field of fields){
@@ -604,6 +655,12 @@ const coerceUserInteractionInputs = (fields, values) => {
coercedValues[name] = value === true || String(value).toLowerCase() === "true";
}else if(type === "json"){
coercedValues[name] = JSON.parse(value);
}else if(type === "ChooseOne"){
const selectedChoice = getUserInteractionChoiceOptions(field).find((choice) => choice.serializedValue === value);
if(selectedChoice === undefined){
throw new Error(`${name} must be one of the available choices`);
}
coercedValues[name] = selectedChoice.value;
}else{
coercedValues[name] = value;
}
@@ -630,7 +687,7 @@ const getNextUserInteractionStepId = (steps, currentStepId) => {
}
return steps[currentIndex + 1].id;
}
export const EventStepUserInteractionDialog = ({onClose, selectedEventGroupInstance, selectedStep, steps}) => {
export const EventStepUserInteractionDialog = ({onClose, onResolved, selectedEventGroupInstance, selectedStep, steps}) => {
const me = useReactiveVar(meState);
const [resolvedStepIds, setResolvedStepIds] = React.useState([]);
const [selectedStepId, setSelectedStepId] = React.useState(selectedStep?.id || 0);
@@ -770,6 +827,13 @@ export const EventStepUserInteractionDialog = ({onClose, selectedEventGroupInsta
if(refetch){
await refetch();
}
if(onResolved){
try{
await onResolved(activeStep);
}catch(error){
console.log(error);
}
}
if(remainingSteps.length === 0){
onClose?.();
}else{
@@ -836,27 +900,56 @@ export const EventStepUserInteractionDialog = ({onClose, selectedEventGroupInsta
<div className="mythic-eventing-user-interaction-prompt">{inputPrompt}</div>
{inputFields.length > 0 &&
<div className="mythic-eventing-user-interaction-inputs">
{inputFields.map((field, index) => (
<div className="mythic-eventing-user-interaction-input-row" key={`${activeStep.id}-${field.name || index}`}>
<div className="mythic-eventing-user-interaction-input-copy">
<div className="mythic-eventing-user-interaction-input-name">
{field.name || "Input"}
{field.required &&
<span className="mythic-eventing-user-interaction-required">required</span>
}
</div>
<div className="mythic-eventing-user-interaction-input-description">
{field.description || field.type || "string"}
{inputFields.map((field, index) => {
const fieldType = field?.type;
const choiceOptions = fieldType === "ChooseOne" ? getUserInteractionChoiceOptions(field) : [];
return (
<div className="mythic-eventing-user-interaction-input-row" key={`${activeStep.id}-${field.name || index}`}>
<div className="mythic-eventing-user-interaction-input-copy">
<div className="mythic-eventing-user-interaction-input-name">
{field.name || "Input"}
{field.required &&
<span className="mythic-eventing-user-interaction-required">required</span>
}
</div>
<div className="mythic-eventing-user-interaction-input-description">
{field.description || field.type || "string"}
</div>
</div>
{fieldType === "ChooseOne" ? (
<TextField
disabled={choiceOptions.length === 0}
fullWidth
onChange={(event) => setInputValues({...inputValues, [field.name]: event.target.value})}
select
size="small"
value={inputValues[field.name] || ""}
SelectProps={{ displayEmpty: true }}
>
{choiceOptions.length === 0 ? (
<MenuItem value="" disabled>No choices available</MenuItem>
) : (
[
<MenuItem key={`${activeStep.id}-${field.name || index}-choice-empty`} value="" disabled>Select a choice</MenuItem>,
...choiceOptions.map((choice, choiceIndex) => (
<MenuItem key={`${activeStep.id}-${field.name || index}-choice-${choiceIndex}`} value={choice.serializedValue}>
{choice.label}
</MenuItem>
))
]
)}
</TextField>
) : (
<TextField
size="small"
value={inputValues[field.name] || ""}
onChange={(event) => setInputValues({...inputValues, [field.name]: event.target.value})}
fullWidth
/>
)}
</div>
<TextField
size="small"
value={inputValues[field.name] || ""}
onChange={(event) => setInputValues({...inputValues, [field.name]: event.target.value})}
fullWidth
/>
</div>
))}
)
})}
</div>
}
</div>
@@ -2385,11 +2478,11 @@ function EventDetailsFilesTable({files}){
b64DecodeUnicode(trackedData.full_remote_path_text)}
</Typography>
) : (
<Link className="mythic-eventing-resource-link" color="textPrimary" underline="always" href={"/direct/download/" + trackedData.agent_file_id}>
<FileDownloadLinkWithAuth className="mythic-eventing-resource-link" color="textPrimary" underline="always" href={"/direct/download/" + trackedData.agent_file_id}>
{b64DecodeUnicode(trackedData.full_remote_path_text) === "" ?
b64DecodeUnicode(trackedData.filename_text) :
b64DecodeUnicode(trackedData.full_remote_path_text)}
</Link>
</FileDownloadLinkWithAuth>
)
}
</MythicStyledTableCell>
@@ -38,6 +38,7 @@ import TableRow from '@mui/material/TableRow';
import TableHead from '@mui/material/TableHead';
import {OperationTableRowNotificationsDialog} from "../Operations/OperationTableRowNotificationsDialog";
import {OperationTableRowUpdateOperatorsDialog} from "../Operations/OperationTableRowUpdateOperatorsDialog";
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
import EditIcon from '@mui/icons-material/Edit';
import AssignmentIndIcon from '@mui/icons-material/AssignmentInd';
import {newOperationMutation, Update_Operation} from "../Operations/OperationTable";
@@ -48,7 +49,6 @@ import PlaylistRemoveIcon from '@mui/icons-material/PlaylistRemove';
import {PreviewFileMediaDialog} from "../../MythicComponents/PreviewFileMedia";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {Link} from '@mui/material';
import {ResponseDisplayScreenshotModal} from "../Callbacks/ResponseDisplayScreenshotModal";
import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight';
import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft';
@@ -1482,7 +1482,7 @@ const Top10RecentFileDownloadsDashboardElement = ({me, data, editing, removeElem
<FontAwesomeIcon className="mythic-dashboard-table-icon-action mythic-dashboard-table-icon-action-info" icon={faPhotoVideo}
onClick={(e) => onPreviewMedia(e, newFile)} />
</MythicStyledTooltip>
<Link className="mythic-dashboard-table-link" color="textPrimary" underline="always" href={"/direct/download/" + newFile.agent_file_id}>{newFile.filename_text}</Link>
<FileDownloadLinkWithAuth className="mythic-dashboard-table-link" color="textPrimary" underline="always" href={"/direct/download/" + newFile.agent_file_id}>{newFile.filename_text}</FileDownloadLinkWithAuth>
{!newFile.complete &&
<Typography className="mythic-dashboard-table-secondary" color="secondary" >({newFile.chunks_received} / <b>{newFile.total_chunks}</b>) Chunks</Typography>
}
@@ -56,118 +56,144 @@ mutation deleteBlockListEntries($name: String!, $entries: [Int!]!){
}
`;
const blockListNameSort = (a, b) => (a.name || "").localeCompare(b.name || "");
const commandSort = (a, b) => (a?.command?.cmd || "").localeCompare(b?.command?.cmd || "");
const getPayloadTypeName = (row) => row?.command?.payloadtype?.name || "Unknown";
const groupBlockListRows = (rows = []) => {
return rows.reduce((prev, row) => {
const payloadTypeName = getPayloadTypeName(row);
if(prev[payloadTypeName] === undefined){
prev[payloadTypeName] = [];
}
prev[payloadTypeName].push(row);
prev[payloadTypeName].sort(commandSort);
return prev;
}, {});
};
const mergeBlockListEntries = (existingEntries = {}, incomingEntries = {}) => {
const mergedEntries = {...existingEntries};
for(const [payloadTypeName, entries] of Object.entries(incomingEntries)){
const entryMap = new Map((mergedEntries[payloadTypeName] || []).map((entry) => [entry.id, entry]));
entries.forEach((entry) => entryMap.set(entry.id, entry));
mergedEntries[payloadTypeName] = [...entryMap.values()].sort(commandSort);
}
return mergedEntries;
};
const addRowsToBlockLists = (currentBlockLists, rows) => {
if(!rows || rows.length === 0){
return currentBlockLists;
}
const blockListName = rows[0].name;
const incomingEntries = groupBlockListRows(rows);
let found = false;
const updatedBlockLists = currentBlockLists.map((blockList) => {
if(blockList.name !== blockListName){
return {...blockList};
}
found = true;
return {...blockList, entries: mergeBlockListEntries(blockList.entries, incomingEntries)};
});
if(!found){
updatedBlockLists.push({name: blockListName, entries: incomingEntries});
}
return updatedBlockLists.sort(blockListNameSort);
};
const removeEntryIDsFromBlockLists = (currentBlockLists, blockListName, deletedIDs = []) => {
if(deletedIDs.length === 0){
return currentBlockLists;
}
const deletedIDSet = new Set(deletedIDs);
return currentBlockLists.reduce((prev, blockList) => {
if(blockList.name !== blockListName){
return [...prev, {...blockList}];
}
const entries = Object.entries(blockList.entries || {}).reduce((entryPrev, [payloadTypeName, entries]) => {
const filteredEntries = (entries || []).filter((entry) => !deletedIDSet.has(entry.id)).sort(commandSort);
if(filteredEntries.length > 0){
entryPrev[payloadTypeName] = filteredEntries;
}
return entryPrev;
}, {});
if(Object.keys(entries).length === 0){
return prev;
}
return [...prev, {...blockList, entries}];
}, []).sort(blockListNameSort);
};
export function CommandBlockListTable(props){
const [openNew, setOpenNewDialog] = React.useState(false);
const [blockLists, setBlockLists] = React.useState([]);
const [newBlockListEntries] = useMutation(newBlockListEntry, {
onCompleted: (data) => {
const newBlockList = data.insert_disabledcommandsprofile.returning.reduce( (prev, cur) => {
if(prev[cur.command.payloadtype.name] === undefined){
prev[cur.command.payloadtype.name] = [];
}
prev[cur.command.payloadtype.name].push(cur);
return {...prev};
}, {});
// check if this is part of a new block list
let currentBlockLists = [...blockLists];
let found = false;
const newBlockLists = currentBlockLists.map( cbl => {
if(cbl["name"] === data.insert_disabledcommandsprofile.returning[0].name){
found = true;
// now we need to update cbl["entries"] based on the newBlockList dictionary
let newEntries = {};
for(const [key, value] of Object.entries(newBlockList)){
if(newEntries[key] === undefined){
newEntries[key] = [...value];
}else{
newEntries[key] = [...newEntries[key], ...value];
}
}
for(const [key, value] of Object.entries(cbl["entries"])){
if(newEntries[key] === undefined){
newEntries[key] = [...value];
}else{
newEntries[key] = [...newEntries[key], ...value];
}
}
return {...cbl, entries: newEntries};
}else{
return {...cbl};
}
});
if(!found){
setBlockLists([...newBlockLists, {"name": data.insert_disabledcommandsprofile.returning[0].name, "entries": newBlockList}]);
}else{
setBlockLists([...newBlockLists]);
}
},
onError: (err) => {
snackActions.warning("Unable to create new block lists");
console.log(err);
}
});
const [deleteBlockList] = useMutation(deleteBlockListMutation, {
onCompleted: (data) => {
if(data.deleteBlockList.status === "success"){
const filteredBlockLists = blockLists.filter( b => b.name !== data.deleteBlockList.name);
setBlockLists(filteredBlockLists);
snackActions.success("Successfully deleted block list");
}else{
snackActions.error(data.deleteBlockList.error);
}
},
onError: (err) => {
snackActions.warning("Unable to delete block list");
console.log(err);
}
})
const [deleteBlockListEntries] = useMutation(deleteBlockListEntriesMutation, {
onCompleted: (data) => {
if(data.deleteBlockListEntry.status === "success"){
snackActions.success("Successfully deleted block list");
let currentBlockLists = [...blockLists];
const newBlockLists = currentBlockLists.map( cbl => {
if(cbl["name"] === data.deleteBlockListEntry.name){
// now we need to update cbl["entries"] based on the newBlockList dictionary
let newEntries = {};
for(const [key, value] of Object.entries(cbl["entries"])){
const filteredValues = value.filter( e => !data.deleteBlockListEntry.deleted_ids.includes(e.id));
newEntries[key] = filteredValues;
}
return {...cbl, entries: newEntries};
}else{
return {...cbl};
}
});
setBlockLists([...newBlockLists]);
}else{
snackActions.error(data.deleteBlockListEntry.error);
}
},
onError: (err) => {
snackActions.warning("Unable to delete block list");
console.log(err);
}
})
const [newBlockListEntries] = useMutation(newBlockListEntry);
const [deleteBlockList] = useMutation(deleteBlockListMutation);
const [deleteBlockListEntries] = useMutation(deleteBlockListEntriesMutation);
React.useEffect( () => {
setBlockLists(props.blockLists);
setBlockLists([...(props.blockLists || [])].sort(blockListNameSort));
}, [props.blockLists])
const onSubmitNewBlockList = ({toAdd}) => {
newBlockListEntries({variables: {entries: toAdd}});
}
const onSubmitEdits = ({toAdd, toRemove}) => {
if(toAdd.length > 0){
newBlockListEntries({variables: {entries: toAdd}});
const onSubmitNewBlockList = async ({toAdd}) => {
if(toAdd.length === 0){
snackActions.warning("Select at least one command for the block list");
return false;
}
try{
const {data} = await newBlockListEntries({variables: {entries: toAdd}});
const rows = data?.insert_disabledcommandsprofile?.returning || [];
if(rows.length === 0){
snackActions.warning("No block list entries were created");
return false;
}
setBlockLists((current) => addRowsToBlockLists(current, rows));
snackActions.success("Successfully created block list");
return true;
}catch(err){
snackActions.warning("Unable to create new block list");
console.log(err);
return false;
}
}
const onSubmitEdits = async ({toAdd, toRemove}) => {
const removeEntryIDs = toRemove.map(c => c.command_id);
if(removeEntryIDs.length > 0){
deleteBlockListEntries({variables: {entries: removeEntryIDs, name: toRemove[0]["name"]}});
if(toAdd.length === 0 && removeEntryIDs.length === 0){
return true;
}
try{
const [addResult, deleteResult] = await Promise.all([
toAdd.length > 0 ? newBlockListEntries({variables: {entries: toAdd}}) : Promise.resolve(null),
removeEntryIDs.length > 0 ? deleteBlockListEntries({variables: {entries: removeEntryIDs, name: toRemove[0].name}}) : Promise.resolve(null),
]);
const addedRows = addResult?.data?.insert_disabledcommandsprofile?.returning || [];
if(addedRows.length > 0){
setBlockLists((current) => addRowsToBlockLists(current, addedRows));
}
if(deleteResult){
const deleteData = deleteResult?.data?.deleteBlockListEntry;
if(deleteData?.status !== "success"){
snackActions.error(deleteData?.error || "Unable to remove block list entries");
return false;
}
setBlockLists((current) => removeEntryIDsFromBlockLists(current, deleteData.name, deleteData.deleted_ids || []));
}
snackActions.success("Successfully updated block list");
return true;
}catch(err){
snackActions.warning("Unable to update block list");
console.log(err);
return false;
}
}
const onAcceptDelete = ({name}) => {
deleteBlockList({variables:{name}})
const onAcceptDelete = async ({name}) => {
try{
const {data} = await deleteBlockList({variables:{name}});
if(data?.deleteBlockList?.status === "success"){
setBlockLists((current) => current.filter((blockList) => blockList.name !== data.deleteBlockList.name));
snackActions.success("Successfully deleted block list");
}else{
snackActions.error(data?.deleteBlockList?.error || "Unable to delete block list");
}
}catch(err){
snackActions.warning("Unable to delete block list");
console.log(err);
}
}
if(props?.me?.user?.current_operation_id === 0){
return null;
@@ -188,7 +214,7 @@ export function CommandBlockListTable(props){
{openNew &&
<MythicDialog open={openNew} fullWidth={true} maxWidth="lg"
onClose={()=>{setOpenNewDialog(false);}}
innerDialog={<EditBlockListDialog editable={true} currentSelected={[]} onSubmit={onSubmitNewBlockList} dialogTitle="Create New Block List" onClose={() => setOpenNewDialog(false)}
innerDialog={<EditBlockListDialog editable={true} currentSelected={{}} onSubmit={onSubmitNewBlockList} dialogTitle="Create New Block List" onClose={() => setOpenNewDialog(false)}
/>}
/>
}
@@ -225,28 +251,27 @@ export function CommandBlockListTable(props){
function CommandBlockListTableRow(props){
const [openDelete, setOpenDeleteDialog] = React.useState(false);
const [blockedCommandDisplay, setBlockedCommandDisplay] = React.useState([]);
const [openUpdate, setOpenUpdateDialog] = React.useState(false);
const [updatedEntries, setUpdatedEntries] = React.useState({});
React.useEffect(() => {
const {blockedCommandDisplay, updatedEntries} = React.useMemo(() => {
let newDisplay = [];
let entries = {};
for(const [key, value] of Object.entries(props.entries)){
const commandNames = value.map(c => c.command.cmd).sort().join(", ");
for(const [key, value] of Object.entries(props.entries || {})){
const entryRows = Array.isArray(value) ? value : [];
const commands = entryRows.map((entry) => entry.command).filter(Boolean);
const commandNames = commands.map((command) => command.cmd).sort().join(", ");
newDisplay.push(
{name: key, commands: commandNames}
)
entries[key] = value.map(c => c.command);
entries[key] = commands;
}
setUpdatedEntries(entries);
setBlockedCommandDisplay(newDisplay);
return {blockedCommandDisplay: newDisplay, updatedEntries: entries};
}, [props.entries])
const onAcceptDelete = () => {
setOpenDeleteDialog(false);
props.onAcceptDelete({name: props.name})
}
const onSubmitEdits = ({toAdd, toRemove}) => {
props.onSubmitEdits({toAdd, toRemove})
return props.onSubmitEdits({toAdd, toRemove})
}
return (
<TableRow hover>
@@ -1,12 +1,18 @@
import React, {useEffect} from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Grid from '@mui/material/Grid';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Checkbox from '@mui/material/Checkbox';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography';
import {useQuery, gql} from '@apollo/client';
import MythicTextField from '../../MythicComponents/MythicTextField';
import { snackActions } from '../../utilities/Snackbar';
@@ -19,104 +25,88 @@ import {
MythicFormField
} from '../../MythicComponents/MythicDialogLayout';
function PayloadTypeBlockListPreMemo(props){
const commandSort = (a, b) => (a?.cmd || "").localeCompare(b?.cmd || "");
const commandID = (command) => command?.id || command?.cmd || "";
const normalizeCommandSelection = (selection) => {
if(!selection || typeof selection !== "object"){
return {};
}
return Object.entries(selection).reduce((prev, [name, commands]) => {
prev[name] = Array.isArray(commands) ? commands.filter(Boolean).sort(commandSort) : [];
return prev;
}, {});
};
const flattenCommands = (commandsByPayloadType) => {
return Object.values(commandsByPayloadType || {}).flat().filter(Boolean);
};
const getSelectedCommandCount = (commandsByPayloadType, payloadTypeName) => {
return (commandsByPayloadType?.[payloadTypeName] || []).length;
};
function PayloadTypeBlockListPreMemo({left: allCommands = [], right = [], leftTitle, rightTitle, name, onChange, itemKey}){
const [checked, setChecked] = React.useState([]);
const [left, setLeft] = React.useState([]);
const [right, setRight] = React.useState(props.right);
const [leftTitle, setLeftTitle] = React.useState("");
const [rightTitle, setRightTitle] = React.useState("");
const leftChecked = intersection(checked, left);
const rightChecked = intersection(checked, right);
function not(a, b) {
if(props.itemKey){
return a.filter( (value) => b.find( (element) => element[props.itemKey] === value[props.itemKey] ) === undefined)
}
return a.filter((value) => b.indexOf(value) === -1);
}
function intersection(a, b) {
if(props.itemKey){
return a.filter( (value) => b.find( (element) => element[props.itemKey] === value[props.itemKey] ) !== undefined)
}
return a.filter((value) => b.indexOf(value) !== -1);
}
const rightCommandIDs = React.useMemo(() => new Set(right.map(commandID)), [right]);
const left = React.useMemo(() => {
return (allCommands || []).filter((command) => !rightCommandIDs.has(commandID(command))).sort(commandSort);
}, [allCommands, rightCommandIDs]);
const sortedRight = React.useMemo(() => [...right].sort(commandSort), [right]);
const checkedCommandIDs = React.useMemo(() => new Set(checked), [checked]);
const leftChecked = React.useMemo(() => left.filter((command) => checkedCommandIDs.has(commandID(command))), [left, checkedCommandIDs]);
const rightChecked = React.useMemo(() => sortedRight.filter((command) => checkedCommandIDs.has(commandID(command))), [sortedRight, checkedCommandIDs]);
const moveCommands = React.useCallback((selected) => {
setChecked([]);
onChange({selected: [...selected].sort(commandSort), name});
}, [name, onChange]);
const handleToggle = (value) => () => {
let currentIndex = -1;
if(props.itemKey){
currentIndex = checked.findIndex( (element) => element[props.itemKey] === value[props.itemKey]);
}else{
currentIndex = checked.indexOf(value);
}
const newChecked = [...checked];
if (currentIndex === -1) {
newChecked.push(value);
} else {
newChecked.splice(currentIndex, 1);
}
setChecked(newChecked);
const selectedCommandID = commandID(value);
setChecked((current) => {
if(current.includes(selectedCommandID)){
return current.filter((existingID) => existingID !== selectedCommandID);
}
return [...current, selectedCommandID];
});
};
const handleAllRight = () => {
setRight(right.concat(left));
setLeft([]);
moveCommands([...sortedRight, ...left]);
};
const handleCheckedRight = () => {
setRight(right.concat(leftChecked));
setLeft(not(left, leftChecked));
setChecked(not(checked, leftChecked));
const movingCommandIDs = new Set(leftChecked.map(commandID));
moveCommands([...sortedRight, ...left.filter((command) => movingCommandIDs.has(commandID(command)))]);
};
const handleCheckedLeft = () => {
setLeft(left.concat(rightChecked));
setRight(not(right, rightChecked));
setChecked(not(checked, rightChecked));
const movingCommandIDs = new Set(rightChecked.map(commandID));
moveCommands(sortedRight.filter((command) => !movingCommandIDs.has(commandID(command))));
};
const handleAllLeft = () => {
setLeft(left.concat(right));
setRight([]);
moveCommands([]);
};
useEffect( () => {
const left = props.left.reduce( (prev, cur) => {
if(props.itemKey === undefined){
if(props.right.includes(cur)){
return [...prev];
}
return [...prev, cur];
}else{
if(props.right.find( element => element[props.itemKey] === cur[props.itemKey])){
return [...prev]
}
return [...prev, cur];
}
}, [])
setLeft(left);
setLeftTitle(props.leftTitle);
setRightTitle(props.rightTitle);
}, [props.left, props.right, props.leftTitle, props.rightTitle, props.itemKey]);
useEffect( () => {
props.onChange({selected: right, name: props.name});
}, [right])
useEffect(() => {
const visibleCommandIDs = new Set([...left, ...sortedRight].map(commandID));
setChecked((current) => current.filter((existingID) => visibleCommandIDs.has(existingID)));
}, [left, sortedRight]);
const customList = (title, items) => (
<div className="mythic-transfer-list">
<div className="mythic-transfer-list-header">{title}</div>
<div className="mythic-transfer-list-body">
<List dense component="div" role="list" style={{padding:0}}>
{items.map((valueObj) => {
const value = props.itemKey === undefined ? valueObj : valueObj[props.itemKey];
const value = itemKey === undefined ? valueObj : valueObj[itemKey];
const valueID = commandID(valueObj);
const labelId = `transfer-list-item-${value}-label`;
return (
<ListItem style={{padding:0}} key={value} role="listitem" button onClick={handleToggle(valueObj)}>
<ListItemIcon>
<Checkbox
checked={props.itemKey === undefined ? checked.indexOf(value) !== -1 : checked.findIndex( (element) => element[props.itemKey] === value) !== -1}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={value} />
<ListItem style={{padding:0}} key={valueID} role="listitem" disablePadding>
<ListItemButton onClick={handleToggle(valueObj)} dense>
<ListItemIcon>
<Checkbox
checked={checkedCommandIDs.has(valueID)}
tabIndex={-1}
disableRipple
inputProps={{ 'aria-labelledby': labelId }}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={value} />
</ListItemButton>
</ListItem>
);
})}
@@ -126,10 +116,10 @@ function PayloadTypeBlockListPreMemo(props){
);
return (
<MythicDialogSection title={props.name} className="mythic-transfer-section">
<Grid container spacing={1} justifyContent="center" alignItems="stretch">
<Grid size={5}>{customList(leftTitle, left)}</Grid>
<Grid>
<MythicDialogSection title={name} className="mythic-transfer-section">
<div className="mythic-block-list-transfer-grid">
<div>{customList(leftTitle, left)}</div>
<div>
<div className="mythic-transfer-controls">
<StyledButton
variant="contained"
@@ -172,9 +162,9 @@ function PayloadTypeBlockListPreMemo(props){
</StyledButton>
</div>
</Grid>
<Grid size={5}>{customList(rightTitle, right)}</Grid>
</Grid>
</div>
<div>{customList(rightTitle, sortedRight)}</div>
</div>
</MythicDialogSection>
);
}
@@ -192,86 +182,179 @@ const getPayloadTypesAndCommandsQuery = gql`
}
`;
export function EditBlockListDialog({dialogTitle, onSubmit, blockListName: propBlockListName, onClose, currentSelected, editable}) {
const [payloadtypes, setPayloadTypes] = React.useState([]);
const [selectedCommands, setSelectedCommands] = React.useState({});
const [selectedCommands, setSelectedCommands] = React.useState(() => normalizeCommandSelection(currentSelected));
const [blockListName, setBlockListName] = React.useState("");
useQuery(getPayloadTypesAndCommandsQuery, {fetchPolicy: "network-only",
onCompleted: (data) => {
if(propBlockListName){
setBlockListName(propBlockListName);
}
// for each of the possible commands mark them as selected or not
const updatedPayloadTypes = data.payloadtype.map( p => {
let selectedCommands = [];
if(currentSelected[p.name] !== undefined){
selectedCommands = [...currentSelected[p.name]];
}
return {...p, selected: selectedCommands};
});
setPayloadTypes(updatedPayloadTypes);
setSelectedCommands({...currentSelected});
},
onError: (data) => {
const [activePayloadTypeName, setActivePayloadTypeName] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const {data, loading} = useQuery(getPayloadTypesAndCommandsQuery, {
fetchPolicy: "cache-first",
onError: () => {
snackActions.error("Failed to load payload type commands");
}
})
const onChange = React.useCallback( ({selected, name}) => {
setSelectedCommands({...selectedCommands, [name]: selected});
}, [selectedCommands]);
const onChangeBlockListName = (name, value, error) => {
});
const normalizedCurrentSelected = React.useMemo(() => normalizeCommandSelection(currentSelected), [currentSelected]);
const payloadtypes = React.useMemo(() => {
return (data?.payloadtype || []).map((payloadtype) => ({
...payloadtype,
commands: [...(payloadtype.commands || [])].sort(commandSort),
selected: selectedCommands[payloadtype.name] || [],
}));
}, [data, selectedCommands]);
const activePayloadType = React.useMemo(() => {
return payloadtypes.find((payloadtype) => payloadtype.name === activePayloadTypeName) || payloadtypes[0] || null;
}, [activePayloadTypeName, payloadtypes]);
const selectedCommandCount = React.useMemo(() => flattenCommands(selectedCommands).length, [selectedCommands]);
React.useEffect(() => {
setBlockListName(propBlockListName || "");
}, [propBlockListName]);
React.useEffect(() => {
setSelectedCommands(normalizedCurrentSelected);
}, [normalizedCurrentSelected]);
React.useEffect(() => {
if(payloadtypes.length === 0){
return;
}
if(!payloadtypes.some((payloadtype) => payloadtype.name === activePayloadTypeName)){
const firstSelectedPayloadType = payloadtypes.find((payloadtype) => getSelectedCommandCount(selectedCommands, payloadtype.name) > 0);
setActivePayloadTypeName((firstSelectedPayloadType || payloadtypes[0]).name);
}
}, [activePayloadTypeName, payloadtypes, selectedCommands]);
const onChange = React.useCallback(({selected, name}) => {
setSelectedCommands((current) => ({...current, [name]: selected}));
}, []);
const onChangeBlockListName = (name, value) => {
setBlockListName(value);
};
const submit = () => {
const submit = async () => {
if(blockListName.trim() === ""){
snackActions.warning("Must supply a block list name");
return;
}
// now diff selectedCommands with props.currentSelected to see which should be added or removed
if(editable && selectedCommandCount === 0){
snackActions.warning("Select at least one command for the block list");
return;
}
let toAdd = [];
let toRemove = [];
const existingCommandIDs = new Set(flattenCommands(normalizedCurrentSelected).map(commandID));
const selectedCommandIDs = new Set(flattenCommands(selectedCommands).map(commandID));
for(const value of Object.values(selectedCommands)){
//key is the payload type name, value is an array of commands
for(let i = 0; i < value.length; i++){
toAdd.push({command_id: value[i].id, name:blockListName.trim()});
if(!existingCommandIDs.has(commandID(value[i]))){
toAdd.push({command_id: value[i].id, name: blockListName.trim()});
}
}
}
for(const value of Object.values(currentSelected)){
for(const value of Object.values(normalizedCurrentSelected)){
for(let i = 0; i < value.length; i++){
// if value[i] in add, then remove it from add because it was selected before and is selected now
// if value[i] is not in add, then add it to toRemove because it was selected and is no longer selected
let index = toAdd.findIndex(c => c.command_id === value[i].id);
if(index > -1){
toAdd.splice(index, 1); //remove it
}else{
if(!selectedCommandIDs.has(commandID(value[i]))){
toRemove.push({command_id: value[i].id, name: blockListName.trim()});
}
}
}
onSubmit({toAdd, toRemove});
onClose();
if(toAdd.length === 0 && toRemove.length === 0){
onClose();
return;
}
setSubmitting(true);
try{
const result = await onSubmit({toAdd, toRemove});
if(result !== false){
onClose();
}
}catch(error){
snackActions.warning("Unable to update block list");
console.log(error);
}finally{
setSubmitting(false);
}
}
return (
<>
<DialogTitle id="form-dialog-title">{dialogTitle}</DialogTitle>
<DialogContent dividers={true}>
<MythicDialogBody>
<DialogContent dividers={true} className="mythic-block-list-dialog-content">
<MythicDialogBody className="mythic-block-list-dialog-body">
<MythicDialogSection title="Block List">
<MythicFormField label="Block List Name" required>
<MythicTextField disabled={!editable} onChange={onChangeBlockListName} value={blockListName} name="Block List Name" showLabel={false} autoFocus requiredValue marginTop="0px" marginBottom="0px"/>
<MythicTextField
disabled={!editable}
onChange={onChangeBlockListName}
value={blockListName}
name="command_block_list_title"
showLabel={false}
autoFocus
autoComplete="new-password"
inputProps={{
id: "mythic-command-block-list-title",
name: "mythic-command-block-list-title",
"data-form-type": "other",
}}
requiredValue
marginTop="0px"
marginBottom="0px"
/>
</MythicFormField>
</MythicDialogSection>
{payloadtypes.map(p => (
<PayloadTypeBlockList key={p.name} leftTitle={"Not Blocked"} onChange={onChange} rightTitle={"Blocked Commands"} itemKey={"cmd"} right={p.selected} left={p.commands} name={p.name}/>
))}
<MythicDialogSection
title="Commands"
actions={<Chip size="small" label={`${selectedCommandCount} blocked`} />}
>
{loading && payloadtypes.length === 0 ? (
<Box className="mythic-block-list-loading">
<CircularProgress size={20} />
<Typography variant="body2">Loading commands</Typography>
</Box>
) : payloadtypes.length === 0 ? (
<Box className="mythic-block-list-loading">
<Typography variant="body2">No payload type commands available</Typography>
</Box>
) : (
<>
<Tabs
className="mythic-block-list-payload-tabs"
value={activePayloadType?.name || false}
onChange={(event, value) => setActivePayloadTypeName(value)}
variant="scrollable"
scrollButtons="auto"
>
{payloadtypes.map((payloadtype) => (
<Tab
aria-label={`${payloadtype.name}, ${getSelectedCommandCount(selectedCommands, payloadtype.name)} blocked commands`}
id={`block-list-payload-tab-${payloadtype.id}`}
key={payloadtype.name}
value={payloadtype.name}
label={
<Box component="span" className="mythic-block-list-payload-tab-label">
<span>{payloadtype.name}</span>
<span className="mythic-block-list-payload-tab-count">{getSelectedCommandCount(selectedCommands, payloadtype.name)}</span>
</Box>
}
/>
))}
</Tabs>
{activePayloadType &&
<PayloadTypeBlockList
key={activePayloadType.name}
leftTitle={"Not Blocked"}
onChange={onChange}
rightTitle={"Blocked Commands"}
itemKey={"cmd"}
right={activePayloadType.selected}
left={activePayloadType.commands}
name={activePayloadType.name}
/>
}
</>
)}
</MythicDialogSection>
</MythicDialogBody>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={onClose}>
<MythicDialogButton onClick={onClose} disabled={submitting}>
Close
</MythicDialogButton>
<MythicDialogButton intent="primary" onClick={submit}>
Submit
<MythicDialogButton intent="primary" onClick={submit} disabled={submitting || loading}>
{submitting ? "Saving" : "Submit"}
</MythicDialogButton>
</MythicDialogFooter>
</>
@@ -1,6 +1,6 @@
import React from 'react';
import { OperationTable } from './OperationTable';
import {useQuery, gql, useLazyQuery} from '@apollo/client';
import {useQuery, gql} from '@apollo/client';
import {CommandBlockListTable} from './CommandBlockListTable';
import { snackActions } from '../../utilities/Snackbar';
import {useMythicLazyQuery} from "../../utilities/useMythicLazyQuery";
@@ -63,29 +63,30 @@ export function Operations(props){
snackActions.error("Failed to get list of operations");
}
});
const getBlockListsSuccess = (data) => {
const condensed = data.disabledcommandsprofile.reduce( (prev, cur) => {
const getBlockListsSuccess = React.useCallback((data) => {
const condensed = (data?.disabledcommandsprofile || []).reduce( (prev, cur) => {
const payloadTypeName = cur?.command?.payloadtype?.name || "Unknown";
if(prev[cur.name] === undefined){
prev[cur.name] = {};
}
if(prev[cur.name][cur.command.payloadtype.name] === undefined){
prev[cur.name][cur.command.payloadtype.name] = [];
if(prev[cur.name][payloadTypeName] === undefined){
prev[cur.name][payloadTypeName] = [];
}
prev[cur.name][cur.command.payloadtype.name].push(cur);
return {...prev};
prev[cur.name][payloadTypeName].push(cur);
return prev;
}, {});
// now break out into array
let arrayForm = [];
for(const [key, value] of Object.entries(condensed)){
arrayForm.push({"name": key, entries: value});
}
let arrayForm = Object.entries(condensed).map(([key, value]) => ({"name": key, entries: value}));
arrayForm.sort((a, b) => a.name.localeCompare(b.name));
setBlockLists(arrayForm);
}
const getBlockListsError = (data) => {
}, []);
const getBlockListsError = React.useCallback(() => {
snackActions.error("Failed to get blocklist options");
}
const getBlockLists = useMythicLazyQuery(GET_BlockLists, {fetchPolicy: "network-only"
});
}, []);
const getBlockListOptions = React.useMemo(() => ({fetchPolicy: "network-only"}), []);
const getBlockLists = useMythicLazyQuery(GET_BlockLists, getBlockListOptions);
const refreshBlockLists = React.useCallback(() => {
return getBlockLists().then(({data}) => getBlockListsSuccess(data)).catch((error) => getBlockListsError(error));
}, [getBlockLists, getBlockListsError, getBlockListsSuccess]);
const onUpdateOperation = ({id, name, complete}) => {
const updatedOperations = operations.map( o => {
if(o.id === id){
@@ -109,11 +110,11 @@ export function Operations(props){
setOperations(updatedOps);
}
const onUpdateCurrentOperation = (operation_id) => {
getBlockLists().then(({data}) => getBlockListsSuccess(data)).catch(({data}) => getBlockListsError(data));
refreshBlockLists();
}
React.useEffect( () => {
getBlockLists().then(({data}) => getBlockListsSuccess(data)).catch(({data}) => getBlockListsError(data));
}, []);
refreshBlockLists();
}, [refreshBlockLists]);
return (
<MythicPageBody>
<OperationTable operations={operations}
@@ -38,6 +38,7 @@ import {meState} from "../../../cache";
import { useReactiveVar } from '@apollo/client';
import {MythicErrorState, MythicLoadingState} from "../../MythicComponents/MythicStateDisplay";
import {MythicSectionHeader} from "../../MythicComponents/MythicPageHeader";
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
const GET_Payload_Details = gql`
@@ -588,9 +589,9 @@ function DetailedPayloadInnerTable(props){
<TableRow hover>
<TableCell>Download URL</TableCell>
<TableCell style={{display: "flex", alignItems: "center"}}>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.payload[0].filemetum.agent_file_id}>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.payload[0].filemetum.agent_file_id}>
{window.location.origin + "/direct/download/" + data.payload[0].filemetum.agent_file_id}
</Link>
</FileDownloadLinkWithAuth>
<MythicStyledTooltip title={"Host Payload Through C2"} >
<IconButton
className="mythic-table-row-icon-action mythic-table-row-icon-action-info"
@@ -5,6 +5,7 @@ import {MythicStyledTooltip} from '../../MythicComponents/MythicStyledTooltip';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {PayloadBuildMessageDialog} from './PayloadBuildMessageDialog';
import {MythicStatusChip} from '../../MythicComponents/MythicStatusChip';
import {handleAuthLink} from "../../utilities/FileDownloadWithAuth";
export function PayloadsTableRowBuildStatus(props){
const [openBuildMessage, setOpenBuildMessageDialog] = React.useState(false);
@@ -14,17 +15,17 @@ export function PayloadsTableRowBuildStatus(props){
if(props.deleted){
return null;
}
const downloadHref = "/direct/download/" + props.filemetum.agent_file_id;
return (
<React.Fragment>
{props.build_phase === "success" ?
( <MythicStyledTooltip title="Download payload">
<MythicStatusChip
component="a"
href={"/direct/download/" + props.filemetum.agent_file_id}
clickable
label="Ready"
status="success"
icon={<GetAppIcon />}
onClick={(event) => handleAuthLink(event, downloadHref)}
/>
</MythicStyledTooltip>
@@ -81,9 +81,9 @@ export const SnackMessage = (props) => {
<Typography variant="subtitle2" >
Zip Created! This is available at any time via the "Uploads" page.
</Typography>
<Link color="textPrimary" href={"/direct/download/" + props.file_id} >
<FileDownloadLinkWithAuth color="textPrimary" href={"/direct/download/" + props.file_id} >
Download here
</Link>
</FileDownloadLinkWithAuth>
</React.Fragment>
@@ -405,7 +405,7 @@ function FileMetaDownloadTableRow(props){
{props.deleted ? (
<Typography variant="body2" style={{wordBreak: "break-all"}}>{props.full_remote_path_text === "" ? props.filename_text : props.full_remote_path_text}</Typography>
) : (
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + props.agent_file_id}>{props.full_remote_path_text === "" ? props.filename_text : props.full_remote_path_text}</Link>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + props.agent_file_id}>{props.full_remote_path_text === "" ? props.filename_text : props.full_remote_path_text}</FileDownloadLinkWithAuth>
)
}
{props.complete ? null : (
@@ -823,7 +823,7 @@ function FileMetaUploadTableRow(props){
<MythicCallbackGroupsDisplay groups={props?.task?.callback.mythictree_groups} />
{props.deleted ? (<Typography variant="body2" style={{wordBreak: "break-all"}}>{props.full_remote_path_text}</Typography>) : (
props.complete ? (
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + props.agent_file_id}>{props.full_remote_path_text}</Link>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + props.agent_file_id}>{props.full_remote_path_text}</FileDownloadLinkWithAuth>
) : (
<React.Fragment>
<Typography variant="body2" style={{wordBreak: "break-all"}}>{props.full_remote_path_text}</Typography> <Typography color="secondary" style={{wordBreak: "break-all"}} >{props.chunks_received} / {props.total_chunks} Chunks Received</Typography>
@@ -950,7 +950,7 @@ function FileMetaUploadTableRow(props){
<MythicCallbackGroupsDisplay groups={props.copy_of_file?.task?.callback.mythictree_groups} />
{props.copy_of_file.deleted ? (<Typography variant="body2" style={{wordBreak: "break-all"}}>{props.copy_of_file.full_remote_path_text}</Typography>) : (
props.copy_of_file.complete ? (
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + props.copy_of_file.agent_file_id}>{props.copy_of_file.full_remote_path_text}</Link>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + props.copy_of_file.agent_file_id}>{props.copy_of_file.full_remote_path_text}</FileDownloadLinkWithAuth>
) : (
<React.Fragment>
<Typography variant="body2" style={{wordBreak: "break-all"}}>{props.copy_of_file.full_remote_path_text}</Typography> <Typography color="secondary" style={{wordBreak: "break-all"}} >{props.copy_of_file.chunks_received} / {props.copy_of_file.total_chunks} Chunks Received</Typography>
@@ -1464,7 +1464,7 @@ function FileMetaEventingWorkflowsTableRow(props){
)}
</MythicStyledTableCell>
<MythicStyledTableCell>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + props.agent_file_id}>{props.filename_text}</Link>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + props.agent_file_id}>{props.filename_text}</FileDownloadLinkWithAuth>
</MythicStyledTableCell>
<MythicStyledTableCell style={{wordBreak: "break-all"}}>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/new/eventing?eventgroup=" + props.eventgroup?.id}>{props.eventgroup?.name}</Link>
@@ -22,6 +22,7 @@ import PublicIcon from '@mui/icons-material/Public';
import {DetailedPayloadTable} from "../Payloads/DetailedPayloadTable";
import InfoIconOutline from '@mui/icons-material/InfoOutlined';
import {getReadableTextColor, isValidHexColor} from "../../MythicComponents/MythicColorInput";
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
const singleLineCellStyle = {
minWidth: 0,
@@ -277,9 +278,9 @@ function TagTableRowElement(props){
}
>
<TagDetailItem label="Filename" wide>
<Link color="textPrimary" download underline="always" target="_blank" href={"/direct/download/" + props.filemetum.agent_file_id}>
<FileDownloadLinkWithAuth color="textPrimary" download underline="always" target="_blank" href={"/direct/download/" + props.filemetum.agent_file_id}>
{filename}
</Link>
</FileDownloadLinkWithAuth>
</TagDetailItem>
<TagDetailItem label="Hash" wide>
<div className="mythic-search-result-stack">
@@ -40,7 +40,7 @@ import {
MythicDialogFooter,
MythicDialogSection,
} from "../../MythicComponents/MythicDialogLayout";
import {reorder} from "../../MythicComponents/MythicDraggableList";
import {MythicDraggablePortal, reorder} from "../../MythicComponents/MythicDraggableList";
import {
Draggable,
DragDropContext,
@@ -166,7 +166,7 @@ const TaskingMetadataLayoutDialog = ({initialItems, onClose, onReset, onSubmit})
return (
<>
<DialogTitle id="form-dialog-title">Tasking Metadata Layout</DialogTitle>
<DialogContent dividers={true} sx={{p: 0}}>
<DialogContent dividers={true} sx={{p: 0, overflow: "hidden"}}>
<MythicDialogBody sx={{height: "min(70vh, 38rem)", p: 1}}>
<MythicDialogSection
title="Metadata Chips"
@@ -219,35 +219,42 @@ const TaskingMetadataDraggableList = ({items, onDragEnd, onToggleVisibility}) =>
const TaskingMetadataDraggableListItem = ({item, index, onToggleVisibility}) => {
return (
<Draggable draggableId={item.name} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
className={`mythic-reorder-row mythic-tasking-metadata-row${snapshot.isDragging ? " mythic-reorder-row-dragging" : ""}${item.visible ? "" : " mythic-reorder-row-disabled"}`}
{...provided.draggableProps}
>
<span className="mythic-reorder-drag-handle" {...provided.dragHandleProps}>
<DragHandleIcon fontSize="small" />
</span>
<div className="mythic-reorder-row-main">
<span className="mythic-reorder-row-title">{item.display}</span>
<span className="mythic-reorder-row-description">{item.description}</span>
{(provided, snapshot) => {
const row = (
<div
ref={provided.innerRef}
className={`mythic-reorder-row mythic-tasking-metadata-row${snapshot.isDragging ? " mythic-reorder-row-dragging" : ""}${item.visible ? "" : " mythic-reorder-row-disabled"}`}
{...provided.draggableProps}
>
<span className="mythic-reorder-drag-handle" {...provided.dragHandleProps}>
<DragHandleIcon fontSize="small" />
</span>
<div className="mythic-reorder-row-main">
<span className="mythic-reorder-row-title">{item.display}</span>
<span className="mythic-reorder-row-description">{item.description}</span>
</div>
<div className="mythic-reorder-row-actions">
<IconButton
aria-label={item.visible ? `Hide ${item.display}` : `Show ${item.display}`}
className={`mythic-table-row-icon-action ${item.visible ? "mythic-table-row-icon-action-hover-danger" : "mythic-table-row-icon-action-hover-info"}`}
size="small"
onClick={() => onToggleVisibility(index)}
>
{item.visible ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</div>
</div>
<div className="mythic-reorder-row-actions">
<IconButton
aria-label={item.visible ? `Hide ${item.display}` : `Show ${item.display}`}
className={`mythic-table-row-icon-action ${item.visible ? "mythic-table-row-icon-action-hover-danger" : "mythic-table-row-icon-action-hover-info"}`}
size="small"
onClick={() => onToggleVisibility(index)}
>
{item.visible ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</div>
</div>
)}
);
return (
<MythicDraggablePortal isDragging={snapshot.isDragging}>
{row}
</MythicDraggablePortal>
);
}}
</Draggable>
)
}
@@ -4,11 +4,11 @@ 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 Link from '@mui/material/Link';
import {b64DecodeUnicode} from '../Callbacks/ResponseDisplay';
import MythicStyledTableCell from '../../MythicComponents/MythicTableCell';
import {MythicPageHeaderChip, MythicSectionHeader} from "../../MythicComponents/MythicPageHeader";
import {MythicStatusChip} from "../../MythicComponents/MythicStatusChip";
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
export function TaskFilesTable(props){
@@ -50,7 +50,7 @@ export function TaskFilesTable(props){
<TableRow key={"file" + file.id} hover>
<MythicStyledTableCell className="mythic-single-task-cell-break">
{!file.deleted && file.complete ? (
<Link className="mythic-single-task-table-link" href={"/direct/download/" + file.agent_file_id}>{b64DecodeUnicode(file.filename_text)}</Link>
<FileDownloadLinkWithAuth className="mythic-single-task-table-link" href={"/direct/download/" + file.agent_file_id}>{b64DecodeUnicode(file.filename_text)}</FileDownloadLinkWithAuth>
) : (
!file.complete ? (
b64DecodeUnicode(file.filename_text) + " (" + file.chunks_received + "/" + file.total_chunks + ")"
+282
View File
@@ -9117,6 +9117,40 @@ tspan {
.mythic-eventing-user-input-field-row .mythic-eventing-step-field-grid {
width: 100%;
}
.mythic-eventing-user-input-source-cell,
.mythic-eventing-user-input-choices {
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
width: 100%;
}
.mythic-eventing-user-input-choices {
background-color: var(--mythic-global-009);
border: 1px solid var(--mythic-global-010);
border-radius: var(--mythic-global-008);
margin-top: 0.55rem;
padding: 0.55rem;
}
.mythic-eventing-user-input-choices-header,
.mythic-eventing-user-input-choice-list {
display: flex;
flex-direction: column;
gap: 0.45rem;
min-width: 0;
width: 100%;
}
.mythic-eventing-user-input-choice-row {
align-items: center;
display: grid;
gap: 0.45rem;
grid-template-columns: 2rem minmax(0, 1fr);
min-width: 0;
width: 100%;
}
.mythic-eventing-user-input-choice-list > .mythic-table-row-action {
align-self: flex-start;
}
.mythic-eventing-step-list-content {
flex: 1 1 auto;
min-width: 0;
@@ -11071,6 +11105,62 @@ tspan {
.mythic-transfer-section {
margin-top: 0.75rem;
}
.mythic-block-list-dialog-content {
min-height: min(70vh, 42rem);
}
.mythic-block-list-dialog-body {
gap: 0.75rem;
}
.mythic-block-list-loading {
align-items: center;
color: var(--mythic-global-002);
display: flex;
gap: 0.65rem;
min-height: 8rem;
justify-content: center;
}
.mythic-block-list-payload-tabs {
background-color: var(--mythic-global-009);
border: 1px solid var(--mythic-global-010);
border-radius: var(--mythic-global-008);
margin-bottom: 0.75rem;
min-height: 38px;
}
.mythic-block-list-payload-tabs .MuiTab-root {
min-height: 38px;
padding: 0.35rem 0.7rem;
}
.mythic-block-list-payload-tab-label {
align-items: center;
display: flex;
gap: 0.45rem;
min-width: 0;
}
.mythic-block-list-payload-tab-label > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-block-list-payload-tab-count {
align-items: center;
background-color: var(--mythic-global-029);
border: 1px solid var(--mythic-global-010);
border-radius: 999px;
color: var(--mythic-global-002);
display: inline-flex;
font-size: 0.68rem;
font-weight: 700;
justify-content: center;
line-height: 1;
min-width: 1.4rem;
padding: 0.18rem 0.38rem;
}
.mythic-block-list-transfer-grid {
align-items: stretch;
display: grid;
gap: 0.5rem;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
}
.mythic-transfer-list {
background-color: var(--mythic-global-009);
border: 1px solid var(--mythic-global-010);
@@ -11131,6 +11221,12 @@ tspan {
.mythic-dialog-choice-divider {
text-align: left;
}
.mythic-block-list-transfer-grid {
grid-template-columns: 1fr;
}
.mythic-block-list-transfer-grid .mythic-transfer-controls {
flex-direction: row;
}
}
.MuiTabs-root {
min-height: 34px;
@@ -11446,6 +11542,180 @@ tspan {
overflow-wrap: anywhere;
padding: 8px 10px 9px;
}
.mythic-chat-special-card {
display: flex;
flex-direction: column;
gap: 0.62rem;
min-width: min(26rem, calc(100vw - 3rem));
padding: 0.72rem 0.78rem;
}
.mythic-chat-eventing-card-waiting,
.mythic-chat-message-special-eventing-waiting {
--mythic-chat-special-accent-text: var(--mythic-global-038);
--mythic-chat-special-accent-border: var(--mythic-global-037);
--mythic-chat-special-accent-strong: var(--mythic-global-037);
--mythic-chat-special-accent-soft: var(--mythic-global-036);
--mythic-chat-special-chip-bg: var(--mythic-global-036);
}
.mythic-chat-eventing-card-success,
.mythic-chat-message-special-eventing-success {
--mythic-chat-special-accent-text: var(--mythic-global-035);
--mythic-chat-special-accent-border: var(--mythic-global-034);
--mythic-chat-special-accent-strong: var(--mythic-global-034);
--mythic-chat-special-accent-soft: var(--mythic-global-033);
--mythic-chat-special-chip-bg: var(--mythic-global-033);
}
.mythic-chat-eventing-card-error,
.mythic-chat-message-special-eventing-error {
--mythic-chat-special-accent-text: var(--mythic-global-048);
--mythic-chat-special-accent-border: var(--mythic-global-058);
--mythic-chat-special-accent-strong: var(--mythic-global-058);
--mythic-chat-special-accent-soft: var(--mythic-global-047);
--mythic-chat-special-chip-bg: var(--mythic-global-047);
}
.mythic-chat-eventing-card-running,
.mythic-chat-message-special-eventing-running {
--mythic-chat-special-accent-text: var(--mythic-global-023);
--mythic-chat-special-accent-border: var(--mythic-global-022);
--mythic-chat-special-accent-strong: var(--mythic-global-022);
--mythic-chat-special-accent-soft: var(--mythic-global-021);
--mythic-chat-special-chip-bg: var(--mythic-global-021);
}
.mythic-chat-eventing-card-queued,
.mythic-chat-eventing-card-neutral,
.mythic-chat-message-special-eventing-queued,
.mythic-chat-message-special-eventing-neutral {
--mythic-chat-special-accent-text: var(--mythic-global-027);
--mythic-chat-special-accent-border: var(--mythic-global-030);
--mythic-chat-special-accent-strong: var(--mythic-global-030);
--mythic-chat-special-accent-soft: var(--mythic-global-026);
--mythic-chat-special-chip-bg: var(--mythic-global-026);
}
.mythic-chat-message-special-eventing .mythic-chat-message-header {
background: linear-gradient(90deg, var(--mythic-chat-special-accent-soft), transparent 72%);
border-bottom-color: var(--mythic-chat-special-accent-border);
}
.mythic-chat-message-special-eventing .mythic-chat-author > span {
color: var(--mythic-chat-special-accent-text);
}
.mythic-chat-special-card-header {
align-items: center;
display: flex;
gap: 0.8rem;
justify-content: space-between;
min-width: 0;
}
.mythic-chat-special-card-title-wrap {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: 0.14rem;
min-width: 0;
}
.mythic-chat-special-card-title {
color: var(--mythic-chat-special-accent-text);
font-size: 0.9rem !important;
font-weight: 850 !important;
line-height: 1.2 !important;
overflow-wrap: anywhere;
}
.mythic-chat-special-card-subtitle {
color: var(--mythic-global-025);
font-size: 0.72rem !important;
font-weight: 700 !important;
line-height: 1.25 !important;
overflow-wrap: anywhere;
}
.mythic-chat-special-status.MuiChip-root {
background-color: var(--mythic-chat-special-chip-bg) !important;
border-color: var(--mythic-chat-special-accent-border) !important;
border-radius: var(--mythic-global-008);
color: var(--mythic-chat-special-accent-text) !important;
flex: 0 0 auto;
font-size: 0.7rem;
font-weight: 800;
height: 1.48rem;
}
.mythic-chat-special-status .MuiChip-label {
color: inherit;
}
.mythic-chat-special-card-prompt {
background-color: var(--mythic-chat-markdown-surface);
border: 1px solid var(--mythic-chat-markdown-border);
border-radius: var(--mythic-global-008);
color: var(--mythic-global-002);
font-size: 0.8rem;
font-weight: 650;
line-height: 1.35;
overflow-wrap: anywhere;
padding: 0.54rem 0.62rem;
white-space: pre-wrap;
}
.mythic-chat-special-card-details {
display: grid;
gap: 0.38rem 0.75rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
}
.mythic-chat-special-card-detail {
display: flex;
flex-direction: column;
gap: 0.08rem;
min-width: 0;
}
.mythic-chat-special-card-detail-label {
color: var(--mythic-global-025);
font-size: 0.62rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.1;
text-transform: uppercase;
}
.mythic-chat-special-card-detail-value {
color: var(--mythic-global-002);
font-size: 0.74rem;
font-weight: 700;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-chat-special-card-footer {
align-items: center;
border-top: 1px solid var(--mythic-chat-markdown-border);
display: flex;
gap: 0.75rem;
justify-content: space-between;
min-width: 0;
padding-top: 0.58rem;
}
.mythic-chat-special-card-refresh-time {
color: var(--mythic-global-025);
flex: 1 1 auto;
font-size: 0.68rem !important;
font-weight: 650 !important;
line-height: 1.2 !important;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-chat-special-card-actions {
align-items: center;
display: flex;
flex: 0 0 auto;
gap: 0.38rem;
justify-content: flex-end;
}
.mythic-chat-special-refresh-button.MuiIconButton-root {
border: 1px solid var(--mythic-chat-markdown-border);
border-radius: var(--mythic-global-008);
height: 1.9rem;
width: 1.9rem;
}
.mythic-chat-special-refresh-button.MuiIconButton-root:hover {
border-color: var(--mythic-global-378);
}
.mythic-chat-edit-box {
padding: 8px 10px 9px;
}
@@ -11717,6 +11987,18 @@ tspan {
max-width: 100%;
min-width: 0;
}
.mythic-chat-special-card-details {
grid-template-columns: minmax(0, 1fr);
}
.mythic-chat-special-card-footer {
align-items: flex-start;
flex-direction: column;
gap: 0.5rem;
}
.mythic-chat-special-card-actions {
justify-content: flex-start;
width: 100%;
}
.mythic-chat-search-dialog.MuiDialog-paper {
height: calc(100vh - 32px);
}
+6
View File
@@ -98,6 +98,12 @@ type Mutation {
): ChatActionOutput
}
type Mutation {
chatRefreshSpecialMessage(
message_id: Int!
): ChatActionOutput
}
type Mutation {
chatRetryRequest(
request_id: Int!
+10
View File
@@ -158,6 +158,16 @@ actions:
- role: mythic_admin
- role: developer
comment: Mark a chat channel as read for the current operator
- name: chatRefreshSpecialMessage
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_refresh_special_message_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
comment: Refresh a chat special message from its source object
- name: chatRetryRequest
definition:
kind: synchronous
@@ -12,10 +12,8 @@ const (
ChatChannelTypeAI = "ai"
ChatMessageAuthorOperator = "operator"
ChatMessageAuthorAI = "ai"
ChatMessageAuthorSystem = "system"
ChatMessageStatusPending = "pending"
ChatMessageStatusStreaming = "streaming"
ChatMessageStatusComplete = "complete"
ChatMessageStatusError = "error"
@@ -0,0 +1,223 @@
package eventing
import (
"database/sql"
"fmt"
"time"
"github.com/its-a-feature/Mythic/database"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
)
const (
ChatSpecialTypeEventingUserInteraction = "eventing_user_interaction"
ChatSpecialSourceEventing = "eventing"
)
type userInteractionChatSnapshotRow struct {
EventStepInstanceID int `db:"eventstepinstance_id"`
EventGroupInstanceID int `db:"eventgroupinstance_id"`
OperationID int `db:"operation_id"`
Status string `db:"status"`
UserInteraction databaseStructs.MythicJSONText `db:"user_interaction"`
UserInteractionResponse databaseStructs.MythicJSONText `db:"user_interaction_response"`
UserInteractionResolvedBy sql.NullInt64 `db:"user_interaction_resolved_by"`
UserInteractionResolvedAt sql.NullTime `db:"user_interaction_resolved_at"`
UpdatedAt time.Time `db:"updated_at"`
EventStepName string `db:"eventstep_name"`
EventStepAction string `db:"eventstep_action"`
EventGroupName string `db:"eventgroup_name"`
RunOperatorID int `db:"run_operator_id"`
RunOperatorUsername string `db:"run_operator_username"`
RunOperatorAccountType string `db:"run_operator_account_type"`
ResolvedByUsername sql.NullString `db:"resolved_by_username"`
}
func ChatEventStepInstanceSourceID(eventStepInstanceID int) string {
return fmt.Sprintf("eventstepinstance:%d", eventStepInstanceID)
}
func BuildUserInteractionChatMessage(eventStepInstanceID int, operationID int) (string, string, map[string]interface{}, error) {
row := userInteractionChatSnapshotRow{}
err := database.DB.Get(&row, `SELECT
eventstepinstance.id "eventstepinstance_id",
eventstepinstance.eventgroupinstance_id,
eventstepinstance.operation_id,
eventstepinstance.status,
eventstepinstance.user_interaction,
eventstepinstance.user_interaction_response,
eventstepinstance.user_interaction_resolved_by,
eventstepinstance.user_interaction_resolved_at,
eventstepinstance.updated_at,
eventstep.name "eventstep_name",
eventstep.action "eventstep_action",
eventgroup.name "eventgroup_name",
eventgroupinstance.operator_id "run_operator_id",
run_operator.username "run_operator_username",
run_operator.account_type "run_operator_account_type",
resolved_operator.username "resolved_by_username"
FROM eventstepinstance
JOIN eventstep ON eventstepinstance.eventstep_id = eventstep.id
JOIN eventgroupinstance ON eventstepinstance.eventgroupinstance_id = eventgroupinstance.id
JOIN eventgroup ON eventgroupinstance.eventgroup_id = eventgroup.id
JOIN operator run_operator ON eventgroupinstance.operator_id = run_operator.id
LEFT JOIN operator resolved_operator ON eventstepinstance.user_interaction_resolved_by = resolved_operator.id
WHERE eventstepinstance.id=$1 AND eventstepinstance.operation_id=$2`,
eventStepInstanceID, operationID)
if err != nil {
return "", "", nil, err
}
config := row.UserInteraction.StructValue()
approvalRequired := UserInteractionApprovalRequired(config)
inputRequired := UserInteractionInputRequired(config)
inputs := UserInteractionInputs(config)
inputNames := make([]string, 0, len(inputs))
requiredInputCount := 0
for _, input := range inputs {
if name := UserInteractionFieldName(input); name != "" {
inputNames = append(inputNames, name)
}
if UserInteractionFieldRequired(input) {
requiredInputCount += 1
}
}
resolved := row.UserInteractionResolvedBy.Valid
waiting := !resolved && (row.Status == EventGroupInstanceStatusAwaitingApproval || row.Status == EventGroupInstanceStatusInputNeeded)
snapshot := map[string]interface{}{
"eventgroupinstance_id": row.EventGroupInstanceID,
"eventstepinstance_id": row.EventStepInstanceID,
"workflow_name": row.EventGroupName,
"step_name": row.EventStepName,
"step_action": row.EventStepAction,
"status": row.Status,
"waiting": waiting,
"resolved": resolved,
"approval_required": approvalRequired,
"input_required": inputRequired,
"approval_prompt": stringFromMap(config, "approval_prompt"),
"input_prompt": stringFromMap(config, "input_prompt"),
"input_count": len(inputs),
"required_input_count": requiredInputCount,
"input_names": inputNames,
"bot_approval_approver": UserInteractionBotApprovalApprover(config),
"run_operator_id": row.RunOperatorID,
"run_operator_username": row.RunOperatorUsername,
"run_operator_account_type": row.RunOperatorAccountType,
"user_interaction_response": row.UserInteractionResponse.StructValue(),
"user_interaction_updated_at": row.UpdatedAt.UTC().Format(time.RFC3339),
}
if row.ResolvedByUsername.Valid {
snapshot["resolved_by_username"] = row.ResolvedByUsername.String
}
if row.UserInteractionResolvedAt.Valid {
snapshot["resolved_at"] = row.UserInteractionResolvedAt.Time.UTC().Format(time.RFC3339)
}
metadata := map[string]interface{}{
"special_type": ChatSpecialTypeEventingUserInteraction,
"source": ChatSpecialSourceEventing,
"source_id": ChatEventStepInstanceSourceID(row.EventStepInstanceID),
"eventgroupinstance_id": row.EventGroupInstanceID,
"eventstepinstance_id": row.EventStepInstanceID,
"eventing_user_interaction": snapshot,
"refreshed_at": time.Now().UTC().Format(time.RFC3339),
"refresh": map[string]interface{}{"action": "chatRefreshSpecialMessage"},
"special_message_version": 1,
}
message := fmt.Sprintf("Eventing user interaction: %s / %s", row.EventGroupName, row.EventStepName)
senderDisplayName := fmt.Sprintf("Eventing - %s", row.EventGroupName)
return message, senderDisplayName, metadata, nil
}
func UpsertUserInteractionChatMessage(eventStepInstanceID int, operationID int) error {
message, senderDisplayName, metadata, err := BuildUserInteractionChatMessage(eventStepInstanceID, operationID)
if err != nil {
return err
}
metadataText := GetMythicJSONTextFromStruct(metadata)
sourceID := ChatEventStepInstanceSourceID(eventStepInstanceID)
_, err = database.DB.Exec(`INSERT INTO chat_channel
(operation_id, name, slug, description, channel_type, created_by)
SELECT id, $2, $2, 'Default operation chat channel', $3, admin_id
FROM operation
WHERE id=$1 AND deleted=false
ON CONFLICT DO NOTHING`,
operationID,
"general",
databaseStructs.ChatChannelTypeStandard)
if err != nil {
return err
}
result, err := database.DB.Exec(`UPDATE chat_message
SET sender_display_name=$2, message=$3, metadata=$4::jsonb, status=$5, updated_at=now()
WHERE id=(
SELECT id FROM chat_message
WHERE operation_id=$1
AND metadata->>'special_type'=$6
AND metadata->>'source_id'=$7
AND deleted=false
ORDER BY id DESC
LIMIT 1
)`,
operationID,
senderDisplayName,
message,
metadataText.String(),
databaseStructs.ChatMessageStatusComplete,
ChatSpecialTypeEventingUserInteraction,
sourceID)
if err != nil {
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return err
}
if rowsAffected > 0 {
return nil
}
_, err = database.DB.Exec(`INSERT INTO chat_message
(operation_id, channel_id, author_type, sender_display_name, message, status, metadata)
SELECT $1, chat_channel.id, $4, $5, $6, $7, $8::jsonb
FROM chat_channel
WHERE chat_channel.operation_id=$1
AND chat_channel.channel_type=$2
AND lower(chat_channel.slug)=$3`,
operationID,
databaseStructs.ChatChannelTypeStandard,
"general",
databaseStructs.ChatMessageAuthorSystem,
senderDisplayName,
message,
databaseStructs.ChatMessageStatusComplete,
metadataText.String())
return err
}
func RefreshExistingUserInteractionChatMessage(eventStepInstanceID int, operationID int) error {
message, senderDisplayName, metadata, err := BuildUserInteractionChatMessage(eventStepInstanceID, operationID)
if err != nil {
return err
}
metadataText := GetMythicJSONTextFromStruct(metadata)
_, err = database.DB.Exec(`UPDATE chat_message
SET sender_display_name=$2, message=$3, metadata=$4::jsonb, status=$5, updated_at=now()
WHERE operation_id=$1
AND metadata->>'special_type'=$6
AND metadata->>'source_id'=$7
AND deleted=false`,
operationID,
senderDisplayName,
message,
metadataText.String(),
databaseStructs.ChatMessageStatusComplete,
ChatSpecialTypeEventingUserInteraction,
ChatEventStepInstanceSourceID(eventStepInstanceID))
return err
}
func stringFromMap(values map[string]interface{}, key string) string {
if value, ok := values[key].(string); ok {
return value
}
return ""
}
+52 -1
View File
@@ -1,10 +1,17 @@
package eventing
import "strings"
import (
"strings"
"github.com/its-a-feature/Mythic/logging"
)
const (
UserInteractionApproverOperator = "operator"
UserInteractionApproverLead = "lead"
UserInteractionInputSourceCustom = "custom"
UserInteractionInputTypeChooseOne = "ChooseOne"
)
func UserInteractionApprovalRequired(config map[string]interface{}) bool {
@@ -93,6 +100,50 @@ func UserInteractionFieldRequired(field map[string]interface{}) bool {
return false
}
func UserInteractionFieldType(field map[string]interface{}) string {
if value, ok := field["type"].(string); ok {
return strings.ToLower(value)
}
return "string"
}
func UserInteractionFieldChoices(field map[string]interface{}) []interface{} {
if value, ok := field["choices"]; ok {
return NormalizeUserInteractionChoices(value)
}
return []interface{}{}
}
func NormalizeUserInteractionChoices(rawChoices interface{}) []interface{} {
choices := []interface{}{}
if rawChoices == nil {
return choices
}
switch typedChoices := rawChoices.(type) {
case []interface{}:
for _, choice := range typedChoices {
stringChoice, ok := choice.(string)
if ok {
choices = append(choices, stringChoice)
}
}
case []string:
for _, choice := range typedChoices {
choices = append(choices, choice)
}
case string:
for _, choice := range strings.Split(typedChoices, "\n") {
choice = strings.TrimSpace(choice)
if choice != "" {
choices = append(choices, choice)
}
}
default:
logging.LogError(nil, "invalid choice type", "type", typedChoices, "choices", rawChoices)
}
return choices
}
func userInteractionBool(value interface{}) bool {
switch typedValue := value.(type) {
case bool:
@@ -0,0 +1,55 @@
package eventing
import "testing"
func TestUserInteractionChoiceContainsSupportsPrimitiveAndLabelValueChoices(t *testing.T) {
field := map[string]interface{}{
"type": "ChooseOne",
"choices": []interface{}{
"alpha",
map[string]interface{}{
"label": "Second choice",
"value": "bravo",
},
map[string]interface{}{
"label": "Numeric choice",
"value": float64(3),
},
},
}
if !UserInteractionChoiceContains(field, "alpha") {
t.Fatalf("expected primitive choice to be accepted")
}
if !UserInteractionChoiceContains(field, "bravo") {
t.Fatalf("expected label/value choice to be accepted")
}
if !UserInteractionChoiceContains(field, 3) {
t.Fatalf("expected numeric choice to compare by value")
}
if UserInteractionChoiceContains(field, "charlie") {
t.Fatalf("unexpected choice was accepted")
}
}
func TestNormalizeUserInteractionChoicesSupportsMapsAndNewlineStrings(t *testing.T) {
mapChoices := NormalizeUserInteractionChoices(map[string]interface{}{
"Bravo": "bravo",
"Alpha": "alpha",
})
if len(mapChoices) != 2 {
t.Fatalf("expected two map choices, got %d", len(mapChoices))
}
firstChoice, ok := mapChoices[0].(map[string]interface{})
if !ok {
t.Fatalf("expected normalized map choice")
}
if firstChoice["label"] != "Alpha" || firstChoice["value"] != "alpha" {
t.Fatalf("expected map choices to be sorted and label/value normalized, got %#v", firstChoice)
}
stringChoices := NormalizeUserInteractionChoices("one\n\ntwo\n")
if len(stringChoices) != 2 || stringChoices[0] != "one" || stringChoices[1] != "two" {
t.Fatalf("expected newline choices to be split, got %#v", stringChoices)
}
}
+86 -2
View File
@@ -755,6 +755,9 @@ func processEventFinishAndNextStepStart(eventNotification EventNotification) {
if err != nil {
logging.LogError(err, "Failed to update eventstepinstance")
}
if err = eventing.RefreshExistingUserInteractionChatMessage(eventSteps[i].ID, eventSteps[i].OperationID); err != nil {
logging.LogError(err, "failed to refresh eventing user interaction chat message after step finished")
}
_, err = database.DB.Exec(`UPDATE apitokens SET deleted=true, active=false
WHERE eventstepinstance_id=$1`, triggeringStep.ID)
if err != nil {
@@ -962,7 +965,7 @@ func startEventStepInstance(eventStepInstanceID int) error {
logging.LogError(err, "failed to get all event step instances")
return err
}
userSuppliedInputs, waitingForUser, err := prepareEventStepUserInteraction(eventStepInstance)
userSuppliedInputs, waitingForUser, err := prepareEventStepUserInteraction(eventStepInstance, allEventSteps)
if err != nil {
return err
}
@@ -1152,11 +1155,12 @@ func replaceVariableInActionDataString(actionDataString string, key string, val
}
}
func prepareEventStepUserInteraction(eventStepInstance databaseStructs.EventStepInstance) (map[string]interface{}, bool, error) {
func prepareEventStepUserInteraction(eventStepInstance databaseStructs.EventStepInstance, allEventSteps []databaseStructs.EventStepInstance) (map[string]interface{}, bool, error) {
config := eventStepInstance.UserInteraction.StructValue()
if len(config) == 0 {
config = eventStepInstance.EventStep.UserInteraction.StructValue()
}
config = resolveUserInteractionConfigValueSources(config, allEventSteps)
if !eventing.UserInteractionHasRequirements(config) {
return map[string]interface{}{}, false, nil
}
@@ -1191,9 +1195,86 @@ func prepareEventStepUserInteraction(eventStepInstance databaseStructs.EventStep
logging.LogError(err, "failed to update event group to waiting for user interaction")
return map[string]interface{}{}, false, err
}
if err = eventing.UpsertUserInteractionChatMessage(eventStepInstance.ID, eventStepInstance.OperationID); err != nil {
logging.LogError(err, "failed to create eventing user interaction chat message")
}
return map[string]interface{}{}, true, nil
}
func resolveUserInteractionConfigValueSources(config map[string]interface{}, allEventSteps []databaseStructs.EventStepInstance) map[string]interface{} {
if len(config) == 0 {
return config
}
resolvedConfig := map[string]interface{}{}
configBytes, err := json.Marshal(config)
if err == nil {
err = json.Unmarshal(configBytes, &resolvedConfig)
}
if err != nil {
for key, value := range config {
resolvedConfig[key] = value
}
}
inputs := eventing.UserInteractionInputs(resolvedConfig)
for i := range inputs {
source, ok := inputs[i]["default_value_source"]
if !ok {
logging.LogError(nil, "failed to get default_value_source for user interaction", "input", inputs[i])
continue
}
inputType, ok := inputs[i]["type"].(string)
if !ok {
logging.LogError(nil, "failed to get type", "input", inputs[i])
continue
}
if source.(string) != "custom" {
resolvedValue, ok := resolveUserInteractionStepOutputReference(source.(string), allEventSteps)
if !ok {
logging.LogError(nil, "failed to resolve user interaction step output reference", "source", source, "all_event_steps", allEventSteps)
continue
}
if inputType == eventing.UserInteractionInputTypeChooseOne {
switch resolvedValue.(type) {
case string:
inputs[i]["choices"] = []string{resolvedValue.(string)}
case []string:
inputs[i]["choices"] = resolvedValue
case []interface{}:
inputs[i]["choices"] = resolvedValue
default:
logging.LogError(nil, "failed to resolve type of user interaction choices", "source", source, "resolvedValue", resolvedValue)
}
} else {
inputs[i]["default_value"] = resolvedValue
}
} else {
if inputType == eventing.UserInteractionInputTypeChooseOne {
inputs[i]["choices"] = eventing.NormalizeUserInteractionChoices(inputs[i]["choices"])
}
}
}
resolvedConfig["inputs"] = inputs
return resolvedConfig
}
func resolveUserInteractionStepOutputReference(sourceReference string, allEventSteps []databaseStructs.EventStepInstance) (interface{}, bool) {
sourcePieces := strings.Split(sourceReference, ".")
if len(sourcePieces) < 2 {
return nil, false
}
stepName := sourcePieces[0]
outputName := sourcePieces[1]
for i := 0; i < len(allEventSteps); i++ {
if allEventSteps[i].EventStep.Name != stepName {
continue
}
stepOutputs := allEventSteps[i].Outputs.StructValue()
outputValue, ok := stepOutputs[outputName]
return outputValue, ok
}
return nil, false
}
func resumeWaitingEventStepInstance(eventStepInstanceID int) {
eventStepInstance := databaseStructs.EventStepInstance{}
err := database.DB.Get(&eventStepInstance, `SELECT
@@ -1700,6 +1781,9 @@ func markStepInstanceAsError(eventStepInstance databaseStructs.EventStepInstance
if err != nil {
logging.LogError(err, "Failed to update eventstep instance error status")
}
if err = eventing.RefreshExistingUserInteractionChatMessage(eventStepInstance.ID, eventStepInstance.OperationID); err != nil {
logging.LogError(err, "failed to refresh eventing user interaction chat message after step error")
}
_, err = database.DB.Exec(`UPDATE apitokens SET deleted=true, active=false
WHERE eventstepinstance_id=$1`, eventStepInstance.ID)
if err != nil {
@@ -0,0 +1,52 @@
package rabbitmq
import (
"testing"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
"github.com/its-a-feature/Mythic/eventing"
)
func TestResolveUserInteractionConfigValueSourcesFromStepOutputs(t *testing.T) {
allEventSteps := []databaseStructs.EventStepInstance{
{
Outputs: eventing.GetMythicJSONTextFromStruct(map[string]interface{}{
"default_choice": "bravo",
"choices": []interface{}{"alpha", "bravo"},
}),
EventStep: databaseStructs.EventStep{
Name: "lookup",
},
},
}
config := map[string]interface{}{
"input_required": true,
"inputs": []interface{}{
map[string]interface{}{
"name": "selection",
"type": "ChooseOne",
"default_value_source": map[string]interface{}{
"type": "lookup.default_choice",
"value": "lookup.default_choice",
},
"choices_source": map[string]interface{}{
"type": "lookup.choices",
"value": "lookup.choices",
},
},
},
}
resolvedConfig := resolveUserInteractionConfigValueSources(config, allEventSteps)
inputs := eventing.UserInteractionInputs(resolvedConfig)
if len(inputs) != 1 {
t.Fatalf("expected one input, got %d", len(inputs))
}
if inputs[0]["default_value"] != "bravo" {
t.Fatalf("expected default value to resolve from step output, got %#v", inputs[0]["default_value"])
}
choices := eventing.UserInteractionFieldChoices(inputs[0])
if len(choices) != 2 || choices[0] != "alpha" || choices[1] != "bravo" {
t.Fatalf("expected choices to resolve from step output, got %#v", choices)
}
}
@@ -21,7 +21,7 @@ func init() {
Queue: PT_TASK_CREATE_TASKING_RESPONSE,
RoutingKey: PT_TASK_CREATE_TASKING_RESPONSE,
Handler: processPtTaskCreateMessages,
Scopes: []string{mythicjwt.SCOPE_RESPONSE_WRITE},
Scopes: []string{mythicjwt.SCOPE_TASK_WRITE},
})
}
@@ -17,7 +17,7 @@ func init() {
Queue: PT_TASK_PROCESS_RESPONSE_RESPONSE,
RoutingKey: PT_TASK_PROCESS_RESPONSE_RESPONSE,
Handler: processPtTaskProcessResponseMessages,
Scopes: []string{mythicjwt.SCOPE_RESPONSE_READ},
Scopes: []string{mythicjwt.SCOPE_TASK_WRITE},
})
}
+2 -2
View File
@@ -1015,8 +1015,8 @@ func CheckAndProcessTaskCompletionHandlers(taskId int, authContext RabbitMQAuthC
task.group_callback_function_started,
task.completed_callback_function_completed, task.completed_callback_function_started,
task.subtask_group_name, task.id, task.status, task.eventstepinstance_id,
apitokens.scopes "apitoken.scopes",
apitokens.id "apitoken.id"
COALESCE(apitokens.scopes, ARRAY[]::text[]) "apitoken.scopes",
COALESCE(apitokens.id, 0) "apitoken.id"
FROM task
LEFT JOIN apitokens ON task.apitokens_id = apitokens.id
WHERE task.id=$1`, taskId)
@@ -186,10 +186,10 @@ func GetRabbitMQAuthContextForTaskID(taskID int) (RabbitMQAuthContext, error) {
task := databaseStructs.Task{}
err := database.DB.Get(&task, `SELECT
task.operator_id, task.operation_id, task.eventstepinstance_id, task.apitokens_id,
apitokens.scopes "apitoken.scopes",
apitokens.id "apitoken.id",
apitokens.active "apitoken.active",
apitokens.deleted "apitoken.deleted"
COALESCE(apitokens.scopes, ARRAY[]::text[]) "apitoken.scopes",
COALESCE(apitokens.id, 0) "apitoken.id",
COALESCE(apitokens.active, false) "apitoken.active",
COALESCE(apitokens.deleted, true) "apitoken.deleted"
FROM task
LEFT JOIN apitokens ON task.apitokens_id = apitokens.id
WHERE task.id=$1`, taskID)
@@ -14,6 +14,7 @@ import (
"github.com/its-a-feature/Mythic/authentication/mythicjwt"
"github.com/its-a-feature/Mythic/database"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
"github.com/its-a-feature/Mythic/eventing"
"github.com/its-a-feature/Mythic/logging"
"github.com/its-a-feature/Mythic/rabbitmq"
"github.com/lib/pq"
@@ -102,6 +103,14 @@ type MarkChatRead struct {
LastReadMessageID *int `json:"last_read_message_id"`
}
type RefreshChatSpecialMessageInput struct {
Input RefreshChatSpecialMessage `json:"input" binding:"required"`
}
type RefreshChatSpecialMessage struct {
MessageID int `json:"message_id" binding:"required"`
}
type ChatSearchInput struct {
Input ChatSearch `json:"input" binding:"required"`
}
@@ -558,6 +567,62 @@ func MarkChatReadWebhook(c *gin.Context) {
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ChannelID: channel.ID})
}
func RefreshChatSpecialMessageWebhook(c *gin.Context) {
var input RefreshChatSpecialMessageInput
if !bindChatInput(c, &input) {
return
}
operatorOperation, ok := chatOperatorOperation(c)
if !ok {
return
}
chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to get chat message for refresh")
chatRespondError(c, "failed to find chat message")
return
}
if !chatRequireScope(c, chatScopeForChannel(channel, false)) {
return
}
if chatMessage.Deleted {
chatRespondError(c, "cannot refresh a deleted chat message")
return
}
metadata := chatMessage.Metadata.StructValue()
if metadata["special_type"] != eventing.ChatSpecialTypeEventingUserInteraction {
chatRespondError(c, "chat message is not refreshable")
return
}
eventStepInstanceID, ok := chatMetadataInt(metadata, "eventstepinstance_id")
if !ok || eventStepInstanceID <= 0 {
chatRespondError(c, "chat message is missing its eventstep instance reference")
return
}
message, senderDisplayName, refreshedMetadata, err := eventing.BuildUserInteractionChatMessage(eventStepInstanceID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "Failed to refresh eventing user interaction chat message")
chatRespondError(c, err.Error())
return
}
refreshedMetadataText := eventing.GetMythicJSONTextFromStruct(refreshedMetadata)
_, err = database.DB.Exec(`UPDATE chat_message
SET sender_display_name=$3, message=$4, metadata=$5::jsonb, status=$6, updated_at=now()
WHERE id=$1 AND operation_id=$2`,
chatMessage.ID,
operatorOperation.CurrentOperation.ID,
senderDisplayName,
message,
refreshedMetadataText.String(),
databaseStructs.ChatMessageStatusComplete)
if err != nil {
logging.LogError(err, "Failed to update refreshed chat message")
chatRespondError(c, err.Error())
return
}
c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
}
func ChatSearchWebhook(c *gin.Context) {
var input ChatSearchInput
if !bindChatInput(c, &input) {
@@ -964,6 +1029,23 @@ func chatJSONText(input interface{}) databaseStructs.MythicJSONText {
return rabbitmq.GetMythicJSONTextFromStruct(input)
}
func chatMetadataInt(metadata map[string]interface{}, key string) (int, bool) {
value, ok := metadata[key]
if !ok {
return 0, false
}
switch typedValue := value.(type) {
case int:
return typedValue, true
case int64:
return int(typedValue), true
case float64:
return int(typedValue), true
default:
return 0, false
}
}
func getChatChannelConfig(channel databaseStructs.ChatChannel) map[string]interface{} {
metadata := channel.AIMetadata.StructValue()
for _, key := range []string{"config", "configuration"} {
@@ -172,9 +172,6 @@ func validateEventingStepUserInteractionSubmit(input EventingStepUserInteraction
if eventing.UserInteractionInputRequired(config) {
submittedInputs := normalizeSubmittedUserInteractionInputs(input.Inputs, config)
for _, field := range eventing.UserInteractionInputs(config) {
if !eventing.UserInteractionFieldRequired(field) {
continue
}
fieldName := eventing.UserInteractionFieldName(field)
if fieldName == "" {
continue
+17
View File
@@ -314,9 +314,22 @@ func setRoutes(r *gin.Engine) {
}),
webcontroller.DownloadBulkFilesWebhook)
allOperationMembers.POST("chat_search_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_CHAT_READ,
mythicjwt.SCOPE_CHAT_AI_READ,
}),
webcontroller.ChatSearchWebhook)
allOperationMembers.POST("chat_mark_read_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_CHAT_READ,
mythicjwt.SCOPE_CHAT_AI_READ,
}),
webcontroller.MarkChatReadWebhook)
allOperationMembers.POST("chat_refresh_special_message_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_CHAT_READ,
}),
webcontroller.RefreshChatSpecialMessageWebhook)
allOperationMembers.POST("preview_file_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_FILE_READ,
@@ -360,6 +373,10 @@ func setRoutes(r *gin.Engine) {
webcontroller.ConsumingServicesTestLog)
// chat
noSpectators.POST("chat_create_channel_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_CHAT_WRITE,
mythicjwt.SCOPE_CHAT_AI_WRITE,
}),
webcontroller.CreateChatChannelWebhook)
noSpectators.POST("chat_update_channel_webhook",
webcontroller.UpdateChatChannelWebhook)