eventing prompt user input/approval

This commit is contained in:
its-a-feature
2026-05-22 15:06:03 -07:00
parent 7dbecdea37
commit 98472409a2
21 changed files with 1751 additions and 67 deletions
@@ -1259,7 +1259,10 @@ const getTaskWidth = (node) => {
return Math.max(325, (nodeText.length * 8) + 10)
}
const getEventNodeWidth = (node) => {
return Math.max(190, (node.maxNameLength * 8) + 120);
const labelLength = Math.max(node.maxNameLength || 0, String(node?.data?.label || "").length);
const actionLength = String(node?.data?.action || "").length;
const statusLength = String(node?.data?.status || "").replace(/_/g, " ").length;
return Math.min(420, Math.max(260, (labelLength * 7) + 130, (actionLength * 7) + 105, (statusLength * 7) + 105));
}
const getBrowserscriptWidth = (node) => {
let nodeText = " ";
@@ -1282,7 +1285,7 @@ const getHeight = (node) => {
return Math.max(node.height || 0, 132);
}
if(node.type === "eventNode"){
return 88;
return node?.data?.status ? 112 : 96;
}
return 80;
}
@@ -583,6 +583,65 @@ const inputOptionsData = {
description: "this is a completely custom input that's whatever you desire"
}
}
const userInteractionInputTypes = ["string", "number", "boolean", "json"];
const userInteractionBotApprovalApprovers = {
operator: {
label: "Any operator",
description: "Operators and the operation lead can approve when this workflow runs as bot."
},
lead: {
label: "Operation lead",
description: "Only the operation lead can approve when this workflow runs as bot."
},
};
const getDefaultUserInteractionConfig = () => ({
approval_required: false,
approval_prompt: "",
approval_policy: {
bot_context: {
approver: "operator",
},
},
input_required: false,
input_prompt: "",
inputs: [],
});
const normalizeUserInteractionConfig = (config) => {
const normalizedConfig = {...getDefaultUserInteractionConfig(), ...(config || {})};
const approvalPolicy = normalizedConfig.approval_policy && typeof normalizedConfig.approval_policy === "object" ? normalizedConfig.approval_policy : {};
const botContext = approvalPolicy.bot_context && typeof approvalPolicy.bot_context === "object" ? approvalPolicy.bot_context : {};
normalizedConfig.approval_policy = {
bot_context: {
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 ? "" : (
typeof input.default_value === "string" ? input.default_value : JSON.stringify(input.default_value)
),
}));
return normalizedConfig;
}
const hasUserInteractionConfig = (config) => {
const normalizedConfig = normalizeUserInteractionConfig(config);
return Boolean(normalizedConfig.approval_required || normalizedConfig.input_required || normalizedConfig.inputs.length > 0);
}
const outputOptionsData = {
payload_create: {
output_fields: payloadFields,
@@ -2468,6 +2527,178 @@ const actionOptionsData = {
}
}
const EventingStepUserInteraction = ({config, onChange}) => {
const userInteraction = normalizeUserInteractionConfig(config);
const updateUserInteraction = (updates) => {
onChange({...userInteraction, ...updates});
}
const updateInputField = (index, fieldName, value) => {
const nextInputs = userInteraction.inputs.map((input, i) => {
if(i !== index){
return {...input};
}
return {...input, [fieldName]: value};
});
updateUserInteraction({inputs: nextInputs});
}
const addInputField = () => {
updateUserInteraction({
input_required: true,
inputs: [...userInteraction.inputs, {
name: "",
type: "string",
required: true,
description: "",
default_value: "",
}],
});
}
const removeInputField = (index) => {
const nextInputs = [...userInteraction.inputs];
nextInputs.splice(index, 1);
updateUserInteraction({inputs: nextInputs});
}
return (
<div className="mythic-eventing-step-dynamic-section mythic-eventing-step-user-interaction-section">
<div className="mythic-eventing-step-switch-stack">
<label className="mythic-eventing-step-switch-row">
<span className="mythic-eventing-step-switch-copy">
<span className="mythic-eventing-step-switch-title">Require approval</span>
<span className="mythic-eventing-step-switch-subtitle">Pause before this step runs until an operator approves it.</span>
</span>
<Switch
checked={userInteraction.approval_required}
onChange={(event) => updateUserInteraction({approval_required: event.target.checked})}
color={"info"}
inputProps={{ 'aria-label': 'require approval' }}
/>
</label>
{userInteraction.approval_required &&
<MythicTextField
placeholder="Prompt shown to the approving user..."
onChange={(name, value) => updateUserInteraction({approval_prompt: value})}
value={userInteraction.approval_prompt}
marginBottom="0px"
/>
}
{userInteraction.approval_required &&
<div className="mythic-eventing-step-approval-policy-row">
<TextField
label="Bot approval role"
select
size="small"
value={userInteraction.approval_policy.bot_context.approver}
onChange={(event) => updateUserInteraction({
approval_policy: {
...userInteraction.approval_policy,
bot_context: {
...userInteraction.approval_policy.bot_context,
approver: event.target.value,
},
},
})}
fullWidth
>
{Object.entries(userInteractionBotApprovalApprovers).map(([value, data]) => (
<MenuItem key={`user-interaction-approver-${value}`} value={value}>{data.label}</MenuItem>
))}
</TextField>
<div className="mythic-eventing-step-helper-text">
{userInteractionBotApprovalApprovers[userInteraction.approval_policy.bot_context.approver]?.description}
</div>
</div>
}
<label className="mythic-eventing-step-switch-row">
<span className="mythic-eventing-step-switch-copy">
<span className="mythic-eventing-step-switch-title">Require user input</span>
<span className="mythic-eventing-step-switch-subtitle">Collect key/value data and merge it into this step's runtime inputs.</span>
</span>
<Switch
checked={userInteraction.input_required || userInteraction.inputs.length > 0}
onChange={(event) => updateUserInteraction(event.target.checked ? {input_required: true} : {input_required: false, inputs: []})}
color={"info"}
inputProps={{ 'aria-label': 'require user input' }}
/>
</label>
{(userInteraction.input_required || userInteraction.inputs.length > 0) &&
<MythicTextField
placeholder="Prompt shown with the input fields..."
onChange={(name, value) => updateUserInteraction({input_prompt: value})}
value={userInteraction.input_prompt}
marginBottom="0px"
/>
}
</div>
{(userInteraction.input_required || userInteraction.inputs.length > 0) &&
<div className="mythic-eventing-step-list">
{userInteraction.inputs.length === 0 &&
<EventingStepEmptyInline>No input fields configured.</EventingStepEmptyInline>
}
{userInteraction.inputs.map((input, index) => (
<div className="mythic-eventing-step-list-item mythic-eventing-step-list-item-editable mythic-eventing-user-input-field-row" key={`user-interaction-input-${index}`}>
<IconButton className="mythic-table-row-icon-action mythic-table-row-icon-action-hover-danger" size="small" onClick={() => removeInputField(index)}>
<DeleteIcon fontSize="small" />
</IconButton>
<div className="mythic-eventing-step-list-content">
<div className="mythic-eventing-step-field-grid">
<MythicTextField
placeholder="Input name"
onChange={(name, value) => updateInputField(index, "name", value)}
value={input.name}
marginBottom="0px"
/>
<FormControl sx={{ display: "inline-block", width: "100%" }} size="small">
<TextField
label="Type"
select
size="small"
style={{ width: "100%" }}
value={input.type}
onChange={(event) => updateInputField(index, "type", event.target.value)}
>
{userInteractionInputTypes.map((type) => (
<MenuItem key={`user-interaction-type-${type}`} value={type}>{type}</MenuItem>
))}
</TextField>
</FormControl>
<MythicTextField
placeholder="Description"
onChange={(name, value) => updateInputField(index, "description", value)}
value={input.description}
marginBottom="0px"
/>
<MythicTextField
placeholder="Default value"
onChange={(name, value) => updateInputField(index, "default_value", value)}
value={input.default_value}
marginBottom="0px"
/>
</div>
<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>
<span className="mythic-eventing-step-switch-subtitle">User must supply this value before the step resumes.</span>
</span>
<Switch
checked={input.required}
onChange={(event) => updateInputField(index, "required", event.target.checked)}
color={"info"}
inputProps={{ 'aria-label': 'required user input' }}
/>
</label>
</div>
</div>
))}
</div>
}
{(userInteraction.input_required || userInteraction.inputs.length > 0) &&
<Button className="mythic-table-row-action mythic-table-row-action-hover-success" onClick={addInputField} variant="outlined" startIcon={<AddCircleIcon fontSize="small" />}>
Add input field
</Button>
}
</div>
)
}
const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1Data}) => {
const [name, setName] = React.useState(step.name);
const [description, setDescription] = React.useState(step.description);
@@ -2476,6 +2707,7 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
const [dependsOn, setDependsOn] = React.useState([]);
const [updatedActionOptions, setUpdatedActionOptions] = React.useState([]);
const [continueOnError, setContinueOnError] = React.useState(step.continue_on_error || false);
const [userInteraction, setUserInteraction] = React.useState(normalizeUserInteractionConfig(step.user_interaction));
const [localInputOptions, setLocalInputOptions] = React.useState([]);
const [inputEditorData, setInputEditorData] = React.useState(null);
@@ -2533,6 +2765,11 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
setContinueOnError(evt.target.checked)
updateStep(index, "continue_on_error", evt.target.checked);
}
const onChangeUserInteraction = (nextConfig) => {
const normalizedConfig = normalizeUserInteractionConfig(nextConfig);
setUserInteraction(normalizedConfig);
updateStep(index, "user_interaction", normalizedConfig);
}
const updateStepInputs = React.useCallback((nextInputs) => {
setInputEditorData(nextInputs);
updateStep(index, "inputs", nextInputs);
@@ -2577,6 +2814,9 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
</div>
<div className="mythic-eventing-step-config-summary-actions">
<span className="mythic-eventing-step-action-chip">{selectedAction}</span>
{hasUserInteractionConfig(userInteraction) &&
<span className="mythic-eventing-step-action-chip mythic-eventing-step-action-chip-warning">user interaction</span>
}
<label className="mythic-eventing-step-switch-row">
<span className="mythic-eventing-step-switch-copy">
<span className="mythic-eventing-step-switch-title">Continue on error</span>
@@ -2651,6 +2891,13 @@ const EventingStep = ({step, allSteps, updateStep, index, step1Data, updateStep1
prevData={step?.outputs || []}/>
</EventingStepConfigSection>
</div>
<EventingStepConfigSection
className="mythic-eventing-step-config-section-wide"
title="User interaction"
description="Pause before execution to request approval, runtime input, or both."
>
<EventingStepUserInteraction config={userInteraction} onChange={onChangeUserInteraction} />
</EventingStepConfigSection>
<EventingStepConfigSection
className="mythic-eventing-step-config-section-wide"
title="Action data"
@@ -2742,6 +2989,7 @@ const CreateEventingStep2 = ({finished, back, first, last, cancel, prevData, ste
outputs: [],
depends_on: [],
environment: {},
user_interaction: getDefaultUserInteractionConfig(),
continue_on_error: false
}]);
}
@@ -2928,6 +3176,9 @@ const CreateEventingStep3 = ({finished, back, first, last, cancel, prevData, ste
inputs: {},
outputs: {},
};
if(hasUserInteractionConfig(step2Data[i].user_interaction)){
stepData.user_interaction = normalizeUserInteractionConfig(step2Data[i].user_interaction);
}
for(let j = 0; j < step2Data[i].inputs.length; j++){
if(step2Data[i].inputs[j].type === "custom"){
stepData.inputs[step2Data[i].inputs[j].name] = step2Data[i].inputs[j].value;
@@ -1,5 +1,10 @@
import React, {} from 'react';
import {EventingStatusChip, EventStepInstanceRenderDialog} from "./EventStepRender";
import {
EventingStatusChip,
EventStepInstanceRenderDialog,
EventStepUserInteractionDialog,
eventingUserInteractionStatuses
} from "./EventStepRender";
import {toLocalTime} from "../../utilities/Time";
import {MythicConfirmDialog} from '../../MythicComponents/MythicConfirmDialog';
import { gql, useMutation } from '@apollo/client';
@@ -16,6 +21,7 @@ import {snackActions} from "../../utilities/Snackbar";
import {MythicDialog} from "../../MythicComponents/MythicDialog";
import OpenInNewTwoToneIcon from '@mui/icons-material/OpenInNewTwoTone';
import IosShareIcon from '@mui/icons-material/IosShare';
import RuleTwoToneIcon from '@mui/icons-material/RuleTwoTone';
import {copyStringToClipboard} from "../../utilities/Clipboard";
import {ipCompare} from "../Callbacks/CallbacksTable";
import MythicResizableGrid from "../../MythicComponents/MythicResizableGrid";
@@ -115,6 +121,7 @@ function EventGroupInstancesTableMaterialReactTablePreMemo({eventgroups, me, set
const [openRetryDialog, setOpenRetryDialog] = React.useState(false);
const [openRunAgainDialog, setOpenRunAgainDialog] = React.useState(false);
const [openEventStepRender, setOpenEventStepRender] = React.useState(false);
const [openUserInteractionDialog, setOpenUserInteractionDialog] = React.useState(false);
const [openInstanceDropdown, setOpenInstanceDropdown] = React.useState(false);
const instanceDropdownRef = React.useRef({options: [], anchor: null});
const selectedLocalInstanceID = React.useRef(0);
@@ -182,6 +189,11 @@ function EventGroupInstancesTableMaterialReactTablePreMemo({eventgroups, me, set
selectedLocalEventGroup.current = e.eventgroup;
setOpenEventStepRender(true);
}
const openUserInteractionResponseDialog = (e) => {
selectedLocalInstanceID.current = e.id;
selectedLocalEventGroup.current = e.eventgroup;
setOpenUserInteractionDialog(true);
}
const onSaveToClipboard = (e) => {
let path = window.location.origin;
path += `/new/eventing?eventgroup=${e.eventgroup.id}&eventgroupinstance=${e.id}`;
@@ -231,6 +243,17 @@ function EventGroupInstancesTableMaterialReactTablePreMemo({eventgroups, me, set
});
}
return [
...(eventingUserInteractionStatuses.includes(row.status) ? [{
name: "Respond to approval / input",
type: "item",
icon: <RuleTwoToneIcon style={eventingInstanceMenuIconStyle} />,
className: "mythic-menu-item-hover-success",
click: ({event}) => {
event?.preventDefault();
event?.stopPropagation();
openUserInteractionResponseDialog(row);
},
}] : []),
{
name: "Open graph in modal",
type: "item",
@@ -574,6 +597,18 @@ function EventGroupInstancesTableMaterialReactTablePreMemo({eventgroups, me, set
/>}
/>
}
{openUserInteractionDialog &&
<MythicDialog fullWidth={true} maxWidth="md" open={openUserInteractionDialog}
onClose={() => {
setOpenUserInteractionDialog(false);
}}
innerDialog={
<EventStepUserInteractionDialog
onClose={() => { setOpenUserInteractionDialog(false); }}
selectedEventGroupInstance={selectedLocalInstanceID.current}
/>}
/>
}
</div>
)
@@ -1,6 +1,6 @@
import React, {useCallback} from 'react';
import {createPortal} from 'react-dom';
import {gql, useQuery, useSubscription, useMutation, useLazyQuery} from '@apollo/client';
import {gql, useQuery, useSubscription, useMutation, useLazyQuery, useReactiveVar} from '@apollo/client';
import Typography from '@mui/material/Typography';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
@@ -60,6 +60,8 @@ import {getStringSize} from "../Callbacks/ResponseDisplayTable";
import {MythicPageHeader} from "../../MythicComponents/MythicPageHeader";
import {MythicEmptyState, MythicErrorState, MythicLoadingState} from "../../MythicComponents/MythicStateDisplay";
import {MythicClientSideTablePagination, useMythicClientPagination} from "../../MythicComponents/MythicTablePagination";
import TextField from '@mui/material/TextField';
import {meState} from "../../../cache";
const getEventSteps = gql`
@@ -74,6 +76,7 @@ query GetEventSteps($eventgroup_id: Int!) {
action_data
inputs
outputs
user_interaction
order
}
}
@@ -89,6 +92,14 @@ subscription GetEventStepInstances($eventgroupinstance_id: Int!) {
depends_on
order
action
user_interaction
}
eventgroupinstance {
operator {
id
username
account_type
}
}
eventgroupinstance_id
created_at
@@ -96,6 +107,10 @@ subscription GetEventStepInstances($eventgroupinstance_id: Int!) {
end_timestamp
status
order
user_interaction
user_interaction_response
user_interaction_resolved_by
user_interaction_resolved_at
}
}
`;
@@ -105,6 +120,7 @@ query getEventStepInstanceDetails($eventstepinstance_id: Int!){
environment
inputs
outputs
user_interaction
id
status
updated_at
@@ -112,6 +128,9 @@ query getEventStepInstanceDetails($eventstepinstance_id: Int!){
order
end_timestamp
action_data
user_interaction_response
user_interaction_resolved_by
user_interaction_resolved_at
stdout
stderr
eventstep {
@@ -121,6 +140,7 @@ query getEventStepInstanceDetails($eventstepinstance_id: Int!){
name
inputs
outputs
user_interaction
environment
depends_on
}
@@ -275,6 +295,7 @@ query getEventStepInformation($eventstep_id: Int!){
action_data
inputs
outputs
user_interaction
order
}
}
@@ -287,6 +308,47 @@ mutation retryFromEventStep($eventstepinstance_id: Int!, $retry_all_groups: Bool
}
}
`;
const submitEventStepUserInteractionMutation = gql`
mutation submitEventStepUserInteraction($eventstepinstance_id: Int!, $approved: Boolean, $inputs: jsonb, $comment: String) {
eventingStepUserInteractionSubmit(eventstepinstance_id: $eventstepinstance_id, approved: $approved, inputs: $inputs, comment: $comment) {
status
error
}
}
`;
const getEventStepInstancesForUserInteraction = gql`
query getEventStepInstancesForUserInteraction($eventgroupinstance_id: Int!, $statuses: [String!]) {
eventstepinstance(where: {eventgroupinstance_id: {_eq: $eventgroupinstance_id}, status: {_in: $statuses}}, order_by: {order: asc}) {
id
eventstep {
name
id
description
depends_on
order
action
user_interaction
}
eventgroupinstance {
operator {
id
username
account_type
}
}
eventgroupinstance_id
created_at
updated_at
end_timestamp
status
order
user_interaction
user_interaction_response
user_interaction_resolved_by
user_interaction_resolved_at
}
}
`;
export const GetStatusSymbol = ({data}) => {
let style = {marginRight: "5px"};
@@ -311,6 +373,14 @@ export const GetStatusSymbol = ({data}) => {
return (<MythicStyledTooltip title={"Skipped"}>
<HideSourceIcon color={"info"} style={{...style}}/>
</MythicStyledTooltip>)
case "awaiting_approval":
return (<MythicStyledTooltip title={"Awaiting approval"}>
<TimelapseIcon color={"warning"} style={{...style}}/>
</MythicStyledTooltip>)
case "input_needed":
return (<MythicStyledTooltip title={"Input needed"}>
<InfoIconOutline color={"warning"} style={{...style}}/>
</MythicStyledTooltip>)
default:
return (<MythicStyledTooltip title={"Waiting..."}>
<PanoramaFishEyeIcon style={{...style}}/>
@@ -376,6 +446,10 @@ const getStatusLabel = (status) => {
return "Cancelled";
case "skipped":
return "Skipped";
case "awaiting_approval":
return "Awaiting approval";
case "input_needed":
return "Input needed";
case "queued":
return "Queued";
default:
@@ -394,6 +468,10 @@ const getStatusClass = (status) => {
return "cancelled";
case "skipped":
return "skipped";
case "awaiting_approval":
return "awaiting_approval";
case "input_needed":
return "input_needed";
default:
return "waiting";
}
@@ -472,6 +550,375 @@ const EventingCodeBlock = ({value, emptyText="No data"}) => {
</div>
)
}
export const eventingUserInteractionStatuses = ["awaiting_approval", "input_needed"];
export const eventStepNeedsUserInteraction = (step) => eventingUserInteractionStatuses.includes(step?.status);
const getUserInteractionConfig = (step) => {
const config = step?.user_interaction || step?.eventstep?.user_interaction || {};
return config && typeof config === "object" ? config : {};
}
const normalizeUserInteractionInputs = (config) => {
const inputs = config?.inputs || [];
if(Array.isArray(inputs)){
return inputs.filter((input) => input && typeof input === "object").map((input) => ({...input}));
}
if(inputs && typeof inputs === "object"){
return Object.entries(inputs).map(([name, value]) => {
if(value && typeof value === "object" && !Array.isArray(value)){
return {name, ...value};
}
return {name, default_value: value};
});
}
return [];
}
const getInteractionInputDefault = (field) => {
const defaultValue = field?.default_value;
if(defaultValue === undefined || defaultValue === null){
return "";
}
if(typeof defaultValue === "string"){
return defaultValue;
}
return JSON.stringify(defaultValue);
}
const coerceUserInteractionInputs = (fields, values) => {
const coercedValues = {};
for(const field of fields){
const name = field?.name || "";
if(name === ""){
continue;
}
const value = values[name];
const type = (field?.type || "string").toLowerCase();
if(value === undefined || value === null || value === ""){
coercedValues[name] = value || "";
continue;
}
if(type === "number"){
const numberValue = Number(value);
if(Number.isNaN(numberValue)){
throw new Error(`${name} must be a number`);
}
coercedValues[name] = numberValue;
}else if(type === "boolean"){
coercedValues[name] = value === true || String(value).toLowerCase() === "true";
}else if(type === "json"){
coercedValues[name] = JSON.parse(value);
}else{
coercedValues[name] = value;
}
}
return coercedValues;
}
const getUserInteractionStepName = (step) => step?.eventstep?.name || step?.name || `Step ${step?.id || ""}`.trim();
const getUserInteractionStepAction = (step) => step?.eventstep?.action || step?.action || "";
const getUserInteractionPrompt = (config, key, fallback) => {
const value = config?.[key];
return typeof value === "string" && value.trim().length > 0 ? value : fallback;
}
const getUserInteractionBotApprovalApprover = (config) => {
const approver = config?.approval_policy?.bot_context?.approver;
return approver === "lead" ? "lead" : "operator";
}
const getNextUserInteractionStepId = (steps, currentStepId) => {
if(steps.length === 0){
return 0;
}
const currentIndex = steps.findIndex((step) => `${step.id}` === `${currentStepId}`);
if(currentIndex === -1 || currentIndex >= steps.length - 1){
return steps[0].id;
}
return steps[currentIndex + 1].id;
}
export const EventStepUserInteractionDialog = ({onClose, selectedEventGroupInstance, selectedStep, steps}) => {
const me = useReactiveVar(meState);
const [resolvedStepIds, setResolvedStepIds] = React.useState([]);
const [selectedStepId, setSelectedStepId] = React.useState(selectedStep?.id || 0);
const shouldFetchSteps = !steps && selectedEventGroupInstance > 0;
const {data, loading, refetch} = useQuery(getEventStepInstancesForUserInteraction, {
variables: {eventgroupinstance_id: selectedEventGroupInstance, statuses: eventingUserInteractionStatuses},
fetchPolicy: "no-cache",
skip: !shouldFetchSteps,
});
const sourceSteps = React.useMemo(() => {
if(steps){
return steps;
}
if(data?.eventstepinstance){
return data.eventstepinstance;
}
if(selectedStep){
return [selectedStep];
}
return [];
}, [steps, data?.eventstepinstance, selectedStep]);
const waitingSteps = React.useMemo(() => {
return sourceSteps.filter((step) => eventStepNeedsUserInteraction(step) && !resolvedStepIds.includes(step.id));
}, [sourceSteps, resolvedStepIds]);
React.useEffect(() => {
if(selectedStep?.id && waitingSteps.some((step) => `${step.id}` === `${selectedStep.id}`)){
setSelectedStepId(selectedStep.id);
return;
}
if(waitingSteps.length > 0 && !waitingSteps.some((step) => `${step.id}` === `${selectedStepId}`)){
setSelectedStepId(waitingSteps[0].id);
}
}, [selectedStep?.id, waitingSteps, selectedStepId]);
const activeStep = waitingSteps.find((step) => `${step.id}` === `${selectedStepId}`) || waitingSteps[0];
const config = React.useMemo(() => getUserInteractionConfig(activeStep), [activeStep]);
const inputFields = React.useMemo(() => normalizeUserInteractionInputs(config), [config]);
const approvalRequired = Boolean(config?.approval_required);
const inputRequired = Boolean(config?.input_required) || inputFields.length > 0;
const [inputValues, setInputValues] = React.useState({});
const [comment, setComment] = React.useState("");
const [submitInteraction] = useMutation(submitEventStepUserInteractionMutation, {
onCompleted: (data) => {
if(data.eventingStepUserInteractionSubmit.status === "success"){
snackActions.success("Submitted eventing step response");
}else{
snackActions.error(data.eventingStepUserInteractionSubmit.error);
}
},
onError: (data) => {
console.log(data);
snackActions.error("Failed to submit eventing step response");
}
});
React.useEffect(() => {
const nextValues = inputFields.reduce((prev, field) => {
if(!field?.name){
return prev;
}
return {...prev, [field.name]: getInteractionInputDefault(field)};
}, {});
setInputValues(nextValues);
setComment("");
}, [activeStep?.id, inputFields]);
if(loading && waitingSteps.length === 0){
return (
<>
<EventingDialogTitle title="User Interaction" subtitle="Loading waiting eventing steps..." />
<DialogContent dividers={true} className="mythic-eventing-user-interaction-dialog-content">
<MythicLoadingState compact title="Loading interaction request" description="Fetching waiting step details." minHeight={180} />
</DialogContent>
<DialogActions className="mythic-eventing-detail-dialog-actions">
<Button className="mythic-table-row-action" onClick={onClose} variant="outlined">Close</Button>
</DialogActions>
</>
)
}
if(waitingSteps.length === 0 || !activeStep){
return (
<>
<EventingDialogTitle title="User Interaction" subtitle="No eventing steps are currently waiting for approval or input." />
<DialogContent dividers={true} className="mythic-eventing-user-interaction-dialog-content">
<MythicEmptyState compact title="Nothing waiting" description="This workflow instance no longer has a step waiting for user interaction." minHeight={180} />
</DialogContent>
<DialogActions className="mythic-eventing-detail-dialog-actions">
<Button className="mythic-table-row-action" onClick={onClose} variant="outlined">Close</Button>
</DialogActions>
</>
)
}
const runOperator = activeStep?.eventgroupinstance?.operator;
const currentUserID = me?.user?.id || me?.user?.user_id;
const botApprovalApprover = getUserInteractionBotApprovalApprover(config);
const currentUserViewMode = me?.user?.view_mode || "";
const currentUserIsLead = currentUserViewMode === "lead";
const currentUserCanRespondToInteraction = currentUserIsLead || currentUserViewMode === "operator";
const canRespond = currentUserCanRespondToInteraction && (!runOperator ||
(runOperator?.account_type === "bot" && (
!approvalRequired ||
(botApprovalApprover === "lead" ? currentUserIsLead : currentUserCanRespondToInteraction)
)) ||
`${runOperator?.id}` === `${currentUserID}`);
const submitResponse = (approved) => {
let coercedInputs = {};
try{
const shouldSubmitInputs = inputRequired && (!approvalRequired || approved);
if(shouldSubmitInputs){
for(const field of inputFields){
const name = field?.name || "";
if(!name || !field?.required){
continue;
}
const value = inputValues[name];
if(value === undefined || value === null || value === ""){
snackActions.error(`Missing required input ${name}`);
return;
}
}
}
coercedInputs = shouldSubmitInputs ? coerceUserInteractionInputs(inputFields, inputValues) : {};
}catch(error){
snackActions.error(error.message);
return;
}
submitInteraction({
variables: {
eventstepinstance_id: activeStep.id,
approved: approvalRequired ? approved : null,
inputs: coercedInputs,
comment,
}
}).then(async (result) => {
if(result?.data?.eventingStepUserInteractionSubmit?.status !== "success"){
return;
}
const remainingSteps = waitingSteps.filter((step) => step.id !== activeStep.id);
setResolvedStepIds((prev) => [...prev, activeStep.id]);
if(refetch){
await refetch();
}
if(remainingSteps.length === 0){
onClose?.();
}else{
setSelectedStepId(getNextUserInteractionStepId(remainingSteps, activeStep.id));
}
});
}
const approvalPrompt = getUserInteractionPrompt(config, "approval_prompt", "Approval is required before this step can execute.");
const inputPrompt = getUserInteractionPrompt(config, "input_prompt", "Additional input is required before this step can execute.");
const stepName = getUserInteractionStepName(activeStep);
const stepAction = getUserInteractionStepAction(activeStep);
return (
<>
<EventingDialogTitle
title="User Interaction"
subtitle={`${waitingSteps.length} waiting step${waitingSteps.length === 1 ? "" : "s"} in this workflow instance`}
statusData={activeStep}
/>
<DialogContent dividers={true} className="mythic-eventing-user-interaction-dialog-content">
<div className={`mythic-eventing-user-interaction-modal ${waitingSteps.length === 1 ? "mythic-eventing-user-interaction-modal-single" : ""}`.trim()}>
{waitingSteps.length > 1 &&
<div className="mythic-eventing-user-interaction-step-picker">
<div className="mythic-eventing-user-interaction-section-label">Waiting steps</div>
{waitingSteps.map((step) => (
<button
className={`mythic-eventing-user-interaction-step-option ${`${step.id}` === `${activeStep.id}` ? "mythic-eventing-user-interaction-step-option-active" : ""}`.trim()}
key={`user-interaction-step-${step.id}`}
onClick={() => setSelectedStepId(step.id)}
type="button"
>
<span className="mythic-eventing-user-interaction-step-name">{getUserInteractionStepName(step)}</span>
<span className="mythic-eventing-user-interaction-step-meta">{getStatusLabel(step.status)}{getUserInteractionStepAction(step) ? ` - ${getUserInteractionStepAction(step)}` : ""}</span>
</button>
))}
</div>
}
<div className="mythic-eventing-user-interaction-workspace">
<div className="mythic-eventing-user-interaction-step-summary">
<div>
<div className="mythic-eventing-user-interaction-section-label">Step</div>
<div className="mythic-eventing-user-interaction-title">{stepName}</div>
</div>
<div className="mythic-eventing-user-interaction-summary-chips">
<EventingStatusChip data={activeStep} />
{stepAction &&
<span className="mythic-eventing-flow-node-action">{stepAction}</span>
}
</div>
</div>
{approvalRequired &&
<div className="mythic-eventing-user-interaction-section">
<div className="mythic-eventing-user-interaction-section-title">Approval</div>
<div className="mythic-eventing-user-interaction-prompt">{approvalPrompt}</div>
{runOperator?.account_type === "bot" &&
<div className="mythic-eventing-user-interaction-policy-note">
{botApprovalApprover === "lead" ? "Requires operation lead approval." : "Operators and the operation lead can approve because this workflow is running as bot."}
</div>
}
</div>
}
{inputRequired &&
<div className="mythic-eventing-user-interaction-section">
<div className="mythic-eventing-user-interaction-section-title">Inputs</div>
<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"}
</div>
</div>
<TextField
size="small"
value={inputValues[field.name] || ""}
onChange={(event) => setInputValues({...inputValues, [field.name]: event.target.value})}
fullWidth
/>
</div>
))}
</div>
}
</div>
}
<div className="mythic-eventing-user-interaction-section">
<div className="mythic-eventing-user-interaction-section-title">Comment</div>
<TextField
size="small"
placeholder="Optional note for this response"
value={comment}
onChange={(event) => setComment(event.target.value)}
fullWidth
/>
<div className="mythic-eventing-user-interaction-permission">
{!currentUserCanRespondToInteraction ? (
"Spectators cannot respond to user interaction steps."
) : !runOperator ? (
"Permission will be checked when this response is submitted."
) : canRespond ? (
runOperator?.account_type === "bot" ? (
approvalRequired && botApprovalApprover === "lead" ? "You can respond because you are the operation lead." : (
approvalRequired ? "You can respond because your operation role can approve this step." : "Operators and the operation lead can respond because this workflow is running as bot."
)
) : `Waiting for ${runOperator?.username || "the run operator"}.`
) : (
runOperator?.account_type === "bot" && approvalRequired ? (
botApprovalApprover === "lead" ? "Only the operation lead can approve this step." : "Only operators or the operation lead can approve this step."
) : `Only ${runOperator?.username || "the run operator"} can respond.`
)}
</div>
</div>
</div>
</div>
</DialogContent>
<DialogActions className="mythic-eventing-detail-dialog-actions">
{approvalRequired &&
<Button
className="mythic-table-row-action mythic-table-row-action-hover-warning"
disabled={!canRespond}
onClick={() => submitResponse(false)}
size="small"
variant="outlined"
>
Deny
</Button>
}
<Button className="mythic-table-row-action" onClick={onClose} size="small" variant="outlined">
Close
</Button>
<Button
className="mythic-table-row-action mythic-table-row-action-hover-success"
disabled={!canRespond}
onClick={() => submitResponse(true)}
size="small"
variant="outlined"
>
{approvalRequired ? (inputRequired ? "Approve and Submit" : "Approve") : "Submit Input"}
</Button>
</DialogActions>
</>
)
}
const EventingDetailSection = ({title, subtitle, count, actions, children, className = "", collapsible = false, defaultExpanded = false}) => {
const [expanded, setExpanded] = React.useState(defaultExpanded);
const isExpanded = collapsible ? expanded : true;
@@ -590,18 +1037,18 @@ function EventNode({data}) {
<>
<Handle type={"source"} position={sourcePosition}/>
<div className={`mythic-eventing-flow-node mythic-eventing-flow-node-${getEventingStatusClass(data?.status)}`.trim()}>
<Typography className="mythic-eventing-flow-node-title" title={data.name}>{data.name}</Typography>
<div className="mythic-eventing-flow-node-main">
<EventingStatusChip data={data}/>
{data.status &&
<GetTimeDuration data={data} />
}
</div>
<Typography className="mythic-eventing-flow-node-title">{data.name}</Typography>
<div className="mythic-eventing-flow-node-meta">
{data.action &&
<span className="mythic-eventing-flow-node-action">{data.action}</span>
<span className="mythic-eventing-flow-node-action" title={data.action}>{data.action}</span>
}
</div>
{data.status &&
<div className="mythic-eventing-flow-node-meta">
<GetTimeDuration data={data} customStyle={{float: "none", fontSize: "unset"}} />
</div>
}
</div>
<Handle type={"target"} position={targetPosition}/>
</>
@@ -897,8 +1344,10 @@ function EventStepInstanceRender({selectedEventGroupInstance}) {
const [nodes, setNodes] = React.useState([]);
const [edges, setEdges] = React.useState([]);
const currentEventStepInstance = React.useRef(0);
const selectedUserInteractionStep = React.useRef(null);
const [openEventStepInstanceDetails, setOpenEventStepInstanceDetails] = React.useState(false);
const [openEventGroupInstanceDetails, setOpenEventGroupInstanceDetails] = React.useState(false);
const [openUserInteractionDialog, setOpenUserInteractionDialog] = React.useState(false);
const [graphData, setGraphData] = React.useState({nodes: [], edges: [], groups: []});
const {fitView} = useReactFlow()
const updateNodeInternals = useUpdateNodeInternals();
@@ -954,6 +1403,17 @@ function EventStepInstanceRender({selectedEventGroupInstance}) {
}
});
const contextMenu = React.useMemo(() => {return [
{
title: 'Respond to approval / input',
className: 'mythic-table-row-action-hover-success',
shouldShow: function(node) {
return eventStepNeedsUserInteraction(node);
},
onClick: function(node) {
selectedUserInteractionStep.current = node;
setOpenUserInteractionDialog(true);
}
},
{
title: 'View Details',
onClick: function(node) {
@@ -1164,8 +1624,8 @@ function EventStepInstanceRender({selectedEventGroupInstance}) {
</ReactFlow>
{openContextMenu && typeof document !== "undefined" && createPortal(
<div style={{...contextMenuCoord, position: "fixed"}} className="context-menu mythic-graph-context-menu">
{contextMenu.map( (m) => (
<Button key={m.title} className="context-menu-button mythic-graph-context-menu-button mythic-table-row-action mythic-table-row-action-hover-info" variant="outlined" onClick={() => {
{contextMenu.filter((m) => !m.shouldShow || m.shouldShow(contextMenuNode.current)).map( (m) => (
<Button key={m.title} className={`context-menu-button mythic-graph-context-menu-button mythic-table-row-action ${m.className || "mythic-table-row-action-hover-info"}`.trim()} variant="outlined" onClick={() => {
m.onClick(contextMenuNode.current);
setOpenContextMenu(false);
}}>{m.title}</Button>
@@ -1184,6 +1644,20 @@ function EventStepInstanceRender({selectedEventGroupInstance}) {
/>}
/>
}
{openUserInteractionDialog &&
<MythicDialog fullWidth={true} maxWidth="md" open={openUserInteractionDialog}
onClose={() => {
setOpenUserInteractionDialog(false);
}}
innerDialog={
<EventStepUserInteractionDialog
onClose={() => { setOpenUserInteractionDialog(false); }}
selectedEventGroupInstance={selectedEventGroupInstance}
selectedStep={selectedUserInteractionStep.current}
steps={steps}
/>}
/>
}
{openEventGroupInstanceDetails &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openEventGroupInstanceDetails}
onClose={() => {
@@ -1319,6 +1793,7 @@ function EventStepInstanceDetailDialog({selectedEventStepInstance, onClose}) {
<EventingMetadataPair label="Inputs" original={stepDefinition.inputs} instance={stepInstance.inputs} />
<EventingMetadataPair label="Outputs" original={stepDefinition.outputs} instance={stepInstance.outputs} />
<EventingMetadataPair label="Action data" original={stepDefinition.action_data} instance={stepInstance.action_data} />
<EventingMetadataPair label="User interaction" original={stepDefinition.user_interaction} instance={stepInstance.user_interaction_response} originalLabel="Configured" instanceLabel="Response" />
</div>
</AccordionDetails>
</Accordion>
@@ -1543,6 +2018,10 @@ function EventStepDetailDialog({selectedEventStep, onClose}) {
<div className="mythic-eventing-metadata-panel-title">Action data</div>
<EventingCodeBlock value={stepDefinition.action_data} />
</div>
<div className="mythic-eventing-metadata-panel">
<div className="mythic-eventing-metadata-panel-title">User interaction</div>
<EventingCodeBlock value={stepDefinition.user_interaction} />
</div>
</div>
</EventingDetailSection>
</DialogContent>
+262 -9
View File
@@ -8923,6 +8923,17 @@ tspan {
min-height: 1.65rem;
padding: 0 0.55rem;
}
.mythic-eventing-step-action-chip-warning {
background-color: var(--mythic-global-046);
border-color: var(--mythic-global-104);
color: var(--mythic-global-038);
}
.mythic-eventing-step-switch-stack {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.mythic-eventing-step-switch-row {
align-items: center;
background-color: var(--mythic-global-231);
@@ -8934,6 +8945,11 @@ tspan {
min-width: 16rem;
padding: 0.3rem 0.35rem 0.3rem 0.6rem;
}
.mythic-eventing-step-switch-row-compact {
margin-top: 0.5rem;
min-width: 0;
width: 100%;
}
.mythic-eventing-step-switch-copy {
display: flex;
flex: 1 1 auto;
@@ -9048,6 +9064,24 @@ tspan {
flex-direction: column;
gap: 0.55rem;
}
.mythic-eventing-step-user-interaction-section {
align-items: stretch;
width: 100%;
}
.mythic-eventing-step-user-interaction-section .mythic-eventing-step-switch-stack,
.mythic-eventing-step-user-interaction-section .mythic-eventing-step-switch-row {
width: 100%;
}
.mythic-eventing-step-user-interaction-section > .mythic-table-row-action {
align-self: flex-start;
}
.mythic-eventing-step-approval-policy-row {
display: flex;
flex-direction: column;
gap: 0.28rem;
min-width: 0;
width: 100%;
}
.mythic-eventing-step-list {
display: flex;
flex-direction: column;
@@ -9069,10 +9103,20 @@ tspan {
.mythic-eventing-step-list-item-editable {
display: block;
}
.mythic-eventing-step-list-item-editable.mythic-eventing-user-input-field-row {
align-items: flex-start;
display: flex;
}
.mythic-eventing-step-list-item > .MuiIconButton-root {
flex: 0 0 auto;
margin-top: 0.45rem;
}
.mythic-eventing-user-input-field-row > .MuiIconButton-root {
margin-top: 0.35rem;
}
.mythic-eventing-user-input-field-row .mythic-eventing-step-field-grid {
width: 100%;
}
.mythic-eventing-step-list-content {
flex: 1 1 auto;
min-width: 0;
@@ -9765,11 +9809,12 @@ tspan {
color: var(--mythic-global-002);
display: flex;
flex-direction: column;
gap: 0.42rem;
height: 100%;
justify-content: center;
justify-content: flex-start;
min-width: 0;
overflow: hidden;
padding: 0.5rem 0.58rem;
padding: 0.58rem 0.64rem;
width: 100%;
}
.mythic-eventing-flow-node-success {
@@ -9778,6 +9823,10 @@ tspan {
.mythic-eventing-flow-node-running {
border-color: var(--mythic-global-384);
}
.mythic-eventing-flow-node-awaiting_approval,
.mythic-eventing-flow-node-input_needed {
border-color: var(--mythic-global-104);
}
.mythic-eventing-flow-node-error {
border-color: var(--mythic-global-354);
}
@@ -9792,23 +9841,28 @@ tspan {
border-color: var(--mythic-global-007);
}
.mythic-eventing-flow-node-main {
align-items: center;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.35rem;
flex-wrap: wrap;
gap: 0.34rem;
justify-content: flex-start;
min-width: 0;
}
.mythic-eventing-flow-node-title {
color: var(--mythic-global-002);
font-size: 0.82rem !important;
display: -webkit-box;
font-size: 0.84rem !important;
font-weight: 850 !important;
line-height: 1.18 !important;
margin: 0 !important;
max-width: 15rem;
min-height: 1.95rem;
overflow: hidden;
overflow-wrap: anywhere;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
white-space: normal;
}
.mythic-eventing-flow-node-meta {
align-items: center;
@@ -9817,7 +9871,7 @@ tspan {
flex-wrap: wrap;
gap: 0.4rem;
justify-content: space-between;
margin-top: 0.42rem;
min-width: 0;
}
.mythic-eventing-flow-node-meta .MuiTypography-root {
color: var(--mythic-global-025) !important;
@@ -9833,11 +9887,16 @@ tspan {
border: 1px solid var(--mythic-global-010);
border-radius: var(--mythic-global-008);
display: inline-flex;
flex: 0 1 auto;
font-size: 0.66rem;
font-weight: 750;
line-height: 1;
max-width: 100%;
min-height: 1.28rem;
overflow: hidden;
padding: 0 0.38rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-eventing-status-chip {
align-items: center;
@@ -9846,17 +9905,27 @@ tspan {
border-radius: var(--mythic-global-008);
color: var(--mythic-global-025);
display: inline-flex;
flex: 0 1 auto;
font-size: 0.7rem;
font-weight: 850;
line-height: 1;
max-width: 100%;
min-height: 1.45rem;
min-width: 0;
padding: 0 0.45rem;
width: fit-content;
}
.mythic-eventing-status-chip .MuiSvgIcon-root {
flex: 0 0 auto;
font-size: 0.95rem !important;
margin: 0 0.28rem 0 0 !important;
}
.mythic-eventing-status-chip span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-eventing-status-chip-success {
background-color: var(--mythic-global-388);
border-color: var(--mythic-global-389);
@@ -9867,6 +9936,12 @@ tspan {
border-color: var(--mythic-global-368);
color: var(--mythic-global-023);
}
.mythic-eventing-status-chip-awaiting_approval,
.mythic-eventing-status-chip-input_needed {
background-color: var(--mythic-global-046);
border-color: var(--mythic-global-104);
color: var(--mythic-global-038);
}
.mythic-eventing-status-chip-error {
background-color: var(--mythic-global-390);
border-color: var(--mythic-global-391);
@@ -9888,6 +9963,184 @@ tspan {
border-color: var(--mythic-global-362);
color: var(--mythic-global-025);
}
.mythic-eventing-user-interaction-dialog-content {
background-color: var(--mythic-global-318);
}
.mythic-eventing-user-interaction-modal {
display: grid;
gap: 0.85rem;
grid-template-columns: minmax(11rem, 15rem) minmax(0, 1fr);
min-width: 0;
}
.mythic-eventing-user-interaction-modal-single {
grid-template-columns: minmax(0, 1fr);
}
.mythic-eventing-user-interaction-step-picker {
display: flex;
flex-direction: column;
gap: 0.38rem;
min-width: 0;
}
.mythic-eventing-user-interaction-step-option {
background-color: var(--mythic-global-009);
border: 1px solid var(--mythic-global-010);
border-radius: var(--mythic-global-008);
color: var(--mythic-global-002);
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.18rem;
min-width: 0;
padding: 0.52rem 0.58rem;
text-align: left;
}
.mythic-eventing-user-interaction-step-option:hover,
.mythic-eventing-user-interaction-step-option-active {
border-color: var(--mythic-global-104);
box-shadow: inset 3px 0 0 var(--mythic-global-104);
}
.mythic-eventing-user-interaction-step-name {
font-size: 0.78rem;
font-weight: 850;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-eventing-user-interaction-step-meta {
color: var(--mythic-global-025);
font-size: 0.68rem;
font-weight: 700;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mythic-eventing-user-interaction-workspace {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-width: 0;
}
.mythic-eventing-user-interaction-step-summary,
.mythic-eventing-user-interaction-section {
background-color: var(--mythic-global-009);
border: 1px solid var(--mythic-global-010);
border-radius: var(--mythic-global-008);
min-width: 0;
padding: 0.75rem;
}
.mythic-eventing-user-interaction-step-summary {
align-items: flex-start;
display: flex;
gap: 0.75rem;
justify-content: space-between;
}
.mythic-eventing-user-interaction-summary-chips {
align-items: center;
display: flex;
flex: 0 1 auto;
flex-wrap: wrap;
gap: 0.35rem;
justify-content: flex-end;
min-width: 0;
}
.mythic-eventing-user-interaction-section-title {
color: var(--mythic-global-002);
font-size: 0.82rem;
font-weight: 850;
line-height: 1.2;
margin-bottom: 0.35rem;
}
.mythic-eventing-user-interaction-section-label {
color: var(--mythic-global-025);
font-size: 0.68rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.1;
margin-bottom: 0.35rem;
text-transform: uppercase;
}
.mythic-eventing-user-interaction-title {
color: var(--mythic-global-002);
font-size: 1rem;
font-weight: 850;
line-height: 1.2;
overflow-wrap: anywhere;
}
.mythic-eventing-user-interaction-prompt,
.mythic-eventing-user-interaction-permission {
color: var(--mythic-global-025);
font-size: 0.78rem;
font-weight: 650;
line-height: 1.35;
margin-top: 0.18rem;
overflow-wrap: anywhere;
}
.mythic-eventing-user-interaction-prompt {
color: var(--mythic-global-002);
white-space: pre-wrap;
}
.mythic-eventing-user-interaction-policy-note {
color: var(--mythic-global-025);
font-size: 0.72rem;
font-weight: 700;
line-height: 1.3;
margin-top: 0.45rem;
}
.mythic-eventing-user-interaction-required {
background-color: var(--mythic-global-046);
border: 1px solid var(--mythic-global-104);
border-radius: var(--mythic-global-008);
color: var(--mythic-global-038);
display: inline-flex;
font-size: 0.62rem;
font-weight: 800;
line-height: 1;
margin-left: 0.38rem;
padding: 0.18rem 0.3rem;
}
.mythic-eventing-user-interaction-inputs {
display: flex;
flex-direction: column;
gap: 0.58rem;
margin-top: 0.62rem;
min-width: 0;
}
.mythic-eventing-user-interaction-input-row {
align-items: flex-start;
display: grid;
gap: 0.75rem;
grid-template-columns: minmax(9rem, 0.45fr) minmax(12rem, 1fr);
min-width: 0;
}
.mythic-eventing-user-interaction-input-copy {
display: flex;
flex-direction: column;
gap: 0.18rem;
min-width: 0;
padding-top: 0.28rem;
}
.mythic-eventing-user-interaction-input-name {
color: var(--mythic-global-002);
font-size: 0.78rem;
font-weight: 850;
line-height: 1.2;
overflow-wrap: anywhere;
}
.mythic-eventing-user-interaction-input-description {
color: var(--mythic-global-025);
font-size: 0.7rem;
font-weight: 650;
line-height: 1.25;
overflow-wrap: anywhere;
}
@media (max-width: 820px) {
.mythic-eventing-user-interaction-modal,
.mythic-eventing-user-interaction-input-row {
grid-template-columns: minmax(0, 1fr);
}
}
.mythic-eventing-detail-chip-row {
display: flex;
flex-wrap: wrap;
+14
View File
@@ -427,6 +427,15 @@ type Mutation {
): eventingTriggerRetryFromStepOutput
}
type Mutation {
eventingStepUserInteractionSubmit(
approved: Boolean
comment: String
eventstepinstance_id: Int!
inputs: jsonb
): eventingStepUserInteractionSubmitOutput
}
type Mutation {
eventingTriggerRunAgain(
eventgroupinstance_id: Int!
@@ -1316,6 +1325,11 @@ type eventingTriggerRetryFromStepOutput {
error: String
}
type eventingStepUserInteractionSubmitOutput {
status: String!
error: String
}
type testProxyOutput {
status: String!
error: String
+12
View File
@@ -611,6 +611,17 @@ actions:
- role: mythic_admin
- role: developer
comment: Manually retry an eventgroup instance from a specific instance step by ID
- name: eventingStepUserInteractionSubmit
definition:
kind: synchronous
handler: '{{MYTHIC_ACTIONS_URL_BASE}}/eventing_step_user_interaction_submit_webhook'
forward_client_headers: true
permissions:
- role: operator
- role: operation_admin
- role: mythic_admin
- role: developer
comment: Submit approval or runtime input for an eventing step instance waiting on user interaction
- name: eventingTriggerRunAgain
definition:
kind: synchronous
@@ -1116,6 +1127,7 @@ custom_types:
- name: containerUploadFileOutput
- name: containerWriteFileOutput
- name: eventingTriggerRetryFromStepOutput
- name: eventingStepUserInteractionSubmitOutput
- name: testProxyOutput
- name: eventingTestFileOutput
- name: getOperatorPreferencesOutput
@@ -34,6 +34,7 @@ select_permissions:
- environment
- inputs
- outputs
- user_interaction
- action
- description
- name
@@ -59,6 +60,7 @@ select_permissions:
- environment
- inputs
- outputs
- user_interaction
- action
- description
- name
@@ -84,6 +86,7 @@ select_permissions:
- environment
- inputs
- outputs
- user_interaction
- action
- description
- name
@@ -109,6 +112,7 @@ select_permissions:
- environment
- inputs
- outputs
- user_interaction
- action
- description
- name
@@ -93,12 +93,16 @@ select_permissions:
- environment
- inputs
- outputs
- user_interaction
- user_interaction_response
- status
- stderr
- stdout
- created_at
- end_timestamp
- updated_at
- user_interaction_resolved_at
- user_interaction_resolved_by
filter:
_and:
- operation_id:
@@ -120,12 +124,16 @@ select_permissions:
- environment
- inputs
- outputs
- user_interaction
- user_interaction_response
- status
- stderr
- stdout
- created_at
- end_timestamp
- updated_at
- user_interaction_resolved_at
- user_interaction_resolved_by
filter:
_and:
- operation_id:
@@ -147,12 +155,16 @@ select_permissions:
- environment
- inputs
- outputs
- user_interaction
- user_interaction_response
- status
- stderr
- stdout
- created_at
- end_timestamp
- updated_at
- user_interaction_resolved_at
- user_interaction_resolved_by
filter:
_and:
- operation_id:
@@ -174,12 +186,16 @@ select_permissions:
- environment
- inputs
- outputs
- user_interaction
- user_interaction_response
- status
- stderr
- stdout
- created_at
- end_timestamp
- updated_at
- user_interaction_resolved_at
- user_interaction_resolved_by
filter:
_and:
- operation_id:
+1 -1
View File
@@ -15,7 +15,7 @@ import (
)
var DB *sqlx.DB
var currentMigrationVersion int64 = 3003020
var currentMigrationVersion int64 = 3003021
// initial structs made with './tables-to-go -u mythic_user -p [password here] -h [ip here] -v -d mythic_db -of output -pn database_structs'
// package pulled from https://github.com/fraenky8/tables-to-go
@@ -0,0 +1,30 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
alter table "public"."eventstep"
add column if not exists "user_interaction" jsonb not null default jsonb_build_object();
alter table "public"."eventstepinstance"
add column if not exists "user_interaction" jsonb not null default jsonb_build_object(),
add column if not exists "user_interaction_response" jsonb not null default jsonb_build_object(),
add column if not exists "user_interaction_resolved_by" integer,
add column if not exists "user_interaction_resolved_at" timestamp without time zone;
alter table "public"."eventstepinstance" drop constraint if exists "eventstepinstance_user_interaction_resolved_by_fkey";
alter table "public"."eventstepinstance" add constraint "eventstepinstance_user_interaction_resolved_by_fkey"
foreign key ("user_interaction_resolved_by") references "public"."operator"("id") on update restrict on delete restrict not valid;
alter table "public"."eventstepinstance" validate constraint "eventstepinstance_user_interaction_resolved_by_fkey";
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table "public"."eventstepinstance" drop constraint if exists "eventstepinstance_user_interaction_resolved_by_fkey";
alter table "public"."eventstepinstance"
drop column if exists "user_interaction_resolved_at",
drop column if exists "user_interaction_resolved_by",
drop column if exists "user_interaction_response",
drop column if exists "user_interaction";
alter table "public"."eventstep"
drop column if exists "user_interaction";
+24 -19
View File
@@ -18,29 +18,34 @@ type EventStep struct {
Environment MythicJSONText `db:"environment" json:"environment" toml:"environment" yaml:"environment,flow"`
Inputs MythicJSONText `db:"inputs" json:"inputs" toml:"inputs" yaml:"inputs,flow"`
Outputs MythicJSONText `db:"outputs" json:"outputs" toml:"outputs" yaml:"outputs,flow"`
UserInteraction MythicJSONText `db:"user_interaction" json:"user_interaction" toml:"user_interaction" yaml:"user_interaction,flow"`
Order int `db:"order" json:"order" toml:"order" yaml:"order"`
ContinueOnError bool `db:"continue_on_error" json:"continue_on_error" toml:"continue_on_error" yaml:"continue_on_error"`
CreatedAt time.Time `db:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"`
}
type EventStepInstance struct {
ID int `db:"id" json:"id" toml:"id" yaml:"id"`
EventGroupInstanceID int `db:"eventgroupinstance_id" json:"eventgroupinstance_id" toml:"eventgroupinstance_id" yaml:"eventgroupinstance_id"`
EventStepID int `db:"eventstep_id" json:"eventstep_id" toml:"eventstep_id" yaml:"eventstep_id"`
OperatorID int `db:"operator_id" json:"operator_id" toml:"operator_id" yaml:"operator_id"`
OperationID int `db:"operation_id" json:"operation_id" toml:"operation_id" yaml:"operation_id"`
Environment MythicJSONText `db:"environment" json:"environment" toml:"environment" yaml:"environment,flow"`
Inputs MythicJSONText `db:"inputs" json:"inputs" toml:"inputs" yaml:"inputs,flow"`
Outputs MythicJSONText `db:"outputs" json:"outputs" toml:"outputs" yaml:"outputs,flow"`
CreatedAt time.Time `db:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"`
EndTimestamp sql.NullTime `db:"end_timestamp" json:"end_timestamp" toml:"end_timestamp" yaml:"end_timestamp"`
Status string `db:"status" json:"status" toml:"status" yaml:"status"`
Stdout string `db:"stdout" json:"stdout" toml:"stdout" yaml:"stdout"`
Stderr string `db:"stderr" json:"stderr" toml:"stderr" yaml:"stderr"`
Order int `db:"order" json:"order" toml:"order" yaml:"order"`
ContinueOnError bool `db:"continue_on_error" json:"continue_on_error" toml:"continue_on_error" yaml:"continue_on_error"`
ActionData MythicJSONText `db:"action_data" json:"action_data" toml:"action_data" yaml:"action_data,flow"`
EventStep EventStep `db:"eventstep"`
EventGroupInstance EventGroupInstance `db:"eventgroupinstance"`
ID int `db:"id" json:"id" toml:"id" yaml:"id"`
EventGroupInstanceID int `db:"eventgroupinstance_id" json:"eventgroupinstance_id" toml:"eventgroupinstance_id" yaml:"eventgroupinstance_id"`
EventStepID int `db:"eventstep_id" json:"eventstep_id" toml:"eventstep_id" yaml:"eventstep_id"`
OperatorID int `db:"operator_id" json:"operator_id" toml:"operator_id" yaml:"operator_id"`
OperationID int `db:"operation_id" json:"operation_id" toml:"operation_id" yaml:"operation_id"`
Environment MythicJSONText `db:"environment" json:"environment" toml:"environment" yaml:"environment,flow"`
Inputs MythicJSONText `db:"inputs" json:"inputs" toml:"inputs" yaml:"inputs,flow"`
Outputs MythicJSONText `db:"outputs" json:"outputs" toml:"outputs" yaml:"outputs,flow"`
CreatedAt time.Time `db:"created_at" json:"created_at" toml:"created_at" yaml:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at" toml:"updated_at" yaml:"updated_at"`
EndTimestamp sql.NullTime `db:"end_timestamp" json:"end_timestamp" toml:"end_timestamp" yaml:"end_timestamp"`
Status string `db:"status" json:"status" toml:"status" yaml:"status"`
Stdout string `db:"stdout" json:"stdout" toml:"stdout" yaml:"stdout"`
Stderr string `db:"stderr" json:"stderr" toml:"stderr" yaml:"stderr"`
Order int `db:"order" json:"order" toml:"order" yaml:"order"`
ContinueOnError bool `db:"continue_on_error" json:"continue_on_error" toml:"continue_on_error" yaml:"continue_on_error"`
ActionData MythicJSONText `db:"action_data" json:"action_data" toml:"action_data" yaml:"action_data,flow"`
UserInteraction MythicJSONText `db:"user_interaction" json:"user_interaction" toml:"user_interaction" yaml:"user_interaction,flow"`
UserInteractionResponse MythicJSONText `db:"user_interaction_response" json:"user_interaction_response" toml:"user_interaction_response" yaml:"user_interaction_response,flow"`
UserInteractionResolvedBy sql.NullInt64 `db:"user_interaction_resolved_by" json:"user_interaction_resolved_by" toml:"user_interaction_resolved_by" yaml:"user_interaction_resolved_by"`
UserInteractionResolvedAt sql.NullTime `db:"user_interaction_resolved_at" json:"user_interaction_resolved_at" toml:"user_interaction_resolved_at" yaml:"user_interaction_resolved_at"`
EventStep EventStep `db:"eventstep"`
EventGroupInstance EventGroupInstance `db:"eventgroupinstance"`
}
@@ -13,12 +13,14 @@ import (
)
var (
EventGroupInstanceStatusRunning = "running"
EventGroupInstanceStatusCancelled = "cancelled"
EventGroupInstanceStatusSuccess = "success"
EventGroupInstanceStatusError = "error"
EventGroupInstanceStatusQueued = "queued"
EventGroupInstanceStatusSkipped = "skipped"
EventGroupInstanceStatusRunning = "running"
EventGroupInstanceStatusCancelled = "cancelled"
EventGroupInstanceStatusSuccess = "success"
EventGroupInstanceStatusError = "error"
EventGroupInstanceStatusQueued = "queued"
EventGroupInstanceStatusSkipped = "skipped"
EventGroupInstanceStatusAwaitingApproval = "awaiting_approval"
EventGroupInstanceStatusInputNeeded = "input_needed"
)
func SaveEventGroup(eventGroup *databaseStructs.EventGroup, currentOperatorOperation *databaseStructs.Operatoroperation) error {
@@ -121,8 +123,8 @@ func SaveEventGroup(eventGroup *databaseStructs.EventGroup, currentOperatorOpera
eventGroup.Steps[i].EventGroup = eventGroup.ID
eventGroup.Steps[i].OperatorID = currentOperatorOperation.CurrentOperator.ID
statement, err = database.DB.PrepareNamed(`INSERT INTO eventstep
(operator_id, operation_id, "name", description, eventgroup_id, environment, depends_on, action, action_data, inputs, outputs, "order", continue_on_error)
VALUES (:operator_id, :operation_id,:name, :description, :eventgroup_id, :environment, :depends_on, :action, :action_data, :inputs, :outputs, :order, :continue_on_error )
(operator_id, operation_id, "name", description, eventgroup_id, environment, depends_on, action, action_data, inputs, outputs, user_interaction, "order", continue_on_error)
VALUES (:operator_id, :operation_id,:name, :description, :eventgroup_id, :environment, :depends_on, :action, :action_data, :inputs, :outputs, :user_interaction, :order, :continue_on_error )
RETURNING id`)
if err != nil {
logging.LogError(err, "Failed to create statement for saving eventstep")
@@ -496,6 +498,7 @@ func CreateEventGroupInstance(eventGroupId int, trigger string, triggeringOperat
EventStepID: eventGroupSteps[i].ID,
Inputs: eventGroupSteps[i].Inputs,
Outputs: eventGroupSteps[i].Outputs,
UserInteraction: eventGroupSteps[i].UserInteraction,
Status: EventGroupInstanceStatusQueued,
Order: eventGroupSteps[i].Order,
ContinueOnError: eventGroupSteps[i].ContinueOnError,
@@ -508,8 +511,8 @@ func CreateEventGroupInstance(eventGroupId int, trigger string, triggeringOperat
}
eventStepInstance.Environment = GetMythicJSONTextFromStruct(eventGroupEnv)
_, err = database.DB.NamedExec(`INSERT INTO eventstepinstance
(eventgroupinstance_id, operator_id, operation_id, eventstep_id, inputs, outputs, status, "order", environment, continue_on_error)
VALUES (:eventgroupinstance_id, :operator_id, :operation_id, :eventstep_id, :inputs, :outputs, :status, :order, :environment, :continue_on_error)`,
(eventgroupinstance_id, operator_id, operation_id, eventstep_id, inputs, outputs, user_interaction, status, "order", environment, continue_on_error)
VALUES (:eventgroupinstance_id, :operator_id, :operation_id, :eventstep_id, :inputs, :outputs, :user_interaction, :status, :order, :environment, :continue_on_error)`,
eventStepInstance)
if err != nil {
logging.LogError(err, "Failed to create new eventStepInstance")
@@ -558,7 +561,10 @@ func updateAllRemainingStepInstances(eventGroupInstanceId int, status string) er
return err
}
for i, _ := range eventStepInstances {
if eventStepInstances[i].Status == EventGroupInstanceStatusQueued || eventStepInstances[i].Status == EventGroupInstanceStatusRunning {
if eventStepInstances[i].Status == EventGroupInstanceStatusQueued ||
eventStepInstances[i].Status == EventGroupInstanceStatusRunning ||
eventStepInstances[i].Status == EventGroupInstanceStatusAwaitingApproval ||
eventStepInstances[i].Status == EventGroupInstanceStatusInputNeeded {
eventStepInstances[i].Status = status
eventStepInstances[i].EndTimestamp.Time = time.Now().UTC()
eventStepInstances[i].EndTimestamp.Valid = true
+2
View File
@@ -64,6 +64,7 @@ var (
TriggerResponseInterceptResponse = "response_intercept_response"
TriggerCallbackCheckin = "callback_checkin"
TriggerTagCreate = "tag_create"
TriggerStepUserInteractionSubmit = "step_user_interaction_submit"
)
// when to trigger a new workflow
@@ -163,6 +164,7 @@ func Ingest(fileContents []byte) (databaseStructs.EventGroup, error) {
databaseEventStep.ActionData = GetMythicJSONTextFromStruct(eventGroup.Steps[i].ActionData)
databaseEventStep.Inputs = GetMythicJSONTextFromStruct(eventGroup.Steps[i].Inputs)
databaseEventStep.Outputs = GetMythicJSONTextFromStruct(eventGroup.Steps[i].Outputs)
databaseEventStep.UserInteraction = GetMythicJSONTextFromStruct(eventGroup.Steps[i].UserInteraction)
databaseEventStep.ContinueOnError = eventGroup.Steps[i].ContinueOnError
databaseEventGroup.Steps = append(databaseEventGroup.Steps, databaseEventStep)
}
+1
View File
@@ -20,6 +20,7 @@ type EventStep struct {
Environment map[string]interface{} `db:"environment" json:"environment" toml:"environment" yaml:"environment"`
Inputs map[string]interface{} `db:"inputs" json:"inputs" toml:"inputs" yaml:"inputs"`
Outputs map[string]interface{} `db:"outputs" json:"outputs" toml:"outputs" yaml:"outputs"`
UserInteraction map[string]interface{} `db:"user_interaction" json:"user_interaction,omitempty" toml:"user_interaction,omitempty" yaml:"user_interaction,omitempty"`
Order int `db:"order" json:"-" toml:"-" yaml:"-"`
ContinueOnError bool `db:"continue_on_error" json:"continue_on_error" toml:"continue_on_error" yaml:"continue_on_error"`
}
@@ -0,0 +1,105 @@
package eventing
import "strings"
const (
UserInteractionApproverOperator = "operator"
UserInteractionApproverLead = "lead"
)
func UserInteractionApprovalRequired(config map[string]interface{}) bool {
if value, ok := config["approval_required"]; ok {
return userInteractionBool(value)
}
return false
}
func UserInteractionInputRequired(config map[string]interface{}) bool {
if value, ok := config["input_required"]; ok && userInteractionBool(value) {
return true
}
return len(UserInteractionInputs(config)) > 0
}
func UserInteractionHasRequirements(config map[string]interface{}) bool {
return UserInteractionApprovalRequired(config) || UserInteractionInputRequired(config)
}
func UserInteractionBotApprovalApprover(config map[string]interface{}) string {
approvalPolicy, ok := config["approval_policy"].(map[string]interface{})
if !ok {
return UserInteractionApproverOperator
}
botContext, ok := approvalPolicy["bot_context"].(map[string]interface{})
if !ok {
return UserInteractionApproverOperator
}
approver, ok := botContext["approver"].(string)
if !ok {
return UserInteractionApproverOperator
}
switch strings.ToLower(approver) {
case UserInteractionApproverLead:
return UserInteractionApproverLead
default:
return UserInteractionApproverOperator
}
}
func UserInteractionInputs(config map[string]interface{}) []map[string]interface{} {
inputs := []map[string]interface{}{}
inputValue, ok := config["inputs"]
if !ok || inputValue == nil {
return inputs
}
switch typedInputs := inputValue.(type) {
case []interface{}:
for _, rawInput := range typedInputs {
if inputMap, ok := rawInput.(map[string]interface{}); ok {
inputs = append(inputs, inputMap)
}
}
case []map[string]interface{}:
inputs = append(inputs, typedInputs...)
case map[string]interface{}:
for name, rawInput := range typedInputs {
inputMap := map[string]interface{}{"name": name}
if rawInputMap, ok := rawInput.(map[string]interface{}); ok {
for key, value := range rawInputMap {
inputMap[key] = value
}
} else {
inputMap["default_value"] = rawInput
}
inputs = append(inputs, inputMap)
}
}
return inputs
}
func UserInteractionFieldName(field map[string]interface{}) string {
if value, ok := field["name"]; ok {
if name, ok := value.(string); ok {
return name
}
}
return ""
}
func UserInteractionFieldRequired(field map[string]interface{}) bool {
if value, ok := field["required"]; ok {
return userInteractionBool(value)
}
return false
}
func userInteractionBool(value interface{}) bool {
switch typedValue := value.(type) {
case bool:
return typedValue
case string:
return strings.ToLower(typedValue) == "true"
default:
return false
}
}
+105 -14
View File
@@ -343,6 +343,12 @@ func listenForEvents() {
processEventFinishAndNextStepStart(event)
case eventing.TriggerConditionalCheckResponse:
processEventFinishAndNextStepStart(event)
case eventing.TriggerStepUserInteractionSubmit:
if event.ActionSuccess {
go resumeWaitingEventStepInstance(event.EventStepInstanceID)
} else {
processEventFinishAndNextStepStart(event)
}
case eventing.TriggerCallbackCheckin:
go findEventGroupsToStart(event)
case eventing.TriggerTagCreate:
@@ -694,7 +700,12 @@ func processEventFinishAndNextStepStart(eventNotification EventNotification) {
return
}
foundStepToFinish := false
stepOutput := getStepInstanceOutputs(eventNotification, triggeringStep)
stepOutput := map[string]interface{}{}
if eventNotification.Trigger == eventing.TriggerStepUserInteractionSubmit {
stepOutput = triggeringStep.EventStep.Outputs.StructValue()
} else {
stepOutput = getStepInstanceOutputs(eventNotification, triggeringStep)
}
// process potential steps for skips
inputs := triggeringStep.ActionData.StructValue()
skipSteps := []string{}
@@ -826,6 +837,12 @@ func startProcessingRunAgainGroupInstanceSteps(oldEventGroupInstanceID int, newO
// still need to do something to start processing steps
go startProcessingNewEventGroupInstanceSteps(eventgroupinstanceID)
}
func eventStepStatusBlocksOrder(status string) bool {
return status == eventing.EventGroupInstanceStatusQueued ||
status == eventing.EventGroupInstanceStatusRunning ||
status == eventing.EventGroupInstanceStatusAwaitingApproval ||
status == eventing.EventGroupInstanceStatusInputNeeded
}
func findNextStepToStartAndStartIt(eventSteps []databaseStructs.EventStepInstance, eventGroupInstance databaseStructs.EventGroupInstance) error {
currentStepOrder := eventGroupInstance.CurrentOrderStep
// marked current step as done, now to see if there are any others to kick off
@@ -834,9 +851,7 @@ func findNextStepToStartAndStartIt(eventSteps []databaseStructs.EventStepInstanc
for allCurrentStepsAreDone {
// make sure all steps of the current order are done before trying to start
for i := 0; i < len(eventSteps); i++ {
if (eventSteps[i].Status == eventing.EventGroupInstanceStatusRunning ||
eventSteps[i].Status == eventing.EventGroupInstanceStatusQueued) &&
eventSteps[i].Order == currentStepOrder {
if eventStepStatusBlocksOrder(eventSteps[i].Status) && eventSteps[i].Order == currentStepOrder {
allCurrentStepsAreDone = false
}
if finalGroupInstanceStatus == eventing.EventGroupInstanceStatusSuccess && eventSteps[i].Status == eventing.EventGroupInstanceStatusError {
@@ -903,8 +918,7 @@ func findNextStepToStartAndStartIt(eventSteps []databaseStructs.EventStepInstanc
}
// lastly check if any steps are still running / queued
for i := 0; i < len(eventSteps); i++ {
if eventSteps[i].Status == eventing.EventGroupInstanceStatusQueued ||
eventSteps[i].Status == eventing.EventGroupInstanceStatusRunning {
if eventStepStatusBlocksOrder(eventSteps[i].Status) {
// something is still running or about to run
return nil
}
@@ -919,12 +933,13 @@ func startEventStepInstance(eventStepInstanceID int) error {
err := database.DB.Get(&eventStepInstance, `SELECT
eventstepinstance.id, eventstepinstance.status, eventstepinstance.order, eventstepinstance.eventgroupinstance_id,
eventstepinstance.operator_id, eventstepinstance.operation_id, eventstepinstance.environment,
eventstepinstance.continue_on_error,
eventstepinstance.continue_on_error, eventstepinstance.user_interaction, eventstepinstance.user_interaction_response,
eventstep.action "eventstep.action",
eventstep.name "eventstep.name",
eventstep.action_data "eventstep.action_data",
eventstep.inputs "eventstep.inputs",
eventstep.outputs "eventstep.outputs",
eventstep.user_interaction "eventstep.user_interaction",
eventgroupinstance.environment "eventgroupinstance.environment",
eventgroupinstance.eventgroup_id "eventgroupinstance.eventgroup_id"
FROM eventstepinstance
@@ -947,6 +962,13 @@ func startEventStepInstance(eventStepInstanceID int) error {
logging.LogError(err, "failed to get all event step instances")
return err
}
userSuppliedInputs, waitingForUser, err := prepareEventStepUserInteraction(eventStepInstance)
if err != nil {
return err
}
if waitingForUser {
return nil
}
// still need to process inputs from outputs of previous steps
inputs := eventStepInstance.EventStep.Inputs.StructValue()
stepEnv := eventStepInstance.Environment.StructValue()
@@ -1074,6 +1096,10 @@ func startEventStepInstance(eventStepInstanceID int) error {
}
}
}
for key, val := range userSuppliedInputs {
inputs[key] = val
actionDataString = replaceVariableInActionDataString(actionDataString, key, val)
}
eventStepInstance.Inputs = GetMythicJSONTextFromStruct(inputs)
// update action data based on event step input as needed
actionDataMap := make(map[string]interface{})
@@ -1126,6 +1152,64 @@ func replaceVariableInActionDataString(actionDataString string, key string, val
}
}
func prepareEventStepUserInteraction(eventStepInstance databaseStructs.EventStepInstance) (map[string]interface{}, bool, error) {
config := eventStepInstance.UserInteraction.StructValue()
if len(config) == 0 {
config = eventStepInstance.EventStep.UserInteraction.StructValue()
}
if !eventing.UserInteractionHasRequirements(config) {
return map[string]interface{}{}, false, nil
}
response := eventStepInstance.UserInteractionResponse.StructValue()
if len(response) > 0 {
if approvedInterface, ok := response["approved"]; ok {
if approved, ok := approvedInterface.(bool); ok && !approved {
return map[string]interface{}{}, false, errors.New("user interaction denied")
}
}
if responseInputs, ok := response["inputs"].(map[string]interface{}); ok {
return responseInputs, false, nil
}
return map[string]interface{}{}, false, nil
}
nextStatus := eventing.EventGroupInstanceStatusInputNeeded
if eventing.UserInteractionApprovalRequired(config) {
nextStatus = eventing.EventGroupInstanceStatusAwaitingApproval
}
eventStepInstance.Status = nextStatus
eventStepInstance.UserInteraction = GetMythicJSONTextFromStruct(config)
_, err := database.DB.NamedExec(`UPDATE eventstepinstance SET
status=:status, user_interaction=:user_interaction
WHERE id=:id`, eventStepInstance)
if err != nil {
logging.LogError(err, "failed to update event step to waiting for user interaction")
return map[string]interface{}{}, false, err
}
_, err = database.DB.Exec(`UPDATE eventgroupinstance SET status=$1 WHERE id=$2 AND end_timestamp IS NULL`,
nextStatus, eventStepInstance.EventGroupInstanceID)
if err != nil {
logging.LogError(err, "failed to update event group to waiting for user interaction")
return map[string]interface{}{}, false, err
}
return map[string]interface{}{}, true, nil
}
func resumeWaitingEventStepInstance(eventStepInstanceID int) {
eventStepInstance := databaseStructs.EventStepInstance{}
err := database.DB.Get(&eventStepInstance, `SELECT
id, eventgroupinstance_id, operation_id, operator_id, continue_on_error
FROM eventstepinstance WHERE id=$1`,
eventStepInstanceID)
if err != nil {
logging.LogError(err, "failed to get event step instance for user interaction resume")
return
}
err = startEventStepInstance(eventStepInstanceID)
if err != nil {
markStepInstanceAsError(eventStepInstance, err.Error())
}
}
// starting a specific step action
func convertMapStringToInt(actionDataMap map[string]interface{}, key string, required bool) error {
value, exists := actionDataMap[key]
@@ -1464,8 +1548,11 @@ func startEventStepInstanceActionInterceptResponse(eventStepInstance databaseStr
}
func restartFailedJobs(eventgroupInstanceID int) error {
_, err := database.DB.Exec(`UPDATE eventstepinstance
SET status=$1, end_timestamp=$2 WHERE eventgroupinstance_id=$3 AND status IN ($4, $5)`,
eventing.EventGroupInstanceStatusQueued, nil, eventgroupInstanceID,
SET status=$1, end_timestamp=$2, user_interaction_response=$3,
user_interaction_resolved_by=$4, user_interaction_resolved_at=$5
WHERE eventgroupinstance_id=$6 AND status IN ($7, $8)`,
eventing.EventGroupInstanceStatusQueued, nil, GetMythicJSONTextFromStruct(map[string]interface{}{}),
nil, nil, eventgroupInstanceID,
eventing.EventGroupInstanceStatusError, eventing.EventGroupInstanceStatusCancelled)
if err != nil {
logging.LogError(err, "Failed to update event steps")
@@ -1542,11 +1629,13 @@ func restartFromStepJobs(eventStepInstanceID int, retryAllEventGroups bool) erro
eventGroupInstances[i].ID == triggerStep.EventGroupInstanceID {
_, err = database.DB.Exec(`UPDATE eventstepinstance
SET status=$1, end_timestamp=$2
SET status=$1, end_timestamp=$2, user_interaction_response=$3,
user_interaction_resolved_by=$4, user_interaction_resolved_at=$5
FROM eventstep
WHERE eventstepinstance.eventstep_id = eventstep.id AND
eventgroupinstance_id=$3 AND (eventstep.name=$4 OR eventstepinstance.order > $5)`,
eventing.EventGroupInstanceStatusQueued, nil, eventGroupInstances[i].ID,
eventgroupinstance_id=$6 AND (eventstep.name=$7 OR eventstepinstance.order > $8)`,
eventing.EventGroupInstanceStatusQueued, nil, GetMythicJSONTextFromStruct(map[string]interface{}{}),
nil, nil, eventGroupInstances[i].ID,
triggerStep.EventStep.Name, triggerStep.Order)
if err != nil {
logging.LogError(err, "Failed to update event steps")
@@ -1641,8 +1730,10 @@ func markStepInstanceAsError(eventStepInstance databaseStructs.EventStepInstance
func cancelQueuedEventStepInstances(eventGroupInstanceID int) error {
_, err := database.DB.Exec(`UPDATE eventstepinstance
SET status=$1, end_timestamp=$2
WHERE eventgroupinstance_id=$3 AND status=$4`,
WHERE eventgroupinstance_id=$3 AND status IN ($4, $5, $6)`,
eventing.EventGroupInstanceStatusCancelled, time.Now().UTC(),
eventGroupInstanceID, eventing.EventGroupInstanceStatusQueued)
eventGroupInstanceID, eventing.EventGroupInstanceStatusQueued,
eventing.EventGroupInstanceStatusAwaitingApproval,
eventing.EventGroupInstanceStatusInputNeeded)
return err
}
@@ -108,6 +108,7 @@ func getFormattedEventingFile(eventGroup *databaseStructs.EventGroup, includeSte
Environment: step.Environment.StructValue(),
Inputs: step.Inputs.StructValue(),
Outputs: step.Outputs.StructValue(),
UserInteraction: step.UserInteraction.StructValue(),
Order: step.Order,
ContinueOnError: step.ContinueOnError,
})
@@ -0,0 +1,282 @@
package webcontroller
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/its-a-feature/Mythic/authentication"
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/its-a-feature/Mythic/database"
)
type EventingStepUserInteractionSubmitInput struct {
Input EventingStepUserInteractionSubmitMessage `json:"input" binding:"required"`
}
type EventingStepUserInteractionSubmitMessage struct {
EventStepInstanceID int `json:"eventstepinstance_id" binding:"required"`
Approved *bool `json:"approved"`
Inputs map[string]interface{} `json:"inputs"`
Comment string `json:"comment"`
}
type EventingStepUserInteractionSubmitMessageResponse struct {
Status string `json:"status"`
Error string `json:"error"`
}
type eventingStepUserInteractionSubmitRow struct {
ID int `db:"id"`
Status string `db:"status"`
OperationID int `db:"operation_id"`
EventGroupInstanceID int `db:"eventgroupinstance_id"`
UserInteraction databaseStructs.MythicJSONText `db:"user_interaction"`
RunOperatorID int `db:"run_operator_id"`
RunOperatorUsername string `db:"run_operator_username"`
RunOperatorAccountType string `db:"run_operator_account_type"`
}
func EventingStepUserInteractionSubmitWebhook(c *gin.Context) {
var input EventingStepUserInteractionSubmitInput
response := EventingStepUserInteractionSubmitMessageResponse{}
err := c.ShouldBindJSON(&input)
if err != nil {
logging.LogError(err, "Failed to get required parameters")
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
Status: "error",
Error: err.Error(),
})
return
}
ginOperatorOperation, ok := c.Get(authentication.ContextKeyOperatorOperationStruct)
if !ok {
logging.LogError(nil, "Failed to get user information")
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
Status: "error",
Error: "Failed to get user information",
})
return
}
operatorOperation := ginOperatorOperation.(*databaseStructs.Operatoroperation)
interactionRow := eventingStepUserInteractionSubmitRow{}
err = database.DB.Get(&interactionRow, `SELECT
eventstepinstance.id,
eventstepinstance.status,
eventstepinstance.operation_id,
eventstepinstance.eventgroupinstance_id,
eventstepinstance.user_interaction,
eventgroupinstance.operator_id "run_operator_id",
operator.username "run_operator_username",
operator.account_type "run_operator_account_type"
FROM eventstepinstance
JOIN eventgroupinstance ON eventstepinstance.eventgroupinstance_id = eventgroupinstance.id
JOIN operator ON eventgroupinstance.operator_id = operator.id
WHERE eventstepinstance.id=$1 AND eventstepinstance.operation_id=$2`,
input.Input.EventStepInstanceID, operatorOperation.CurrentOperation.ID)
if err != nil {
logging.LogError(err, "failed to get event step instance for user interaction")
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
Status: "error",
Error: err.Error(),
})
return
}
err = validateEventingStepUserInteractionSubmit(input.Input, interactionRow, operatorOperation)
if err != nil {
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
Status: "error",
Error: err.Error(),
})
return
}
submittedInputs := normalizeSubmittedUserInteractionInputs(input.Input.Inputs, interactionRow.UserInteraction.StructValue())
approved := true
if eventing.UserInteractionApprovalRequired(interactionRow.UserInteraction.StructValue()) && input.Input.Approved != nil {
approved = *input.Input.Approved
}
responseData := map[string]interface{}{
"approved": approved,
"inputs": submittedInputs,
"comment": input.Input.Comment,
"submitted_by": operatorOperation.CurrentOperator.Username,
"submitted_by_id": operatorOperation.CurrentOperator.ID,
"submitted_at": time.Now().UTC(),
}
if !approved {
err = resolveEventingStepUserInteraction(interactionRow, responseData, operatorOperation, false)
if err != nil {
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
Status: "error",
Error: err.Error(),
})
return
}
rabbitmq.EventingChannel <- rabbitmq.EventNotification{
Trigger: eventing.TriggerStepUserInteractionSubmit,
OperationID: operatorOperation.CurrentOperation.ID,
OperatorID: operatorOperation.CurrentOperator.ID,
EventStepInstanceID: input.Input.EventStepInstanceID,
ActionSuccess: false,
ActionStderr: fmt.Sprintf("User interaction denied by %s", operatorOperation.CurrentOperator.Username),
Outputs: map[string]interface{}{},
}
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{Status: "success"})
return
}
err = resolveEventingStepUserInteraction(interactionRow, responseData, operatorOperation, true)
if err != nil {
c.JSON(http.StatusOK, EventingStepUserInteractionSubmitMessageResponse{
Status: "error",
Error: err.Error(),
})
return
}
rabbitmq.EventingChannel <- rabbitmq.EventNotification{
Trigger: eventing.TriggerStepUserInteractionSubmit,
OperationID: operatorOperation.CurrentOperation.ID,
OperatorID: operatorOperation.CurrentOperator.ID,
EventStepInstanceID: input.Input.EventStepInstanceID,
ActionSuccess: true,
Outputs: map[string]interface{}{},
}
response.Status = "success"
c.JSON(http.StatusOK, response)
}
func validateEventingStepUserInteractionSubmit(input EventingStepUserInteractionSubmitMessage, interactionRow eventingStepUserInteractionSubmitRow, operatorOperation *databaseStructs.Operatoroperation) error {
if interactionRow.Status != eventing.EventGroupInstanceStatusAwaitingApproval &&
interactionRow.Status != eventing.EventGroupInstanceStatusInputNeeded {
return errors.New("step is not waiting for user interaction")
}
config := interactionRow.UserInteraction.StructValue()
approvalRequired := eventing.UserInteractionApprovalRequired(config)
if err := validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, approvalRequired, config); err != nil {
return err
}
if approvalRequired && input.Approved == nil {
return errors.New("approval decision is required")
}
if !approvalRequired && input.Approved != nil && !*input.Approved {
return errors.New("approval is not configured for this step")
}
if input.Approved != nil && !*input.Approved {
return nil
}
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
}
value, ok := submittedInputs[fieldName]
if !ok || isEmptyUserInteractionValue(value) {
return fmt.Errorf("missing required input %s", fieldName)
}
}
}
return nil
}
func validateEventingStepUserInteractionResponder(interactionRow eventingStepUserInteractionSubmitRow, operatorOperation *databaseStructs.Operatoroperation, approvalRequired bool, config map[string]interface{}) error {
if operatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR {
return errors.New("spectators cannot respond to user interaction steps")
}
if interactionRow.RunOperatorAccountType != databaseStructs.AccountTypeBot {
if interactionRow.RunOperatorID != operatorOperation.CurrentOperator.ID {
return fmt.Errorf("only %s can provide input for this step", interactionRow.RunOperatorUsername)
}
return nil
}
if !approvalRequired {
return nil
}
switch eventing.UserInteractionBotApprovalApprover(config) {
case eventing.UserInteractionApproverLead:
if operatorOperation.ViewMode != database.OPERATOR_OPERATION_VIEW_MODE_LEAD {
return errors.New("only the operation lead can approve this step")
}
}
return nil
}
func isEmptyUserInteractionValue(value interface{}) bool {
if value == nil {
return true
}
if valueString, ok := value.(string); ok {
return valueString == ""
}
return false
}
func normalizeSubmittedUserInteractionInputs(submittedInputs map[string]interface{}, config map[string]interface{}) map[string]interface{} {
normalizedInputs := map[string]interface{}{}
for key, value := range submittedInputs {
normalizedInputs[key] = value
}
for _, field := range eventing.UserInteractionInputs(config) {
fieldName := eventing.UserInteractionFieldName(field)
if fieldName == "" {
continue
}
if _, ok := normalizedInputs[fieldName]; ok {
continue
}
if defaultValue, ok := field["default_value"]; ok {
normalizedInputs[fieldName] = defaultValue
}
}
return normalizedInputs
}
func resolveEventingStepUserInteraction(interactionRow eventingStepUserInteractionSubmitRow, responseData map[string]interface{}, operatorOperation *databaseStructs.Operatoroperation, resumeStep bool) error {
nextStatus := interactionRow.Status
if resumeStep {
nextStatus = eventing.EventGroupInstanceStatusQueued
}
result, err := database.DB.Exec(`UPDATE eventstepinstance SET
user_interaction_response=$1,
user_interaction_resolved_by=$2,
user_interaction_resolved_at=$3,
status=$4
WHERE id=$5 AND status IN ($6, $7) AND user_interaction_resolved_by IS NULL`,
eventing.GetMythicJSONTextFromStruct(responseData),
operatorOperation.CurrentOperator.ID,
time.Now().UTC(),
nextStatus,
interactionRow.ID,
eventing.EventGroupInstanceStatusAwaitingApproval,
eventing.EventGroupInstanceStatusInputNeeded)
if err != nil {
logging.LogError(err, "failed to resolve event step user interaction")
return err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
logging.LogError(err, "failed to check event step user interaction update")
return err
}
if rowsAffected == 0 {
return errors.New("step interaction was already resolved")
}
if resumeStep {
_, err = database.DB.Exec(`UPDATE eventgroupinstance SET status=$1 WHERE id=$2 AND end_timestamp IS NULL`,
eventing.EventGroupInstanceStatusRunning, interactionRow.EventGroupInstanceID)
if err != nil {
logging.LogError(err, "failed to resume event group instance after user interaction")
return err
}
}
return nil
}
@@ -0,0 +1,89 @@
package webcontroller
import (
"testing"
"github.com/its-a-feature/Mythic/database"
databaseStructs "github.com/its-a-feature/Mythic/database/structs"
"github.com/its-a-feature/Mythic/eventing"
)
func TestValidateEventingStepUserInteractionResponderBotApprovalRoles(t *testing.T) {
interactionRow := eventingStepUserInteractionSubmitRow{
RunOperatorAccountType: databaseStructs.AccountTypeBot,
}
operatorOperation := &databaseStructs.Operatoroperation{}
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR
err := validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{})
if err == nil {
t.Fatal("expected spectator to be blocked from default bot approval")
}
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_OPERATOR
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{})
if err != nil {
t.Fatalf("expected operator to approve default bot approval: %v", err)
}
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_LEAD
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{})
if err != nil {
t.Fatalf("expected lead to approve default bot approval: %v", err)
}
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_OPERATOR
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{
"approval_policy": map[string]interface{}{
"bot_context": map[string]interface{}{
"approver": eventing.UserInteractionApproverLead,
},
},
})
if err == nil {
t.Fatal("expected operator to be blocked from lead-only bot approval")
}
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_LEAD
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{
"approval_policy": map[string]interface{}{
"bot_context": map[string]interface{}{
"approver": eventing.UserInteractionApproverLead,
},
},
})
if err != nil {
t.Fatalf("expected lead to approve lead-only bot approval: %v", err)
}
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, false, map[string]interface{}{})
if err == nil {
t.Fatal("expected spectator to be blocked from bot input-only interaction")
}
operatorOperation.ViewMode = database.OPERATOR_OPERATION_VIEW_MODE_OPERATOR
err = validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, false, map[string]interface{}{})
if err != nil {
t.Fatalf("expected operator to respond to bot input-only interaction: %v", err)
}
}
func TestValidateEventingStepUserInteractionResponderBlocksSpectatorRunOperator(t *testing.T) {
interactionRow := eventingStepUserInteractionSubmitRow{
RunOperatorAccountType: databaseStructs.AccountTypeUser,
RunOperatorID: 7,
RunOperatorUsername: "spectator-user",
}
operatorOperation := &databaseStructs.Operatoroperation{
CurrentOperator: databaseStructs.Operator{
ID: 7,
},
ViewMode: database.OPERATOR_OPERATION_VIEW_MODE_SPECTATOR,
}
err := validateEventingStepUserInteractionResponder(interactionRow, operatorOperation, true, map[string]interface{}{})
if err == nil {
t.Fatal("expected spectator run operator to be blocked from user interaction")
}
}
@@ -625,6 +625,11 @@ func setRoutes(r *gin.Engine) {
mythicjwt.SCOPE_EVENTING_WRITE,
}),
webcontroller.EventingTriggerRetryFromStepWebhook)
noSpectators.POST("eventing_step_user_interaction_submit_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_EVENTING_WRITE,
}),
webcontroller.EventingStepUserInteractionSubmitWebhook)
noSpectators.POST("eventing_trigger_runagain_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_EVENTING_WRITE,