mirror of
https://github.com/its-a-feature/Mythic
synced 2026-06-08 14:55:38 +00:00
block list updates, user interact eventing prompts
This commit is contained in:
@@ -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}
|
||||
|
||||
+36
-29
@@ -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;
|
||||
|
||||
@@ -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 + ")"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ type Mutation {
|
||||
): ChatActionOutput
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
chatRefreshSpecialMessage(
|
||||
message_id: Int!
|
||||
): ChatActionOutput
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
chatRetryRequest(
|
||||
request_id: Int!
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"} {
|
||||
|
||||
-3
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user