diff --git a/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTasking.js b/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTasking.js index 140c13b5..ceb3b071 100644 --- a/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTasking.js +++ b/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTasking.js @@ -147,6 +147,9 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa if(oldArray[i].timestamp !== newArray[i].timestamp){ return false; } + if(oldArray[i].response_count !== newArray[i].response_count){ + return false; + } if(oldArray[i].opsec_pre_blocked !== newArray[i].opsec_pre_blocked){ return false; } diff --git a/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTaskingConsole.js b/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTaskingConsole.js index ce12b8b7..1f4bb898 100644 --- a/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTaskingConsole.js +++ b/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTaskingConsole.js @@ -147,6 +147,9 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose if(oldArray[i].timestamp !== newArray[i].timestamp){ return false; } + if(oldArray[i].response_count !== newArray[i].response_count){ + return false; + } if(oldArray[i].opsec_pre_blocked !== newArray[i].opsec_pre_blocked){ return false; } diff --git a/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTaskingSplit.js b/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTaskingSplit.js index 9e4203f1..52f3f065 100644 --- a/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTaskingSplit.js +++ b/MythicReactUI/src/components/pages/Callbacks/CallbacksTabsTaskingSplit.js @@ -146,6 +146,9 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa if(oldArray[i].timestamp !== newArray[i].timestamp){ return false; } + if(oldArray[i].response_count !== newArray[i].response_count){ + return false; + } if(oldArray[i].opsec_pre_blocked !== newArray[i].opsec_pre_blocked){ return false; } diff --git a/MythicReactUI/src/components/pages/Callbacks/ResponseDisplay.js b/MythicReactUI/src/components/pages/Callbacks/ResponseDisplay.js index f5df8ff6..432c1cb4 100644 --- a/MythicReactUI/src/components/pages/Callbacks/ResponseDisplay.js +++ b/MythicReactUI/src/components/pages/Callbacks/ResponseDisplay.js @@ -33,32 +33,38 @@ subscription subResponsesStream($task_id: Int!){ } `; const getResponsesLazyQuery = gql` -query subResponsesQuery($task_id: Int!, $fetchLimit: Int, $offset: Int!, $search: String!) { - response(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}, limit: $fetchLimit, offset: $offset, order_by: {id: asc}) { +query subResponsesQuery($task_id: Int!, $fetchLimit: Int, $offset: Int!, $where: response_bool_exp!) { + response(where: $where, limit: $fetchLimit, offset: $offset, order_by: {id: asc}) { id response: response_text timestamp is_error } - response_aggregate(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}){ + response_aggregate(where: $where){ aggregate{ count } } + latest_response: response(where: {task_id: {_eq: $task_id}}, limit: 1, order_by: {id: desc}) { + id + } }`; const getAllResponsesLazyQuery = gql` -query subResponsesQuery($task_id: Int!, $search: String!) { - response(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}, order_by: {id: asc}) { +query subResponsesQuery($task_id: Int!, $where: response_bool_exp!, $fetchLimit: Int) { + response(where: $where, limit: $fetchLimit, order_by: {id: asc}) { id response: response_text timestamp is_error } - response_aggregate(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}){ + response_aggregate(where: $where){ aggregate{ count } } + latest_response: response(where: {task_id: {_eq: $task_id}}, limit: 1, order_by: {id: desc}) { + id + } }`; const taskScript = gql` query getBrowserScriptsQuery($command_id: Int!){ @@ -93,6 +99,45 @@ export function b64DecodeUnicode(str) { } } } +const MAX_SELECT_ALL_RESPONSES = 5000; +const DEFAULT_RESPONSE_PAGE_SIZE = operatorSettingDefaults["experiment-responseStreamLimit"] || 200; +const normalizeResponsePageSize = (pageSize) => { + const parsed = Number(pageSize); + if(!Number.isFinite(parsed) || parsed <= 0){ + return DEFAULT_RESPONSE_PAGE_SIZE; + } + return Math.min(parsed, MAX_SELECT_ALL_RESPONSES); +} +const responseWhereClause = (taskID, search) => { + if(search === undefined || search === ""){ + return {task_id: {_eq: taskID}}; + } + return {task_id: {_eq: taskID}, response_escape: {_ilike: "%" + search + "%"}}; +} +const decodeResponse = (response) => { + return {...response, response: b64DecodeUnicode(response.response)}; +} +const responsesToOutput = (responses) => { + return responses.reduce( (prev, cur) => { + return prev + cur.response; + }, ""); +} +const mergeResponsesById = ({existingResponses, incomingResponses, limit, keepNewest=false}) => { + const responseMap = new Map(); + existingResponses.forEach( (response) => responseMap.set(response.id, response)); + incomingResponses.forEach( (response) => responseMap.set(response.id, response)); + const mergedResponses = Array.from(responseMap.values()).sort( (a,b) => a.id > b.id ? 1 : -1); + if(limit === undefined || mergedResponses.length <= limit){ + return mergedResponses; + } + return keepNewest ? mergedResponses.slice(-limit) : mergedResponses.slice(0, limit); +} +const getAggregateCount = (data) => { + return data?.response_aggregate?.aggregate?.count || 0; +} +const getLatestResponseID = (data) => { + return data?.latest_response?.[0]?.id || 0; +} export const ResponseDisplay = (props) =>{ const interactive = props?.task?.command?.supported_ui_features.includes("task_response:interactive") || false; return ( @@ -105,34 +150,43 @@ export const ResponseDisplay = (props) =>{ } const NonInteractiveResponseDisplay = (props) => { const theme = useTheme(); - const [output, setOutput] = React.useState(""); const [rawResponses, setRawResponses] = React.useState([]); - const seenResponseIDs = React.useRef([]); - const search = React.useRef(""); + const rawResponsesRef = React.useRef([]); + const [search, setSearch] = React.useState(""); const [totalCount, setTotalCount] = React.useState(0); + const totalCountRef = React.useRef(0); const [openBackdrop, setOpenBackdrop] = React.useState(true); - const togglingAllOutputToPaginated = React.useRef(false); const initialResponseStreamLimit = GetMythicSetting({setting_name: "experiment-responseStreamLimit", default_value: operatorSettingDefaults["experiment-responseStreamLimit"]}); + const pageSize = normalizeResponsePageSize(initialResponseStreamLimit); + const [currentPage, setCurrentPage] = React.useState(1); + const currentPageRef = React.useRef(1); + const highestKnownResponseID = React.useRef(0); + const pageDataLoaded = React.useRef(false); + const taskResponseCountRef = React.useRef(props.task.response_count || 0); + const selectAllWarningShown = React.useRef(false); + const output = React.useMemo(() => responsesToOutput(rawResponses), [rawResponses]); + React.useEffect( () => { + rawResponsesRef.current = rawResponses; + }, [rawResponses]); + const setTrackedTotalCount = React.useCallback( (newTotalCount) => { + totalCountRef.current = newTotalCount; + setTotalCount(newTotalCount); + }, []); + const warnSelectAllCapped = React.useCallback( (loadedCount, aggregateCount) => { + if(aggregateCount > loadedCount && !selectAllWarningShown.current){ + snackActions.warning(`Task output is too large to load all at once. Showing the first ${loadedCount} responses; use pagination or download to view the rest.`); + selectAllWarningShown.current = true; + } + }, []); const [fetchMoreResponses] = useLazyQuery(getResponsesLazyQuery, { - fetchPolicy: "network-only", + fetchPolicy: "no-cache", onCompleted: (data) => { - data.response.forEach( (r) => { - if(!seenResponseIDs.current.includes(r.id)){ - seenResponseIDs.current.push(r.id); - } - }) - // set raw responses to be what we just manually fetched - const responseArray = data.response.map( r =>{ return {...r, response: b64DecodeUnicode(r.response)}}); + const responseArray = data.response.map(decodeResponse); + rawResponsesRef.current = responseArray; setRawResponses(responseArray); - - const responses = responseArray.reduce( (prev, cur) => { - return prev + cur.response; - }, b64DecodeUnicode("")); - setOutput(responses); - // update maxID - if(!props.selectAllOutput){ - setTotalCount(data.response_aggregate.aggregate.count); - } + setTrackedTotalCount(getAggregateCount(data)); + highestKnownResponseID.current = Math.max(highestKnownResponseID.current, getLatestResponseID(data)); + pageDataLoaded.current = true; setOpenBackdrop(false); }, onError: (data) => { @@ -140,185 +194,184 @@ const NonInteractiveResponseDisplay = (props) => { } }); const [fetchAllResponses] = useLazyQuery(getAllResponsesLazyQuery, { - fetchPolicy: "network-only", + fetchPolicy: "no-cache", onCompleted: (data) => { - const responseArray = data.response.map( r =>{ return {...r, response: b64DecodeUnicode(r.response)}}); + const responseArray = data.response.map(decodeResponse); + rawResponsesRef.current = responseArray; setRawResponses(responseArray); - - const responses = responseArray.reduce( (prev, cur) => { - return prev + cur.response; - }, b64DecodeUnicode("")); - setOutput(responses); - - setTotalCount(1); + const aggregateCount = getAggregateCount(data); + setTrackedTotalCount(aggregateCount); + highestKnownResponseID.current = Math.max(highestKnownResponseID.current, getLatestResponseID(data)); + pageDataLoaded.current = true; + currentPageRef.current = 1; + setCurrentPage(1); + warnSelectAllCapped(responseArray.length, aggregateCount); setOpenBackdrop(false); - togglingAllOutputToPaginated.current = true; }, onError: (data) => { - + snackActions.error("Failed to fetch all responses: " + data) } }); - React.useEffect( () => { - //if(props.selectAllOutput !== oldSelectAllOutput.current){ - //oldSelectAllOutput.current = props.selectAllOutput; - if(props.selectAllOutput){ - setOpenBackdrop(true); - if(search.current === ""){ - fetchAllResponses({variables: {task_id: props.task.id, search: "%%"}}) - }else{ - fetchAllResponses({variables: {task_id: props.task.id, search: "%" + search.current + "%"}}) - } - }else if(togglingAllOutputToPaginated.current){ - // going from select all output to not select all output - // don't fetch this on first load - onSubmitPageChange(1); - togglingAllOutputToPaginated.current = false; - } - //} - }, [props.selectAllOutput, togglingAllOutputToPaginated.current]); + const fetchResponsePage = React.useCallback( (requestedPage, showBackdrop=true) => { + const nextPage = Math.max(1, requestedPage || 1); + currentPageRef.current = nextPage; + setCurrentPage(nextPage); + if(showBackdrop){ + setOpenBackdrop(true); + } + fetchMoreResponses({variables: {task_id: props.task.id, + fetchLimit: pageSize, + offset: pageSize * (nextPage - 1), + where: responseWhereClause(props.task.id, search) + }}) + }, [fetchMoreResponses, pageSize, props.task.id, search]); + const fetchSelectAllResponses = React.useCallback( () => { + currentPageRef.current = 1; + setCurrentPage(1); + setOpenBackdrop(true); + fetchAllResponses({variables: { + task_id: props.task.id, + where: responseWhereClause(props.task.id, search), + fetchLimit: MAX_SELECT_ALL_RESPONSES + }}); + }, [fetchAllResponses, props.task.id, search]); React.useEffect( () => { setOpenBackdrop(true); - setOutput(""); + rawResponsesRef.current = []; setRawResponses([]); - setTotalCount(0); - //onSubmitPageChange(1); + setTrackedTotalCount(0); + currentPageRef.current = 1; + setCurrentPage(1); + highestKnownResponseID.current = 0; + pageDataLoaded.current = false; + taskResponseCountRef.current = props.task.response_count || 0; + selectAllWarningShown.current = false; }, [props.task.id]); - setTimeout(() => { - // close the backdrop after 2 seconds in case there's no data to fetch - setOpenBackdrop(false); - }, 1000); - const subscriptionDataCallback = ({data}) => { - //console.log("fetchLimit", fetchLimit, "totalCount", totalCount); - if(rawResponses.length >= initialResponseStreamLimit && initialResponseStreamLimit > 0 && !props.selectAllOutput){ - // we won't display it - console.log("got more than we can see currently", totalCount); - setOpenBackdrop(false); - let newTotal = totalCount; - data.data.response_stream.forEach( (r) => { - if(!seenResponseIDs.current.includes(r.id)){ - newTotal += 1; - seenResponseIDs.current.push(r.id); - } - }) - setTotalCount(newTotal); + React.useEffect( () => { + if(props.selectAllOutput){ + fetchSelectAllResponses(); + }else{ + fetchResponsePage(1); + } + }, [props.selectAllOutput, props.task.id, search, fetchResponsePage, fetchSelectAllResponses]); + React.useEffect( () => { + if(!openBackdrop){ + return; + } + const timeoutID = setTimeout(() => { + setOpenBackdrop(false); + }, 1000); + return () => clearTimeout(timeoutID); + }, [openBackdrop]); + React.useEffect( () => { + taskResponseCountRef.current = props.task.response_count || 0; + if(search !== ""){ + return; + } + if(props.task.response_count > totalCountRef.current){ + setTrackedTotalCount(props.task.response_count); + } + }, [props.task.response_count, search, setTrackedTotalCount]); + const subscriptionDataCallback = React.useCallback( ({data}) => { + const streamedResponses = data?.data?.response_stream || []; + if(streamedResponses.length === 0 || !pageDataLoaded.current){ + return; + } + const previousHighestID = highestKnownResponseID.current; + const newStreamedResponses = streamedResponses.filter( (response) => response.id > previousHighestID); + if(newStreamedResponses.length === 0){ + return; + } + highestKnownResponseID.current = Math.max(previousHighestID, ...newStreamedResponses.map( (response) => response.id)); + if(search !== ""){ + const lowerCaseSearch = search.toLowerCase(); + const matchingResponseStreamed = newStreamedResponses.some( (response) => { + return b64DecodeUnicode(response.response).toLowerCase().includes(lowerCaseSearch); + }); + if(matchingResponseStreamed){ + fetchResponsePage(currentPageRef.current, false); + } + return; + } + const currentTotalCount = Math.max(totalCountRef.current, taskResponseCountRef.current); + if(props.selectAllOutput){ + const previousResponses = rawResponsesRef.current; + if(previousResponses.length >= MAX_SELECT_ALL_RESPONSES){ + warnSelectAllCapped(previousResponses.length, currentTotalCount); return; } - // we still have some room to view more, but only room for initialResponseStreamLimit - totalFetched.current - let newTotal = totalCount; - const newerResponses = data.data.response_stream.reduce( (prev, cur) => { - if(!seenResponseIDs.current.includes(cur.id)){ - newTotal += 1; - seenResponseIDs.current.push(cur.id); - } - let prevIndex = prev.findIndex( (v,i,a) => v.id === cur.id); - if(prevIndex >= 0){ - prev[prevIndex] = {...cur, response: b64DecodeUnicode(cur.response)}; - return prev; - } - return [...prev, {...cur, response: b64DecodeUnicode(cur.response)}] - }, rawResponses); - // sort them to make sure we're still in order - newerResponses.sort( (a,b) => a.id > b.id ? 1 : -1); - setTotalCount(newTotal); - // newerResponses is everything we've seen plus everything new - if(initialResponseStreamLimit > 0 && !props.selectAllOutput){ - // take just the responses that make up our stream limit - const finalRawResponses = newerResponses.slice(0, initialResponseStreamLimit); - const outputResponses = finalRawResponses.reduce( (prev, cur) => { - return prev + cur.response; - }, b64DecodeUnicode("")); - if(finalRawResponses.length !== rawResponses.length){ - setRawResponses(finalRawResponses); - setOutput(outputResponses); - } - } else { - setRawResponses(newerResponses); - const outputResponses = newerResponses.reduce( (prev, cur) => { - return prev + cur.response; - }, b64DecodeUnicode("")); - setOutput(outputResponses); + const remainingResponseSlots = MAX_SELECT_ALL_RESPONSES - previousResponses.length; + const decodedResponses = newStreamedResponses.slice(0, remainingResponseSlots).map(decodeResponse); + const mergedResponses = mergeResponsesById({ + existingResponses: previousResponses, + incomingResponses: decodedResponses, + limit: MAX_SELECT_ALL_RESPONSES, + }); + rawResponsesRef.current = mergedResponses; + setRawResponses(mergedResponses); + warnSelectAllCapped(mergedResponses.length, currentTotalCount); + return; + } + const pageStartIndex = (currentPageRef.current - 1) * pageSize; + const pageEndIndex = currentPageRef.current * pageSize; + const streamedStartIndex = Math.max(0, currentTotalCount - newStreamedResponses.length); + const streamedEndIndex = currentTotalCount; + if(streamedStartIndex >= pageEndIndex || streamedEndIndex <= pageStartIndex){ + return; + } + const visibleStreamedResponses = newStreamedResponses.reduce( (responses, response, index) => { + const responseIndex = streamedStartIndex + index; + if(responseIndex >= pageStartIndex && responseIndex < pageEndIndex){ + return [...responses, decodeResponse(response)]; } - setOpenBackdrop(false); - - }; + return responses; + }, []); + if(visibleStreamedResponses.length === 0){ + return; + } + const mergedResponses = mergeResponsesById({ + existingResponses: rawResponsesRef.current, + incomingResponses: visibleStreamedResponses, + limit: pageSize, + }); + rawResponsesRef.current = mergedResponses; + setRawResponses(mergedResponses); + }, [fetchResponsePage, pageSize, props.selectAllOutput, search, warnSelectAllCapped]); useSubscription(subResponsesStream, { variables: {task_id: props.task.id}, - fetchPolicy: "network_only", + fetchPolicy: "no-cache", onData: subscriptionDataCallback }); - const onSubmitPageChange = (currentPage) => { - //console.log("onSubmitPageChange") - if(search.current === undefined || search.current === ""){ - fetchMoreResponses({variables: {task_id: props.task.id, - fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit, - offset: initialResponseStreamLimit * (currentPage - 1), - search: "%_%" - }}) - }else{ - fetchMoreResponses({variables: {task_id: props.task.id, - fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit, - offset: initialResponseStreamLimit * (currentPage - 1), - search: "%" + search.current + "%" - }}) - } + const onSubmitPageChange = (nextPage) => { + fetchResponsePage(nextPage); } const onSubmitSearch = React.useCallback( (newSearch) => { - search.current = newSearch; - //console.log("onSubmitSearch") - //setOpenBackdrop(true); - if(newSearch === undefined || newSearch === ""){ - if(props.selectAllOutput){ - fetchAllResponses({variables: {task_id: props.task.id, search: "%%"}}) - }else{ - fetchMoreResponses({variables: {task_id: props.task.id, - fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit, - offset: 0, - search: "%_%" - }}) - } - - }else{ - if(props.selectAllOutput){ - fetchAllResponses({variables: {task_id: props.task.id, search: "%" + newSearch + "%"}}) - }else{ - fetchMoreResponses({variables: {task_id: props.task.id, - fetchLimit: initialResponseStreamLimit, - offset: 0, - search: "%" + newSearch + "%" - }}) - } - - } - }, [search.current]); + selectAllWarningShown.current = false; + currentPageRef.current = 1; + setCurrentPage(1); + setSearch(newSearch || ""); + }, []); return ( - <> +