Compare commits

..
9 Commits
Author SHA1 Message Date
Cody Thomas 96b24b48a7 Merge pull request #594 from je4yfc/integration
Generalized Support for external services across compose generation and port checks
2026-10-11 10:24:27 -05:00
Cody Thomas bdc722f6ce Merge pull request #589 from mjmaley/fix/cli-pasted-whitespace
Handle pasted whitespace in task arguments
2026-10-08 10:39:11 -05:00
Joseph Erdosy f9e71d0859 feat(cli): generalize external service handling in compose configuration and port checks
Signed-off-by: Joseph Erdosy <[email protected]>
2026-09-10 10:46:37 -04:00
Joseph Erdosy 742d878928 fix(cli): support external Nginx and create config dir for block lists
Signed-off-by: Joseph Erdosy <[email protected]>
2026-09-10 10:46:34 -04:00
Joseph Erdosy 01ea8bc896 feat(database): add external PostgreSQL support and configurable SSL mode (#587, #590)
Signed-off-by: Joseph Erdosy <[email protected]>
2026-09-10 10:46:31 -04:00
pwn 94418bcd52 fix(cli): fail fast on docker-compose failures instead of misleading health loop
The pty path in runDockerCompose returned the (nil) error variable on
nonzero exit, and start() discarded ServiceStart's error. Both together
let 'mythic-cli start' proceed into the UI probe loop after a failed
compose up, ending in os.Exit(1) with unrelated diagnostics (run-1.log).

- dockerComposeManager.runDockerCompose: return a descriptive error on
  nonzero exit in the pty path
- start: log.Fatalf on ServiceStart error so compose failures surface
  immediately

Signed-off-by: Joseph Erdosy <[email protected]>
2026-09-10 10:46:28 -04:00
Cody Thomas 3ae9c5fe18 Merge pull request #593 from je4yfc/cli-error-handling-fixes
fix(cli): fail fast on docker-compose failures in mythic-cli start
2026-09-07 10:57:19 -05:00
pwn 5e85a20d14 fix(cli): fail fast on docker-compose failures instead of misleading health loop
The pty path in runDockerCompose returned the (nil) error variable on
nonzero exit, and start() discarded ServiceStart's error. Both together
let 'mythic-cli start' proceed into the UI probe loop after a failed
compose up, ending in os.Exit(1) with unrelated diagnostics (run-1.log).

- dockerComposeManager.runDockerCompose: return a descriptive error on
  nonzero exit in the pty path
- start: log.Fatalf on ServiceStart error so compose failures surface
  immediately
2026-09-06 14:29:33 -04:00
mjmaley 5f46db6ede Handle pasted whitespace in task arguments 2026-09-02 16:56:36 -04:00
911 changed files with 44638 additions and 97779 deletions

No files matched your search

-2
View File
@@ -2,11 +2,9 @@ version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
target-branch: "Mythic-v4.0.0"
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
directory: "/"
target-branch: "Mythic-v4.0.0"
schedule:
interval: "weekly"
+52 -50
View File
@@ -9,7 +9,7 @@ on:
# types: [published]
push:
branches:
- 'Mythic-v4.0.0'
- 'Mythic3.3'
- 'master'
tags:
- "v*.*.*"
@@ -44,7 +44,7 @@ env:
VERSION: ${{ github.ref_name }}
# Branch for pushing release changes
RELEASE_BRANCH: "Mythic-v4.0.0"
RELEASE_BRANCH: master
jobs:
# Builds the base container image and pushes it to the container registry
@@ -55,27 +55,27 @@ jobs:
packages: write
steps:
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# the following are unique to this job
- name: Lowercase the server container image name
run: echo "MYTHIC_SERVER_IMAGE_NAME=${MYTHIC_SERVER_IMAGE_NAME,,}" >> ${GITHUB_ENV}
- name: Copy version file over
run: cp VERSION mythic-docker/src/VERSION
- name: Build and push the server container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: mythic-docker
file: mythic-docker/.docker/Dockerfile
@@ -99,25 +99,25 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# the following are unique to this job
- name: Lowercase the postgres container image name
run: echo "MYTHIC_POSTGRES_IMAGE_NAME=${MYTHIC_POSTGRES_IMAGE_NAME,,}" >> ${GITHUB_ENV}
- name: Build and push the postgres container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: postgres-docker
file: postgres-docker/Dockerfile
@@ -141,25 +141,25 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# the following are unique to this job
- name: Lowercase the rabbitmq container image name
run: echo "MYTHIC_RABBITMQ_IMAGE_NAME=${MYTHIC_RABBITMQ_IMAGE_NAME,,}" >> ${GITHUB_ENV}
- name: Build and push the rabbitmq container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: rabbitmq-docker
file: rabbitmq-docker/Dockerfile
@@ -183,25 +183,25 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# the following are unique to this job
- name: Lowercase the documentation container image name
run: echo "MYTHIC_DOCUMENTATION_IMAGE_NAME=${MYTHIC_DOCUMENTATION_IMAGE_NAME,,}" >> ${GITHUB_ENV}
- name: Build and push the documentation container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: documentation-docker
file: documentation-docker/Dockerfile
@@ -225,27 +225,27 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# these two are unique
- name: Lowercase the jupyter container image name
run: echo "MYTHIC_JUPYTER_IMAGE_NAME=${MYTHIC_JUPYTER_IMAGE_NAME,,}" >> ${GITHUB_ENV}
- name: Build and push the jupyter container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: jupyter-docker
file: jupyter-docker/Dockerfile
@@ -269,27 +269,27 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# these two are unique
- name: Lowercase the graphql container image name
run: echo "MYTHIC_GRAPHQL_IMAGE_NAME=${MYTHIC_GRAPHQL_IMAGE_NAME,,}" >> ${GITHUB_ENV}
- name: Build and push the graphql container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: hasura-docker
file: hasura-docker/Dockerfile
@@ -313,27 +313,27 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# these two are unique
- name: Lowercase the nginx container image name
run: echo "MYTHIC_NGINX_IMAGE_NAME=${MYTHIC_NGINX_IMAGE_NAME,,}" >> ${GITHUB_ENV}
- name: Build and push the nginx container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: nginx-docker
file: nginx-docker/Dockerfile
@@ -357,30 +357,32 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
with:
lfs: true
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# these two are unique
- name: Lowercase the react container image name
run: echo "MYTHIC_REACT_IMAGE_NAME=${MYTHIC_REACT_IMAGE_NAME,,}" >> ${GITHUB_ENV}
- name: Build and push the react container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: .
file: mythic-react-docker/.docker/Dockerfile
context: mythic-react-docker
file: mythic-react-docker/Dockerfile
tags: |
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:${{ env.VERSION }}
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:latest
@@ -411,20 +413,20 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v4 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
uses: docker/setup-qemu-action@v3
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v3
# the following are unique to this job
- name: Lowercase the cli container image name
run: echo "MYTHIC_CLI_IMAGE_NAME=${MYTHIC_CLI_IMAGE_NAME,,}" >> ${GITHUB_ENV}
@@ -435,7 +437,7 @@ jobs:
sed -i "s|^const MythicDockerLatest =.*$|const MythicDockerLatest = \"${VERSION}\"|" constants.go
- name: Build and push the cli container image
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: Mythic_CLI
file: Mythic_CLI/Dockerfile
@@ -470,7 +472,7 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
# update names to lowercase
- name: Lowercase the server container image name
-5
View File
@@ -169,9 +169,4 @@ jupyter-docker/jupyter/MythicExamples/.ipynb_checkpoints/
nginx-docker/config/blockips.conf
/MythicReactUI/build/
/MythicReactUI/node_modules/
/mythic-react-docker/mythic/public/
/.gocache/
.gocache/
MythicReactUI/build
MythicReactUI/node_modules
+1 -105
View File
@@ -4,110 +4,6 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.0.9] - 2026-11-04
### Changed
- applied a v4 version of PR #607 to address cursors in payloadtype/c2 sync and remote postgres connections
## [4.0.7] - 2026-07-31
### Breaking Changes
- Reworked authentication for protected HTTP and GraphQL requests:
- Protected requests no longer authenticate from the `mythic` cookie; all clients must send access tokens and API tokens with the `Authorization: Bearer <token>` header.
- Replaced JWT-based API tokens with opaque `mtk_` tokens that are hashed at rest and shown only when created.
- Be sure to save your API Tokens when you generate them because you cannot recover them in the UI or database!
- Existing API tokens must be regenerated after upgrading.
- Added resource-level read/write scopes to API tokens and enforced them across Hasura actions, webhooks, and Mythic RPC calls.
- Write scopes include the corresponding read scope.
- Tokens can use explicit scopes, resource wildcards such as `task.*`, or the global `*` scope.
- A token can only grant scopes already available to its caller.
- Using a JWT (access_token) through the web UI automatically have a global `*` scope.
- Removed the `/api/v1.4` prefix from Mythic action and webhook routes and updated the Hasura action base URL accordingly.
- Normalized the remaining snake_case Hasura action names to camelCase, including:
- `callbackgraphedge_add` and `callbackgraphedge_remove` to `callbackgraphedgeAdd` and `callbackgraphedgeRemove`
- `config_check` to `configCheck`
- `download_bulk` to `downloadBulk`
- `dynamic_query_function` to `dynamicQueryFunction`
- `rebuild_payload` to `rebuildPayload`
- `redirect_rules` to `redirectRules`
- `reissue_task` and `reissue_task_handler` to `reissueTask` and `reissueTaskHandler`
- `typedarray_parse_function` to `typedarrayParseFunction`
- `meHook` was replaced by the expanded `whoami` query
- Standardized task and callback action arguments on operation-scoped display IDs.
- Integrations should migrate ambiguous fields such as `task_id`, `callback_id`, `tasks`, and `callbacks` to the corresponding `task_display_id`, `callback_display_id`, `task_display_ids`, and `callback_display_ids` fields.
- Added authenticated RabbitMQ context propagation and scope enforcement for container RPC requests and responses.
- All containers must use compatible container libraries that preserve the Mythic auth-context header!
- Changed eventing `mythic.apitoken` inputs from a scalar value to an object with an explicit `type` and optional `scopes` list.
- Replaced wrapper payload type allowlists with `wrapper_payload_requirements`.
- Wrapper containers should declare requirement rules, and payload builders can return `build_metadata` used to select compatible payloads.
### Added
- Added operation chat with standard and AI-backed channels.
- Supports channel creation, locking, archiving, muting, unread state, history, full-text search, and message editing/deletion.
- Supports streamed AI responses, cancellation, retry, model and channel metadata, and per-channel bot API tokens.
- Added structured tool-use output, operator approval and input requests, selectable choices, delegation metadata, and sub-agent summaries/final output.
- Added Markdown rendering and clickable metadata references in chat.
- Added eventing steps that can pause for operator approval or runtime input and resume after a response is submitted.
- Added scoped API-token discovery and introspection through `apiTokenScopeDefinitions`, `scopeCheck`, and the expanded `whoami` response.
- Added operator-owned command and generic aliases with payload type/chat container scoping, nested expansion, and import/export support.
- Added task parameter reference expansion:
- `@cred` references can resolve credential fields or complete structured credential parameters.
- `@link` references can resolve payload links and track payload-on-host relationships.
- Resolution details are stored with the task and sent to payload type containers.
- Added richer build and C2 parameter definitions:
- Parameter groups, display names, display names for choices, hide conditions, and dynamic queries.
- Complex dynamic choices and `other_parameters` context.
- `JSONString` parameters with inline JSON Schema rendering.
- Saved, named build-parameter instances alongside saved C2 parameter instances.
- Added conditional wrapper compatibility based on payload build metadata, and the wrapper's selected build parameters.
- Added payload build metadata and paginated discovery of payloads compatible with a wrapper.
- Added offset-based file transfers alongside chunk-number transfers, including resume support and byte-level progress tracking.
- Added an interactive file-editor task response protocol with version history, conflict detection, staged previews, refresh, force-overwrite, and close actions.
- Added tracked C2 file-hosting state with update, retry, stop, and remove operations.
- Added a payload-type agent RPC path for asynchronous payload-wide requests to callbacks.
- Added richer credentials with subtype, structured identity, metadata, custom display values, update support, and dedicated JWT and Kerberos renderers.
- Added `custom_rpc_timeout` for RPC calls that are expected to run longer without retrying duplicate work.
### Changed
- Modernized the React UI with standardized dialogs, action buttons, state/status chips, page headers, colors, pagination, filtering, and virtualized resizable tables.
- Reworked callback tasking and output:
- Added xterm-based interactive terminal output and improved keyboard/scroll handling.
- Improved task response pagination, streaming, Markdown/terminal display, single-task views, filters, and completion tracking.
- Updated callback tabs, rows, C2 path diagrams, and the callback file/process browsers.
- Updated the payload builder, wrapper builder, C2 profile pages, eventing editor, search pages, payload tables, browser scripts, reporting, installed services, settings, and dashboard to use the new UI components and workflows.
- Added authenticated media, screenshot, file preview, upload, and download handling now that browser cookie authentication is no longer used.
- Improved the file browser's incremental insert/delete processing, directory deletion behavior, container file browsing, and large update performance.
- Improved proxy traffic, delegate routing, callback graph path caching, RabbitMQ dispatch, and Mythic RPC performance.
- Improved task/response sequencing and callback/task display-ID allocation under concurrent activity.
- Updated API-token creation for bot accounts so an operation member can create a token for a bot in the same operation, subject to view mode and delegated scopes.
- Updated C2, payload, and build-parameter synchronization to use stored compatibility metadata instead of legacy resync RPCs.
- Updated the documentation UI with light/dark themes, collapsible navigation, preserved sidebar state, and in-page navigation.
- Updated the React release build to use chunked assets, gzip compression, and the UI baked into the published image by default.
- Local `build_ui` output is mounted only when local UI build context is enabled.
- Updated the bundled Jupyter Mythic package to `0.3.0-rc8`.
### Fixed
- Prevented duplicate container `OnStart` calls and duplicate file-upload processing.
- Fixed out-of-order and repeated file chunks inflating transfer progress or prematurely completing downloads.
- Fixed task completion callback races and duplicate callback-function execution.
- Fixed response decoding so one malformed response no longer discards the rest of a response batch.
- Fixed operation alert counts becoming stale during concurrent event-log updates, deletes, or warning resolution.
- Fixed file-browser update ordering, upload tracking, remote-path handling, and large insert/delete operations.
- Fixed artifact search and multiple search-table rendering/filtering issues.
- Fixed payload and C2 synchronization races, container-down warning resolution, SQL-query handling, and hosted-file state tracking.
- Fixed multiple callback table, single-task view, browser-script, eventing, payload, and responsive-layout issues.
### Removed
- Removed the obsolete operation-specific browser-script mapping table.
- Removed the legacy browser-script `screenshot`, `download`, and `search` result renderers; use the supported media, table, and plaintext response formats instead.
- Removed the legacy payload type, C2 profile, and translation-container resync RPC send paths.
## [3.4.36] - 2026-06-21
### Changed
@@ -2375,4 +2271,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [2.3.12] - 2022-08-25
### Changed
- Updated P2P communications spec to return `mythic_uuid` and `new_uuid` (same value). Eventually `mythic_uuid` will be removed entirely to help reduce the number of mandatory `mythic` strings in agents.
- Updated P2P communications spec to return `mythic_uuid` and `new_uuid` (same value). Eventually `mythic_uuid` will be removed entirely to help reduce the number of mandatory `mythic` strings in agents.
-20
View File
@@ -1,20 +0,0 @@
*
!Dockerfile
!package.json
!package-lock.json
!config-overrides.js
!public/
!public/**
!src/
!src/**
!.env
.DS_Store
**/.DS_Store
node_modules/
build/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.npm/
.cache/
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24-alpine
FROM node:20-alpine
WORKDIR /app
+79 -1497
View File
File diff suppressed because it is too large. Load diff
+4 -5
View File
@@ -26,14 +26,12 @@
"@mui/material": "^7.0.2",
"@mui/utils": "^7.0.2",
"@mui/x-charts": "^8.1.0",
"@mui/x-data-grid": "^8.1.0",
"@mui/x-date-pickers": "^8.1.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.6.0",
"ace-builds": "^1.40.1",
"anser": "^2.3.2",
"dayjs": "^1.11.13",
"dompurify": "^3.4.17",
"elkjs": "^0.10.0",
"graphql": "^16.10.0",
"graphql-ws": "^6.0.4",
@@ -42,6 +40,8 @@
"ip6": "^0.2.10",
"json5": "^2.2.3",
"jwt-decode": "^4.0.0",
"moment": "^2.30.1",
"moment-timezone": "^0.5.45",
"path-browserify": "^1.0.1",
"randexp": "^0.5.3",
"react": "^18.3.1",
@@ -49,7 +49,7 @@
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.4.6",
"react-markdown": "^10.1.0",
"react-moment": "^1.1.3",
"react-router-dom": "^7.13.1",
"react-scrollbar-size": "^5.0.0",
"react-split": "^2.0.14",
@@ -58,7 +58,6 @@
"react-virtualized": "^9.22.6",
"react-virtualized-auto-sizer": "^1.0.26",
"react-window": "^1.8.11",
"remark-gfm": "^4.0.1",
"save-svg-as-png": "^1.4.17",
"semver": "^7.6.2",
"sql.js": "^1.13.0",
@@ -1,61 +0,0 @@
let browserScript;
let browserScriptSource;
let activeRequestID;
const serializeError = (error) => ({
name: (error && error.name) || "Error",
message: (error && error.message) || String(error),
stack: error && error.stack,
});
const postLog = (...args) => {
try {
self.postMessage({type: "log", requestID: activeRequestID, args});
} catch (error) {
self.postMessage({
type: "log",
requestID: activeRequestID,
args: args.map((arg) => String(arg)),
});
}
};
console.log = postLog;
self.onmessage = ({data}) => {
if(!data || data.type !== "render"){
return;
}
const {requestID, script, task, responses} = data;
activeRequestID = requestID;
let phase = "compile";
try {
if(typeof script === "string" && script !== browserScriptSource){
browserScript = undefined;
browserScriptSource = undefined;
browserScript = Function(`"use strict";return(${script})`)();
if(typeof browserScript !== "function"){
throw new TypeError("Browser script must evaluate to a function");
}
browserScriptSource = script;
}
if(typeof browserScript !== "function"){
throw new Error("No browser script is loaded");
}
phase = "execute";
const result = browserScript(task, responses);
if(result && typeof result.then === "function"){
throw new TypeError("Browser scripts must return their display data synchronously");
}
phase = "serialize";
self.postMessage({type: "result", requestID, result});
} catch (error) {
self.postMessage({type: "error", requestID, phase, error: serializeError(error)});
} finally {
activeRequestID = undefined;
}
};
-25
View File
@@ -21,31 +21,6 @@
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Mythic</title>
<script>
(function () {
var resizeObserverLoopErrors = [
"ResizeObserver loop limit exceeded",
"ResizeObserver loop completed with undelivered notifications."
];
function isResizeObserverLoopError(message) {
return resizeObserverLoopErrors.indexOf(message) !== -1;
}
window.addEventListener("error", function (event) {
if (isResizeObserverLoopError(event.message)) {
event.stopImmediatePropagation();
event.preventDefault();
}
}, true);
window.addEventListener("unhandledrejection", function (event) {
var reason = event.reason;
var message = reason && reason.message ? reason.message : reason;
if (isResizeObserverLoopError(message)) {
event.stopImmediatePropagation();
event.preventDefault();
}
}, true);
})();
</script>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
+61 -234
View File
@@ -1,7 +1,6 @@
import { makeVar } from '@apollo/client';
import {restartWebsockets} from "./index";
import {snackActions} from "./components/utilities/Snackbar";
import {mergeRefreshedUser} from "./userState";
export const meState = makeVar({loggedIn:false, user: null, access_token: null, refresh_token: null});
export const alertCount = makeVar(0);
@@ -19,73 +18,22 @@ export const taskTimestampDisplayFieldOptions = [
display: "When Agent Picked up Task",
}
]
export const taskingDisplayFieldOptions = [
{
name: "timestamp",
display: "Timestamp",
description: "Show the configured task timestamp."
},
{
name: "task",
display: "Task number",
description: "Show the T-number link for each task."
},
{
name: "username",
display: "Username",
description: "Show the operator that issued the task."
},
{
name: "callback",
display: "Callback number",
description: "Show the C-number link for the task callback."
},
{
name: "host",
display: "Host",
description: "Show the callback host."
},
{
name: "ip",
display: "IP address",
description: "Show the callback primary IP."
},
{
name: "groups",
display: "Callback groups",
description: "Show the callback's tree groups."
},
{
name: "payload_type",
display: "Payload type",
description: "Show the task payload type."
},
];
export const defaultTaskingDisplayFields = ["timestamp", "task", "username", "callback", "payload_type"];
export const normalizeTaskingDisplayFields = (fields) => {
if(!Array.isArray(fields)){
return [...defaultTaskingDisplayFields];
}
const validFieldNames = taskingDisplayFieldOptions.map((option) => option.name);
return fields.reduce( (prev, fieldName) => {
if(validFieldNames.includes(fieldName) && !prev.includes(fieldName)){
return [...prev, fieldName];
}
return prev;
}, []);
}
export const taskingContextFieldsOptions = ["impersonation_context", "cwd", "user", "host", "ip", "pid", "process_short_name", "extra_info", "architecture"].sort();
export const defaultShortcuts = [
"ActiveCallbacks", "Payloads", "PayloadTypesAndC2",
"Operations", "SearchFiles", "SearchProxies",
"CreatePayload", "Eventing", "Chat",
"CreatePayload", "Eventing",
].sort();
export const operatorSettingDefaults = {
fontSize: 12,
navBarOpen: false,
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontFamily: "Verdana, Arial, sans-serif",
showMedia: true,
hideUsernames: false,
showIP: false,
showHostname: false,
showOPSECBypassUsername: false,
taskingDisplayFields: defaultTaskingDisplayFields,
showCallbackGroups: false,
useDisplayParamsForCLIHistory: true,
interactType: "interactSplit",
taskTimestampDisplayField: "timestamp",
@@ -96,233 +44,119 @@ export const operatorSettingDefaults = {
hideTaskingContext: false,
taskingContextFields: ["impersonation_context", "cwd"],
["experiment-responseStreamLimit"]: 200,
chatSelectedChannelID: 0,
virtualizedTablePadding: 8,
sideShortcuts: defaultShortcuts,
palette: {
primary: {
dark: "#7dd3fc",
light: "#2563eb",
dark: "#75859b",
light: "#75859b",
},
error: {
dark: '#fb7185',
light: '#dc2626'
dark: '#bd5142',
light: '#c42c32'
},
success: {
dark: '#34d399',
light: '#15803d',
dark: '#85b089',
light: '#0e7004',
},
secondary: {
dark: '#94a3b8',
light: '#64748b'
dark: '#bebebe',
light: '#a6a5a5'
},
info: {
dark: '#22d3ee',
light: '#0891b2'
dark: '#84b4dc',
light: '#4990b2'
},
warning: {
dark: "#fbbf24",
light: "#b45309",
dark: "#dc8455",
light: "#ffb74d",
},
background: {
dark: '#0b0f14',
light: '#f6f8fb'
dark: '#282828',
light: '#f6f6f6'
},
paper: {
dark: '#111820',
light: '#ffffff'
},
surfaceRaised: {
dark: '#16212b',
light: '#ffffff'
},
surfaceMuted: {
dark: '#0f1720',
light: '#fafbfc'
dark: '#282828',
light: '#ececec'
},
tableHeader: {
dark: '#17212c',
light: '#e8eef5'
dark: '#484848',
light: '#c4c4c4'
},
tableHover: {
dark: "#1e2a36",
light: "#eaf1f8",
dark: "#3c3c3c",
light: "#e8e8e8",
},
pageHeader: {
dark: '#111a23',
light: '#ffffff'
},
pageHeaderText: {
dark: "#e7edf5",
light: "#0f172a",
},
chatMessageOperatorBackground: {
dark: "#111820",
light: "#ffffff",
},
chatMessageSelfBackground: {
dark: "#142033",
light: "#f8fbff",
},
chatMessageSystemBackground: {
dark: "#2a2116",
light: "#fff7ed",
},
chatMarkdownSurfaceBackground: {
dark: "#0b1118",
light: "#f5f5f5",
},
chatMarkdownSurfaceStrongBackground: {
dark: "#1f2a37",
light: "#e2e8f0",
},
sectionHeaderAccent: {
dark: "#7dd3fc",
light: "#b3cbff",
},
sectionHeaderGradientStart: {
dark: "#153049",
light: "#dbeafe",
},
sectionHeaderGradientMiddle: {
dark: "#111f2e",
light: "#eef6ff",
},
sectionHeaderGradientEnd: {
dark: "#111820",
light: "#ffffff",
},
subtleAccentGradientStart: {
dark: "#132338",
light: "#eef6ff",
},
subtleAccentGradientEnd: {
dark: "#111820",
light: "#ffffff",
},
graphGroupColor: {
dark: "#24384a",
light: "#d9e6f2",
},
chartSeries1: {
dark: "#38bdf8",
light: "#0284c7",
},
chartSeries2: {
dark: "#34d399",
light: "#16a34a",
},
chartSeries3: {
dark: "#fbbf24",
light: "#ca8a04",
},
chartSeries4: {
dark: "#fb7185",
light: "#e11d48",
},
chartSeries5: {
dark: "#a78bfa",
light: "#7c3aed",
},
chartSeries6: {
dark: "#2dd4bf",
light: "#0d9488",
},
chartSeries7: {
dark: "#f472b6",
light: "#db2777",
},
chartSeries8: {
dark: "#f97316",
light: "#ea580c",
},
chartSeries9: {
dark: "#818cf8",
light: "#4f46e5",
},
chartSeries10: {
dark: "#a3e635",
light: "#65a30d",
dark: '#1b2025',
light: '#706c6e'
},
text: {
dark: "#e6edf3",
light: "#111827",
},
textSecondary: {
dark: "#a7b2c2",
light: "#526174",
},
textDisabled: {
dark: "#6f7c8d",
light: "#93a1b2",
dark: "#e4e4e4",
light: "#000000",
},
selectedCallbackColor: {
dark: '#123a5a',
light: '#dbeafe',
dark: '#26456e',
light: '#c6e5f6',
},
selectedCallbackHierarchyColor: {
dark: '#172d49',
light: '#edf6ff',
dark: '#273e5d',
light: '#deeff8',
},
backgroundImage: {
dark: null,
light: null
},
navBarIcons: {
dark: '#dce8f8',
dark: '#ffffff',
light: '#ffffff'
},
navBarText: {
dark: '#dce8f8',
dark: '#ffffff',
light: '#ffffff'
},
navBarColor: {
dark: "#1a283d",
light: "#5c73a5",
dark: "#194573",
light: "#3b606d",
},
navBarBottomColor: {
dark: "#0c131c",
light: "#1f2937",
dark: "#330814",
light: "#283581",
},
taskPromptTextColor: {
dark: '#9fb3c8',
light: '#64748b'
dark: '#bebebe',
light: '#a6a5a5'
},
taskPromptCommandTextColor: {
dark: "#f8fafc",
light: "#0f172a",
dark: "#e4e4e4",
light: "#000000",
},
taskContextColor: {
dark: "#14324f",
light: "#dbeafe",
dark: "#122848",
light: "#acc0da",
},
taskContextImpersonationColor: {
dark: "#4a1824",
light: "#fee2e2",
dark: "#641616",
light: "#dec0c0",
},
taskContextExtraColor: {
dark: "#123a35",
light: "#dcfce7",
dark: "#2a5953",
light: "#a7ce9d",
},
emptyFolderColor: {
dark: '#8997aa',
light: '#64748b'
},
folderColor: {
dark: '#f2c86b',
light: '#d6a23f'
dark: '#bebebe',
light: '#a6a5a5'
},
outputBackgroundColor: {
dark: '#070b10',
light: '#f8fafc'
dark: '#282828',
light: '#f6f6f6'
},
outputTextColor: {
dark: '#e6edf3',
light: '#111827',
dark: '#f6f6f6',
light: '#282828',
},
borderColor: {
dark: "#263241",
light: "#d5dee9"
dark: "#595858",
light: "#c5c5c5"
}
},
}
@@ -356,7 +190,7 @@ export const successfulRefresh = (data) => {
let now = new Date();
let serverNow = new Date(data.user.current_utc_time);
const difference = (serverNow.getTime() - now.getTime()) ;
let me = mergeRefreshedUser(meState().user, data.user);
let me = {...meState().user};
me.server_skew = difference;
me.login_time = now;
meState({
@@ -370,14 +204,6 @@ export const successfulRefresh = (data) => {
localStorage.setItem("user", JSON.stringify(me));
}
export const FailedRefresh = (restart_websockets) =>{
const accessToken = localStorage.getItem("access_token");
if(accessToken){
fetch('/logout', {
method: 'POST',
headers: {'Authorization': `Bearer ${accessToken}`},
keepalive: true,
}).catch(() => {});
}
console.log("failed refresh");
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
@@ -401,3 +227,4 @@ export const FailedRefresh = (restart_websockets) =>{
}
}
File diff suppressed because it is too large. Load diff
@@ -22,7 +22,7 @@ const subscribe_payloads = gql`
}
`;
export function EventFeedNotifications() {
export function EventFeedNotifications(props) {
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
//const fromNow = React.useRef( );
useSubscription(subscribe_payloads, {
@@ -1,291 +0,0 @@
import React from 'react';
import {Box, Checkbox, InputAdornment, TextField, Typography} from '@mui/material';
import {MythicActionButton} from './MythicActionButton';
import SearchIcon from '@mui/icons-material/Search';
import {gql, useQuery} from "@apollo/client";
import {MythicChip} from './MythicChip';
export const defaultAPITokenScopes = ["*"];
export const apiTokenScopeDefinitionsQuery = gql`
query apiTokenScopeDefinitionsQuery {
apiTokenScopeDefinitions {
status
error
scopes {
name
display_name
description
resource
access
includes
}
grantable_wildcards
}
}
`;
export const normalizeAPITokenScopeSelection = (scopes = []) => {
const uniqueScopes = Array.from(new Set((scopes || []).filter((scope) => Boolean(scope)))).sort();
if(uniqueScopes.includes("*")){
return ["*"];
}
return uniqueScopes.reduce((prev, scope) => {
const resource = scope.endsWith(".*") ? scope.slice(0, -2) : scope.split(".")[0];
if(scope.endsWith(".*")){
return [
...prev.filter((selectedScope) => !selectedScope.startsWith(`${resource}.`)),
scope,
].sort();
}
if(prev.includes(`${resource}.*`)){
return prev;
}
return [...prev, scope].sort();
}, []);
};
export function APITokenScopeSelector({
selectedScopes = [],
onChange,
requiredScopes = [],
requiredScopeDescriptions = {},
fetchPolicy = "cache-first",
libraryMaxHeight,
className = "",
sx = {},
}) {
const [filter, setFilter] = React.useState("");
const normalizedSelectedScopes = React.useMemo(() => {
return normalizeAPITokenScopeSelection(selectedScopes);
}, [selectedScopes]);
const {data: scopeData, loading: scopeLoading, error: scopeQueryError} = useQuery(apiTokenScopeDefinitionsQuery, {
fetchPolicy,
});
const availableScopes = React.useMemo(() => {
if(scopeData?.apiTokenScopeDefinitions?.status !== "success"){
return [];
}
return scopeData?.apiTokenScopeDefinitions?.scopes || [];
}, [scopeData]);
const grantableWildcards = React.useMemo(() => {
return scopeData?.apiTokenScopeDefinitions?.grantable_wildcards || [];
}, [scopeData]);
const groupedScopes = React.useMemo(() => {
return availableScopes.reduce((prev, cur) => {
const resource = cur.resource || "other";
return {
...prev,
[resource]: [...(prev[resource] || []), cur],
};
}, {});
}, [availableScopes]);
const visibleResources = React.useMemo(() => {
const normalizedFilter = filter.toLowerCase();
return Object.keys(groupedScopes).sort().filter(resource => {
if(normalizedFilter === ""){
return true;
}
if(resource.toLowerCase().includes(normalizedFilter)){
return true;
}
return groupedScopes[resource].some(scope =>
scope.name.toLowerCase().includes(normalizedFilter) ||
(scope.display_name || "").toLowerCase().includes(normalizedFilter) ||
(scope.description || "").toLowerCase().includes(normalizedFilter)
);
});
}, [filter, groupedScopes]);
const visibleScopeCount = React.useMemo(() => {
return visibleResources.reduce((prev, resource) => prev + groupedScopes[resource].length, 0);
}, [groupedScopes, visibleResources]);
const scopeLoadFailed = scopeQueryError !== undefined || scopeData?.apiTokenScopeDefinitions?.status === "error";
const scopesUnavailable = scopeLoading || scopeLoadFailed;
const selectedScopesLabel = normalizedSelectedScopes.includes("*") ? "Full access selected" : `${normalizedSelectedScopes.length} selected`;
const fullAccessDisabled = !grantableWildcards.includes("*");
const emitScopes = (nextScopes) => {
if(onChange){
onChange(normalizeAPITokenScopeSelection(nextScopes));
}
}
const scopeIsSelected = (scope) => {
return normalizedSelectedScopes.includes(scope);
}
const toggleScope = (scope) => {
if(scope === "*"){
emitScopes(scopeIsSelected("*") ? [] : defaultAPITokenScopes);
return;
}
const resource = scope.endsWith(".*") ? scope.slice(0, -2) : scope.split(".")[0];
const withoutAll = normalizedSelectedScopes.filter(s => s !== "*");
const withoutResourceWildcard = withoutAll.filter(s => s !== `${resource}.*`);
if(scope.endsWith(".*")){
emitScopes(normalizedSelectedScopes.includes(scope) ? withoutResourceWildcard : [
...withoutResourceWildcard.filter(s => !s.startsWith(`${resource}.`)),
scope,
].sort());
return;
}
if(normalizedSelectedScopes.includes(scope)){
emitScopes(withoutResourceWildcard.filter(s => s !== scope));
return;
}
emitScopes([...withoutResourceWildcard, scope].sort());
}
const selectVisibleScopes = () => {
const visibleScopeNames = visibleResources.flatMap(resource => groupedScopes[resource].map(scope => scope.name));
emitScopes(Array.from(new Set([
...normalizedSelectedScopes.filter(scope => scope !== "*"),
...visibleScopeNames,
])).sort());
}
const clearScopes = () => {
emitScopes([]);
}
const librarySx = libraryMaxHeight === undefined ? undefined : {maxHeight: libraryMaxHeight};
return (
<Box className={className} sx={sx}>
<Box sx={{alignItems: "center", display: "flex", flexWrap: "wrap", gap: 0.75, mb: 1}}>
<MythicChip label={`${visibleScopeCount} visible`} />
<MythicChip label={selectedScopesLabel} />
<MythicActionButton disabled={scopesUnavailable || visibleScopeCount === 0 || scopeIsSelected("*")} size="small" onClick={selectVisibleScopes}>
Select Visible
</MythicActionButton>
<MythicActionButton disabled={normalizedSelectedScopes.length === 0} size="small" onClick={clearScopes}>
Clear
</MythicActionButton>
</Box>
{requiredScopes.length > 0 &&
<Box className="mythic-api-token-scope-card items-start flex gap-3 mythic-api-token-scope-card-selected min-w-0 rounded cursor-pointer bg-surface border-subtle text-primary mythic-tone-primary bg-tone-1 border-tone-3" sx={{mb: 1.25}}>
<Box className="mythic-api-token-scope-card-copy min-w-0 w-full">
<Typography className="mythic-api-token-scope-card-title text-sm font-800 leading-125 min-w-0 wrap-anywhere text-primary">Needed for this use</Typography>
{requiredScopes.map((scope) => (
<Typography key={`required-${scope}`} variant="caption" color="text.secondary" sx={{display: "block"}}>
{scope}: {requiredScopeDescriptions[scope] || "Required for this workflow."}
</Typography>
))}
</Box>
</Box>
}
<TextField
className="mythic-api-token-scope-search"
size="small"
fullWidth
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="Search scopes"
disabled={scopesUnavailable}
InputProps={{startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>}}
/>
<Box className="mythic-api-token-scope-library flex flex-column gap-5 overflow-auto rounded bg-neutral-1 border-subtle" sx={librarySx}>
{scopesUnavailable &&
<Box className={`mythic-api-token-scope-state items-center flex justify-center rounded bg-surface-raised border-subtle text-muted text-center${scopeLoadFailed ? " mythic-api-token-scope-state-error mythic-tone-error bg-tone-1 border-tone-3 text-tone" : ""}`}>
<Typography variant="body2">
{scopeLoading ? "Loading scopes..." : (scopeQueryError?.message || scopeData?.apiTokenScopeDefinitions?.error || "Failed to load scopes")}
</Typography>
</Box>
}
{!scopesUnavailable &&
<>
<Box
className={`mythic-api-token-scope-card items-start flex gap-3 mythic-api-token-scope-card-full rounded cursor-pointer bg-surface border-subtle text-primary mythic-tone-warning bg-tone-1 border-tone-2${scopeIsSelected("*") ? " mythic-api-token-scope-card-selected mythic-tone-primary bg-tone-1 border-tone-3" : ""}${fullAccessDisabled ? " mythic-api-token-scope-card-disabled" : ""} min-w-0`}
component="label"
>
<Checkbox
disabled={fullAccessDisabled}
checked={scopeIsSelected("*")}
onChange={() => toggleScope("*")}
/>
<Box className="mythic-api-token-scope-card-copy min-w-0 w-full">
<Typography className="mythic-api-token-scope-card-title text-sm font-800 leading-125 min-w-0 wrap-anywhere text-primary">Full access (*)</Typography>
<Typography className="mythic-api-token-scope-card-description text-xs leading-135 wrap-anywhere text-muted">
Grants every current and future API scope available to this operator.
</Typography>
</Box>
</Box>
{visibleResources.length === 0 &&
<Box className="mythic-api-token-scope-state items-center flex justify-center rounded bg-surface-raised border-subtle text-muted text-center">
<Typography variant="body2">No scopes match your search.</Typography>
</Box>
}
{visibleResources.map(resource => {
const resourceWildcard = `${resource}.*`;
const canGrantResourceWildcard = grantableWildcards.includes(resourceWildcard);
const resourceScopes = [...groupedScopes[resource]].sort((a, b) => (a.access || "").localeCompare(b.access || ""));
const resourceWildcardSelected = scopeIsSelected("*") || scopeIsSelected(resourceWildcard);
return (
<Box className="mythic-api-token-resource-card p-6 flex flex-column gap-5 min-w-0 rounded bg-surface-raised border-subtle" key={resource}>
<Box className="mythic-api-token-resource-header items-start flex flex-wrap gap-6 justify-between min-w-0">
<Box sx={{minWidth: 0}}>
<Typography className="mythic-api-token-resource-title text-sm font-800 leading-120 text-primary">
{resource.split("_").join(" ")}
</Typography>
<Typography className="mythic-api-token-resource-subtitle">
{resourceScopes.length === 1 ? "1 available scope" : `${resourceScopes.length} available scopes`}
</Typography>
</Box>
<Box
className={`mythic-api-token-resource-wildcard text-xs font-750 items-center inline-flex flex-none gap-2 rounded cursor-pointer bg-neutral-1 border-subtle text-muted${resourceWildcardSelected ? " mythic-api-token-resource-wildcard-selected" : ""}${!canGrantResourceWildcard ? " mythic-api-token-resource-wildcard-disabled" : ""}`}
component="label"
>
<Checkbox
size="small"
disabled={scopeIsSelected("*") || !canGrantResourceWildcard}
checked={resourceWildcardSelected}
onChange={() => toggleScope(resourceWildcard)}
/>
<span>{resourceWildcard}</span>
</Box>
</Box>
<Box className="mythic-api-token-scope-grid gap-4 min-w-0 grid">
{resourceScopes.map(scope => {
const includedByWildcard = scopeIsSelected("*") || scopeIsSelected(resourceWildcard);
const scopeSelected = scopeIsSelected(scope.name) || includedByWildcard;
return (
<Box
className={`mythic-api-token-scope-card items-start flex gap-3 rounded cursor-pointer bg-surface border-subtle text-primary${scopeSelected ? " mythic-api-token-scope-card-selected mythic-tone-primary bg-tone-1 border-tone-3" : ""}${includedByWildcard ? " mythic-api-token-scope-card-inherited" : ""} min-w-0`}
component="label"
key={scope.name}
>
<Checkbox
size="small"
disabled={includedByWildcard}
checked={scopeSelected}
onChange={() => toggleScope(scope.name)}
/>
<Box className="mythic-api-token-scope-card-copy min-w-0 w-full">
<Box className="mythic-api-token-scope-card-title-row items-start flex gap-4 justify-between min-w-0">
<Typography className="mythic-api-token-scope-card-title text-sm font-800 leading-125 min-w-0 wrap-anywhere text-primary">{scope.display_name || scope.name}</Typography>
<MythicChip
compact
label={scope.access || "scope"}
size="small"
tone={scope.access === "read" ? "info" : ["write", "create", "update"].includes(scope.access) ? "success" : ["delete", "admin"].includes(scope.access) ? "error" : "neutral"}
/>
</Box>
<Typography className="mythic-api-token-scope-name text-xs leading-135 wrap-anywhere text-muted font-mono">
{scope.name}
</Typography>
<Typography className="mythic-api-token-scope-card-description text-xs leading-135 wrap-anywhere text-muted">
{scope.description}
</Typography>
{scope.includes?.length > 0 &&
<Typography className="mythic-api-token-scope-includes text-xs leading-135 font-700 wrap-anywhere mythic-tone-info text-tone">
Includes {scope.includes.join(", ")}
</Typography>
}
</Box>
</Box>
)
})}
</Box>
</Box>
);
})}
</>
}
</Box>
</Box>
)
}
@@ -1,82 +0,0 @@
import React from 'react';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import {normalizeMythicChipTone} from './MythicChip';
import {MythicStyledTooltip} from './MythicStyledTooltip';
export const MythicActionButton = React.forwardRef(function MythicActionButton({
active = false,
appearance, // can be plain or raised
autoWidth = false,
children,
className = "",
colorMode = "hover", // can be always or hover
compact = false,
icon,
iconOnly = false,
label,
muted = false,
rotated = false,
selected = false,
shape, // can be square or round
size = "small",
startIcon,
tone = "neutral",
tooltip,
tooltipStyle = {},
variant = "outlined",
...props
}, ref) {
const resolvedTone = normalizeMythicChipTone(tone);
const resolvedAppearance = appearance || (iconOnly ? "plain" : "raised");
const resolvedShape = shape || (resolvedAppearance === "raised" ? "square" : "round");
const resolvedColorMode = colorMode === "always" ? "always" : "hover";
const shapeClass = iconOnly ? "mythic-action-button-icon" : "mythic-action-button-label";
const resolvedClassName = [
"mythic-action-button",
`mythic-action-button-${resolvedAppearance}`,
shapeClass,
`mythic-action-button-shape-${resolvedShape}`,
`mythic-action-button-color-${resolvedColorMode}`,
`mythic-tone-${resolvedTone}`,
active || selected ? "mythic-action-button-active" : "",
autoWidth ? "mythic-action-button-auto-width" : "",
compact ? "mythic-action-button-compact" : "",
muted ? "mythic-action-button-muted" : "",
rotated ? "mythic-action-button-rotated" : "",
className,
].filter(Boolean).join(" ");
const ariaLabel = props["aria-label"] || tooltip || (typeof label === "string" ? label : undefined);
const content = label ?? children;
const button = iconOnly ? (
<IconButton
ref={ref}
aria-label={ariaLabel}
size={size}
{...props}
className={resolvedClassName}
color="inherit"
>
{icon || children}
</IconButton>
) : (
<Button
ref={ref}
aria-label={ariaLabel}
size={size}
startIcon={icon || startIcon}
variant={variant}
{...props}
className={resolvedClassName}
color="inherit"
>
{content}
</Button>
);
return tooltip ? (
<MythicStyledTooltip title={tooltip} tooltipStyle={{display: "inline-flex", ...tooltipStyle}}>
{button}
</MythicStyledTooltip>
) : button;
});
@@ -3,7 +3,6 @@ import {MythicStyledTooltip} from "./MythicStyledTooltip";
import WifiIcon from '@mui/icons-material/Wifi';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import { faLink } from '@fortawesome/free-solid-svg-icons';
import {ImageWithAuth} from "../utilities/ImageWithAuth";
export const MythicAgentSVGIcon = ({payload_type, style, is_p2p}) => {
const theme = useTheme();
@@ -12,17 +11,17 @@ export const MythicAgentSVGIcon = ({payload_type, style, is_p2p}) => {
}
return (
<MythicStyledTooltip title={payload_type} >
<ImageWithAuth src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"}
style={{...style}} />
<img src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"}
style={{...style}} />
{is_p2p === false &&
<WifiIcon style={{width: "20px", height: "20px", marginLeft: "-20px", marginBottom: "5px",
borderRadius: "10px", border: "1px solid",
backgroundColor: theme.palette.background.default}}/>
backgroundColor: theme.palette.primary.main}}/>
}
{is_p2p === true &&
<FontAwesomeIcon icon={faLink} style={{width: "20px", height: "20px", marginLeft: "-20px", marginBottom: "5px",
borderRadius: "10px", border: "1px solid",
backgroundColor: theme.palette.background.default}} />
backgroundColor: theme.palette.primary.main}} />
}
</MythicStyledTooltip>
@@ -36,8 +35,7 @@ export const MythicAgentSVGIconNoTooltip = ({payload_type, is_p2p, className, st
const extraStyles = style ? style : {};
return (
<div style={{display: "inline-block", left: "50%"}} className={className}>
<ImageWithAuth src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"}
style={{height: "20px", margin: 'auto', ...extraStyles}} />
<img src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"} style={{height: "20px", margin: 'auto', ...extraStyles}}/>
{is_p2p === false &&
<WifiIcon style={{width: "10px", height: "10px", marginLeft: "-5px", marginBottom: "0px",
borderRadius: "10px", border: "1px solid",
@@ -1,50 +0,0 @@
import React from 'react';
import {useTheme} from '@mui/material/styles';
import SmartToyTwoToneIcon from '@mui/icons-material/SmartToyTwoTone';
import {ImageWithAuth} from "../utilities/ImageWithAuth";
const defaultIconProps = {fontSize: "small"};
const shallowEqualObjects = (left = {}, right = {}) => {
if(left === right){
return true;
}
const leftKeys = Object.keys(left);
const rightKeys = Object.keys(right);
if(leftKeys.length !== rightKeys.length){
return false;
}
return leftKeys.every((key) => left[key] === right[key]);
};
const MythicChatContainerIconComponent = ({containerName, className = "", imgClassName = "", iconProps = defaultIconProps, altText}) => {
const theme = useTheme();
const [imageFailed, setImageFailed] = React.useState(false);
const iconName = String(containerName || "").trim();
const imageSrc = iconName ? `/static/${iconName}_${theme.palette.mode}.svg` : "";
const handleImageError = React.useCallback(() => {
setImageFailed(true);
}, []);
React.useEffect(() => {
setImageFailed(false);
}, [imageSrc]);
if(!imageSrc || imageFailed){
return <SmartToyTwoToneIcon {...iconProps} />;
}
return (
<ImageWithAuth
alt={altText || iconName}
className={`${className} ${imgClassName}`.trim()}
onError={handleImageError}
src={imageSrc}
/>
);
};
export const MythicChatContainerIcon = React.memo(MythicChatContainerIconComponent, (previousProps, nextProps) => (
previousProps.containerName === nextProps.containerName &&
previousProps.className === nextProps.className &&
previousProps.imgClassName === nextProps.imgClassName &&
previousProps.altText === nextProps.altText &&
shallowEqualObjects(previousProps.iconProps || defaultIconProps, nextProps.iconProps || defaultIconProps)
));
@@ -1,69 +0,0 @@
import React from 'react';
import Chip from '@mui/material/Chip';
import {useTheme} from "@mui/material/styles";
const supportedTones = new Set([
"primary",
"secondary",
"neutral",
"info",
"success",
"warning",
"error",
]);
export const normalizeMythicChipTone = (tone) => supportedTones.has(tone) ? tone : "secondary";
const ChipPrimitive = React.forwardRef(function ChipPrimitive({
className = "",
color = undefined,
compact = false,
iconOnly = false,
muted = false,
shape,
size = "small",
tone = undefined,
variant = "outlined",
...props
}, ref) {
const resolvedTone = normalizeMythicChipTone(tone || (color ? color : "secondary"));
const muiVariant = variant ? variant : "outlined";
const hasCustomColor = Boolean(color) && !supportedTones.has(color);
const resolvedClassName = [
"mythic-chip-base",
"text-xs",
"font-800",
"items-center",
"inline-flex",
"gap-2",
"max-w-full",
"min-w-0",
"truncate",
"whitespace-nowrap",
shape === "square" ? "mythic-square-chip" : "mythic-chip",
hasCustomColor ? "mythic-chip-custom-color" : `mythic-tone-${resolvedTone}`,
compact ? "mythic-chip-compact text-2xs" : "",
muted ? "mythic-chip-muted" : "",
iconOnly ? "mythic-chip-icon-only justify-center" : "",
variant === "outlined" ? "mythic-chip-outlined" : "",
className,
].filter(Boolean).join(" ");
return (
<Chip
ref={ref}
className={resolvedClassName}
//color={color ? color : undefined}
size={size}
variant={muiVariant}
{...props}
/>
);
});
export const MythicChip = React.forwardRef(function MythicChip(props, ref) {
return <ChipPrimitive ref={ref} shape="pill" {...props} />;
});
export const SquareChip = React.forwardRef(function SquareChip(props, ref) {
return <ChipPrimitive ref={ref} shape="square" {...props} />;
});
@@ -1,120 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Popper from '@mui/material/Popper';
import {HexColorInput, HexColorPicker} from 'react-colorful';
export const isValidHexColor = (color) => {
if(typeof color !== "string"){
return false;
}
if(color.length !== 7){
return false;
}
if(color[0] !== "#"){
return false;
}
return /^#[0-9a-fA-F]{6}$/.test(color);
}
export const getReadableTextColor = (backgroundColor) => {
if(!isValidHexColor(backgroundColor)){
return "#ffffff";
}
const red = parseInt(backgroundColor.slice(1, 3), 16);
const green = parseInt(backgroundColor.slice(3, 5), 16);
const blue = parseInt(backgroundColor.slice(5, 7), 16);
const brightness = (red * 299 + green * 587 + blue * 114) / 1000;
return brightness >= 140 ? "#111827" : "#ffffff";
}
export const MythicColorSwatchInput = ({color, label, onChange, inputWidth = "96px", sx = {}}) => {
const [pickerOpen, setPickerOpen] = React.useState(false);
const swatchRef = React.useRef(null);
const safeColor = isValidHexColor(color) ? color : "#000000";
return (
<ClickAwayListener onClickAway={() => setPickerOpen(false)}>
<Box sx={{display: "flex", alignItems: "center", gap: 1, minWidth: 0, position: "relative", ...sx}}>
<Box
component="button"
type="button"
ref={swatchRef}
onClick={(evt) => {
evt.preventDefault();
evt.stopPropagation();
setPickerOpen((open) => !open);
}}
aria-label={label}
aria-expanded={pickerOpen}
sx={{
alignItems: "center",
backgroundColor: "transparent",
border: "1px solid",
borderColor: "divider",
borderRadius: "4px",
cursor: "pointer",
display: "inline-flex",
flex: "0 0 auto",
height: 30,
justifyContent: "center",
p: "2px",
width: 34,
}}
>
<Box
sx={{
backgroundColor: safeColor,
borderRadius: "3px",
height: "100%",
width: "100%",
}}
/>
</Box>
<Popper
open={pickerOpen}
anchorEl={swatchRef.current}
placement="bottom-start"
style={{zIndex: 100000}}
>
<Box
onMouseDown={(evt) => evt.stopPropagation()}
onClick={(evt) => evt.stopPropagation()}
sx={{
backgroundColor: "background.paper",
border: "1px solid",
borderColor: "divider",
borderRadius: "6px",
boxShadow: 8,
mt: 0.5,
p: 1,
"& .react-colorful": {
width: 210,
height: 160,
},
}}
>
<HexColorPicker color={safeColor} onChange={onChange} />
</Box>
</Popper>
<HexColorInput
color={color}
onChange={onChange}
prefixed={true}
style={{
width: inputWidth,
minWidth: 0,
height: "30px",
boxSizing: "border-box",
borderRadius: "4px",
border: "1px solid rgba(128,128,128,0.45)",
background: "transparent",
color: "inherit",
padding: "0 8px",
fontFamily: "inherit",
fontSize: "0.85rem",
}}
/>
</Box>
</ClickAwayListener>
);
}
@@ -1,9 +1,10 @@
import React from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import {MythicDialog} from './MythicDialog';
import DialogContentText from '@mui/material/DialogContentText';
import DialogContent from '@mui/material/DialogContent';
import {MythicDialogButton, MythicDialogFooter} from "./MythicDialogLayout";
export function MythicConfirmDialog(props) {
const handleSubmit = () => {
@@ -13,10 +14,6 @@ export function MythicConfirmDialog(props) {
}
props.onClose();
}
const acceptIntent = props.acceptColor === "success" ? "primary" :
props.acceptColor === "warning" ? "warning" :
props.acceptColor === "info" ? "info" :
"destructive";
return (
<MythicDialog fullWidth={false} maxWidth="sm" open={props.open} onClose={()=>{props.onClose()}} innerDialog={
@@ -29,14 +26,14 @@ export function MythicConfirmDialog(props) {
</DialogContentText>
</DialogContent>
)}
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
{props.cancelText ? (props.cancelText) : ("Cancel")}
</MythicDialogButton>
<MythicDialogButton onClick={handleSubmit} autoFocus intent={acceptIntent}>
</Button>
<Button onClick={handleSubmit} autoFocus variant="contained" color={props.acceptColor ? (props.acceptColor) : ("error")}>
{props.acceptText ? (props.acceptText) : ("Remove")}
</MythicDialogButton>
</MythicDialogFooter>
</Button>
</DialogActions>
</React.Fragment>
} />
);
@@ -1,6 +1,7 @@
import {MythicActionButton} from "./MythicActionButton";
import React, {useEffect} from 'react';
import Dialog from '@mui/material/Dialog';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Table from '@mui/material/Table';
@@ -18,56 +19,12 @@ import "ace-builds/src-noconflict/theme-monokai";
import "ace-builds/src-noconflict/ext-searchbox";
import {useTheme} from '@mui/material/styles';
import WrapTextIcon from '@mui/icons-material/WrapText';
import {IconButton} from '@mui/material';
import {MythicStyledTooltip} from "./MythicStyledTooltip";
import Draggable from 'react-draggable';
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
import {MythicDialogButton, MythicDialogFooter} from "./MythicDialogLayout";
import {MythicChip} from "./MythicChip";
let mythicDialogIdCounter = 0;
let mythicDialogStack = [];
const MythicDraggableDialogPaper = React.forwardRef(function MythicDraggableDialogPaper(props, ref) {
const {
draggableCancel,
draggableHandle,
draggableOnStart,
draggableOnStop,
...paperProps
} = props;
const nodeRef = React.useRef(null);
const setPaperRef = React.useCallback((node) => {
nodeRef.current = node;
if(typeof ref === "function"){
ref(node);
} else if(ref){
ref.current = node;
}
}, [ref]);
const onStart = React.useCallback((event, data) => {
return draggableOnStart?.(event, data, nodeRef.current);
}, [draggableOnStart]);
return (
<Draggable
nodeRef={nodeRef}
handle={draggableHandle}
cancel={draggableCancel}
onStart={onStart}
onStop={draggableOnStop}
>
<Paper {...paperProps} ref={setPaperRef} />
</Draggable>
);
});
// Kept temporarily for route-level compatibility while consumers migrate to the lightweight base module.
// eslint-disable-next-line no-unused-vars
function LegacyMythicDialog(props) {
const theme = useTheme();
const dialogIdRef = React.useRef(null);
if(dialogIdRef.current === null){
dialogIdRef.current = mythicDialogIdCounter++;
}
export function MythicDialog(props) {
const [draggedState, setDraggedState] = React.useState({
style: {},
paperStyle: {
@@ -81,20 +38,8 @@ function LegacyMythicDialog(props) {
hideBackdrop: false,
modified: false
});
const nodeRef = React.useRef(null);
const descriptionElementRef = React.useRef(null);
React.useEffect(() => {
if(!props.open){
return undefined;
}
mythicDialogStack = mythicDialogStack.filter((id) => id !== dialogIdRef.current);
mythicDialogStack.push(dialogIdRef.current);
return () => {
mythicDialogStack = mythicDialogStack.filter((id) => id !== dialogIdRef.current);
};
}, [props.open]);
const isTopDialog = () => {
return mythicDialogStack[mythicDialogStack.length - 1] === dialogIdRef.current;
};
React.useEffect(() => {
if (props.open) {
const { current: descriptionElement } = descriptionElementRef;
@@ -104,77 +49,71 @@ function LegacyMythicDialog(props) {
}
}, [props.open]);
const dialogOnClick = (e) => {
if(e.target.classList?.contains("MuiDialog-container")){
e.stopPropagation();
if(!isTopDialog()){
return;
}
if(draggedState.hideBackdrop){
props.onClose?.();
}
//e.preventDefault();
//e.stopPropagation();
if(e.target.classList.length > 0 && e.target.classList.contains("MuiDialog-container")){
props.onClose();
}
}
const dialogOnContextMenu = (e) => {
e.stopPropagation();
}
const handleOnClose = (event, reason) => {
if(reason === "backdropClick" || reason === "escapeKeyDown"){
event?.stopPropagation?.();
if(!isTopDialog()){
return;
}
}
if(reason === "backdropClick" && draggedState.hideBackdrop){
return;
}
props.onClose?.();
props.onClose();
}
const onStart = React.useCallback((e, _data, paperNode) => {
const onStart = (e) => {
if(e){
e.stopPropagation();
e.preventDefault();
}
setDraggedState((currentState) => {
if(currentState.modified){
return currentState;
}
const dragTarget = paperNode || e?.target?.closest?.(".MuiPaper-root") || e?.target?.offsetParent;
const targetWidth = dragTarget?.offsetWidth || e?.target?.offsetParent?.offsetWidth || 0;
return {
style: {},
if(!draggedState.modified){
setDraggedState({
style: {
height: e.target.offsetParent.offsetHeight + "px",
width: e.target.offsetParent.offsetWidth + "px",
margin: "auto",
overflowY: "auto",
},
paperStyle: {
...(targetWidth > 0 ? {width: targetWidth + "px"} : {}),
//height: e.target.offsetParent.offsetHeight + "px",
width: e.target.offsetParent.offsetWidth + "px",
margin: 0,
overflowY: "auto",
},
containerStyle: {
overflow: "visible",
height: "fit-content",
overflowY: "auto"
},
hideBackdrop: true,
modified: true,
};
});
}, []);
const onStop = React.useCallback((e) => {
})
}
}
const onStop = (e) => {
if(e){
e.stopPropagation();
e.preventDefault();
}
}, []);
}
return (
<Draggable
nodeRef={nodeRef}
handle="#mythic-draggable-title"
cancel={'[class*="MuiDialogContent-root"]'}
onStart={onStart}
onStop={onStop}
>
<Dialog
ref={nodeRef}
open={props.open}
onClick={dialogOnClick}
onClose={handleOnClose}
scroll="paper"
maxWidth={props.maxWidth}
fullWidth={true}
PaperComponent={MythicDraggableDialogPaper}
PaperProps={{
draggableHandle: "#mythic-draggable-title",
draggableCancel: '[class*="MuiDialogContent-root"]',
draggableOnStart: onStart,
draggableOnStop: onStop,
}}
style={{...props.style, ...draggedState.style}}
disableEnforceFocus={true}
disablePortal={false}
@@ -182,13 +121,10 @@ function LegacyMythicDialog(props) {
aria-labelledby="scroll-dialog-title"
aria-describedby="scroll-dialog-description"
sx={{
".MuiPaper-root": {
borderRadius: "8px",
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.borderColor}`,
[".MuiPaper-root"]: {
...draggedState.paperStyle
},
".MuiDialog-container": {
[".MuiDialog-container"]: {
...draggedState.containerStyle
}
}}
@@ -197,11 +133,10 @@ function LegacyMythicDialog(props) {
>
{props.innerDialog}
</Dialog>
</Draggable>
);
}
export {MythicDialog} from "./MythicDialogBase";
export function MythicModifyStringDialog(props) {
const [comment, setComment] = React.useState("");
const [wrap, setWrap] = React.useState(props.wrap ? props.wrap : false);
@@ -230,9 +165,9 @@ export function MythicModifyStringDialog(props) {
<MythicDraggableDialogTitle>{props.title}
<MythicStyledTooltip title={wrap ? "Toggle off word wrap" : "Toggl on word wrap"}
tooltipStyle={{float: "right"}}>
<MythicActionButton iconOnly onClick={() => {setWrap(!wrap)}}>
<IconButton onClick={() => {setWrap(!wrap)}}>
<WrapTextIcon color={wrap ? "success" : "secondary"} />
</MythicActionButton>
</IconButton>
</MythicStyledTooltip>
</MythicDraggableDialogTitle>
}
@@ -255,238 +190,162 @@ export function MythicModifyStringDialog(props) {
/>
</DialogContent>
{ (props.onClose || props.onSubmit) &&
<MythicDialogFooter>
<DialogActions>
{props.onClose &&
<MythicDialogButton onClick={props.onClose}>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
</Button>
}
{props.onSubmit &&
<MythicDialogButton intent="primary" onClick={onCommitSubmit}>
<Button onClick={onCommitSubmit} variant="contained" color="success">
{props.onSubmitText ? props.onSubmitText : "Submit"}
</MythicDialogButton>
</Button>
}
</MythicDialogFooter>
</DialogActions>
}
</React.Fragment>
);
}
const isPlainObject = (value) => {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
const parseJSONDialogValue = (value) => {
if(value === undefined || value === null){
return value;
}
if(typeof value !== "string"){
return value;
}
try{
return JSON.parse(value);
}catch(error){
return value;
}
}
const getJSONValueType = (value) => {
if(value === null){return "null"}
if(value === undefined){return "empty"}
if(Array.isArray(value)){return "array"}
if(isPlainObject(value)){return "object"}
return typeof value;
}
const getJSONValueCount = (value) => {
if(Array.isArray(value)){return value.length}
if(isPlainObject(value)){return Object.keys(value).length}
if(value === undefined || value === null || value === ""){return 0}
return 1;
}
const getArrayObjectHeaders = (rows) => {
const headers = [];
rows.forEach((row) => {
if(!isPlainObject(row)){return}
Object.keys(row).forEach((key) => {
if(!headers.includes(key)){
headers.push(key);
}
});
});
return headers;
}
const JSONTypeBadge = ({value}) => {
const type = getJSONValueType(value);
const count = getJSONValueCount(value);
const label = type === "array" ? `${count} item${count === 1 ? "" : "s"}` :
type === "object" ? `${count} field${count === 1 ? "" : "s"}` :
type;
return <MythicChip compact label={label} tone={["array", "object"].includes(type) ? "info" : "neutral"} />;
}
const JSONPrimitiveValue = ({name, value, me}) => {
const type = getJSONValueType(value);
if(type === "null" || type === "empty" || value === ""){
return <span className="mythic-json-value-empty text-xs text-disabled">None</span>
}
if(type === "boolean"){
return <MythicChip compact label={value ? "True" : "False"} tone={value ? "success" : "warning"} />;
}
return <span className="mythic-json-value-primitive text-primary text-xs leading-135 wrap-anywhere">{convertValueToContextValue(name, value, me)}</span>
}
const JSONTableCellValue = ({name, value, me, depth}) => {
if(Array.isArray(value) || isPlainObject(value)){
return (
<JSONTableValue
label={name}
value={value}
me={me}
depth={depth + 1}
/>
);
}
return <JSONPrimitiveValue name={name} value={value} me={me} />;
}
const JSONTableValue = ({label, value, me, depth=0, leftColumn="Name", rightColumn="Value"}) => {
const type = getJSONValueType(value);
const showPanelHeader = Boolean(label) && depth === 0;
if(type !== "array" && type !== "object"){
return <JSONPrimitiveValue name={label || ""} value={value} me={me} />;
}
if(type === "object"){
const entries = Object.entries(value);
return (
<div className={`mythic-json-panel p-4 flex flex-column gap-4 rounded bg-neutral-1 border-subtle${depth === 0 ? " mythic-json-panel-root bg-surface-muted" : ""} min-w-0`}>
{showPanelHeader &&
<div className="mythic-json-panel-header items-center flex flex-wrap gap-4 justify-between min-w-0">
<span className="mythic-json-panel-title text-xs font-800 leading-125 min-w-0 wrap-anywhere text-primary">{label}</span>
<JSONTypeBadge value={value} />
</div>
}
{entries.length === 0 ? (
<div className="mythic-json-empty-state text-xs text-muted">No fields to display.</div>
) : (
<TableContainer className="mythic-json-table-wrap overflow-auto rounded border-subtle">
<Table size="small" stickyHeader={depth === 0} style={{tableLayout: "fixed"}}>
<TableHead>
<TableRow>
<TableCell style={{width: depth === 0 ? "14rem" : "12rem"}}>{leftColumn}</TableCell>
<TableCell>{rightColumn}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{entries.map(([key, entryValue]) => (
<TableRow key={`${depth}-${key}`} hover>
<TableCell className="mythic-json-key-cell">
<div className="mythic-json-key-stack flex flex-column gap-2 min-w-0">
<span className="mythic-json-key text-xs font-800 leading-125 wrap-anywhere text-primary">{key}</span>
<JSONTypeBadge value={entryValue} />
</div>
</TableCell>
<TableCell className="mythic-json-value-cell text-primary text-xs leading-135 wrap-anywhere">
<JSONTableCellValue name={key} value={entryValue} me={me} depth={depth} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</div>
);
}
const objectHeaders = getArrayObjectHeaders(value);
const isObjectArray = value.length > 0 && objectHeaders.length > 0 && value.every((row) => isPlainObject(row));
return (
<div className={`mythic-json-panel p-4 flex flex-column gap-4 rounded bg-neutral-1 border-subtle${depth === 0 ? " mythic-json-panel-root bg-surface-muted" : ""} min-w-0`}>
{showPanelHeader &&
<div className="mythic-json-panel-header items-center flex flex-wrap gap-4 justify-between min-w-0">
<span className="mythic-json-panel-title text-xs font-800 leading-125 min-w-0 wrap-anywhere text-primary">{label}</span>
<JSONTypeBadge value={value} />
</div>
}
{value.length === 0 ? (
<div className="mythic-json-empty-state text-xs text-muted">No items to display.</div>
) : isObjectArray ? (
<TableContainer className="mythic-json-table-wrap overflow-auto rounded border-subtle">
<Table size="small" stickyHeader={depth === 0} style={{tableLayout: "fixed", minWidth: `${Math.max(38, objectHeaders.length * 12)}rem`}}>
<TableHead>
<TableRow>
<TableCell style={{width: "4rem"}}>#</TableCell>
{objectHeaders.map((header) => (
<TableCell key={`array-header-${header}`} style={{width: "12rem"}}>{header}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{value.map((row, rowIndex) => (
<TableRow key={`array-row-${rowIndex}`} hover>
<TableCell className="mythic-json-index-cell text-xs font-800 text-muted text-right">{rowIndex + 1}</TableCell>
{objectHeaders.map((header) => (
<TableCell key={`array-row-${rowIndex}-${header}`} className="mythic-json-value-cell text-primary text-xs leading-135 wrap-anywhere">
<JSONTableCellValue name={header} value={row[header]} me={me} depth={depth} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<TableContainer className="mythic-json-table-wrap overflow-auto rounded border-subtle">
<Table size="small" stickyHeader={depth === 0} style={{tableLayout: "fixed"}}>
<TableHead>
<TableRow>
<TableCell style={{width: "4rem"}}>#</TableCell>
<TableCell>Value</TableCell>
</TableRow>
</TableHead>
<TableBody>
{value.map((entryValue, rowIndex) => (
<TableRow key={`array-value-row-${rowIndex}`} hover>
<TableCell className="mythic-json-index-cell text-xs font-800 text-muted text-right">{rowIndex + 1}</TableCell>
<TableCell className="mythic-json-value-cell text-primary text-xs leading-135 wrap-anywhere">
<JSONTableCellValue name={`${label || "value"} ${rowIndex + 1}`} value={entryValue} me={me} depth={depth} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</div>
);
}
export function MythicViewJSONAsTableDialog(props) {
const parsedValue = React.useMemo(() => parseJSONDialogValue(props.value), [props.value]);
const rootLabel = props.title || "JSON Data";
const [comment, setComment] = React.useState([]);
const [tableType, setTableType] = React.useState("dictionary");
const [headers, setHeaders] = React.useState([]);
useEffect( () => {
let permissions = [];
try{
let permissionDict;
if(props.value.constructor === Object){
permissionDict = props.value;
}else{
permissionDict = JSON.parse(props.value);
}
if(!Array.isArray(permissionDict) && typeof permissionDict !== 'string'){
for(let key in permissionDict){
if(permissionDict[key] && permissionDict[key].constructor === Object){
// potentially have a nested dictionary here or array to become a dictionary, mark it
permissions.push({"name": key, "value": permissionDict[key], new_table: true, is_dictionary: true, headers: ["Name", "Value"]});
} else if(permissionDict[key] && Array.isArray(permissionDict[key])) {
if (permissionDict[key].length === 1){
if(permissionDict[key][0].constructor === Object){
permissions.push({"name": key, "value": permissionDict[key][0], new_table: true, is_dictionary: true, headers: ["Name", "Value"]});
}else{
permissions.push({"name": key, "value": JSON.stringify(permissionDict[key], null, 2)});
}
} else if (permissionDict[key].length > 1) {
if (permissionDict[key][0].constructor === Object) {
let newHeaders = [];
for(let i = 0; i < permissionDict[key].length; i++){
for(let newKey in permissionDict[key][i]){
if(!newHeaders.includes(newKey)){newHeaders.push(newKey)}
}
}
newHeaders.sort()
permissions.push({"name": key, "value": permissionDict[key], new_table: true, is_array: true, headers: newHeaders});
} else {
// it's an array, but not of dictionaries, so just stringify it
permissions.push({"name": key, "value": JSON.stringify(permissionDict[key], null, 2)});
}
} else {
permissions.push({"name": key, "value": JSON.stringify(permissionDict[key], null, 2)});
}
}else if(permissionDict[key] !== undefined && permissionDict[key] !== null){
permissions.push({"name": key, "value": permissionDict[key]});
}
setHeaders([props.leftColumn, props.rightColumn]);
}
}else{
setTableType("array");
if(permissionDict.length > 0){
setHeaders(Object.keys(permissionDict[0]));
permissions = permissionDict;
}else{
setHeaders([]);
}
}
}catch(error){
console.log(error);
}
setComment(permissions);
}, [props.value, props.leftColumn, props.rightColumn]);
return (
<React.Fragment>
<MythicDraggableDialogTitle style={{wordBreak: "break-all", maxWidth: "100%"}}>
<div className="mythic-json-title-row items-center flex flex-wrap gap-4 justify-between min-w-0 w-full">
<span>{rootLabel}</span>
<JSONTypeBadge value={parsedValue} />
</div>
</MythicDraggableDialogTitle>
<DialogContent dividers={true} className="mythic-dialog-body flex flex-column gap-6 mythic-json-dialog-body min-w-0 w-full overflow-auto">
<JSONTableValue
value={parsedValue}
me={props.me}
leftColumn={props.leftColumn || "Name"}
rightColumn={props.rightColumn || "Value"}
/>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<MythicDraggableDialogTitle style={{wordBreak: "break-all", maxWidth: "100%"}}>{props.title}</MythicDraggableDialogTitle>
<TableContainer className="mythicElement" style={{paddingLeft: "10px"}}>
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "calc(100vw)", "overflow": "scroll"}}>
<TableHead>
<TableRow>
{headers.map( (header, index) => (
<TableCell key={'header' + index} style={index === 0 ? {width: "15%", wordBreak: "break-all"} : {wordBreak: "break-all"}}>{header}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{tableType === "dictionary" ? (
comment.map( (element, index) => (
<TableRow key={'row' + index} hover>
<TableCell style={{wordBreak: "break-all"}}>{element.name}</TableCell>
{element.new_table ?
(
<TableContainer className="mythicElement">
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "calc(100vw)", "overflow": "scroll"}}>
<TableHead>
<TableRow>
{element.headers.map( (header, index) => (
<TableCell key={'eheader' + header + index} style={index === 0 ? {width: "15%", wordBreak: "break-all"} : {wordBreak: "break-all"}}>{header}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{element.is_dictionary ? (
Object.keys(element.value).map( (key, dictIndex) => (
<TableRow key={'element' + dictIndex + "dictheader"}>
<TableCell style={{width: "30%", wordBreak: "break-all"}}>{key}</TableCell>
<TableCell style={{wordBreak: "break-all", whiteSpace: "pre-wrap"}}>{convertValueToContextValue(key, element.value[key], props.me)}</TableCell>
</TableRow>
))
): (
element.value.map( (e, elementIndex) => (
<TableRow>
{element.headers.map( (header, headerIndex) => (
<TableCell key={'element' + elementIndex + "header" + headerIndex} style={headerIndex === 0 ? {width: "15%", wordBreak: "break-all"} : {wordBreak: "break-all", whiteSpace: "pre-wrap"}}>{convertValueToContextValue(header, e[header], props.me)}</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)
:
(<TableCell style={{wordBreak: "break-all", whiteSpace: "pre-wrap"}}>{convertValueToContextValue(element.name, element.value, props.me) }</TableCell>)
}
</TableRow>
))
) : (
comment.map( (row, index) => (
<TableRow key={'row' + index} hover>
{Object.keys(row).map( (key) => (
<TableCell key={"row" + index + "cell" + key} style={{wordBreak: "break-all"}}>{convertValueToContextValue(key, row[key], props.me)}</TableCell>
))}
</TableRow>
))
) }
</TableBody>
</Table>
</TableContainer>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
</MythicDialogFooter>
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -510,7 +369,7 @@ export function MythicViewObjectPropertiesAsTableDialog(props) {
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
<DialogContent dividers={true}>
<Paper elevation={5} style={{position: "relative"}} variant={"elevation"}>
<TableContainer component={Paper}>
<TableContainer component={Paper} className="mythicElement">
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "calc(100vw)", "overflow": "scroll"}}>
<TableHead>
<TableRow>
@@ -530,34 +389,30 @@ export function MythicViewObjectPropertiesAsTableDialog(props) {
</TableContainer>
</Paper>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
</MythicDialogFooter>
</Button>
</DialogActions>
</React.Fragment>
);
}
const convertValueToContextValue = (key, value, me) => {
const keyText = String(key || "").toLowerCase();
if(value === undefined || value === null){
return "";
}
if( keyText.includes("time") ){
if( key.includes("time") ){
try{
return TableRowDateCell({cellData: value, view_utc_time: me?.user?.view_utc_time})
}catch(error){
console.log("failed to parse metadata as date", key, value);
return value;
}
} else if( keyText.includes("size") ){
} else if( key.includes("size") ){
try{
return TableRowSizeCell({cellData: value})
}catch(error){
console.log("failed to parse metadata as size", key, value);
return value;
}
} else if (isPlainObject(value)) {
} else if (value.constructor === Object) {
return JSON.stringify(value, null, 2);
} else if (Array.isArray(value)){
return JSON.stringify(value, null, 2);
@@ -625,4 +480,4 @@ export const TableRowSizeCell = ({ cellData, rowData }) => {
}
};
return getStringSize(cellData);
};
};
@@ -1,111 +0,0 @@
import React from "react";
import Dialog from "@mui/material/Dialog";
import Paper from "@mui/material/Paper";
import Draggable from "react-draggable";
import {useTheme} from "@mui/material/styles";
let mythicDialogIdCounter = 0;
let mythicDialogStack = [];
const MythicDraggableDialogPaper = React.forwardRef(function MythicDraggableDialogPaper(props, ref) {
const {draggableCancel, draggableHandle, draggableOnStart, draggableOnStop, ...paperProps} = props;
const nodeRef = React.useRef(null);
const setPaperRef = React.useCallback((node) => {
nodeRef.current = node;
if(typeof ref === "function"){
ref(node);
}else if(ref){
ref.current = node;
}
}, [ref]);
const onStart = React.useCallback((event, data) => {
return draggableOnStart?.(event, data, nodeRef.current);
}, [draggableOnStart]);
return (
<Draggable nodeRef={nodeRef} handle={draggableHandle} cancel={draggableCancel}
onStart={onStart} onStop={draggableOnStop}>
<Paper {...paperProps} ref={setPaperRef} />
</Draggable>
);
});
export function MythicDialog(props) {
const theme = useTheme();
const dialogIdRef = React.useRef(null);
if(dialogIdRef.current === null){
dialogIdRef.current = mythicDialogIdCounter++;
}
const [draggedState, setDraggedState] = React.useState({
style: {},
paperStyle: {margin: "0", height: "fit-content", width: "stretch"},
containerStyle: {},
hideBackdrop: false,
modified: false,
});
const descriptionElementRef = React.useRef(null);
React.useEffect(() => {
if(!props.open){return undefined;}
mythicDialogStack = mythicDialogStack.filter((id) => id !== dialogIdRef.current);
mythicDialogStack.push(dialogIdRef.current);
return () => {
mythicDialogStack = mythicDialogStack.filter((id) => id !== dialogIdRef.current);
};
}, [props.open]);
const isTopDialog = () => mythicDialogStack[mythicDialogStack.length - 1] === dialogIdRef.current;
React.useEffect(() => {
if(props.open && descriptionElementRef.current !== null){
descriptionElementRef.current.focus();
}
}, [props.open]);
const dialogOnClick = (event) => {
if(event.target.classList?.contains("MuiDialog-container")){
event.stopPropagation();
if(isTopDialog() && draggedState.hideBackdrop){
props.onClose?.();
}
}
};
const handleOnClose = (event, reason) => {
if(reason === "backdropClick" || reason === "escapeKeyDown"){
event?.stopPropagation?.();
if(!isTopDialog()){return;}
}
if(reason === "backdropClick" && draggedState.hideBackdrop){return;}
props.onClose?.();
};
const onStart = React.useCallback((event, _data, paperNode) => {
event?.stopPropagation?.();
event?.preventDefault?.();
setDraggedState((currentState) => {
if(currentState.modified){return currentState;}
const dragTarget = paperNode || event?.target?.closest?.(".MuiPaper-root") || event?.target?.offsetParent;
const targetWidth = dragTarget?.offsetWidth || event?.target?.offsetParent?.offsetWidth || 0;
return {
style: {},
paperStyle: {...(targetWidth > 0 ? {width: `${targetWidth}px`} : {}), margin: 0, overflowY: "auto"},
containerStyle: {overflow: "visible"},
hideBackdrop: true,
modified: true,
};
});
}, []);
const onStop = React.useCallback((event) => {
event?.stopPropagation?.();
event?.preventDefault?.();
}, []);
return (
<Dialog open={props.open} onClose={handleOnClose} scroll="paper" maxWidth={props.maxWidth} fullWidth={true}
PaperComponent={MythicDraggableDialogPaper}
PaperProps={{draggableHandle: "#mythic-draggable-title", draggableCancel: '[class*="MuiDialogContent-root"]', draggableOnStart: onStart, draggableOnStop: onStop}}
style={{...props.style, ...draggedState.style}} disableEnforceFocus={true} disablePortal={false}
hideBackdrop={draggedState.hideBackdrop} aria-labelledby="scroll-dialog-title"
aria-describedby="scroll-dialog-description"
sx={{
".MuiPaper-root": {borderRadius: "8px", backgroundColor: theme.palette.background.paper, border: `1px solid ${theme.borderColor}`, ...draggedState.paperStyle},
".MuiDialog-container": {...draggedState.containerStyle},
}}
onMouseDown={dialogOnClick} onContextMenu={(event) => event.stopPropagation()}>
{props.innerDialog}
</Dialog>
);
}
@@ -1,171 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import {MythicActionButton} from './MythicActionButton';
import DialogActions from '@mui/material/DialogActions';
import Typography from '@mui/material/Typography';
const joinClasses = (...classes) => classes.filter(Boolean).join(" ");
export function MythicDialogBody({children, className, compact = false, ...props}) {
return (
<Box
className={joinClasses("mythic-dialog-body flex flex-column gap-6 min-w-0 w-full", compact && "mythic-dialog-body-compact gap-4", className)}
{...props}
>
{children}
</Box>
);
}
export function MythicDialogSection({title, description, actions, children, className, ...props}) {
return (
<Box component="section" className={joinClasses("mythic-dialog-section bg-surface-muted border-subtle min-w-0 rounded", className)} {...props}>
{(title || description || actions) &&
<Box className="mythic-dialog-section-header items-start flex gap-6 justify-between min-w-0">
<Box sx={{minWidth: 0}}>
{title &&
<Typography component="h3" className="mythic-dialog-section-title text-sm font-700 leading-125 text-primary">
{title}
</Typography>
}
{description &&
<Typography component="div" className="mythic-dialog-section-description">
{description}
</Typography>
}
</Box>
{actions &&
<Box className="mythic-dialog-section-actions items-center flex flex-none gap-3">
{actions}
</Box>
}
</Box>
}
{children}
</Box>
);
}
export function MythicDialogGrid({children, className, minWidth = "16rem", sx = {}, ...props}) {
return (
<Box
className={joinClasses("mythic-dialog-grid items-logical-start gap-5 min-w-0 w-full grid", className)}
sx={{"--mythic-dialog-grid-min": minWidth, ...sx}}
{...props}
>
{children}
</Box>
);
}
export function MythicDialogChoiceRow({children, className, ...props}) {
return (
<Box className={joinClasses("mythic-dialog-choice-row items-center gap-4 w-full grid", className)} {...props}>
{children}
</Box>
);
}
export function MythicDialogChoiceDivider({children = "OR", className, ...props}) {
return (
<Box component="span" className={joinClasses("mythic-dialog-choice-divider text-xs font-700 text-muted text-center", className)} {...props}>
{children}
</Box>
);
}
export function MythicDialogFooter({children, className, ...props}) {
return (
<DialogActions className={className} {...props}>
{children}
</DialogActions>
);
}
export function MythicDialogButton({children, className, intent = "secondary", ...props}) {
const tone = intent === "destructive" ? "error" : intent;
return (
<MythicActionButton
className={joinClasses("mythic-dialog-button", className)}
colorMode="always"
size="small"
tone={tone}
variant="contained"
{...props}
>
{children}
</MythicActionButton>
);
}
export function MythicForm({children, className, ...props}) {
return (
<Box component="form" className={joinClasses("mythic-form flex flex-column gap-6 min-w-0 w-full", className)} {...props}>
{children}
</Box>
);
}
export function MythicFormGrid({children, className, minWidth = "16rem", sx = {}, ...props}) {
return (
<Box
className={joinClasses("mythic-form-grid items-logical-start gap-6 min-w-0 w-full grid", className)}
sx={{"--mythic-form-grid-min": minWidth, ...sx}}
{...props}
>
{children}
</Box>
);
}
export function MythicFormField({children, className, description, label, required = false, ...props}) {
return (
<Box className={joinClasses("mythic-form-field flex flex-column gap-3 min-w-0", className)} {...props}>
{(label || description) &&
<Box className="mythic-form-field-copy min-w-0">
{label &&
<Typography component="label" className="mythic-form-field-label text-xs font-750 leading-125 text-primary">
{label}{required && <Box component="span" className="mythic-form-field-required"> *</Box>}
</Typography>
}
{description &&
<Typography component="div" className="mythic-form-field-description">
{description}
</Typography>
}
</Box>
}
<Box className="mythic-form-field-control min-w-0 w-full">
{children}
</Box>
</Box>
);
}
export function MythicFormNote({children, className, ...props}) {
return (
<Box className={joinClasses("mythic-form-note text-xs leading-140 rounded bg-neutral-1 border-subtle text-muted", className)} {...props}>
{children}
</Box>
);
}
export function MythicFormSwitchRow({control, label, description, className, ...props}) {
return (
<Box className={joinClasses("mythic-form-switch-row py-5 px-6 items-center flex gap-6 justify-between min-w-0 w-full rounded bg-surface border-subtle", className)} {...props}>
<Box sx={{minWidth: 0}}>
<Typography component="div" className="mythic-form-field-label text-xs font-750 leading-125 text-primary">
{label}
</Typography>
{description &&
<Typography component="div" className="mythic-form-field-description">
{description}
</Typography>
}
</Box>
<Box className="mythic-form-switch-control flex-none">
{control}
</Box>
</Box>
);
}
@@ -1,17 +1,10 @@
import DialogTitle from '@mui/material/DialogTitle';
import {useTheme} from '@mui/material/styles';
export const MythicDraggableDialogTitle = ({children}) => {
const theme = useTheme();
return (
<DialogTitle id="mythic-draggable-title" style={{
cursor: 'move',
width: "100%",
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
}}>
<DialogTitle id="mythic-draggable-title" style={{ cursor: 'move', width: "100%" }}>
{children}
</DialogTitle>
)
}
}
@@ -1,6 +1,4 @@
import {createPortal} from 'react-dom';
export const reorder = (
list,
startIndex,
@@ -10,11 +8,4 @@ export const reorder = (
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
export const MythicDraggablePortal = ({children, isDragging}) => {
if(isDragging && typeof document !== "undefined"){
return createPortal(children, document.body);
}
return children;
};
};
@@ -1,23 +1,19 @@
import React from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormControl from '@mui/material/FormControl';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import TextField from '@mui/material/TextField';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import Paper from '@mui/material/Paper';
import { Divider, Input, MenuItem, Select } from '@mui/material';
import MythicTextField from './MythicTextField';
import {gql } from '@apollo/client';
import {snackActions} from '../utilities/Snackbar';
import {useMutation } from '@apollo/client';
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicDialogGrid,
MythicDialogSection,
MythicForm,
MythicFormField,
MythicFormNote
} from './MythicDialogLayout';
const submitFeedbackMutation = gql`
mutation submitFeedback($webhookType: String!, $webhookData: jsonb!){
@@ -29,15 +25,15 @@ const submitFeedbackMutation = gql`
`;
export function MythicFeedbackDialog(props) {
const [message, setMessage] = React.useState("");
const [taskID, setTaskID] = React.useState("");
const [taskID, setTaskID] = React.useState(0);
const messageTypeOptions = [
{display: "Bug", value: "bug", description: "Something is broken or behaving unexpectedly."},
{display: "Feature Request", value: "feature_request", description: "A workflow, capability, or quality-of-life improvement."},
{display: "Confusing UI", value: "confusing_ui", description: "A part of the interface is unclear or hard to use."},
{display: "Detection", value: "detection", description: "Detection, telemetry, or visibility feedback."},
{display: "Bug", value: "bug"},
{display: "Feature Request", value: "feature_request"},
{display: "Confusing UI", value: "confusing_ui"},
{display: "Detection", value: "detection"},
];
const [messageType, setMessageType] = React.useState("bug");
const [submitFeedback, {loading}] = useMutation(submitFeedbackMutation, {
const [submitFeedback] = useMutation(submitFeedbackMutation, {
update: (cache, {data}) => {
if(data.sendExternalWebhook.status === "success"){
snackActions.success("Submitted Feedback!");
@@ -52,111 +48,90 @@ export function MythicFeedbackDialog(props) {
}
});
const handleSubmit = () => {
const trimmedMessage = message.trim();
if(trimmedMessage.length === 0){
snackActions.warning("Please include feedback before submitting.");
return;
}
let webhookData = {};
if(Number(taskID) > 0){
webhookData["task_display_id"] = String(Number(taskID));
if(taskID > 0){
webhookData["task_id"] = String(taskID);
}
webhookData["message"] = trimmedMessage;
webhookData["message"] = message;
webhookData["feedback_type"] = messageType;
submitFeedback({variables: {webhookType: "new_feedback", webhookData: webhookData}});
}
const handleMessageTypeChange = (evt) => {
setMessageType(evt.target.value);
}
const handleTaskIDChange = (evt) => {
setTaskID(evt.target.value);
const handleTaskIDChange = (name, value, error) => {
setTaskID(value);
}
const handleMessageChange = (evt) => {
setMessage(evt.target.value);
const handleMessageChange = (name, value, error) => {
setMessage(value);
}
const onSubmitForm = (event) => {
event.preventDefault();
handleSubmit();
const handleOnEnter = (event) => {
if( event.shiftKey){
handleSubmit();
}
}
const selectedMessageType = messageTypeOptions.find((opt) => opt.value === messageType);
const canSubmit = message.trim().length > 0 && !loading;
return (
<React.Fragment>
<DialogTitle>{props.title || "Submit Feedback"}</DialogTitle>
<DialogContent dividers={true}>
<MythicDialogBody>
<MythicDialogSection
title="Feedback Destination"
description="Send this report to the feedback webhook configured for the current operation."
>
<MythicFormNote>
Use this for bugs, workflow friction, feature ideas, detection notes, or anything operators should be able to capture without leaving Mythic.
</MythicFormNote>
</MythicDialogSection>
<MythicDialogSection
title="Report Details"
description="Capture the category, optional task context, and the feedback details."
>
<MythicForm onSubmit={onSubmitForm}>
<MythicDialogGrid minWidth="14rem">
<MythicFormField label="Feedback Type" description={selectedMessageType?.description} required>
<FormControl fullWidth size="small">
<Select
labelId="feedback-type-select-label"
id="feedback-type-select"
value={messageType}
onChange={handleMessageTypeChange}
>
{messageTypeOptions.map( (opt) => (
<MenuItem value={opt.value} key={opt.value}>{opt.display}</MenuItem>
) )}
</Select>
</FormControl>
</MythicFormField>
<MythicFormField
label="Task"
description="Optional task display ID for feedback tied to specific output or tasking."
>
<TextField
fullWidth
size="small"
type="number"
value={taskID}
onChange={handleTaskIDChange}
placeholder="Optional"
inputProps={{min: 0}}
/>
</MythicFormField>
</MythicDialogGrid>
<MythicFormField
label="Feedback"
description="Include the context an operator or maintainer would need to understand the issue."
required
>
<TextField
autoFocus
fullWidth
multiline
minRows={6}
maxRows={10}
value={message}
onChange={handleMessageChange}
placeholder="What happened, what did you expect, and what would make this easier?"
/>
</MythicFormField>
</MythicForm>
</MythicDialogSection>
</MythicDialogBody>
<DialogTitle >{props.title}</DialogTitle>
<Divider></Divider>
<DialogContent style={{padding: "10px"}}>
Send a feedback report to the slack webhook configured for the current operation.
This provides a way to easily capture a bug, feedback requests, or comments without breaking operator flow too much. <br/>
Shift+Enter will auto-submit the form.
<TableContainer className="mythicElement">
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
<TableBody style={{whiteSpace: "pre"}}>
<TableRow hover >
<TableCell style={{width: "5rem"}}>
Feedback Type
</TableCell>
<TableCell>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={messageType}
onChange={handleMessageTypeChange}
input={<Input style={{width: "100%"}}/>}
>
{messageTypeOptions.map( (opt) => (
<MenuItem value={opt.value} key={opt.value}>{opt.display}</MenuItem>
) )}
</Select>
</TableCell>
</TableRow>
<TableRow hover >
<TableCell style={{width: "5rem"}}>
Task (if applicable)
</TableCell>
<TableCell>
<MythicTextField value={taskID} type={"number"}
onChange={handleTaskIDChange} display="inline-block" name={"taskid"} showLabel={false}
/>
</TableCell>
</TableRow>
<TableRow hover>
<TableCell>
Feedback
</TableCell>
<TableCell>
<MythicTextField value={message} multiline={true} onEnter={handleOnEnter}
onChange={handleMessageChange} display="inline-block" name={"taskid"} showLabel={false}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
<MythicDialogButton onClick={handleSubmit} intent="primary" disabled={!canSubmit}>
{loading ? "Submitting..." : "Submit Feedback"}
</MythicDialogButton>
</MythicDialogFooter>
</Button>
<Button onClick={handleSubmit} variant="contained" color="success">
Submit Feedback
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -9,6 +9,7 @@ import ComputerIcon from '@mui/icons-material/Computer';
import DescriptionIcon from '@mui/icons-material/Description';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorIcon from '@mui/icons-material/Error';
import { useTheme } from '@mui/material/styles';
import { Typography } from '@mui/material';
import { MythicStyledTooltip } from "./MythicStyledTooltip";
import WidgetsIcon from '@mui/icons-material/Widgets';
@@ -39,65 +40,31 @@ const StyledAutoSizer = styled(AutoSizer)((
theme
}
) => ({
height: "100%",
minHeight: 0,
minWidth: 0,
overflow: "hidden",
width: "100%",
[`& .${classes.rowContainer}`]: {
cursor: "pointer",
overflow: "hidden",
transition: "background-color 120ms ease, color 120ms ease",
},
[`& .${classes.rowContainer}:hover`]: {
backgroundColor: theme.palette.action.hover,
},
[`& .${classes.rowContainer}`]: {},
[`& .${classes.row}`]: {
display: "flex",
alignItems: "center",
height: "100%",
minWidth: "fit-content",
paddingRight: theme.spacing(1),
alignItems: "left",
marginLeft: (props) => theme.spacing(3 * props.depth),
userSelect: "none",
whiteSpace: "nowrap",
width: "100%",
},
[`& .${classes.row}[data-deleted="true"]`]: {
textDecoration: "line-through",
whiteSpace: "nowrap"
},
[`& .${classes.rowButtonWrapper}`]: {
alignSelf: "stretch",
borderLeft: `2px dashed ${alpha(theme.palette.text.primary, 0.34)}`,
flex: "0 0 14px",
marginLeft: 7,
width: theme.spacing(3),
textAlign: "center",
"&:hover": {
cursor: "pointer",
textDecoration: "underline"
}
},
[`& .${classes.rowButton}`]: {
alignItems: "center",
display: "inline-flex",
flex: "0 0 auto",
justifyContent: "center",
width: theme.spacing(3),
width: theme.spacing(3)
},
[`& .${classes.rowLabel}`]: {
color: theme.palette.text.primary,
fontFamily: "inherit",
fontSize: theme.typography.pxToRem(13),
lineHeight: 1.35,
margin: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
},
[`& .${classes.rowLabel}[data-muted="true"]`]: {
color: theme.palette.text.secondary,
marginLeft: theme.spacing(0.5)
},
[`& .${classes.heading}`]: {
@@ -135,20 +102,9 @@ const StyledAutoSizer = styled(AutoSizer)((
},
[`& .${classes.icon}`]: {
flex: "0 0 auto",
height: 16,
marginLeft: 3,
marginRight: 5,
verticalAlign: 'middle',
width: 16,
},
[`& .${classes.icon}[data-folder="true"]`]: {
color: theme.folderColor,
},
[`& .${classes.icon}[data-empty="true"]`]: {
color: theme.emptyFolderColor,
height: 20,
width: 20,
},
[`& .${classes.details}`]: {
@@ -164,11 +120,7 @@ const StyledAutoSizer = styled(AutoSizer)((
[`& .${classes.paper}`]: {
width: '100%',
height: "100%",
marginBottom: 0,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
marginBottom: theme.spacing(2),
},
[`& .${classes.table}`]: {
@@ -212,6 +164,7 @@ const VirtualTreeRow = React.memo(({
const itemTreeData = ListProps.data[ListProps.index];
const item = ListProps.treeRootData[itemTreeData.group]?.[itemTreeData.host]?.[itemTreeData.full_path_text] || itemTreeData;
//console.log("item", item, "itemlookup", ListProps.treeRootData[itemTreeData.host]?.[itemTreeData.name])
const theme = useTheme();
const handleOnClickButton = (e) => {
e.stopPropagation();
if (itemTreeData.isOpen) {
@@ -239,57 +192,90 @@ const VirtualTreeRow = React.memo(({
return "";
}
return (
<div className={`${classes.rowContainer} hoverme ${selectedPath()}`.trim()}
<div className={`hoverme ${selectedPath()}`}
style={ListProps.style}
onContextMenu={handleContextClick}
onClick={handleOnClickRow}>
<div className={classes.row} data-deleted={itemTreeData.deleted ? "true" : undefined}>
<div style={{display: 'flex' , marginBottom: "1px", flexGrow: 1, width: "100%"}}>
{[...Array(itemTreeData.depth)].map((o, i) => (
<span
className={classes.rowButtonWrapper}
<div
key={'folder' + itemTreeData.id + 'lines' + i}
/>
style={{
borderLeft: `2px dashed ${alpha(theme.palette.text.primary, 0.4)}`,
marginLeft: 7,
paddingRight: 7,
display: 'inline-block',
}}></div>
))}
<div
className={classes.root}
style={{ backgroundColor: theme.body, color: theme.text, alignItems: 'center', display: 'flex', paddingRight: "10px", textDecoration: itemTreeData.deleted ? 'line-through' : '' }}
>
{itemTreeData.is_group ? (
<WidgetsIcon className={classes.icon} />
<WidgetsIcon style={{
width: "15px",
height: "15px",
marginLeft: "3px",
marginRight: "5px" }} />
): itemTreeData.root ? (
<ComputerIcon className={classes.icon} />
<ComputerIcon style={{
width: "15px",
height: "15px",
marginLeft: '3px',
marginRight: '5px' }} />
) : !itemTreeData.can_have_children ? (
<DescriptionIcon className={classes.icon} />
<DescriptionIcon style={{
width: "15px",
height: "15px",
marginLeft: '3px',
marginRight: '5px' }} />
) : itemTreeData.isOpen ? (
<FontAwesomeIcon
icon={faFolderOpen}
className={classes.icon}
data-folder={item?.has_children || item.success ? "true" : undefined}
data-empty={item?.has_children || item.success ? undefined : "true"}
style={{
width: "15px",
height: "15px",
marginLeft: '3px',
marginRight: '5px',
color: item?.has_children || item.success ? theme.folderColor : theme.emptyFolderColor,
}}
size={"lg"}
onClick={handleOnClickButton} />
) : (
<FontAwesomeIcon
className={classes.icon}
data-folder={item?.has_children || item.success ? "true" : undefined}
data-empty={item?.has_children || item.success ? undefined : "true"}
style={{
width: "15px",
height: "15px",
marginLeft: '3px',
marginRight: '5px',
color: item?.has_children || item.success ? theme.folderColor : theme.emptyFolderColor, }}
size={"lg"}
icon={faFolder} onClick={handleOnClickButton} />
)}
<Typography
className={classes.rowLabel}
data-muted={item?.has_children || item.success !== null ? undefined : "true"}
component="pre">
style={{
color:
item?.has_children ||
item.success !== null
? theme.palette.text.primary
: theme.palette.text.secondary,
}} component="pre">
{itemTreeData.name}
</Typography>
{item.success === true && itemTreeData.depth > 0 ? (
<MythicStyledTooltip title='Successfully listed contents of folder'>
<CheckCircleOutlineIcon className={classes.icon} fontSize='small' color="success" />
<MythicStyledTooltip title='Successfully listed contents of folder' style={{display: "inline-flex", marginLeft: "5px"}}>
<CheckCircleOutlineIcon fontSize='small' color="success" />
</MythicStyledTooltip>
) : item.success === false && itemTreeData.depth > 0 ? (
<MythicStyledTooltip title='Failed to list contents of folder'>
<ErrorIcon className={classes.icon} fontSize='small' color="error" />
<MythicStyledTooltip title='Failed to list contents of folder' style={{display: "inline-flex", marginLeft: "5px"}}>
<ErrorIcon fontSize='small' color="error" />
</MythicStyledTooltip>
) : null}
</div>
</div>
</div>
);
@@ -449,35 +435,33 @@ const FileBrowserVirtualTreePreMemo = ({
}
}, [selectedFolderData, flattenedNodes]);
return flattenedNodes.length > 0 ? (
<div className="mythic-process-browser-table-shell flex flex-column h-full min-h-0 min-w-0 overflow-hidden w-full relative">
<StyledAutoSizer>
{(AutoSizerProps) => (
<List
itemData={flattenedNodes}
layout="vertical"
height={AutoSizerProps.height}
width={AutoSizerProps.width}
itemCount={flattenedNodes.length}
itemKey={itemKey}
itemSize={24}
ref={gridRef}
>
{(ListProps) => (
<VirtualTreeRow
{...ListProps}
tabInfo={tabInfo}
selectedFolderData={selectedFolderData}
treeRootData={treeRootData}
onSelectNode={onSelectNode}
onExpandNode={onExpandNode}
onCollapseNode={onCollapseNode}
onContextMenu={onContextMenu}
/>
)}
</List>
)}
</StyledAutoSizer>
</div>
<StyledAutoSizer>
{(AutoSizerProps) => (
<List
itemData={flattenedNodes}
layout="vertical"
height={AutoSizerProps.height}
width={AutoSizerProps.width}
itemCount={flattenedNodes.length}
itemKey={itemKey}
itemSize={24}
ref={gridRef}
>
{(ListProps) => (
<VirtualTreeRow
{...ListProps}
tabInfo={tabInfo}
selectedFolderData={selectedFolderData}
treeRootData={treeRootData}
onSelectNode={onSelectNode}
onExpandNode={onExpandNode}
onCollapseNode={onCollapseNode}
onContextMenu={onContextMenu}
/>
)}
</List>
)}
</StyledAutoSizer>
) : null;
};
export const FileBrowserVirtualTree = React.memo(FileBrowserVirtualTreePreMemo);
@@ -2,13 +2,13 @@ import React from 'react';
import {snackActions} from "../utilities/Snackbar";
import {MythicStyledTooltip} from "./MythicStyledTooltip";
import {MythicDialog} from "./MythicDialog";
import {gql} from '@apollo/client';
import {Link} from '@mui/material';
import {useMutation, useLazyQuery, gql} from '@apollo/client';
import {b64DecodeUnicode} from "../pages/Callbacks/ResponseDisplay";
import {PreviewFileMediaDialog} from "./PreviewFileMedia";
import {PreviewFileMediaDialog} from "../pages/Search/PreviewFileMedia";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {useMythicLazyQuery} from "../utilities/useMythicLazyQuery";
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
export const getfileInformationQuery = gql`
query getFileInformation($file_id: String!){
@@ -85,9 +85,9 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
style={{height: "20px", position: "relative", cursor: "pointer", display: "inline-block"}}
onClick={onPreviewMedia} />
</MythicStyledTooltip>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + fileData.agent_file_id}>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + fileData.agent_file_id}>
{fileData.display_link === "" ? window.location.origin + "/direct/download/" + fileData.agent_file_id : fileData.display_link}
</FileDownloadLinkWithAuth>
</Link>
{openPreviewMediaDialog &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openPreviewMediaDialog}
onClose={onClose}
@@ -99,4 +99,4 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
}
</>
)
}
}
@@ -1,5 +1,4 @@
import {snackActions} from '../utilities/Snackbar';
import {mythicFetch} from '../utilities/MythicConnection';
export const UploadTaskFile = async (file, comment) => {
let formData = new FormData();
@@ -12,7 +11,7 @@ export const UploadTaskFile = async (file, comment) => {
return null;
}
try{
const upload_response = await mythicFetch('/task_upload_file_webhook', {
const upload_response = await fetch('/api/v1.4/task_upload_file_webhook', {
method: 'POST',
body: formData,
headers: {
@@ -22,11 +21,7 @@ export const UploadTaskFile = async (file, comment) => {
});
try{
const upload_result = upload_response.json().then(data => {
if(data?.status === "error"){
snackActions.error(data.error || "Failed to upload file");
return null;
}
return data?.agent_file_id || null;
return data?.agent_file_id || data?.error || null;
}).catch(error => {
snackActions.warning("Error: " + upload_response.statusText + "\nError Code: " + upload_response.status);
console.log("Error trying to get json response", error.toString());
@@ -42,53 +37,13 @@ export const UploadTaskFile = async (file, comment) => {
return null;
}
}
export const UploadDirectFile = async (agent_file_id, file) => {
let formData = new FormData();
try{
formData.append("file", file);
//snackActions.info("Saving " + file.name + " to Mythic...", {autoHideDuration: 1000});
}catch(error){
console.log(error)
return null;
}
try{
const upload_response = await mythicFetch('/direct/upload/' + agent_file_id, {
method: 'POST',
body: formData,
headers: {
"Authorization": `Bearer ${localStorage.getItem("access_token")}`,
MythicSource: "web",
}
});
try{
return upload_response.json().then(data => {
if(data?.status === "error"){
snackActions.error(data.error || "Failed to save file");
return data;
}
return data;
}).catch(error => {
snackActions.warning("Error: " + upload_response.statusText + "\nError Code: " + upload_response.status);
console.log("Error trying to get json response", error.toString());
return null;
});
}catch(error){
snackActions.error(error.toString());
return null;
}
}catch(error){
snackActions.error(error.toString());
return null;
}
}
export const UploadEventFile = async (file, comment) => {
let formData = new FormData();
formData.append("file", file);
formData.append("comment", comment);
snackActions.info("Uploading " + file.name + " to Mythic...", {autoHideDuration: 1000});
try{
const upload_response = await mythicFetch('/eventing_import_webhook', {
const upload_response = await fetch('/api/v1.4/eventing_import_webhook', {
method: 'POST',
body: formData,
headers: {
@@ -120,7 +75,7 @@ export const UploadEventGroupFile = async (file, eventgroup_id) => {
formData.append("file", file);
snackActions.info("Uploading " + file.name + " to Mythic...", {autoHideDuration: 1000});
try{
const upload_response = await mythicFetch('/eventing_register_file_webhook', {
const upload_response = await fetch('/api/v1.4/eventing_register_file_webhook', {
method: 'POST',
body: formData,
headers: {
@@ -145,4 +100,4 @@ export const UploadEventGroupFile = async (file, eventgroup_id) => {
snackActions.error(error.toString());
return null;
}
}
}
@@ -18,8 +18,6 @@ export const Dropdown = React.forwardRef(
style,
transformOrigin,
anchorOrigin,
MenuListProps,
PaperProps,
},
ref
) => {
@@ -72,11 +70,7 @@ export const Dropdown = React.forwardRef(
<>
<Menu
elevation={5}
PaperProps={{
...PaperProps,
sx: { minWidth: minWidth ?? 0, ...(PaperProps?.sx || {}) }
}}
MenuListProps={MenuListProps}
PaperProps={{ sx: { minWidth: minWidth ?? 0 } }}
style={{zIndex: 100000, position: "absolute"}}
anchorEl={anchorReference === "anchorEl" ? anchorEl : undefined}
transition={"true"}
@@ -260,4 +254,4 @@ export const DropdownMenuItem = styled(MenuItem)`
export const DropdownNestedMenuItem = styled(NestedMenuItem)`
`;
`;
@@ -5,12 +5,9 @@ export const MythicPageBody = ({children}) => {
height: "100%",
display: "flex",
flexDirection: "column",
padding: "0.5rem",
gap: "0.2rem",
minWidth: 0,
minHeight: 0,
margin: "0.5rem",
}}>
{children}
</div>
)
}
}
@@ -1,270 +1,32 @@
import Box from '@mui/material/Box';
import {useTheme} from '@mui/material/styles';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import {alpha, useTheme} from '@mui/material/styles';
import {MythicChip} from './MythicChip';
import {Typography} from "@mui/material";
export const MythicPageHeader = ({
actions,
children,
className = "",
dense = false,
icon,
meta,
style = {},
subtitle,
sx = {},
title,
headerVariant = "h6",
}) => {
export const MythicPageHeader = ({children, title, style={}, headerVariant="h5"}) => {
const theme = useTheme();
const headerTextColor = theme.pageHeaderText?.main || theme.palette.text.primary;
const supportingTextColor = alpha(headerTextColor, 0.78);
const hasActionContent = actions || children;
const actionSurface = alpha(headerTextColor, 0.1);
const actionSurfaceHover = alpha(headerTextColor, 0.16);
const actionSurfaceActive = alpha(headerTextColor, 0.23);
const actionBorder = alpha(headerTextColor, 0.24);
const actionBorderHover = alpha(headerTextColor, 0.42);
return (
<Paper
className={`mythic-page-header${className ? ` ${className}` : ""}`}
elevation={0}
style={style}
sx={{
alignItems: {xs: "stretch", md: "center"},
backgroundColor: theme.pageHeader.main,
border: `1px solid ${alpha(headerTextColor, 0.18)}`,
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: `inset 0 1px 0 ${alpha(headerTextColor, 0.12)}`,
color: headerTextColor,
display: "flex",
flex: "0 0 auto",
flexDirection: {xs: "column", md: "row"},
flexWrap: {xs: "nowrap", md: "wrap"},
gap: dense ? 0.75 : 1,
height: "auto",
justifyContent: "space-between",
maxHeight: "none",
minHeight: dense ? 44 : 58,
minWidth: 0,
overflow: "visible",
p: dense ? "0.5rem 0.75rem" : "0.7rem 0.85rem",
width: "100%",
"& .MuiButton-root, & .MuiToggleButton-root": {
alignItems: "center",
backgroundColor: `${actionSurface} !important`,
border: `1px solid ${actionBorder} !important`,
borderRadius: `${theme.shape.borderRadius}px`,
boxShadow: "none !important",
color: `${headerTextColor} !important`,
fontSize: "0.76rem",
fontWeight: 750,
gap: 0.35,
justifyContent: "center",
letterSpacing: 0,
lineHeight: 1.2,
minHeight: 32,
minWidth: {xs: "auto", sm: "7rem"},
px: 1.05,
textTransform: "none",
whiteSpace: "nowrap",
"&:hover": {
backgroundColor: `${actionSurfaceHover} !important`,
borderColor: `${actionBorderHover} !important`,
boxShadow: `inset 0 1px 0 ${alpha(headerTextColor, 0.12)}`,
},
},
"& .MuiButton-root.Mui-focusVisible, & .MuiToggleButton-root.Mui-focusVisible": {
outline: `2px solid ${alpha(headerTextColor, 0.38)}`,
outlineOffset: 2,
},
"& .MuiButton-root.Mui-disabled, & .MuiToggleButton-root.Mui-disabled": {
backgroundColor: `${alpha(headerTextColor, 0.04)} !important`,
borderColor: `${alpha(headerTextColor, 0.1)} !important`,
color: `${alpha(headerTextColor, 0.38)} !important`,
},
"& .MuiToggleButton-root.Mui-selected": {
backgroundColor: `${actionSurfaceActive} !important`,
borderColor: `${alpha(headerTextColor, 0.48)} !important`,
color: `${headerTextColor} !important`,
boxShadow: `inset 0 1px 0 ${alpha(headerTextColor, 0.16)}`,
"&:hover": {
backgroundColor: `${alpha(headerTextColor, 0.28)} !important`,
},
},
"& .MuiButton-startIcon, & .MuiButton-endIcon": {
color: "inherit",
marginLeft: 0,
marginRight: 0,
},
"& .MuiButton-startIcon": {
paddingRight: "0.25rem",
},
"& .MuiButton-endIcon": {
paddingLeft: "0.25rem",
},
"& .MuiIconButton-root": {
backgroundColor: `${actionSurface} !important`,
border: `1px solid ${actionBorder} !important`,
borderRadius: `${theme.shape.borderRadius}px`,
color: `${headerTextColor} !important`,
height: 32,
width: 32,
"&:hover": {
backgroundColor: `${actionSurfaceHover} !important`,
borderColor: `${actionBorderHover} !important`,
},
"&.Mui-disabled": {
backgroundColor: `${alpha(headerTextColor, 0.04)} !important`,
borderColor: `${alpha(headerTextColor, 0.1)} !important`,
color: `${alpha(headerTextColor, 0.38)} !important`,
},
},
...sx,
}}
<Paper elevation={2}
variant={"elevation"}
style={{
backgroundColor: theme.pageHeader.main,
color: theme.pageHeaderText.main,
border: `1px solid ${theme.borderColor}`,
alignItems: "center",
display: "flex",
justifyContent: "space-between",
marginBottom: "0.5rem",
padding: "0.5rem",
borderRadius: "5px",
...style
}}
>
<Stack direction="row" sx={{gap: 1, minWidth: 0, flex: {xs: "0 1 auto", md: "1 1 24rem"}}}>
{icon &&
<Box sx={{
alignItems: "center",
backgroundColor: alpha(headerTextColor, 0.1),
border: `1px solid ${alpha(headerTextColor, 0.16)}`,
borderRadius: `${theme.shape.borderRadius}px`,
display: "inline-flex",
flex: "0 0 auto",
height: dense ? 32 : 36,
justifyContent: "center",
mt: subtitle || meta ? 0.15 : 0,
width: dense ? 32 : 36,
}}>
{icon}
</Box>
}
<Box sx={{display: "flex", flexDirection: "column", gap: 0.25, minWidth: 0}}>
<Typography
variant={headerVariant}
sx={{
color: headerTextColor,
fontWeight: 750,
letterSpacing: 0,
lineHeight: 1.18,
minWidth: 0,
}}
>
{title}
</Typography>
{subtitle &&
<Typography
component="div"
sx={{
color: supportingTextColor,
fontSize: "0.78rem",
fontWeight: 600,
lineHeight: 1.35,
maxWidth: "64rem",
}}
>
{subtitle}
</Typography>
}
{meta &&
<Box sx={{alignItems: "center", display: "flex", flexWrap: "wrap", gap: 0.5, mt: 0.25}}>
{meta}
</Box>
}
</Box>
</Stack>
{hasActionContent &&
<Stack direction="row" sx={{
alignContent: "center",
alignItems: "center",
color: headerTextColor,
flex: "0 1 auto",
flexWrap: "wrap",
gap: 0.45,
justifyContent: {xs: "flex-start", md: "flex-end"},
maxWidth: "100%",
minWidth: 0,
}}>
{actions}
{children}
</Stack>
}
<Typography variant={headerVariant} style={{textAlign: "left", display: "inline-block", marginLeft: "1rem", width: "100%"}}>
{title}
</Typography>
<div style={{display: "flex", marginRight: "1rem", alignItems: "center"}}>
{children}
</div>
</Paper>
);
}
)
export const MythicPageHeaderChip = ({status, sx = {}, ...props}) => {
const theme = useTheme();
const headerTextColor = theme.pageHeaderText?.main || theme.palette.text.primary;
const normalizedStatus = status === "active" || status === "enabled" ? "success" :
status === "inactive" || status === "disabled" ? "warning" :
status === "neutral" ? "secondary" : status;
const statusColor = normalizedStatus ? theme.palette[normalizedStatus]?.main : null;
const chipColor = statusColor || alpha(headerTextColor, 0.88);
return (
<MythicChip
size="small"
tone={normalizedStatus || "secondary"}
variant="outlined"
{...props}
sx={{
backgroundColor: statusColor ? alpha(statusColor, theme.palette.mode === "dark" ? 0.22 : 0.13) : alpha(headerTextColor, 0.08),
borderColor: statusColor ? alpha(statusColor, theme.palette.mode === "dark" ? 0.55 : 0.38) : alpha(headerTextColor, 0.2),
color: chipColor,
fontSize: "0.72rem",
fontWeight: 750,
height: 22,
letterSpacing: 0,
"& .MuiChip-icon": {
color: statusColor ? chipColor : alpha(headerTextColor, 0.82),
},
...sx,
}}
/>
);
};
export const MythicSectionHeader = ({
actions,
dense = false,
subtitle,
sx = {},
title,
}) => {
const theme = useTheme();
const headerTextColor = theme.pageHeaderText?.main || theme.palette.text.primary;
const sectionAccentColor = theme.sectionHeader?.accent || theme.palette.primary.main;
return (
<MythicPageHeader
actions={actions}
className="mythic-section-header overflow-hidden relative"
dense={dense}
subtitle={subtitle}
title={title}
sx={{
backgroundImage: theme.gradients?.sectionHeader,
borderColor: alpha(sectionAccentColor, theme.palette.mode === "dark" ? 0.55 : 0.38),
boxShadow: `inset 0 1px 0 ${alpha(headerTextColor, 0.22)}, 0 2px 6px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.28 : 0.12)}`,
mb: 0.5,
mt: dense ? 1 : 1.25,
overflow: "hidden",
pl: dense ? 1.45 : 1.6,
position: "relative",
"&::before": {
backgroundColor: sectionAccentColor,
bottom: 0,
boxShadow: `0 0 0 1px ${alpha(headerTextColor, 0.2)}`,
content: '""',
left: 0,
position: "absolute",
top: 0,
width: 6,
},
...sx,
}}
/>
);
};
}
@@ -6,29 +6,20 @@ import {Dropdown, DropdownMenuItem, DropdownNestedMenuItem} from "../MythicNeste
const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
const [openContextMenu, setOpenContextMenu] = React.useState(false);
const rowClassName = data.gridUUID + "row" + rowIndex;
const rowHighlight = rowIndex % 2 === 0 ? 'MythicResizableGridRowHighlight' : '';
const rowHighlight = rowIndex % 2 === 1 ? 'MythicResizableGridRowHighlight' : '';
const [contextMenuOptions, setContextMenuOptions] = React.useState(data?.rowContextMenuOptions || []);
const dropdownAnchorRef = React.useRef(null);
const item = data.items[rowIndex][columnIndex];
const cellStyle = item?.props?.cellData?.cellStyle || {};
const rowStyle = data.items[rowIndex][columnIndex]?.props?.rowData?.rowStyle || {};
const contextMenuLocationRef = React.useRef({x: 0, y: 0});
const ownsContextRowRef = React.useRef(false);
const updateRowClass = useCallback((className, shouldAdd) => {
const cells = document.getElementsByClassName(rowClassName);
if(cells.length > 0){
for(const cell of cells){
cell.classList.toggle(className, shouldAdd);
}
}
}, [rowClassName]);
const handleDoubleClick = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
data.onDoubleClickRow(e, rowIndex - 1, data.items[rowIndex][columnIndex]?.props?.rowData); // minus 1 to account for header row
},
[columnIndex, data, rowIndex]
[data, rowIndex]
);
const handleClick = (event) => {
event.preventDefault();
@@ -38,24 +29,22 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
}
};
const selectedClass = data.items[rowIndex][columnIndex]?.props?.rowData?.selected ? "selectedCallback" : "";
const customRowClass = data.items[rowIndex][columnIndex]?.props?.rowData?.rowClassName || "";
const rowFirstCellClass = columnIndex === 0 ? classes.rowFirstCell : "";
const rowLastCellClass = columnIndex === data.items[rowIndex].length - 1 ? classes.rowLastCell : "";
const rowInteractiveClass = data.onRowClick ? classes.rowInteractive : "";
const setHoveredRow = useCallback((enabled) => {
updateRowClass(classes.hoveredRow, enabled);
}, [updateRowClass]);
const setContextRow = useCallback((enabled) => {
updateRowClass(classes.contextRow, enabled);
}, [updateRowClass]);
const onMouseEnter = useCallback(() => {
setHoveredRow(true);
}, [setHoveredRow]);
const onMouseLeave = useCallback(() => {
if(!ownsContextRowRef.current){
setHoveredRow(false);
const onMouseEnter = () => {
const cells = document.getElementsByClassName(rowClassName);
if(cells.length > 0){
for(const cell of cells){
cell.classList.add(classes.hoveredRow);
}
}
}, [setHoveredRow]);
}
const onMouseLeave = () => {
const cells = document.getElementsByClassName(rowClassName);
if(cells.length > 0){
for(const cell of cells){
cell.classList.remove(classes.hoveredRow);
}
}
}
const handleMenuItemClick = (event, clickOption) => {
event.preventDefault();
event.stopPropagation();
@@ -63,24 +52,10 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
setOpenContextMenu(false);
};
React.useEffect( () => {
if(openContextMenu){
ownsContextRowRef.current = true;
setContextRow(true);
setHoveredRow(true);
} else if(ownsContextRowRef.current){
ownsContextRowRef.current = false;
setContextRow(false);
setHoveredRow(false);
if(!openContextMenu){
onMouseLeave();
}
}, [openContextMenu, setContextRow, setHoveredRow]);
React.useEffect( () => {
return () => {
if(ownsContextRowRef.current){
setContextRow(false);
setHoveredRow(false);
}
};
}, [setContextRow, setHoveredRow]);
}, [openContextMenu]);
const handleContextClick = useCallback(
(event) => {
event.preventDefault();
@@ -108,7 +83,7 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
);
return (
<div style={{...style, ...cellStyle, ...rowStyle}}
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass} ${customRowClass} ${rowFirstCellClass} ${rowLastCellClass} ${rowInteractiveClass}`}
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass}`}
onDoubleClick={handleDoubleClick}
onClick={handleClick}
onMouseEnter={onMouseEnter}
@@ -151,7 +126,6 @@ export const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOpti
<DropdownMenuItem
key={option.name}
disabled={option.disabled}
className={option.className || (option.danger ? "mythic-action-tone-hover mythic-tone-error" : undefined)}
onClick={(event) => handleMenuItemClick(event, option.click)}
>
{option.icon}{option.name}
@@ -162,12 +136,11 @@ export const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOpti
disabled={option.disabled}
menu={
option.menuItems.map((menuOption, indx) => (
<DropdownMenuItem
key={menuOption.name}
disabled={menuOption.disabled}
className={menuOption.className || (menuOption.danger ? "mythic-action-tone-hover mythic-tone-error" : undefined)}
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
>
<DropdownMenuItem
key={menuOption.name}
disabled={menuOption.disabled}
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
>
{menuOption.icon}{menuOption.name}
</DropdownMenuItem>
))
@@ -210,4 +183,4 @@ export default Cell;
</Grow>
)}
</Popper>
*/
*/
@@ -0,0 +1,59 @@
import React, { useState } from 'react';
import {classes} from './styles';
import Draggable from 'react-draggable';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DragHandleIcon from '@mui/icons-material/DragHandle';
const DraggableHandles = React.forwardRef(({ height, rowHeight, width, minColumnWidth, columnWidths, onStop }, ref) => {
const [isDragging, setIsDragging] = useState(-1);
const nodeRef = React.useRef(null);
return (
<div
ref={ref}
className={classes.draggableHandlesContainer}
style={{
height: height,
width: width,
pointerEvents: isDragging >= 0 ? 'initial' : 'none',
}}>
{columnWidths.map((_, i) => {
// leftOffset is the sum of the width of all columns left of i
const leftOffset = columnWidths.slice(0, i).reduce((a, b) => a + b, 0);
return (
<Draggable
key={i}
nodeRef={nodeRef}
axis='x'
bounds={{
left: minColumnWidth - columnWidths[i],
right: Number.POSITIVE_INFINITY,
top: 0,
bottom: 0,
}}
offsetParent={document.body}
defaultPosition={{x: 0, y: 0}}
position={{x: 0, y: 0}}
onStart={() => {
setIsDragging(i);
}}
onStop={(e, data) => {
setIsDragging(-1);
onStop(data.x, i);
}}>
<DragHandleIcon
ref={nodeRef}
className={isDragging === i ? classes.draggableHandlesClickAreaSelected : classes.draggableHandlesClickArea}
style={{
left: leftOffset + columnWidths[i] - 1 - 7,
rotate: isDragging === i ? "0deg" : "90deg",
}}>
</DragHandleIcon>
</Draggable>
);
})}
</div>
);
});
export default DraggableHandles;
@@ -1,181 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import {MythicActionButton} from '../MythicActionButton';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography';
const DEFAULT_FILTER = {
include: [],
exclude: [],
includeMode: "any",
caseSensitive: false,
};
const splitFilterTerms = (value) => {
if(Array.isArray(value)){
return value.map((entry) => String(entry).trim()).filter(Boolean);
}
if(value === undefined || value === null){
return [];
}
return String(value)
.split(/[\n,]+/)
.map((entry) => entry.trim())
.filter(Boolean);
};
export const normalizeGridColumnFilter = (value) => {
if(value === undefined || value === null){
return {...DEFAULT_FILTER};
}
if(typeof value === "string" || typeof value === "number" || typeof value === "boolean"){
return {
...DEFAULT_FILTER,
include: splitFilterTerms(value),
};
}
if(typeof value === "object"){
return {
include: splitFilterTerms(value.include),
exclude: splitFilterTerms(value.exclude),
includeMode: value.includeMode === "all" ? "all" : "any",
caseSensitive: Boolean(value.caseSensitive),
};
}
return {...DEFAULT_FILTER};
};
export const isGridColumnFilterActive = (value) => {
const normalizedFilter = normalizeGridColumnFilter(value);
return normalizedFilter.include.length > 0 || normalizedFilter.exclude.length > 0;
};
export const getUpdatedGridFilterOptions = (filterOptions, key, value) => {
const nextFilterOptions = {...filterOptions};
if(!key){
return nextFilterOptions;
}
const normalizedFilter = normalizeGridColumnFilter(value);
if(isGridColumnFilterActive(normalizedFilter)){
nextFilterOptions[key] = normalizedFilter;
}else{
delete nextFilterOptions[key];
}
return nextFilterOptions;
};
export const gridValuePassesFilter = (value, filterOption) => {
const normalizedFilter = normalizeGridColumnFilter(filterOption);
if(!isGridColumnFilterActive(normalizedFilter)){
return true;
}
const cellText = String(value ?? "");
const haystack = normalizedFilter.caseSensitive ? cellText : cellText.toLowerCase();
const normalizeTerm = (term) => normalizedFilter.caseSensitive ? term : term.toLowerCase();
const termMatches = (term) => haystack.includes(normalizeTerm(term));
const includeMatches = normalizedFilter.include.length === 0 ? true :
normalizedFilter.includeMode === "all" ?
normalizedFilter.include.every(termMatches) :
normalizedFilter.include.some(termMatches);
if(!includeMatches){
return false;
}
return !normalizedFilter.exclude.some(termMatches);
};
export function GridColumnFilterDialog({filterValue, onClose, onSubmit, selectedColumn}) {
const normalizedFilter = React.useMemo(() => normalizeGridColumnFilter(filterValue), [filterValue]);
const [includeText, setIncludeText] = React.useState(normalizedFilter.include.join("\n"));
const [excludeText, setExcludeText] = React.useState(normalizedFilter.exclude.join("\n"));
const [includeMode, setIncludeMode] = React.useState(normalizedFilter.includeMode);
const [caseSensitive, setCaseSensitive] = React.useState(normalizedFilter.caseSensitive);
const columnName = selectedColumn?.name || selectedColumn?.plaintext || selectedColumn?.key || "Column";
const buildFilter = () => ({
include: splitFilterTerms(includeText),
exclude: splitFilterTerms(excludeText),
includeMode,
caseSensitive,
});
const handleSubmit = () => {
onSubmit(buildFilter());
onClose();
};
const handleClear = () => {
onSubmit({...DEFAULT_FILTER});
onClose();
};
const onIncludeModeChange = (event, value) => {
if(value !== null){
setIncludeMode(value);
}
};
return (
<div className="mythic-grid-filter-dialog bg-surface-raised text-primary">
<DialogTitle className="mythic-grid-filter-dialog-title bg-header text-header text-base font-850 leading-120 border-b-subtle">
Filter {columnName}
</DialogTitle>
<DialogContent className="mythic-grid-filter-dialog-content flex flex-column">
<Typography className="mythic-grid-filter-dialog-copy text-muted text-xs font-650 leading-135" component="div">
Add one term per line, or separate terms with commas. Include terms decide what can stay visible; exclude terms remove matching rows.
</Typography>
<Box className="mythic-grid-filter-dialog-mode-row items-center flex gap-6 justify-between">
<Typography className="mythic-grid-filter-dialog-label text-xs font-800 text-primary" component="div">
Include matching
</Typography>
<ToggleButtonGroup
exclusive
onChange={onIncludeModeChange}
size="small"
value={includeMode}
>
<ToggleButton value="any">Any</ToggleButton>
<ToggleButton value="all">All</ToggleButton>
</ToggleButtonGroup>
</Box>
<div className="mythic-grid-filter-dialog-fields grid grid-cols-2">
<TextField
fullWidth
label="Include rows matching"
minRows={4}
multiline
autoFocus
onChange={(event) => setIncludeText(event.target.value)}
placeholder={"admin\nprod"}
value={includeText}
/>
<TextField
fullWidth
label="Exclude rows matching"
minRows={4}
multiline
onChange={(event) => setExcludeText(event.target.value)}
placeholder={"debug\ntest"}
value={excludeText}
/>
</div>
<FormControlLabel
control={
<Switch
checked={caseSensitive}
onChange={(event) => setCaseSensitive(event.target.checked)}
size="small"
/>
}
label="Case sensitive"
/>
</DialogContent>
<DialogActions className="mythic-grid-filter-dialog-actions py-6 px-8 border-t-subtle">
<MythicActionButton onClick={handleClear}>Clear</MythicActionButton>
<MythicActionButton onClick={onClose}>Cancel</MythicActionButton>
<MythicActionButton colorMode="always" onClick={handleSubmit} tone="primary" variant="contained">Apply Filter</MythicActionButton>
</DialogActions>
</div>
);
}
@@ -3,9 +3,8 @@ import { useCallback } from 'react';
import useSingleAndDoubleClick from '../../utilities/useSingleAndDoubleClick';
import {classes} from './styles';
import React from 'react';
import FilterAltOutlinedIcon from '@mui/icons-material/FilterAltOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faFilter} from '@fortawesome/free-solid-svg-icons';
import {ContextMenu} from "./Cell";
const HeaderCell = ({
@@ -15,16 +14,11 @@ const HeaderCell = ({
sortIndicatorIndex,
sortDirection,
headerNameKey = "name",
isResizing = false,
onResizePointerDown = () => {},
VariableSizeGridProps: { style, rowIndex, columnIndex, data },
}) => {
const dropdownAnchorRef = React.useRef(null);
const item = data.items[rowIndex][columnIndex];
const isFiltered = item?.filtered || false;
const isSorted = sortIndicatorIndex === columnIndex;
const HeaderSortIcon = sortDirection === 'ASC' ? KeyboardArrowUpIcon : KeyboardArrowDownIcon;
const headerLabel = item?.[headerNameKey] || "";
const contextMenuLocationRef = React.useRef({x: 0, y: 0});
const handleClick = useCallback(
(e) => {
@@ -39,131 +33,44 @@ const HeaderCell = ({
},
[onDoubleClick, columnIndex]
);
const handleResizePointerDown = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onResizePointerDown(event, columnIndex);
},
[onResizePointerDown, columnIndex]
);
const handleResizeDoubleClick = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onDoubleClick(event, columnIndex);
},
[onDoubleClick, columnIndex]
);
const stopResizeClickPropagation = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
},
[]
);
const [openContextMenu, setOpenContextMenu] = React.useState(false);
const menuContext = React.useMemo(() => {
return {columnIndex, rowIndex, data: data.items[rowIndex][columnIndex]?.props?.rowData || {}};
}, [columnIndex, data.items, rowIndex]);
const resolvedContextMenuOptions = React.useMemo(() => {
const resolveOption = (option) => {
const resolvedOption = {
...option,
disabled: typeof option.disabled === "function" ? option.disabled(menuContext) : option.disabled,
};
if(option.menuItems){
resolvedOption.menuItems = option.menuItems.map(resolveOption);
}
return resolvedOption;
};
return contextMenuOptions.map(resolveOption);
}, [contextMenuOptions, menuContext]);
const handleContextClick = useCallback(
(event) => {
event.preventDefault();
if(resolvedContextMenuOptions && resolvedContextMenuOptions.length > 0){
if(item.disableFilterMenu){
return;
}
if(contextMenuOptions && contextMenuOptions.length > 0){
contextMenuLocationRef.current.x = event.clientX;
contextMenuLocationRef.current.y = event.clientY;
setOpenContextMenu(true);
}
},
[resolvedContextMenuOptions]
[contextMenuOptions, columnIndex] // eslint-disable-line react-hooks/exhaustive-deps
);
const handleMenuItemClick = (event, clickOption) => {
event.preventDefault();
event.stopPropagation();
clickOption({event, ...menuContext});
clickOption({event, columnIndex, rowIndex, data: data.items[rowIndex][columnIndex]?.props?.rowData || {}});
setOpenContextMenu(false);
};
const filterMenuOption = React.useMemo(
() => resolvedContextMenuOptions.find((option) => option.type === "item" && option.name === "Filter Column"),
[resolvedContextMenuOptions]
);
const handleFilterIndicatorClick = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
if(filterMenuOption?.disabled || !filterMenuOption?.click){
return;
}
filterMenuOption.click({event, ...menuContext});
setOpenContextMenu(false);
},
[filterMenuOption, menuContext]
);
const handleClicks = useSingleAndDoubleClick(handleClick, handleDoubleClick);
const className = `${classes.headerCell} ${item.disableSort ? classes.headerCellNoSort : ""}`;
return (
<div
style={style}
className={className}
onClick={handleClicks}
onContextMenu={handleContextClick}
ref={dropdownAnchorRef}
title={typeof headerLabel === "string" ? headerLabel : undefined}
>
<Box className={classes.headerContent}>
<Typography className={`${classes.cellInner} ${classes.headerLabel}`} component='div' variant='body1'>
{typeof headerLabel === "string" ? headerLabel : headerLabel}
<div style={style} className={classes.headerCell} onClick={handleClicks} onContextMenu={handleContextClick} ref={dropdownAnchorRef}>
<Box display='flex' alignItems='center' justifyContent='space-between' width='100%'>
<Typography className={classes.cellInner} variant='body1'>
{item[headerNameKey].toUpperCase()}
</Typography>
<Box className={classes.headerActions}>
{isFiltered &&
<button
aria-label={`Edit filter for ${typeof headerLabel === "string" ? headerLabel : "column"}`}
className={`${classes.headerIndicator} ${classes.headerFilterIcon}`}
onClick={handleFilterIndicatorClick}
title="Edit Filter"
type="button"
>
<FilterAltOutlinedIcon fontSize="inherit" />
</button>
}
{isSorted &&
<span className={`${classes.headerIndicator} ${classes.headerSortIcon}`} title={`Sorted ${sortDirection === 'ASC' ? 'ascending' : 'descending'}`}>
<HeaderSortIcon fontSize="inherit" />
</span>
}
</Box>
<ContextMenu dropdownAnchorRef={dropdownAnchorRef} contextMenuOptions={resolvedContextMenuOptions}
{isFiltered && <FontAwesomeIcon icon={faFilter} />}
{sortIndicatorIndex === columnIndex && (sortDirection === 'ASC' ? <div>↑</div> : <div>↓</div>)}
<ContextMenu dropdownAnchorRef={dropdownAnchorRef} contextMenuOptions={contextMenuOptions}
disableFilterMenu={item?.disableFilterMenu} openContextMenu={openContextMenu}
contextMenuLocationRef={contextMenuLocationRef}
setOpenContextMenu={setOpenContextMenu} handleMenuItemClick={handleMenuItemClick}
/>
</Box>
{!item.disableResize &&
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${typeof headerLabel === "string" ? headerLabel : "column"} column`}
className={`${classes.headerResizeHandle} ${isResizing ? classes.headerResizeHandleActive : ""}`}
onPointerDown={handleResizePointerDown}
onDoubleClick={handleResizeDoubleClick}
onClick={stopResizeClickPropagation}
/>
}
</div>
);
};
@@ -5,201 +5,15 @@ import useScrollbarSize from 'react-scrollbar-size';
import { VariableSizeGrid } from 'react-window';
import HeaderCell from './HeaderCell';
import Cell from './Cell';
import DraggableHandles from './DraggableHandles';
import {classes} from './styles';
import {GetMythicSetting, useSetMythicSetting} from "../MythicSavedUserSetting";
import FitScreenIcon from '@mui/icons-material/FitScreen';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
import ViewColumnIcon from '@mui/icons-material/ViewColumn';
import FilterAltOutlinedIcon from '@mui/icons-material/FilterAltOutlined';
const HeaderCellContext = createContext({});
const MIN_COLUMN_WIDTH = 50;
const MIN_FLEX_COLUMN_WIDTH = 50;
const AUTOSIZE_HORIZONTAL_PADDING = 40;
const AUTOSIZE_HEADER_EXTRA_WIDTH = 30;
const headerMenuIconStyle = {fontSize: "1rem", marginRight: "8px"};
const normalizeHeaderContextMenuOption = (option) => {
if(option?.name === "Filter Column"){
return {
...option,
icon: <FilterAltOutlinedIcon style={headerMenuIconStyle} />,
};
}
if(option?.menuItems){
return {
...option,
menuItems: option.menuItems.map(normalizeHeaderContextMenuOption),
};
}
return option;
};
const MIN_COLUMN_WIDTH = 100;
const MIN_FLEX_COLUMN_WIDTH = 150;
let autosizeCanvas;
const getNumericWidth = (value) => {
const width = Number(value);
return Number.isFinite(width) ? width : undefined;
};
const getColumnMinWidth = (column) => {
const configuredMinWidth = getNumericWidth(column?.minWidth);
return Math.max(configuredMinWidth || 0, column?.fillWidth ? MIN_FLEX_COLUMN_WIDTH : MIN_COLUMN_WIDTH);
};
const getColumnMaxWidth = (column) => {
const configuredMaxWidth = getNumericWidth(column?.maxWidth);
return configuredMaxWidth && configuredMaxWidth > 0 ? configuredMaxWidth : Infinity;
};
const clampColumnWidth = (column, width) => {
const minWidth = getColumnMinWidth(column);
const maxWidth = getColumnMaxWidth(column);
const numericWidth = getNumericWidth(width) || minWidth;
return Math.floor(Math.min(maxWidth, Math.max(numericWidth, minWidth)));
};
const getPreferredColumnWidth = (column) => {
return column?.width === undefined ? getColumnMinWidth(column) : column.width;
};
const distributeFillWidth = ({columns, columnWidths, totalWidth}) => {
const availableWidth = Math.max(0, Math.floor(totalWidth || 0));
const currentWidth = columnWidths.reduce((a, b) => a + b, 0);
const widthDifference = availableWidth - currentWidth;
if(widthDifference <= 0 || columns.length === 0){
return columnWidths;
}
const fillColumnIndexes = columns.reduce((previous, column, index) => {
return column.fillWidth ? [...previous, index] : previous;
}, []);
const targetIndexes = fillColumnIndexes.length > 0 ? fillColumnIndexes : [columns.length - 1];
let remainingWidth = widthDifference;
const updatedColumnWidths = [...columnWidths];
for(let i = 0; i < targetIndexes.length; i++){
const columnIndex = targetIndexes[i];
const share = i === targetIndexes.length - 1 ? remainingWidth : Math.floor(widthDifference / targetIndexes.length);
const currentColumnWidth = updatedColumnWidths[columnIndex];
const nextColumnWidth = clampColumnWidth(columns[columnIndex], currentColumnWidth + share);
updatedColumnWidths[columnIndex] = nextColumnWidth;
remainingWidth -= nextColumnWidth - currentColumnWidth;
}
return updatedColumnWidths;
};
const getInitialColumnWidths = ({columns, savedWidths = [], totalWidth = 0}) => {
const hasSavedWidths = savedWidths.length === columns.length;
const baseColumnWidths = columns.map((column, index) => {
return clampColumnWidth(column, hasSavedWidths ? savedWidths[index] : getPreferredColumnWidth(column));
});
if(hasSavedWidths){
return baseColumnWidths;
}
return distributeFillWidth({columns, columnWidths: baseColumnWidths, totalWidth});
};
const areColumnWidthsEqual = (first = [], second = []) => {
if(first.length !== second.length){
return false;
}
return first.every((width, index) => Math.floor(width || MIN_COLUMN_WIDTH) === Math.floor(second[index] || MIN_COLUMN_WIDTH));
};
const normalizeColumnWidths = (columnWidths = []) => {
return columnWidths.map((columnWidth) => Math.floor(getNumericWidth(columnWidth) || MIN_COLUMN_WIDTH));
};
const getMeasureContext = () => {
if(typeof document === "undefined"){
return null;
}
if(autosizeCanvas === undefined){
autosizeCanvas = document.createElement("canvas");
}
const context = autosizeCanvas.getContext("2d");
if(context === null){
return null;
}
const referenceElement = document.querySelector(`.${classes.cell}`) || document.body;
const computedStyle = window.getComputedStyle(referenceElement);
context.font = [
computedStyle.fontStyle,
computedStyle.fontVariant,
computedStyle.fontWeight,
computedStyle.fontSize,
computedStyle.fontFamily,
].join(" ");
return context;
};
const getDisplayText = (value) => {
if(value === undefined || value === null){
return "";
}
if(typeof value === "string"){
const trimmedValue = value.trim();
if(trimmedValue.startsWith("[")){
try{
const parsedValue = JSON.parse(trimmedValue);
if(Array.isArray(parsedValue) && parsedValue.length > 0){
return getDisplayText(parsedValue[0]);
}
}catch(error){
// Keep the original string when it is not a serialized array.
}
}
return value;
}
if(typeof value === "number" || typeof value === "boolean"){
return String(value);
}
if(Array.isArray(value)){
return value.map((entry) => getDisplayText(entry)).filter(Boolean).join(", ");
}
if(React.isValidElement(value)){
return getDisplayText(value.props?.cellData || value.props?.children);
}
if(typeof value === "object"){
if(value.plaintext !== undefined){
return getDisplayText(value.plaintext);
}
if(value.name !== undefined){
return getDisplayText(value.name);
}
if(value.button?.name !== undefined){
return getDisplayText(value.button.name);
}
if(value.display !== undefined){
return getDisplayText(value.display);
}
return "";
}
return "";
};
const getCellAutosizeText = ({column, cell}) => {
const cellProps = cell?.props || {};
const columnKey = column?.key || column?.plaintext;
const rowData = cellProps.rowData || {};
const cellDataText = getDisplayText(cellProps.cellData);
if(cellDataText !== ""){
return cellDataText;
}
if(columnKey !== undefined){
return getDisplayText(rowData?.[columnKey]);
}
return getDisplayText(cell);
};
const measureAutosizeText = (context, text) => {
const normalizedText = getDisplayText(text);
if(normalizedText === ""){
return 0;
}
const lines = normalizedText.split(/\r?\n/);
return lines.reduce((longestLineWidth, line) => {
const textWidth = context ? context.measureText(line).width : line.length * 8;
return Math.max(longestLineWidth, textWidth);
}, 0);
};
const getAutosizedColumnWidth = ({column, columnIndex, headerNameKey, items}) => {
const measureContext = getMeasureContext();
const headerText = getDisplayText(column?.[headerNameKey] || column?.name || column?.plaintext);
const headerWidth = measureAutosizeText(measureContext, headerText.toUpperCase()) + AUTOSIZE_HEADER_EXTRA_WIDTH;
const widestCellWidth = items.reduce((widestWidth, itemRow) => {
const cellText = getCellAutosizeText({column, cell: itemRow[columnIndex]});
return Math.max(widestWidth, measureAutosizeText(measureContext, cellText));
}, 0);
return clampColumnWidth(column, Math.max(headerWidth, widestCellWidth) + AUTOSIZE_HORIZONTAL_PADDING);
};
const CellRendererPreMemo = ({ style, rowIndex, columnIndex, data }) => {
return rowIndex === 0 ? null : <Cell style={style} rowIndex={rowIndex} columnIndex={columnIndex} data={data} />;
@@ -207,16 +21,12 @@ const CellRendererPreMemo = ({ style, rowIndex, columnIndex, data }) => {
const CellRenderer = React.memo(CellRendererPreMemo);
const innerElementType = React.forwardRef(({ children, style }, ref) => {
const HeaderCellData = useContext(HeaderCellContext);
const resizingColumnIndex = HeaderCellData.resizingColumnIndex;
const resizeGuideLeft = resizingColumnIndex >= 0 ?
HeaderCellData.columnWidths.slice(0, resizingColumnIndex + 1).reduce((a, b) => a + b, 0) :
-1;
const onHeaderDoubleClick = React.useCallback( (e, columnIndex) => {
if (HeaderCellData.columns[columnIndex].disableAutosize) return;
HeaderCellData.autosizeColumn({columnIndex});
}, [HeaderCellData.columns, HeaderCellData.autosizeColumn]);
return (
<div ref={ref} style={{...style, position: "relative"}}>
<div ref={ref} style={style}>
{/* always render header cells */}
<div
className={classes.headerCellRow}
@@ -234,8 +44,6 @@ const innerElementType = React.forwardRef(({ children, style }, ref) => {
contextMenuOptions={HeaderCellData.contextMenuOptions}
sortIndicatorIndex={HeaderCellData.sortIndicatorIndex}
sortDirection={HeaderCellData.sortDirection}
isResizing={HeaderCellData.resizingColumnIndex === i}
onResizePointerDown={HeaderCellData.startColumnResize}
VariableSizeGridProps={{
style: {
position: 'absolute',
@@ -254,15 +62,6 @@ const innerElementType = React.forwardRef(({ children, style }, ref) => {
</div>
{/* render other cells as usual */}
{children}
{resizingColumnIndex >= 0 &&
<div
className={classes.resizeGuide}
style={{
left: resizeGuideLeft,
height: style.height,
}}
/>
}
</div>
);
});
@@ -293,31 +92,19 @@ const ResizableGridWrapper = ({
const { width: scrollbarWidth } = useScrollbarSize();
const localColumnsRef = React.useRef(columns);
const initialSavedWidths = name === undefined ? [] : GetMythicSetting({setting_name: `${name}_column_widths`, default_value: {}, output: "json-array"});
const savedColumnWidths = Array.isArray(initialSavedWidths) ? initialSavedWidths : [];
const [columnWidths, setColumnWidths] = useState(() => getInitialColumnWidths({
columns,
savedWidths: savedColumnWidths,
totalWidth: AutoSizerProps.width - scrollbarWidth,
const [columnWidths, setColumnWidths] = useState(columns.map((column, index) => {
if(initialSavedWidths.length > 0){
return initialSavedWidths[index];
}
if(column.fillWidth){
return Math.max(column?.width || 0, MIN_FLEX_COLUMN_WIDTH);
}
return Math.max(column?.width || 0, MIN_COLUMN_WIDTH);
}));
const gridUUID = React.useMemo( () => GetShortRandomString(), []);
const gridRef = useRef(null);
const columnWidthsRef = useRef(columnWidths);
const lastResetColumnIndexRef = useRef(0);
const activeResizeRef = useRef(null);
const resizeFrameRef = useRef(null);
const cleanupResizeListenersRef = useRef(null);
const [resizingColumnIndex, setResizingColumnIndex] = useState(-1);
const dragHandlesRef = useRef(null);
const [updateSetting] = useSetMythicSetting();
const setColumnWidthsAndRef = useCallback( (newColumnWidths, resetColumnIndex=0) => {
const normalizedColumnWidths = normalizeColumnWidths(newColumnWidths);
if(areColumnWidthsEqual(columnWidthsRef.current, normalizedColumnWidths)){
return false;
}
lastResetColumnIndexRef.current = resetColumnIndex;
columnWidthsRef.current = normalizedColumnWidths;
setColumnWidths(normalizedColumnWidths);
return true;
}, []);
const getColumnWidth = useCallback(
(index) => {
return columnWidths[index] || MIN_COLUMN_WIDTH;
@@ -334,196 +121,152 @@ const ResizableGridWrapper = ({
[rowHeight, headerRowHeight]
);
useEffect(() => {
if(savedColumnWidths.length === columns.length && localColumnsRef.current.length === columns.length){
if(initialSavedWidths.length > 0 && localColumnsRef.current.length === columns.length){
return;
}
localColumnsRef.current = columns;
const updatedColumnWidths = getInitialColumnWidths({
columns,
savedWidths: [],
totalWidth: AutoSizerProps.width - scrollbarWidth,
const totalWidth = AutoSizerProps.width - scrollbarWidth;
const updatedColumnWidths = columns.map((column, index) => {
return column.width || MIN_COLUMN_WIDTH
});
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
const totalWidthDiff = totalWidth - updatedColumnWidths.reduce((a, b) => a + b, 0);
if (totalWidthDiff !== 0) {
let updatedWidthIndexs = [];
for(let i = 0; i < columns.length; i++){
// check if any of the columns have the `fillWidth` property to true
if(columns[i]["fillWidth"]){
updatedWidthIndexs.push(i);
}
}
if(updatedWidthIndexs.length === 0){
updatedWidthIndexs.push(columns.length - 1);
}
for(let i = 0; i < updatedWidthIndexs.length; i++){
updatedColumnWidths[updatedWidthIndexs[i]] += Math.max(totalWidthDiff / updatedWidthIndexs.length, MIN_FLEX_COLUMN_WIDTH);
}
//updatedColumnWidths[updatedWidthIndex] += totalWidthDiff;
}
setColumnWidths(updatedColumnWidths);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
}
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current, setColumnWidthsAndRef]); // eslint-disable-line react-hooks/exhaustive-deps
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if(gridRef.current){
gridRef.current.resetAfterColumnIndex(lastResetColumnIndexRef.current, true);
gridRef.current.resetAfterColumnIndex(0, true);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: columnWidths});
}
}, [columnWidths]);
/* Event Handlers */
const startColumnResize = useCallback( (event, columnIndex) => {
if(columns[columnIndex]?.disableResize){
return;
}
const initialWidths = columnWidthsRef.current.map( (columnWidth) => Math.floor(columnWidth || MIN_COLUMN_WIDTH));
const startClientX = event.clientX;
const startWidth = initialWidths[columnIndex] || MIN_COLUMN_WIDTH;
const previousCursor = document.body.style.cursor;
const previousUserSelect = document.body.style.userSelect;
const applyResizeWidth = (clientX) => {
const activeResize = activeResizeRef.current;
if(!activeResize){
return;
}
activeResize.latestClientX = clientX;
if(resizeFrameRef.current !== null){
return;
}
resizeFrameRef.current = window.requestAnimationFrame(() => {
resizeFrameRef.current = null;
const currentResize = activeResizeRef.current;
if(!currentResize){
return;
}
const resizeDelta = currentResize.latestClientX - currentResize.startClientX;
const nextColumnWidth = clampColumnWidth(columns[currentResize.columnIndex], currentResize.startWidth + resizeDelta);
const updatedColumnWidths = currentResize.initialWidths.map( (columnWidth, index) => {
return currentResize.columnIndex === index ? nextColumnWidth : columnWidth;
});
setColumnWidthsAndRef(updatedColumnWidths, currentResize.columnIndex);
});
};
const cleanupResizeListeners = () => {
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerUp);
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousUserSelect;
cleanupResizeListenersRef.current = null;
};
const finishResize = (clientX) => {
const currentResize = activeResizeRef.current;
if(!currentResize){
cleanupResizeListeners();
return;
}
if(resizeFrameRef.current !== null){
window.cancelAnimationFrame(resizeFrameRef.current);
resizeFrameRef.current = null;
}
const resizeDelta = clientX - currentResize.startClientX;
const nextColumnWidth = clampColumnWidth(columns[currentResize.columnIndex], currentResize.startWidth + resizeDelta);
const updatedColumnWidths = currentResize.initialWidths.map( (columnWidth, index) => {
return currentResize.columnIndex === index ? nextColumnWidth : columnWidth;
});
activeResizeRef.current = null;
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, currentResize.columnIndex);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
setResizingColumnIndex(-1);
cleanupResizeListeners();
};
function handlePointerMove(pointerEvent){
pointerEvent.preventDefault();
applyResizeWidth(pointerEvent.clientX);
}
function handlePointerUp(pointerEvent){
pointerEvent.preventDefault();
finishResize(pointerEvent.clientX);
}
event.currentTarget.setPointerCapture?.(event.pointerId);
activeResizeRef.current = {
columnIndex,
initialWidths,
latestClientX: startClientX,
startClientX,
startWidth,
};
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
setResizingColumnIndex(columnIndex);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", handlePointerUp);
cleanupResizeListenersRef.current = cleanupResizeListeners;
}, [columns, name, setColumnWidthsAndRef, updateSetting]);
useEffect(() => {
return () => {
cleanupResizeListenersRef.current?.();
activeResizeRef.current = null;
if(resizeFrameRef.current !== null){
window.cancelAnimationFrame(resizeFrameRef.current);
resizeFrameRef.current = null;
}
};
}, []);
const autosizeColumn = useCallback( ({columnIndex}) => {
const column = columns[columnIndex];
if(column?.disableDoubleClick || column?.disableAutosize){
return
}
const currentColumnWidths = columnWidthsRef.current;
const updatedColumnWidths = currentColumnWidths.map((columnWidth, index) => {
const resizeColumn = useCallback( (x, columnIndex) => {
const updatedColumnWidths = columnWidths.map((columnWidth, index) => {
if (columnIndex === index) {
return getAutosizedColumnWidth({column, columnIndex, headerNameKey, items});
return Math.floor(Math.max(columnWidth + x, MIN_COLUMN_WIDTH));
}
return Math.floor(columnWidth);
});
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, columnIndex);
if(didUpdateWidths){
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
setColumnWidths(updatedColumnWidths);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
}
}, [columnWidths]);
const autosizeColumn = useCallback( ({columnIndex}) => {
if(columns[columnIndex].disableDoubleClick){
return
}
const longestElementInColumn = Math.max(...items.map((itemRow) => {
if(columns[columnIndex].key === undefined){
if(columns[columnIndex].plaintext){
columns[columnIndex].key = columns[columnIndex].plaintext;
} else {
return 30;
}
}
if(columns[columnIndex].key !== undefined){
if(columns[columnIndex].key.includes("time")){
return 30;
}
if(columns[columnIndex].key === "mythictree_groups"){
return itemRow[columnIndex]?.props?.cellData.length;
}
if(columns[columnIndex].type === "size"){
if(itemRow[columnIndex]?.props?.cellData !== undefined){
if(typeof itemRow[columnIndex]?.props?.cellData === 'number'){
return String(itemRow[columnIndex]?.props?.cellData)?.length;
}
return String(itemRow[columnIndex]?.props?.cellData?.plaintext)?.length;
}
return itemRow[columnIndex].length;
}
try{
items = JSON.parse(itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key]);
if(Array.isArray(items) && items.length > 0){
return String(items[0]).length;
}
}catch(error){
//console.log(itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key])
}
if(typeof itemRow[columnIndex]?.props?.cellData === 'string' ){
return itemRow[columnIndex]?.props?.cellData.length;
}
let data = itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key];
if(columns[columnIndex].inMetadata){
return itemRow[columnIndex]?.props?.cellData.length;
}
if(data === undefined){
return MIN_COLUMN_WIDTH;
}
if(data.plaintext){
return String(data.plaintext)?.length;
} else if(data?.button?.name) {
return String(data?.button?.name)?.length ;
} else {
return MIN_COLUMN_WIDTH;
}
//return String(itemRow[columnIndex]?.props?.rowData?.[columns[columnIndex].key]).length || -1;
} else if(typeof(itemRow[columnIndex]?.props?.cellData) === "string") {
try {
items = JSON.parse(itemRow[columnIndex]?.props?.cellData);
if (Array.isArray(items) && items.length > 0) {
return String(items[0]).length;
}
} catch (error) {
return itemRow[columnIndex]?.props?.cellData.length;
}
} else if(Array.isArray(itemRow[columnIndex]?.props?.cellData)){
return itemRow[columnIndex]?.props?.cellData.join(", ").length;
}else {
return itemRow[columnIndex]?.props?.cellData?.length || -1;
}
}));
const updatedColumnWidths = columnWidths.map((columnWidth, index) => {
if (columnIndex === index) {
if(isNaN(longestElementInColumn)){
return MIN_COLUMN_WIDTH;
}
return Math.floor(Math.max(longestElementInColumn * 10 + 40, MIN_COLUMN_WIDTH));
}
return Math.floor(columnWidth);
});
//console.log(updatedColumnWidths, columnWidths, longestElementInColumn);
let updatedValues = false;
for(let i = 0; i < updatedColumnWidths.length; i++){
if(updatedColumnWidths[i] !== columnWidths[i]){
updatedValues = true;
break;
}
}
}, [columns, headerNameKey, items, name, setColumnWidthsAndRef, updateSetting]);
const autosizeAllColumns = useCallback( () => {
const currentColumnWidths = columnWidthsRef.current;
const updatedColumnWidths = currentColumnWidths.map((columnWidth, columnIndex) => {
const column = columns[columnIndex];
if(column?.disableDoubleClick || column?.disableAutosize){
return Math.floor(columnWidth);
}
return getAutosizedColumnWidth({column, columnIndex, headerNameKey, items});
});
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, 0);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
if(updatedValues){
setColumnWidths(() => updatedColumnWidths);
}
}, [columns, headerNameKey, items, name, setColumnWidthsAndRef, updateSetting]);
const resetColumnWidths = useCallback( () => {
const updatedColumnWidths = getInitialColumnWidths({
columns,
savedWidths: [],
totalWidth: AutoSizerProps.width - scrollbarWidth,
});
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, 0);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
}, [AutoSizerProps.width, columns, name, scrollbarWidth, setColumnWidthsAndRef, updateSetting]);
const headerContextMenuOptions = React.useMemo( () => {
const sizingOptions = [
{
name: "Autosize Column",
type: "item",
icon: <FitScreenIcon style={headerMenuIconStyle} />,
disabled: ({columnIndex}) => columns[columnIndex]?.disableDoubleClick || columns[columnIndex]?.disableAutosize,
click: ({columnIndex}) => autosizeColumn({columnIndex}),
},
{
name: "Autosize All Columns",
type: "item",
icon: <ViewColumnIcon style={headerMenuIconStyle} />,
disabled: columns.every((column) => column?.disableDoubleClick || column?.disableAutosize),
click: () => autosizeAllColumns(),
},
{
name: "Reset Column Widths",
type: "item",
icon: <RestartAltIcon style={headerMenuIconStyle} />,
click: () => resetColumnWidths(),
},
];
return [...sizingOptions, ...(contextMenuOptions || []).map(normalizeHeaderContextMenuOption)];
}, [autosizeAllColumns, autosizeColumn, columns, contextMenuOptions, resetColumnWidths]);
}, [columnWidths]);
useEffect( () => {
if(callbackTableGridRef){
@@ -538,19 +281,22 @@ const ResizableGridWrapper = ({
"headerNameKey": headerNameKey,
"onClickHeader": onClickHeader,
"autosizeColumn": autosizeColumn,
"contextMenuOptions": headerContextMenuOptions,
"contextMenuOptions": contextMenuOptions,
"sortIndicatorIndex": sortIndicatorIndex,
"sortDirection": sortDirection,
"getColumnWidth": getColumnWidth,
"resizingColumnIndex": resizingColumnIndex,
"startColumnResize": startColumnResize,
"itemsWithHeader": itemsWithHeader,
};
const localOnScroll = React.useCallback( ({scrollLeft}) => {
if (dragHandlesRef.current) {
dragHandlesRef.current.scrollTo({ left: scrollLeft });
}
}, [dragHandlesRef.current])
return (
<>
<HeaderCellContext.Provider value={headerCellData}>
<VariableSizeGrid
className={classes.grid}
height={Math.max(1, AutoSizerProps.height)}
width={AutoSizerProps.width}
columnCount={columns.length}
@@ -565,9 +311,19 @@ const ResizableGridWrapper = ({
innerElementType={innerElementType}
overscanRowCount={20}
useIsScrolling={false}
onScroll={localOnScroll}
ref={gridRef}>
{CellRenderer}
</VariableSizeGrid>
<DraggableHandles
height={AutoSizerProps.height}
rowHeight={headerRowHeight}
width={AutoSizerProps.width}
minColumnWidth={MIN_COLUMN_WIDTH}
columnWidths={columnWidths}
onStop={resizeColumn}
ref={dragHandlesRef}
/>
</HeaderCellContext.Provider>
</>
@@ -593,31 +349,29 @@ const MythicResizableGrid = ({
name,
}) => {
return (
<div className={classes.root}>
<AutoSizer style={{height: "100%", width: "100%"}}>
{(AutoSizerProps) => (
<ResizableGridWrapper
name={name}
columns={columns}
callbackTableGridRef={callbackTableGridRef}
headerNameKey={headerNameKey}
sortIndicatorIndex={sortIndicatorIndex}
sortDirection={sortDirection}
items={items}
widthMeasureKey={widthMeasureKey}
rowHeight={rowHeight}
headerRowHeight={headerRowHeight}
onClickHeader={onClickHeader}
onDoubleClickRow={onDoubleClickRow}
contextMenuOptions={contextMenuOptions}
rowContextMenuOptions={rowContextMenuOptions}
onRowContextMenuClick={onRowContextMenuClick}
onRowClick={onRowClick}
{...AutoSizerProps}
/>
)}
</AutoSizer>
</div>
<AutoSizer style={{height: "100%"}}>
{(AutoSizerProps) => (
<ResizableGridWrapper
name={name}
columns={columns}
callbackTableGridRef={callbackTableGridRef}
headerNameKey={headerNameKey}
sortIndicatorIndex={sortIndicatorIndex}
sortDirection={sortDirection}
items={items}
widthMeasureKey={widthMeasureKey}
rowHeight={rowHeight}
headerRowHeight={headerRowHeight}
onClickHeader={onClickHeader}
onDoubleClickRow={onDoubleClickRow}
contextMenuOptions={contextMenuOptions}
rowContextMenuOptions={rowContextMenuOptions}
onRowContextMenuClick={onRowContextMenuClick}
onRowClick={onRowClick}
{...AutoSizerProps}
/>
)}
</AutoSizer>
);
};
@@ -627,7 +381,6 @@ MythicResizableGrid.propTypes = {
name: PropTypes.string,
width: PropTypes.number,
disableAutosize: PropTypes.bool,
disableResize: PropTypes.bool,
disableSort: PropTypes.bool
})
).isRequired,
@@ -1,24 +1,12 @@
const PREFIX = 'MythicResizableGrid';
export const classes = {
root: `${PREFIX}-root flex-fill h-full min-h-0 min-w-0 overflow-hidden relative rounded w-full`,
grid: `${PREFIX}-grid`,
headerCellRow: `${PREFIX}-headerCellRow flex flex-row`,
headerCell: `${PREFIX}-headerCell cursor-pointer items-center flex justify-between min-w-0 overflow-hidden relative`,
headerCellNoSort: `${PREFIX}-headerCellNoSort`,
headerContent: `${PREFIX}-headerContent items-center flex gap-3 justify-between min-w-0 w-full`,
headerLabel: `${PREFIX}-headerLabel min-w-0 truncate`,
headerActions: `${PREFIX}-headerActions items-center inline-flex flex-none gap-2 min-w-0`,
headerIndicator: `${PREFIX}-headerIndicator items-center inline-flex flex-none justify-center rounded`,
headerFilterIcon: `${PREFIX}-headerFilterIcon cursor-pointer`,
headerSortIcon: `${PREFIX}-headerSortIcon`,
headerResizeHandle: `${PREFIX}-headerResizeHandle`,
headerResizeHandleActive: `${PREFIX}-headerResizeHandleActive`,
resizeGuide: `${PREFIX}-resizeGuide`,
headerCellRow: `${PREFIX}-headerCellRow`,
headerCell: `${PREFIX}-headerCell`,
hoveredRow: `${PREFIX}-hoveredRow`,
contextRow: `${PREFIX}-contextRow`,
rowFirstCell: `${PREFIX}-rowFirstCell`,
rowLastCell: `${PREFIX}-rowLastCell`,
rowInteractive: `${PREFIX}-rowInteractive`,
cell: `${PREFIX}-cell items-center flex min-w-0 relative truncate`,
cellInner: `${PREFIX}-cellInner items-center flex min-w-0 truncate w-full whitespace-nowrap`,
cell: `${PREFIX}-cell`,
cellInner: `${PREFIX}-cellInner`,
draggableHandlesContainer: `${PREFIX}-draggableHandlesContainer`,
draggableHandlesClickArea: `${PREFIX}-draggableHandlesClickArea`,
draggableHandlesClickAreaSelected: `${PREFIX}-draggableHandlesClickAreaSelected`,
draggableHandlesIndicator: `${PREFIX}-draggableHandlesIndicator`,
}
@@ -17,26 +17,12 @@ export const GetComputedFontSize = () => {
const fontSizeString = window.getComputedStyle(element).fontSize;
return parseFloat(fontSizeString);
}
let latestPreferenceSnapshot = null;
const getPreferenceSnapshot = () => {
return latestPreferenceSnapshot || mePreferences() || {};
};
const areSettingValuesEqual = (first, second) => {
if(first === second){
return true;
}
if(typeof first !== "object" || typeof second !== "object" || first === null || second === null){
return false;
}
try{
return JSON.stringify(first) === JSON.stringify(second);
}catch(error){
return false;
}
};
/*
setting_name options:
taskingDisplayFields
hideUsernames
showIP
showHostname
showCallbackGroups
showMedia
interactType
@@ -57,7 +43,7 @@ export function useGetMythicSetting({setting_name, default_value}){
return setting;
}
export function GetMythicSetting({setting_name, default_value}){
const preferences = getPreferenceSnapshot();
const preferences = mePreferences();
return preferences?.[setting_name] === undefined ? default_value : preferences?.[setting_name];
}
export function useSetMythicSetting() {
@@ -71,35 +57,27 @@ export function useSetMythicSetting() {
}
});
return [
({setting_name, value, broadcast=true}) => {
const currentPreferences = getPreferenceSnapshot();
if(!areSettingValuesEqual(currentPreferences?.[setting_name], value)){
({setting_name, value}) => {
if(mePreferences()?.[setting_name] !== value){
const updatedPreferences = {
...currentPreferences,
...mePreferences(),
[setting_name]: value,
};
latestPreferenceSnapshot = updatedPreferences;
if(broadcast){
mePreferences(updatedPreferences);
}
mePreferences(updatedPreferences);
updateSetting({variables: {preferences: updatedPreferences}});
}
},
({settings, broadcast=true}) => {
({settings}) => {
const updatedPreferences = {
...getPreferenceSnapshot(),
...mePreferences(),
...settings,
};
latestPreferenceSnapshot = updatedPreferences;
if(broadcast){
mePreferences(updatedPreferences);
}
mePreferences(updatedPreferences);
updateSetting({variables: {preferences: updatedPreferences}});
},
() => {
latestPreferenceSnapshot = operatorSettingDefaults;
mePreferences(operatorSettingDefaults);
updateSetting({variables: {preferences: operatorSettingDefaults}});
}
]
}
}
@@ -1,5 +1,5 @@
import React, {useRef, useEffect} from 'react';
import {MythicActionButton} from './MythicActionButton';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
@@ -8,9 +8,13 @@ import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import InputLabel from '@mui/material/InputLabel';
import Input from '@mui/material/Input';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import Paper from '@mui/material/Paper';
import MythicStyledTableCell from "./MythicTableCell";
export function MythicSelectFromListDialog(props) {
const [options, setOptions] = React.useState([]);
@@ -61,12 +65,12 @@ export function MythicSelectFromListDialog(props) {
</React.Fragment>
</DialogContent>
<DialogActions>
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicActionButton>
<MythicActionButton colorMode="always" onClick={handleSubmit} tone="success" variant="contained">
</Button>
<Button onClick={handleSubmit} variant="contained" color="success">
{props.action}
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
);
@@ -74,17 +78,10 @@ export function MythicSelectFromListDialog(props) {
export function MythicSelectFromRawListDialog(props) {
const [options, setOptions] = React.useState([]);
const actionText = props.action || "Select";
const handleSubmit = (selected) => {
props.onSubmit(selected);
props.onClose();
}
const handleKeyDown = (event, selected) => {
if(event.key === "Enter" || event.key === " "){
event.preventDefault();
handleSubmit(selected);
}
}
useEffect( () => {
//expects options to be an array of dictionaries with a "display" field for what gets presented to the user
const opts = [...props.options];
@@ -93,37 +90,26 @@ export function MythicSelectFromRawListDialog(props) {
return (
<React.Fragment>
<DialogTitle >{props.title}</DialogTitle>
<DialogContent dividers={true} className="mythic-raw-select-dialog-content">
{options.length === 0 ? (
<Box className="mythic-raw-select-empty items-center flex justify-center text-muted text-center">
<Typography variant="body2">No options available</Typography>
</Box>
) : (
<Stack className="mythic-raw-select-list gap-4">
{options.map( (choice, i) => (
<Box
key={String(choice) + i}
className="mythic-raw-select-row items-center flex gap-6 justify-between min-w-0 w-full rounded cursor-pointer bg-surface-muted border-subtle text-primary"
role="button"
tabIndex={0}
onClick={() => handleSubmit(choice)}
onKeyDown={(event) => handleKeyDown(event, choice)}
>
<Typography className="mythic-raw-select-value min-w-0 truncate whitespace-nowrap" title={String(choice)}>
{String(choice)}
</Typography>
<MythicActionButton className="mythic-raw-select-action" colorMode="always" tone="info" variant="outlined" size="small" tabIndex={-1}>
{actionText}
</MythicActionButton>
</Box>
))}
</Stack>
)}
</DialogContent>
<div style={{height: "100%", display: "flex"}}>
<TableContainer component={Paper} className="mythicElement" style={{flexGrow: 1, overflowY: "auto"}}>
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
<TableBody style={{whiteSpace: "pre"}}>
{options.map( (choice, i) => (
<TableRow hover key={choice + i}>
<MythicStyledTableCell style={{width: "5rem"}}>
<Button onClick={() => handleSubmit(choice)} variant="contained" color="primary">Select</Button>
</MythicStyledTableCell>
<MythicStyledTableCell>{choice}</MythicStyledTableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</div>
<DialogActions>
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
);
@@ -1,7 +1,7 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
import { Link } from '@mui/material';
const PREFIX = 'MythicSnackDownload';
@@ -59,7 +59,7 @@ const Root = styled('div')((
[`& .${classes.checkIcon}`]: {
fontSize: 20,
color: theme.palette.text.secondary,
color: '#b3b3b3',
paddingRight: 4,
},
@@ -77,10 +77,10 @@ export const MythicSnackDownload = (props) => {
</Typography>
<React.Fragment>
<Typography gutterBottom>File ready for download</Typography>
<FileDownloadLinkWithAuth color="textPrimary" download={true} href={"/direct/download/" + props.file_id} target="_blank">
<Link color="textPrimary" download={true} href={"/direct/download/" + props.file_id} target="_blank">
Download here
</FileDownloadLinkWithAuth>
</Link>
</React.Fragment>
</Root>
);
};
};
@@ -1,192 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import {MythicActionButton} from './MythicActionButton';
import CircularProgress from '@mui/material/CircularProgress';
import Skeleton from '@mui/material/Skeleton';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import Typography from '@mui/material/Typography';
import {alpha, useTheme} from '@mui/material/styles';
import ErrorOutlineOutlinedIcon from '@mui/icons-material/ErrorOutlineOutlined';
import HourglassEmptyOutlinedIcon from '@mui/icons-material/HourglassEmptyOutlined';
import InboxOutlinedIcon from '@mui/icons-material/InboxOutlined';
import SearchOffOutlinedIcon from '@mui/icons-material/SearchOffOutlined';
const stateIcons = {
empty: <InboxOutlinedIcon />,
error: <ErrorOutlineOutlinedIcon />,
loading: <HourglassEmptyOutlinedIcon />,
search: <SearchOffOutlinedIcon />,
};
export function MythicStateDisplay({
action,
actionLabel,
compact = false,
description,
icon,
loading = false,
minHeight,
onAction,
severity = "empty",
sx = {},
title,
}) {
const theme = useTheme();
const defaultIcon = stateIcons[severity] || stateIcons.empty;
const iconNode = loading ? (
<CircularProgress size={compact ? 18 : 22} color="inherit" disableShrink />
) : React.cloneElement(icon || defaultIcon, {fontSize: compact ? "small" : "medium"});
const resolvedTitle = title || (loading ? "Loading" : "Nothing to show");
const resolvedMinHeight = minHeight || (compact ? 112 : 176);
return (
<Box sx={{
alignItems: "center",
color: theme.palette.text.secondary,
display: "flex",
justifyContent: "center",
minHeight: resolvedMinHeight,
p: compact ? 1.5 : 2.5,
width: "100%",
...sx,
}}>
<Box sx={{
alignItems: "center",
display: "flex",
flexDirection: "column",
gap: compact ? 0.75 : 1,
maxWidth: compact ? 360 : 460,
textAlign: "center",
}}>
<Box sx={{
alignItems: "center",
backgroundColor: theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.055) : alpha(theme.palette.common.black, 0.035),
border: `1px solid ${theme.palette.mode === "dark" ? alpha(theme.palette.common.white, 0.09) : alpha(theme.palette.common.black, 0.08)}`,
borderRadius: "6px",
color: severity === "error" ? theme.palette.error.main : theme.palette.text.secondary,
display: "inline-flex",
height: compact ? 32 : 40,
justifyContent: "center",
width: compact ? 32 : 40,
}}>
{iconNode}
</Box>
<Typography component="div" sx={{
color: theme.palette.text.primary,
fontSize: compact ? "0.86rem" : "0.95rem",
fontWeight: 800,
lineHeight: 1.25,
}}>
{resolvedTitle}
</Typography>
{description &&
<Typography component="div" sx={{
color: theme.palette.text.secondary,
fontSize: compact ? "0.76rem" : "0.82rem",
lineHeight: 1.35,
}}>
{description}
</Typography>
}
{action || (actionLabel && onAction) ? (
action || (
<MythicActionButton size="small" variant="outlined" onClick={onAction} sx={{mt: 0.5}}>
{actionLabel}
</MythicActionButton>
)
) : null}
</Box>
</Box>
);
}
export function MythicEmptyState(props) {
return <MythicStateDisplay severity="empty" {...props} />;
}
export function MythicSearchEmptyState(props) {
return <MythicStateDisplay severity="search" title="No results" {...props} />;
}
export function MythicLoadingState(props) {
return <MythicStateDisplay severity="loading" loading title="Loading" {...props} />;
}
export function MythicErrorState(props) {
return <MythicStateDisplay severity="error" title="Something went wrong" {...props} />;
}
export function MythicTableEmptyState({colSpan, ...props}) {
return (
<TableRow>
<TableCell colSpan={colSpan} sx={{borderBottom: 0, p: 0}}>
<MythicEmptyState {...props} />
</TableCell>
</TableRow>
);
}
export function MythicTableSearchEmptyState({colSpan, ...props}) {
return (
<TableRow>
<TableCell colSpan={colSpan} sx={{borderBottom: 0, p: 0}}>
<MythicSearchEmptyState {...props} />
</TableCell>
</TableRow>
);
}
export function MythicTableErrorState({colSpan, ...props}) {
return (
<TableRow>
<TableCell colSpan={colSpan} sx={{borderBottom: 0, p: 0}}>
<MythicErrorState {...props} />
</TableCell>
</TableRow>
);
}
export function MythicTableSkeletonRows({colSpan, columns = 4, rows = 4}) {
const skeletonColumns = Math.max(1, columns);
return (
<>
{[...Array(rows).keys()].map((rowIndex) => (
<TableRow key={`mythic-table-skeleton-${rowIndex}`}>
<TableCell colSpan={colSpan} sx={{px: 1.25, py: 0.75}}>
<Box sx={{
display: "grid",
gap: 1,
gridTemplateColumns: `repeat(${skeletonColumns}, minmax(0, 1fr))`,
}}>
{[...Array(skeletonColumns).keys()].map((columnIndex) => (
<Skeleton
animation="wave"
height={22}
key={`mythic-table-skeleton-${rowIndex}-${columnIndex}`}
sx={{borderRadius: 1}}
variant="rounded"
width={columnIndex === skeletonColumns - 1 ? "72%" : "100%"}
/>
))}
</Box>
</TableCell>
</TableRow>
))}
</>
);
}
export function MythicTableLoadingState({colSpan, columns, rows = 4, showSkeleton = true, ...props}) {
return (
<>
<TableRow>
<TableCell colSpan={colSpan} sx={{borderBottom: showSkeleton ? undefined : 0, p: 0}}>
<MythicLoadingState {...props} />
</TableCell>
</TableRow>
{showSkeleton &&
<MythicTableSkeletonRows colSpan={colSpan} columns={columns} rows={rows} />
}
</>
);
}
@@ -1,123 +0,0 @@
import React from 'react';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import LockOutlinedIcon from '@mui/icons-material/LockOutlined';
import TimelapseIcon from '@mui/icons-material/Timelapse';
import DoneAllIcon from '@mui/icons-material/DoneAll';
import BlockIcon from '@mui/icons-material/Block';
import HideSourceIcon from '@mui/icons-material/HideSource';
import PanoramaFishEyeIcon from '@mui/icons-material/PanoramaFishEye';
import NotificationsActiveOutlinedIcon from '@mui/icons-material/NotificationsActiveOutlined';
import {MythicChip} from './MythicChip';
import {MythicStyledTooltip} from './MythicStyledTooltip';
const statusConfig = {
success: {tone: "success", icon: <CheckCircleOutlineIcon />, label: "Success", tooltip: "Completed successfully"},
error: {tone: "error", icon: <ErrorOutlineIcon />, label: "Error", tooltip: "Errored"},
warning: {tone: "warning", icon: <WarningAmberIcon />, label: "Warning"},
info: {tone: "info", icon: <InfoOutlinedIcon />, label: "Info"},
active: {tone: "success", icon: <RadioButtonCheckedIcon />, label: "Active"},
inactive: {tone: "error", icon: <RadioButtonUncheckedIcon />, label: "Inactive"},
disabled: {tone: "neutral", icon: <RadioButtonUncheckedIcon />, label: "Disabled"},
deleted: {tone: "error", icon: <DeleteOutlineIcon />, label: "Deleted"},
locked: {tone: "warning", icon: <LockOutlinedIcon />, label: "Locked"},
building: {tone: "warning", icon: <TimelapseIcon />, label: "Building"},
completed: {tone: "success", icon: <DoneAllIcon />, label: "Completed"},
blocked: {tone: "error", icon: <BlockIcon />, label: "Blocked"},
skipped: {tone: "secondary", icon: <HideSourceIcon />, label: "Skipped"},
running: {tone: "info", icon: <TimelapseIcon />, label: "Running"},
cancelled: {tone: "warning", icon: <HideSourceIcon />, label: "Cancelled"},
queued: {tone: "secondary", icon: <PanoramaFishEyeIcon />, label: "Queued", tooltip: "Waiting to run"},
awaiting_approval: {tone: "warning", icon: <TimelapseIcon />, label: "Awaiting approval"},
input_needed: {tone: "warning", icon: <InfoOutlinedIcon />, label: "Input needed"},
configured: {tone: "secondary", icon: <PanoramaFishEyeIcon />, label: "Configured"},
waiting: {tone: "secondary", icon: <PanoramaFishEyeIcon />, label: "Waiting"},
runnable: {tone: "success", icon: <RadioButtonCheckedIcon />, label: "Runnable"},
needs_approval: {tone: "warning", icon: <TimelapseIcon />, label: "Needs approval"},
stopped: {tone: "warning", icon: <RadioButtonUncheckedIcon />, label: "Stopped"},
available: {tone: "success", icon: <RadioButtonCheckedIcon />, label: "Available"},
hidden: {tone: "warning", icon: <HideSourceIcon />, label: "Hidden"},
updating: {tone: "warning", icon: <TimelapseIcon />, label: "Updating"},
alerting: {tone: "warning", icon: <NotificationsActiveOutlinedIcon />, label: "Alerting"},
dead: {tone: "warning", icon: <WarningAmberIcon />, label: "Likely dead"},
unknown: {tone: "error", icon: <ErrorOutlineIcon />, label: "Unknown"},
secondary: {tone: "secondary", icon: <InfoOutlinedIcon />, label: "Status"},
};
export function getMythicStatusConfig(status) {
return statusConfig[status] || statusConfig.secondary;
}
export function getMythicStatusFromTaskStatus(status) {
const normalized = `${status || ""}`.toLowerCase();
if(normalized.includes("error") || normalized.includes("failed")){
return "error";
}
if(normalized.includes("warning")){
return "warning";
}
if(normalized.includes("success") || normalized.includes("completed")){
return "success";
}
if(normalized.includes("processing") || normalized.includes("building")){
return "building";
}
return "info";
}
export function MythicStatusIcon({
className = "",
status = "secondary",
tooltip = false,
tooltipStyle = {},
tooltipTitle,
...props
}) {
const config = getMythicStatusConfig(status);
const icon = React.cloneElement(config.icon, {
...props,
className: `mythic-icon-tone text-tone mythic-tone-${config.tone}${className ? ` ${className}` : ""}`,
});
return tooltip ? (
<MythicStyledTooltip
title={tooltipTitle || config.tooltip || config.label}
tooltipStyle={{display: "inline-flex", ...tooltipStyle}}
>
{icon}
</MythicStyledTooltip>
) : icon;
}
export function MythicStatusChip({
label,
status = "secondary",
className = "",
icon,
showIcon = true,
size = "small",
variant = "soft",
sx = {},
...props
}) {
const config = getMythicStatusConfig(status);
const chipIcon = showIcon ? (icon || config.icon) : undefined;
return (
<MythicChip
className={className}
label={label || config.label}
icon={chipIcon}
size={size}
tone={config.tone}
variant={variant}
sx={sx}
{...props}
/>
);
}
@@ -1,4 +1,4 @@
import {MythicActionButton} from "./MythicActionButton";
import { IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import Tab from '@mui/material/Tab';
import React from 'react';
@@ -9,11 +9,9 @@ import MenuList from '@mui/material/MenuList';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import Paper from '@mui/material/Paper';
import Badge from '@mui/material/Badge';
import {alpha, useTheme} from '@mui/material/styles';
import {
Draggable,
} from "@hello-pangea/dnd";
import IconButton from "@mui/material/IconButton";
export function MythicTabPanel(props) {
const { children, value, index, maxHeight, tabInfo, getCallbackData, queryParams, changeSearchParam, showDeleted, ...other } =
@@ -24,7 +22,6 @@ export function MythicTabPanel(props) {
display: value === index ? 'flex' : 'none',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
width: '100%',
maxWidth: '100%',
overflowY: "auto",
@@ -75,7 +72,6 @@ export function MythicTabLabel(props) {
};
const [openContextMenu, setOpenContextMenu] = React.useState(false);
const dropdownAnchorRef = React.useRef(null);
const theme = useTheme();
const handleContextClick = (event) => {
event.preventDefault();
event.stopPropagation();
@@ -98,41 +94,25 @@ export function MythicTabLabel(props) {
{(provided, snapshot) => (
<div ref={provided.innerRef}
{...provided.draggableProps}>
{(() => {
const selected = selectedIndex === index || snapshot.isDragging;
const tabAccent = tabInfo?.color || theme.palette.primary.main;
const selectedTabSurface = theme.palette.background.paper;
const selectedTabAccent = alpha(tabAccent, theme.palette.mode === "dark" ? 0.34 : 0.2);
return (
<Tab
className={selectedIndex === index || snapshot.isDragging ? "selectedCallback" : "" }
label={
<span onContextMenu={handleContextClick} style={{}} ref={dropdownAnchorRef}>
<Badge color="success" variant="dot" invisible={!highlight} >
{label}
</Badge>
<IconButton component='div' size='small' onClick={onClick} {...other}>
<CloseIcon />
</IconButton>
</span>
}
{...a11yProps(index)}
{...other}
{...provided.dragHandleProps}
style={{
padding: "0rem .5rem 0rem 0.5rem",
margin: "0 0.125rem 0 0",
minHeight: "2rem",
borderRadius: `${theme.shape.borderRadius}px`,
borderTop: selected ? `3px solid ${tabAccent}` : `1px solid ${theme.borderColor}`,
borderRight: `1px solid ${theme.borderColor}`,
borderBottom: `1px solid ${theme.borderColor}`,
borderLeft: `1px solid ${theme.borderColor}`,
backgroundColor: selected ? selectedTabSurface : theme.surfaces?.muted,
backgroundImage: selected ?
`linear-gradient(180deg, ${selectedTabAccent} 0%, ${selectedTabSurface} 82%)` :
"none",
color: theme.palette.text.primary,
}}
style={{padding: "0px 5px 0px 5px", borderRadius: "4px", margin: 0,
borderBottom: selectedIndex === index ? `2px solid grey` : '',
backgroundColor: selectedIndex === index ? tabInfo.color : snapshot.isDragging ? tabInfo.color : ""}}
/>
);
})()}
<Popper open={openContextMenu} anchorEl={dropdownAnchorRef.current} role={undefined} transition style={{zIndex: 40}}>
{({ TransitionProps, placement }) => (
<Grow
@@ -141,7 +121,7 @@ export function MythicTabLabel(props) {
transformOrigin: placement === 'bottom' ? 'center top' : 'center bottom',
}}
>
<Paper variant="outlined" className={"dropdownMenuColored bg-surface rounded border"}>
<Paper variant="outlined" className={"dropdownMenuColored"}>
<ClickAwayListener onClickAway={handleClose} mouseEvent={"onMouseDown"}>
<MenuList id="split-button-menu" >
{contextMenuOptions.map((option, index) => (
@@ -152,7 +132,6 @@ export function MythicTabLabel(props) {
{option.name}
</MenuItem>
))}
<MenuItem onClick={onClick}>Close Tab</MenuItem>
</MenuList>
</ClickAwayListener>
</Paper>
@@ -171,9 +150,10 @@ export function MythicSearchTabLabel(props) {
return (
<Tab
label={
<span className="mythic-search-tab-label items-center inline-flex gap-3 whitespace-nowrap">
<span>
{label}
<br />
{iconComponent}
<span>{label}</span>
</span>
}
{...a11yProps(index)}
@@ -1,19 +1,6 @@
import {styled} from '@mui/material/styles';
import TableCell from '@mui/material/TableCell';
const MythicStyledTableCell = styled(TableCell, {})(({theme}) => ({
borderBottom: `1px solid ${theme.table?.borderSoft || theme.borderColor}`,
lineHeight: 1.35,
padding: "6px 10px !important",
verticalAlign: "middle",
"&.MuiTableCell-head": {
borderBottom: `1px solid ${theme.table?.border || theme.borderColor}`,
padding: "6px 10px !important",
},
"&:first-of-type": {
paddingLeft: "12px !important",
},
"&:last-of-type": {
paddingRight: "12px !important",
},
}));
const MythicStyledTableCell = styled(TableCell, {})({
padding: "2px 0 2px 10px !important",
});
export default MythicStyledTableCell;
@@ -1,119 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import Pagination from '@mui/material/Pagination';
import Typography from '@mui/material/Typography';
export const MythicTablePagination = ({
count,
totalCount,
fetchLimit,
onChange,
page,
id,
color = "primary",
label = "Total Results",
summary,
className = "",
containerStyle = {},
paginationStyle = {},
...paginationProps
}) => {
const pageCount = count ?? Math.ceil((totalCount || 0) / (fetchLimit || 1));
const controlledPage = page === undefined ? {} : {page};
return (
<Box className={`mythic-table-footer flex justify-center items-center flex-wrap gap-4 rounded bg-surface-muted border-subtle ${className} w-full`.trim()} id={id} style={containerStyle}>
<Pagination
count={pageCount}
variant="outlined"
color={color}
boundaryCount={1}
siblingCount={1}
onChange={onChange}
showFirstButton={true}
showLastButton={true}
style={paginationStyle}
{...controlledPage}
{...paginationProps}
/>
{summary !== undefined && summary !== null ? (
<Typography className="mythic-table-total text-sm font-650 text-muted whitespace-nowrap">{summary}</Typography>
) : totalCount !== undefined && (
<Typography className="mythic-table-total text-sm font-650 text-muted whitespace-nowrap">{label}: {totalCount}</Typography>
)}
</Box>
);
};
export const useMythicClientPagination = ({
items = [],
resetKey = "",
rowsPerPage = 25,
}) => {
const safeItems = React.useMemo(() => {
return Array.isArray(items) ? items : [];
}, [items]);
const [page, setPage] = React.useState(1);
const totalCount = safeItems.length;
const pageCount = Math.max(1, Math.ceil(totalCount / rowsPerPage));
const safePage = Math.min(page, pageCount);
const pageStart = (safePage - 1) * rowsPerPage;
const pageData = React.useMemo(() => {
return safeItems.slice(pageStart, pageStart + rowsPerPage);
}, [pageStart, rowsPerPage, safeItems]);
const pageEnd = totalCount === 0 ? 0 : Math.min(pageStart + rowsPerPage, totalCount);
const pageStartDisplay = totalCount === 0 ? 0 : pageStart + 1;
React.useEffect(() => {
setPage(1);
}, [resetKey, rowsPerPage, totalCount]);
React.useEffect(() => {
if(page > pageCount){
setPage(pageCount);
}
}, [page, pageCount]);
const onChangePage = React.useCallback((event, nextPage) => {
setPage(nextPage);
}, []);
return {
onChangePage,
page: safePage,
pageCount,
pageData,
pageEnd,
pageStart,
pageStartDisplay,
rangeLabel: `${pageStartDisplay}-${pageEnd} of ${totalCount}`,
rowsPerPage,
showPagination: totalCount > rowsPerPage,
totalCount,
};
};
export const MythicClientSideTablePagination = ({
id,
pagination,
selectedCount,
selectedLabel = "selected",
...paginationProps
}) => {
if(!pagination?.showPagination){
return null;
}
const summary = selectedCount === undefined || selectedCount === null ?
pagination.rangeLabel :
`${pagination.rangeLabel}, ${selectedCount} ${selectedLabel}`;
return (
<MythicTablePagination
count={pagination.pageCount}
id={id}
onChange={pagination.onChangePage}
page={pagination.page}
summary={summary}
{...paginationProps}
/>
);
};
@@ -1,116 +0,0 @@
import React from 'react';
import Box from '@mui/material/Box';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import ToggleButton from '@mui/material/ToggleButton';
import SearchIcon from '@mui/icons-material/Search';
import MythicTextField from './MythicTextField';
import {MythicActionButton} from './MythicActionButton';
export const MythicTableToolbar = ({children, className = "", style = {}, variant}) => {
return (
<Box className={`mythic-table-toolbar flex flex-none flex-wrap gap-4 w-full rounded bg-surface-muted border-subtle${variant ? ` mythic-table-toolbar-${variant}` : ""} ${className}`.trim()} style={style}>
{children}
</Box>
);
};
export const MythicTableToolbarGroup = ({children, grow = false, label, className = "", style = {}}) => {
return (
<Box className={`mythic-table-toolbar-group items-center flex flex-wrap gap-3 ${grow ? "mythic-table-toolbar-group-grow" : ""} ${className} max-w-full`.trim()} style={style}>
{label &&
<span className="mythic-table-toolbar-group-label text-2xs font-800 leading-100 text-muted">{label}</span>
}
{children}
</Box>
);
};
export const MythicToolbarSelect = ({children, className = "", style = {}, ...props}) => {
return (
<Select
className={`mythic-toolbar-select ${className} w-full`.trim()}
size="small"
style={style}
{...props}
>
{children}
</Select>
);
};
export const MythicToolbarMenuItem = MenuItem;
export const MythicSearchField = ({
value,
onChange,
onEnter,
onSearch,
disabled = false,
placeholder = "Search...",
name = "Search",
showLabel = false,
autoFocus,
inputProps = {},
endAdornment = null,
}) => {
return (
<MythicTextField
disabled={disabled}
placeholder={placeholder}
value={value}
marginTop="0px"
marginBottom="0px"
showLabel={showLabel}
onChange={onChange}
onEnter={onEnter || onSearch}
name={name}
autoFocus={autoFocus}
InputProps={{
endAdornment: onSearch ? (
<React.Fragment>
{endAdornment}
<MythicActionButton disabled={disabled} icon={<SearchIcon />} iconOnly onClick={onSearch} tone="primary" tooltip="Search" />
</React.Fragment>
) : endAdornment,
style: {padding: 0},
...inputProps,
}}
/>
);
};
export const MythicToolbarButton = ({children, tone, ...props}) => {
return (
<MythicActionButton tone={tone} {...props}>
{children}
</MythicActionButton>
);
};
export const MythicToolbarToggle = ({
checked,
onClick,
label,
activeLabel,
inactiveLabel,
activeIcon,
inactiveIcon,
value = "toggle",
className = "",
...props
}) => {
return (
<ToggleButton
className={`mythic-toolbar-toggle border-subtle text-muted gap-3 rounded whitespace-nowrap ${className}`.trim()}
value={value}
selected={checked}
onClick={onClick}
size="small"
{...props}
>
{checked ? activeIcon : inactiveIcon}
<span>{checked ? (activeLabel || label) : (inactiveLabel || label)}</span>
</ToggleButton>
);
};
@@ -1,10 +1,16 @@
import {MythicActionButton} from "./MythicActionButton";
import React from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import MythicTextField from './MythicTextField';
import {useQuery, gql, useMutation} from '@apollo/client';
import {Box, Select, MenuItem, Link, Typography} from '@mui/material';
import { Select, Input, MenuItem, Link, IconButton } from '@mui/material';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableRow from '@mui/material/TableRow';
import AceEditor from 'react-ace';
import 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/theme-monokai';
@@ -15,42 +21,22 @@ import { MythicDialog } from './MythicDialog';
import {MythicConfirmDialog} from './MythicConfirmDialog';
import DeleteIcon from '@mui/icons-material/Delete';
import WebhookIcon from '@mui/icons-material/Webhook';
import {MythicChip} from './MythicChip';
import Chip from '@mui/material/Chip';
import LocalOfferOutlinedIcon from '@mui/icons-material/LocalOfferOutlined';
import {mythicFetch} from '../utilities/MythicConnection';
import {MythicStyledTooltip} from "./MythicStyledTooltip";
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import Typography from '@mui/material/Typography';
import MythicStyledTableCell from "./MythicTableCell";
import {meState} from "../../cache";
import { useReactiveVar } from '@apollo/client';
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
import {NewTagtypesDialog} from "../pages/Tags/NewTagtypesDialog";
import {getTagReadableTextColor, TagTypeChip} from "./MythicTagChip";
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicDialogSection,
MythicFormField,
MythicFormGrid,
MythicFormNote
} from "./MythicDialogLayout";
const getCreateTagTargetObject = (target_object) => {
if(target_object === "task_id"){
return "task_display_id";
} else if(target_object === "callback_id"){
return "callback_display_id";
}
return target_object;
}
const createNewTagMutationTemplate = ({target_object}) => {
const createTagTargetObject = getCreateTagTargetObject(target_object);
// target_object should be something like "task_id"
// target_object_id should be something like "89"
return gql`
mutation createNewTag($url: String!, $data: jsonb!, $source: String!, $${createTagTargetObject}: Int!, $tagtype_id: Int!){
createTag(url: $url, data: $data, source: $source, ${createTagTargetObject}: $${createTagTargetObject}, tagtype_id: $tagtype_id){
mutation createNewTag($url: String!, $data: jsonb!, $source: String!, $${target_object}: Int!, $tagtype_id: Int!){
createTag(url: $url, data: $data, source: $source, ${target_object}: $${target_object}, tagtype_id: $tagtype_id){
id
status
error
@@ -135,11 +121,8 @@ export const TagsDisplay = ({tags, expand}) => {
)
}
const TagChipDisplay = ({tag, expand}) => {
const theme = useTheme();
const [openTagDisplay, setOpenTagDisplay] = React.useState(false);
const [label, setLabel] = React.useState(expand ? tag.tagtype.name : tag.tagtype.name[0]);
const color = tag?.tagtype?.color || "";
const textColor = getTagReadableTextColor(theme, color);
const onSelectTag = (event, tag) => {
if(event){
event.preventDefault();
@@ -168,16 +151,10 @@ const TagChipDisplay = ({tag, expand}) => {
}
return (
<React.Fragment>
<MythicChip color={color} onMouseOver={onMouseOver} onMouseOut={onMouseOut} label={label} size="small" onClick={(e) => onSelectTag(e)}
<Chip onMouseOver={onMouseOver} onMouseOut={onMouseOut} label={label} size="small" onClick={(e) => onSelectTag(e)}
style={{float: "right", backgroundColor:tag.tagtype.color, height: "15px"}}
sx={{
backgroundColor: color + " !important",
color: textColor + " !important",
float: "right",
height: "15px",
"& .MuiChip-label": {
color: "inherit",
overflow: "visible"
},
"& .MuiChip-label": {overflow: "visible"}
}}
/>
{openTagDisplay &&
@@ -196,7 +173,7 @@ const StringTagDataEntry = ({name, value}) => {
const colorRegex = ":color=[\"\'](?<color>.*?)[\"\']";
const onClick = (e, url) => {
e.preventDefault();
mythicFetch(url).then((response) => {
fetch(url).then((response) => {
if (response.status !== 200) {
snackActions.warning("HTTP " + response.status + " response");
} else {
@@ -230,16 +207,9 @@ const StringTagDataEntry = ({name, value}) => {
</MythicStyledTooltip>
)
}
if(capturePieces[2].startsWith("http:") || capturePieces[2].startsWith("https:")){
return (
return (
<Link href={capturePieces[2]} color="textPrimary" target={"_blank"} >{capturePieces[1]}</Link>
)
} else {
return <Typography>
{capturePieces[1]} - ( {capturePieces[2]} )
</Typography>
}
)
} else if(value.startsWith("http:") || value.startsWith("https:")){
return (
<>
@@ -250,56 +220,6 @@ const StringTagDataEntry = ({name, value}) => {
}
return value;
}
const RenderedTagDataPreview = ({data}) => {
const trimmedData = typeof data === "string" ? data.trim() : data;
if(trimmedData === ""){
return <Box className="mythic-tag-data-preview-empty text-sm items-center flex justify-center text-muted text-center">No JSON data to render.</Box>;
}
try {
const parsedData = typeof data === "string" ? JSON.parse(data) : data;
if(parsedData === null || typeof parsedData !== "object"){
return (
<Box className="mythic-tag-data-preview-row gap-4 min-w-0 rounded grid bg-neutral-1 border-subtle">
<Box className="mythic-tag-data-preview-key text-xs font-800 min-w-0 truncate text-muted whitespace-nowrap">value</Box>
<Box className="mythic-tag-data-preview-value text-sm leading-135 min-w-0 wrap-anywhere text-primary">{String(parsedData)}</Box>
</Box>
);
}
const entries = Array.isArray(parsedData) ?
parsedData.map((value, index) => [`[${index}]`, value]) :
Object.entries(parsedData);
if(entries.length === 0){
return <Box className="mythic-tag-data-preview-empty text-sm items-center flex justify-center text-muted text-center">JSON object is empty.</Box>;
}
return (
<Box className="mythic-tag-data-preview-list flex flex-column gap-3 min-w-0">
{entries.map(([key, value]) => (
<Box className="mythic-tag-data-preview-row gap-4 min-w-0 rounded grid bg-neutral-1 border-subtle" key={key}>
<Box className="mythic-tag-data-preview-key text-xs font-800 min-w-0 truncate text-muted whitespace-nowrap">{key}</Box>
<Box className="mythic-tag-data-preview-value text-sm leading-135 min-w-0 wrap-anywhere text-primary">
{typeof value === "string" ? (
<StringTagDataEntry name={key} value={value} />
) : typeof value === "boolean" ? (
value ? "True" : "False"
) : typeof value === "object" && value !== null ? (
<pre>{JSON.stringify(value, null, 2)}</pre>
) : (
String(value)
)}
</Box>
</Box>
))}
</Box>
);
} catch (error) {
return <Box className="mythic-tag-data-preview-empty text-sm items-center flex justify-center text-muted text-center">Enter valid JSON to render a preview.</Box>;
}
}
const TagReadonlyValue = ({children}) => (
<Box className="mythic-tag-readonly-value text-sm leading-135 items-center flex min-w-0 wrap-anywhere w-full rounded bg-surface border-subtle text-primary">
{children || "None"}
</Box>
)
function ViewTagDialog(props) {
const theme = useTheme();
const [selectedTag, setSelectedTag] = React.useState({});
@@ -357,82 +277,118 @@ function ViewTagDialog(props) {
}
props.onClose(event);
}
const stopPropagation = (e) => {
const stopClicks = (e) => {
e.stopPropagation();
e.preventDefault();
}
const rawData = selectedTag?.is_json ? JSON.stringify(selectedTag?.data || {}, null, 2) : selectedTag?.data || "";
return (
<React.Fragment>
<MythicDraggableDialogTitle>View Tag</MythicDraggableDialogTitle>
<DialogContent dividers={true} style={{width: "100%"}} onClick={stopPropagation}>
<MythicDialogBody>
<MythicDialogSection
title="Tag Type"
description={selectedTag?.tagtype?.description || "No description provided."}
actions={
objectInfo.object_type !== "" &&
<ViewEditTags target_object={objectInfo.object_type} target_object_id={objectInfo.object_id} me={props.me} />
}
>
{selectedTag?.tagtype &&
<TagTypeChip tagtype={selectedTag.tagtype} />
}
</MythicDialogSection>
<MythicDialogSection title="Reference Details">
<MythicFormGrid minWidth="16rem">
<MythicFormField label="Source">
<TagReadonlyValue>{selectedTag?.source || "None"}</TagReadonlyValue>
</MythicFormField>
<MythicFormField label="External URL">
<TagReadonlyValue>
{selectedTag?.url ? (
<Link href={selectedTag.url.startsWith("http:") || selectedTag.url.startsWith("https:") ? selectedTag.url : "#"}
color="textPrimary" target="_blank" referrerPolicy='no'>
{selectedTag.url}
</Link>
) : (
"No reference link provided"
)}
</TagReadonlyValue>
</MythicFormField>
</MythicFormGrid>
</MythicDialogSection>
<MythicDialogSection title="Tag Data">
{selectedTag?.is_json ? (
<Box className="mythic-tag-data-preview-frame mythic-tag-data-preview-frame-full min-w-0 overflow-auto rounded bg-surface border-subtle text-primary">
<RenderedTagDataPreview data={selectedTag?.data || {}} />
</Box>
) : (
<Box className="mythic-tag-editor-frame min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
<AceEditor
mode="json"
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
fontSize={14}
showGutter={true}
showPrintMargin={false}
minLines={8}
maxLines={18}
highlightActiveLine={false}
readOnly={true}
value={rawData}
width={"100%"}
setOptions={{
showLineNumbers: true,
tabSize: 4,
useWorker: false,
wrapBehavioursEnabled: true,
wrap: true
}}/>
</Box>
)}
</MythicDialogSection>
</MythicDialogBody>
</DialogContent>
<MythicDialogFooter onClick={stopPropagation}>
<MythicDialogButton onClick={onClose}>
<DialogTitle id="form-dialog-title" onClick={stopClicks}>View Tag</DialogTitle>
<TableContainer className="mythicElement" onClick={stopClicks}>
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
<TableBody>
<TableRow hover>
<TableCell style={{width: "20%"}}>Tag Type</TableCell>
<TableCell style={{display: "inline-flex", flexDirection: "row", width: "100%"}}>
<Chip label={selectedTag?.tagtype?.name||""} size="small" style={{float: "right", backgroundColor:selectedTag?.tagtype?.color||""}} />
<ViewEditTags target_object={objectInfo.object_type} target_object_id={objectInfo.object_id} me={props.me} />
</TableCell>
</TableRow>
<TableRow hover>
<TableCell>Description</TableCell>
<TableCell>{selectedTag?.tagtype?.description || ""}</TableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>Source</MythicStyledTableCell>
<MythicStyledTableCell>
{selectedTag?.source ||""}
</MythicStyledTableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>Reference URL</MythicStyledTableCell>
<MythicStyledTableCell>
{selectedTag?.url === "" ? (
"No reference link provided"
) : (
<Link href={selectedTag?.url || "#"} color="textPrimary" target="_blank" referrerPolicy='no'>{selectedTag?.url ? "click here" : "No reference link provided"}</Link>
)}
</MythicStyledTableCell>
</TableRow>
<TableRow>
<TableCell>Data</TableCell>
<TableCell>
{selectedTag?.is_json ? (
<TableContainer className="mythicElement">
<Table size="small" style={{ "maxWidth": "100%", "overflow": "scroll"}}>
<TableBody>
{Object.keys(selectedTag.data).map( key => (
<TableRow key={key} hover>
<MythicStyledTableCell>{key}</MythicStyledTableCell>
{typeof selectedTag.data[key] === "string" ? (
<MythicStyledTableCell style={{whiteSpace: "pre-wrap"}}>
<StringTagDataEntry name={key} value={String(selectedTag.data[key])} />
</MythicStyledTableCell>
) : typeof selectedTag.data[key] === "object" ? (
Array.isArray(selectedTag.data[key]) ? (
<TableCell style={{whiteSpace: "pre-wrap"}}>{JSON.stringify(selectedTag.data[key], null, 2)}</TableCell>
) : (
<MythicStyledTableCell>
<Table size={"small"} >
<TableBody>
{Object.keys(selectedTag.data[key]).map(key2 => (
<TableRow key={key2} >
<MythicStyledTableCell>{key2}</MythicStyledTableCell>
<MythicStyledTableCell>
<StringTagDataEntry name={key2} value={String(selectedTag.data[key][key2])} />
</MythicStyledTableCell>
</TableRow>
))}
</TableBody>
</Table>
</MythicStyledTableCell>
)
) : typeof selectedTag.data[key] === "boolean" ? (
<MythicStyledTableCell>{selectedTag.data[key] ? "True" : "False"}</MythicStyledTableCell>
) :
(
<MythicStyledTableCell>{String(selectedTag.data[key])}</MythicStyledTableCell>
)
}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<AceEditor
mode="json"
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
fontSize={14}
showGutter={true}
maxLines={20}
highlightActiveLine={false}
value={selectedTag?.data || ""}
width={"100%"}
setOptions={{
showLineNumbers: true,
tabSize: 4,
useWorker: false,
wrapBehavioursEnabled: true,
wrap: true
}}/>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
<DialogActions onClick={(e) => e.stopPropagation()}>
<Button onClick={onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
</MythicDialogFooter>
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -530,15 +486,8 @@ const handleTaskTypeChange = (evt) => {
setNewData(String(evt.target.value.data));
}
}
const handleNewTagCreate = ({tagtype_id, source, url, data, id, tagtype}) => {
const newTag = {id, source, url, data, tagtype_id, tagtype};
const newTags = [...existingTags, newTag].sort((a, b) => a.tagtype.name.localeCompare(b.tagtype.name));
setExistingTags(newTags);
setSelectedTag(newTag);
setNewSource(source);
setNewURL(url);
setNewData(data);
setOpenNewDialog(false);
const handleNewTagCreate = ({tagtype_id, source, url, data, id}) => {
props.onClose();
}
const onAcceptDelete = () => {
deleteTag({variables: {tag_id: selectedTag.id}});
@@ -553,122 +502,92 @@ return (
onClose={()=>{setOpenNewDialog(false);}}
innerDialog={<NewTagDialog me={props.me}
target_object={props.target_object}
target_object_id={props.target_object_id}
target_object_display_id={props.target_object_display_id}
target_object_id={props.target_object_id}
onClose={()=>{setOpenNewDialog(false);}}
onSubmit={handleNewTagCreate} />}
/>}
<MythicDialogBody>
<MythicDialogSection
title="Tag Selection"
description={existingTags.length === 0 ? "No tags are attached yet." : "Choose the tag you want to update."}
actions={
<MythicStyledTooltip title={"Add New Tag"}>
<MythicActionButton iconOnly appearance="raised" colorMode="hover" tone="success" size="small" onClick={() => setOpenNewDialog(true)}>
<AddCircleOutlineIcon fontSize="small" />
</MythicActionButton>
</MythicStyledTooltip>
}
>
{existingTags.length === 0 ? (
<MythicFormNote>
Add a tag to capture source, reference, and structured data for this item.
</MythicFormNote>
) : (
<Box sx={{display: "grid", gap: 1, gridTemplateColumns: {xs: "1fr", md: selectedTag.id ? "minmax(0, 1fr) auto" : "1fr"}, alignItems: "end"}}>
<MythicFormField label="Existing Tag">
<Select
fullWidth
size="small"
value={selectedTag}
onChange={handleTaskTypeChange}
renderValue={(tag) => tag?.id ? <TagTypeChip tagtype={tag.tagtype} /> : "Select a tag"}
>
{existingTags.map( (opt) => (
<MenuItem value={opt} key={opt.id}>
<TagTypeChip tagtype={opt.tagtype} />
</MenuItem>
) )}
</Select>
</MythicFormField>
{selectedTag.id &&
<MythicStyledTooltip title={"Delete Tag"}>
<Box sx={{alignItems: "center", alignSelf: "end", display: "flex", height: 38}}>
<MythicActionButton iconOnly appearance="raised" colorMode="hover" tone="error" size="small" onClick={()=>{setOpenDeleteDialog(true);}}>
<DeleteIcon fontSize="small" />
</MythicActionButton>
</Box>
</MythicStyledTooltip>
}
{selectedTag.id && selectedTag?.tagtype?.description &&
<Box sx={{gridColumn: "1 / -1"}}>
<MythicFormNote>{selectedTag.tagtype.description}</MythicFormNote>
</Box>
}
</Box>
)}
{openDelete &&
<MythicConfirmDialog onClose={() => {setOpenDeleteDialog(false);}} onSubmit={onAcceptDelete} open={openDelete}/>
}
</MythicDialogSection>
{selectedTag.id &&
<>
<MythicDialogSection title="Reference Details">
<MythicFormGrid minWidth="16rem">
<MythicFormField label="Source">
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" showLabel={false} marginTop="0" marginBottom="0" />
</MythicFormField>
<MythicFormField label="External URL">
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" showLabel={false} marginTop="0" marginBottom="0" />
{newURL &&
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no' sx={{display: "inline-flex", mt: 0.5, fontSize: "0.78rem"}}>
Open reference
</Link>
<Table size="small" style={{ width: "100%", overflow: "scroll"}}>
<TableBody>
<TableRow hover>
<MythicStyledTableCell style={{width: "30%"}}>Select Existing Tag to Edit or Add New</MythicStyledTableCell>
<MythicStyledTableCell style={{display: "inline-flex", flexDirection: "row-reverse"}}>
<MythicStyledTooltip title={"Add New Tag"}>
<IconButton variant='contained' color="success" style={{float: "right"}} onClick={() => setOpenNewDialog(true)} >
<AddCircleOutlineIcon />
</IconButton>
</MythicStyledTooltip>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={selectedTag}
onChange={handleTaskTypeChange}
input={<Input />}
>
{existingTags.map( (opt) => (
<MenuItem value={opt} key={opt.id}>
<Chip label={opt.tagtype.name} size="small" style={{float: "right", backgroundColor:opt.tagtype.color}} />
</MenuItem>
) )}
</Select>
{selectedTag.id &&
<IconButton size="small" style={{float: "right"}} onClick={()=>{setOpenDeleteDialog(true);}} color="error" variant="contained"><DeleteIcon/></IconButton>
}
</MythicFormField>
</MythicFormGrid>
</MythicDialogSection>
<MythicDialogSection title="JSON Data">
<Box className="mythic-tag-data-split gap-6 min-w-0 w-full grid">
<Box className="mythic-tag-editor-frame min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
<AceEditor
mode="json"
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
onChange={onEditorChange}
fontSize={14}
showGutter={true}
showPrintMargin={false}
minLines={10}
maxLines={18}
highlightActiveLine={true}
value={newData}
width={"100%"}
setOptions={{
showLineNumbers: true,
tabSize: 4,
useWorker: false,
wrap: true
}}/>
</Box>
<Box className="mythic-tag-data-preview-frame min-w-0 overflow-auto rounded bg-surface border-subtle text-primary">
<RenderedTagDataPreview data={newData} />
</Box>
</Box>
</MythicDialogSection>
</>
}
</MythicDialogBody>
{openDelete &&
<MythicConfirmDialog onClose={() => {setOpenDeleteDialog(false);}} onSubmit={onAcceptDelete} open={openDelete}/>
}
</MythicStyledTableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>Tag Description</MythicStyledTableCell>
<MythicStyledTableCell>{selectedTag?.tagtype?.description || ""}</MythicStyledTableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>Source</MythicStyledTableCell>
<MythicStyledTableCell>
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" />
</MythicStyledTableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>External URL</MythicStyledTableCell>
<MythicStyledTableCell>
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" />
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no'>{newURL ? "click here" : ""}</Link>
</MythicStyledTableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>JSON Data</MythicStyledTableCell>
<MythicStyledTableCell>
<AceEditor
mode="json"
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
onChange={onEditorChange}
fontSize={14}
showGutter={true}
maxLines={20}
highlightActiveLine={true}
value={newData}
width={"100%"}
setOptions={{
showLineNumbers: true,
tabSize: 4,
useWorker: false
}}/>
</MythicStyledTableCell>
</TableRow>
</TableBody>
</Table>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
</Button>
{selectedTag.id &&
<MythicDialogButton intent="primary" onClick={onSubmit}>
<Button onClick={onSubmit} variant="contained" color="success">
Submit
</MythicDialogButton>
</Button>
}
</MythicDialogFooter>
</DialogActions>
</React.Fragment>
);
}
@@ -679,8 +598,7 @@ export function NewTagDialog(props) {
const [newData, setNewData] = React.useState("");
const [selectedTagType, setSelectedTagType] = React.useState("");
const [existingTagTypes, setExistingTagTypes] = React.useState([]);
const [openNewTagTypeDialog, setOpenNewTagTypeDialog] = React.useState(false);
useQuery(getTagtypesQuery, {
const { loading, error } = useQuery(getTagtypesQuery, {
onCompleted: data => {
setExistingTagTypes(data.tagtype);
if(data.tagtype.length > 0){
@@ -693,7 +611,7 @@ export function NewTagDialog(props) {
onCompleted: data => {
if(data.createTag.status === "success"){
snackActions.success("Successfully created new tag!");
props.onSubmit({source:newSource, url:newURL, data:newData, tagtype_id:selectedTagType.id, id: data.createTag.id, tagtype: selectedTagType});
props.onSubmit({source:newSource, url:newURL, data:newData, tagtype_id:selectedTagType.id, id: data.createTag.id});
props.onClose()
} else {
snackActions.error(data.createTag.error);
@@ -707,16 +625,11 @@ export function NewTagDialog(props) {
const handleTaskTypeChange = (evt) => {
setSelectedTagType(evt.target.value);
}
const onNewTagType = ({name, description, id, color}) => {
const newTagType = {name, description, id, color};
setExistingTagTypes((prev) => [...prev, newTagType].sort((a, b) => a.name.localeCompare(b.name)));
setSelectedTagType(newTagType);
}
const onSubmit = () => {
newTag({variables:
{source:newSource, url:newURL, data:newData,
tagtype_id:selectedTagType.id,
[getCreateTagTargetObject(props.target_object)]: props.target_object_display_id || props.target_object_id
[props.target_object]: props.target_object_id
}})
}
const onChangeSource = (name, value, error) => {
@@ -731,107 +644,91 @@ export function NewTagDialog(props) {
return (
<React.Fragment>
{openNewTagTypeDialog &&
<MythicDialog fullWidth={true} maxWidth="md" open={openNewTagTypeDialog}
onClose={()=>{setOpenNewTagTypeDialog(false);}}
innerDialog={
<NewTagtypesDialog
onClose={()=>{setOpenNewTagTypeDialog(false);}}
onSubmit={onNewTagType}
/>
}
/>
}
<MythicDraggableDialogTitle >Add New Tag</MythicDraggableDialogTitle>
<DialogContent dividers={true} style={{width: "100%"}}>
<MythicDialogBody>
<MythicDialogSection
title="Tag Type"
description="Select the taxonomy entry this tag should use."
actions={
<Button className="mythic-compact-action bg-surface-raised border text-primary text-xs font-750 mythic-action-tone-hover mythic-tone-info rounded" size="small" variant="outlined" onClick={() => setOpenNewTagTypeDialog(true)}>
Manage Tag Types
</Button>
}
>
<MythicFormField label="Tag">
<Select
fullWidth
size="small"
value={selectedTagType}
onChange={handleTaskTypeChange}
displayEmpty
renderValue={(tagtype) => tagtype?.id ? <TagTypeChip tagtype={tagtype} /> : "No tag types available"}
>
{existingTagTypes.map( (opt) => (
<MenuItem value={opt} key={opt.name}>
<TagTypeChip tagtype={opt} />
</MenuItem>
) )}
</Select>
</MythicFormField>
{selectedTagType?.description &&
<MythicFormNote>{selectedTagType.description}</MythicFormNote>
}
</MythicDialogSection>
<MythicDialogSection title="Reference Details">
<MythicFormGrid minWidth="16rem">
<MythicFormField label="Source">
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" showLabel={false} marginTop="0" marginBottom="0" />
</MythicFormField>
<MythicFormField label="External URL">
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" showLabel={false} marginTop="0" marginBottom="0" />
{newURL &&
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no' sx={{display: "inline-flex", mt: 0.5, fontSize: "0.78rem"}}>
Open reference
</Link>
}
</MythicFormField>
</MythicFormGrid>
</MythicDialogSection>
<MythicDialogSection title="JSON Data">
<Box className="mythic-tag-data-split gap-6 min-w-0 w-full grid">
<Box className="mythic-tag-editor-frame min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
<AceEditor
<Table size="small" style={{ overflow: "scroll", width: "100%"}}>
<TableBody>
<TableRow hover>
<MythicStyledTableCell style={{width: "20%"}}>
<Typography>
Tag
</Typography>
<Typography size="small" component="span" style={{fontSize: theme.typography.pxToRem(15)}}>
To create a new tag type click <Link style={{wordBreak: "break-all"}}
color="textPrimary"
href={"/new/tagtypes"}
underline="always" target="_blank">
here
</Link>
</Typography>
</MythicStyledTableCell>
<MythicStyledTableCell>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={selectedTagType}
onChange={handleTaskTypeChange}
input={<Input style={{width: "100%"}}/>}
>
{existingTagTypes.map( (opt) => (
<MenuItem value={opt} key={opt.name}>
<Chip label={opt.name} size="small" style={{ backgroundColor:opt.color}} />
</MenuItem>
) )}
</Select>
</MythicStyledTableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>Source</MythicStyledTableCell>
<MythicStyledTableCell>
<MythicTextField value={newSource} onChange={onChangeSource} name="Source of tag data" />
</MythicStyledTableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>External URL</MythicStyledTableCell>
<MythicStyledTableCell>
<MythicTextField value={newURL} onChange={onChangeURL} name="External URL reference" />
<Link href={newURL} color="textPrimary" target="_blank" referrerPolicy='no'>{newURL}</Link>
</MythicStyledTableCell>
</TableRow>
<TableRow hover>
<MythicStyledTableCell>JSON Data</MythicStyledTableCell>
<MythicStyledTableCell>
<AceEditor
mode="json"
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
onChange={onEditorChange}
fontSize={14}
showGutter={true}
showPrintMargin={false}
minLines={10}
maxLines={18}
maxLines={20}
highlightActiveLine={true}
value={newData}
width={"100%"}
setOptions={{
showLineNumbers: true,
tabSize: 4,
useWorker: false,
wrap: true
useWorker: false
}}/>
</Box>
<Box className="mythic-tag-data-preview-frame min-w-0 overflow-auto rounded bg-surface border-subtle text-primary">
<RenderedTagDataPreview data={newData} />
</Box>
</Box>
</MythicDialogSection>
</MythicDialogBody>
</MythicStyledTableCell>
</TableRow>
</TableBody>
</Table>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
</Button>
{selectedTagType !== "" &&
<MythicDialogButton intent="primary" onClick={onSubmit}>
<Button onClick={onSubmit} variant="contained" color="success">
Submit
</MythicDialogButton>
</Button>
}
</MythicDialogFooter>
</DialogActions>
</React.Fragment>
);
}
export const ViewEditTags = ({target_object, target_object_id, target_object_display_id}) => {
export const ViewEditTags = ({target_object, target_object_id}) => {
const me = useReactiveVar(meState);
const [openTagDialog, setOpenTagDialog] = React.useState(false);
const toggleTagDialog = (event, open) => {
@@ -843,16 +740,16 @@ export const ViewEditTags = ({target_object, target_object_id, target_object_dis
}
return(
<React.Fragment>
<MythicActionButton iconOnly onClick={(e) => toggleTagDialog(e, true)} size="small"
<IconButton onClick={(e) => toggleTagDialog(e, true)} size="small"
style={{display: "inline-block", float: "right", padding: "0px"}}>
<LocalOfferOutlinedIcon />
</MythicActionButton>
</IconButton>
{openTagDialog &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openTagDialog}
onClose={(e)=>{toggleTagDialog(e, false)}}
innerDialog={<ViewEditTagsDialog me={me} target_object={target_object} target_object_id={target_object_id} target_object_display_id={target_object_display_id} onClose={(e)=>{toggleTagDialog(e, false)}} />}
innerDialog={<ViewEditTagsDialog me={me} target_object={target_object} target_object_id={target_object_id} onClose={(e)=>{toggleTagDialog(e, false)}} />}
/>}
</React.Fragment>
)
}
}
@@ -1,28 +0,0 @@
import React from 'react';
import {useTheme} from '@mui/material/styles';
import {getReadableTextColor, isValidHexColor} from './MythicColorInput';
import {MythicChip} from './MythicChip';
export const getTagReadableTextColor = (theme, color) => {
return isValidHexColor(color) ? getReadableTextColor(color) : theme.palette.text.primary;
}
export const TagTypeChip = ({tagtype, label, sx={}}) => {
const theme = useTheme();
const color = tagtype?.color || "";
const textColor = getTagReadableTextColor(theme, color);
return (
<MythicChip
color={color}
label={label || tagtype?.name || "Tag"}
size="small"
sx={{
backgroundColor: color || "transparent",
border: "1px solid",
borderColor: theme.borderColor,
color: textColor,
...sx,
}}
/>
);
}
@@ -1,5 +1,5 @@
import React, {useState} from 'react';
import {MythicActionButton} from './MythicActionButton';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
@@ -35,10 +35,11 @@ export function MythicTextEditDialog(props) {
}}/>
</DialogContent>
<DialogActions>
<MythicActionButton colorMode="always" variant="contained" onClick={props.onClose} tone="primary">
<Button variant="contained" onClick={props.onClose} color="primary">
Close
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -17,31 +17,20 @@ const Root = styled('div')(({theme}) => ({
const ValidationTextField = styled(TextField)(({theme}) => ({
[`&.${classes.textFieldRoot}`]: {
'& .MuiInputBase-root': {
backgroundColor: theme.palette.background.paper,
borderRadius: theme.shape.borderRadius,
},
'& fieldset': {
borderColor: theme.borderColor,
borderColor: 'grey',
borderWidth: 1,
},
'&:hover fieldset': {
borderColor: theme.palette.text.secondary,
},
'& .Mui-focused fieldset': {
borderColor: `${theme.palette.primary.main} !important`,
borderWidth: '1px !important',
},
'& input:invalid + fieldset': {
borderColor: theme.palette.error.main,
borderColor: 'red',
borderWidth: 2,
},
'& input:valid:focus + fieldset': {
borderLeftWidth: 2,
borderLeftWidth: 6,
padding: '4px !important', // override inline-style
},
'& textarea:focus + textarea + fieldset': {
borderLeftWidth: 2,
borderLeftWidth: 6,
//padding: '4px !important', // override inline-style
},
},
@@ -66,7 +55,6 @@ const MythicTextField = ({
disabled = false,
marginTop = "5px",
InputProps = {},
inputProps = {},
inputLabelProps = {},
multiline = false,
maxRows = 10,
@@ -105,9 +93,6 @@ const MythicTextField = ({
handleChange(event);
}
}
const resolvedAutoComplete = autoComplete === undefined ? "new-password" : (
typeof autoComplete === "string" ? autoComplete : (autoComplete ? "on" : "off")
);
return (
<Root style={{width: width ? width + "rem" : "100%", display: inline ? "inline-block": "",}}>
@@ -122,7 +107,7 @@ const MythicTextField = ({
autoFocus={autoFocus}
variant={variant}
data-lpignore={true}
autoComplete={resolvedAutoComplete}
autoComplete={autoComplete === undefined ? "new-password" : (autoComplete ? "on" : "off")}
disabled={disabled}
required={requiredValue}
InputLabelProps={inputLabelProps}
@@ -132,7 +117,6 @@ const MythicTextField = ({
type={type}
onWheel={ event => event.target.blur() }
InputProps={{...InputProps, spellCheck: false}}
inputProps={{...inputProps, autoComplete: resolvedAutoComplete}}
helperText={localError ? errorText : helperText}
style={{
padding:0,
@@ -1,62 +0,0 @@
import * as React from "react";
import { useState, useEffect } from "react";
import { MythicStyledTooltip } from './MythicStyledTooltip';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc';
dayjs.extend(relativeTime);
dayjs.extend(utc);
const formatDiff = (ms) => {
if (ms < 0) ms = 0;
const s = Math.trunc(ms / 1000) % 60;
const m = Math.trunc(ms / 60000) % 60;
const h = Math.trunc(ms / 3600000) % 24;
const d = Math.trunc(ms / 86400000);
return (d ? d + "d " : "") + (h ? h + "h " : "") + (m ? m + "m " : "") + s + "s";
};
export const MythicTimestamp = ({
children,
fromNow = false,
duration = false,
endTime,
interval = 0,
withTitle = false,
titleFormat = "YYYY-MM-DD HH:mm:ss",
serverSkew = 0,
}) => {
const [now, setNow] = useState(Date.now);
useEffect(() => {
if (!interval) return;
const id = setInterval(() => setNow(Date.now()), interval);
return () => clearInterval(id);
}, [interval]);
// Mythic's server timestamps without an explicit offset represent UTC.
const t = dayjs.utc(children);
const compute = () => {
if (!t.isValid()) return "";
const serverNow = dayjs(now).add(serverSkew, 'ms');
if (duration) {
const end = endTime != null ? dayjs.utc(endTime) : serverNow;
if (!end.isValid()) return "";
return formatDiff(end.diff(t));
}
if (fromNow) {
const seconds = Math.round(Math.abs(serverNow.diff(t)) / 1000);
if (seconds < 60) return `${seconds} ${seconds === 1 ? 'second' : 'seconds'}`;
return t.from(serverNow, true);
}
return t.local().format(titleFormat);
};
const display = compute();
const title = withTitle && t.isValid() ? t.local().format(titleFormat) : "";
if (withTitle) return (
<MythicStyledTooltip title={title}>
<span>{display}</span>
</MythicStyledTooltip>
);
return <span>{display}</span>;
};
@@ -1,89 +0,0 @@
import React, {act} from 'react';
import {createRoot} from 'react-dom/client';
import {MythicTimestamp} from './MythicTimestamp';
jest.mock('./MythicStyledTooltip', () => ({
MythicStyledTooltip: ({children, title}) => <div title={title}>{children}</div>,
}));
describe('MythicTimestamp', () => {
let container;
let root;
const start = '2026-01-15T12:00:00.000000';
const render = (props) => {
act(() => root.render(<MythicTimestamp {...props} />));
return container.textContent;
};
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-01-15T12:00:10Z'));
global.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
expect(jest.getTimerCount()).toBe(0);
jest.useRealTimers();
delete global.IS_REACT_ACT_ENVIRONMENT;
});
it('uses UTC for timezone-less end timestamps and respects explicit offsets', () => {
const props = {children: start + 'Z', duration: true};
expect(render({...props, endTime: '2026-01-15T12:01:00.000000'})).toBe('1m 0s');
expect(render({...props, endTime: '2026-01-15T04:01:00-08:00'})).toBe('1m 0s');
});
it('recomputes when only the end time changes', () => {
const props = {children: start + 'Z', duration: true};
render({...props, endTime: '2026-01-15T12:01:00'});
expect(render({...props, endTime: '2026-01-15T12:02:00'})).toBe('2m 0s');
});
it('uses updated server skew immediately and on subsequent ticks', () => {
const props = {children: start + 'Z', fromNow: true, interval: 1000};
expect(render(props)).toBe('10 seconds');
expect(render({...props, serverSkew: 3600000})).toBe('an hour');
act(() => jest.advanceTimersByTime(1000));
expect(container.textContent).toBe('an hour');
});
it('keeps numeric seconds through 59 seconds and updates every tick', () => {
const props = {children: start + 'Z', fromNow: true, interval: 1000};
expect(render(props)).toBe('10 seconds');
act(() => jest.advanceTimersByTime(49000));
expect(container.textContent).toBe('59 seconds');
act(() => jest.advanceTimersByTime(1000));
expect(container.textContent).toBe('a minute');
});
it('shows zero seconds for fresh check-ins, including after the age resets', () => {
const props = {fromNow: true, interval: 1000};
expect(render({...props, children: new Date(Date.now() - 200).toISOString()})).toBe('0 seconds');
act(() => jest.advanceTimersByTime(1000));
expect(container.textContent).toBe('1 second');
act(() => jest.advanceTimersByTime(1000));
expect(container.textContent).toBe('2 seconds');
expect(render({...props, children: new Date(Date.now()).toISOString()})).toBe('0 seconds');
act(() => jest.advanceTimersByTime(1000));
expect(container.textContent).toBe('1 second');
});
it('recomputes when display mode or format changes', () => {
const props = {children: start + 'Z'};
expect(render({...props, fromNow: true})).toBe('10 seconds');
expect(render({...props, titleFormat: 'YYYY'})).toBe('2026');
expect(render({...props, titleFormat: 'MM'})).toBe('01');
});
it('renders invalid timestamps as empty text and clears timers on unmount', () => {
expect(render({children: 'invalid', fromNow: true, interval: 1000})).toBe('');
expect(jest.getTimerCount()).toBe(1);
render({children: start, duration: true, endTime: 'invalid'});
expect(container.textContent).toBe('');
expect(jest.getTimerCount()).toBe(0);
});
});
@@ -1,6 +1,6 @@
import React, {useEffect} from 'react';
import { styled } from '@mui/material/styles';
import {MythicActionButton} from './MythicActionButton';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
@@ -9,11 +9,14 @@ import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Checkbox from '@mui/material/Checkbox';
import { CardContent } from '@mui/material';
import CardHeader from '@mui/material/CardHeader';
import Divider from '@mui/material/Divider';
const PREFIX = 'MythicTransferList';
export const classes = {
button: `${PREFIX}-button`,
divider: `${PREFIX}-divider`
};
@@ -23,10 +26,21 @@ export const StyledDivider = styled(Divider)((
}
) => ({
[`&.${classes.divider}`]: {
backgroundColor: theme.palette.primary.main,
border: `2px solid ${theme.palette.primary.main}`
backgroundColor: "rgb(100, 170, 204)",
border: "2px solid rgba(100, 170, 204)"
}
}));
export const StyledButton = styled(Button)((
{
theme
}
) => ({
[`&.${classes.button}`]: {
margin: theme.spacing(0.5, 0),
},
}));
const CustomListElement = ({value, onClick, itemKey}) => {
const displayValue = itemKey ? value[itemKey] : value.value;
const labelId = `transfer-list-item-${displayValue}-label`;
@@ -47,9 +61,10 @@ const CustomListElement = ({value, onClick, itemKey}) => {
const CustomList = ({title, items, left, onClick, itemKey}) => {
return (
<div className="mythic-transfer-list flex flex-column min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
<div className="mythic-transfer-list-header bg-table-header text-xs font-700 leading-125 border-b text-primary">{title}</div>
<div className="mythic-transfer-list-body flex-fill overflow-auto">
<>
<CardHeader title={title} />
<StyledDivider classes={{root: classes.divider}}/>
<CardContent style={{flexGrow: 1, height: "100%", width: "100%", overflowY: "auto", padding: 0}}>
<List dense component="div" role="list" style={{padding:0, width: "100%"}}>
{items.map((value, index) => (
<div key={index}>
@@ -65,8 +80,8 @@ const CustomList = ({title, items, left, onClick, itemKey}) => {
))}
</List>
</div>
</div>
</CardContent>
</>
);
}
const CustomTransferList = ({leftTitle, rightTitle, initialData, parentLeftData, parentRightData, itemKey}) => {
@@ -133,49 +148,45 @@ const CustomTransferList = ({leftTitle, rightTitle, initialData, parentLeftData,
<div style={{paddingLeft: 0, flexGrow: 1, marginLeft: 0, marginRight: "10px", position: "relative", overflowY: "auto", display: "flex", flexDirection: "column" }}>
<CustomList title={leftTitle} left={true} items={data} onClick={handleToggle} itemKey={itemKey} />
</div>
<div className="mythic-transfer-controls py-0 px-4 items-center flex flex-column gap-6 justify-center">
<MythicActionButton
colorMode="always"
tone="primary"
<div style={{display: "flex", flexDirection: "column", justifyContent: "center"}}>
<StyledButton
variant="contained"
size="small"
className={classes.button}
onClick={handleAllRight}
aria-label="move all right"
>
&gt;&gt;
</MythicActionButton>
<MythicActionButton
colorMode="always"
tone="primary"
</StyledButton>
<StyledButton
variant="contained"
size="small"
disabled={data.filter( x => x.checked && x.left).length === 0}
className={classes.button}
onClick={handleCheckedRight}
aria-label="move selected right"
>
&gt;
</MythicActionButton>
<MythicActionButton
colorMode="always"
tone="primary"
</StyledButton>
<StyledButton
variant="contained"
size="small"
disabled={data.filter( x => x.checked && x.right).length === 0}
className={classes.button}
onClick={handleCheckedLeft}
aria-label="move selected left"
>
&lt;
</MythicActionButton>
<MythicActionButton
colorMode="always"
tone="primary"
</StyledButton>
<StyledButton
variant="contained"
size="small"
className={classes.button}
onClick={handleAllLeft}
aria-label="move all left"
>
&lt;&lt;
</MythicActionButton>
</StyledButton>
</div>
<div style={{marginLeft: "10px", position: "relative", flexGrow: 1, display: "flex", overflowY: "auto", flexDirection: "column" }}>
@@ -232,13 +243,14 @@ export function MythicTransferListDialog(props) {
/>
</DialogContent>
<DialogActions>
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicActionButton>
<MythicActionButton colorMode="always" onClick={setFinalTags} tone="success" variant="contained">
</Button>
<Button onClick={setFinalTags} variant="contained" color="success">
Submit
</MythicActionButton>
</Button>
</DialogActions>
</>
);
}
@@ -5,7 +5,7 @@ import DialogTitle from '@mui/material/DialogTitle';
import {milisecondsToString, useInterval } from './utilities/Time';
import {JWTTimeLeft, GetNewToken} from '../index';
import {FailedRefresh} from '../cache';
import {MythicActionButton} from './MythicComponents/MythicActionButton';
import { Button } from '@mui/material';
import { snackActions } from './utilities/Snackbar';
@@ -43,13 +43,14 @@ export function RefreshTokenDialog(props) {
Your Session will expire in {timeLeft}
</DialogContent>
<DialogActions>
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
<Button onClick={onClose} variant="contained" color="primary">
Logout
</MythicActionButton>
<MythicActionButton colorMode="always" onClick={onCommitSubmit} tone="success" variant="contained">
</Button>
<Button onClick={onCommitSubmit} variant="contained" color="success">
Extend
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
)
}
@@ -4,51 +4,27 @@ import Badge from '@mui/material/Badge';
import NotificationsActiveTwoToneIcon from '@mui/icons-material/NotificationsActiveTwoTone';
import { useTheme } from '@mui/material/styles';
import {alertCount} from "../cache";
import {MeContext} from "./App";
import {requestCurrentOperationSync} from "./utilities/MythicConnection";
const SUB_Event_Logs = gql`
subscription OperationAlertCounts($operation_id: Int!, $operator_id: Int!){
operation_by_pk(id: $operation_id) {
subscription OperationAlertCounts{
operation_stream(cursor: {initial_value: {updated_at: "1970-01-01"}, ordering: ASC}, batch_size: 1) {
id
alert_count
operators(where: {id: {_eq: $operator_id}}) {
id
}
}
}
`;
export function TopAppBarVerticalEventLogNotifications() {
export function TopAppBarVerticalEventLogNotifications(props) {
const theme = useTheme();
const me = React.useContext(MeContext);
const [alerts, setAlerts] = React.useState(alertCount());
const syncRequestedForOperation = React.useRef(null);
React.useEffect(() => {
syncRequestedForOperation.current = null;
}, [me?.user?.current_operation_id]);
const {error} = useSubscription(SUB_Event_Logs, {
variables: {
operation_id: me?.user?.current_operation_id || 0,
operator_id: me?.user?.user_id || 0,
},
skip: !me?.user?.current_operation_id || !me?.user?.user_id,
const { loading, error } = useSubscription(SUB_Event_Logs, {
onError: data => {
console.log("vertical event log error")
console.error(data);
},
onData: ({data}) => {
const operation = data.data?.operation_by_pk;
if(!operation){
return;
}
if(operation.operators.length === 0 &&
syncRequestedForOperation.current !== operation.id){
syncRequestedForOperation.current = operation.id;
requestCurrentOperationSync();
return;
}
const newAlertCount = operation.alert_count;
const newAlertCount = data.data.operation_stream[0].alert_count;
if(newAlertCount !== alerts){
setAlerts(newAlertCount);
alertCount(newAlertCount);
@@ -70,3 +46,4 @@ export function TopAppBarVerticalEventLogNotifications() {
)
);
}
File diff suppressed because it is too large. Load diff
@@ -3,8 +3,8 @@ import { gql, useMutation, useSubscription } from '@apollo/client';
import {BrowserScriptsTable} from './BrowserScriptsTable';
import {snackActions} from '../../utilities/Snackbar';
import { Backdrop } from '@mui/material';
import {CircularProgress} from '@mui/material';
import {MythicPageBody} from "../../MythicComponents/MythicPageBody";
import {MythicLoadingState} from "../../MythicComponents/MythicStateDisplay";
const SUB_BrowserScripts = gql`
@@ -137,8 +137,8 @@ export function BrowserScripts({me}){
}, [])
return (
<MythicPageBody>
<Backdrop open={backdropOpen} style={{zIndex: 1300, position: "absolute"}} invisible={false}>
<MythicLoadingState compact title="Loading browser scripts" description="Fetching scripts for this operator." sx={{color: "inherit"}} />
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={false}>
<CircularProgress color="inherit" />
</Backdrop>
<BrowserScriptsTable
browserscripts={browserScripts} operation_id={me?.user?.current_operation_id || 0} onToggleActive={onToggleActive}
@@ -1,130 +1,53 @@
import React from 'react';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import Typography from '@mui/material/Typography';
import { BrowserScriptsTableRow } from './BrowserScriptsTableRow';
import {useTheme} from '@mui/material/styles';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import Button from '@mui/material/Button';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {EditScriptDialog} from './EditScriptDialog';
import {MythicPageHeader, MythicPageHeaderChip} from "../../MythicComponents/MythicPageHeader";
import {
MythicSearchField,
MythicTableToolbar,
MythicTableToolbarGroup,
MythicToolbarButton,
MythicToolbarMenuItem,
MythicToolbarSelect
} from "../../MythicComponents/MythicTableToolbar";
import {MythicTableEmptyState} from "../../MythicComponents/MythicStateDisplay";
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
import {MythicPageHeader} from "../../MythicComponents/MythicPageHeader";
export function BrowserScriptsTable(props){
const theme = useTheme();
const [openNewScriptDialog, setOpenNewScriptDialog] = React.useState(false);
const [search, setSearch] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const normalizedSearch = search.trim().toLowerCase();
const visibleScripts = React.useMemo(() => {
return props.browserscripts.filter((script) => {
const matchesStatus = statusFilter === "all" ||
(statusFilter === "active" && script.active) ||
(statusFilter === "disabled" && !script.active) ||
(statusFilter === "modified" && script.user_modified) ||
(statusFilter === "default" && !script.user_modified);
if(!matchesStatus){
return false;
}
if(normalizedSearch === ""){
return true;
}
return [
script.payloadtype?.name,
script.command?.cmd,
script.author,
].some((value) => (value || "").toLowerCase().includes(normalizedSearch));
});
}, [normalizedSearch, props.browserscripts, statusFilter]);
const scriptCountLabel = props.browserscripts.length === 1 ? "1 script" : `${props.browserscripts.length} scripts`;
const visibleCountLabel = visibleScripts.length === 1 ? "1 shown" : `${visibleScripts.length} shown`;
const activeCount = props.browserscripts.filter((script) => script.active).length;
const activeCountLabel = activeCount === 1 ? "1 active" : `${activeCount} active`;
const modifiedCount = props.browserscripts.filter((script) => script.user_modified).length;
const hasFilter = normalizedSearch !== "" || statusFilter !== "all";
const onChangeSearch = (name, value) => {
setSearch(value);
}
return (
<>
<MythicPageHeader
title={"Browser Scripts"}
subtitle={"Manage custom browser script renderers for task output in the current UI."}
meta={
<>
<MythicPageHeaderChip label={scriptCountLabel} />
{hasFilter && <MythicPageHeaderChip label={visibleCountLabel} />}
<MythicPageHeaderChip label={activeCountLabel} />
{modifiedCount > 0 && <MythicPageHeaderChip label={`${modifiedCount} modified`} />}
</>
<MythicPageHeader title={"Browser Scripts"}>
<Button size="small" onClick={() => setOpenNewScriptDialog(true)} style={{whiteSpace: "nowrap", color: "white"}}
startIcon={<AddCircleIcon color={"success"} style={{backgroundColor: "white", borderRadius: "10px"}}/>} >
New Script
</Button>
{openNewScriptDialog &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openNewScriptDialog}
onClose={()=>{setOpenNewScriptDialog(false);}}
innerDialog={
<EditScriptDialog me={props.me} onClose={()=>{setOpenNewScriptDialog(false);}} title="Create New Browser Script" new={true} onSubmitEdit={props.onSubmitNew} />
} />
}
actions={
<MythicToolbarButton variant="contained" colorMode="hover" tone="success" onClick={() => setOpenNewScriptDialog(true)} startIcon={<AddCircleIcon />}>
Script
</MythicToolbarButton>
}
/>
{openNewScriptDialog &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openNewScriptDialog}
onClose={()=>{setOpenNewScriptDialog(false);}}
innerDialog={
<EditScriptDialog me={props.me} onClose={()=>{setOpenNewScriptDialog(false);}} title="Create New Browser Script" new={true} onSubmitEdit={props.onSubmitNew} />
} />
}
<MythicTableToolbar variant="search">
<MythicTableToolbarGroup grow>
<MythicSearchField
name="Search browser scripts"
placeholder="Search payload, command, or author..."
value={search}
onChange={onChangeSearch}
/>
</MythicTableToolbarGroup>
<MythicTableToolbarGroup>
<MythicToolbarSelect
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value)}
>
<MythicToolbarMenuItem value="all">All scripts</MythicToolbarMenuItem>
<MythicToolbarMenuItem value="active">Active</MythicToolbarMenuItem>
<MythicToolbarMenuItem value="disabled">Disabled</MythicToolbarMenuItem>
<MythicToolbarMenuItem value="modified">User modified</MythicToolbarMenuItem>
<MythicToolbarMenuItem value="default">Container default</MythicToolbarMenuItem>
</MythicToolbarSelect>
</MythicTableToolbarGroup>
</MythicTableToolbar>
<TableContainer style={{flexGrow: 1, minHeight: 0, overflow: "auto"}}>
<Table className="mythic-browser-scripts-table table-fixed max-w-full w-full" stickyHeader={true} size="small">
</MythicPageHeader>
<TableContainer className="mythicElement" style={{height: "100%", flexGrow: 1}}>
<Table stickyHeader={true} size="small" style={{"tableLayout": "fixed", "maxWidth": "100%", "overflow": "scroll"}}>
<TableHead>
<TableRow>
<MythicStyledTableCell style={{width: "24rem"}}>Script</MythicStyledTableCell>
<MythicStyledTableCell style={{width: "10rem"}}>Author</MythicStyledTableCell>
<MythicStyledTableCell style={{width: "9.5rem"}}>Active</MythicStyledTableCell>
<MythicStyledTableCell style={{width: "12rem"}}>Source</MythicStyledTableCell>
<MythicStyledTableCell style={{width: "4rem", textAlign: "center"}}>Actions</MythicStyledTableCell>
<MythicStyledTableCell className="mythic-browser-script-spacer-cell min-w-0" />
<TableCell style={{width: "3rem"}}>Edit</TableCell>
<TableCell style={{width: "5rem"}}>Active</TableCell>
<TableCell style={{width: "5rem"}}>Payload</TableCell>
<TableCell style={{width: "20rem"}}>Command</TableCell>
<TableCell style={{width: "12rem"}}> Author</TableCell>
<TableCell style={{textAlign: "left"}}>User Modified?</TableCell>
</TableRow>
</TableHead>
<TableBody>
{visibleScripts.length === 0 &&
<MythicTableEmptyState
colSpan={6}
compact
title={props.browserscripts.length === 0 ? "No browser scripts" : "No browser scripts match"}
description={props.browserscripts.length === 0 ? "Create or import browser scripts to customize task output rendering." : "Try changing the search text or status filter."}
/>
}
{visibleScripts.map( (op) => (
{props.browserscripts.map( (op) => (
<BrowserScriptsTableRow
me={props.me}
operation_id={props.operation_id} onToggleActive={props.onToggleActive}
@@ -140,3 +63,4 @@ export function BrowserScriptsTable(props){
)
}
@@ -1,15 +1,13 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import React from 'react';
import Box from '@mui/material/Box';
import {Switch} from '@mui/material';
import { Switch} from '@mui/material';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {EditScriptDialog} from './EditScriptDialog';
import EditIcon from '@mui/icons-material/Edit';
import SettingsIcon from '@mui/icons-material/Settings';
import IconButton from '@mui/material/IconButton';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import {MythicAgentSVGIcon} from "../../MythicComponents/MythicAgentSVGIcon";
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
import {MythicChip} from "../../MythicComponents/MythicChip";
export function BrowserScriptsTableRow(props){
const [openEdit, setOpenEdit] = React.useState(false);
@@ -24,44 +22,27 @@ export function BrowserScriptsTableRow(props){
}
return (
<React.Fragment>
<TableRow className={props.active ? "" : "mythic-browser-script-row-disabled"} key={"payload" + props.id} hover>
<MythicStyledTableCell>
<Box className="mythic-browser-script-script-cell items-center flex gap-5 min-w-0">
<MythicStyledTooltip title={props.payloadtype.name}>
<Box className="mythic-browser-script-payload-icon items-center inline-flex flex-none justify-center rounded bg-neutral-1 border-subtle">
<MythicAgentSVGIcon payload_type={props.payloadtype.name} style={{width: "32px", height: "32px"}} />
</Box>
</MythicStyledTooltip>
<Box className="mythic-browser-script-script-copy flex flex-column gap-1 min-w-0">
<Box className="mythic-browser-script-command-name text-sm font-750 leading-125 truncate text-primary whitespace-nowrap">{props.command.cmd}</Box>
<Box className="mythic-browser-script-payload-name text-xs font-650 leading-125 truncate text-muted whitespace-nowrap">{props.payloadtype.name}</Box>
</Box>
</Box>
</MythicStyledTableCell>
<MythicStyledTableCell>{props.author}</MythicStyledTableCell>
<MythicStyledTableCell>
<Box className="mythic-browser-script-active-cell items-center flex gap-3 min-w-0">
<Switch
checked={props.active}
onChange={onToggleActive}
color="success"
inputProps={{ 'aria-label': 'Toggle browser script active state', "track": "white" }}
name="Active"
size="small"
/>
</Box>
</MythicStyledTableCell>
<MythicStyledTableCell>
<MythicChip label={props.user_modified ? "User modified" : "Container default"} tone={props.user_modified ? "warning" : "secondary"} />
</MythicStyledTableCell>
<MythicStyledTableCell style={{textAlign: "center"}}>
<Box className="items-center flex flex-wrap gap-3 flex-nowrap mythic-browser-script-actions justify-center">
<MythicActionButton iconOnly appearance="raised" colorMode="hover" tone="info" size="small" onClick={()=>{setOpenEdit(true);}}>
<EditIcon fontSize="small" />
</MythicActionButton>
</Box>
</MythicStyledTableCell>
<MythicStyledTableCell className="mythic-browser-script-spacer-cell min-w-0" />
<TableRow key={"payload" + props.id} hover>
<TableCell >
<IconButton size="small" onClick={()=>{setOpenEdit(true);}} color="info" variant="contained"><SettingsIcon color="info" /></IconButton>
</TableCell>
<TableCell >
<Switch
checked={props.active}
onChange={onToggleActive}
color="info"
inputProps={{ 'aria-label': 'checkbox', "track": "white" }}
name="Active"
/>
</TableCell>
<TableCell>
<MythicStyledTooltip title={props.payloadtype.name}>
<MythicAgentSVGIcon payload_type={props.payloadtype.name} style={{width: "35px", height: "35px"}} />
</MythicStyledTooltip>
</TableCell>
<TableCell>{props.command.cmd}</TableCell>
<TableCell>{props.author}</TableCell>
<TableCell>{props.user_modified ? "User Modified" : "" } </TableCell>
{openEdit &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openEdit}
@@ -75,3 +56,4 @@ export function BrowserScriptsTableRow(props){
</React.Fragment>
)
}
@@ -1,7 +1,6 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import React, {useEffect, useRef} from 'react';
import Box from '@mui/material/Box';
import Collapse from '@mui/material/Collapse';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import AceEditor from 'react-ace';
@@ -15,22 +14,11 @@ import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import InputLabel from '@mui/material/InputLabel';
import Tooltip from '@mui/material/Tooltip';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import Input from '@mui/material/Input';
import Split from 'react-split';
import {TaskDisplay} from "../Callbacks/TaskDisplay";
import {MythicChip} from "../../MythicComponents/MythicChip";
import {taskingDataFragment} from '../Callbacks/CallbackMutations'
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicFormField,
MythicFormGrid,
MythicFormNote
} from "../../MythicComponents/MythicDialogLayout";
import {MythicEmptyState, MythicLoadingState} from "../../MythicComponents/MythicStateDisplay";
const getCommandsAndPayloadTypesQuery = gql`
@@ -54,38 +42,6 @@ query getAvailableTasks($command_id: Int!){
}
`;
const BROWSER_SCRIPT_WORKBENCH_SPLIT_KEY = "browserScriptWorkbenchSplitSizes";
const BROWSER_SCRIPT_TOP_SPLIT_KEY = "browserScriptTopSplitSizes";
const defaultWorkbenchSplitSizes = [48, 52];
const defaultTopSplitSizes = [68, 32];
const getStoredSplitSizes = (storageKey, fallback) => {
try {
if(typeof window === "undefined"){
return fallback;
}
const storedSizes = window.localStorage.getItem(storageKey);
if(!storedSizes){
return fallback;
}
const parsedSizes = JSON.parse(storedSizes);
if(!Array.isArray(parsedSizes) || parsedSizes.length !== fallback.length || !parsedSizes.every(Number.isFinite)){
return fallback;
}
return parsedSizes;
} catch (error) {
return fallback;
}
}
const storeSplitSizes = (storageKey, sizes) => {
try {
window.localStorage.setItem(storageKey, JSON.stringify(sizes));
} catch (error) {
// Best effort only; layout still works without persisted sizes.
}
}
export function EditScriptDialog(props) {
const theme = useTheme();
const [script, setScript] = React.useState("");
@@ -93,19 +49,12 @@ export function EditScriptDialog(props) {
const [selectedCommand, setSelectedCommand] = React.useState('');
const [payloadTypeCmdOptions, setPayloadTypeCmdOptions] = React.useState([]);
const [commandOptions, setCommandOptions] = React.useState([]);
const inputPTRef = useRef(null);
const inputCMDRef = useRef(null);
const inputTaskRef = useRef(null);
const [availableTasks, setAvailableTasks] = React.useState([]);
const [selectedTask, setSelectedTask] = React.useState('');
const [workbenchSplitSizes, setWorkbenchSplitSizes] = React.useState(() => getStoredSplitSizes(BROWSER_SCRIPT_WORKBENCH_SPLIT_KEY, defaultWorkbenchSplitSizes));
const [topSplitSizes, setTopSplitSizes] = React.useState(() => getStoredSplitSizes(BROWSER_SCRIPT_TOP_SPLIT_KEY, defaultTopSplitSizes));
const [targetOpen, setTargetOpen] = React.useState(() => props.new === true);
const selectedPayloadTypeOption = React.useMemo(() => {
return payloadTypeCmdOptions.find((payloadType) => payloadType.id === selectedPayloadType);
}, [payloadTypeCmdOptions, selectedPayloadType]);
const selectedCommandOption = React.useMemo(() => {
return commandOptions.find((command) => command.id === selectedCommand);
}, [commandOptions, selectedCommand]);
const taskSelectorValue = selectedTask || "";
const {loading: targetLoading} = useQuery(getCommandsAndPayloadTypesQuery, {
useQuery(getCommandsAndPayloadTypesQuery, {
onCompleted: data => {
setPayloadTypeCmdOptions(data.payloadtype);
if(props.payload_type_id !== undefined){
@@ -117,8 +66,11 @@ export function EditScriptDialog(props) {
}
if(props.command_id !== undefined){
setSelectedCommand(props.command_id);
const payloadType = data.payloadtype.find((payloadType) => payloadType.id === props.payload_type_id);
setCommandOptions(payloadType?.commands || []);
for(let i = 0; i < data.payloadtype.length; i++){
if(props.payload_type_id === data.payloadtype[i].id){
setCommandOptions(data.payloadtype[i].commands)
}
}
}else{
if(data.payloadtype.length > 0){
if(data.payloadtype[0].commands.length > 0){
@@ -132,13 +84,11 @@ export function EditScriptDialog(props) {
}
});
const [getAvailableTasks, {loading: tasksLoading}] = useLazyQuery(getExistingTasksForCommand, {
const [getAvailableTasks] = useLazyQuery(getExistingTasksForCommand, {
onCompleted: data => {
setAvailableTasks(data.task);
if(data.task.length > 0){
setSelectedTask(data.task[0]);
}else{
setSelectedTask('');
}
},
onError: data => {
@@ -152,12 +102,9 @@ export function EditScriptDialog(props) {
useEffect( () => {
if(selectedCommand !== ""){
getAvailableTasks({variables: {command_id: selectedCommand}})
}else{
setAvailableTasks([]);
setSelectedTask('');
}
}, [selectedCommand, getAvailableTasks]);
}, [selectedCommand]);
useEffect( () => {
if(props.script !== undefined){
try{
@@ -190,12 +137,9 @@ export function EditScriptDialog(props) {
}
const onChangeSelectedPayloadType = (event) => {
setSelectedPayloadType(event.target.value);
const selectedPayload = payloadTypeCmdOptions.find( (p) => p.id === event.target.value);
const cmds = selectedPayload?.commands || [];
setCommandOptions(cmds);
setSelectedCommand(cmds[0]?.id || '');
setAvailableTasks([]);
setSelectedTask('');
const cmds = payloadTypeCmdOptions.filter( (p) => p.id === event.target.value);
setCommandOptions(cmds[0].commands);
setSelectedCommand(cmds[0].commands[0].id);
}
const onChangeTask = (event) => {
setSelectedTask(event.target.value);
@@ -204,48 +148,34 @@ export function EditScriptDialog(props) {
setSelectedCommand(event.target.value);
}
const onLoad = (editor) => {
editorRef.current = editor;
requestAnimationFrame(() => editor.resize());
// Your editor options comes here
editor.on('change', (arg, activeEditor) => {
editorRef.current = activeEditor;
editor.removeEventListener('change');
editorRef.current.resize();
});
}
const onOutputLoad = (editor) => {
outputRef.current = editor;
requestAnimationFrame(() => editor.resize());
// Your editor options comes here
editor.on('change', (arg, activeEditor) => {
outputRef.current = activeEditor;
editor.removeEventListener('change');
outputRef.current.resize();
});
}
React.useEffect( () => {
const logBackup = console.log;
const formatConsoleArg = (arg) => {
if(typeof arg === "string"){
return arg;
}
try {
return JSON.stringify(arg, null, 2);
} catch (error) {
return String(arg);
}
};
console.log = function(...args) {
const message = args.map(formatConsoleArg).join(" ");
logStreamRef.current += "\n" + message;
setLogOutput(logStreamRef.current);
logBackup.apply(console, args);
};
return () => {
console.log = logBackup;
var logBackup = console.log;
console.log = function(msg) {
logStreamRef.current += "\n" + msg;
logBackup.apply(msg);
setLogOutput(logStreamRef.current)
};
}, []);
React.useEffect(() => {
requestAnimationFrame(() => {
if(editorRef.current){
editorRef.current.resize();
}
if(outputRef.current){
outputRef.current.resize();
}
});
}, [selectedTask, tasksLoading]);
const resizeEditors = () => {
const onDragging = () => {
if(editorRef.current){
editorRef.current.resize();
}
@@ -253,227 +183,128 @@ export function EditScriptDialog(props) {
outputRef.current.resize();
}
}
const onWorkbenchDragEnd = (sizes) => {
setWorkbenchSplitSizes(sizes);
storeSplitSizes(BROWSER_SCRIPT_WORKBENCH_SPLIT_KEY, sizes);
resizeEditors();
}
const onTopDragEnd = (sizes) => {
setTopSplitSizes(sizes);
storeSplitSizes(BROWSER_SCRIPT_TOP_SPLIT_KEY, sizes);
resizeEditors();
}
const onToggleTargetOpen = () => {
setTargetOpen((current) => !current);
}
const selectedTaskLabel = (task) => {
return `${task.command_name} / ${task.display_id} / ${task.display_params}`;
}
return (
<React.Fragment>
<DialogTitle>
<Box className="mythic-dialog-title-row items-center flex flex-wrap gap-5 justify-between min-w-0">
<Box component="span">{props.title ? props.title : "Edit " + props.author + "'s BrowserScript Code"}</Box>
</Box>
</DialogTitle>
<DialogContent className="mythic-browser-script-dialog-content flex flex-column overflow-hidden" dividers={true}>
<MythicDialogBody className="mythic-browser-script-dialog-body flex-fill min-h-0 overflow-hidden">
<Box className={`mythic-browser-script-target-panel rounded bg-surface-raised border-subtle shadow-1${targetOpen ? " mythic-browser-script-target-panel-open" : ""} overflow-hidden`}>
<Box className="mythic-browser-script-target-summary items-center flex gap-5 justify-between">
<Box className="mythic-browser-script-target-copy items-center flex gap-5 min-w-0">
<Box component="span" className="mythic-browser-script-target-label text-xs font-800 leading-100 flex-none text-muted">Script Target</Box>
<Box className="mythic-browser-script-target-chips items-center flex flex-wrap gap-3 min-w-0">
<MythicChip size="small" label={selectedPayloadTypeOption?.name || (targetLoading ? "Loading payload types" : "No payload type")} />
<MythicChip size="small" label={selectedCommandOption?.cmd || "No command"} />
</Box>
</Box>
<Tooltip title={targetOpen ? "Collapse target settings" : "Edit target settings"}>
<MythicActionButton iconOnly
aria-expanded={targetOpen}
aria-label={targetOpen ? "Collapse target settings" : "Edit target settings"}
appearance="raised" colorMode="hover" tone="primary"
onClick={onToggleTargetOpen}
size="small"
>
{targetOpen ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
</MythicActionButton>
</Tooltip>
</Box>
<Collapse
in={targetOpen}
onEntered={resizeEditors}
onExited={resizeEditors}
timeout="auto"
unmountOnExit
<DialogTitle >
{props.title ? props.title : "Edit " + props.author + "'s BrowserScript Code"}
</DialogTitle>
<DialogContent dividers={true} style={{height: `calc(100vh)`, display: "flex", flexDirection: "column", width: "100%"}}>
<div style={{display: "flex"}}>
<FormControl style={{width: "50%"}}>
<InputLabel ref={inputPTRef}>Payload Type</InputLabel>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={selectedPayloadType}
onChange={onChangeSelectedPayloadType}
input={<Input style={{width: "100%"}}/>}
>
<Box className="mythic-browser-script-target-details p-5 flex flex-column gap-4 bg-surface-muted">
<MythicFormGrid minWidth="16rem">
<MythicFormField label="Payload Type">
<FormControl fullWidth size="small">
<InputLabel id="browser-script-payload-type-label">Payload Type</InputLabel>
<Select
label="Payload Type"
labelId="browser-script-payload-type-label"
value={selectedPayloadType}
onChange={onChangeSelectedPayloadType}
disabled={targetLoading}
>
{payloadTypeCmdOptions.map( (opt) => (
<MenuItem value={opt.id} key={"payloadtype" + opt.id}>{opt.name}</MenuItem>
) )}
</Select>
</FormControl>
</MythicFormField>
<MythicFormField label="Command">
<FormControl fullWidth size="small">
<InputLabel id="browser-script-command-label">Command</InputLabel>
<Select
label="Command"
labelId="browser-script-command-label"
value={selectedCommand}
onChange={onChangeSelectedCommand}
disabled={targetLoading || commandOptions.length === 0}
>
{commandOptions.map( (opt) => (
<MenuItem value={opt.id} key={"command" + opt.id}>{opt.cmd}</MenuItem>
) )}
</Select>
</FormControl>
</MythicFormField>
</MythicFormGrid>
<MythicFormNote>
Use Save for Testing to update the script without closing the editor. Select an already executed matching task below, then collapse and re-expand that task if you need it to reload the updated renderer.
</MythicFormNote>
</Box>
</Collapse>
</Box>
<Split
className="mythic-browser-script-workbench flex flex-column flex-fill min-h-0 min-w-0 w-full"
direction="vertical"
gutterSize={8}
minSize={[210, 300]}
onDrag={resizeEditors}
onDragEnd={onWorkbenchDragEnd}
sizes={workbenchSplitSizes}
>
<Split
className="mythic-browser-script-top-split flex min-h-0 min-w-0 w-full"
gutterSize={8}
minSize={[320, 220]}
onDrag={resizeEditors}
onDragEnd={onTopDragEnd}
sizes={topSplitSizes}
{payloadTypeCmdOptions.map( (opt) => (
<MenuItem value={opt.id} key={"payloadtype" + opt.id}>{opt.name}</MenuItem>
) )}
</Select>
</FormControl>
<FormControl style={{width: "50%", paddingBottom: "10px"}}>
<InputLabel ref={inputCMDRef}>Command</InputLabel>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={selectedCommand}
onChange={onChangeSelectedCommand}
input={<Input style={{width: "100%"}}/>}
>
<div className="mythic-browser-script-pane flex flex-column mythic-browser-script-editor-pane h-full min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
<div className="mythic-browser-script-pane-header text-xs font-800 items-center flex flex-none justify-between bg-surface-muted border-b-subtle text-primary">
<span>Script Code</span>
<span>JavaScript</span>
</div>
<div className="mythic-browser-script-editor-frame flex-fill min-h-0 min-w-0">
<AceEditor
mode="javascript"
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
width="100%"
onLoad={onLoad}
height="100%"
value={script}
focus={true}
onChange={onChange}
setOptions={{
displayIndentGuides: true,
fontSize: 13,
showPrintMargin: false,
tabSize: 4,
useWorker: false,
}}
/>
</div>
{commandOptions.map( (opt) => (
<MenuItem value={opt.id} key={"command" + opt.id}>{opt.cmd}</MenuItem>
) )}
</Select>
</FormControl>
</div>
<p>
<b>To test locally</b>: Make your changes in the top left code box. Any <b>console.log</b> entries will appear in the box to the right when executed.
Click <b>Save for Testing</b> to finalize your changes. Make sure to select an already executed task that matches this command from the bottom.
You will need to collapse and re-expand the task to pull in your updated changes.
</p>
<div style={{display: "flex", width: "100%", flexGrow: 1}}>
<Split direction="vertical" style={{height: "100%", width: "100%"}} sizes={[30, 70]} onDrag={onDragging} >
<Split style={{display: "flex"}} sizes={[70, 30]} onDrag={onDragging} >
<div className="bg-gray-light">
<AceEditor
mode="javascript"
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
width="100%"
onLoad={onLoad}
height="100%"
value={script}
focus={true}
onChange={onChange}
setOptions={{
useWorker: false
}}
/>
</div>
<div className="mythic-browser-script-pane flex flex-column mythic-browser-script-console-pane h-full min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
<div className="mythic-browser-script-pane-header text-xs font-800 items-center flex flex-none justify-between bg-surface-muted border-b-subtle text-primary">
<span>Console Output</span>
<span>console.log</span>
</div>
<div className="mythic-browser-script-editor-frame flex-fill min-h-0 min-w-0">
<AceEditor
mode="javascript"
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
width="100%"
onLoad={onOutputLoad}
height="100%"
value={logOutput}
focus={false}
readOnly
setOptions={{
fontSize: 12,
highlightActiveLine: false,
showGutter: false,
showPrintMargin: false,
tabSize: 4,
useWorker: false,
}}
/>
</div>
<div className="bg-gray-light" >
<AceEditor
mode="javascript"
theme={theme.palette.mode === 'dark' ? 'monokai' : 'github'}
width="100%"
onLoad={onOutputLoad}
height="100%"
value={logOutput}
focus={false}
onChange={onChange}
setOptions={{
useWorker: false
}}
/>
</div>
</Split>
<div className="mythic-browser-script-pane flex flex-column mythic-browser-script-preview-pane h-full min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
<div className="mythic-browser-script-pane-header text-xs font-800 items-center flex flex-none justify-between bg-surface-muted border-b-subtle text-primary">
<span>Test Preview</span>
<span>{availableTasks.length === 1 ? "1 task" : `${availableTasks.length} tasks`}</span>
</div>
<div className="mythic-browser-script-preview-controls bg-header flex-none border-b-subtle">
<FormControl fullWidth size="small">
<InputLabel id="browser-script-test-task-label">Test Script With Task</InputLabel>
<Select
label="Test Script With Task"
labelId="browser-script-test-task-label"
value={taskSelectorValue}
onChange={onChangeTask}
disabled={tasksLoading || availableTasks.length === 0}
onOpen={resizeEditors}
renderValue={(task) => task ? selectedTaskLabel(task) : ""}
>
{availableTasks.map( (opt) => (
<MenuItem value={opt} key={"task" + opt.id}>{selectedTaskLabel(opt)}</MenuItem>
) )}
</Select>
</FormControl>
</div>
<div className="mythic-browser-script-preview-frame p-4 flex-fill min-h-0 min-w-0 overflow-auto">
{tasksLoading ? (
<MythicLoadingState compact title="Loading test tasks" description="Fetching previous tasks for this command." />
) : selectedTask !== "" ? (
<TaskDisplay me={props.me} task={selectedTask} command_id={selectedTask.command == null ? 0 : selectedTask.command.id} />
) : (
<MythicEmptyState compact title="No matching tasks" description="Run this command at least once to preview this browser script against task output." />
)}
</div>
<div className="bg-gray-light" >
<FormControl style={{width: "100%"}}>
<InputLabel ref={inputTaskRef} style={{paddingTop: "10px"}}>Test Script With Task</InputLabel>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={selectedTask}
onChange={onChangeTask}
input={<Input style={{width: "100%"}}/>}
>
{availableTasks.map( (opt) => (
<MenuItem value={opt} key={"task" + opt.id}>{opt.command_name + " / " + opt.display_id + " / " + opt.display_params}</MenuItem>
) )}
</Select>
</FormControl>
{selectedTask !== "" &&
<TaskDisplay me={props.me} task={selectedTask} command_id={selectedTask.command == null ? 0 : selectedTask.command.id} />
}
</div>
</Split>
</MythicDialogBody>
</div>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
</Button>
{props.new ? (
<MythicDialogButton disabled={selectedCommand === "" || selectedPayloadType === ""} intent="primary" onClick={onSubmit}>
<Button onClick={onSubmit} variant="contained" color="success">
Create
</MythicDialogButton>
</Button>
) : (
<React.Fragment>
<MythicDialogButton intent="warning" onClick={onRevert}>
<Button onClick={onRevert} variant="contained" color="warning">
Revert
</MythicDialogButton>
<MythicDialogButton intent="info" disabled={selectedCommand === "" || selectedPayloadType === ""} onClick={onTest}>
</Button>
<Button onClick={onTest} variant="contained" color="info">
Save For Testing
</MythicDialogButton>
<MythicDialogButton disabled={selectedCommand === "" || selectedPayloadType === ""} intent="primary" onClick={onSubmit}>
</Button>
<Button onClick={onSubmit} variant="contained" color="success">
Save and Exit
</MythicDialogButton>
</Button>
</React.Fragment>
)}
</MythicDialogFooter>
</DialogActions>
</React.Fragment>
);
}
@@ -1,4 +1,6 @@
import React from 'react';
import React, {useEffect} from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import List from '@mui/material/List';
@@ -6,14 +8,11 @@ import ListItem from '@mui/material/ListItem';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Checkbox from '@mui/material/Checkbox';
import CardHeader from '@mui/material/CardHeader';
import {gql, useQuery} from '@apollo/client';
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
import {
MythicDialogButton,
MythicDialogBody,
MythicDialogFooter,
MythicDialogSection
} from '../../MythicComponents/MythicDialogLayout';
import { CardContent } from '@mui/material';
import {classes, StyledButton, StyledDivider} from '../../MythicComponents/MythicTransferList';
import {MythicDraggableDialogTitle} from "../../MythicComponents/MythicDraggableDialogTitle";
const getCommandsQuery = gql`
query GetCallbackDetails($callback_id: Int!) {
@@ -69,14 +68,15 @@ const CustomList = ({title, items, handleToggle}) => {
))
}, [items, handleToggle]);
return (
<div className="mythic-transfer-list flex flex-column min-w-0 overflow-hidden w-full rounded bg-surface border-subtle">
<div className="mythic-transfer-list-header bg-table-header text-xs font-700 leading-125 border-b text-primary">{title}</div>
<div className="mythic-transfer-list-body flex-fill overflow-auto">
<>
<CardHeader className={classes.cardHeader} title={title}/>
<StyledDivider classes={{root: classes.divider}}/>
<CardContent style={{flexGrow: 1, overflowY: "auto", padding: 0}}>
<List dense component="div" role="list" style={{padding: 0, width: "100%"}}>
{renderedList}
</List>
</div>
</div>
</CardContent>
</>
)
};
export function AddRemoveCallbackCommandsDialog(props) {
@@ -187,75 +187,67 @@ export function AddRemoveCallbackCommandsDialog(props) {
<React.Fragment>
<DialogTitle>Add or Remove Commands for Callback {props.display_id} </DialogTitle>
<DialogContent dividers={true} style={{height: "100%", display: "flex", flexDirection: "column", position: "relative", overflowY: "auto"}}>
<MythicDialogBody>
<MythicDialogSection
title="Command Availability"
description="Updates Mythic's callback command association without changing commands inside the active payload."
>
<div style={{display: "flex", flexDirection: "row", overflowY: "auto", flexGrow: 1, minHeight: 0}}>
<div style={{paddingLeft: 0, flexGrow: 1, marginLeft: 0, marginRight: "10px", position: "relative", overflowY: "auto", display: "flex", flexDirection: "column" }}>
<CustomList title={leftTitle} items={left} handleToggle={handleToggle}/>
</div>
<div className="mythic-transfer-controls py-0 px-4 items-center flex flex-column gap-6 justify-center">
<MythicActionButton
colorMode="always"
tone="primary"
variant="contained"
size="small"
onClick={handleAllRight}
disabled={left.length === 0}
aria-label="move all right"
>
&gt;&gt;
</MythicActionButton>
<MythicActionButton
colorMode="always"
tone="primary"
variant="contained"
size="small"
onClick={handleCheckedRight}
aria-label="move selected right"
>
&gt;
</MythicActionButton>
<MythicActionButton
colorMode="always"
tone="primary"
variant="contained"
size="small"
onClick={handleCheckedLeft}
aria-label="move selected left"
>
&lt;
</MythicActionButton>
<MythicActionButton
colorMode="always"
tone="primary"
variant="contained"
size="small"
onClick={handleAllLeft}
disabled={right.length === 0}
aria-label="move all left"
>
&lt;&lt;
</MythicActionButton>
</div>
<div style={{marginLeft: "10px", position: "relative", flexGrow: 1, display: "flex", flexDirection: "column" }}>
<CustomList title={rightTitle} items={right} handleToggle={handleToggle} />
</div>
</div>
</MythicDialogSection>
</MythicDialogBody>
This will add or remove commands associated with this callback from Mythic's perspective.
This does NOT add or remove commands within the payload itself that's beaconing out to Mythic.
<div style={{display: "flex", flexDirection: "row", overflowY: "auto", flexGrow: 1, minHeight: 0}}>
<div style={{paddingLeft: 0, flexGrow: 1, marginLeft: 0, marginRight: "10px", position: "relative", overflowY: "auto", display: "flex", flexDirection: "column" }}>
<CustomList title={leftTitle} items={left} handleToggle={handleToggle}/>
</div>
<div style={{display: "flex", flexDirection: "column", justifyContent: "center"}}>
<StyledButton
variant="contained"
size="small"
className={classes.button}
onClick={handleAllRight}
disabled={left.length === 0}
aria-label="move all right"
>
&gt;&gt;
</StyledButton>
<StyledButton
variant="contained"
size="small"
className={classes.button}
onClick={handleCheckedRight}
aria-label="move selected right"
>
&gt;
</StyledButton>
<StyledButton
variant="contained"
size="small"
className={classes.button}
onClick={handleCheckedLeft}
aria-label="move selected left"
>
&lt;
</StyledButton>
<StyledButton
variant="contained"
size="small"
className={classes.button}
onClick={handleAllLeft}
disabled={right.length === 0}
aria-label="move all left"
>
&lt;&lt;
</StyledButton>
</div>
<div style={{marginLeft: "10px", position: "relative", flexGrow: 1, display: "flex", flexDirection: "column" }}>
<CustomList title={rightTitle} items={right} handleToggle={handleToggle} />
</div>
</div>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
<MythicDialogButton intent="primary" onClick={setFinalTags}>
</Button>
<Button onClick={setFinalTags} variant="contained" color="success">
Submit
</MythicDialogButton>
</MythicDialogFooter>
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -1,177 +0,0 @@
import {createSandboxedWorker} from "./BrowserScriptSandbox";
const DEFAULT_TIMEOUT_MS = 10000;
const toError = (errorData, phase) => {
const error = new Error(errorData?.message || "Browser script failed");
error.name = errorData?.name || "Error";
error.stack = errorData?.stack || error.stack;
error.phase = phase;
return error;
};
export class BrowserScriptRunner {
constructor({workerFactory=createSandboxedWorker, timeoutMS=DEFAULT_TIMEOUT_MS} = {}) {
this.workerFactory = workerFactory;
this.timeoutMS = timeoutMS;
this.nextRequestID = 1;
this.latestRequestID = 0;
this.activeRequest = undefined;
this.queuedRequest = undefined;
this.workerScript = undefined;
this.disposed = false;
}
startWorker = () => {
this.worker = this.workerFactory();
this.worker.onmessage = this.handleMessage;
this.worker.onerror = this.handleWorkerError;
};
restartWorker = () => {
if(this.worker){
this.worker.onmessage = null;
this.worker.onerror = null;
this.worker.terminate();
this.worker = undefined;
}
this.workerScript = undefined;
};
run = ({script, task, responses, onResult, onError}) => {
if(this.disposed){
return undefined;
}
const request = {
id: this.nextRequestID++,
script,
task,
responses,
onResult,
onError,
};
this.latestRequestID = request.id;
this.queuedRequest = request;
if(!this.activeRequest){
this.runQueuedRequest();
}
return request.id;
};
cancel = (requestID) => {
if(this.queuedRequest?.id === requestID){
this.queuedRequest = undefined;
}
if(this.activeRequest?.id === requestID){
this.activeRequest.cancelled = true;
}
if(this.latestRequestID === requestID){
this.latestRequestID = 0;
}
};
runQueuedRequest = () => {
if(this.disposed || this.activeRequest || !this.queuedRequest){
return;
}
const request = this.queuedRequest;
this.queuedRequest = undefined;
this.activeRequest = request;
const message = {
type: "render",
requestID: request.id,
task: request.task,
responses: request.responses,
};
if(request.script !== this.workerScript){
message.script = request.script;
this.workerScript = request.script;
}
request.timeout = setTimeout(() => {
this.finishActiveRequest({
error: new Error(`Browser script exceeded the ${this.timeoutMS}ms time limit`),
restartWorker: true,
});
}, this.timeoutMS);
try {
if(!this.worker){
this.startWorker();
}
this.worker.postMessage(message);
} catch (error) {
this.finishActiveRequest({error, restartWorker: true});
}
};
handleMessage = ({data}) => {
const request = this.activeRequest;
if(!request || data?.requestID !== request.id){
return;
}
if(data.type === "log"){
if(!request.cancelled && request.id === this.latestRequestID && !this.queuedRequest){
console.log(...(Array.isArray(data.args) ? data.args : [data.args]));
}
return;
}
if(data.type === "error"){
if(data.phase === "compile"){
this.workerScript = undefined;
}
this.finishActiveRequest({error: toError(data.error, data.phase)});
return;
}
if(data.type === "result"){
this.finishActiveRequest({result: data.result});
}
};
handleWorkerError = (event) => {
event?.preventDefault?.();
if(!this.activeRequest){
this.restartWorker();
return;
}
this.finishActiveRequest({
error: new Error(event?.message || "Browser script worker failed"),
restartWorker: true,
});
};
finishActiveRequest = ({result, error, restartWorker=false}) => {
const request = this.activeRequest;
if(!request){
return;
}
clearTimeout(request.timeout);
this.activeRequest = undefined;
const isCurrent = !request.cancelled && request.id === this.latestRequestID && !this.queuedRequest;
if(restartWorker){
this.restartWorker();
}
if(isCurrent){
if(error){
request.onError?.(error);
}else{
request.onResult?.(result);
}
}
this.runQueuedRequest();
};
dispose = () => {
this.disposed = true;
if(this.activeRequest){
clearTimeout(this.activeRequest.timeout);
}
this.activeRequest = undefined;
this.queuedRequest = undefined;
this.restartWorker();
};
}
@@ -1,144 +0,0 @@
import {BrowserScriptRunner} from "./BrowserScriptRunner";
class MockWorker {
constructor() {
this.messages = [];
this.terminated = false;
}
postMessage = (message) => {
this.messages.push(message);
};
terminate = () => {
this.terminated = true;
};
respond = (data) => {
this.onmessage?.({data});
};
}
const createRunner = (options = {}) => {
const workers = [];
const workerFactory = jest.fn(() => {
const worker = new MockWorker();
workers.push(worker);
return worker;
});
return {
runner: new BrowserScriptRunner({workerFactory, ...options}),
workerFactory,
workers,
};
};
const makeRequest = (overrides = {}) => ({
script: "function(task, responses) { return {plaintext: responses.join('')}; }",
task: {id: 1},
responses: ["one"],
onResult: jest.fn(),
onError: jest.fn(),
...overrides,
});
describe("BrowserScriptRunner", () => {
test("creates its worker lazily and reuses the compiled script", () => {
const {runner, workerFactory, workers} = createRunner();
expect(workerFactory).not.toHaveBeenCalled();
const first = makeRequest();
const firstID = runner.run(first);
expect(workerFactory).toHaveBeenCalledTimes(1);
expect(workers[0].messages[0]).toMatchObject({
type: "render",
requestID: firstID,
script: first.script,
});
workers[0].respond({type: "result", requestID: firstID, result: {plaintext: "one"}});
const second = makeRequest({responses: ["two"]});
const secondID = runner.run(second);
expect(workerFactory).toHaveBeenCalledTimes(1);
expect(workers[0].messages[1]).toEqual({
type: "render",
requestID: secondID,
task: second.task,
responses: second.responses,
});
workers[0].respond({type: "result", requestID: secondID, result: {plaintext: "two"}});
expect(first.onResult).toHaveBeenCalledWith({plaintext: "one"});
expect(second.onResult).toHaveBeenCalledWith({plaintext: "two"});
runner.dispose();
});
test("coalesces updates while a render is running", () => {
const {runner, workers} = createRunner();
const first = makeRequest({responses: ["one"]});
const second = makeRequest({responses: ["two"]});
const latest = makeRequest({responses: ["three"]});
const firstID = runner.run(first);
runner.run(second);
const latestID = runner.run(latest);
expect(workers[0].messages).toHaveLength(1);
workers[0].respond({type: "result", requestID: firstID, result: {plaintext: "one"}});
expect(first.onResult).not.toHaveBeenCalled();
expect(second.onResult).not.toHaveBeenCalled();
expect(workers[0].messages).toHaveLength(2);
expect(workers[0].messages[1]).toMatchObject({
requestID: latestID,
responses: ["three"],
});
workers[0].respond({type: "result", requestID: latestID, result: {plaintext: "three"}});
expect(latest.onResult).toHaveBeenCalledWith({plaintext: "three"});
runner.dispose();
});
test("restarts a timed-out worker and reports the current failure", () => {
jest.useFakeTimers();
const {runner, workers} = createRunner({timeoutMS: 25});
const request = makeRequest();
runner.run(request);
jest.advanceTimersByTime(25);
expect(workers[0].terminated).toBe(true);
expect(request.onError).toHaveBeenCalledWith(expect.objectContaining({
message: "Browser script exceeded the 25ms time limit",
}));
const next = makeRequest({responses: ["after timeout"]});
runner.run(next);
expect(workers).toHaveLength(2);
expect(workers[1].messages[0].script).toBe(next.script);
runner.dispose();
jest.useRealTimers();
});
test("returns worker errors and reloads scripts after compile failures", () => {
const {runner, workers} = createRunner();
const request = makeRequest();
const requestID = runner.run(request);
workers[0].respond({
type: "error",
requestID,
phase: "compile",
error: {name: "SyntaxError", message: "Unexpected token"},
});
expect(request.onError).toHaveBeenCalledWith(expect.objectContaining({
name: "SyntaxError",
message: "Unexpected token",
phase: "compile",
}));
const retry = makeRequest();
runner.run(retry);
expect(workers[0].messages[1].script).toBe(retry.script);
runner.dispose();
});
});
@@ -1,79 +0,0 @@
// The frame owns only a worker and a private message port. It never evaluates
// service code in its window. The opaque origin denies application storage;
// the blob worker inherits this CSP, which denies all network destinations.
export const sandboxDocument = `<!doctype html>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline' 'unsafe-eval'; worker-src blob:; connect-src 'none'; base-uri 'none'; form-action 'none'">
<script>
addEventListener('message', function initialize(event) {
if (event.source !== parent || !event.ports[0]) return;
removeEventListener('message', initialize);
const port = event.ports[0];
try {
const url = URL.createObjectURL(new Blob([event.data], {type: 'text/javascript'}));
const worker = new Worker(url);
URL.revokeObjectURL(url);
worker.onmessage = event => port.postMessage(event.data);
worker.onerror = event => {
event.preventDefault();
port.postMessage({type: 'sandbox-error', message: event.message});
};
port.onmessage = event => worker.postMessage(event.data);
port.start();
} catch (error) {
port.postMessage({type: 'sandbox-error', message: error.message});
}
});
</script>`;
let workerSource;
const loadWorkerSource = () => {
if (!workerSource) {
workerSource = fetch("/new/browser-script-worker.js").then(response => {
if (!response.ok) throw new Error("Failed to load browser script worker");
return response.text();
}).catch(error => {
workerSource = undefined;
throw error;
});
}
return workerSource;
};
// Worker-compatible adapter preserves the runner's queue, cancellation and
// timeout behavior. Removing the frame also terminates its worker.
export const createSandboxedWorker = () => {
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", "allow-scripts");
frame.hidden = true;
frame.title = "Browser script sandbox";
const channel = new MessageChannel();
let disposed = false;
const adapter = {
onmessage: null,
onerror: null,
postMessage: message => channel.port1.postMessage(message),
terminate: () => {
disposed = true;
frame.remove();
channel.port1.close();
channel.port2.close();
},
};
channel.port1.onmessage = event => {
if (disposed) return;
if (event.data?.type === "sandbox-error") {
adapter.onerror?.({message: event.data.message});
} else {
adapter.onmessage?.(event);
}
};
const loaded = new Promise(resolve => { frame.onload = resolve; });
frame.srcdoc = sandboxDocument;
document.body.appendChild(frame);
Promise.all([loaded, loadWorkerSource()]).then(([, source]) => {
if (!disposed) frame.contentWindow.postMessage(source, "*", [channel.port2]);
}).catch(error => {
if (!disposed) adapter.onerror?.(error);
});
return adapter;
};
@@ -1,70 +0,0 @@
import fs from "fs";
import path from "path";
import vm from "vm";
const workerSource = fs.readFileSync(
path.resolve(__dirname, "../../../../public/browser-script-worker.js"),
"utf8",
);
const createWorkerContext = () => {
const messages = [];
const context = {
console: {log: jest.fn()},
self: {
postMessage: (message) => messages.push(message),
},
};
vm.runInNewContext(workerSource, context);
return {messages, worker: context.self};
};
test("browser scripts run without page globals or local storage", () => {
const {messages, worker} = createWorkerContext();
worker.onmessage({
data: {
type: "render",
requestID: 1,
script: `function() {
return {
plaintext: [typeof window, typeof document, typeof localStorage].join(","),
};
}`,
task: {id: 1},
responses: [],
},
});
expect(messages).toEqual([{
type: "result",
requestID: 1,
result: {plaintext: "undefined,undefined,undefined"},
}]);
});
test("the worker reuses its loaded function for later responses", () => {
const {messages, worker} = createWorkerContext();
worker.onmessage({
data: {
type: "render",
requestID: 1,
script: "function(task, responses) { return {plaintext: task.id + responses.join('')}; }",
task: {id: 7},
responses: ["first"],
},
});
worker.onmessage({
data: {
type: "render",
requestID: 2,
task: {id: 8},
responses: ["second"],
},
});
expect(messages[1]).toEqual({
type: "result",
requestID: 2,
result: {plaintext: "8second"},
});
});
File diff suppressed because it is too large. Load diff
@@ -1,465 +0,0 @@
import React from "react";
import {gql, useQuery, useSubscription} from "@apollo/client";
import {getSkewedNow} from "../../utilities/Time";
const browserTreeFragment = gql`
fragment callbackBrowserTreeData on mythictree {
comment
deleted
task_id
filemeta {
id
agent_file_id
filename_text
}
tags {
tagtype {
name
color
id
}
id
}
host
id
os
can_have_children
has_children
success
full_path_text
display_path_text
name_text
timestamp
parent_path_text
tree_type
metadata
callback {
id
display_id
mythictree_groups
}
}
`;
const rootBrowserTreeQuery = gql`
${browserTreeFragment}
query callbackBrowserTreeRoots($tree_type: String!) {
mythictree(
where: {parent_path_text: {_eq: ""}, tree_type: {_eq: $tree_type}}
order_by: {id: asc}
) {
...callbackBrowserTreeData
}
}
`;
const browserTreeSubscription = gql`
${browserTreeFragment}
subscription callbackBrowserTreeStream($now: timestamp!, $tree_type: String!) {
mythictree_stream(
batch_size: 1000
cursor: {initial_value: {timestamp: $now}}
where: {tree_type: {_eq: $tree_type}}
) {
...callbackBrowserTreeData
}
}
`;
const processTreeSubscription = gql`
${browserTreeFragment}
subscription callbackProcessTreeStream($now: timestamp!) {
mythictree_stream(
batch_size: 1000
cursor: {initial_value: {timestamp: $now}}
where: {tree_type: {_eq: "process"}, deleted: {_eq: false}}
) {
...callbackBrowserTreeData
}
}
`;
const getGroups = (row) => row?.callback?.mythictree_groups || ["Unknown Callbacks"];
const decodeFilename = (value) => {
if(!value){return "";}
try{
const text = window.atob(value);
const bytes = Uint8Array.from(text, (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
}catch(error){
try{
return decodeURIComponent(window.atob(value));
}catch(secondError){
return value;
}
}
};
const uniqueBy = (values = [], getKey = (value) => value?.id) => {
const result = [];
const indexes = new Map();
values.forEach((value) => {
const key = getKey(value);
if(key === undefined || key === null){
result.push(value);
return;
}
if(indexes.has(key)){
result[indexes.get(key)] = value;
}else{
indexes.set(key, result.length);
result.push(value);
}
});
return result;
};
const uniquePermissions = (values = []) => uniqueBy(values, (value) => {
if(value && typeof value === "object"){
const keys = Object.keys(value).sort();
return JSON.stringify(value, keys);
}
return `${typeof value}:${value}`;
});
const decodeFilemeta = (values = []) => values.map((file) => ({
...file,
filename_text: decodeFilename(file.filename_text),
}));
const uniqueFilemeta = (values = []) => uniqueBy(values).sort((left, right) => left.id - right.id);
const rowFingerprint = (row) => JSON.stringify({
id: row.id,
task_id: row.task_id,
timestamp: row.timestamp,
deleted: row.deleted,
success: row.success,
comment: row.comment,
has_children: row.has_children,
metadata: row.metadata,
tags: (row.tags || []).map((tag) => tag.id),
filemeta: (row.filemeta || []).map((file) => [file.id, file.filename_text]),
callback: row.callback?.id,
callbackGroups: row.callback?.mythictree_groups,
});
const cloneRow = (row, mode) => ({
...row,
metadata: {...(row.metadata || {})},
tags: uniqueBy(row.tags || []),
filemeta: mode === "file" ? uniqueFilemeta(decodeFilemeta(row.filemeta || [])) : [...(row.filemeta || [])],
...(mode === "process" ? {callbacks: row.callback ? [row.callback] : []} : {}),
});
const mergeFileRow = (existing, row) => {
const next = {...existing};
if((existing.task_id ?? -1) <= (row.task_id ?? -1)){
next.deleted = row.deleted;
}
if((existing.success === null || !existing.success) && row.success !== null){
next.success = row.success;
next.task_id = row.task_id;
}
next.comment = row.comment;
next.tags = uniqueBy([...(existing.tags || []), ...(row.tags || [])]);
next.has_children = row.has_children || existing.has_children;
next.metadata = {...(existing.metadata || {})};
["size", "access_time", "modify_time"].forEach((key) => {
next.metadata[key] = row.metadata?.[key];
});
if(row.metadata?.permissions !== undefined && row.metadata?.permissions !== null){
if(Array.isArray(existing.metadata?.permissions) && Array.isArray(row.metadata.permissions)){
next.metadata.permissions = uniquePermissions([
...existing.metadata.permissions,
...row.metadata.permissions,
]);
}else if(Array.isArray(existing.metadata?.permissions) !== Array.isArray(row.metadata.permissions)){
next.metadata.permissions = row.metadata.permissions;
}else{
next.metadata.permissions = {
...(existing.metadata?.permissions || {}),
...(row.metadata.permissions || {}),
};
}
}
next.filemeta = uniqueFilemeta([...(existing.filemeta || []), ...decodeFilemeta(row.filemeta || [])]);
return next;
};
const mergeCustomRow = (existing, row) => {
if((existing.task_id ?? -1) >= (row.task_id ?? -1)){
return existing;
}
return {
...existing,
deleted: row.deleted,
success: ((existing.success === null || !existing.success) && row.success !== null) ? row.success : existing.success,
comment: row.comment,
tags: uniqueBy(row.tags || []),
has_children: row.has_children || existing.has_children,
metadata: {...(existing.metadata || {}), ...(row.metadata || {})},
display_path_text: row.display_path_text,
task_id: row.task_id,
};
};
const mergeProcessRow = (existing, row) => {
if((existing.task_id ?? -1) <= (row.task_id ?? -1) && row.deleted){
return null;
}
return {
...existing,
has_children: row.has_children || existing.has_children,
callbacks: uniqueBy([...(existing.callbacks || []), ...(row.callback ? [row.callback] : [])]),
comment: `${existing.comment || ""}${row.comment || ""}`,
tags: uniqueBy([...(existing.tags || []), ...(row.tags || [])]),
metadata: (existing.task_id ?? -1) > (row.task_id ?? -1) ?
{...(row.metadata || {}), ...(existing.metadata || {})} :
{...(existing.metadata || {}), ...(row.metadata || {})},
task_id: Math.max(existing.task_id ?? -1, row.task_id ?? -1),
};
};
const mergeRow = (existing, row, mode) => {
if(!existing){
if(mode === "process" && row.deleted){return null;}
return cloneRow(row, mode);
}
if(mode === "file"){
return mergeFileRow(existing, row);
}
if(mode === "process"){
return mergeProcessRow(existing, row);
}
return mergeCustomRow(existing, row);
};
const cloneMatrixBucket = (matrix, group, host, parent, cloned) => {
if(!cloned.root){
matrix = {...matrix};
cloned.root = true;
}
if(!cloned.groups.has(group)){
matrix[group] = {...(matrix[group] || {})};
cloned.groups.add(group);
}
const hostKey = `${group}\u0000${host}`;
if(!cloned.hosts.has(hostKey)){
matrix[group][host] = {...(matrix[group][host] || {})};
cloned.hosts.add(hostKey);
}
const parentKey = `${hostKey}\u0000${parent}`;
if(!cloned.parents.has(parentKey)){
matrix[group][host][parent] = {...(matrix[group][host][parent] || {})};
cloned.parents.add(parentKey);
}
return matrix;
};
export const mergeBrowserTreeRows = (entry, rows, mode = entry.mode) => {
let matrix = entry.matrix;
let changed = false;
const changedBuckets = new Set();
const cloned = {root: false, groups: new Set(), hosts: new Set(), parents: new Set()};
rows.forEach((row) => {
const fingerprint = rowFingerprint(row);
if(entry.fingerprints.get(row.id) === fingerprint){return;}
entry.fingerprints.set(row.id, fingerprint);
getGroups(row).forEach((group) => {
entry.data[group] = entry.data[group] || {};
entry.data[group][row.host] = entry.data[group][row.host] || {};
const existing = entry.data[group][row.host][row.full_path_text];
let next = mergeRow(existing, row, mode);
if(next === existing){return;}
const previousParent = existing?.parent_path_text ?? row.parent_path_text ?? "";
const nextParent = row.parent_path_text ?? previousParent;
if(next && next.parent_path_text !== nextParent){next = {...next, parent_path_text: nextParent};}
matrix = cloneMatrixBucket(matrix, group, row.host, previousParent, cloned);
if(nextParent !== previousParent){
matrix = cloneMatrixBucket(matrix, group, row.host, nextParent, cloned);
delete matrix[group][row.host][previousParent][row.full_path_text];
}
if(next === null){
delete entry.data[group][row.host][row.full_path_text];
delete matrix[group][row.host][previousParent][row.full_path_text];
}else{
entry.data[group][row.host][row.full_path_text] = next;
matrix[group][row.host][nextParent][row.full_path_text] = 1;
}
changedBuckets.add(`${group}\u0000${row.host}\u0000${previousParent}`);
if(nextParent !== previousParent){changedBuckets.add(`${group}\u0000${row.host}\u0000${nextParent}`);}
changed = true;
});
});
if(changed){entry.matrix = matrix;}
return {changed, changedBuckets};
};
const createEntry = (mode) => ({
mode,
data: {},
dataRef: {current: {}},
matrix: {},
version: 0,
hydrated: false,
fingerprints: new Map(),
loadedScopes: new Set(),
listeners: new Set(),
changeListeners: new Set(),
});
export const createCallbackBrowserTreeStore = () => {
const entries = new Map();
const getEntry = (treeType, mode = treeType === "process" ? "process" : treeType === "file" ? "file" : "custom") => {
if(!entries.has(treeType)){
const entry = createEntry(mode);
entry.dataRef.current = entry.data;
entries.set(treeType, entry);
}
return entries.get(treeType);
};
const publish = (entry) => {
entry.version += 1;
entry.listeners.forEach((listener) => listener());
};
return {
getEntry,
mergeRows(treeType, mode, rows, {notify = false, hydrated = false} = {}){
const entry = getEntry(treeType, mode);
const result = mergeBrowserTreeRows(entry, rows, mode);
if(hydrated){entry.hydrated = true;}
if(result.changed || hydrated){publish(entry);}
if(notify && result.changed){
entry.changeListeners.forEach((listener) => listener(result));
}
return {...result, matrix: entry.matrix, data: entry.data};
},
setMatrix(treeType, matrix){
const entry = getEntry(treeType);
entry.matrix = matrix;
publish(entry);
},
subscribe(treeType, listener){
const entry = getEntry(treeType);
entry.listeners.add(listener);
return () => entry.listeners.delete(listener);
},
subscribeToChanges(treeType, listener){
const entry = getEntry(treeType);
entry.changeListeners.add(listener);
return () => entry.changeListeners.delete(listener);
},
getSnapshot(treeType){return getEntry(treeType).version;},
isScopeLoaded(treeType, scope){return getEntry(treeType).loadedScopes.has(scope);},
markScopeLoaded(treeType, scope){getEntry(treeType).loadedScopes.add(scope);},
};
};
const CallbackBrowserTreeContext = React.createContext(null);
const getTreeSources = (openTabs) => {
const sources = new Map();
openTabs.forEach((tab) => {
if(tab.tabType === "fileBrowser"){
sources.set("file", {treeType: "file", mode: "file"});
}else if(tab.tabType === "processBrowser"){
sources.set("process", {treeType: "process", mode: "process"});
}else if(tab.tabType === "customFileBasedBrowser" && tab.customBrowser?.name){
sources.set(tab.customBrowser.name, {treeType: tab.customBrowser.name, mode: "custom"});
}
});
return [...sources.values()];
};
const CallbackBrowserTreeSource = ({treeType, mode, store}) => {
const fromNow = React.useRef(getSkewedNow());
useQuery(rootBrowserTreeQuery, {
variables: {tree_type: treeType},
fetchPolicy: "no-cache",
onCompleted: (data) => store.mergeRows(treeType, mode, data.mythictree || [], {hydrated: true}),
});
useSubscription(mode === "process" ? processTreeSubscription : browserTreeSubscription, {
variables: mode === "process" ? {now: fromNow.current} : {now: fromNow.current, tree_type: treeType},
fetchPolicy: "no-cache",
ignoreResults: true,
onData: ({data}) => store.mergeRows(treeType, mode, data.data?.mythictree_stream || [], {notify: true}),
});
return null;
};
export const CallbackBrowserTreeProvider = ({openTabs, children}) => {
const storeRef = React.useRef();
if(!storeRef.current){storeRef.current = createCallbackBrowserTreeStore();}
const sources = React.useMemo(() => getTreeSources(openTabs), [openTabs]);
return (
<CallbackBrowserTreeContext.Provider value={storeRef.current}>
{sources.map((source) => (
<CallbackBrowserTreeSource key={source.treeType} {...source} store={storeRef.current} />
))}
{children}
</CallbackBrowserTreeContext.Provider>
);
};
const emptySubscribe = () => () => {};
export const useCallbackBrowserTree = ({treeType, mode, active, onInactiveChange}) => {
const store = React.useContext(CallbackBrowserTreeContext);
if(!store){throw new Error("useCallbackBrowserTree must be used inside CallbackBrowserTreeProvider");}
store.getEntry(treeType, mode);
const subscribe = React.useCallback(
(listener) => active ? store.subscribe(treeType, listener) : emptySubscribe(listener),
[active, store, treeType]
);
React.useSyncExternalStore(
subscribe,
React.useCallback(() => store.getSnapshot(treeType), [store, treeType]),
React.useCallback(() => store.getSnapshot(treeType), [store, treeType]),
);
React.useEffect(() => store.subscribeToChanges(treeType, () => {
if(!active){onInactiveChange?.();}
}), [active, onInactiveChange, store, treeType]);
const entry = store.getEntry(treeType, mode);
return {
treeRootDataRef: entry.dataRef,
treeAdjMtx: entry.matrix,
version: entry.version,
hydrated: entry.hydrated,
mergeRows: React.useCallback(
(rows, options) => store.mergeRows(treeType, mode, rows, options),
[mode, store, treeType]
),
setTreeAdjMtx: React.useCallback(
(matrix) => store.setMatrix(treeType, matrix),
[store, treeType]
),
isScopeLoaded: React.useCallback(
(scope) => store.isScopeLoaded(treeType, scope),
[store, treeType]
),
markScopeLoaded: React.useCallback(
(scope) => store.markScopeLoaded(treeType, scope),
[store, treeType]
),
};
};
export const getDefaultBrowserSelection = (treeData, matrix, callbackID, preferredHost) => {
const groups = Object.keys(matrix).sort();
if(groups.length === 0){return {group: "Default", host: preferredHost || ""};}
let callbackGroup;
for(const group of groups){
for(const hostNodes of Object.values(treeData[group] || {})){
for(const node of Object.values(hostNodes || {})){
if(node?.callback?.id === callbackID){callbackGroup = group; break;}
}
if(callbackGroup){break;}
}
if(callbackGroup){break;}
}
const group = callbackGroup || (groups.includes("Default") ? "Default" : groups[0]);
const hosts = Object.keys(matrix[group] || {}).sort();
return {group, host: hosts.includes(preferredHost) ? preferredHost : (hosts[0] || preferredHost || "")};
};
@@ -1,141 +0,0 @@
jest.mock("../../utilities/Time", () => ({getSkewedNow: () => "2026-01-01T00:00:00Z"}));
import {createCallbackBrowserTreeStore} from "./CallbackBrowserTreeStore";
const callback = {id: 7, display_id: 7, mythictree_groups: ["Default"]};
const row = (overrides = {}) => ({
id: 1,
task_id: 1,
timestamp: "2026-01-01T00:00:00Z",
deleted: false,
success: true,
comment: "first",
tags: [{id: 1, tagtype: {id: 1, name: "a", color: "red"}}],
filemeta: [],
host: "HOST",
can_have_children: true,
has_children: true,
full_path_text: "/",
name_text: "/",
parent_path_text: "",
tree_type: "file",
metadata: {size: 1, permissions: ["r"]},
callback,
...overrides,
});
describe("callback browser tree store", () => {
test("merges a streamed file update with the existing precedence", () => {
const store = createCallbackBrowserTreeStore();
store.mergeRows("file", "file", [row()], {hydrated: true});
store.mergeRows("file", "file", [row({
id: 2,
task_id: 2,
timestamp: "2026-01-01T00:00:01Z",
deleted: true,
success: false,
comment: "new",
tags: [{id: 2, tagtype: {id: 2, name: "b", color: "blue"}}],
metadata: {size: 2, access_time: 3, modify_time: 4, permissions: ["w"]},
})], {notify: true});
const node = store.getEntry("file").data.Default.HOST["/"];
expect(node.deleted).toBe(true);
expect(node.comment).toBe("new");
expect(node.tags.map((tag) => tag.id)).toEqual([1, 2]);
expect(node.metadata).toMatchObject({size: 2, access_time: 3, modify_time: 4, permissions: ["r", "w"]});
});
test("ignores exact replay and emits no duplicate notification", () => {
const store = createCallbackBrowserTreeStore();
const listener = jest.fn();
store.subscribeToChanges("file", listener);
const streamedRow = row();
store.mergeRows("file", "file", [streamedRow], {notify: true});
store.mergeRows("file", "file", [streamedRow], {notify: true});
expect(listener).toHaveBeenCalledTimes(1);
expect(store.getEntry("file").data.Default.HOST["/"].tags).toHaveLength(1);
});
test("deduplicates tags, callbacks, and file metadata while preserving newer values", () => {
const store = createCallbackBrowserTreeStore();
store.mergeRows("process", "process", [row({tree_type: "process", comment: "a"})]);
store.mergeRows("process", "process", [row({
id: 2,
timestamp: "2026-01-01T00:00:01Z",
tree_type: "process",
comment: "b",
})]);
const process = store.getEntry("process").data.Default.HOST["/"];
expect(process.callbacks).toHaveLength(1);
expect(process.tags).toHaveLength(1);
expect(process.comment).toBe("ab");
store.mergeRows("file", "file", [row({filemeta: [{id: 3, filename_text: ""}]})]);
store.mergeRows("file", "file", [row({
id: 3,
timestamp: "2026-01-01T00:00:02Z",
filemeta: [{id: 3, filename_text: ""}],
})]);
expect(store.getEntry("file").data.Default.HOST["/"].filemeta).toHaveLength(1);
});
test("does not decode an already-normalized filename again on later updates", () => {
const store = createCallbackBrowserTreeStore();
store.mergeRows("file", "file", [row({filemeta: [{id: 3, filename_text: "dGVzdA=="}]})]);
store.mergeRows("file", "file", [row({
id: 2,
task_id: 2,
timestamp: "2026-01-01T00:00:01Z",
filemeta: [],
})]);
expect(store.getEntry("file").data.Default.HOST["/"].filemeta[0].filename_text).toBe("test");
});
test("updates only the affected adjacency branches", () => {
const store = createCallbackBrowserTreeStore();
store.mergeRows("file", "file", [
row(),
row({id: 2, host: "OTHER", full_path_text: "C:\\", timestamp: "2026-01-01T00:00:01Z"}),
]);
const before = store.getEntry("file").matrix;
const untouchedHost = before.Default.OTHER;
store.mergeRows("file", "file", [row({
id: 3,
full_path_text: "/tmp",
parent_path_text: "/",
timestamp: "2026-01-01T00:00:02Z",
})]);
const after = store.getEntry("file").matrix;
expect(after).not.toBe(before);
expect(after.Default.OTHER).toBe(untouchedHost);
expect(after.Default.HOST["/"]["/tmp"]).toBe(1);
});
test("moves an existing node between parent buckets without rebuilding unrelated hosts", () => {
const store = createCallbackBrowserTreeStore();
store.mergeRows("custom", "custom", [row({
tree_type: "custom",
full_path_text: "/child",
parent_path_text: "/old",
})]);
store.mergeRows("custom", "custom", [row({
tree_type: "custom",
task_id: 2,
timestamp: "2026-01-01T00:00:01Z",
full_path_text: "/child",
parent_path_text: "/new",
})]);
const matrix = store.getEntry("custom").matrix.Default.HOST;
expect(matrix["/old"]["/child"]).toBeUndefined();
expect(matrix["/new"]["/child"]).toBe(1);
});
test("historical hydration does not emit a background notification", () => {
const store = createCallbackBrowserTreeStore();
const listener = jest.fn();
store.subscribeToChanges("custom", listener);
store.mergeRows("custom", "custom", [row({tree_type: "custom"})], {hydrated: true});
expect(listener).not.toHaveBeenCalled();
expect(store.getEntry("custom").hydrated).toBe(true);
});
});
@@ -35,7 +35,7 @@ mutation hideCallback ($callback_display_id: Int!, $active: Boolean!){
export const removeEdgeMutation = gql`
mutation removeEdgeMutation ($edge_id: Int!){
callbackgraphedgeRemove(edge_id: $edge_id) {
callbackgraphedge_remove(edge_id: $edge_id) {
status
error
}
@@ -44,7 +44,7 @@ mutation removeEdgeMutation ($edge_id: Int!){
export const addEdgeMutation = gql`
mutation addEdgeMutation ($source_id: Int!, $destination_id: Int!, $c2profile: String!){
callbackgraphedgeAdd(c2profile: $c2profile, destination_id: $destination_id, source_id: $source_id) {
callbackgraphedge_add(c2profile: $c2profile, destination_id: $destination_id, source_id: $source_id) {
status
error
}
@@ -97,7 +97,6 @@ export const taskingDataFragment = gql`
parent_task_id
agent_task_id
tasking_location
apitokens_id
callback {
display_id
id
@@ -165,7 +164,6 @@ export const taskingDataFragment = gql`
id
comment
agent_task_id
apitokens_id
commentOperator{
username
}
@@ -194,9 +192,6 @@ export const taskingDataFragment = gql`
cmd
supported_ui_features
id
payloadtype {
name
}
}
command_name
response_count
@@ -228,8 +223,8 @@ export const taskingDataFragment = gql`
}
`;
export const createTaskingMutation = gql`
mutation createTasking($callback_display_id: Int, $callback_display_ids: [Int], $command: String!, $params: String!, $files: [String], $token_id: Int, $tasking_location: String, $original_params: String, $parameter_group_name: String, $parent_task_display_id: Int, $is_interactive_task: Boolean, $interactive_task_type: Int, $payload_type: String, $resolve_task_references: Boolean) {
createTask(callback_display_id: $callback_display_id, callback_display_ids: $callback_display_ids, command: $command, params: $params, files: $files, token_id: $token_id, tasking_location: $tasking_location, original_params: $original_params, parameter_group_name: $parameter_group_name, parent_task_display_id: $parent_task_display_id, is_interactive_task: $is_interactive_task, interactive_task_type: $interactive_task_type, payload_type: $payload_type, resolve_task_references: $resolve_task_references) {
mutation createTasking($callback_id: Int, $callback_ids: [Int], $command: String!, $params: String!, $files: [String], $token_id: Int, $tasking_location: String, $original_params: String, $parameter_group_name: String, $parent_task_id: Int, $is_interactive_task: Boolean, $interactive_task_type: Int, $payload_type: String) {
createTask(callback_id: $callback_id, callback_ids: $callback_ids, command: $command, params: $params, files: $files, token_id: $token_id, tasking_location: $tasking_location, original_params: $original_params, parameter_group_name: $parameter_group_name, parent_task_id: $parent_task_id, is_interactive_task: $is_interactive_task, interactive_task_type: $interactive_task_type, payload_type: $payload_type) {
status
id
error
@@ -237,8 +232,8 @@ mutation createTasking($callback_display_id: Int, $callback_display_ids: [Int],
}
`;
export const exportCallbackConfigQuery = gql`
query exportCallbackConfigQuery($callback_display_id: Int!) {
exportCallbackConfig(callback_display_id: $callback_display_id) {
query exportCallbackConfigQuery($agent_callback_id: String!) {
exportCallbackConfig(agent_callback_id: $agent_callback_id) {
status
error
config
@@ -1,110 +0,0 @@
const EMPTY_CHILDREN = Object.freeze([]);
const comparableTaskFields = [
"comment", "completed", "display_params", "original_params", "status", "timestamp",
"response_count", "opsec_pre_blocked", "opsec_pre_bypassed", "opsec_post_blocked",
"opsec_post_bypassed", "has_intercepted_response", "tasking_location",
];
const relationIDsEqual = (left = [], right = []) => {
if(left.length !== right.length){
return false;
}
for(let index = 0; index < left.length; index++){
if(left[index]?.id !== right[index]?.id){
return false;
}
}
return true;
};
export const taskRowsEqual = (left, right) => {
if(left === right){
return true;
}
if(!left || !right || left.id !== right.id){
return false;
}
for(const field of comparableTaskFields){
if(left[field] !== right[field]){
return false;
}
}
return left.command?.id === right.command?.id &&
left.operator?.username === right.operator?.username &&
left.commentOperator?.username === right.commentOperator?.username &&
relationIDsEqual(left.tags, right.tags) &&
relationIDsEqual(left.tasks, right.tasks);
};
export const mergeTasksByID = (existing = [], incoming = []) => {
if(incoming.length === 0){
return existing;
}
const tasksByID = new Map(existing.map((task) => [task.id, task]));
incoming.forEach((task) => {
const current = tasksByID.get(task.id);
tasksByID.set(task.id, taskRowsEqual(current, task) ? current : task);
});
const merged = Array.from(tasksByID.values()).sort((left, right) => left.id - right.id);
if(merged.length === existing.length && merged.every((task, index) => task === existing[index])){
return existing;
}
return merged;
};
export const nextLiveTaskLimit = (currentLimit, liveResultCount, increment = 20) => {
return liveResultCount >= currentLimit ? currentLimit + increment : currentLimit;
};
export const createTaskChildrenStore = () => {
const childrenByParent = new Map();
const listenersByParent = new Map();
let highestTaskID = 0;
const notify = (parentID) => {
listenersByParent.get(parentID)?.forEach((listener) => listener());
};
const merge = (tasks = []) => {
const incomingByParent = new Map();
tasks.forEach((task) => {
if(task?.parent_task_id === null || task?.parent_task_id === undefined){
return;
}
highestTaskID = Math.max(highestTaskID, task.id || 0);
const parentTasks = incomingByParent.get(task.parent_task_id) || [];
parentTasks.push(task);
incomingByParent.set(task.parent_task_id, parentTasks);
});
const changedParents = [];
incomingByParent.forEach((incoming, parentID) => {
const current = childrenByParent.get(parentID) || EMPTY_CHILDREN;
const merged = mergeTasksByID(current, incoming);
if(merged !== current){
childrenByParent.set(parentID, merged);
changedParents.push(parentID);
}
});
changedParents.forEach(notify);
return changedParents;
};
return {
getHighestTaskID: () => highestTaskID,
getSnapshot: (parentID) => childrenByParent.get(parentID) || EMPTY_CHILDREN,
merge,
subscribe: (parentID, listener) => {
const listeners = listenersByParent.get(parentID) || new Set();
listeners.add(listener);
listenersByParent.set(parentID, listeners);
return () => {
listeners.delete(listener);
if(listeners.size === 0){
listenersByParent.delete(parentID);
}
};
},
};
};
@@ -1,45 +0,0 @@
import {createTaskChildrenStore, mergeTasksByID, nextLiveTaskLimit} from "./CallbackTaskingStreamUtils";
describe("callback task stream utilities", () => {
test("merges tasks by id while preserving unchanged row identity", () => {
const first = {id: 1, status: "complete", tasks: [], tags: [], command: {id: 1}, operator: {username: "a"}};
const second = {id: 2, status: "running", tasks: [], tags: [], command: {id: 2}, operator: {username: "a"}};
const merged = mergeTasksByID([first, second], [
{...first},
{...second, status: "complete"},
{id: 3, status: "submitted", tasks: [], tags: [], command: {id: 3}, operator: {username: "a"}},
]);
expect(merged.map((task) => task.id)).toEqual([1, 2, 3]);
expect(merged[0]).toBe(first);
expect(merged[1]).not.toBe(second);
});
test("grows the live limit only when the live result window is saturated", () => {
expect(nextLiveTaskLimit(20, 19)).toBe(20);
expect(nextLiveTaskLimit(20, 20)).toBe(40);
expect(nextLiveTaskLimit(40, 40)).toBe(60);
});
test("groups children, deduplicates replay, and notifies only changed parents", () => {
const store = createTaskChildrenStore();
const parentOneListener = jest.fn();
const parentTwoListener = jest.fn();
store.subscribe(10, parentOneListener);
store.subscribe(20, parentTwoListener);
const child = {id: 2, parent_task_id: 10, status: "running", tasks: [], tags: []};
store.merge([child, {id: 3, parent_task_id: 20, status: "complete", tasks: [], tags: []}]);
expect(store.getSnapshot(10)).toEqual([child]);
expect(parentOneListener).toHaveBeenCalledTimes(1);
expect(parentTwoListener).toHaveBeenCalledTimes(1);
store.merge([{...child}]);
expect(parentOneListener).toHaveBeenCalledTimes(1);
store.merge([{...child, status: "complete"}]);
expect(parentOneListener).toHaveBeenCalledTimes(2);
expect(parentTwoListener).toHaveBeenCalledTimes(1);
expect(store.getHighestTaskID()).toBe(3);
});
});
@@ -1,4 +1,3 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import React, { useEffect } from 'react';
import { CallbacksTabs } from './CallbacksTabs';
import TocIcon from '@mui/icons-material/Toc';
@@ -10,8 +9,8 @@ import {MythicDialog} from "../../MythicComponents/MythicDialog";
import {ImportCallbackConfigDialog} from "./ImportCallbackConfigDialog";
import {reorder} from "../../MythicComponents/MythicDraggableList";
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import {useTheme} from '@mui/material/styles';
export const getCallbackIdFromClickedTab = (tabId) => {
if(tabId === null || tabId === undefined){return 0}
@@ -33,7 +32,6 @@ export const getCallbackIdFromClickedTab = (tabId) => {
}
export function Callbacks({me}) {
const theme = useTheme();
const [openCallbackImport, setOpenCallbackImport] = React.useState(false);
const [topDisplay, setTopDisplay] = React.useState('table');
const [newDataForTab, setNewDataForTab] = React.useState({});
@@ -217,60 +215,34 @@ export function Callbacks({me}) {
minSize={[0,0]}
onDragEnd={(sizes) => localStorage.setItem('callbackTableSplitSizes', JSON.stringify(sizes))}
style={{ height: "100%" }}>
<div style={{display: "flex", flexDirection: "row-reverse", height: "100%", minWidth: 0, overflow: "hidden"}}>
<Paper elevation={0} style={{
width: "2.25rem",
display: "flex",
gap: "0.25rem",
flexDirection: "column",
alignItems: "center",
overflow: "hidden",
backgroundColor: theme.palette.background.paper,
borderTop: `1px solid ${theme.borderColor}`,
borderRight: 0,
borderBottom: `1px solid ${theme.borderColor}`,
borderLeft: `1px solid ${theme.borderColor}`,
borderRadius: `${theme.shape.borderRadius}px 0 0 ${theme.shape.borderRadius}px`,
paddingTop: "3px",
}}>
<div style={{display: "flex", flexDirection: "row-reverse"}}>
<Paper elevation={5} style={{width: "30px", display: "flex", flexDirection: "column", alignItems: "center", overflow: "hidden",
backgroundColor: "transparent"}}>
{topDisplay !== 'table' &&
<MythicActionButton
iconOnly
appearance={"raised"}
shape={"square"}
variant={"outlined"}
icon={<TocIcon />}
tooltip={"Table View"}
onClick={() =>setTopDisplay("table")}>
</MythicActionButton>
<MythicStyledTooltip title={"Table View"}>
<IconButton onClick={() =>setTopDisplay("table")}>
<TocIcon />
</IconButton>
</MythicStyledTooltip>
}
{topDisplay !== 'graph' &&
<MythicActionButton
iconOnly
appearance={"raised"}
shape={"square"}
variant={"outlined"}
icon={<AssessmentIcon />}
tooltip={"Graph View"}
onClick={() =>setTopDisplay("graph")}>
</MythicActionButton>
<MythicStyledTooltip title={"Graph View"} >
<IconButton onClick={() =>setTopDisplay("graph")}>
<AssessmentIcon />
</IconButton>
</MythicStyledTooltip>
}
<MythicActionButton
appearance={"raised"}
iconOnly
shape={"square"}
colorMode={"hover"}
tone={"neutral"}
icon={<PhoneForwardedIcon />}
tooltip={"Import previously exported Callbacks"}
onClick={() =>setOpenCallbackImport(true)}>
</MythicActionButton>
{openCallbackImport &&
<MythicDialog fullWidth={true} maxWidth="sm" open={openCallbackImport}
onClose={()=>{setOpenCallbackImport(false);}}
innerDialog={<ImportCallbackConfigDialog onClose={()=>{setOpenCallbackImport(false);}} />}
/>
}
<MythicStyledTooltip title={"Import previously exported Callbacks"} >
<IconButton onClick={() =>setOpenCallbackImport(true)}>
<PhoneForwardedIcon />
</IconButton>
</MythicStyledTooltip>
</Paper>
<CallbacksTop
callbackTableGridRef={callbackTableGridRef}
@@ -298,4 +270,4 @@ export function Callbacks({me}) {
</Split>
</>
);
}
}
@@ -1,8 +1,10 @@
import React, {useEffect, useState, useMemo, useContext} from 'react';
import {DrawC2PathElementsFlowWithProvider} from './C2PathDialog';
import {Button} from '@mui/material';
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
import ButtonGroup from '@mui/material/ButtonGroup';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import MenuItem from '@mui/material/MenuItem';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import {useMutation } from '@apollo/client';
import {hideCallbackMutation, removeEdgeMutation, addEdgeMutation} from './CallbackMutations';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
@@ -17,10 +19,10 @@ import OutlinedInput from '@mui/material/OutlinedInput';
import InputLabel from '@mui/material/InputLabel';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import {CallbackGraphEdgesContext, CallbacksContext} from './CallbacksTop';
import {CallbackGraphEdgesContext, CallbacksContext, OnOpenTabContext} from './CallbacksTop';
import {Dropdown, DropdownMenuItem} from "../../MythicComponents/MythicNestedMenus";
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {operatorSettingDefaults} from "../../../cache";
import {useTaskReferenceSubmitter} from './taskingReferences';
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 1;
@@ -58,10 +60,12 @@ query loadedLinkCommandsQuery ($callback_id: Int!){
const GraphViewOptions = ({viewConfig, setViewConfig}) => {
const theme = useTheme();
const dropdownAnchorRef = React.useRef(null);
const [dropdownOpen, setDropdownOpen] = React.useState(false);
const [showConfiguration, setShowConfiguration] = React.useState(false);
const labelComponentOptions = ["display_id", "user", "host", "ip", "domain", "os", "process_name"];
const [selectedComponentOptions, setSelectedComponentOptions] = React.useState(viewConfig?.label_components || ["display_id", "user"]);
const [selectedGroupBy, setSelectedGroupBy] = React.useState(viewConfig?.group_by || "None");
const [selectedComponentOptions, setSelectedComponentOptions] = React.useState(["display_id", "user"]);
const [selectedGroupBy, setSelectedGroupBy] = React.useState("None");
const groupByOptions = ["host", "user", "ip", "domain", "os", "process_name", "None"];
const handleChange = (event) => {
const {
@@ -81,108 +85,120 @@ const GraphViewOptions = ({viewConfig, setViewConfig}) => {
useEffect( () => {
setViewConfig({...viewConfig, group_by: selectedGroupBy});
}, [selectedGroupBy])
const toggleViewConfig = (field) => {
setViewConfig({...viewConfig, [field]: !viewConfig[field]});
}
const toggleRankDirection = () => {
setViewConfig({...viewConfig, rankDir: viewConfig["rankDir"] === "LR" ? "TB" : "LR"});
}
const handleDropdownToggle = (evt) => {
evt.stopPropagation();
setDropdownOpen((prevOpen) => !prevOpen);
};
const handleMenuItemClick = (event, click) => {
click();
setDropdownOpen(false);
};
const options = [
{name: showConfiguration ? "Hide Grouping Options": "Show Grouping Options", click: () => {
setShowConfiguration(!showConfiguration);
}},
{name: viewConfig["include_disconnected"] ? 'Show Only Active Edges' : "Show All Edges", click: () => {
const view = {...viewConfig, include_disconnected: !viewConfig["include_disconnected"]};
setViewConfig(view);
}},
{name: viewConfig["show_all_nodes"] ? 'Hide inactive callbacks' : 'Show All Callbacks', click: () => {
const view = {...viewConfig, show_all_nodes: !viewConfig["show_all_nodes"]};
setViewConfig(view);
}},
{name: viewConfig["rankDir"] === "LR" ? 'Change Layout to Top-Bottom' : "Change Layout to Left-Right", click: () => {
if(viewConfig["rankDir"] === "LR"){
const view = {...viewConfig, rankDir: "TB"};
setViewConfig(view);
}else{
const view = {...viewConfig, rankDir: "LR"};
setViewConfig(view);
}
}},
{name: viewConfig["packet_flow_view"] ? "View Connection Directions" : "View Egress Routes" , click: () => {
const view = {...viewConfig, packet_flow_view: !viewConfig["packet_flow_view"]};
setViewConfig(view);
}},
];
const handleClose = (event) => {
if (dropdownAnchorRef.current && dropdownAnchorRef.current.contains(event.target)) {
return;
}
setDropdownOpen(false);
};
return (
<div className="mythic-callback-graph-options flex flex-column gap-4">
<Button
size="small"
className="mythic-callback-graph-options-toggle rounded"
onClick={() => setShowConfiguration(!showConfiguration)}
>
{showConfiguration ? "Hide Graph Options" : "Graph Options"}
</Button>
<div >
<ButtonGroup variant="contained" ref={dropdownAnchorRef} aria-label="split button" style={{marginTop: "0px", marginLeft: "25px"}} color="primary">
<Button size="small" color="primary" aria-controls={dropdownOpen ? 'split-button-menu' : undefined}
aria-expanded={dropdownOpen ? 'true' : undefined}
aria-haspopup="menu"
onClick={handleDropdownToggle}>
Configure Graph View <ArrowDropDownIcon />
</Button>
</ButtonGroup>
{showConfiguration &&
<div className="mythic-callback-graph-options-panel flex flex-column gap-5 min-w-0 rounded bg-surface border-subtle">
<div className="mythic-callback-graph-options-actions flex flex-wrap gap-3 min-w-0">
<MythicActionButton
size="small"
className="mythic-callback-graph-option-button"
colorMode={!viewConfig["include_disconnected"] ? "always" : "hover"}
tone={!viewConfig["include_disconnected"] ? "success" : "neutral"}
onClick={() => toggleViewConfig("include_disconnected")}
>
{viewConfig["include_disconnected"] ? "All Edges" : "Active Edges"}
</MythicActionButton>
<MythicActionButton
size="small"
className="mythic-callback-graph-option-button"
colorMode={viewConfig["show_all_nodes"] ? "always" : "hover"}
tone={viewConfig["show_all_nodes"] ? "warning" : "neutral"}
onClick={() => toggleViewConfig("show_all_nodes")}
>
{viewConfig["show_all_nodes"] ? "All Callbacks" : "Connected Callbacks"}
</MythicActionButton>
<Button
size="small"
className="mythic-callback-graph-option-button rounded"
onClick={toggleRankDirection}
>
{viewConfig["rankDir"] === "LR" ? "Left to Right" : "Top to Bottom"}
</Button>
<Button
size="small"
className="mythic-callback-graph-option-button rounded"
onClick={() => toggleViewConfig("packet_flow_view")}
>
{viewConfig["packet_flow_view"] ? "Egress Routes" : "Connection Directions"}
</Button>
</div>
<div className="mythic-callback-graph-options-fields gap-5 min-w-0 grid">
<FormControl size="small" className="mythic-callback-graph-options-field">
<InputLabel id="callback-graph-group-label">Group By</InputLabel>
<Select
labelId="callback-graph-group-label"
id="callback-graph-group"
value={selectedGroupBy}
onChange={handleGroupByChange}
input={<OutlinedInput id="select-callback-graph-group" label="Group By" />}
<FormControl sx={{ width: 200, backgroundColor: theme.palette.background.paper}} >
<InputLabel id="demo-chip-label">Group Callbacks By</InputLabel>
<Select
labelId="demo-chip-label"
id="demo-chip"
value={selectedGroupBy}
onChange={handleGroupByChange}
input={<OutlinedInput id="select-chip" label="Group Callbacks By" />}
>
{groupByOptions.map((name) => (
<MenuItem
key={name}
value={name}
>
{groupByOptions.map((name) => (
<MenuItem
key={name}
value={name}
>
{name}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" className="mythic-callback-graph-options-field mythic-callback-graph-options-field-wide">
<InputLabel id="callback-graph-label-components-label">Callback Labels</InputLabel>
<Select
labelId="callback-graph-label-components-label"
id="callback-graph-label-components"
multiple
value={selectedComponentOptions}
onChange={handleChange}
input={<OutlinedInput id="select-callback-graph-label-components" label="Callback Labels" />}
MenuProps={MenuProps}
renderValue={(selected) => selected.join(", ")}
>
{labelComponentOptions.map((name) => (
<MenuItem
key={name}
value={name}
style={getStyles(name, selectedComponentOptions, theme)}
>
{name}
</MenuItem>
))}
</Select>
</FormControl>
</div>
{viewConfig["show_all_nodes"] &&
<div className="mythic-callback-graph-options-warning text-xs rounded mythic-tone-warning bg-tone-1 border border-tone-2 text-tone">
Showing all callbacks can be slow in large operations.
</div>
}
</div>
{name}
</MenuItem>
))}
</Select>
</FormControl>
}
{showConfiguration &&
<FormControl sx={{minWidth: 300, backgroundColor: theme.palette.background.paper}}>
<InputLabel id="demo-multiple-chip-label">Display Properties per Callback</InputLabel>
<Select
labelId="demo-multiple-chip-label"
id="demo-multiple-chip"
multiple
value={selectedComponentOptions}
onChange={handleChange}
input={<OutlinedInput id="select-multiple-chip" label="Display Properties per Callback" />}
MenuProps={MenuProps}
>
{labelComponentOptions.map((name) => (
<MenuItem
key={name}
value={name}
style={getStyles(name, selectedComponentOptions, theme)}
>
{name}
</MenuItem>
))}
</Select>
</FormControl>
}
<ClickAwayListener onClickAway={handleClose}>
<Dropdown
isOpen={dropdownAnchorRef.current}
onOpen={setDropdownOpen}
externallyOpen={dropdownOpen}
menu={
options.map((option, index) => (
<DropdownMenuItem
key={option.name}
disabled={option.disabled}
onClick={(event) => handleMenuItemClick(event, option.click)}
>
{option.name}
</DropdownMenuItem>
))
}
/>
</ClickAwayListener>
</div>
)
}
@@ -233,18 +249,17 @@ export function CallbacksGraph({onOpenTab}){
}
});
const {submitTask, dialog: taskReferenceSubmitDialog} = useTaskReferenceSubmitter(createTask);
const submitParametersDialog = (cmd, parameters, files) => {
setOpenParametersDialog(false);
submitTask({variables: {callback_display_id: selectedCallback.display_id, command: cmd, params: parameters, files}});
createTask({variables: {callback_id: selectedCallback.id, command: cmd, params: parameters, files}});
}
const [viewConfig, setViewConfig] = React.useState({
rankDir: "TB",
label_components: ["display_id", "user"],
packet_flow_view: true,
include_disconnected: false,
include_disconnected: true,
show_all_nodes: false,
group_by: "host"
group_by: "None"
});
const [hideCallback] = useMutation(hideCallbackMutation, {
update: (cache, {data}) => {
@@ -299,20 +314,19 @@ export function CallbacksGraph({onOpenTab}){
}
const contextMenu = useMemo(() => {return [
{
title: 'Open Tasking',
title: 'Hide Callback',
onClick: function(node) {
hideCallback({variables: {callback_display_id: node.display_id}});
}
},
{
title: 'Interact',
onClick: function(node){
onOpenTab({tabType: "interact", tabID: node.callback_id + "interact", callbackID: node.callback_id});
}
},
{
title: "Add P2P Edge",
onClick: function(node){
setAddEdgeSource(node);
setManuallyAddEdgeDialogOpen(true);
}
},
{
title: "Remove Active Edge",
title: "Manually Remove Edge",
onClick: function(node){
const opts = callbackgraphedges.reduce( (prev, e) => {
if(e.source.id === node.id || e.destination.id === node.id){
@@ -334,8 +348,15 @@ export function CallbacksGraph({onOpenTab}){
setManuallyRemoveEdgeDialogOpen(true);
}
},
{
title: "Manually Add Edge",
onClick: function(node){
setAddEdgeSource(node);
setManuallyAddEdgeDialogOpen(true);
}
},
{
title: "Task Link Command",
title: "Task Callback for Edge",
onClick: function(node){
setLinkCommands([]);
setSelectedLinkCommand(null);
@@ -344,13 +365,7 @@ export function CallbacksGraph({onOpenTab}){
setSelectedCallback(node);
}
},
{
title: 'Hide Callback',
onClick: function(node) {
hideCallback({variables: {callback_display_id: node.display_id}});
}
},
]}, [getLinkCommands, hideCallback, callbackgraphedges, onOpenTab]);
]}, [getLinkCommands, hideCallback, viewConfig, onOpenTab]);
React.useEffect( () => {
try {
const storageItemOptions = GetMythicSetting({setting_name: "callbacks_table_filter_options", default_value: operatorSettingDefaults.callbacks_table_filters});
@@ -377,7 +392,6 @@ export function CallbacksGraph({onOpenTab}){
}
return (
<div style={{maxWidth: "100%", "overflow": "hidden", height: "100%"}}>
{taskReferenceSubmitDialog}
{manuallyRemoveEdgeDialogOpen &&
<MythicDialog fullWidth={true} maxWidth="sm" open={manuallyRemoveEdgeDialogOpen}
@@ -399,7 +413,7 @@ export function CallbacksGraph({onOpenTab}){
{openParametersDialog &&
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
onClose={()=>{setOpenParametersDialog(false);}}
innerDialog={<TaskParametersDialog command={selectedLinkCommand} callback={selectedCallback} callback_display_id={selectedCallback?.display_id} onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}} />}
innerDialog={<TaskParametersDialog command={selectedLinkCommand} callback={selectedCallback} onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}} />}
/>
}
{openSelectLinkCommandDialog &&
@@ -416,3 +430,4 @@ export function CallbacksGraph({onOpenTab}){
</div>
);
}
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useContext} from 'react';
import {MythicDialog} from '../../MythicComponents/MythicDialog';
import {MythicDialog, MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import {
exportCallbackConfigQuery,
hideCallbackMutation, lockCallbackMutation, unlockCallbackMutation, updateCallbackTriggerMutation,
@@ -23,6 +23,7 @@ import {CallbacksTabsHideMultipleDialog, CallbacksTabsSelectMultipleDialog} from
import {CallbacksTabsTaskMultipleDialog} from "./CallbacksTabsTaskMultipleDialog";
import ip6 from 'ip6';
import {CallbacksContext, OnOpenTabContext, OnOpenTabsContext} from "./CallbacksTop";
import {useTheme} from '@mui/material/styles';
import {GetMythicSetting, useSetMythicSetting, GetComputedFontSize} from "../../MythicComponents/MythicSavedUserSetting";
import {DetailedCallbackTable} from "./DetailedCallbackTable";
import {ModifyCallbackMythicTreeGroupsDialog} from "./ModifyCallbackMythicTreeGroupsDialog";
@@ -33,7 +34,6 @@ import WidgetsIcon from '@mui/icons-material/Widgets';
import TerminalIcon from '@mui/icons-material/Terminal';
import ImportExportIcon from '@mui/icons-material/ImportExport';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import {downloadFileFromMemory} from '../../utilities/Clipboard';
import EditIcon from '@mui/icons-material/Edit';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
@@ -53,24 +53,16 @@ import NotificationsOffTwoToneIcon from '@mui/icons-material/NotificationsOffTwo
import {CallbacksTableEditTriggerOnCheckinDialog} from "./CallbacksTableEditTriggerOnCheckinDialog";
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
import PlayCircleFilledTwoToneIcon from '@mui/icons-material/PlayCircleFilledTwoTone';
import Typography from '@mui/material/Typography';
import {EventTriggerContextSelectDialog} from "../Eventing/EventTriggerContextSelect";
import {useMythicLazyQuery} from "../../utilities/useMythicLazyQuery";
import {faFilter} from '@fortawesome/free-solid-svg-icons';
import {getReadableTextColor, isValidHexColor} from "../../MythicComponents/MythicColorInput";
import {
GridColumnFilterDialog,
getUpdatedGridFilterOptions,
gridValuePassesFilter,
isGridColumnFilterActive
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
import {MythicConfirmDialog} from "../../MythicComponents/MythicConfirmDialog";
export const getCustomBrowsersQuery = gql(`
query getCustomBrowsersQuery{
custombrowser(where: {deleted: {_eq: false}}, order_by: {name: asc}){
id
name
display_name
type
separator
columns
@@ -122,7 +114,7 @@ export const ipCompare = (a, b) => {
}
}
const callbackTableInitialColumns = [
{key: "id", type: 'number', name: "Interact", width: 175, disableDoubleClick: true},
{key: "id", type: 'number', name: "Interact", width: 150, disableDoubleClick: true},
{key: "mythictree_groups", type: 'array', name: "Groups", width: 150},
{key: "ip", type: 'ip', name: "IP", width: 150},
{key: "external_ip",type: 'string', name: "External IP", width: 150},
@@ -140,20 +132,12 @@ const callbackTableInitialColumns = [
{key: "process_short_name", type: 'string', name: "Process Name", fillWidth: true},
{key: "tags", type: 'tags', name: "Tags", fillWidth: false, disableDoubleClick: true, width: 150}
];
const getCallbackRowStyle = (row) => {
if(!isValidHexColor(row.color)){
return {};
}
return {
backgroundColor: row.color,
color: getReadableTextColor(row.color),
};
}
function CallbacksTablePreMemo(props){
const callbacks = useContext(CallbacksContext);
const onOpenTab = useContext(OnOpenTabContext);
const onOpenTabs = useContext(OnOpenTabsContext);
const interactType = GetMythicSetting({setting_name: "interactType", default_value: "interact", output: "string"});
const theme = useTheme();
const [loadingSettings, setLoadingSettings] = React.useState(true);
const [columnOrder, setColumnOrder] = React.useState(callbackTableInitialColumns);
const [openReorderDialog, setOpenReorderDialog] = React.useState(false);
@@ -171,7 +155,6 @@ function CallbacksTablePreMemo(props){
setOpenEditMythicTreeGroupsDialog(true);
}
const [openEditDescriptionDialog, setOpenEditDescriptionDialog] = React.useState(false);
const [callbackLocalUpdates, setCallbackLocalUpdates] = React.useState({});
const [updateDescriptionMutation] = useMutation(updateDescriptionCallbackMutation, {
update: (cache, {data}) => {
if(data.updateCallback.status === "success"){
@@ -187,16 +170,6 @@ function CallbacksTablePreMemo(props){
}
});
const updateDescriptionRef = React.useRef({payload_description: "", callback_display_id: 0, description: "", color: ""});
const applyCallbackLocalUpdate = React.useCallback(({callback_display_id, description, color}) => {
setCallbackLocalUpdates((currentUpdates) => ({
...currentUpdates,
[String(callback_display_id)]: {description, color},
}));
const callbackIndex = callbacks.findIndex((callback) => callback.display_id === callback_display_id);
if(callbackIndex !== -1){
callbacks[callbackIndex] = {...callbacks[callbackIndex], description, color};
}
}, [callbacks]);
const updateDescription = ({payload_description, callback_display_id, description, color}) => {
updateDescriptionRef.current = {
payload_description: payload_description,
@@ -223,26 +196,8 @@ function CallbacksTablePreMemo(props){
}
}
const updateDescriptionSubmit = React.useCallback( ({callback_display_id, description, color}) => {
const previousCallback = callbacks.find((callback) => callback.display_id === callback_display_id);
applyCallbackLocalUpdate({callback_display_id, description, color});
updateDescriptionMutation({variables: {callback_display_id: callback_display_id, description, color}}).then(({data}) => {
if(data?.updateCallback?.status !== "success" && previousCallback){
applyCallbackLocalUpdate({
callback_display_id,
description: previousCallback.description,
color: previousCallback.color,
});
}
}).catch(() => {
if(previousCallback){
applyCallbackLocalUpdate({
callback_display_id,
description: previousCallback.description,
color: previousCallback.color,
});
}
});
}, [applyCallbackLocalUpdate, callbacks, updateDescriptionMutation]);
updateDescriptionMutation({variables: {callback_display_id: callback_display_id, description, color}})
}, []);
const [openCallbackDropdown, setOpenCallbackDropdown] = React.useState(false);
const callbackDropdownRef = React.useRef({options: [], callback: {}});
const [clickedCallbackID, setClickedCallbackId] = React.useState(0);
@@ -264,11 +219,9 @@ function CallbacksTablePreMemo(props){
const [openContextMenu, setOpenContextMenu] = React.useState(false);
const [openHideMultipleDialog, setOpenHideMultipleDialog] = React.useState(false);
const [openTriggerDialog, setOpenTriggerDialog] = React.useState({open: false, trigger_on_checkin_after_time: 0, display_id: 0});
const [openUnlockDialog, setOpenUnlockDialog] = React.useState({open: false, display_id: 0, lockOwner: ""});
const [openTaskMultipleDialog, setOpenTaskMultipleDialog] = React.useState({open: false, data: {}});
const [filterOptions, setFilterOptions] = React.useState({});
const [selectedColumn, setSelectedColumn] = React.useState({});
const [virtualizedTablePadding, setVirtualizedTablePadding] = React.useState(8);
const [columnVisibility, setColumnVisibility] = React.useState(() => {
let defaults = {"visible": ["Interact", "Host", "Domain", "User", "Description", "Last Checkin", "Agent", "IP", "PID"],
"hidden": ["Arch", "Sleep", "Process Name", "External IP", "C2", "OS", "Groups", "Tags"]}
@@ -359,7 +312,20 @@ function CallbacksTablePreMemo(props){
fetchPolicy: "no-cache",
onCompleted: (data) => {
if(data.exportCallbackConfig.status === "success"){
downloadFileFromMemory(data.exportCallbackConfig.config, "exported_callback.json", "text/plain");
const dataBlob = new Blob([data.exportCallbackConfig.config], {type: 'text/plain'});
const ele = document.getElementById("download_config");
if(ele !== null){
ele.href = URL.createObjectURL(dataBlob);
ele.download = "exported_callback.json";
ele.click();
}else{
const element = document.createElement("a");
element.id = "download_config";
element.href = URL.createObjectURL(dataBlob);
element.download = "exported_callback.json";
document.body.appendChild(element);
element.click();
}
}else{
snackActions.error("Failed to export configuration: " + data.exportCallbackConfig.error);
}
@@ -375,71 +341,27 @@ function CallbacksTablePreMemo(props){
const eventingDataRef = React.useRef({});
const [openEventingDialog, setOpenEventingDialog] = React.useState(false);
const [openEventingMultipleDialog, setOpenEventingMultipleDialog] = React.useState(false);
const [updateSetting, updateSettings] = useSetMythicSetting();
const [updateSetting] = useSetMythicSetting();
const onUpdateTrigger = (newTriggerValue) => {
updateTrigger({variables: {callback_display_id: openTriggerDialog.display_id, trigger_on_checkin_after_time: newTriggerValue}})
setOpenTriggerDialog({...openTriggerDialog, open: false});
}
const onOpenTriggerDialog = React.useCallback((callback) => {
setOpenTriggerDialog({
open: true,
trigger_on_checkin_after_time: callback.trigger_on_checkin_after_time,
display_id: callback.display_id,
});
}, []);
const onOpenUnlockDialog = React.useCallback((callback) => {
setOpenUnlockDialog({
open: true,
display_id: callback.display_id,
lockOwner: callback.locked_operator?.username || "unknown operator",
});
}, []);
const onCloseUnlockDialog = React.useCallback(() => {
setOpenUnlockDialog({open: false, display_id: 0, lockOwner: ""});
}, []);
const onSubmitUnlockDialog = React.useCallback(() => {
if(openUnlockDialog.display_id > 0){
unlockCallback({variables: {callback_display_id: openUnlockDialog.display_id}});
}
}, [openUnlockDialog.display_id, unlockCallback]);
async function getCustomBrowsersForContextMenu(){
return await getCustomBrowsers({}).then(result => {return result.data?.custombrowser});
}
const callbackMenuIcon = (icon, intent = "neutral") => (
<span className={`mythic-callback-action-menu-icon bg-tone-1 text-tone text-sm leading-100 items-center inline-flex flex-none justify-center mythic-tone-${intent} rounded`}>
{icon}
</span>
);
const callbackMenuLabel = (primary, secondary) => (
<span className="mythic-callback-action-menu-label flex flex-column gap-1 min-w-0">
<span className="mythic-callback-action-menu-label-primary text-primary text-sm font-800 leading-120">{primary}</span>
{secondary &&
<span className="mythic-callback-action-menu-label-secondary text-muted text-xs font-600 leading-125 truncate whitespace-nowrap">{secondary}</span>
}
</span>
);
const callbackMenuSection = (label) => ({
name: <span className="mythic-callback-action-menu-section-label text-2xs font-850 leading-120 text-muted">{label}</span>,
icon: null,
click: ({event}) => {},
type: "item",
disabled: true,
className: "mythic-callback-action-menu-section"
});
async function onRowContextClick({rowDataStatic}) {
// based on row, return updated options array?
let defaultInteractIcon = <KeyboardIcon fontSize="small"/>;
let defaultInteractIcon = <KeyboardIcon style={{paddingRight: "5px"}}/>;
if(interactType === "interactSplit"){
defaultInteractIcon = <VerticalSplitIcon fontSize="small"/>;
defaultInteractIcon = <VerticalSplitIcon style={{paddingRight: "5px"}}/>;
} else if(interactType === "interactConsole"){
defaultInteractIcon = <TerminalIcon fontSize="small"/>;
defaultInteractIcon = <TerminalIcon style={{paddingRight: "5px"}}/>;
}
let customBrowsers = await getCustomBrowsersForContextMenu();
customBrowsers = customBrowsers.map(b => {
return {
name: b.display_name === "" ? b.name : b.display_name,
icon: callbackMenuIcon(b.type === 'file' ? <FontAwesomeIcon icon={faFolderOpen} /> : <AccountTreeIcon fontSize="small" />),
className: "mythic-callback-action-menu-item rounded",
name: b.name,
icon: b.type === 'file' ? <FontAwesomeIcon icon={faFolderOpen} style={{color: theme.folderColor, cursor: "pointer", marginRight: "10px"}} /> : <AccountTreeIcon style={{paddingRight: "5px"}}/>,
click: ({event}) => {
event.preventDefault();
event.stopPropagation();
@@ -457,15 +379,20 @@ function CallbacksTablePreMemo(props){
})
return [
{
name: callbackMenuLabel("Callback " + rowDataStatic.display_id, rowDataStatic.user + "@" + rowDataStatic.host),
name: <div style={{display: "flex", flexDirection: "column"}}>
<Typography>
{"Callback: " + rowDataStatic.display_id}
</Typography>
<Typography style={{fontSize: theme?.typography.pxToRem(12),}}>
{rowDataStatic.user + "@" + rowDataStatic.host}
</Typography>
</div>,
icon: null, click: ({event}) => {},
type: "item",
disabled: true,
className: "mythic-callback-action-menu-header"
disabled: true
},
callbackMenuSection("Tasking"),
{
name: "Interact", icon: callbackMenuIcon(defaultInteractIcon, "primary"), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
name: "Interact", icon: defaultInteractIcon, click: ({event}) => {
event.stopPropagation();
const tabType = interactType;
onOpenTab({
@@ -477,55 +404,7 @@ function CallbacksTablePreMemo(props){
}, type: "item"
},
{
name: "Tasking Views", icon: callbackMenuIcon(<VerticalSplitIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item rounded",
menuItems: [
{
name: 'Default Tasking', icon: callbackMenuIcon(<KeyboardIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
event.stopPropagation();
const tabType = "interact";
onOpenTab({
tabType: tabType,
tabID: rowDataStatic.id + tabType,
callbackID: rowDataStatic.id,
color: rowDataStatic.color,
displayID: rowDataStatic.display_id});
}
},
{
name: 'Split Tasking', icon: callbackMenuIcon(<VerticalSplitIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
event.stopPropagation();
const tabType = "interactSplit";
onOpenTab({
tabType: tabType,
tabID: rowDataStatic.id + tabType,
callbackID: rowDataStatic.id,
color: rowDataStatic.color,
displayID: rowDataStatic.display_id});
}
},
{
name: "Console View", icon: callbackMenuIcon(<TerminalIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
event.stopPropagation();
const tabType = "interactConsole";
onOpenTab({
tabType: tabType,
tabID: rowDataStatic.id + tabType,
callbackID: rowDataStatic.id,
color: rowDataStatic.color,
displayID: rowDataStatic.display_id});
}
},
{
name: "Expand Callback", icon: callbackMenuIcon(<OpenInNewIcon fontSize="small" />), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
event.stopPropagation();
window.open("/new/callbacks/" + rowDataStatic.display_id, "_blank").focus();
}
},
]
},
callbackMenuSection("Callback State"),
{
name: "Edit Description and Color", icon: callbackMenuIcon(<EditIcon fontSize="small" />), className: "mythic-callback-action-menu-item rounded", click:({event}) => {
name: "Edit Description and Color", icon: <EditIcon style={{paddingRight: "5px"}} />, click:({event}) => {
event.stopPropagation();
updateDescription({payload_description: rowDataStatic.payload.description,
callback_display_id: rowDataStatic.display_id,
@@ -535,36 +414,13 @@ function CallbacksTablePreMemo(props){
}, type: "item"
},
{
name: rowDataStatic.locked ? 'Unlock (Locked by ' + (rowDataStatic.locked_operator?.username || "unknown operator") + ')' : 'Lock Callback',
icon: callbackMenuIcon(rowDataStatic.locked ? <LockIcon fontSize="small"/> : <LockOpenIcon fontSize="small" />, rowDataStatic.locked ? "warning" : "neutral"),
className: "mythic-callback-action-menu-item rounded",
click: ({event}) => {
event.stopPropagation();
if(rowDataStatic.locked){
onOpenUnlockDialog(rowDataStatic);
}else{
lockCallback({variables: {callback_display_id: rowDataStatic.display_id}})
}
}, type: "item"
},
{
name: rowDataStatic.trigger_on_checkin_after_time > 0 ? "Adjust Alert Trigger" : "Add New Alert Trigger",
type: "item",
icon: callbackMenuIcon(rowDataStatic.trigger_on_checkin_after_time > 0 ? <NotificationsOffTwoToneIcon fontSize="small" /> : <NotificationsActiveTwoToneIcon fontSize="small"/>, rowDataStatic.trigger_on_checkin_after_time > 0 ? "warning" : "neutral"),
className: "mythic-callback-action-menu-item rounded",
click: ({event}) => {
event.stopPropagation();
onOpenTriggerDialog(rowDataStatic);
}
},
{
name: 'Hide Callback', icon: callbackMenuIcon(<VisibilityOffIcon fontSize="small"/>, "warning"), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
name: 'Hide Callback', icon: <VisibilityOffIcon color={"warning"} style={{paddingRight: "5px"}}/>, click: ({event}) => {
event.stopPropagation();
hideCallback({variables: {callback_display_id: rowDataStatic.display_id}});
}, type: "item"
},
{
name: "Exit Callback", icon: callbackMenuIcon(<FontAwesomeIcon icon={faSkullCrossbones} />, "error"), className: "mythic-callback-action-menu-item rounded",
name: "Exit Callback", icon: <FontAwesomeIcon icon={faSkullCrossbones} style={{color: theme.palette.error.main, cursor: "pointer", marginRight: "8px"}} />,
click: ({event}) => {
taskingData.current = {
"parameters": "",
@@ -577,16 +433,36 @@ function CallbacksTablePreMemo(props){
setOpenTaskingButton(true);
}, type: "item"
},
callbackMenuSection("Workflows and Browsers"),
{
name: "Eventing", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small" />), click: (event) => {}, type: "menu", className: "mythic-callback-action-menu-item rounded",
name: rowDataStatic.locked ? 'Unlock (Locked by ' + rowDataStatic.locked_operator.username + ')' : 'Lock Callback',
icon: rowDataStatic.locked ? (<LockIcon color={"error"} style={{paddingRight: "5px"}}/>) : (<LockOpenIcon color={"success"} style={{paddingRight: "5px"}} />),
click: ({event}) => {
event.stopPropagation();
if(rowDataStatic.locked){
unlockCallback({variables: {callback_display_id: rowDataStatic.display_id}})
}else{
lockCallback({variables: {callback_display_id: rowDataStatic.display_id}})
}
}, type: "item"
},
{
name: rowDataStatic.trigger_on_checkin_after_time > 0 ? "Adjust Alert Trigger" : "Add New Alert Trigger",
type: "item",
icon: rowDataStatic.trigger_on_checkin_after_time > 0 ? (<NotificationsOffTwoToneIcon color={"warning"} style={{paddingRight: "5px"}} />) : (<NotificationsActiveTwoToneIcon color={"success"} style={{paddingRight: "5px"}}/>),
click: ({event}) => {
event.stopPropagation();
setOpenTriggerDialog({open: true, trigger_on_checkin_after_time: rowDataStatic.trigger_on_checkin_after_time, display_id: rowDataStatic.display_id})
}
},
{
name: "Eventing", icon: <PlayCircleFilledTwoToneIcon />, click: (event) => {}, type: "menu",
menuItems: [
{
name: "Start Eventing Workflow", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small" />), className: "mythic-callback-action-menu-item rounded",
name: "Start Eventing Workflow", icon: <PlayCircleFilledTwoToneIcon style={{paddingRight: "5px"}} />,
click: ({event}) => {
eventingDataRef.current = {
name: "callback_display_id",
value: rowDataStatic.display_id
name: "callback_id",
value: rowDataStatic.id
};
setOpenEventingDialog(true);
}
@@ -594,10 +470,10 @@ function CallbacksTablePreMemo(props){
]
},
{
name: "Browsers", icon: callbackMenuIcon(<AccountTreeIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item rounded",
name: "Browsers", icon: null, click: () => {}, type: "menu",
menuItems: [
{
name: 'File Browser', icon: callbackMenuIcon(<FontAwesomeIcon icon={faFolderOpen} />), className: "mythic-callback-action-menu-item rounded",
name: 'File Browser', icon: <FontAwesomeIcon icon={faFolderOpen} style={{color: theme.folderColor, cursor: "pointer", marginRight: "10px"}} />,
click: ({event}) => {
event.preventDefault();
event.stopPropagation();
@@ -611,7 +487,7 @@ function CallbacksTablePreMemo(props){
}
},
{
name: 'Process Browser', icon: callbackMenuIcon(<AccountTreeIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded",
name: 'Process Browser', icon: <AccountTreeIcon style={{paddingRight: "5px"}}/>,
click: ({event}) => {
event.preventDefault();
event.stopPropagation();
@@ -625,63 +501,105 @@ function CallbacksTablePreMemo(props){
}
},
{
name: <span className="mythic-callback-action-menu-section-label text-2xs font-850 leading-120 text-muted">Custom Agent Browsers</span>,
name: <div style={{display: "flex", flexDirection: "column", width: "100%"}}>
<Typography>
{"Custom Agent Browsers:"}
</Typography>
</div>,
icon: null, click: ({event}) => {},
type: "item",
disabled: true,
className: "mythic-callback-action-menu-section"
disabled: true
},
...customBrowsers
]
},
{
name: "Other Callbacks", icon: callbackMenuIcon(<OpenInNewIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item rounded",
name: "Bulk Actions", icon: null, click: (event) => { }, type: "menu",
menuItems: [
{
name: "Interact", icon: callbackMenuIcon(<KeyboardIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interact"});
name: "Hide Multiple", icon: <VisibilityOffIcon color={"warning"} style={{paddingRight: "5px"}}/>,
click: ({event}) => {
setOpenHideMultipleDialog(true);
}
},
{
name: "Split Tasking", icon: callbackMenuIcon(<VerticalSplitIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interactSplit"});
name: "Task Multiple", icon: <FontAwesomeIcon icon={faList} style={{cursor: "pointer", marginRight: "10px"}} />,
click: ({event}) => {
setOpenTaskMultipleDialog({open: true, data: rowDataStatic});
}
},
{
name: "Console View", icon: callbackMenuIcon(<TerminalIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interactConsole"});
}
},
{
name: "File Browser", icon: callbackMenuIcon(<FontAwesomeIcon icon={faFolderOpen} />), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "fileBrowser"});
}
},
{
name: "Process Browser", icon: callbackMenuIcon(<AccountTreeIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "processBrowser"});
name: "Start Multiple Eventing Workflows", icon: <PlayCircleFilledTwoToneIcon style={{paddingRight: "5px"}}/>,
click: ({event}) => {
setOpenEventingMultipleDialog(true);
}
}
]
},
callbackMenuSection("Metadata"),
{
name: "Metadata", icon: callbackMenuIcon(<InfoIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item rounded",
name: "Tasking Views", icon: null, click: () => {}, type: "menu",
menuItems: [
{
name: "Export Callback", icon: callbackMenuIcon(<ImportExportIcon fontSize="small" />), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
name: 'Default Tasking', icon: <KeyboardIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
event.stopPropagation();
exportConfig({variables: {callback_display_id: rowDataStatic.display_id}});
const tabType = "interact";
onOpenTab({
tabType: tabType,
tabID: rowDataStatic.id + tabType,
callbackID: rowDataStatic.id,
color: rowDataStatic.color,
displayID: rowDataStatic.display_id});
}
},
{
name: "View Metadata", icon: callbackMenuIcon(<InfoIcon fontSize="small" />), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
name: 'Split Tasking', icon: <VerticalSplitIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
event.stopPropagation();
const tabType = "interactSplit";
onOpenTab({
tabType: tabType,
tabID: rowDataStatic.id + tabType,
callbackID: rowDataStatic.id,
color: rowDataStatic.color,
displayID: rowDataStatic.display_id});
}
},
{
name: "Console View", icon: <TerminalIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
event.stopPropagation();
const tabType = "interactConsole";
onOpenTab({
tabType: tabType,
tabID: rowDataStatic.id + tabType,
callbackID: rowDataStatic.id,
color: rowDataStatic.color,
displayID: rowDataStatic.display_id});
}
},
{
name: "Expand Callback", icon: <OpenInNewIcon style={{paddingRight: "5px"}} />, click: ({event}) => {
event.stopPropagation();
window.open("/new/callbacks/" + rowDataStatic.display_id, "_blank").focus();
}
},
]
},
{
name: "Metadata", icon: null, click: () => {}, type: "menu",
menuItems: [
{
name: "Export Callback", icon: <ImportExportIcon style={{paddingRight: "5px"}} />, click: ({event}) => {
event.stopPropagation();
exportConfig({variables: {agent_callback_id: rowDataStatic.agent_callback_id}});
}
},
{
name: "View Metadata", icon: <InfoIcon color={"info"} style={{paddingRight: "5px"}} />, click: ({event}) => {
event.stopPropagation();
metaDialog(rowDataStatic.id);
}
},
{
name: "Modify Groupings", icon: callbackMenuIcon(<WidgetsIcon fontSize="small" />), className: "mythic-callback-action-menu-item rounded", click: ({event}) => {
name: "Modify Groupings", icon: <WidgetsIcon color={"info"} style={{paddingRight: "5px"}} />, click: ({event}) => {
event.stopPropagation();
editMythicTreeGroupsDialog(rowDataStatic.id);
}
@@ -689,24 +607,31 @@ function CallbacksTablePreMemo(props){
]
},
{
name: "Bulk Actions", icon: callbackMenuIcon(<FontAwesomeIcon icon={faList} />), click: (event) => { }, type: "menu", className: "mythic-callback-action-menu-item rounded",
name: "Other Callbacks", icon: null, click: () => {}, type: "menu",
menuItems: [
{
name: "Hide Multiple", icon: callbackMenuIcon(<VisibilityOffIcon fontSize="small"/>, "warning"), className: "mythic-callback-action-menu-item rounded",
click: ({event}) => {
setOpenHideMultipleDialog(true);
name: "Interact", icon: <KeyboardIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interact"});
}
},
{
name: "Task Multiple", icon: callbackMenuIcon(<FontAwesomeIcon icon={faList} />), className: "mythic-callback-action-menu-item rounded",
click: ({event}) => {
setOpenTaskMultipleDialog({open: true, data: rowDataStatic});
name: "Split Tasking", icon: <VerticalSplitIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interactSplit"});
}
},
{
name: "Start Multiple Eventing Workflows", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small"/>), className: "mythic-callback-action-menu-item rounded",
click: ({event}) => {
setOpenEventingMultipleDialog(true);
name: "Console View", icon: <TerminalIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interactConsole"});
}
},
{
name: "File Browser", icon: <FontAwesomeIcon icon={faFolderOpen} style={{color: theme.folderColor, cursor: "pointer", marginRight: "10px"}} />, click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "fileBrowser"});
}
},
{
name: "Process Browser", icon: <AccountTreeIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "processBrowser"});
}
}
]
@@ -744,40 +669,32 @@ function CallbacksTablePreMemo(props){
}catch(error){
console.log("Failed to load callbacks_table_filter_options", error);
}
try {
const storageColumnOrder = GetMythicSetting({setting_name: "callbacks_table_column_order", default_value: callbackTableInitialColumns.map(c => c.name)});
if(storageColumnOrder !== null){
let newOrder = [];
for(let i = 0; i < storageColumnOrder.length; i++){
for(let j = 0; j < columnOrder.length; j++){
if(columnOrder[j].name === storageColumnOrder[i]){
newOrder.push(columnOrder[j]);
break;
try {
const storageColumnOrder = GetMythicSetting({setting_name: "callbacks_table_column_order", default_value: callbackTableInitialColumns.map(c => c.name)});
if(storageColumnOrder !== null){
let newOrder = [];
for(let i = 0; i < storageColumnOrder.length; i++){
for(let j = 0; j < columnOrder.length; j++){
if(columnOrder[j].name === storageColumnOrder[i]){
newOrder.push(columnOrder[j]);
break;
}
}
}
if(newOrder.length === callbackTableInitialColumns.length){
setColumnOrder(newOrder);
}
}
if(newOrder.length === callbackTableInitialColumns.length){
setColumnOrder(newOrder);
}
}catch(error){
console.log("Failed to load callbacks_table_filter_options", error);
}
}catch(error){
console.log("Failed to load callbacks_table_filter_options", error);
}
try {
const storageItem = GetMythicSetting({setting_name: "virtualizedTablePadding", default_value: operatorSettingDefaults.virtualizedTablePadding});
if(storageItem !== null){
setVirtualizedTablePadding(parseInt(storageItem));
}
}catch(error){
console.log("Failed to load virtualizedTablePadding", error);
}
setLoadingSettings(false);
setLoadingSettings(false);
}, [])
const columns = useMemo(
() =>
columnOrder.reduce( (prev, cur) => {
if(columnVisibility.visible.includes(cur.name) || cur.name === "Interact"){
if(isGridColumnFilterActive(filterOptions[cur.key])){
if(filterOptions[cur.key] && String(filterOptions[cur.key]).length > 0){
return [...prev, {...cur, filtered: true}];
}else{
return [...prev, {...cur}];
@@ -848,27 +765,21 @@ function CallbacksTablePreMemo(props){
}, [])
const filterRow = (row) => {
for(const [key,value] of Object.entries(filterOptions)){
if(key === "agent") {
if (!gridValuePassesFilter(row.payload.payloadtype.name, value)) {
return true;
}
}else if(key === "id"){
if (!gridValuePassesFilter(row.display_id, value)) {
return true;
}
if(key === "agent"){
if(!String(row.payload.payloadtype.name).toLowerCase().includes(String(value).toLowerCase())){
return true;
}
}else{
if(!gridValuePassesFilter(row[key], value)){
if(!String(row[key]).toLowerCase().includes(String(value).toLowerCase())){
return true;
}
}
}
return false;
}
const sortedData = React.useMemo(() => {
const tempData = callbacks.map((callback) => {
const localUpdate = callbackLocalUpdates[String(callback.display_id)];
return localUpdate ? {...callback, ...localUpdate} : callback;
});
const tempData = [...callbacks];
if (sortData.sortType === 'number' || sortData.sortType === 'size' || sortData.sortType === 'date') {
tempData.sort((a, b) => (parseInt(a[sortData.sortKey]) > parseInt(b[sortData.sortKey]) ? 1 : -1));
@@ -912,12 +823,10 @@ function CallbacksTablePreMemo(props){
return <CallbacksTableIDCell
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}}
key={`callback${row.id}_${c.name}`}
callbackDropdown={callbackDropdown}
onOpenTriggerDialog={onOpenTriggerDialog}
onOpenUnlockDialog={onOpenUnlockDialog}
/>;
case "Groups":
return <CallbacksTableStringCell
@@ -925,7 +834,7 @@ function CallbacksTablePreMemo(props){
cellData={row.mythictree_groups.join(", ")}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}}
/>;
case "IP":
@@ -934,110 +843,109 @@ function CallbacksTablePreMemo(props){
cellData={row.ip}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}}
callback_id={row.id} />;
case "External IP":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.external_ip}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />;
case "Host":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.host}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />;
case "User":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.user + (row.impersonation_context === "" ? "" : ` [${row.impersonation_context}]`)}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />;
case "Domain":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.domain}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />;
case "OS":
return <CallbacksTableOSCell key={`callback${row.id}_${c.name}`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}}
cellData={row.os} />;
case "Arch":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}}
cellData={row.architecture} />;
case "PID":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.pid}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />;
case "Last Checkin":
return <CallbacksTableLastCheckinCell key={`callback${row.id}_${c.name}`} me={props.me}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}}
cellData={row.last_checkin} />;
case "Description":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.description}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />;
case "Sleep":
return <CallbacksTableSleepCell key={`callback${row.id}_${c.name}`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}}
cellData={row.sleep_info} updateSleepInfo={updateSleepInfo} />;
case "Agent":
return <CallbacksTablePayloadTypeCell key={`callback${row.id}_${c.name}`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}}
cellData={row.payload.payloadtype.name}/>;
case "C2":
return <CallbacksTableC2Cell key={`callback${row.id}_c2`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />
case "Process Name":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`}
cellData={row.process_short_name}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />;
case "Tags":
return <CallbacksTableTagsCell key={`callback${row.id}_${c.name}`}
cellData={row.tags}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: getCallbackRowStyle(row),
rowStyle: {backgroundColor: `${row.color}`},
}} />;
}
})];
}
}, [])
}, [callbacks, callbackLocalUpdates, sortData, filterOptions, columnVisibility, clickedCallbackID, onOpenTriggerDialog, onOpenUnlockDialog]);
}, [callbacks, sortData, filterOptions, columnVisibility, clickedCallbackID]);
const onSubmitFilterOptions = (value) => {
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
setFilterOptions({...filterOptions, [selectedColumn.key]: value });
try{
updateSetting({setting_name: "callbacks_table_filter_options", value: nextFilterOptions, broadcast: false});
updateSetting({setting_name: "callbacks_table_filter_options", value: {...filterOptions, [selectedColumn.key]: value }});
}catch(error){
console.log("failed to save filter options");
}
@@ -1081,15 +989,10 @@ function CallbacksTablePreMemo(props){
newHidden.push(newOrder[i].name);
}
}
updateSetting({setting_name: "callbacks_table_column_order", value: newOrder.map(c => c.name)});
setColumnOrder(newOrder);
setColumnVisibility({visible: newVisible, hidden: newHidden});
updateSettings({
settings: {
callbacks_table_column_order: newOrder.map(c => c.name),
callbacks_table_columns: newVisible,
},
broadcast: false,
});
updateSetting({setting_name: "callbacks_table_columns", value: newVisible});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -1097,8 +1000,8 @@ function CallbacksTablePreMemo(props){
}
const onSubmitSelectMultiple = (callbacks) => {
eventingDataRef.current = {
trigger_context_type: "callback_display_id",
trigger_context_ids: callbacks.map(c => c.display_id)
trigger_context_type: "callback_id",
trigger_context_ids: callbacks.map(c => c.id)
};
setOpenEventingDialog(true);
}
@@ -1111,7 +1014,7 @@ function CallbacksTablePreMemo(props){
sortIndicatorIndex={sortColumn}
sortDirection={sortData.sortDirection}
items={sortedData}
rowHeight={GetComputedFontSize() + virtualizedTablePadding}
rowHeight={GetComputedFontSize() + 7}
onClickHeader={onClickHeader}
onDoubleClickRow={onRowDoubleClick}
contextMenuOptions={contextMenuOptions}
@@ -1120,10 +1023,10 @@ function CallbacksTablePreMemo(props){
{openContextMenu &&
<MythicDialog fullWidth={true} maxWidth="sm" open={openContextMenu}
onClose={()=>{setOpenContextMenu(false);}}
innerDialog={<GridColumnFilterDialog
innerDialog={<MythicModifyStringDialog
title='Filter Column'
onSubmit={onSubmitFilterOptions}
filterValue={filterOptions[selectedColumn.key]}
selectedColumn={selectedColumn}
value={filterOptions[selectedColumn.key]}
onClose={() => {
setOpenContextMenu(false);
}}
@@ -1184,13 +1087,11 @@ function CallbacksTablePreMemo(props){
isOpen={callbackDropdownRef.current.dropdownAnchorRef}
onOpen={setOpenCallbackDropdown}
externallyOpen={openCallbackDropdown}
minWidth={285}
menu={
callbackDropdownRef.current.options.map((option, index) => (
option.type === 'item' ? (
<DropdownMenuItem
key={"callback-action-" + index}
className={option.className}
key={option.name}
disabled={option.disabled}
onClick={(event) => handleMenuItemClick(event, option.click)}
>
@@ -1198,15 +1099,12 @@ function CallbacksTablePreMemo(props){
</DropdownMenuItem>
) : option.type === 'menu' ? (
<DropdownNestedMenuItem
key={"callback-action-" + index}
className={option.className}
label={<span className="mythic-callback-action-menu-nested-label items-center inline-flex min-w-0">{option.icon}{option.name}</span>}
label={option.name}
disabled={option.disabled}
menu={
option.menuItems.map((menuOption, indx) => (
<DropdownMenuItem
key={"callback-action-" + index + "-" + indx}
className={menuOption.className}
key={menuOption.name}
disabled={menuOption.disabled}
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
>
@@ -1246,17 +1144,6 @@ function CallbacksTablePreMemo(props){
}
/>
}
{openUnlockDialog.open &&
<MythicConfirmDialog
open={openUnlockDialog.open}
title={"Unlock Callback?"}
dialogText={`Unlock callback ${openUnlockDialog.display_id} currently locked by ${openUnlockDialog.lockOwner}?`}
acceptText={"Unlock"}
acceptColor={"warning"}
onClose={onCloseUnlockDialog}
onSubmit={onSubmitUnlockDialog}
/>
}
{openReorderDialog &&
<MythicDialog fullWidth={true} maxWidth="sm" open={openReorderDialog}
onClose={()=>{setOpenReorderDialog(false);}}
@@ -1326,4 +1213,4 @@ function CallbacksTablePreMemo(props){
</div>
)
}
export const CallbacksTable = React.memo(CallbacksTablePreMemo);
export const CallbacksTable = React.memo(CallbacksTablePreMemo);
@@ -1,25 +1,24 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import React from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import {MythicDraggablePortal, reorder} from "../../MythicComponents/MythicDraggableList";
import IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import {reorder} from "../../MythicComponents/MythicDraggableList";
import {
Draggable,
DragDropContext,
Droppable,
} from "@hello-pangea/dnd";
import { List, ListItem, ListItemText, Paper } from '@mui/material';
import DragHandleIcon from '@mui/icons-material/DragHandle';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import VisibilityIcon from '@mui/icons-material/Visibility';
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicDialogSection,
} from "../../MythicComponents/MythicDialogLayout";
export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onClose, onReset, visible, hidden}) {
const [items, setItems] = React.useState(initialItems);
const theme = useTheme();
const onDragEnd = ({ destination, source }) => {
// dropped outside the list
if (!destination) return;
@@ -27,12 +26,13 @@ export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onCl
setItems(newItems);
};
React.useEffect( () => {
setItems((currentItems) => currentItems.map( c => {
const newItems = items.map( c => {
if(visible.includes(c.name)){
return {...c, visible: true};
}
return {...c, visible: false};
}));
})
setItems(newItems);
}, [visible, hidden]);
const onToggleVisibility = (i) => {
const newItems = items.map( (c, index) => {
@@ -49,28 +49,21 @@ export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onCl
return (
<React.Fragment>
<DialogTitle id="form-dialog-title">Column Layout</DialogTitle>
<DialogContent dividers={true} sx={{p: 0, overflow: "hidden"}}>
<MythicDialogBody sx={{height: "min(70vh, 42rem)", p: 1}}>
<MythicDialogSection
title="Columns"
sx={{display: "flex", flexDirection: "column", flex: "1 1 auto", minHeight: 0}}
>
<DraggableList items={items} onToggleVisibility={onToggleVisibility} onDragEnd={onDragEnd} />
</MythicDialogSection>
</MythicDialogBody>
<DialogTitle id="form-dialog-title">Drag and Drop to Adjust the Order and Toggle Visibility</DialogTitle>
<DialogContent dividers={true} style={{height: "100%", margin: 0, padding: 0, background: theme.palette.background.main}}>
<DraggableList items={items} onToggleVisibility={onToggleVisibility} onDragEnd={onDragEnd} />
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={onClose}>
<DialogActions>
<Button onClick={onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
<MythicDialogButton onClick={onReset} intent="warning">
</Button>
<Button onClick={onReset} variant="contained" color="warning">
Reset
</MythicDialogButton>
<MythicDialogButton onClick={onFinish} intent="primary">
Save
</MythicDialogButton>
</MythicDialogFooter>
</Button>
<Button onClick={onFinish} variant="contained" color="success">
Submit
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -80,55 +73,46 @@ export const DraggableList = ({ items, onDragEnd, onToggleVisibility }) => {
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="callback-table-column-list">
{(provided) => (
<div className="mythic-reorder-list flex flex-fill flex-column gap-4 min-h-0 overflow-auto" ref={provided.innerRef} {...provided.droppableProps}>
<List style={{border: 0, padding: 0}} ref={provided.innerRef} {...provided.droppableProps}>
{items.map((item, index) => (
<DraggableListItem onToggleVisibility={onToggleVisibility} item={item} index={index} key={item.key} />
))}
{provided.placeholder}
</div>
</List>
)}
</Droppable>
</DragDropContext>
);
};
export const DraggableListItem = ({ item, index, onToggleVisibility }) => {
const theme = useTheme();
return (
<Draggable draggableId={item.key} index={index}>
{(provided, snapshot) => {
const row = (
<div
ref={provided.innerRef}
className={`mythic-reorder-row items-center flex flex-none gap-4 rounded bg-surface-raised border-subtle text-primary shadow-none${snapshot.isDragging ? " mythic-reorder-row-dragging mythic-tone-primary bg-tone-1 border-tone-3 shadow-3" : ""}${item.visible ? "" : " mythic-reorder-row-disabled"} min-w-0 w-full`}
{...provided.draggableProps}
>
<span className="mythic-reorder-drag-handle items-center inline-flex justify-center rounded bg-neutral-2 border-subtle text-muted" {...provided.dragHandleProps}>
<DragHandleIcon fontSize="small" />
</span>
<div className="mythic-reorder-row-main items-center flex flex-fill gap-4 min-w-0">
<span className="mythic-reorder-row-title text-sm font-750 leading-125 min-w-0 truncate whitespace-nowrap">{item.name}</span>
</div>
<div className="mythic-reorder-row-actions items-center flex flex-none gap-3">
<MythicActionButton iconOnly
aria-label={item.visible ? `Hide ${item.name}` : `Show ${item.name}`}
appearance="raised" colorMode="hover" tone={item.visible ? "error" : "info"}
size="small"
onClick={() => onToggleVisibility(index)}
>
{item.visible ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</MythicActionButton>
</div>
</div>
);
return (
<MythicDraggablePortal isDragging={snapshot.isDragging}>
{row}
</MythicDraggablePortal>
);
}}
{(provided, snapshot) => (
<ListItem
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
component={Paper}
disabled={!item.visible}
sx={snapshot.isDragging ? { background: theme.palette.secondary.main } : {
}}
>
<DragHandleIcon style={{marginRight: "10px"}}/>
<ListItemText primary={item.name} />
<IconButton onClick={() => onToggleVisibility(index)} style={{float: "right", margin: 0, padding: 0}}>
{item.visible ? (
<VisibilityIcon color={"success"} />
) : (
<VisibilityOffIcon color={"error"} />
)
}
</IconButton>
</ListItem>
)}
</Draggable>
);
};
@@ -1,5 +1,5 @@
import React, {useEffect} from 'react';
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
@@ -10,17 +10,15 @@ import "ace-builds/src-noconflict/theme-github";
import "ace-builds/src-noconflict/theme-monokai";
import "ace-builds/src-noconflict/ext-searchbox";
import {useTheme} from '@mui/material/styles';
import {HexColorInput, HexColorPicker} from 'react-colorful';
import Typography from '@mui/material/Typography';
import {getReadableTextColor, isValidHexColor, MythicColorSwatchInput} from '../../MythicComponents/MythicColorInput';
export function CallbacksTableEditDescriptionColorDialog(props) {
const [comment, setComment] = React.useState("");
const [color, setColor] = React.useState("");
const theme = useTheme();
const onCommitSubmit = () => {
const normalizedColor = color?.toLowerCase() || "";
if(normalizedColor === "" || normalizedColor === "ffffff" || normalizedColor === "#ffffff" ||
normalizedColor === "000000" || normalizedColor === "#000000"){
if(color === "ffffff" || color === "000000"){
props.onSubmit(comment, "");
} else {
props.onSubmit(comment, color);
@@ -41,11 +39,6 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
}
setColor(props.color);
}, [props.description, props.color]);
const previewColor = isValidHexColor(color) ? color : "";
const darkPreviewBackground = previewColor || "#1f2937";
const lightPreviewBackground = previewColor || "#f8fafc";
const darkPreviewTextColor = previewColor ? getReadableTextColor(previewColor) : "#ffffff";
const lightPreviewTextColor = previewColor ? getReadableTextColor(previewColor) : "#111827";
return (
<React.Fragment>
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
@@ -68,74 +61,26 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
showInvisibles: false,
}}
/>
<Box sx={{p: 1.5, borderTop: "1px solid", borderColor: "divider", backgroundColor: "background.paper"}}>
<Box
sx={{
p: 1.5,
border: "1px solid",
borderColor: "divider",
borderRadius: "8px",
}}
>
<Box sx={{display: "flex", alignItems: "center", justifyContent: "space-between", gap: 2, mb: 1}}>
<Box sx={{minWidth: 0}}>
<Typography variant="body2" sx={{fontWeight: 700}}>Callback Color</Typography>
<Typography variant="caption" sx={{color: "text.secondary"}}>Callback row background and tasking accent</Typography>
</Box>
<Box sx={{display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap", justifyContent: "flex-end"}}>
<MythicColorSwatchInput
color={isValidHexColor(color) ? color : "#000000"}
label="Callback color"
onChange={setColor}
/>
<MythicActionButton colorMode="always" onClick={() => setColor("")} tone="success" variant="outlined" size="small">
Clear
</MythicActionButton>
</Box>
</Box>
<Box sx={{display: "grid", gridTemplateColumns: {xs: "1fr", sm: "1fr 1fr"}, gap: 1}}>
<Box
sx={{
minHeight: 40,
px: 1.5,
display: "flex",
alignItems: "center",
borderRadius: "6px",
border: "1px solid",
borderColor: "divider",
backgroundColor: darkPreviewBackground,
}}
>
<Typography sx={{color: darkPreviewTextColor, fontWeight: 700}}>Dark callback row</Typography>
</Box>
<Box
sx={{
minHeight: 40,
px: 1.5,
display: "flex",
alignItems: "center",
borderRadius: "6px",
border: "1px solid",
borderColor: "divider",
backgroundColor: lightPreviewBackground,
}}
>
<Typography sx={{color: lightPreviewTextColor, fontWeight: 700}}>Light callback row</Typography>
</Box>
</Box>
</Box>
<HexColorPicker color={color} onChange={setColor} style={{width: "100%"}} />
<HexColorInput color={color} onChange={setColor} style={{width: "80%"}} />
<Button onClick={()=>setColor("ffffff")} color={"success"}>
Clear
</Button>
<Box sx={{width: "100%", height: 25, backgroundColor: color}} >
<Typography style={{color: "white", display: "inline-block", marginRight: "10px"}}>DarkMode Text With Color Background</Typography>
<Typography style={{color: "black", display: "inline-block"}}>LightMode Text With Color Background</Typography>
</Box>
</DialogContent>
<DialogActions>
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicActionButton>
</Button>
{props.onSubmit &&
<MythicActionButton colorMode="always" onClick={onCommitSubmit} tone="success" variant="contained">
<Button onClick={onCommitSubmit} variant="contained" color="success">
Submit
</MythicActionButton>
</Button>
}
</DialogActions>
</React.Fragment>
);
}
}
@@ -1,17 +1,18 @@
import React, {useEffect} from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import Box from '@mui/material/Box';
import AceEditor from 'react-ace';
import "ace-builds/src-noconflict/mode-json";
import "ace-builds/src-noconflict/theme-github";
import "ace-builds/src-noconflict/theme-monokai";
import "ace-builds/src-noconflict/ext-searchbox";
import {useTheme} from '@mui/material/styles';
import {HexColorInput, HexColorPicker} from 'react-colorful';
import Typography from '@mui/material/Typography';
import NotificationsActiveTwoToneIcon from '@mui/icons-material/NotificationsActiveTwoTone';
import MythicTextField from "../../MythicComponents/MythicTextField";
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicDialogSection,
MythicForm,
MythicFormField
} from "../../MythicComponents/MythicDialogLayout";
export function CallbacksTableEditTriggerOnCheckinDialog(props) {
const [comment, setComment] = React.useState(0);
@@ -30,73 +31,29 @@ export function CallbacksTableEditTriggerOnCheckinDialog(props) {
}
return (
<React.Fragment>
<DialogTitle id="form-dialog-title">Callback check-in alert</DialogTitle>
<DialogContent dividers={true}>
<MythicDialogBody>
<div className="mythic-callback-trigger-summary p-6 items-start flex gap-6 min-w-0 rounded bg-surface-muted border-subtle">
<span className={`mythic-callback-trigger-summary-icon items-center inline-flex flex-none justify-center rounded bg-neutral-1 border-subtle text-muted${comment > 0 ? " mythic-callback-trigger-summary-icon-active" : ""}`}>
<NotificationsActiveTwoToneIcon fontSize="small" />
</span>
<div className="mythic-callback-trigger-summary-copy min-w-0">
<Typography className="mythic-callback-trigger-summary-title">
{comment > 0 ? `Alert after ${comment} minute${comment === 1 ? "" : "s"} without a check-in` : "Alerting disabled"}
</Typography>
<Typography className="mythic-callback-trigger-summary-description">
This setting only triggers when the callback checks in after crossing the configured threshold.
</Typography>
</div>
</div>
<MythicDialogSection
title="Threshold"
description="Set how many minutes this callback can remain silent before its next check-in can trigger eventing."
>
<MythicForm>
<MythicFormField
label="Minutes without a check-in"
description="Use 0 to disable this alert for the callback."
>
<div className="mythic-form-field-control min-w-0 w-full">
<MythicTextField
autoFocus={true}
onChange={onChange}
type="number"
value={comment}
onEnter={onSubmit}
name="Trigger threshold"
showLabel={false}
marginTop="0"
marginBottom="0"
InputProps={{inputProps: {min: 0}}}
/>
</div>
</MythicFormField>
</MythicForm>
</MythicDialogSection>
<MythicDialogSection title="Eventing behavior">
<div className="mythic-callback-trigger-rule-list gap-4 grid">
<div className="mythic-callback-trigger-rule text-xs leading-140 rounded bg-surface-muted border-subtle text-muted">
Trigger name: <strong>callback_checkin</strong>
</div>
<div className="mythic-callback-trigger-rule text-xs leading-140 rounded bg-surface-muted border-subtle text-muted">
Matching workflow filters still apply, including payload type and supported OS restrictions.
</div>
<div className="mythic-callback-trigger-rule text-xs leading-140 rounded bg-surface-muted border-subtle text-muted">
If no matching workflow exists, no workflow will run.
</div>
</div>
</MythicDialogSection>
</MythicDialogBody>
<DialogTitle id="form-dialog-title">{"Adjust this callback's trigger threshold"}</DialogTitle>
<DialogContent dividers={true} style={{height: "100%"}}>
<Typography >
This adjusts how long, in minutes, this callback must <b>not</b> checkin before finally checking in to trigger an <b>eventing workflow</b> (trigger is callback_checkin).
</Typography>
<Typography >
A zero value means never trigger.
</Typography>
<Typography >
If no eventing workflow for <b>callback_checkin</b> that matches the right payload_types and supported_os restrictions, then nothing will happen.
</Typography>
<MythicTextField autoFocus={true} onChange={onChange} type={"number"} value={comment} onEnter={onSubmit} name={"trigger threshold in minutes"} showLabel={false} />
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicDialogButton>
</Button>
{props.onSubmit &&
<MythicDialogButton onClick={onSubmit} intent="primary">
Save Threshold
</MythicDialogButton>
<Button onClick={onSubmit} variant="contained" color="success">
Submit
</Button>
}
</MythicDialogFooter>
</DialogActions>
</React.Fragment>
);
}
}
@@ -1,9 +1,11 @@
import React, {useCallback, useEffect, useContext} from 'react';
import {IconButton} from '@mui/material';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import { MythicDisplayTextDialog} from '../../MythicComponents/MythicDisplayTextDialog';
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import KeyboardIcon from '@mui/icons-material/Keyboard';
import LockIcon from '@mui/icons-material/Lock';
import WifiIcon from '@mui/icons-material/Wifi';
import InsertLinkTwoToneIcon from '@mui/icons-material/InsertLinkTwoTone';
import {C2PathDialog} from './C2PathDialog';
@@ -13,26 +15,26 @@ import {
} from './CallbackMutations';
import {useMutation } from '@apollo/client';
import SnoozeIcon from '@mui/icons-material/Snooze';
import {useTheme} from '@mui/material/styles';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import {faQuestion, faRobot} from '@fortawesome/free-solid-svg-icons';
import {faQuestion, faSkullCrossbones, faRobot} from '@fortawesome/free-solid-svg-icons';
import {faLinux, faApple, faWindows, faChrome, faAndroid} from '@fortawesome/free-brands-svg-icons';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
import { MythicSelectFromRawListDialog } from '../../MythicComponents/MythicSelectFromListDialog';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import { areEqual } from 'react-window';
import {CallbackGraphEdgesContext, OnOpenTabContext} from './CallbacksTop';
import Moment from 'react-moment';
import moment from 'moment';
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import TerminalIcon from '@mui/icons-material/Terminal';
import VerticalSplitIcon from '@mui/icons-material/VerticalSplit';
import NotificationsActiveTwoToneIcon from '@mui/icons-material/NotificationsActiveTwoTone';
import {faSocks} from '@fortawesome/free-solid-svg-icons';
import {TagsDisplay, ViewEditTags} from "../../MythicComponents/MythicTag";
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import {MythicStatusChip} from "../../MythicComponents/MythicStatusChip";
import {MythicTimestamp} from "../../MythicComponents/MythicTimestamp";
import ButtonGroup from "@mui/material/ButtonGroup";
import Button from "@mui/material/Button";
export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown, onOpenTriggerDialog, onOpenUnlockDialog}) =>{
export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown}) =>{
const theme = useTheme();
const dropdownAnchorRef = React.useRef(null);
const onOpenTab = useContext(OnOpenTabContext);
const interactType = GetMythicSetting({setting_name: "interactType", default_value: "interact"})
@@ -99,85 +101,44 @@ export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown, onOp
event.stopPropagation();
callbackDropdown({rowDataStatic, event});
};
const handleOpenTriggerDialog = (event) => {
event.stopPropagation();
onOpenTriggerDialog?.(rowDataStatic);
};
const handleOpenUnlockDialog = (event) => {
event.stopPropagation();
onOpenUnlockDialog?.(rowDataStatic);
};
const localOnOpenTab = () => {
onOpenTab({tabType: interactType, tabID: rowDataStatic.id + interactType, callbackID: rowDataStatic.id, displayID: rowDataStatic.display_id});
}
let defaultInteractIcon = <KeyboardIcon fontSize="small" />;
let defaultInteractIcon = <KeyboardIcon style={{paddingRight: "5px"}}/>;
if(interactType === "interactSplit"){
defaultInteractIcon = <VerticalSplitIcon fontSize="small" />;
defaultInteractIcon = <VerticalSplitIcon style={{paddingRight: "5px"}}/>;
} else if(interactType === "interactConsole"){
defaultInteractIcon = <TerminalIcon fontSize="small" />;
defaultInteractIcon = <TerminalIcon style={{paddingRight: "5px"}}/>;
}
const highIntegrity = rowDataStatic.integrity_level >= 3;
const lockOwner = rowDataStatic.locked_operator?.username || "unknown operator";
const onOpenTabClick = (e) => {
e.stopPropagation();
localOnOpenTab()
}
return (
<div id={`callbacksTableID${rowDataStatic.id}`} className="mythic-callback-interactCell items-center inline-flex gap-1 overflow-hidden w-full">
<ButtonGroup className="mythic-split-action-group inline-flex " >
<MythicActionButton
appearance={"raised"}
compact
colorMode={highIntegrity ? "always" : "hover"}
icon={defaultInteractIcon}
onClick={onOpenTabClick}
tone={highIntegrity ? "error" : "primary"}
tooltip={`Open ${interactType === "interactSplit" ? "split" : interactType === "interactConsole" ? "console" : "tasking"} view${highIntegrity ? " - high integrity callback" : ""}`}
>
<span className="font-700 font-mono text-output">
{rowDataStatic.display_id}
</span>
</MythicActionButton>
<MythicActionButton
appearance={"raised"}
compact
aria-haspopup="menu"
icon={<ArrowDropDownIcon />}
onClick={handleDropdownToggle}
ref={dropdownAnchorRef}
colorMode={highIntegrity ? "always" : "hover"}
tone={highIntegrity ? "error" : "primary"}
/>
</ButtonGroup>
{rowDataStatic.locked &&
<MythicStyledTooltip title={`Locked by ${lockOwner}`}>
<MythicStatusChip
aria-label={`Unlock callback ${rowDataStatic.display_id}`}
compact
iconOnly
onClick={handleOpenUnlockDialog}
status="locked"
/>
</MythicStyledTooltip>
}
return (
<div id={`callbacksTableID${rowDataStatic.id}`} style={{display: "inline-flex", alignItems: "flex-start"}}>
<IconButton style={{padding: 0, margin: 0}} color={rowDataStatic.integrity_level > 2 ? "error" : ""}
onClick={(evt) => {evt.stopPropagation();localOnOpenTab()}}
>
{rowDataStatic.locked ? (<LockIcon style={{marginRight: "10px"}} />):(defaultInteractIcon)}
</IconButton>
{rowDataStatic.display_id}
<IconButton
style={{margin: 0, padding: 0}}
color={rowDataStatic.integrity_level > 2 ? "error" : ""}
aria-haspopup="menu"
onClick={handleDropdownToggle}
ref={dropdownAnchorRef}
>
<ArrowDropDownIcon/>
</IconButton>
{rowDataStatic.trigger_on_checkin_after_time > 0 &&
<MythicStyledTooltip title={`Alert on callback after no checkin for ${rowDataStatic.trigger_on_checkin_after_time} minutes`}>
<MythicStatusChip
aria-label={`Adjust alert trigger for callback ${rowDataStatic.display_id}`}
compact
iconOnly
onClick={handleOpenTriggerDialog}
status="alerting"
/>
<NotificationsActiveTwoToneIcon color={"success"}/>
</MythicStyledTooltip>
}
{rowDataStatic.callbackports.length > 0 &&
<MythicStyledTooltip title={proxyMessage()}>
<MythicStatusChip compact icon={<FontAwesomeIcon icon={faSocks} />} iconOnly label="Proxy active" />
<FontAwesomeIcon icon={faSocks} size="lg" style={{color: theme.palette.success.main}}/>
</MythicStyledTooltip>
}
</div>
</div>
)
},
areEqual)
@@ -187,41 +148,48 @@ export const CallbacksTableStringCell = React.memo(({rowData, cellData}) => {
)
}, areEqual)
export const CallbacksTableLastCheckinCell = React.memo( ({rowData, cellData, me}) => {
const adjustOutput = (newTime) => {
if(newTime === "a few seconds"){
moment.relativeTimeThreshold('s', 60);
moment.relativeTimeThreshold('ss', 0);
return moment(rowData.last_checkin + "Z", "YYYY-MM-DDTHH:mm:ss.SSSSSSZ").subtract(me?.user?.server_skew || 0, 'millisecond').fromNow(true)
}
return moment(rowData.last_checkin + "Z", "YYYY-MM-DDTHH:mm:ss.SSSSSSZ").subtract(me?.user?.server_skew || 0, 'millisecond').fromNow(true);
//return newTime;
}
const theme = useTheme();
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
return ""
}
if(rowData.last_checkin === "1970-01-01T00:00:00"){
return (
<div className="mythic-callback-cellInline items-center inline-flex gap-2 h-full max-w-full min-w-0 overflow-hidden w-full">
{rowData.dead &&
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
<MythicStatusChip compact iconOnly status="dead" />
</MythicStyledTooltip>
}
<span className="mythic-callback-cellText min-w-0 truncate whitespace-nowrap">Streaming Now</span>
</div>
<>
{rowData.dead &&
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
<FontAwesomeIcon disabled icon={faSkullCrossbones} style={{
color: theme.palette.error.main, cursor: "pointer", marginRight: "10px",}} />
</MythicStyledTooltip>
}
{"Streaming Now"}
</>
)
}
return (
<div className="mythic-callback-cellInline items-center inline-flex gap-2 h-full max-w-full min-w-0 overflow-hidden w-full">
<div style={{display: "flex", alignItems: "center"}}>
{rowData.dead &&
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
<MythicStatusChip compact iconOnly status="dead" />
<FontAwesomeIcon icon={faSkullCrossbones} style={{
color: theme.palette.error.main, cursor: "pointer", marginRight: "10px",}} />
</MythicStyledTooltip>
}
<span className="mythic-callback-cellText min-w-0 truncate whitespace-nowrap">
<MythicTimestamp
interval={1000}
<Moment filter={adjustOutput} interval={1000} parse={"YYYY-MM-DDTHH:mm:ss.SSSSSSZ"}
withTitle
titleFormat={"YYYY-MM-DD HH:mm:ss"}
serverSkew={me?.user?.server_skew}
fromNow
ago
fromNow ago
>
{rowData.last_checkin + "Z"}
</MythicTimestamp>
</span>
</Moment>
</div>
)
@@ -252,8 +220,7 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
const updateIPsInfo = React.useCallback( ({callback_display_id, ips}) => {
updateIPs({variables: {callback_display_id: callback_display_id, ips}})
}, []);
const onClick = (event) => {
event.stopPropagation();
const onClick = () => {
setOpenPickIP(true);
}
const editIPSubmit = (selected_ip) => {
@@ -273,11 +240,13 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
}, [cellData]);
return (
<>
<div className="mythic-callback-cellInline items-center inline-flex gap-2 h-full max-w-full min-w-0 overflow-hidden w-full">
<div style={{display: "inline-flex", alignItems: "center", height: "100%"}}>
{options.length > 1 &&
<MythicActionButton appearance="raised" compact icon={<UnfoldMoreIcon />} iconOnly onClick={onClick} tooltip="Adjust displayed IP" />
<MythicStyledTooltip title={"Adjust Displayed"}>
<UnfoldMoreIcon onClick={onClick} style={{paddingTop: "5px", cursor: "pointer", width: "unset"}} />
</MythicStyledTooltip>
}
<span className="mythic-callback-cellText min-w-0 truncate whitespace-nowrap">{displayIP}</span>
{displayIP}
</div>
{openPickIP &&
<MythicDialog fullWidth={true} open={openPickIP} onClose={() => {setOpenPickIP(false);}}
@@ -294,10 +263,13 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
)
}, areEqual)
export const CallbacksTableC2Cell = React.memo(({rowData, centered = true}) => {
export const CallbacksTableC2Cell = React.memo(({rowData}) => {
const theme = useTheme();
const [localRowData, setLocalRowData] = React.useState(rowData);
const initialCallbackGraphEdges = useContext(CallbackGraphEdgesContext);
const onOpenTab = useContext(OnOpenTabContext);
const [activeEgress, setActiveEgress] = React.useState(theme.palette.success.main);
const [hasEgressRoute, setHasEgressRoute] = React.useState(true);
const [openC2Dialog, setOpenC2Dialog] = React.useState(false);
const [callbackgraphedges, setCallbackgraphedges] = React.useState([]);
const [callbackgraphedgesAll, setCallbackgraphedgesAll] = React.useState([]);
@@ -305,6 +277,19 @@ export const CallbacksTableC2Cell = React.memo(({rowData, centered = true}) => {
event.stopPropagation();
setOpenC2Dialog(true);
}
useEffect( () => {
const routes = callbackgraphedgesAll.filter( (edge) => {
if(!edge.c2profile.is_p2p && edge.source.id === localRowData.id && edge.destination.id === localRowData.id){
return true;
}
return false;
}).length;
if(routes > 0 && !hasEgressRoute){
setHasEgressRoute(true);
}else if(routes === 0 && hasEgressRoute){
setHasEgressRoute(false);
}
}, [callbackgraphedgesAll, localRowData]);
useEffect( () => {
const getEdges = (activeOnly) => {
//update our aggregate of callbackgraphedges for both src and dst that involve us
@@ -357,6 +342,21 @@ export const CallbacksTableC2Cell = React.memo(({rowData, centered = true}) => {
setCallbackgraphedgesAll(myEdges);
}, [initialCallbackGraphEdges, localRowData]);
useEffect( () => {
//determine if there are any active routes left at all
//console.log(localRowData.display_id, callbackgraphedges)
const activeRoutes = callbackgraphedges.filter( (edge) => {
if(!edge.c2profile.is_p2p && edge.end_timestamp === null){
return true;
}
return false
});
if(activeRoutes.length === 0){
setActiveEgress(theme.palette.error.main);
}else{
setActiveEgress(theme.palette.success.main);
}
}, [callbackgraphedges, theme.palette.success.main, theme.palette.error.main]);
useEffect( () => {
if(rowData.id !== localRowData.id){
setLocalRowData(rowData);
@@ -365,31 +365,12 @@ export const CallbacksTableC2Cell = React.memo(({rowData, centered = true}) => {
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
return null
}
const directEgressRoutes = callbackgraphedgesAll.filter((edge) =>
!edge.c2profile?.is_p2p &&
edge.source.id === localRowData.id &&
edge.destination.id === localRowData.id
);
const hasDirectEgressRoute = directEgressRoutes.length > 0;
const directEgressActive = directEgressRoutes.some((edge) => edge.end_timestamp === null);
const p2pRouteActive = callbackgraphedges.some((edge) =>
!edge.c2profile?.is_p2p && edge.end_timestamp === null
);
const c2RouteActive = hasDirectEgressRoute ? directEgressActive : p2pRouteActive;
const c2Tooltip = hasDirectEgressRoute ?
(directEgressActive ? "Direct C2 route active. View C2 path information" : "Direct C2 route inactive. View C2 path information") :
(p2pRouteActive ? "Active P2P route to Mythic. View C2 path information" : "No active route to Mythic. View C2 path information");
return (
<div className={`mythic-callback-cellInline items-center inline-flex gap-2${centered ? " mythic-callback-cellInlineCenter justify-center" : ""} h-full max-w-full min-w-0 overflow-hidden w-full`}>
<MythicActionButton appearance="raised"
active={!c2RouteActive}
compact
icon={hasDirectEgressRoute ? <WifiIcon /> : <InsertLinkTwoToneIcon />}
iconOnly
onClick={onOpenC2Dialog}
tone={c2RouteActive ? "success" : "error"}
tooltip={c2Tooltip}
/>
<div>
{hasEgressRoute ?
<WifiIcon onClick={onOpenC2Dialog} style={{color: activeEgress, cursor: "pointer"}}/> :
<InsertLinkTwoToneIcon onClick={onOpenC2Dialog} style={{color: activeEgress, cursor: "pointer"}} />
}
{openC2Dialog &&
<MythicDialog
fullWidth={true}
@@ -416,35 +397,34 @@ export const CallbacksTableOSCell = React.memo( ({rowData, cellData}) => {
const [openOSDialog, setOpenOSDialog] = React.useState(false);
const getOSIcon = useCallback( () => {
if(rowData?.payload?.payloadtype?.agent_type !== "agent"){
return <FontAwesomeIcon icon={faRobot} />
return <FontAwesomeIcon icon={faRobot} style={{cursor: "pointer"}} onClick={displayOSInfo} />
}
switch(rowData.payload.os.toLowerCase()){
case "windows":
return <FontAwesomeIcon icon={faWindows} />
return <FontAwesomeIcon icon={faWindows} style={{cursor: "pointer"}} onClick={displayOSInfo} />
case "linux":
case "centos":
case "redhat":
case "debian":
case "fedora":
case "freebsd":
return <FontAwesomeIcon icon={faLinux} />
return <FontAwesomeIcon icon={faLinux} style={{cursor: "pointer"}} onClick={displayOSInfo} />
case "macos":
return <FontAwesomeIcon icon={faApple} />
return <FontAwesomeIcon icon={faApple} style={{cursor: "pointer"}} onClick={displayOSInfo}/>
case "chrome":
return <FontAwesomeIcon icon={faChrome} />
return <FontAwesomeIcon icon={faChrome} style={{cursor: "pointer"}} onClick={displayOSInfo} />
case "android":
return <FontAwesomeIcon icon={faAndroid} />
return <FontAwesomeIcon icon={faAndroid} style={{cursor: "pointer"}} onClick={displayOSInfo} />
default:
return <FontAwesomeIcon icon={faQuestion} />
return <FontAwesomeIcon icon={faQuestion} style={{cursor: "pointer"}} onClick={displayOSInfo} />
}
}, [rowData?.payload?.os]);
const displayOSInfo = React.useCallback( (event) => {
event.stopPropagation();
const displayOSInfo = React.useCallback( () => {
setOpenOSDialog(true);
}, []);
return (
<div className="mythic-callback-cellInline items-center inline-flex gap-2 mythic-callback-cellInlineCenter justify-center h-full max-w-full min-w-0 overflow-hidden w-full">
<MythicActionButton appearance="raised" compact icon={getOSIcon()} iconOnly onClick={displayOSInfo} tone="primary" tooltip="View operating system information" />
<div>
{getOSIcon()}
{ openOSDialog &&
<MythicDisplayTextDialog
onClose={()=>{setOpenOSDialog(false);}}
@@ -460,7 +440,8 @@ export const CallbacksTableOSCell = React.memo( ({rowData, cellData}) => {
)
}, areEqual);
export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSleepInfo, centered = true}) => {
export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSleepInfo}) => {
const theme = useTheme();
const [openSleepDialog, setOpenSleepDialog] = React.useState(false);
const editSleepSubmit = (sleep) => {
updateSleepInfo({sleep_info: sleep, callback_display_id: rowData.display_id});
@@ -473,15 +454,9 @@ export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSl
return null
}
return (
<div className={`mythic-callback-cellInline items-center inline-flex gap-2${centered ? " mythic-callback-cellInlineCenter justify-center" : ""} h-full max-w-full min-w-0 overflow-hidden w-full`}>
<MythicActionButton appearance="raised"
active={cellData === ""}
compact
icon={<SnoozeIcon />}
iconOnly
onClick={onOpenSleepDialog}
tone={cellData === "" ? "warning" : "info"}
tooltip={cellData === "" ? "No sleep information set" : "View or edit sleep information"}
<div style={{height: "100%", display: "flex", alignItems: "center"}}>
<SnoozeIcon onClick={onOpenSleepDialog}
style={{color: cellData === "" ? theme.palette.warning.main : theme.palette.info.main, cursor: "pointer"}}
/>
{ openSleepDialog &&
<MythicDialog fullWidth={true} open={openSleepDialog} maxWidth={"md"}
@@ -503,14 +478,9 @@ export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSl
}, areEqual)
export const CallbacksTableTagsCell = React.memo(({rowData, cellData}) => {
return (
<div className="mythic-tag-cell items-center flex gap-3 mythic-tag-cell-fill min-w-0 overflow-hidden h-full w-full">
<ViewEditTags target_object={"callback_id"} target_object_id={rowData.id} target_object_display_id={rowData.display_id} />
<div className="mythic-tag-list items-center flex flex-fill flex-nowrap gap-2 mythic-callback-tag-list min-w-0 overflow-hidden">
{cellData?.length > 0 ?
<TagsDisplay tags={cellData} expand={false} /> :
<span className="mythic-callback-tagsEmpty text-xs font-650 truncate text-disabled whitespace-nowrap">No tags</span>
}
</div>
<div>
<ViewEditTags target_object={"callback_id"} target_object_id={rowData.id} />
<TagsDisplay tags={cellData} expand={false} />
</div>
)
}, areEqual)
}, areEqual)
@@ -14,7 +14,6 @@ import {
CallbacksTabsCustomFileBasedBrowserLabel,
CallbacksTabsCustomFileBasedBrowserPanel
} from "./CallbacksTabsCustomFileBasedBrowser";
import {CallbackBrowserTreeProvider} from "./CallbackBrowserTreeStore";
export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clickedTabId, setClickedTabId, onEditTabDescription, contextMenuOptions, me, newDataForTab, setNewDataForTab}) {
@@ -75,11 +74,8 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
},]
return (
<CallbackBrowserTreeProvider openTabs={openTabs}>
<div style={{width: "100%", maxWidth: "100%", display: 'flex', flexDirection: 'column',
flexGrow: 1, height: "100%", }}>
<AppBar color='default' position={"static"} className="no-box-shadow mythic-table-toolbar flex flex-none flex-wrap gap-4 w-full rounded bg-surface-muted border-subtle"
style={{marginBottom: "0.2rem"}}>
<div style={{width: "100%", maxWidth: "100%", display: 'flex', flexDirection: 'column', flexGrow: 1, height: "100%" }}>
<AppBar color='default' position={"static"} style={{backgroundColor: "transparent"}} >
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="callback-tabs-list" direction={"horizontal"}>
{(provided) => (
@@ -92,6 +88,7 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
textColor='primary'
variant='fullWidth'
scrollButtons={true}
style={{ }}
TabIndicatorProps={{style: {display: "none"}}}
aria-label='scrollable tabs'>
{openTabs.map((tab, index) => {
@@ -322,6 +319,5 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
}
})}
</div>
</CallbackBrowserTreeProvider>
);
}
@@ -1,4 +1,3 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import { MythicTabPanel, MythicTabLabel } from '../../MythicComponents/MythicTabPanel';
import React, { useEffect, useCallback } from 'react';
import { gql, useLazyQuery, useQuery, useSubscription, useMutation } from '@apollo/client';
@@ -7,12 +6,13 @@ import { MythicDialog } from '../../MythicComponents/MythicDialog';
import MythicTextField from '../../MythicComponents/MythicTextField';
import Grid from '@mui/material/Grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import IconButton from '@mui/material/IconButton';
import { CallbacksTabsFileBrowserTree } from './CallbacksTabsFileBrowserTree';
import IosShareIcon from '@mui/icons-material/IosShare';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { MythicModifyStringDialog } from '../../MythicComponents/MythicDialog';
import {Backdrop} from '@mui/material';
import { Backdrop } from '@mui/material';
import {CircularProgress} from '@mui/material';
import {TaskFromUIButton} from './TaskFromUIButton';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
@@ -24,7 +24,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import PlaylistRemoveIcon from '@mui/icons-material/PlaylistRemove';
import {GetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {useGetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
import {loadedCommandsQuery} from "./CallbacksTabsProcessBrowser";
import {getSkewedNow} from "../../utilities/Time";
@@ -32,10 +32,9 @@ import {CallbacksTabsCustomFileBasedBrowserTable} from "./CallbacksTabsCustomFil
import EditIcon from '@mui/icons-material/Edit';
import SettingsInputComponentRoundedIcon from '@mui/icons-material/SettingsInputComponentRounded';
import KeyboardArrowDownRoundedIcon from '@mui/icons-material/KeyboardArrowDownRounded';
import {getDefaultBrowserSelection, useCallbackBrowserTree} from "./CallbackBrowserTreeStore";
const fileBrowserDataFragment = gql`
fragment fileBrowserObjData on mythictree {
const fileDataFragment = gql`
fragment fileObjData on mythictree {
comment
deleted
task_id
@@ -66,45 +65,45 @@ const fileBrowserDataFragment = gql`
}
`;
const rootFileQuery = gql`
${fileBrowserDataFragment}
${fileDataFragment}
query myRootCustomFolderQuery($tree_type: String!) {
mythictree(where: { parent_path_text: { _eq: "" }, tree_type: {_eq: $tree_type} }) {
...fileBrowserObjData
...fileObjData
}
}
`;
const folderQuery = gql`
${fileBrowserDataFragment}
${fileDataFragment}
query myFolderQuery($parent_path_text: String!, $parents: [String], $tree_type: String!) {
children: mythictree(
where: { parent_path_text: { _eq: $parent_path_text }, tree_type: {_eq: $tree_type} }
order_by: { can_have_children: asc, name: asc }
) {
...fileBrowserObjData
...fileObjData
}
parents: mythictree(
where: { full_path_text: { _in: $parents }, tree_type: {_eq: $tree_type} }
order_by: { can_have_children: asc, name: asc }
) {
...fileBrowserObjData
...fileObjData
}
self: mythictree(
where: { full_path_text: { _eq: $parent_path_text }, tree_type: {_eq: $tree_type} }
order_by: { can_have_children: asc, name: asc }
) {
...fileBrowserObjData
...fileObjData
}
}
`;
const fileDataSubscription = gql`
${fileBrowserDataFragment}
${fileDataFragment}
subscription liveData($now: timestamp!, $tree_type: String!) {
mythictree_stream(
batch_size: 1000,
cursor: {initial_value: {timestamp: $now}},
where: {tree_type: {_eq: $tree_type} }
) {
...fileBrowserObjData
...fileObjData
}
}
`;
@@ -200,25 +199,6 @@ export function CallbacksTabsCustomFileBasedBrowserLabel(props) {
export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo, me, setNewDataForTab }) => {
const fromNow = React.useRef(getSkewedNow());
const [treeType, setTreeType] = React.useState(tabInfo.customBrowser.name);
const active = index === value;
const markInactiveChange = useCallback(() => {
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {
...previous,
[tabInfo.tabID]: true,
});
}, [setNewDataForTab, tabInfo.tabID]);
const {
treeRootDataRef,
treeAdjMtx,
version: treeVersion,
hydrated: treeHydrated,
setTreeAdjMtx,
} = useCallbackBrowserTree({
treeType,
mode: "custom",
active,
onInactiveChange: markInactiveChange,
});
const [treeConfig, setTreeConfig] = React.useState({
name: tabInfo.customBrowser.name,
table: {
@@ -236,6 +216,9 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
export_function: tabInfo.customBrowser?.export_function || "",
});
const [backdropOpen, setBackdropOpen] = React.useState(false);
const treeRootDataRef = React.useRef({}); // hold all of the actual data
// hold the simple adjacency matrix for parent/child relationships
const [treeAdjMtx, setTreeAdjMtx] = React.useState({});
const [selectedFolderData, setSelectedFolderData] = React.useState({
full_path_text: '',
host: tabInfo.host,
@@ -264,7 +247,6 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
const loadingCommandsRef = React.useRef(false);
useQuery(rootFileQuery, {
variables: {tree_type: treeType},
skip: true,
onCompleted: (data) => {
// use an adjacency matrix but only for full_path_text -> children, not both directions
// create the top level data in the treeRootDataRef
@@ -335,7 +317,6 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
});
useSubscription(fileDataSubscription, {
variables: {now: fromNow.current, tree_type:treeType},
skip: true,
fetchPolicy: "no-cache",
onData: ({data}) => {
for(let i = 0; i < data.data.mythictree_stream.length; i++){
@@ -404,37 +385,9 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
}
}
})
const initializedSelectionRef = React.useRef(false);
React.useEffect(() => {
if(!treeHydrated || initializedSelectionRef.current){return;}
const selection = getDefaultBrowserSelection(
treeRootDataRef.current,
treeAdjMtx,
tabInfo.callbackID,
tabInfo.host,
);
setSelectedFolderData({
full_path_text: '',
host: selection.host,
group: selection.group,
parent_path_text: "",
tree_type: treeType,
display_path_text: "",
});
initializedSelectionRef.current = true;
}, [tabInfo.callbackID, tabInfo.host, treeAdjMtx, treeHydrated, treeRootDataRef, treeType, treeVersion]);
React.useEffect(() => {
if(!active || !initializedSelectionRef.current){return;}
setSelectedFolderData((previous) => {
const current = treeRootDataRef.current[previous.group]?.[previous.host]?.[previous.full_path_text];
if(!current){return previous;}
return {...current, group: previous.group, fromHistory: previous.fromHistory};
});
}, [active, treeRootDataRef, treeVersion]);
useSubscription(fileBrowserTaskSub, {
variables: {now: fromNow.current, callback_id: tabInfo.callbackID, tree_type: treeType + "_browser"},
fetchPolicy: "no-cache",
ignoreResults: true,
onData: ({data}) => {
for(let i = 0; i < data.data.task_stream.length; i++){
if(data.data.task_stream[i].status.toLowerCase().includes("error") && data.data.task_stream[i].completed){
@@ -728,7 +681,7 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
return (
<MythicTabPanel index={index} value={value}>
<Split direction="horizontal" style={{width: "100%", height: "100%", display: "flex", overflow: "hidden"}} minSize={[0,0]} sizes={[30, 70]} >
<div className="bg-gray-base" style={{display: "inline-flex", height: "100%", minHeight: 0, minWidth: 0, overflow: "hidden"}}>
<div className="bg-gray-base" style={{display: "inline-flex"}}>
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
<CircularProgress color="inherit" />
</Backdrop>
@@ -748,11 +701,10 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
/>
</div>
<div className="bg-gray-light" style={{display: "inline-flex", height: "100%", minHeight: 0, minWidth: 0, overflow: "hidden"}}>
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, minHeight: 0, minWidth: 0, overflow: "hidden" }}>
<div className="bg-gray-light" style={{display: "inline-flex"}}>
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: "hidden" }}>
<div style={{ flexGrow: 0 }}>
<FileBrowserTableTop
active={active}
tabInfo={tabInfo}
taskingTableTopTypedDataRef={taskingTableTopTypedDataRef}
autoTaskLsOnEmptyDirectoriesRef={autoTaskLsOnEmptyDirectoriesRef}
@@ -768,13 +720,11 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
treeConfig={treeConfig}
/>
</div>
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "hidden" }}>
<div style={{ flexGrow: 1 }}>
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
<CircularProgress color="inherit" />
</Backdrop>
<CallbacksTabsCustomFileBasedBrowserTable
active={active}
dataVersion={treeVersion}
tabInfo={tabInfo}
showDeletedFiles={showDeletedFiles}
onRowDoubleClick={fetchFolderData}
@@ -810,7 +760,6 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
);
};
const FileBrowserTableTop = ({
active,
selectedFolderData,
taskingTableTopTypedDataRef,
autoTaskLsOnEmptyDirectoriesRef,
@@ -823,9 +772,9 @@ const FileBrowserTableTop = ({
tabInfo,
treeConfig
}) => {
const [autoTaskLsOnEmptyDirectories, setAutoTaskLsOnEmptyDirectories] = React.useState(() => GetMythicSetting({
const autoTaskLsOnEmptyDirectories = useGetMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories", default_value: false
}));
});
const [updateMythicSetting] = useSetMythicSetting();
const [openEditHostDialog, setOpenEditHostDialog] = React.useState(false);
const [fullPath, setFullPath] = React.useState('');
@@ -899,8 +848,6 @@ const FileBrowserTableTop = ({
}
useSubscription(subscriptionCallbackTokens, {
variables: {callback_id: tabInfo.callbackID}, fetchPolicy: "no-cache",
skip: !active,
ignoreResults: true,
shouldResubscribe: true,
onData: ({data}) => {
setTokenOptions(data.data.callbacktoken);
@@ -1001,15 +948,8 @@ const FileBrowserTableTop = ({
}});
};
const onToggleAutoTaskLsOnEmptyDirectories = () => {
const nextAutoTaskLsOnEmptyDirectories = !autoTaskLsOnEmptyDirectories;
setAutoTaskLsOnEmptyDirectories(nextAutoTaskLsOnEmptyDirectories);
autoTaskLsOnEmptyDirectoriesRef.current = nextAutoTaskLsOnEmptyDirectories;
updateMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories",
value: nextAutoTaskLsOnEmptyDirectories,
broadcast: false,
});
if(!nextAutoTaskLsOnEmptyDirectories){
updateMythicSetting({setting_name: "autoTaskLsOnEmptyDirectories", value: !autoTaskLsOnEmptyDirectories});
if(autoTaskLsOnEmptyDirectories){
snackActions.info("No longer auto issuing listings for empty paths");
} else {
snackActions.success("Now starting to auto issue listings for empty paths");
@@ -1047,7 +987,7 @@ const FileBrowserTableTop = ({
autoTaskLsOnEmptyDirectoriesRef.current = autoTaskLsOnEmptyDirectories;
}, [autoTaskLsOnEmptyDirectories]);
return (
<Grid container spacing={0} className="mythic-file-browser-tableTop">
<Grid container spacing={0} style={{ paddingTop: '5px' }}>
<Grid size={12}>
{openEditHostDialog && (
<MythicDialog
@@ -1077,70 +1017,63 @@ const FileBrowserTableTop = ({
name={<>
{placeHolder}
<MythicStyledTooltip title={`Edit the supplied Host field`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" compact shape="square" tone="info"
onClick={() => {setOpenEditHostDialog(true);}}
size="small">
<EditIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
onClick={() => {setOpenEditHostDialog(true);}}
size="large">
<EditIcon color='info' />
</IconButton>
</MythicStyledTooltip>
{placeHolderGroups}
</>}
marginTop="0"
marginBottom="0"
InputProps={{
className: "mythic-file-browser-pathInput",
endAdornment: (
<div className="mythic-file-browser-toolbarGroup items-center inline-flex mythic-file-browser-toolbarGroupEnd">
<React.Fragment>
<MythicStyledTooltip title={`Task current callback (${tabInfo["displayID"]}) to list contents`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
onClick={onLocalListFilesButton}
size="small">
<RefreshIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
onClick={onLocalListFilesButton}
size="large">
<RefreshIcon color='info' />
</IconButton>
</MythicStyledTooltip>
{autoTaskLsOnEmptyDirectories ? (
<MythicStyledTooltip title={"Currently tasking listing on empty directories, click to toggle off"} >
<MythicActionButton iconOnly
appearance="plain" active colorMode="hover" shape="square" tone="success"
onClick={onToggleAutoTaskLsOnEmptyDirectories}
disabled={!treeConfig.show_current_path}
size="small">
<PlaylistAddIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{padding: "3px"}}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
disabled={!treeConfig.show_current_path}
size={"large"}>
<PlaylistAddIcon color={"success"} ></PlaylistAddIcon>
</IconButton>
</MythicStyledTooltip>
) : (
<MythicStyledTooltip title={"Currently not tasking listing on empty directories, click to toggle on"} >
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="warning"
disabled={!treeConfig.show_current_path}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size="small">
<PlaylistRemoveIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{padding: "3px"}}
disabled={!treeConfig.show_current_path}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size={"large"}>
<PlaylistRemoveIcon color={"secondary"} ></PlaylistRemoveIcon>
</IconButton>
</MythicStyledTooltip>
)}
<MythicStyledTooltip title={showDeletedFiles ? 'Hide Deleted Entries' : 'Show Deleted Entries'}>
<MythicActionButton iconOnly
appearance="plain" active={showDeletedFiles} colorMode="hover" shape="square" tone="warning"
<IconButton
style={{ padding: '3px' }}
onClick={onLocalToggleShowDeletedFiles}
size="small">
size="large">
{showDeletedFiles ? (
<VisibilityIcon fontSize="small" />
<VisibilityIcon color="success" />
) : (
<VisibilityOffIcon fontSize="small" />
<VisibilityOffIcon color={"secondary"} />
)}
</MythicActionButton>
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={`Export Current Path and Children`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
disabled={treeConfig.export_function === ""}
onClick={onLocalExportButton}
size="small">
<IosShareIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
disabled={treeConfig.export_function === ""}
onClick={onLocalExportButton}
color="info"
size="large">
<IosShareIcon/>
</IconButton>
</MythicStyledTooltip>
{treeConfig.extra_table_inputs?.length > 0 &&
<MythicStyledTooltip title={
@@ -1148,60 +1081,61 @@ const FileBrowserTableTop = ({
showExtraInputs ?
`Hide Extra Browser Inputs ${extraDataSet ? "( Extra Data Currently Set )" : ""}` :
`Show Extra Browser Inputs ${extraDataSet ? "( Extra Data Currently Set )" : ""}`}>
<MythicActionButton iconOnly
appearance="plain" active={extraDataRequired || extraDataSet} autoWidth colorMode="hover" shape="square" tone={extraDataRequired ? "error" : extraDataSet ? "warning" : "info"}
onClick={() => {setShowExtraInputs(!showExtraInputs)}}
disableFocusRipple={true}
disableRipple={true}
size="small">
<SettingsInputComponentRoundedIcon fontSize="small" />
<IconButton style={{ padding: '3px' }}
onClick={() => {setShowExtraInputs(!showExtraInputs)}}
disableFocusRipple={true}
disableRipple={true}
size="large">
<SettingsInputComponentRoundedIcon color={extraDataRequired ? "error" : extraDataSet ? "warning" : "info"} />
{showExtraInputs ? (
<KeyboardArrowDownRoundedIcon style={{rotate: "180deg"}} />
) : (
<KeyboardArrowDownRoundedIcon style={{}} />
) }
</MythicActionButton>
</IconButton>
</MythicStyledTooltip>
}
</div>
</React.Fragment>
),
startAdornment: (
<div className="mythic-file-browser-toolbarGroup items-center inline-flex mythic-file-browser-toolbarGroupStart">
<div style={{display: "inline-flex",
alignItems: "center",
borderRight: "1px solid grey",
marginRight: "10px",
padding: "0 5px 0 0"}}>
{tokenOptions.length > 0 &&
<div className="mythic-file-browser-tokenSelect overflow-hidden">
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
</div>
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
}
<MythicStyledTooltip title={`Move back to previous listing`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
disabled={historyIndex >= history.length -1 }
onClick={moveIndexToPreviousListing}
size="small">
<ArrowBackIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
disabled={historyIndex >= history.length -1 }
onClick={moveIndexToPreviousListing}
color='info'
size="large">
<ArrowBackIcon />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={`Move to next listing`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
disabled={historyIndex <= 0}
onClick={moveIndexToNextListing}
size="small">
<ArrowForwardIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
disabled={historyIndex <= 0}
onClick={moveIndexToNextListing}
size="large"
color='info'>
<ArrowForwardIcon />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Move up a directory"} >
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
onClick={onLocalMoveUpDirectoryButton}
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
<IconButton style={{padding: "0 0 0 0"}}
onClick={onLocalMoveUpDirectoryButton}
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
>
<KeyboardReturnIcon fontSize="small" style={{rotate: "90deg"}} />
</MythicActionButton>
<KeyboardReturnIcon style={{rotate: "90deg"}} ></KeyboardReturnIcon>
</IconButton>
</MythicStyledTooltip>
</div>),
style: { },
}}
/>
{showExtraInputs &&
@@ -1,4 +1,3 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import React, { useEffect } from 'react';
import { useMutation, useLazyQuery, gql } from '@apollo/client';
import { snackActions } from '../../utilities/Snackbar';
@@ -13,6 +12,7 @@ import DescriptionIcon from '@mui/icons-material/Description';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorIcon from '@mui/icons-material/Error';
import { useTheme } from '@mui/material/styles';
import { IconButton } from '@mui/material';
import EditIcon from '@mui/icons-material/Edit';
import VisibilityIcon from '@mui/icons-material/Visibility';
import ListIcon from '@mui/icons-material/List';
@@ -24,6 +24,7 @@ import { copyStringToClipboard } from '../../utilities/Clipboard';
import MythicResizableGrid from '../../MythicComponents/MythicResizableGrid';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
import {faFilter} from '@fortawesome/free-solid-svg-icons';
import {MythicTransferListDialog} from '../../MythicComponents/MythicTransferList';
import {TagsDisplay, ViewEditTags} from '../../MythicComponents/MythicTag';
import { toLocalTime } from '../../utilities/Time';
import RefreshIcon from '@mui/icons-material/Refresh';
@@ -35,13 +36,6 @@ import {
} from "../../MythicComponents/MythicSavedUserSetting";
import {getIconColor, getIconName} from "./ResponseDisplayTable";
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
import {
GridColumnFilterDialog,
getUpdatedGridFilterOptions,
gridValuePassesFilter,
isGridColumnFilterActive
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
import {operatorSettingDefaults} from "../../../cache";
const updateFileComment = gql`
mutation updateCommentMutation($mythictree_id: Int!, $comment: String!) {
@@ -54,7 +48,7 @@ const updateFileComment = gql`
export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
const theme = useTheme();
const [updateSetting, updateSettings] = useSetMythicSetting();
const [updateSetting] = useSetMythicSetting();
const [allData, setAllData] = React.useState([]);
const [openReorderDialog, setOpenReorderDialog] = React.useState(false);
const [openContextMenu, setOpenContextMenu] = React.useState(false);
@@ -65,7 +59,6 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
const permissionDataRef = React.useRef({metadata: {}, name: ""});
const commentDataRef = React.useRef({id: 0, comment: ""});
const [loading, setLoading] = React.useState(true);
const [virtualizedTablePadding, setVirtualizedTablePadding] = React.useState(operatorSettingDefaults.virtualizedTablePadding);
const [sortData, setSortData] = React.useState({"sortKey": null, "sortDirection": null, "sortType": null})
const [columnVisibility, setColumnVisibility] = React.useState({
"visible": [...props.treeConfig.table.visible],
@@ -92,7 +85,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
() =>
columnOrder.reduce( (prev, cur) => {
if(columnVisibility.visible.includes(cur.name)){
if(isGridColumnFilterActive(filterOptions[cur.key])){
if(filterOptions[cur.key] && String(filterOptions[cur.key]).length > 0){
return [...prev, {...cur, filtered: true}];
}else{
return [...prev, {...cur}];
@@ -183,10 +176,9 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
return tempData;
}, [allData, sortData]);
const onSubmitFilterOptions = (value) => {
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
setFilterOptions({...filterOptions, [selectedColumn.key]: value });
try{
updateSetting({setting_name: `${props.treeConfig.name}_browser_filter_options`, value: nextFilterOptions, broadcast: false});
updateSetting({setting_name: `${props.treeConfig.name}_browser_filter_options`, value: {...filterOptions, [selectedColumn.key]: value }});
}catch(error){
console.log("failed to save filter options");
}
@@ -202,11 +194,11 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
}
for(const [key,value] of Object.entries(filterOptions)){
if(["name_text", "comment"].includes(key)){
if(!gridValuePassesFilter(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key], value)){
if(!String(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key]).toLowerCase().includes(value)){
return true;
}
} else {
if(!gridValuePassesFilter(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row].metadata[key], value)){
if(!String(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row].metadata[key]).toLowerCase().includes(value)){
return true;
}
}
@@ -214,14 +206,14 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
}
return false;
}
const cachedGridDataRef = React.useRef([]);
const gridData = React.useMemo(() => {
if(props.active === false){return cachedGridDataRef.current;}
const nextGridData = [];
// row is just the name
sortedData.forEach((row) => {
if(!filterRow(row)){
nextGridData.push(columns.map( c => {
const gridData = React.useMemo(
() =>
// row is just the name
sortedData.reduce((prev, row) => {
if(filterRow(row)){
return [...prev];
}else{
return [...prev, columns.map( c => {
switch(c.type) {
case "name":
return <FileBrowserTableRowNameCell
@@ -258,13 +250,12 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
cellData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]?.metadata?.[c.key] || ""}
rowData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]}/>
}
}));
}
});
cachedGridDataRef.current = nextGridData;
return nextGridData;
}, [columns, sortedData, props.active, props.dataVersion, props.onTaskRowAction, filterOptions,
props.selectedFolderData, props.showDeletedFiles, props.treeRootData]);
})];
}
}, []),
[sortedData, props.onTaskRowAction, filterOptions, columnVisibility, props.showDeletedFiles]
);
const getDisplayFormat = () => {
if(sortedData.length === 0 && props?.selectedFolderData?.success === false){
return "showTask";
@@ -282,7 +273,6 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
}
const displayFormat = getDisplayFormat();
useEffect(() => {
if(props.active === false){return;}
// when the folder changes, we need to aggregate all of the entries
//console.log(props.selectedFolderData, props.treeAdjMatrix, props.treeRootData)
let desiredPath = props.selectedFolderData.full_path_text;
@@ -292,7 +282,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
let newAllData = Object.keys(props.treeAdjMatrix[props.selectedFolderData.group]?.[props.selectedFolderData.host]?.[desiredPath] || {});
setAllData(newAllData);
//console.log("just set all data")
}, [props.active, props.dataVersion, props.selectedFolderData, props.treeAdjMatrix]);
}, [props.selectedFolderData, props.treeAdjMatrix]);
const onRowDoubleClick = (e, rowIndex, rowData) => {
if (!rowData.can_have_children) {
return;
@@ -486,8 +476,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
{
name: removeDisplay, type: "item",
disabled: removeCommand === undefined,
danger: true,
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
click: ({event}) => {
event.stopPropagation();
props.onTaskRowAction({
@@ -557,8 +546,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
{
name: `Remove All Selected`, type: "item",
disabled: removeCommand === undefined,
danger: true,
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
click: ({event}) => {
event.stopPropagation();
let newTasks = [];
@@ -674,14 +662,6 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
}catch(error){
console.log("Failed to load custom browser_table_filter_options", error);
}
try {
const storageItem = GetMythicSetting({setting_name: "virtualizedTablePadding", default_value: operatorSettingDefaults.virtualizedTablePadding});
if(storageItem !== null){
setVirtualizedTablePadding(parseInt(storageItem));
}
}catch(error){
console.log("Failed to load virtualizedTablePadding", error);
}
setLoading(false);
}, []);
const onSubmitColumnReorder = (newOrder) => {
@@ -698,15 +678,10 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
snackActions.error("Can't update to show no fields");
return;
}
updateSetting({setting_name: `${props.treeConfig.name}_browser_column_order`, value: newOrder.map(c => c.name)});
setColumnOrder(newOrder);
setColumnVisibility({visible: newVisible, hidden: newHidden});
updateSettings({
settings: {
[`${props.treeConfig.name}_browser_column_order`]: newOrder.map(c => c.name),
[`${props.treeConfig.name}_browser_table_columns`]: newVisible,
},
broadcast: false,
});
updateSetting({setting_name: `${props.treeConfig.name}_browser_table_columns`, value: newVisible});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -736,7 +711,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
sortIndicatorIndex={sortColumn}
sortDirection={sortData.sortDirection}
items={gridData}
rowHeight={GetComputedFontSize() + virtualizedTablePadding}
rowHeight={GetComputedFontSize() + 7}
onClickHeader={onClickHeader}
onDoubleClickRow={onRowDoubleClick}
contextMenuOptions={contextMenuOptions}
@@ -807,17 +782,17 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: `1px solid ${theme.palette.divider}`,
border: "1px solid black",
padding: "5px",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
}}>
{"Only PARTIAL data has been collected for this path. "}<br/>
{"Task this callback to list the contents"}
<MythicActionButton iconOnly style={{margin: 0, padding: 0, marginRight: "10px"}}
<IconButton style={{margin: 0, padding: 0, marginRight: "10px"}}
onClick={props.onListFilesButtonFromTableWithNoEntries}>
<RefreshIcon color={"info"} fontSize={"large"}
style={{display: "inline-block",}}/>
</MythicActionButton>
</IconButton>
</div>
</div>
}
@@ -828,9 +803,9 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: `1px solid ${theme.palette.divider}`,
border: "1px solid black",
padding: "5px",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
}}>
{"Some data exists for this path, but isn't loaded into the UI. "}
<br/>
@@ -845,17 +820,17 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: `1px solid ${theme.palette.divider}`,
border: "1px solid black",
padding: "5px",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
}}>
{"No data has been collected for this path. "}
<div style={{display: "flex", alignItems: "center"}}>
{"Task this callback to list the contents"}
<MythicActionButton iconOnly style={{margin: 0, padding: 0, marginRight: "10px"}} onClick={props.onListFilesButtonFromTableWithNoEntries} >
<IconButton style={{margin: 0, padding: 0, marginRight: "10px"}} onClick={props.onListFilesButtonFromTableWithNoEntries} >
<RefreshIcon color={"info"} fontSize={"large"}
style={{ display: "inline-block",}} />
</MythicActionButton>
</IconButton>
</div>
</div>
</div>
@@ -869,10 +844,10 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
setOpenContextMenu(false);
}}
innerDialog={
<GridColumnFilterDialog
<MythicModifyStringDialog
title='Filter Column'
onSubmit={onSubmitFilterOptions}
filterValue={filterOptions[selectedColumn.key]}
selectedColumn={selectedColumn}
value={filterOptions[selectedColumn.key]}
onClose={() => {
setOpenContextMenu(false);
}}
@@ -998,4 +973,4 @@ export const TableRowSizeCell = ({ cellData, rowData }) => {
}
};
return getStringSize(cellData);
};
};
@@ -1,19 +1,19 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import { MythicTabPanel, MythicTabLabel } from '../../MythicComponents/MythicTabPanel';
import React, { useEffect, useCallback } from 'react';
import { gql, useLazyQuery, useSubscription } from '@apollo/client';
import { gql, useLazyQuery, useQuery, useSubscription } from '@apollo/client';
import { snackActions } from '../../utilities/Snackbar';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import MythicTextField from '../../MythicComponents/MythicTextField';
import Grid from '@mui/material/Grid';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import RefreshIcon from '@mui/icons-material/Refresh';
import IconButton from '@mui/material/IconButton';
import { CallbacksTabsFileBrowserTree } from './CallbacksTabsFileBrowserTree';
import { CallbacksTabsFileBrowserTable } from './CallbacksTabsFileBrowserTable';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { MythicModifyStringDialog } from '../../MythicComponents/MythicDialog';
import {Backdrop} from '@mui/material';
import { Backdrop } from '@mui/material';
import {CircularProgress} from '@mui/material';
import {TaskFromUIButton} from './TaskFromUIButton';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
@@ -26,11 +26,10 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import {b64DecodeUnicode} from "./ResponseDisplay";
import PlaylistAddIcon from '@mui/icons-material/PlaylistAdd';
import PlaylistRemoveIcon from '@mui/icons-material/PlaylistRemove';
import {GetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {useGetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
import {loadedCommandsQuery} from "./CallbacksTabsProcessBrowser";
import {getSkewedNow} from "../../utilities/Time";
import {getDefaultBrowserSelection, useCallbackBrowserTree} from "./CallbackBrowserTreeStore";
const fileDataFragment = gql`
fragment fileObjData on mythictree {
@@ -68,6 +67,14 @@ const fileDataFragment = gql`
}
}
`;
const rootFileQuery = gql`
${fileDataFragment}
query myRootFolderQuery {
mythictree(where: { parent_path_text: { _eq: "" }, tree_type: {_eq: "file"} }, order_by: {id: desc}) {
...fileObjData
}
}
`;
const folderQuery = gql`
${fileDataFragment}
query myFolderQuery($parent_path_text: String!, $parents: [String]) {
@@ -91,6 +98,18 @@ const folderQuery = gql`
}
}
`;
const fileDataSubscription = gql`
${fileDataFragment}
subscription liveData($now: timestamp!) {
mythictree_stream(
batch_size: 1000,
cursor: {initial_value: {timestamp: $now}},
where: {tree_type: {_eq: "file"} }
) {
...fileObjData
}
}
`;
const fileBrowserTaskSub = gql`
subscription fileBrowserTasks($now: timestamp!, $callback_id: Int!){
task_stream(
@@ -125,15 +144,6 @@ export const getAllParentNodes = (node) => {
}
return newNodes;
}
const mergeUniqueById = (existing = [], incoming = []) => [
...new Map([...existing, ...incoming].map((item) => [item.id, item])).values(),
];
const mergeUniqueValues = (existing = [], incoming = []) => [
...new Map([...existing, ...incoming].map((item) => [
item && typeof item === "object" ? JSON.stringify(item) : `${typeof item}:${item}`,
item,
])).values(),
];
export function CallbacksTabsFileBrowserLabel(props) {
const [description, setDescription] = React.useState('File Browser: ' + props.tabInfo.displayID);
const [openEditDescriptionDialog, setOpenEditDescriptionDialog] = React.useState(false);
@@ -190,26 +200,10 @@ export function CallbacksTabsFileBrowserLabel(props) {
}
export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNewDataForTab, baseUIFeature }) => {
const fromNow = React.useRef(getSkewedNow());
const active = index === value;
const markInactiveChange = useCallback(() => {
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {
...previous,
[tabInfo.tabID]: true,
});
}, [setNewDataForTab, tabInfo.tabID]);
const {
treeRootDataRef,
treeAdjMtx,
version: treeVersion,
hydrated: treeHydrated,
setTreeAdjMtx,
} = useCallbackBrowserTree({
treeType: "file",
mode: "file",
active,
onInactiveChange: markInactiveChange,
});
const [backdropOpen, setBackdropOpen] = React.useState(false);
const treeRootDataRef = React.useRef({}); // hold all of the actual data
// hold the simple adjacency matrix for parent/child relationships
const [treeAdjMtx, setTreeAdjMtx] = React.useState({});
const [selectedFolderData, setSelectedFolderData] = React.useState({
full_path_text: '',
host: tabInfo.host,
@@ -232,35 +226,164 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
});
const loadedCommandsRef = React.useRef({});
const loadingCommandsRef = React.useRef(false);
const initializedSelectionRef = React.useRef(false);
React.useEffect(() => {
if(!treeHydrated || initializedSelectionRef.current){return;}
const selection = getDefaultBrowserSelection(
treeRootDataRef.current,
treeAdjMtx,
tabInfo.callbackID,
tabInfo.host,
);
setSelectedFolderData({
full_path_text: '',
host: selection.host,
group: selection.group,
parent_path_text: "",
});
initializedSelectionRef.current = true;
}, [tabInfo.callbackID, tabInfo.host, treeAdjMtx, treeHydrated, treeRootDataRef, treeVersion]);
React.useEffect(() => {
if(!active || !initializedSelectionRef.current){return;}
setSelectedFolderData((previous) => {
const current = treeRootDataRef.current[previous.group]?.[previous.host]?.[previous.full_path_text];
if(!current){return previous;}
return {...current, group: previous.group, fromHistory: previous.fromHistory};
});
}, [active, treeRootDataRef, treeVersion]);
useQuery(rootFileQuery, {
onCompleted: (data) => {
// use an adjacency matrix but only for full_path_text -> children, not both directions
// create the top level data in the treeRootDataRef
let defaultGroup = "Default";
for(let i = 0; i < data.mythictree.length; i++){
let currentGroups = data.mythictree[i]?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
try{
if(data.mythictree[i]?.['callback']?.['id'] === tabInfo.callbackID){
if(!currentGroups.includes(defaultGroup, 0)){
defaultGroup = currentGroups[0];
}
}
}catch(error){
console.log(error);
}
for(let j = 0; j < currentGroups.length; j++){
if(treeRootDataRef.current[currentGroups[j]] === undefined){
treeRootDataRef.current[currentGroups[j]] = {};
}
if( treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]] === undefined) {
// new host discovered
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]] = {};
}
if(treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] === undefined){
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] = {...data.mythictree[i]};
} else {
if(treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]].success === null){
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] = {...data.mythictree[i]}
}
}
if(data.mythictree[i].id > treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]].id){
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]] |= data.mythictree[i].has_children;
treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]].success |= data.mythictree[i].success;
}
}
}
// create the top level data in the adjacency matrix
const newMatrix = data.mythictree.reduce( (prev, cur) => {
let currentGroups = cur?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
for(let j = 0; j < currentGroups.length; j++) {
if (prev[currentGroups[j]] === undefined) {
prev[currentGroups[j]] = {};
}
if (prev[currentGroups[j]][cur["host"]] === undefined) {
// the current host isn't tracked in the adjacency matrix, so add it
prev[currentGroups[j]][cur["host"]] = {}
}
if (prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]] === undefined) {
// the current parent's path isn't tracked, so add it and ourselves as children
prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]] = {};
}
prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]][cur["full_path_text"]] = 1;
}
return prev;
}, {...treeAdjMtx});
//console.log(treeRootDataRef.current);
setTreeAdjMtx(newMatrix);
setSelectedFolderData({
full_path_text: '',
host: tabInfo.host,
group: defaultGroup,
parent_path_text: "",
});
},
fetchPolicy: 'no-cache',
});
useSubscription(fileDataSubscription, {
variables: {now: fromNow.current},
fetchPolicy: "no-cache",
onData: ({data}) => {
for(let i = 0; i < data.data.mythictree_stream.length; i++){
let currentGroups = data.data.mythictree_stream[i]?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
for(let j = 0; j < currentGroups.length; j++) {
if (treeRootDataRef.current[currentGroups[j]] === undefined) {
treeRootDataRef.current[currentGroups[j]] = {};
}
if (treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]] === undefined) {
// new host discovered
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]] = {};
}
if(treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]] === undefined){
// first time we're seeing this file data, just add it
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]] = {...data.data.mythictree_stream[i]};
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]].filemeta = treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]].filemeta.map(f => {
return {...f, filename_text: b64DecodeUnicode(f.filename_text)}
})
if(selectedFolderData.group === currentGroups[j] && selectedFolderData.host === data.data.mythictree_stream[i]["host"] &&
selectedFolderData.full_path_text === data.data.mythictree_stream[i]["full_path_text"]){
setSelectedFolderData({...treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]],
group: currentGroups[j], fromHistory: selectedFolderData.fromHistory});
}
} else {
// we need to merge data in because we already have some info
let existingData = treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]];
if(existingData.task_id <= data.data.mythictree_stream[i].task_id){
existingData.deleted = data.data.mythictree_stream[i].deleted;
}
if( (existingData.success === null || !existingData.success) && data.data.mythictree_stream[i].success !== null){
existingData.success = data.data.mythictree_stream[i].success;
existingData.task_id = data.data.mythictree_stream[i].task_id;
}
existingData.comment = data.data.mythictree_stream[i].comment;
existingData.tags = [...existingData.tags, ...data.data.mythictree_stream[i].tags];
existingData.has_children = data.data.mythictree_stream[i].has_children || existingData.has_children;
existingData.metadata.size = data.data.mythictree_stream[i].metadata.size;
existingData.metadata.access_time = data.data.mythictree_stream[i].metadata.access_time;
existingData.metadata.modify_time = data.data.mythictree_stream[i].metadata.modify_time;
if(data.data.mythictree_stream[i].metadata.permissions !== undefined && data.data.mythictree_stream[i].metadata.permissions !== null){
if(Array.isArray(existingData.metadata.permissions) && Array.isArray(data.data.mythictree_stream[i].metadata.permissions)){
existingData.metadata.permissions = [...existingData.metadata.permissions, ...data.data.mythictree_stream[i].metadata.permissions];
} else if(Array.isArray(existingData.metadata.permissions) !== Array.isArray(data.data.mythictree_stream[i].metadata.permissions)) {
// had array/dict and got mismatched array/dict, so just take newer one
existingData.metadata.permissions = data.data.mythictree_stream[i].metadata.permissions;
} else {
existingData.metadata.permissions = {...existingData.metadata.permissions, ...data.data.mythictree_stream[i].metadata.permissions};
}
}
let newfileData = data.data.mythictree_stream[i].filemeta.map(f => {
return {...f, filename_text: b64DecodeUnicode(f.filename_text)};
})
existingData.filemeta = [...existingData.filemeta, ...newfileData]
existingData.filemeta.sort((a,b) => a.id > b.id ? 1 : -1);
treeRootDataRef.current[currentGroups[j]][data.data.mythictree_stream[i]["host"]][data.data.mythictree_stream[i]["full_path_text"]] = {...existingData};
if(selectedFolderData.group === currentGroups[j] && selectedFolderData.host === data.data.mythictree_stream[i]["host"] &&
selectedFolderData.full_path_text === data.data.mythictree_stream[i]["full_path_text"]){
setSelectedFolderData({...existingData, group: currentGroups[j], fromHistory: selectedFolderData.fromHistory});
}
}
}
}
const newMatrix = data.data.mythictree_stream.reduce( (prev, cur) => {
let currentGroups = cur?.["callback"]?.["mythictree_groups"] || ["Unknown Callbacks"];
for(let j = 0; j < currentGroups.length; j++) {
if (prev[currentGroups[j]] === undefined) {
prev[currentGroups[j]] = {};
}
if (prev[currentGroups[j]][cur["host"]] === undefined) {
// the current host isn't tracked in the adjacency matrix, so add it
prev[currentGroups[j]][cur["host"]] = {}
}
if (prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]] === undefined) {
// the current parent's path isn't tracked, so add it and ourselves as children
prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]] = {};
}
prev[currentGroups[j]][cur["host"]][cur["parent_path_text"]][cur["full_path_text"]] = 1;
}
return prev;
}, {...treeAdjMtx});
setTreeAdjMtx(newMatrix);
if(index !== value){
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: true}});
}
}
})
useSubscription(fileBrowserTaskSub, {
variables: {now: fromNow.current, callback_id: tabInfo.callbackID},
fetchPolicy: "no-cache",
ignoreResults: true,
onData: ({data}) => {
for(let i = 0; i < data.data.task_stream.length; i++){
if(data.data.task_stream[i].status.toLowerCase().includes("error") && data.data.task_stream[i].completed){
@@ -347,7 +470,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
existingData.task_id = mythictree[i].task_id;
}
existingData.comment += mythictree[i].comment;
existingData.tags = mergeUniqueById(existingData.tags, mythictree[i].tags);
existingData.tags = [...existingData.tags, ...mythictree[i].tags];
existingData.has_children = mythictree[i].has_children || existingData.has_children;
existingData.metadata.size = mythictree[i].metadata.size;
existingData.metadata.access_time = mythictree[i].metadata.access_time;
@@ -355,7 +478,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
//console.log("updating permissions", "existing", existingData.metadata.permissions, "new", mythictree[i].metadata)
if(mythictree[i].metadata.permissions !== undefined && mythictree[i].metadata.permissions !== null){
if(Array.isArray(existingData.metadata.permissions) && Array.isArray(mythictree[i].metadata.permissions)){
existingData.metadata.permissions = mergeUniqueValues(existingData.metadata.permissions, mythictree[i].metadata.permissions);
existingData.metadata.permissions = [...existingData.metadata.permissions, ...mythictree[i].metadata.permissions];
} else if(Array.isArray(existingData.metadata.permissions) !== Array.isArray(mythictree[i].metadata.permissions)) {
// had array/dict and got mismatched array/dict, so just take newer one
existingData.metadata.permissions = mythictree[i].metadata.permissions;
@@ -366,7 +489,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
let newfileData = mythictree[i].filemeta.map(f => {
return {...f, filename_text: b64DecodeUnicode(f.filename_text)};
})
existingData.filemeta = mergeUniqueById(existingData.filemeta, newfileData)
existingData.filemeta = [...existingData.filemeta, ...newfileData]
existingData.filemeta.sort((a,b) => a.id > b.id ? 1 : -1);
treeRootDataRef.current[currentGroups[j]][mythictree[i]["host"]][mythictree[i]["full_path_text"]] = {...existingData};
if(selectedFolderData.group === currentGroups[j] && selectedFolderData.host === mythictree[i]["host"] &&
@@ -559,8 +682,8 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
}
return (
<MythicTabPanel index={index} value={value}>
<Split direction="horizontal" className="mythic-eventing-split flex h-full min-h-0 min-w-0 overflow-hidden w-full" sizes={[30, 70]} >
<div className="mythic-eventing-sidebar flex flex-column min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
<Split direction="horizontal" style={{width: "100%", height: "100%", display: "flex", overflow: "hidden"}} sizes={[30, 70]} >
<div className="bg-gray-base" style={{display: "inline-flex"}}>
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
<CircularProgress color="inherit" />
</Backdrop>
@@ -579,11 +702,10 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
/>
</div>
<div className="mythic-eventing-content flex min-h-0 min-w-0 overflow-hidden rounded bg-surface-raised border-subtle">
<div className="mythic-process-browser-table-shell flex flex-column h-full min-h-0 min-w-0 overflow-hidden w-full relative">
<div>
<div className="bg-gray-light" style={{display: "inline-flex"}}>
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: "hidden" }}>
<div style={{ flexGrow: 0 }}>
<FileBrowserTableTop
active={active}
tabInfo={tabInfo}
taskingTableTopTypedDataRef={taskingTableTopTypedDataRef}
autoTaskLsOnEmptyDirectoriesRef={autoTaskLsOnEmptyDirectoriesRef}
@@ -598,13 +720,11 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
baseUIFeature={baseUIFeature}
/>
</div>
<div className="mythic-process-browser-grid-shell flex-fill min-h-0 min-w-0 overflow-hidden">
<div style={{ flexGrow: 1 }}>
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
<CircularProgress color="inherit" />
</Backdrop>
<CallbacksTabsFileBrowserTable
active={active}
dataVersion={treeVersion}
tabInfo={tabInfo}
showDeletedFiles={showDeletedFiles}
onRowDoubleClick={fetchFolderData}
@@ -639,7 +759,6 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
);
};
const FileBrowserTableTop = ({
active,
selectedFolderData,
taskingTableTopTypedDataRef,
autoTaskLsOnEmptyDirectoriesRef,
@@ -653,9 +772,9 @@ const FileBrowserTableTop = ({
tabInfo,
baseUIFeature
}) => {
const [autoTaskLsOnEmptyDirectories, setAutoTaskLsOnEmptyDirectories] = React.useState(() => GetMythicSetting({
const autoTaskLsOnEmptyDirectories = useGetMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories", default_value: false
}));
});
const [updateMythicSetting] = useSetMythicSetting();
const [fullPath, setFullPath] = React.useState('');
const selectedToken = React.useRef("Default Token");
@@ -684,8 +803,6 @@ const FileBrowserTableTop = ({
}
useSubscription(subscriptionCallbackTokens, {
variables: {callback_id: tabInfo.callbackID}, fetchPolicy: "no-cache",
skip: !active,
ignoreResults: true,
shouldResubscribe: true,
onData: ({data}) => {
setTokenOptions(data.data.callbacktoken);
@@ -714,15 +831,16 @@ const FileBrowserTableTop = ({
return;
}
if(selectedFolderData.id !== ""){
setHistory((currentHistory) => {
if(currentHistory[0]?.full_path_text === selectedFolderData.full_path_text){
return currentHistory;
if(history[0]?.full_path_text !== selectedFolderData.full_path_text){
// always add newest things to the bottom of the stack
setHistory([selectedFolderData, ...history]);
if(history.length > 20){
// pop off from the top (the oldest) if we get more than 50
setHistory([selectedFolderData, ...history.splice(history.length-1, 1)])
}
return [selectedFolderData, ...currentHistory].slice(0, 20);
});
setHistoryIndex(0);
}
}
}, [selectedFolderData])
}, [selectedFolderData, history])
const moveIndexToPreviousListing = () => {
// we're getting closer to the end of the historyRef.current, the oldest listing
if(historyIndex >= history.length -1){
@@ -768,14 +886,7 @@ const FileBrowserTableTop = ({
}
};
const onToggleAutoTaskLsOnEmptyDirectories = () => {
const nextAutoTaskSetting = !autoTaskLsOnEmptyDirectories;
setAutoTaskLsOnEmptyDirectories(nextAutoTaskSetting);
autoTaskLsOnEmptyDirectoriesRef.current = nextAutoTaskSetting;
updateMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories",
value: nextAutoTaskSetting,
broadcast: false,
});
updateMythicSetting({setting_name: "autoTaskLsOnEmptyDirectories", value: !autoTaskLsOnEmptyDirectories});
if(autoTaskLsOnEmptyDirectories){
snackActions.info("No longer auto issuing listings for empty paths");
} else {
@@ -806,7 +917,7 @@ const FileBrowserTableTop = ({
autoTaskLsOnEmptyDirectoriesRef.current = autoTaskLsOnEmptyDirectories;
}, [autoTaskLsOnEmptyDirectories]);
return (
<Grid container spacing={0} className="mythic-file-browser-tableTop">
<Grid container spacing={0} style={{ paddingTop: '10px' }}>
<Grid size={12}>
<MythicTextField
placeholder={selectedFolderData.host}
@@ -814,98 +925,93 @@ const FileBrowserTableTop = ({
onEnter={goToDirectory}
onChange={onChangePath}
name={placeHolder}
marginTop="0"
marginBottom="0"
InputProps={{
className: "mythic-file-browser-pathInput",
endAdornment: (
<div className="mythic-file-browser-toolbarGroup items-center inline-flex mythic-file-browser-toolbarGroupEnd">
<React.Fragment>
<MythicStyledTooltip title={`Task current callback (${tabInfo["displayID"]}) to list contents`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
onClick={onLocalListFilesButton}
size="small">
<RefreshIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
onClick={onLocalListFilesButton}
size="large">
<RefreshIcon color='info' />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={`Upload file to folder via current callback (${tabInfo["displayID"]})`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
onClick={onLocalUploadFileButton}
size="small">
<CloudUploadIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
onClick={onLocalUploadFileButton}
size="large">
<CloudUploadIcon color="info" />
</IconButton>
</MythicStyledTooltip>
{autoTaskLsOnEmptyDirectories ? (
<MythicStyledTooltip title={"Currently tasking listing on empty directories, click to toggle off"} >
<MythicActionButton iconOnly
appearance="plain" active colorMode="hover" shape="square" tone="success"
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size="small">
<PlaylistAddIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{padding: "3px"}}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size={"large"}>
<PlaylistAddIcon color={"success"} ></PlaylistAddIcon>
</IconButton>
</MythicStyledTooltip>
) : (
<MythicStyledTooltip title={"Currently not tasking listing on empty directories, click to toggle on"} >
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="warning"
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size="small">
<PlaylistRemoveIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{padding: "3px"}}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size={"large"}>
<PlaylistRemoveIcon color={"secondary"} ></PlaylistRemoveIcon>
</IconButton>
</MythicStyledTooltip>
)}
<MythicStyledTooltip title={showDeletedFiles ? 'Hide Deleted Files' : 'Show Deleted Files'}>
<MythicActionButton iconOnly
appearance="plain" active={showDeletedFiles} colorMode="hover" shape="square" tone="warning"
<IconButton
style={{ padding: '3px' }}
onClick={onLocalToggleShowDeletedFiles}
size="small">
size="large">
{showDeletedFiles ? (
<VisibilityIcon fontSize="small" />
<VisibilityIcon color="success" />
) : (
<VisibilityOffIcon fontSize="small" />
<VisibilityOffIcon color="error" />
)}
</MythicActionButton>
</IconButton>
</MythicStyledTooltip>
</div>
</React.Fragment>
),
startAdornment: (
<div className="mythic-file-browser-toolbarGroup items-center inline-flex mythic-file-browser-toolbarGroupStart">
<div style={{display: "inline-flex",
alignItems: "center",
borderRight: "1px solid grey",
marginRight: "10px",
padding: "0 5px 0 0"}}>
{tokenOptions.length > 0 &&
<div className="mythic-file-browser-tokenSelect overflow-hidden">
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
</div>
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
}
<MythicStyledTooltip title={`Move back to previous listing`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
disabled={historyIndex >= history.length -1 }
onClick={moveIndexToPreviousListing}
size="small">
<ArrowBackIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
disabled={historyIndex >= history.length -1 }
onClick={moveIndexToPreviousListing}
color='info'
size="large">
<ArrowBackIcon />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={`Move to next listing`}>
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
disabled={historyIndex <= 0}
onClick={moveIndexToNextListing}
size="small">
<ArrowForwardIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{ padding: '3px' }}
disabled={historyIndex <= 0}
onClick={moveIndexToNextListing}
size="large"
color='info'>
<ArrowForwardIcon />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Move up a directory"} >
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
onClick={onLocalMoveUpDirectoryButton}
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
<IconButton style={{padding: "0 0 0 0"}}
onClick={onLocalMoveUpDirectoryButton}
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
>
<KeyboardReturnIcon fontSize="small" style={{rotate: "90deg"}} />
</MythicActionButton>
<KeyboardReturnIcon style={{rotate: "90deg"}} ></KeyboardReturnIcon>
</IconButton>
</MythicStyledTooltip>
</div>),
style: { },
}}
/>
</Grid>
@@ -1,4 +1,3 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import React, { useEffect } from 'react';
import { useMutation, useLazyQuery, gql } from '@apollo/client';
import { snackActions } from '../../utilities/Snackbar';
@@ -13,6 +12,7 @@ import DescriptionIcon from '@mui/icons-material/Description';
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline';
import ErrorIcon from '@mui/icons-material/Error';
import { useTheme } from '@mui/material/styles';
import { IconButton } from '@mui/material';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import EditIcon from '@mui/icons-material/Edit';
import { DownloadHistoryDialog } from './DownloadHistoryDialog';
@@ -31,7 +31,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
import { toLocalTime } from '../../utilities/Time';
import {b64DecodeUnicode} from "./ResponseDisplay";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {PreviewFileMediaDialog} from "../../MythicComponents/PreviewFileMedia";
import {PreviewFileMediaDialog} from "../Search/PreviewFileMedia";
import RefreshIcon from '@mui/icons-material/Refresh';
import {Dropdown, DropdownMenuItem, DropdownNestedMenuItem} from "../../MythicComponents/MythicNestedMenus";
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
@@ -41,13 +41,6 @@ import {
useSetMythicSetting
} from "../../MythicComponents/MythicSavedUserSetting";
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
import {
GridColumnFilterDialog,
getUpdatedGridFilterOptions,
gridValuePassesFilter,
isGridColumnFilterActive
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
import {operatorSettingDefaults} from "../../../cache";
const getFileDownloadHistory = gql`
query getFileDownloadHistory($full_path_text: String!, $host: String!, $group: [String!]) {
@@ -96,8 +89,7 @@ const columnDefaults = [
{ name: 'Comment', type: 'string', key: 'comment', width: 200, visible: false },
];
export const CallbacksTabsFileBrowserTable = (props) => {
const theme = useTheme();
const [updateSetting, updateSettings] = useSetMythicSetting();
const [updateSetting] = useSetMythicSetting();
const [loading, setLoading] = React.useState(true);
const [allData, setAllData] = React.useState([]);
const [openContextMenu, setOpenContextMenu] = React.useState(false);
@@ -108,7 +100,6 @@ export const CallbacksTabsFileBrowserTable = (props) => {
"visible": defaultVisibleColumns,
"hidden": ["Tags", "Comment"]
});
const [virtualizedTablePadding, setVirtualizedTablePadding] = React.useState(operatorSettingDefaults.virtualizedTablePadding);
const [openReorderDialog, setOpenReorderDialog] = React.useState(false);
const [columnOrder, setColumnOrder] = React.useState(columnDefaults);
const [selectedRows, setSelectedRows] = React.useState([]);
@@ -116,7 +107,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
() =>
columnOrder.reduce( (prev, cur) => {
if(columnVisibility.visible.includes(cur.name)){
if(isGridColumnFilterActive(filterOptions[cur.key])){
if(filterOptions[cur.key] && String(filterOptions[cur.key]).length > 0){
return [...prev, {...cur, filtered: true}];
}else{
return [...prev, {...cur}];
@@ -206,10 +197,11 @@ export const CallbacksTabsFileBrowserTable = (props) => {
return tempData;
}, [allData, sortData]);
const onSubmitFilterOptions = (value) => {
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
try{
updateSetting({setting_name: `file_browser_filter_options`, value: nextFilterOptions, broadcast: false});
let newValue = value ? value : "";
setFilterOptions({...filterOptions, [selectedColumn.key]: newValue });
updateSetting({setting_name: `file_browser_filter_options`, value: {...filterOptions,[selectedColumn.key]: newValue }});
}catch(error){
console.log("failed to save filter options", error);
}
@@ -224,20 +216,20 @@ export const CallbacksTabsFileBrowserTable = (props) => {
return true;
}
for(const [key,value] of Object.entries(filterOptions)){
if(!gridValuePassesFilter(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key], value)){
if(!String(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key]).toLowerCase().includes(value)){
return true;
}
}
return false;
}
const cachedGridDataRef = React.useRef([]);
const gridData = React.useMemo(() => {
if(props.active === false){return cachedGridDataRef.current;}
const nextGridData = [];
// row is just the name
sortedData.forEach((row) => {
if(!filterRow(row)){
nextGridData.push(columns.map( c => {
const gridData = React.useMemo(
() =>
// row is just the name
sortedData.reduce((prev, row) => {
if(filterRow(row)){
return [...prev];
}else{
return [...prev, columns.map( c => {
switch(c.name){
case "Info":
return <FileBrowserTableRowActionCell
@@ -271,13 +263,12 @@ export const CallbacksTabsFileBrowserTable = (props) => {
case "Comment":
return <FileBrowserTableRowStringCell cellData={row.comment} rowData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]} />
}
}));
}
});
cachedGridDataRef.current = nextGridData;
return nextGridData;
}, [columns, sortedData, props.active, props.dataVersion, props.onTaskRowAction, filterOptions,
props.selectedFolderData, props.showDeletedFiles, props.treeRootData]);
})];
}
}, []),
[sortedData, props.onTaskRowAction, filterOptions, columnVisibility, props.showDeletedFiles]
);
const getDisplayFormat = () => {
if(sortedData.length === 0 && props?.selectedFolderData?.success === false){
return "showTask";
@@ -295,7 +286,6 @@ export const CallbacksTabsFileBrowserTable = (props) => {
}
const displayFormat = getDisplayFormat();
useEffect(() => {
if(props.active === false){return;}
// when the folder changes, we need to aggregate all of the entries
//console.log(props.selectedFolderData, props.treeAdjMatrix, props.treeRootData)
let desiredPath = props.selectedFolderData.full_path_text;
@@ -305,7 +295,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
let newAllData = Object.keys(props.treeAdjMatrix[props.selectedFolderData.group]?.[props.selectedFolderData.host]?.[desiredPath] || {});
setAllData(newAllData);
//console.log("just set all data")
}, [props.active, props.dataVersion, props.selectedFolderData, props.treeAdjMatrix]);
}, [props.selectedFolderData, props.treeAdjMatrix]);
const onRowDoubleClick = (e, rowIndex, rowData) => {
if (!rowData.can_have_children) {
return;
@@ -402,14 +392,6 @@ export const CallbacksTabsFileBrowserTable = (props) => {
}catch(error){
console.log("Failed to load file_browser_table_filter_options", error);
}
try {
const storageItem = GetMythicSetting({setting_name: "virtualizedTablePadding", default_value: operatorSettingDefaults.virtualizedTablePadding});
if(storageItem !== null){
setVirtualizedTablePadding(parseInt(storageItem));
}
}catch(error){
console.log("Failed to load virtualizedTablePadding", error);
}
setLoading(false);
}, []);
const onSubmitColumnReorder = (newOrder) => {
@@ -426,15 +408,11 @@ export const CallbacksTabsFileBrowserTable = (props) => {
snackActions.error("Can't update to show no fields");
return;
}
console.log("newOrder", newOrder)
updateSetting({setting_name: `file_browser_column_order`, value: newOrder.map(c => c.name)});
setColumnOrder(newOrder);
setColumnVisibility({visible: newVisible, hidden: newHidden});
updateSettings({
settings: {
file_browser_column_order: newOrder.map(c => c.name),
file_browser_table_columns: newVisible,
},
broadcast: false,
});
updateSetting({setting_name: `file_browser_table_columns`, value: newVisible});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -563,8 +541,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
},
{
name: removeDisplay, type: "item", disabled: removeCommand === undefined,
danger: true,
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
click: ({event}) => {
event.stopPropagation();
props.onTaskRowAction({
@@ -607,8 +584,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
},
{
name: `Remove All Selected`, type: "item",
danger: true,
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
click: ({event}) => {
event.stopPropagation();
let newTasks = [];
@@ -684,7 +660,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
sortIndicatorIndex={sortColumn}
sortDirection={sortData.sortDirection}
items={gridData}
rowHeight={GetComputedFontSize() + virtualizedTablePadding}
rowHeight={GetComputedFontSize() + 7}
onClickHeader={onClickHeader}
onDoubleClickRow={onRowDoubleClick}
contextMenuOptions={contextMenuOptions}
@@ -699,17 +675,17 @@ export const CallbacksTabsFileBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: `1px solid ${theme.palette.divider}`,
border: "1px solid black",
padding: "5px",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
}}>
{"Only PARTIAL data has been collected for this path. "}<br/>
{"Task this callback to list the contents"}
<MythicActionButton iconOnly style={{margin: 0, padding: 0, marginRight: "10px"}}
<IconButton style={{margin: 0, padding: 0, marginRight: "10px"}}
onClick={props.onListFilesButtonFromTableWithNoEntries}>
<RefreshIcon color={"info"} fontSize={"large"}
style={{display: "inline-block",}}/>
</MythicActionButton>
</IconButton>
</div>
</div>
}
@@ -720,9 +696,9 @@ export const CallbacksTabsFileBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: `1px solid ${theme.palette.divider}`,
border: "1px solid black",
padding: "5px",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
}}>
{"Some data exists for this path, but isn't loaded into the UI. "}
<br/>
@@ -737,17 +713,17 @@ export const CallbacksTabsFileBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: `1px solid ${theme.palette.divider}`,
border: "1px solid black",
padding: "5px",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
}}>
{"No data has been collected for this path. "}
<div style={{display: "flex", alignItems: "center"}}>
{"Task this callback to list the contents"}
<MythicActionButton iconOnly style={{margin: 0, padding: 0, marginRight: "10px"}} onClick={props.onListFilesButtonFromTableWithNoEntries} >
<IconButton style={{margin: 0, padding: 0, marginRight: "10px"}} onClick={props.onListFilesButtonFromTableWithNoEntries} >
<RefreshIcon color={"info"} fontSize={"large"}
style={{ display: "inline-block",}} />
</MythicActionButton>
</IconButton>
</div>
</div>
</div>
@@ -759,10 +735,10 @@ export const CallbacksTabsFileBrowserTable = (props) => {
<MythicDialog fullWidth={true} maxWidth="sm" open={openContextMenu}
onClose={()=>{setOpenContextMenu(false);}}
innerDialog={
<GridColumnFilterDialog
<MythicModifyStringDialog
title='Filter Column'
onSubmit={onSubmitFilterOptions}
filterValue={filterOptions[selectedColumn.key]}
selectedColumn={selectedColumn}
value={filterOptions[selectedColumn.key]}
onClose={() => {
setOpenContextMenu(false);
}}
@@ -1000,17 +976,18 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
}
return (
<React.Fragment>
<MythicActionButton colorMode="always" tone="info" iconOnly
<IconButton
style={{height: "100%" }}
size='small'
aria-controls={dropdownOpen ? 'split-button-menu' : undefined}
aria-expanded={dropdownOpen ? 'true' : undefined}
aria-haspopup='menu'
onClick={handleDropdownToggle}
color='info'
variant='contained'
ref={dropdownAnchorRef}>
<SettingsIcon />
</MythicActionButton>
</IconButton>
{treeRootData[selectedFolderData.host][rowData.full_path_text]?.filemeta.length > 0 ?
<MythicStyledTooltip title={treeRootData[selectedFolderData.host][rowData.full_path_text]?.filemeta[0]?.complete ?
"Preview Media" : "Preview Partial Media"}>
@@ -1041,7 +1018,6 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
<DropdownMenuItem
key={option.name}
disabled={option.disabled}
className={option.danger ? "mythic-action-tone-hover mythic-tone-error" : undefined}
onClick={(event) => handleMenuItemClick(event, option.click)}
>
{option.icon} {option.name}
@@ -1055,7 +1031,6 @@ const FileBrowserTableRowActionCell = ({ rowData, cellData, onTaskRowAction, tre
<DropdownMenuItem
key={menuOption.name}
disabled={menuOption.disabled}
className={menuOption.danger ? "mythic-action-tone-hover mythic-tone-error" : undefined}
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
>
{menuOption.icon}{menuOption.name}
@@ -1,13 +1,14 @@
import React from 'react';
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
import React, {useEffect} from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import {useQuery, gql } from '@apollo/client';
import {useMutation} from '@apollo/client';
import {hideCallbacksMutation} from './CallbackMutations';
import {snackActions} from "../../utilities/Snackbar";
import {CallbacksTableLastCheckinCell, CallbacksTablePayloadTypeCell, CallbacksTableIPCell} from "./CallbacksTableRow";
import { DataGrid } from '@mui/x-data-grid';
import DialogContentText from '@mui/material/DialogContentText';
import {CallbacksTabsSelectTable} from "./CallbacksTabsSelectTable";
const callbacksAndFeaturesQuery = gql`
@@ -34,6 +35,107 @@ query callbacksAndFeatures{
}
}`;
const columns = [
{ field: 'display_id', headerName: 'ID', width: 70, type: 'number', },
{
field: 'host',
headerName: 'Host',
flex: 0.5,
},
{
field: 'user',
headerName: 'User',
flex: 0.5,
},
{
field: 'pid',
headerName: 'PID',
type: 'number',
width: 70,
},
{
field: 'description',
headerName: 'Description',
flex: 1,
},
{
field: 'ip',
headerName: 'IP',
width: 100,
renderCell: (params) => <CallbacksTableIPCell rowData={params.row} cellData={params.row.ip} />,
sortable: false,
valueGetter: (value, row) => {
try{
return JSON.parse(row.ip)[0];
}catch(error){
return row.ip;
}
}
},
{
field: "last_checkin",
headerName: "Checkin",
width: 100,
valueGetter: (value, row) => new Date(row.last_checkin),
renderCell: (params) =>
<CallbacksTableLastCheckinCell rowData={params.row} />,
},
{
field: "payload.payloadtype.name",
headerName: "Agent",
flex: 0.5,
valueGetter: (value, row) => row.payload.payloadtype.name,
renderCell: (params) => <CallbacksTablePayloadTypeCell rowData={params.row} />
},
{
field: "mythictree_groups_string",
headerName: "Groups",
flex: 0.5,
}
];
const CustomSelectTable = ({initialData, selectedData, sortModel}) => {
const [data, setData] = React.useState([]);
const [rowSelectionModel, setRowSelectionModel] = React.useState({
type: 'include',
ids: new Set([]),
});
React.useEffect( () => {
selectedData.current = data.reduce( (prev, cur) => {
if(rowSelectionModel.ids.has(cur.id)){return [...prev, cur]}
return [...prev];
}, []);
}, [data, rowSelectionModel]);
React.useEffect( () => {
setData(initialData.map(c => {
return {...c};
}));
}, [initialData]);
return (
<div style={{height: "calc(80vh)"}}>
<DataGrid
rows={data}
columns={columns}
initialState={{
pagination: {
paginationModel: {
},
},
sorting: {
sortModel: [sortModel],
},
}}
autoPageSize
checkboxSelection
onRowSelectionModelChange={(newRowSelectionModel) => {
setRowSelectionModel(newRowSelectionModel);
}}
rowSelectionModel={rowSelectionModel}
density={"compact"}
/>
</div>
)
}
export function CallbacksTabsHideMultipleDialog({onClose}) {
const selectedData = React.useRef([]);
@@ -49,7 +151,7 @@ export function CallbacksTabsHideMultipleDialog({onClose}) {
onClose();
}
});
const {loading} = useQuery(callbacksAndFeaturesQuery,{
useQuery(callbacksAndFeaturesQuery,{
fetchPolicy: "no-cache",
onCompleted: (data) => {
const callbackData = data.callback.map( c => {
@@ -77,20 +179,17 @@ export function CallbacksTabsHideMultipleDialog({onClose}) {
<DialogContentText style={{textAlign: "center"}}>
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
</DialogContentText>
<CallbacksTabsSelectTable initialData={initialData}
loading={loading}
selectedData={selectedData}
sortModel={{ field: 'last_checkin', sort: 'asc' }}
tableId="hide-multiple-callbacks-table"
tableLabel="Hide multiple callbacks"
<CustomSelectTable initialData={initialData}
selectedData={selectedData}
sortModel={{ field: 'last_checkin', sort: 'asc' }}
/>
<DialogActions>
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
<Button onClick={onClose} variant="contained" color="primary">
Close
</MythicActionButton>
<MythicActionButton colorMode="always" onClick={submitTasking} tone="warning" variant="contained">
</Button>
<Button onClick={submitTasking} variant="contained" color="warning">
Hide
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
);
@@ -99,7 +198,7 @@ export function CallbacksTabsSelectMultipleDialog({onClose, onSubmit}) {
const selectedData = React.useRef([]);
const [initialData, setInitialData] = React.useState([]);
const {loading} = useQuery(callbacksAndFeaturesQuery,{
useQuery(callbacksAndFeaturesQuery,{
fetchPolicy: "no-cache",
onCompleted: (data) => {
const callbackData = data.callback.map( c => {
@@ -125,21 +224,19 @@ export function CallbacksTabsSelectMultipleDialog({onClose, onSubmit}) {
<DialogContentText style={{textAlign: "center"}}>
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
</DialogContentText>
<CallbacksTabsSelectTable initialData={initialData}
loading={loading}
selectedData={selectedData}
sortModel={{ field: 'display_id', sort: 'desc' }}
tableId="select-multiple-callbacks-table"
tableLabel="Select multiple callbacks"
<CustomSelectTable initialData={initialData}
selectedData={selectedData}
sortModel={{ field: 'display_id', sort: 'desc' }}
/>
<DialogActions>
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
<Button onClick={onClose} variant="contained" color="primary">
Close
</MythicActionButton>
<MythicActionButton colorMode="always" onClick={submitTasking} tone="warning" variant="contained">
</Button>
<Button onClick={submitTasking} variant="contained" color="warning">
Use Selection
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -1,10 +1,11 @@
import React from 'react';
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
import React, {useContext} from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import {useQuery, gql } from '@apollo/client';
import {CallbacksTableLastCheckinCell, CallbacksTablePayloadTypeCell, CallbacksTableIPCell} from "./CallbacksTableRow";
import { DataGrid } from '@mui/x-data-grid';
import DialogContentText from '@mui/material/DialogContentText';
import {CallbacksTabsSelectTable} from "./CallbacksTabsSelectTable";
const callbacksAndFeaturesQuery = gql`
@@ -31,9 +32,97 @@ query callbacksAndFeatures{
}
}`;
const columns = [
{ field: 'display_id', headerName: 'ID', width: 70, type: 'number', },
{
field: 'host',
headerName: 'Host',
flex: 0.5,
},
{
field: 'user',
headerName: 'User',
flex: 0.5,
},
{
field: 'pid',
headerName: 'PID',
type: 'number',
width: 70,
},
{
field: 'description',
headerName: 'Description',
flex: 1,
},
{
field: 'ip',
headerName: 'IP',
width: 100,
renderCell: (params) => <CallbacksTableIPCell rowData={params.row} cellData={params.row.ip} />,
sortable: false,
valueGetter: (value, row) => {
try{
return JSON.parse(row.ip)[0];
}catch(error){
return row.ip;
}
}
},
{
field: "last_checkin",
headerName: "Checkin",
width: 100,
valueGetter: (value, row) => new Date(row.last_checkin),
renderCell: (params) =>
<CallbacksTableLastCheckinCell rowData={params.row} />,
},
{
field: "payload.payloadtype.name",
headerName: "Agent",
flex: 0.5,
valueGetter: (value, row) => row.payload.payloadtype.name,
renderCell: (params) => <CallbacksTablePayloadTypeCell rowData={params.row} />
},
{
field: "mythictree_groups_string",
headerName: "Groups",
flex: 0.5,
}
];
const CustomSelectTable = ({initialData, selectedData, onRowClick}) => {
const [data, setData] = React.useState([]);
React.useEffect( () => {
setData(initialData.map(c => {
return {...c};
}));
}, [initialData]);
return (
<div style={{height: "calc(80vh)"}}>
<DataGrid
rows={data}
columns={columns}
initialState={{
pagination: {
paginationModel: {
},
},
sorting: {
sortModel: [{ field: 'display_id', sort: 'desc' }],
},
}}
autoPageSize
onRowClick={onRowClick}
density={"compact"}
/>
</div>
)
}
export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs}) {
const selectedData = React.useRef([]);
const [initialData, setInitialData] = React.useState([]);
const {loading} = useQuery(callbacksAndFeaturesQuery,{
useQuery(callbacksAndFeaturesQuery,{
fetchPolicy: "no-cache",
onCompleted: (data) => {
const callbackData = data.callback.map( c => {
@@ -45,7 +134,7 @@ export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs})
}
});
const onRowClick = (rowData) => {
onOpenTabs([rowData]);
onOpenTabs([rowData.row]);
}
@@ -55,19 +144,16 @@ export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs})
<DialogContentText style={{textAlign: "center"}}>
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
</DialogContentText>
<CallbacksTabsSelectTable initialData={initialData}
loading={loading}
onRowClick={onRowClick}
selectable={false}
sortModel={{ field: 'display_id', sort: 'desc' }}
tableId={`open-multiple-${tabType}-callbacks-table`}
tableLabel={`Open ${tabType} callback`}
<CustomSelectTable initialData={initialData}
selectedData={selectedData}
onRowClick={onRowClick}
/>
<DialogActions>
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
<Button onClick={onClose} variant="contained" color="primary">
Close
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -1,10 +1,11 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useCallback, useEffect} from 'react';
import {gql, useQuery, useSubscription } from '@apollo/client';
import React, {useEffect, useRef} from 'react';
import {gql, useQuery, useSubscription, useLazyQuery } from '@apollo/client';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {useTheme} from '@mui/material/styles';
import Grid from '@mui/material/Grid';
import RefreshIcon from '@mui/icons-material/Refresh';
import IconButton from '@mui/material/IconButton';
import {CallbacksTabsProcessBrowserTable} from './CallbacksTabsProcessBrowserTable';
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import {TaskFromUIButton} from './TaskFromUIButton';
@@ -12,20 +13,15 @@ import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip'
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import Select from '@mui/material/Select';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import InputLabel from '@mui/material/InputLabel';
import Input from '@mui/material/Input';
import {ViewCallbackMythicTreeGroupsDialog} from "./ViewCallbackMythicTreeGroupsDialog";
import WidgetsIcon from '@mui/icons-material/Widgets';
import {snackActions} from "../../utilities/Snackbar";
import {Backdrop, Typography} from '@mui/material';
import { Backdrop, Typography } from '@mui/material';
import {CircularProgress} from '@mui/material';
import ExpandIcon from '@mui/icons-material/Expand';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import SearchIcon from '@mui/icons-material/Search';
import ClearIcon from '@mui/icons-material/Clear';
import {useMythicLazyQuery} from "../../utilities/useMythicLazyQuery";
import {getDefaultBrowserSelection, useCallbackBrowserTree} from "./CallbackBrowserTreeStore";
const treeFragment = gql`
fragment treeObjData on mythictree {
@@ -86,9 +82,6 @@ const treeHostQuery = gql`
}
}
`;
const mergeUniqueById = (existing = [], incoming = []) => [
...new Map([...existing, ...incoming].filter(Boolean).map((item) => [item.id, item])).values(),
];
export const loadedCommandsQuery = gql`
query loadedCommandUIFeatures($callback_id: Int!){
loadedcommands(where: {callback_id: {_eq: $callback_id}}){
@@ -149,38 +142,17 @@ export function CallbacksTabsProcessBrowserLabel(props){
)
}
export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, setNewDataForTab}) =>{
const theme = useTheme();
const fromNow = React.useRef((new Date()));
const active = index === value;
const markInactiveChange = useCallback(() => {
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {
...previous,
[tabInfo.tabID]: true,
});
}, [setNewDataForTab, tabInfo.tabID]);
const {
treeRootDataRef,
treeAdjMtx,
version: treeVersion,
hydrated: treeHydrated,
setTreeAdjMtx,
isScopeLoaded,
markScopeLoaded,
} = useCallbackBrowserTree({
treeType: "process",
mode: "process",
active,
onInactiveChange: markInactiveChange,
});
const [backdropOpen, setBackdropOpen] = React.useState(false);
const [expandOrCollapseAll, setExpandOrCollapseAll] = React.useState(true);
const [expandOrCollapseAll, setExpandOrCollapseAll] = React.useState(false);
const treeRootDataRef = React.useRef({}); // hold all the actual data
const [treeAdjMtx, setTreeAdjMtx] = React.useState({}); // hold the simple adjacency matrix for parent/child relationships
const [openTaskingButton, setOpenTaskingButton] = React.useState(false);
const taskingData = React.useRef({"parameters": "", "ui_feature": "process_browser:list"});
const mountedRef = React.useRef(true);
const [showDeletedFiles, setShowDeletedFiles] = React.useState(false);
const [selectedHost, setSelectedHost] = React.useState("");
const [selectedGroup, setSelectedGroup] = React.useState("");
const [quickFilter, setQuickFilter] = React.useState("");
const loadedCommandsRef = React.useRef({});
const loadingCommandsRef = React.useRef(false);
const getNewMatrix = () => {
@@ -200,7 +172,6 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
return newMatrix;
}
useQuery(rootQuery, {
skip: true,
onCompleted: (data) => {
// use an adjacency matrix but only for full_path_text -> children, not both directions
let defaultGroup = "Default";
@@ -279,7 +250,6 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
});
useSubscription(treeSubscription, {
variables: {now: fromNow.current},
skip: true,
fetchPolicy: "no-cache",
onData: ({data}) => {
for(let i = 0; i < data.data.mythictree_stream.length; i++){
@@ -307,9 +277,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
}
}
existingData.has_children = data.data.mythictree_stream[i].has_children || existingData.has_children;
existingData.callbacks = mergeUniqueById(existingData.callbacks, [data.data.mythictree_stream[i].callback]);
existingData.callbacks.push(data.data.mythictree_stream[i].callback)
existingData.comment += data.data.mythictree_stream[i].comment;
existingData.tags = mergeUniqueById(existingData.tags, data.data.mythictree_stream[i].tags);
existingData.tags = [...existingData.tags, ...data.data.mythictree_stream[i].tags];
if(existingData.task_id > data.data.mythictree_stream[i].task_id){
existingData.metadata = {...data.data.mythictree_stream[i].metadata, ...existingData.metadata};
} else {
@@ -326,19 +296,6 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
}
}
});
const initializedSelectionRef = React.useRef(false);
React.useEffect(() => {
if(!treeHydrated || initializedSelectionRef.current){return;}
const selection = getDefaultBrowserSelection(
treeRootDataRef.current,
treeAdjMtx,
tabInfo.callbackID,
tabInfo.host,
);
setSelectedGroup(selection.group);
setSelectedHost(selection.host);
initializedSelectionRef.current = true;
}, [tabInfo.callbackID, tabInfo.host, treeAdjMtx, treeHydrated, treeRootDataRef, treeVersion]);
const getHostProcessesQuerySuccess = (data) => {
snackActions.dismiss();
// add in all of the raw data
@@ -364,8 +321,8 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
}
let existingData = treeRootDataRef.current[currentGroups[j]][data.mythictree[i]["host"]][data.mythictree[i]["full_path_text"]];
existingData.comment += data.mythictree[i].comment;
existingData.callbacks = mergeUniqueById(existingData.callbacks, [data.mythictree[i].callback]);
existingData.tags = mergeUniqueById(existingData.tags, data.mythictree[i].tags);
existingData.callbacks.push(data.mythictree[i].callback)
existingData.tags = [...existingData.tags, ...data.mythictree[i].tags];
if(existingData.task_id > data.mythictree[i].task_id){
existingData.metadata = {...data.mythictree[i].metadata, ...existingData.metadata};
} else {
@@ -379,7 +336,6 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
const newMatrix = getNewMatrix();
//console.log(treeRootDataRef.current);
setTreeAdjMtx(newMatrix);
markScopeLoaded(`host:${selectedHost}`);
//console.log("just set treeAdjMtx, about to close backdrop")
setBackdropOpen(false);
}
@@ -393,9 +349,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
callback_display_id: tabInfo["displayID"]});
setOpenTaskingButton(true);
}
const onTaskRowAction = ({process_id, architecture, uifeature, openDialog, getConfirmation, callback_id, callback_display_id, display_id}) => {
const onTaskRowAction = ({process_id, architecture, uifeature, openDialog, getConfirmation, callback_id, callback_display_id}) => {
taskingData.current = {"parameters": {host: selectedHost, process_id, architecture},
"ui_feature": uifeature, openDialog, getConfirmation, callback_id, callback_display_id: callback_display_id || display_id};
"ui_feature": uifeature, openDialog, getConfirmation, callback_id, callback_display_id};
setOpenTaskingButton(true);
}
const toggleShowDeletedFiles = (showStatus) => {
@@ -436,11 +392,10 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
return undefined;
}
React.useEffect( () => {
if(!active || selectedHost === "" || isScopeLoaded(`host:${selectedHost}`)){return;}
getHostProcessesQuery({variables: {host: selectedHost}})
.then(({data}) => getHostProcessesQuerySuccess(data)).catch(({data}) => console.log(data));
setBackdropOpen(true);
}, [active, isScopeLoaded, selectedHost]);
}, [selectedHost]);
React.useEffect( () => {
return() => {
mountedRef.current = false;
@@ -455,9 +410,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute",}} invisible={false}>
<div style={{
borderRadius: "4px",
border: `1px solid ${theme.palette.divider}`,
border: "1px solid black",
padding: "5px",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
alignItems: "center",
display: "flex", flexDirection: "column"}}>
<CircularProgress color="inherit" />
@@ -478,12 +433,8 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
hostOptions={treeRootDataRef.current[selectedGroup] || {}}
expandOrCollapseAll={expandOrCollapseAll}
setExpandOrCollapseAll={setExpandOrCollapseAll}
quickFilter={quickFilter}
setQuickFilter={setQuickFilter}
/>
<CallbacksTabsProcessBrowserTable
active={active}
dataVersion={treeVersion}
showDeletedFiles={showDeletedFiles}
tabInfo={tabInfo}
onRowDoubleClick={() => {}}
@@ -491,7 +442,6 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
treeAdjMatrix={treeAdjMtx}
host={selectedHost}
group={selectedGroup}
quickFilter={quickFilter}
expandOrCollapseAll={expandOrCollapseAll}
onTaskRowAction={onTaskRowAction}
getLoadedCommandForUIFeature={getLoadedCommandForUIFeature}
@@ -522,12 +472,13 @@ const ProcessBrowserTableTop = ({
hostOptions,
groupOptions,
expandOrCollapseAll,
setExpandOrCollapseAll,
quickFilter,
setQuickFilter
setExpandOrCollapseAll
}) => {
const theme = useTheme();
const [showDeletedFiles, setLocalShowDeletedFiles] = React.useState(false);
const [openViewGroupsDialog, setOpenViewGroupDialog] = React.useState(false);
const inputRef = useRef(null);
const inputGroupRef = useRef(null);
const onLocalListFilesButton = () => {
onListFilesButton()
}
@@ -544,104 +495,58 @@ const ProcessBrowserTableTop = ({
const onLocalExpandOrCollapseAllButton = () => {
setExpandOrCollapseAll(!expandOrCollapseAll);
}
const onClearQuickFilter = () => {
setQuickFilter("");
}
return (
<div className="mythic-file-browser-tableTop mythic-process-browser-tableTop py-4 px-5">
<div className="mythic-process-browser-toolbar items-center flex gap-4">
<div className="mythic-process-browser-control items-center inline-flex gap-3 mythic-process-browser-controlGroup min-w-0 rounded bg-neutral-1 border-subtle">
<span className="mythic-process-browser-controlLabel font-750 text-muted">Group</span>
<FormControl size="small" className="mythic-process-browser-selectControl">
<Select
value={group}
onChange={handleGroupChange}
displayEmpty
className="mythic-process-browser-select"
>
{Object.keys(groupOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
<MythicStyledTooltip title="View callbacks associated with this group">
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
onClick={() => {setOpenViewGroupDialog(true);}}
size="small">
<WidgetsIcon fontSize="small" />
</MythicActionButton>
</MythicStyledTooltip>
</div>
<div className="mythic-process-browser-control items-center inline-flex gap-3 mythic-process-browser-controlHost min-w-0 rounded bg-neutral-1 border-subtle">
<span className="mythic-process-browser-controlLabel font-750 text-muted">Host</span>
<FormControl size="small" className="mythic-process-browser-selectControl">
<Select
value={host}
onChange={handleChange}
displayEmpty
className="mythic-process-browser-select"
>
{Object.keys(hostOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
</div>
<TextField
className="mythic-process-browser-searchInput"
size="small"
value={quickFilter}
onChange={(event) => setQuickFilter(event.target.value)}
placeholder="Filter name, PID, user, arch, session, command..."
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
endAdornment: quickFilter ? (
<InputAdornment position="end">
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="error"
onClick={onClearQuickFilter}
size="small">
<ClearIcon fontSize="small" />
</MythicActionButton>
</InputAdornment>
) : null
}}
/>
<div className="mythic-file-browser-toolbarGroup items-center inline-flex mythic-process-browser-actions">
<MythicStyledTooltip title="Task current callback to list processes">
<MythicActionButton iconOnly
appearance="plain" colorMode="hover" shape="square" tone="info"
onClick={onLocalListFilesButton}
size="small">
<RefreshIcon fontSize="small" />
</MythicActionButton>
<Grid container spacing={0} style={{paddingTop: "10px"}}>
<Grid size={12}>
<FormControl style={{width: "30%"}}>
<InputLabel ref={inputGroupRef}>Available Groups</InputLabel>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={group}
onChange={handleGroupChange}
input={<Input style={{width: "100%"}}/>}
endAdornment={
<React.Fragment>
<MythicStyledTooltip title="View Callbacks associated with this group">
<IconButton style={{padding: "3px", paddingRight: "25px"}} onClick={() => {setOpenViewGroupDialog(true);}} size="large"><WidgetsIcon style={{color: theme.palette.info.main}}/></IconButton>
</MythicStyledTooltip>
</React.Fragment>
}
>
{Object.keys(groupOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
<FormControl style={{width: "70%"}}>
<InputLabel ref={inputRef}>Available Hosts</InputLabel>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={host}
onChange={handleChange}
input={<Input style={{width: "100%"}}/>}
endAdornment={
<React.Fragment>
<MythicStyledTooltip title="Task Callback to List Processes">
<IconButton style={{padding: "3px"}} onClick={onLocalListFilesButton} size="large"><RefreshIcon style={{color: theme.palette.info.main}}/></IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={expandOrCollapseAll ? "Collapse all processes" : "Expand all processes"} >
<MythicActionButton iconOnly
appearance="plain" active={expandOrCollapseAll} colorMode="hover" shape="square" tone={expandOrCollapseAll ? "success" : "info"}
onClick={onLocalExpandOrCollapseAllButton}
size="small">
<ExpandIcon fontSize="small" />
</MythicActionButton>
<IconButton style={{padding: "3px"}}
onClick={onLocalExpandOrCollapseAllButton} size={"large"}>
<ExpandIcon color={expandOrCollapseAll ? "info" : "success"} />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={showDeletedFiles ? 'Hide deleted processes' : 'Show deleted processes'}>
<MythicActionButton iconOnly
appearance="plain" active={showDeletedFiles} colorMode="hover" shape="square" tone="warning"
onClick={onLocalToggleShowDeletedFiles}
size="small">
{showDeletedFiles ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</MythicActionButton>
</MythicStyledTooltip>
</div>
<div style={{paddingRight: "20px"}} />
</React.Fragment>
}
>
{Object.keys(hostOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
{openViewGroupsDialog &&
<MythicDialog
fullWidth={true}
@@ -654,7 +559,7 @@ const ProcessBrowserTableTop = ({
}
/>
}
</div>
</div>
</Grid>
</Grid>
);
}
File diff suppressed because it is too large. Load diff
@@ -1,284 +0,0 @@
import React from 'react';
import Checkbox from '@mui/material/Checkbox';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import TableSortLabel from '@mui/material/TableSortLabel';
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
import {MythicTableEmptyState, MythicTableLoadingState} from "../../MythicComponents/MythicStateDisplay";
import {CallbacksTableIPCell, CallbacksTableLastCheckinCell, CallbacksTablePayloadTypeCell} from "./CallbacksTableRow";
import {MythicClientSideTablePagination, useMythicClientPagination} from "../../MythicComponents/MythicTablePagination";
const getPrimaryIP = (row) => {
try{
return JSON.parse(row.ip)[0] || "";
}catch(error){
return row.ip || "";
}
}
const compareValues = (left, right) => {
if(left === right){
return 0;
}
if(left === undefined || left === null){
return -1;
}
if(right === undefined || right === null){
return 1;
}
if(left instanceof Date && right instanceof Date){
return left.getTime() - right.getTime();
}
if(typeof left === "number" && typeof right === "number"){
return left - right;
}
return String(left).localeCompare(String(right), undefined, {numeric: true, sensitivity: "base"});
}
const getColumns = ({includeAgent}) => {
const columns = [
{field: "display_id", headerName: "ID", width: "4.5rem", sortValue: (row) => row.display_id},
{field: "host", headerName: "Host", sortValue: (row) => row.host},
{field: "user", headerName: "User", sortValue: (row) => row.user},
{field: "pid", headerName: "PID", width: "4.5rem", sortValue: (row) => row.pid},
{field: "description", headerName: "Description", sortValue: (row) => row.description},
{
field: "ip",
headerName: "IP",
width: "8rem",
sortValue: getPrimaryIP,
render: (row) => <CallbacksTableIPCell rowData={row} cellData={row.ip} />,
},
{
field: "last_checkin",
headerName: "Checkin",
width: "8rem",
sortValue: (row) => new Date(row.last_checkin),
render: (row) => <CallbacksTableLastCheckinCell rowData={row} />,
},
];
if(includeAgent){
columns.push({
field: "payload.payloadtype.name",
headerName: "Agent",
sortValue: (row) => row.payload?.payloadtype?.name || "",
render: (row) => <CallbacksTablePayloadTypeCell rowData={row} />,
});
}
columns.push({field: "mythictree_groups_string", headerName: "Groups", sortValue: (row) => row.mythictree_groups_string});
return columns;
}
export const CallbacksTabsSelectTable = ({
includeAgent = true,
initialData,
loading = false,
onRowClick,
selectable = true,
selectedData,
sortModel = {field: "display_id", sort: "desc"},
tableId = "callbacks-select-table",
tableLabel = "Callback selection",
rowsPerPage = 25,
}) => {
const [data, setData] = React.useState([]);
const [orderBy, setOrderBy] = React.useState(sortModel.field);
const [order, setOrder] = React.useState(sortModel.sort || "asc");
const [selectedIds, setSelectedIds] = React.useState(new Set());
const columns = React.useMemo(() => getColumns({includeAgent}), [includeAgent]);
React.useEffect(() => {
setOrderBy(sortModel.field);
setOrder(sortModel.sort || "asc");
}, [sortModel.field, sortModel.sort]);
React.useEffect(() => {
setData(initialData.map((c) => ({...c})));
setSelectedIds(new Set());
}, [initialData]);
React.useEffect(() => {
if(selectable && selectedData){
selectedData.current = data.filter((row) => selectedIds.has(row.id));
}
}, [data, selectable, selectedData, selectedIds]);
const sortedData = React.useMemo(() => {
const sortColumn = columns.find((column) => column.field === orderBy);
if(!sortColumn){
return data;
}
const direction = order === "desc" ? -1 : 1;
return [...data].sort((left, right) => direction * compareValues(sortColumn.sortValue(left), sortColumn.sortValue(right)));
}, [columns, data, order, orderBy]);
const selectedCount = selectedIds.size;
const allSelected = selectable && sortedData.length > 0 && selectedCount === sortedData.length;
const partiallySelected = selectable && selectedCount > 0 && selectedCount < sortedData.length;
const pagination = useMythicClientPagination({
items: sortedData,
resetKey: `${orderBy}:${order}`,
rowsPerPage,
});
const handleSort = (field) => {
if(orderBy === field){
setOrder((current) => current === "asc" ? "desc" : "asc");
}else{
setOrderBy(field);
setOrder("asc");
}
}
const handleSelectAll = (event) => {
if(event.target.checked){
setSelectedIds(new Set(sortedData.map((row) => row.id)));
}else{
setSelectedIds(new Set());
}
}
const toggleSelectedRow = (row) => {
setSelectedIds((current) => {
const next = new Set(current);
if(next.has(row.id)){
next.delete(row.id);
}else{
next.add(row.id);
}
return next;
});
}
const handleSelectRow = (event, row) => {
event.stopPropagation();
toggleSelectedRow(row);
}
const handleRowClick = (row) => {
if(selectable){
toggleSelectedRow(row);
}else if(onRowClick){
onRowClick(row);
}
}
const handleRowKeyDown = (event, row) => {
if(!selectable && !onRowClick){
return;
}
if(event.key === "Enter" || event.key === " "){
event.preventDefault();
handleRowClick(row);
}
}
return (
<>
<TableContainer
aria-label={`${tableLabel} scroll area`}
className="mythic-dialog-table-wrap mythic-fixed-row-table-wrap mythic-callback-select-table-wrap"
data-testid={`${tableId}-scroll-region`}
id={`${tableId}-scroll-region`}
role="region"
style={{height: "min(60vh, 42rem)", minHeight: "20rem", overflowY: "auto"}}
>
<Table aria-label={tableLabel} data-testid={tableId} id={tableId} stickyHeader size="small" style={{height: "auto"}}>
<TableHead>
<TableRow>
{selectable &&
<MythicStyledTableCell padding="checkbox">
<Checkbox
checked={allSelected}
color="primary"
indeterminate={partiallySelected}
inputProps={{"aria-label": `Select all callbacks in ${tableLabel}`}}
onChange={handleSelectAll}
size="small"
/>
</MythicStyledTableCell>
}
{columns.map((column) => (
<MythicStyledTableCell key={column.field} sortDirection={orderBy === column.field ? order : false} style={{width: column.width}}>
<TableSortLabel
active={orderBy === column.field}
direction={orderBy === column.field ? order : "asc"}
onClick={() => handleSort(column.field)}
>
{column.headerName}
</TableSortLabel>
</MythicStyledTableCell>
))}
</TableRow>
</TableHead>
<TableBody id={`${tableId}-body`}>
{loading ? (
<MythicTableLoadingState
colSpan={columns.length + (selectable ? 1 : 0)}
columns={columns.length + (selectable ? 1 : 0)}
compact
rows={4}
title="Loading callbacks"
description="Fetching callback data."
minHeight={100}
/>
) : sortedData.length === 0 ? (
<MythicTableEmptyState
colSpan={columns.length + (selectable ? 1 : 0)}
compact
title="No callbacks"
description="No active callbacks match this selection."
minHeight={180}
/>
) : (
pagination.pageData.map((row) => {
const selected = selectedIds.has(row.id);
return (
<TableRow
aria-label={`Callback ${row.display_id} ${row.user || ""}@${row.host || ""}`}
className={selected ? "selectedCallback" : ""}
hover
id={`${tableId}-row-${row.id}`}
key={row.id}
onClick={() => handleRowClick(row)}
onKeyDown={(event) => handleRowKeyDown(event, row)}
tabIndex={selectable || onRowClick ? 0 : undefined}
selected={selected}
style={{cursor: selectable || onRowClick ? "pointer" : undefined}}
>
{selectable &&
<MythicStyledTableCell padding="checkbox">
<Checkbox
checked={selected}
color="primary"
inputProps={{"aria-label": `Select callback ${row.display_id}`}}
onClick={(event) => handleSelectRow(event, row)}
size="small"
/>
</MythicStyledTableCell>
}
{columns.map((column) => (
<MythicStyledTableCell key={column.field}>
{column.render ? column.render(row) : row[column.field]}
</MythicStyledTableCell>
))}
</TableRow>
)
})
)}
</TableBody>
</Table>
</TableContainer>
{!loading &&
<MythicClientSideTablePagination
id={`${tableId}-pagination`}
pagination={pagination}
selectedCount={selectable ? selectedCount : undefined}
/>
}
</>
)
}
@@ -1,15 +1,16 @@
import React from 'react';
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
import React, {useEffect} from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import Grid from '@mui/material/Grid';
import {useQuery, gql } from '@apollo/client';
import {TaskFromUIButton} from './TaskFromUIButton';
import {CallbacksTabsTaskingInput} from "./CallbacksTabsTaskingInput";
import {CallbacksTableIPCell, CallbacksTableLastCheckinCell} from "./CallbacksTableRow";
import { DataGrid } from '@mui/x-data-grid';
import { validate as uuidValidate } from 'uuid';
import {snackActions} from "../../utilities/Snackbar";
import DialogContentText from '@mui/material/DialogContentText';
import {CallbacksTabsSelectTable} from "./CallbacksTabsSelectTable";
const callbacksAndFeaturesQuery = gql`
@@ -23,7 +24,6 @@ query callbacksAndFeatures($payloadtype_id: Int!) {
integrity_level
pid
display_id
operation_id
last_checkin
ip
dead
@@ -36,6 +36,100 @@ query callbacksAndFeatures($payloadtype_id: Int!) {
}
}`;
const columns = [
{ field: 'display_id', headerName: 'ID', width: 80, type: 'number', },
{
field: 'host',
headerName: 'Host',
flex: 0.5,
},
{
field: 'user',
headerName: 'User',
flex: 0.5,
},
{
field: 'pid',
headerName: 'PID',
type: 'number',
width: 80,
},
{
field: 'description',
headerName: 'Description',
flex: 1,
},
{
field: 'ip',
headerName: 'IP',
width: 100,
renderCell: (params) => <CallbacksTableIPCell rowData={params.row} cellData={params.row.ip} />,
sortable: false,
valueGetter: (value, row) => {
try{
return JSON.parse(row.ip)[0];
}catch(error){
return row.ip;
}
}
},
{
field: "last_checkin",
headerName: "Checkin",
width: 100,
valueGetter: (value, row) => new Date(row.last_checkin),
renderCell: (params) =>
<CallbacksTableLastCheckinCell rowData={params.row} />,
},
{
field: "mythictree_groups_string",
headerName: "Groups",
flex: 0.5,
}
];
const CustomSelectTable = ({initialData, selectedData}) => {
const [data, setData] = React.useState([]);
const [rowSelectionModel, setRowSelectionModel] = React.useState({
type: 'include',
ids: new Set([]),
});
React.useEffect( () => {
selectedData.current = data.reduce( (prev, cur) => {
if(rowSelectionModel.ids.has(cur.id)){return [...prev, cur]}
return [...prev];
}, []);
}, [data, rowSelectionModel]);
React.useEffect( () => {
setData(initialData.map(c => {
return {...c};
}));
}, [initialData]);
return (
<div style={{height: "calc(80vh)"}}>
<DataGrid
rows={data}
columns={columns}
initialState={{
pagination: {
paginationModel: {
},
},
sorting: {
sortModel: [{ field: 'display_id', sort: 'desc' }],
},
}}
autoPageSize
checkboxSelection
onRowSelectionModelChange={(newRowSelectionModel) => {
setRowSelectionModel(newRowSelectionModel);
}}
rowSelectionModel={rowSelectionModel}
density={"compact"}
/>
</div>
)
}
export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
const mountedRef = React.useRef(true);
const [selectedToken, setSelectedToken] = React.useState({});
@@ -44,7 +138,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
const finalTaskedParameters = React.useRef(null);
const [initialData, setInitialData] = React.useState([]);
const selectedData = React.useRef([]);
const {loading} = useQuery(callbacksAndFeaturesQuery, {variables: {payloadtype_id: callback.payload.payloadtype.id},
useQuery(callbacksAndFeaturesQuery, {variables: {payloadtype_id: callback.payload.payloadtype.id},
fetchPolicy: "no-cache",
onCompleted: (data) => {
setInitialData(data.callback.map( c => {
@@ -63,9 +157,9 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
const callbacks = selectedData.current.map( c => c.display_id)
if(callbacks.length > 0){
if(finalTaskedParameters.current){
taskingData.current = {...taskingData.current, callback_display_ids: callbacks, openDialog: false, parameters: finalTaskedParameters.current};
taskingData.current = {...taskingData.current, callback_ids: callbacks, openDialog: false, parameters: finalTaskedParameters.current};
}else{
taskingData.current = {...taskingData.current, callback_display_ids: callbacks, openDialog: true};
taskingData.current = {...taskingData.current, callback_ids: callbacks, openDialog: true};
}
setOpenTaskingButton(true);
}else{
@@ -78,7 +172,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
//onClose();
setOpenTaskingButton(false);
}
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
//console.log(message, cmd, parsed);
if(selectedData.current.length === 0){
//onClose();
@@ -105,7 +199,6 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
tasking_location: newTaskingLocation,
dontShowSuccessDialog: false,
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
};
submitTasking();
return;
@@ -152,7 +245,6 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
tasking_location: newTaskingLocation,
dontShowSuccessDialog: false,
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
};
}else{
finalTaskedParameters.current = undefined;
@@ -165,7 +257,6 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
tasking_location: newTaskingLocation,
dontShowSuccessDialog: false,
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
};
}
submitTasking();
@@ -184,31 +275,11 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
dontShowSuccessDialog: false,
parameter_group_name: cmdGroupNames[0],
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
};
submitTasking();
}
}
}
const onSubmitAliasCommandLine = (message) => {
if(selectedData.current.length === 0){
snackActions.warning("No callbacks selected");
return;
}
const trimmed = message.trim();
const command = trimmed.split(/\s+/)[0];
const params = trimmed.length > command.length ? trimmed.slice(command.length).trim() : "";
finalTaskedParameters.current = params;
taskingData.current = {
cmd: command,
callback_id: callback.id,
openDialog: false,
parameters: params,
tasking_location: "alias",
dontShowSuccessDialog: false,
};
submitTasking();
};
const changeSelectedToken = (token) => {
if(token === "Default Token"){
setSelectedToken("Default Token");
@@ -224,41 +295,32 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
<DialogContentText style={{textAlign: "center"}}>
<b>Note: </b> Last checkin times are based on when this window opened and won't refresh.
</DialogContentText>
<CallbacksTabsSelectTable
includeAgent={false}
initialData={initialData}
loading={loading}
selectedData={selectedData}
sortModel={{ field: 'display_id', sort: 'desc' }}
tableId="task-multiple-callbacks-table"
tableLabel={`Task multiple ${callback.payload.payloadtype.name} callbacks`}
/>
<CustomSelectTable initialData={initialData}
selectedData={selectedData} />
<Grid size={12}>
<CallbacksTabsTaskingInput filterTasks={false} onSubmitFilter={()=>{}} onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine}
<CallbacksTabsTaskingInput filterTasks={false} onSubmitFilter={()=>{}} onSubmitCommandLine={onSubmitCommandLine}
changeSelectedToken={changeSelectedToken}
payloadtype_name={callback.payload.payloadtype.name}
hide_context={true}
filterOptions={{}} callback_id={callback.id} callback_display_id={callback.display_id}
operation_id={callback.operation_id || initialData[0]?.operation_id}
callback_os={callback.payload.os} parentMountedRef={mountedRef} />
filterOptions={{}} callback_id={callback.id} callback_os={callback.payload.os} parentMountedRef={mountedRef} />
</Grid>
{openTaskingButton &&
<TaskFromUIButton cmd={taskingData.current?.cmd}
callback_id={taskingData?.current?.callback_id || 0}
callback_display_ids={taskingData?.current?.callback_display_ids || undefined}
callback_ids={taskingData?.current?.callback_ids || undefined}
parameters={taskingData.current?.parameters || ""}
openDialog={taskingData.current?.openDialog || false}
tasking_location={taskingData.current?.tasking_location || "command_line"}
selected_task_references={taskingData.current?.selected_task_references || []}
dontShowSuccessDialog={taskingData.current?.dontShowSuccessDialog || false}
selectCallback={taskingData.current?.selectCallback || false}
onTasked={onTasked}/>
}
<DialogActions>
<MythicActionButton colorMode="always" onClick={onClose} tone="primary" variant="contained">
<Button onClick={onClose} variant="contained" color="primary">
Cancel
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
);
}
@@ -1,20 +1,19 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useEffect, useRef} from 'react';
import { useMutation } from '@apollo/client';
import React, {useEffect, useRef, useCallback, useLayoutEffect} from 'react';
import { gql, useMutation, useLazyQuery, useSubscription } from '@apollo/client';
import { TaskDisplay } from './TaskDisplay';
import {snackActions} from '../../utilities/Snackbar';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {TaskParametersDialog} from './TaskParametersDialog';
import {CallbacksTabsTaskingInput} from './CallbacksTabsTaskingInput';
import LinearProgress from '@mui/material/LinearProgress';
import { IconButton} from '@mui/material';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
import {createTaskingMutation} from "./CallbackMutations";
import {taskingDataFragment, createTaskingMutation} from "./CallbackMutations";
import { validate as uuidValidate } from 'uuid';
import {useTaskReferenceSubmitter} from "./taskingReferences";
import {useCallbackTaskingData} from "./useCallbackTaskingData";
import {getSkewedNow} from "../../utilities/Time";
export function CallbacksTabsTaskingLabel(props){
@@ -61,11 +60,36 @@ export function CallbacksTabsTaskingLabel(props){
)
}
// this is to listen for the latest tasking
const fetchLimit = 30;
const getTaskingQuery = gql`
${taskingDataFragment}
subscription getTasking($callback_id: Int!, $fromNow: timestamp!, $limit: Int){
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}, timestamp: {_gt: $fromNow}}, order_by: {id: desc}, limit: $limit) {
...taskData
}
}
`;
const getNextBatchTaskingQuery = gql`
${taskingDataFragment}
query getBatchTasking($callback_id: Int!, $offset: Int!, $fetchLimit: Int!){
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}}, order_by: {id: desc}, limit: $fetchLimit, offset: $offset) {
...taskData
}
callback(where: {id: {_eq: $callback_id}}){
id
display_id
}
}
`;
export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, parentMountedRef, me, collapseTaskRequest, setNewDataForTab}) =>{
const active = index === value;
const [taskLimit, setTaskLimit] = React.useState(20);
const [scrollToBottom, setScrollToBottom] = React.useState(false);
const [openParametersDialog, setOpenParametersDialog] = React.useState(false);
const [commandInfo, setCommandInfo] = React.useState({});
const [taskingData, setTaskingData] = React.useState({task: []});
const taskingDataRef = React.useRef({task: []});
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
const [selectedToken, setSelectedToken] = React.useState({});
const [filterOptions, setFilterOptions] = React.useState({
"operatorsList": [],
@@ -75,7 +99,13 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
"everythingButList": [],
"hideErrors": false
});
const [canScroll, setCanScroll] = React.useState(true);
const mountedRef = React.useRef(true);
useEffect( () => {
taskingDataRef.current = taskingData;
}, [taskingData]);
const [fetched, setFetched] = React.useState(false);
const [fetchedAllTasks, setFetchedAllTasks] = React.useState(false);
const messagesEndRef = useRef(null);
const newlyIssuedTasks = useRef([]);
const [createTask] = useMutation(createTaskingMutation, {
@@ -91,39 +121,158 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
console.error(data);
}
});
const {submitTask, dialog: taskReferenceSubmitDialog} = useTaskReferenceSubmitter(createTask);
const onBackgroundChange = React.useCallback(() => {
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {...previous, [tabInfo.tabID]: true});
}, [setNewDataForTab, tabInfo.tabID]);
const {
fetched,
fetchedAllTasks,
loadingMore,
loadMoreTasks,
taskChildrenStore,
tasks,
} = useCallbackTaskingData({
callbackID: tabInfo.callbackID,
active,
onBackgroundChange,
onMissingCallback: () => onCloseTab(tabInfo),
const equalTaskTrees = (oldArray, newArray) => {
if(oldArray.length !== newArray.length){
return false;
}
for(let i = 0; i < oldArray.length; i++){
if(oldArray[i].comment !== newArray[i].comment){
return false;
}
if(oldArray[i].commentOperator !== newArray[i].commentOperator){
return false;
}
if(oldArray[i].completed !== newArray[i].completed){
return false;
}
if(oldArray[i].display_params !== newArray[i].display_params){
return false;
}
if(oldArray[i].original_params !== newArray[i].original_params){
return false;
}
if(oldArray[i].status !== newArray[i].status){
return false;
}
if(oldArray[i].timestamp !== newArray[i].timestamp){
return false;
}
if(oldArray[i].opsec_pre_blocked !== newArray[i].opsec_pre_blocked){
return false;
}
if(oldArray[i].opsec_pre_bypassed !== newArray[i].opsec_pre_bypassed){
return false;
}
if(oldArray[i].opsec_post_blocked !== newArray[i].opsec_post_blocked){
return false;
}
if(oldArray[i].opsec_post_bypassed !== newArray[i].opsec_post_bypassed){
return false;
}
if(oldArray[i].tasks.length !== newArray[i].tasks.length){
return false;
}
if(oldArray[i].tags.length !== newArray[i].tags.length){
return false;
}
}
return true;
}
const subscriptionDataCallback = ({data}) => {
if((mountedRef && !mountedRef.current) || (parentMountedRef && !parentMountedRef.current)){
return null;
}
if(!fetched){
setFetched(true);
}
if(index !== value && fetched){
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: true}});
}
//console.log("new subscription data in CallbacksTabsTasking", subscriptionData);
const oldLength = taskingDataRef.current.task.length;
const mergedData = data.data.task.reduce( (prev, cur) => {
const index = prev.findIndex(element => element.id === cur.id);
if(index > -1){
// need to update an element
const updated = prev.map( (element) => {
if(element.id === cur.id){
return cur;
}else{
return element;
}
});
return updated;
}else{
return [...prev, cur];
}
}, [...taskingDataRef.current.task]);
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
if(!equalTaskTrees(taskingDataRef.current.task, mergedData)){
setTaskingData({task: mergedData});
}
if(mergedData.length > oldLength){
setCanScroll(true);
}
if(mergedData.length > taskLimit){
setTaskLimit(mergedData.length);
}
}
useSubscription(getTaskingQuery, {
variables: {callback_id: tabInfo.callbackID, fromNow:fromNow, limit: taskLimit},
onError: data => {
console.error(data)
},
fetchPolicy: "no-cache",
onData: subscriptionDataCallback});
const [getInfiniteScrollTasking, {loading: loadingMore}] = useLazyQuery(getNextBatchTaskingQuery, {
onError: data => {
console.error(data);
},
onCompleted: (data) => {
let foundNew = false;
if(data.callback.length === 0){
onCloseTab(tabInfo);
return;
}
const mergedData = data.task.reduce( (prev, cur) => {
const index = prev.findIndex(element => element.id === cur.id);
if(index > -1){
// need to update an element
const updated = prev.map( (element) => {
if(element.id === cur.id){
return cur;
}else{
return element;
}
});
return updated;
}else{
foundNew = true;
return [...prev, cur];
}
}, [...taskingData.task]);
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
setTaskingData({task: mergedData});
if(!foundNew){
setFetchedAllTasks(true);
}else{
if(data.task.length < fetchLimit){
setFetchedAllTasks(true);
}else{
setFetchedAllTasks(false);
}
}
if(!scrollToBottom){setScrollToBottom(true)}
},
fetchPolicy: "no-cache"
});
const taskingData = React.useMemo(() => ({task: tasks}), [tasks]);
useEffect( () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
setCanScroll(true);
return() => {
mountedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect( () => {
if(fetched && tasks.length > 0 && !scrollToBottom){
setScrollToBottom(true);
}
if(scrollToBottom){
messagesEndRef.current?.scrollIntoView();
messagesEndRef.current.scrollIntoView();
}
}, [fetched, scrollToBottom, tasks.length]);
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
}, [scrollToBottom]);
const loadMoreTasks = () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
}
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
console.log(message, cmd, parsed);
let params = message.split(" ");
delete params[0];
@@ -136,13 +285,12 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
}
if(cmd.commandparameters.length === 0){
// if there are no parameters, just send whatever the user types along
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd.cmd,
params: params,
parameter_group_name: "Default",
tasking_location: newTaskingLocation,
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
});
}else{
// check if there's a "file" component that needs to be displayed
@@ -177,93 +325,42 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
if(fileParamExists || force_parsed_popup || missingRequiredPrams){
//need to do a popup
if(cmdGroupNames.length > 0){
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0], taskReferenceOptions});
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0]});
}else{
setCommandInfo({...cmd, "parsedParameters": parsed, taskReferenceOptions});
setCommandInfo({...cmd, "parsedParameters": parsed});
}
setOpenParametersDialog(true);
}else{
delete parsed["_"];
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd.cmd,
params: JSON.stringify(parsed),
tasking_location: newTaskingLocation,
original_params: params,
parameter_group_name: cmdGroupNames[0],
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
taskReferenceEditCommandInfo: {...cmd, parsedParameters: parsed, groupName: cmdGroupNames[0], taskReferenceOptions},
});
}
}
}
}
const onSubmitAliasCommandLine = (message, alias) => {
const trimmed = message.trim();
const command = trimmed.split(/\s+/)[0];
const params = trimmed.length > command.length ? trimmed.slice(command.length).trim() : "";
onCreateTask({
callback_display_id: tabInfo.displayID,
command,
params,
parameter_group_name: "Default",
tasking_location: "alias",
payload_type: alias?.payloadtype?.name || tabInfo.payloadtype,
});
};
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type, metadata={}) => {
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type) => {
setOpenParametersDialog(false);
const selectedTaskReferences = [
...(commandInfo.taskReferenceOptions?.selected_task_references || []),
...(metadata.selectedTaskReferences || []),
];
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd,
params: parameters,
files: files,
tasking_location: "modal",
parameter_group_name: selectedParameterGroup,
payload_type: payload_type,
selected_task_references: selectedTaskReferences,
taskReferenceEditCommandInfo: commandInfo,
payload_type: payload_type
});
}
const getTaskReferenceEditParameters = (variables) => {
try{
const parsedParameters = JSON.parse(variables.params || "{}");
if(parsedParameters && typeof parsedParameters === "object" && !Array.isArray(parsedParameters)){
return parsedParameters;
}
}catch(error){
// fall through to an empty parameter object
}
return {};
}
const openTaskReferenceEditDialog = (variables, commandContext) => {
if(!commandContext?.id){
return;
}
const selectedTaskReferences = variables.selected_task_references || commandContext.taskReferenceOptions?.selected_task_references || [];
setCommandInfo({
...commandContext,
parsedParameters: getTaskReferenceEditParameters(variables),
groupName: variables.parameter_group_name || commandContext.groupName,
taskReferenceOptions: {
...(commandContext.taskReferenceOptions || {}),
selected_task_references: selectedTaskReferences,
},
});
setOpenParametersDialog(true);
}
const onCreateTask = ({callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references, taskReferenceEditCommandInfo}) => {
const variables = {callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references};
const onCreateTask = ({callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}) => {
if(selectedToken.token_id !== undefined){
variables.token_id = selectedToken.token_id;
createTask({variables: {callback_id, command, params, files, token_id: selectedToken.token_id, tasking_location, original_params, parameter_group_name, payload_type}});
}else{
createTask({variables: {callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}});
}
submitTask({
variables,
onTaskReferenceReviewCancel: taskReferenceEditCommandInfo ? (pendingVariables) => openTaskReferenceEditDialog(pendingVariables, taskReferenceEditCommandInfo) : undefined,
});
}
const onSubmitFilter = (newFilter) => {
setFilterOptions(newFilter);
@@ -287,22 +384,21 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
<MythicTabPanel index={index} value={value} >
{!fetched && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
{loadingMore && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
{taskReferenceSubmitDialog}
<div style={{overflowY: "auto", flexGrow: 1, width: "100%"}} id={`taskingPanel${tabInfo.callbackID}`}>
{!fetchedAllTasks &&
<MythicStyledTooltip tooltipStyle={{marginLeft: "50%", width: "100%"}} title="Fetch Older Tasks">
<MythicActionButton colorMode="always" tone="success" iconOnly
<MythicStyledTooltip tooltipStyle={{marginLeft: "50%"}} title="Fetch Older Tasks">
<IconButton
onClick={loadMoreTasks}
variant="contained"
size="large"><AutorenewIcon/></MythicActionButton>
color="success"
size="large"><AutorenewIcon/></IconButton>
</MythicStyledTooltip>}
{
taskingData.task.map((task) => (
<TaskDisplay key={"taskinteractdisplay" + task.id} me={me} task={task}
command_id={task.command == null ? 0 : task.command.id}
collapseAllRequest={collapseAllRequest}
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}
taskChildrenStore={taskChildrenStore} active={active}/>
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}/>
))
}
<div ref={messagesEndRef}/>
@@ -311,20 +407,16 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
<CallbacksTabsTaskingInput filterTasks={true} me={me}
onSubmitFilter={onSubmitFilter}
onSubmitCommandLine={onSubmitCommandLine}
onSubmitAliasCommandLine={onSubmitAliasCommandLine}
changeSelectedToken={changeSelectedToken}
filterOptions={filterOptions}
focus={index === value}
callback_id={tabInfo.callbackID}
callback_display_id={tabInfo.displayID}
operation_id={tabInfo.operation_id}
payloadtype_name={tabInfo.payloadtype}
callback_os={tabInfo.os} parentMountedRef={mountedRef} active={active} />
callback_os={tabInfo.os} parentMountedRef={mountedRef} />
{openParametersDialog &&
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
onClose={()=>{setOpenParametersDialog(false);}}
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
callback_display_id={tabInfo.displayID}
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
payloadtype_id={tabInfo.payloadtype_id} operation_id={tabInfo.operation_id}
onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}}
/>}
@@ -333,4 +425,4 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
</MythicTabPanel>
);
}
}
@@ -1,20 +1,19 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useEffect, useRef} from 'react';
import { useMutation } from '@apollo/client';
import React, {useEffect, useRef, useCallback, useLayoutEffect} from 'react';
import { gql, useMutation, useLazyQuery, useSubscription } from '@apollo/client';
import { TaskDisplayConsole } from './TaskDisplay';
import {snackActions} from '../../utilities/Snackbar';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {TaskParametersDialog} from './TaskParametersDialog';
import {CallbacksTabsTaskingInput} from './CallbacksTabsTaskingInput';
import LinearProgress from '@mui/material/LinearProgress';
import { IconButton} from '@mui/material';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
import {createTaskingMutation} from "./CallbackMutations";
import {taskingDataFragment, createTaskingMutation} from "./CallbackMutations";
import { validate as uuidValidate } from 'uuid';
import {useTaskReferenceSubmitter} from "./taskingReferences";
import {useCallbackTaskingData} from "./useCallbackTaskingData";
import {getSkewedNow} from "../../utilities/Time";
export function CallbacksTabsTaskingConsoleLabel(props){
@@ -61,11 +60,36 @@ export function CallbacksTabsTaskingConsoleLabel(props){
)
}
// this is to listen for the latest tasking
const fetchLimit = 20;
const getTaskingQuery = gql`
${taskingDataFragment}
subscription getTasking($callback_id: Int!, $fromNow: timestamp!, $limit: Int){
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}, timestamp: {_gt: $fromNow}}, order_by: {id: desc}, limit: $limit) {
...taskData
}
}
`;
const getNextBatchTaskingQuery = gql`
${taskingDataFragment}
query getBatchTasking($callback_id: Int!, $offset: Int!, $fetchLimit: Int!){
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}}, order_by: {id: desc}, limit: $fetchLimit, offset: $offset) {
...taskData
}
callback(where: {id: {_eq: $callback_id}}){
id
display_id
}
}
`;
export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onCloseTab, parentMountedRef, me, setNewDataForTab}) =>{
const active = index === value;
const [taskLimit, setTaskLimit] = React.useState(10);
const [scrollToBottom, setScrollToBottom] = React.useState(false);
const [openParametersDialog, setOpenParametersDialog] = React.useState(false);
const [commandInfo, setCommandInfo] = React.useState({});
const [taskingData, setTaskingData] = React.useState({task: []});
const taskingDataRef = React.useRef({task: []});
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
const [selectedToken, setSelectedToken] = React.useState({});
const [filterOptions, setFilterOptions] = React.useState({
"operatorsList": [],
@@ -75,7 +99,13 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
"everythingButList": [],
"hideErrors": false
});
const [canScroll, setCanScroll] = React.useState(true);
const mountedRef = React.useRef(true);
useEffect( () => {
taskingDataRef.current = taskingData;
}, [taskingData]);
const [fetched, setFetched] = React.useState(false);
const [fetchedAllTasks, setFetchedAllTasks] = React.useState(false);
const messagesEndRef = useRef(null);
const newlyIssuedTasks = useRef([]);
const [createTask] = useMutation(createTaskingMutation, {
@@ -91,39 +121,159 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
console.error(data);
}
});
const {submitTask, dialog: taskReferenceSubmitDialog} = useTaskReferenceSubmitter(createTask);
const onBackgroundChange = React.useCallback(() => {
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {...previous, [tabInfo.tabID]: true});
}, [setNewDataForTab, tabInfo.tabID]);
const {
fetched,
fetchedAllTasks,
loadingMore,
loadMoreTasks,
taskChildrenStore,
tasks,
} = useCallbackTaskingData({
callbackID: tabInfo.callbackID,
active,
onBackgroundChange,
onMissingCallback: () => onCloseTab(tabInfo),
const equalTaskTrees = (oldArray, newArray) => {
if(oldArray.length !== newArray.length){
return false;
}
for(let i = 0; i < oldArray.length; i++){
if(oldArray[i].comment !== newArray[i].comment){
return false;
}
if(oldArray[i].commentOperator !== newArray[i].commentOperator){
return false;
}
if(oldArray[i].completed !== newArray[i].completed){
return false;
}
if(oldArray[i].display_params !== newArray[i].display_params){
return false;
}
if(oldArray[i].original_params !== newArray[i].original_params){
return false;
}
if(oldArray[i].status !== newArray[i].status){
return false;
}
if(oldArray[i].timestamp !== newArray[i].timestamp){
return false;
}
if(oldArray[i].opsec_pre_blocked !== newArray[i].opsec_pre_blocked){
return false;
}
if(oldArray[i].opsec_pre_bypassed !== newArray[i].opsec_pre_bypassed){
return false;
}
if(oldArray[i].opsec_post_blocked !== newArray[i].opsec_post_blocked){
return false;
}
if(oldArray[i].opsec_post_bypassed !== newArray[i].opsec_post_bypassed){
return false;
}
if(oldArray[i].tasks.length !== newArray[i].tasks.length){
return false;
}
if(oldArray[i].tags.length !== newArray[i].tags.length){
return false;
}
}
return true;
}
const subscriptionDataCallback = ({data}) => {
if((mountedRef && !mountedRef.current) || (parentMountedRef && !parentMountedRef.current)){
return null;
}
if(!fetched){
setFetched(true);
}
if(index !== value && fetched){
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: true}});
}
//console.log("new subscription data in CallbacksTabsTasking", subscriptionData);
const oldLength = taskingDataRef.current.task.length;
const mergedData = data.data.task.reduce( (prev, cur) => {
const index = prev.findIndex(element => element.id === cur.id);
if(index > -1){
// need to update an element
const updated = prev.map( (element) => {
if(element.id === cur.id){
return cur;
}else{
return element;
}
});
return updated;
}else{
return [...prev, cur];
}
}, [...taskingDataRef.current.task]);
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
if(!equalTaskTrees(taskingDataRef.current.task, mergedData)){
setTaskingData({task: mergedData});
}
if(mergedData.length > oldLength){
setCanScroll(true);
}
if(mergedData.length > taskLimit){
setTaskLimit(mergedData.length);
}
}
useSubscription(getTaskingQuery, {
variables: {callback_id: tabInfo.callbackID, fromNow:fromNow, limit: taskLimit},
shouldResubscribe: true,
onError: data => {
console.error(data)
},
fetchPolicy: "no-cache",
onData: subscriptionDataCallback});
const [getInfiniteScrollTasking, {loading: loadingMore}] = useLazyQuery(getNextBatchTaskingQuery, {
onError: data => {
console.error(data);
},
onCompleted: (data) => {
let foundNew = false;
if(data.callback.length === 0){
onCloseTab(tabInfo);
return;
}
const mergedData = data.task.reduce( (prev, cur) => {
const index = prev.findIndex(element => element.id === cur.id);
if(index > -1){
// need to update an element
const updated = prev.map( (element) => {
if(element.id === cur.id){
return cur;
}else{
return element;
}
});
return updated;
}else{
foundNew = true;
return [...prev, cur];
}
}, [...taskingData.task]);
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
setTaskingData({task: mergedData});
if(!foundNew){
setFetchedAllTasks(true);
}else{
if(data.task.length < fetchLimit){
setFetchedAllTasks(true);
}else{
setFetchedAllTasks(false);
}
}
if(!scrollToBottom){setScrollToBottom(true)}
},
fetchPolicy: "no-cache"
});
const taskingData = React.useMemo(() => ({task: tasks}), [tasks]);
useEffect( () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
setCanScroll(true);
return() => {
mountedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect( () => {
if(fetched && tasks.length > 0 && !scrollToBottom){
setScrollToBottom(true);
}
if(scrollToBottom){
messagesEndRef.current?.scrollIntoView();
messagesEndRef.current.scrollIntoView();
}
}, [fetched, scrollToBottom, tasks.length]);
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
}, [scrollToBottom]);
const loadMoreTasks = () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
}
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
//console.log(message, cmd, parsed);
let params = message.split(" ");
delete params[0];
@@ -136,13 +286,12 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
}
if(cmd.commandparameters.length === 0){
// if there are no parameters, just send whatever the user types along
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd.cmd,
params: params,
parameter_group_name: "Default",
tasking_location: newTaskingLocation,
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
});
}else{
// check if there's a "file" component that needs to be displayed
@@ -177,93 +326,42 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
if(fileParamExists || force_parsed_popup || missingRequiredPrams){
//need to do a popup
if(cmdGroupNames.length > 0){
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0], taskReferenceOptions});
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0]});
}else{
setCommandInfo({...cmd, "parsedParameters": parsed, taskReferenceOptions});
setCommandInfo({...cmd, "parsedParameters": parsed});
}
setOpenParametersDialog(true);
}else{
delete parsed["_"];
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd.cmd,
params: JSON.stringify(parsed),
tasking_location: newTaskingLocation,
original_params: params,
parameter_group_name: cmdGroupNames[0],
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
taskReferenceEditCommandInfo: {...cmd, parsedParameters: parsed, groupName: cmdGroupNames[0], taskReferenceOptions},
});
}
}
}
const onSubmitAliasCommandLine = (message, alias) => {
const trimmed = message.trim();
const command = trimmed.split(/\s+/)[0];
const params = trimmed.length > command.length ? trimmed.slice(command.length).trim() : "";
onCreateTask({
callback_display_id: tabInfo.displayID,
command,
params,
parameter_group_name: "Default",
tasking_location: "alias",
payload_type: alias?.payloadtype?.name || tabInfo.payloadtype,
});
};
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type, metadata={}) => {
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type) => {
setOpenParametersDialog(false);
const selectedTaskReferences = [
...(commandInfo.taskReferenceOptions?.selected_task_references || []),
...(metadata.selectedTaskReferences || []),
];
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd,
params: parameters,
files: files,
tasking_location: "modal",
parameter_group_name: selectedParameterGroup,
payload_type: payload_type,
selected_task_references: selectedTaskReferences,
taskReferenceEditCommandInfo: commandInfo,
payload_type: payload_type
});
}
const getTaskReferenceEditParameters = (variables) => {
try{
const parsedParameters = JSON.parse(variables.params || "{}");
if(parsedParameters && typeof parsedParameters === "object" && !Array.isArray(parsedParameters)){
return parsedParameters;
}
}catch(error){
// fall through to an empty parameter object
}
return {};
}
const openTaskReferenceEditDialog = (variables, commandContext) => {
if(!commandContext?.id){
return;
}
const selectedTaskReferences = variables.selected_task_references || commandContext.taskReferenceOptions?.selected_task_references || [];
setCommandInfo({
...commandContext,
parsedParameters: getTaskReferenceEditParameters(variables),
groupName: variables.parameter_group_name || commandContext.groupName,
taskReferenceOptions: {
...(commandContext.taskReferenceOptions || {}),
selected_task_references: selectedTaskReferences,
},
});
setOpenParametersDialog(true);
}
const onCreateTask = ({callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references, taskReferenceEditCommandInfo}) => {
const variables = {callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references};
const onCreateTask = ({callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}) => {
if(selectedToken.token_id !== undefined){
variables.token_id = selectedToken.token_id;
createTask({variables: {callback_id, command, params, files, token_id: selectedToken.token_id, tasking_location, original_params, parameter_group_name, payload_type}});
}else{
createTask({variables: {callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}});
}
submitTask({
variables,
onTaskReferenceReviewCancel: taskReferenceEditCommandInfo ? (pendingVariables) => openTaskReferenceEditDialog(pendingVariables, taskReferenceEditCommandInfo) : undefined,
});
}
const onSubmitFilter = (newFilter) => {
setFilterOptions(newFilter);
@@ -282,37 +380,34 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
{!fetched && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
{loadingMore && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
{taskReferenceSubmitDialog}
<div style={{overflowY: "auto", flexGrow: 1, width: "100%"}} id={`taskingPanelConsole${tabInfo.callbackID}`}>
{!fetchedAllTasks &&
<MythicStyledTooltip title="Fetch Older Tasks" style={{marginLeft: "50%"}}>
<MythicActionButton colorMode="always" tone="success" iconOnly
<IconButton
onClick={loadMoreTasks}
variant="contained"
size="large"><AutorenewIcon /></MythicActionButton>
color="success"
size="large"><AutorenewIcon /></IconButton>
</MythicStyledTooltip>}
{
taskingData.task.map((task) => (
<TaskDisplayConsole key={"taskinteractdisplayconsole" + task.id} me={me} task={task}
command_id={task.command == null ? 0 : task.command.id}
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}
taskChildrenStore={taskChildrenStore} active={active}/>
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}/>
))
}
<div ref={messagesEndRef}/>
</div>
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter} onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine} changeSelectedToken={changeSelectedToken}
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter} onSubmitCommandLine={onSubmitCommandLine} changeSelectedToken={changeSelectedToken}
filterOptions={filterOptions} callback_id={tabInfo.callbackID}
callback_display_id={tabInfo.displayID}
operation_id={tabInfo.operation_id}
payloadtype_name={tabInfo.payloadtype} focus={index === value}
callback_os={tabInfo.os} parentMountedRef={mountedRef} active={active} />
callback_os={tabInfo.os} parentMountedRef={mountedRef} />
{openParametersDialog &&
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
onClose={()=>{setOpenParametersDialog(false);}}
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
callback_display_id={tabInfo.displayID}
payloadtype_id={tabInfo.payloadtype_id} operation_id={tabInfo.operation_id}
onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}}
/>}
@@ -321,4 +416,4 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
</MythicTabPanel>
);
}
}
@@ -1,40 +1,63 @@
import React, {useEffect} from 'react';
import Box from '@mui/material/Box';
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
import Checkbox from '@mui/material/Checkbox';
import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import MythicTextField from '../../MythicComponents/MythicTextField';
import Switch from '@mui/material/Switch';
import Select from '@mui/material/Select';
import Chip from '@mui/material/Chip';
import Input from '@mui/material/Input';
import InputLabel from '@mui/material/InputLabel';
import MenuItem from '@mui/material/MenuItem';
import FormControl from '@mui/material/FormControl';
import ListItemText from '@mui/material/ListItemText';
import MenuItem from '@mui/material/MenuItem';
import OutlinedInput from '@mui/material/OutlinedInput';
import Select from '@mui/material/Select';
import Switch from '@mui/material/Switch';
import Typography from '@mui/material/Typography';
import ClearIcon from '@mui/icons-material/Clear';
import FilterAltIcon from '@mui/icons-material/FilterAlt';
import MythicTextField from '../../MythicComponents/MythicTextField';
import {MythicChip, SquareChip} from '../../MythicComponents/MythicChip';
import Checkbox from '@mui/material/Checkbox';
import {useQuery, gql } from '@apollo/client';
import { meState } from '../../../cache';
import {useReactiveVar} from '@apollo/client';
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicDialogGrid,
MythicDialogSection,
MythicFormField,
MythicFormSwitchRow
} from '../../MythicComponents/MythicDialogLayout';
const ITEM_HEIGHT = 42;
const PREFIX = 'CallbacksTabsTaskingFilterDialog';
const classes = {
formControl: `${PREFIX}-formControl`,
chips: `${PREFIX}-chips`,
chip: `${PREFIX}-chip`,
noLabel: `${PREFIX}-noLabel`
};
const Root = styled('div')((
{
theme
}
) => ({
[`& .${classes.formControl}`]: {
margin: theme.spacing(1),
width: "100%",
},
[`& .${classes.chips}`]: {
display: 'flex',
flexWrap: 'wrap',
},
[`& .${classes.chip}`]: {
margin: 2,
},
[`& .${classes.noLabel}`]: {
marginTop: theme.spacing(2),
}
}));
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 6 + ITEM_PADDING_TOP,
width: 320,
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250,
},
},
variant: "menu",
@@ -49,60 +72,6 @@ query operatorQuery($operation_id: Int!) {
}
}
}`;
const selectedLabel = (values, singular, plural = singular) => {
if(values.length === 0){
return "";
}
return `${values.length} ${values.length === 1 ? singular : plural}`;
}
const FilterSummaryChip = ({icon, label, muted=false}) => (
<MythicChip
icon={icon}
label={label}
size="small"
tone={muted ? "neutral" : "primary"}
/>
)
const MultiSelectField = ({label, value, options, onChange, emptyLabel}) => {
const renderValue = (selected) => {
if(selected.length === 0){
return <span className="mythic-tasking-filter-select-empty text-muted text-xs font-650">{emptyLabel}</span>
}
return (
<Box className="mythic-tasking-filter-select-chips items-center flex gap-2 flex-wrap min-w-0">
{selected.map((selectedValue) => (
<SquareChip key={selectedValue} label={selectedValue} />
))}
</Box>
);
}
return (
<FormControl fullWidth={true} size="small" className="mythic-tasking-filter-select">
<Select
multiple
displayEmpty
value={value}
onChange={onChange}
input={<OutlinedInput />}
renderValue={renderValue}
MenuProps={MenuProps}
aria-label={label}
>
{options.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox color="primary" checked={value.indexOf(name) > -1} />
<ListItemText primary={name} />
</MenuItem>
))}
</Select>
</FormControl>
)
}
export function CallbacksTabsTaskingFilterDialog(props) {
const me = useReactiveVar(meState);
const [onlyOperators, setOnlyOperators] = React.useState([]);
@@ -142,7 +111,7 @@ export function CallbacksTabsTaskingFilterDialog(props) {
const commandOptionNames = props.filterCommandOptions.map(c => c.cmd);
setCommandOptions(commandOptionNames);
}
}, [props.filterOptions, props.filterCommandOptions]);
}, [props.filterOptions]);
const onSubmit = () => {
props.onSubmit({
"operatorsList": onlyOperators,
@@ -164,20 +133,17 @@ export function CallbacksTabsTaskingFilterDialog(props) {
setHideErrors(event.target.checked);
}
const handleOperatorChange = (event) => {
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
setOnlyOperators(value);
setOnlyOperators(event.target.value);
}
const handleOnlyCommandsChange = (event) => {
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
setOnlyCommands(value);
if(value.length > 0){
setOnlyCommands(event.target.value);
if(event.target.value.length > 0){
setEverythingBut([]);
}
}
const handleEverythingButChange = (event) => {
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
setEverythingBut(value);
if(value.length > 0){
setEverythingBut(event.target.value);
if(event.target.value.length > 0){
setOnlyCommands([]);
}
}
@@ -187,182 +153,110 @@ export function CallbacksTabsTaskingFilterDialog(props) {
const clearAllEverythingBut = () => {
setEverythingBut([]);
}
const clearAllFilters = () => {
setOnlyOperators([]);
setOnlyHasComments(false);
setOnlyCommands([]);
setEverythingBut([]);
setOnlyParameters("");
setHideErrors(false);
}
const activeFilters = [
selectedLabel(onlyOperators, "operator"),
onlyHasComments ? "Has comments" : "",
hideErrors ? "Errors hidden" : "",
selectedLabel(onlyCommands, "included command", "included commands"),
selectedLabel(everythingBut, "excluded command", "excluded commands"),
onlyParameters !== "" ? "Parameter regex" : "",
].filter(Boolean);
return (
<Box className="mythic-tasking-filter-dialog bg-surface-raised text-primary">
<DialogTitle id="mythic-draggable-title" className="mythic-accent-dialog-title relative">
<Box className="mythic-accent-dialog-title-row items-center flex gap-6 min-w-0">
<Box className="mythic-accent-dialog-title-icon items-center inline-flex flex-none justify-center rounded">
<FilterAltIcon fontSize="small" />
</Box>
<Box sx={{minWidth: 0}}>
<Typography component="div" className="mythic-tasking-filter-title-main text-header text-base font-850 leading-115">
Task visibility filters
</Typography>
<Typography component="div" className="mythic-accent-dialog-title-subtitle text-header-muted text-xs font-600 leading-125">
Control which tasks are shown for this callback.
</Typography>
</Box>
</Box>
</DialogTitle>
<DialogContent dividers={true} className="mythic-tasking-filter-dialog-content overflow-auto">
<MythicDialogBody compact={true}>
<Box className="mythic-tasking-filter-summary items-center flex flex-wrap gap-3">
{activeFilters.length > 0 ? (
activeFilters.map((filterLabel) => (
<FilterSummaryChip key={filterLabel} icon={<FilterAltIcon />} label={filterLabel} />
))
) : (
<FilterSummaryChip muted={true} icon={<FilterAltIcon />} label="No active filters" />
)}
</Box>
<MythicDialogSection
title="Operator and task state"
description="Narrow the task list by operator, comments, or error state."
>
<MythicDialogGrid minWidth="18rem">
<MythicFormField
label="Operators"
description="Only show tasks created by selected operators."
>
<MultiSelectField
label="Operators"
<Root>
<DialogTitle id="form-dialog-title">Filter Which Tasks Are Visible in this Callback</DialogTitle>
<DialogContent dividers={true} style={{overflow: "hidden"}}>
<React.Fragment>
<FormControl className={classes.formControl}>
<InputLabel id="operator-chip-label">Only Show Tasks by the Following Operators</InputLabel>
<Select
labelId="operator-chip-label"
multiple
id="operator-chip"
value={onlyOperators}
options={operatorUsernames}
onChange={handleOperatorChange}
emptyLabel="Any operator"
/>
</MythicFormField>
<Box className="mythic-column-stack flex flex-column gap-4 min-w-0">
<MythicFormSwitchRow
label="Only tasks with comments"
description="Require at least one comment."
control={
<Switch
checked={onlyHasComments}
onChange={handleCommentsChange}
color="primary"
name="Only Comments"
inputProps={{'aria-label': 'Only tasks with comments'}}
/>
}
/>
<MythicFormSwitchRow
label="Hide error tasks"
description="Remove tasks that are currently marked as errors."
control={
<Switch
checked={hideErrors}
onChange={handleHideErrorsChange}
color="primary"
name="Hide Errors"
inputProps={{'aria-label': 'Hide error tasks'}}
/>
}
/>
</Box>
</MythicDialogGrid>
</MythicDialogSection>
<MythicDialogSection
title="Command scope"
description="Choose commands to include, or choose commands to hide."
>
<Box className="mythic-tasking-filter-command-grid items-logical-start gap-5 min-w-0 w-full grid">
<MythicFormField
label="Only show commands"
description="When set, only matching command names remain visible."
>
<MultiSelectField
label="Only show commands"
value={onlyCommands}
options={commandOptions}
onChange={handleOnlyCommandsChange}
emptyLabel="No include filter"
/>
{onlyCommands.length > 0 &&
<MythicActionButton
className="mythic-tasking-filter-clear-button"
compact
tone="warning"
onClick={clearAllOnlyCommands}
size="small"
startIcon={<ClearIcon fontSize="small" />}
variant="outlined"
input={<Input />}
renderValue={(selected) => (
<div className={classes.chips}>
{selected.map((value) => (
<Chip key={value} label={value} className={classes.chip} />
))}
</div>
)}
MenuProps={MenuProps}
>
{operatorUsernames.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox color="primary" checked={onlyOperators.indexOf(name) > -1} />
<ListItemText primary={name} />
</MenuItem>
))}
</Select>
</FormControl>
Only Show Tasks with Comments: <Switch checked={onlyHasComments} onChange={handleCommentsChange} color="primary" name="Only Comments" inputProps={{'aria-label': 'primary checkbox'}}/>
<br/>
Hide Error Tasks:<Switch checked={hideErrors} onChange={handleHideErrorsChange} color="primary" name="Hide Errors" inputProps={{'aria-label': 'primary checkbox'}}/>
<FormControl className={classes.formControl}>
<InputLabel id="include-chip-label">Only Show These Commands</InputLabel>
<Select
labelId="include-chip-label"
multiple
id="include-chip"
value={onlyCommands}
onChange={handleOnlyCommandsChange}
input={<Input />}
renderValue={(selected) => (
<div className={classes.chips}>
{selected.map((value) => (
<Chip key={value} label={value} className={classes.chip} />
))}
</div>
)}
MenuProps={MenuProps}
>
Clear include
</MythicActionButton>
}
</MythicFormField>
<Box className="mythic-tasking-filter-choice-divider text-xs font-750 text-muted text-center">or</Box>
<MythicFormField
label="Hide commands"
description="When set, selected command names are removed from view."
>
<MultiSelectField
label="Hide commands"
value={everythingBut}
options={commandOptions}
onChange={handleEverythingButChange}
emptyLabel="No exclude filter"
/>
{everythingBut.length > 0 &&
<MythicActionButton
className="mythic-tasking-filter-clear-button"
compact
tone="warning"
onClick={clearAllEverythingBut}
size="small"
startIcon={<ClearIcon fontSize="small" />}
variant="outlined"
{commandOptions.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox color="primary" checked={onlyCommands.indexOf(name) > -1} />
<ListItemText primary={name} />
</MenuItem>
))}
</Select>
</FormControl>
{onlyCommands.length > 0 &&
<Button onClick={clearAllOnlyCommands} variant={"contained"}>Clear</Button>
}
<FormControl className={classes.formControl}>
<InputLabel id="exclude-chip-label">Do Not Show These Commands</InputLabel>
<Select
labelId="exclude-chip-label"
multiple
id="exclude-chip"
value={everythingBut}
onChange={handleEverythingButChange}
input={<Input />}
renderValue={(selected) => (
<div className={classes.chips}>
{selected.map((value) => (
<Chip key={value} label={value} className={classes.chip} />
))}
</div>
)}
MenuProps={MenuProps}
>
Clear exclude
</MythicActionButton>
}
</MythicFormField>
</Box>
</MythicDialogSection>
<MythicDialogSection
title="Parameter matching"
description="Apply a regular expression to task parameters."
>
<MythicTextField
value={onlyParameters}
onChange={onChange}
name="Parameter regex"
placeholder="Regex to match task parameters"
marginBottom="0px"
marginTop="0px"
/>
</MythicDialogSection>
</MythicDialogBody>
</DialogContent>
<MythicDialogFooter className="mythic-tasking-filter-dialog-actions">
<MythicDialogButton onClick={clearAllFilters}>
Clear all
</MythicDialogButton>
<MythicDialogButton onClick={props.onClose}>
Close
</MythicDialogButton>
<MythicDialogButton onClick={onSubmit} intent="primary">
Apply filters
</MythicDialogButton>
</MythicDialogFooter>
</Box>
{commandOptions.map((name) => (
<MenuItem key={name} value={name}>
<Checkbox color="primary" checked={everythingBut.indexOf(name) > -1} />
<ListItemText primary={name} />
</MenuItem>
))}
</Select>
</FormControl>
{everythingBut.length > 0 &&
<Button onClick={clearAllEverythingBut} variant={"contained"}>Clear</Button>
}
<MythicTextField value={onlyParameters} onChange={onChange} name="Only Show Tasks with the Following Parameter Regex"/>
</React.Fragment>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" >
Close
</Button>
<Button onClick={onSubmit} color="success" variant="contained" >
Filter
</Button>
</DialogActions>
</Root>
);
}
File diff suppressed because it is too large. Load diff
@@ -42,16 +42,15 @@ export function CallbacksTabsTaskingInputTokenSelect(props) {
}
}
return (
<FormControl className={props.className || ""} size={props.modern ? "small" : undefined} style={{width: props.width ? props.width : "20%"}}>
<FormControl style={{width: props.width ? props.width : "20%"}}>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={selected}
onChange={handleChange}
variant={props.modern ? "outlined" : "filled"}
size={props.modern ? "small" : undefined}
variant="filled"
renderValue={renderValue}
input={props.modern ? undefined : <Input style={{width: "100%"}}/>}
input={<Input style={{width: "100%"}}/>}
>
<MenuItem value={"Default Token"} key={0}>Default Token</MenuItem>
{options.map( (opt) => (
@@ -61,3 +60,4 @@ export function CallbacksTabsTaskingInputTokenSelect(props) {
</FormControl>
);
}
@@ -1,23 +1,21 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useEffect, useRef} from 'react';
import { useMutation } from '@apollo/client';
import React, {useEffect, useRef, useCallback, useLayoutEffect} from 'react';
import { gql, useMutation, useLazyQuery, useSubscription } from '@apollo/client';
import { TaskDisplayFlat } from './TaskDisplay';
import {snackActions} from '../../utilities/Snackbar';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {TaskParametersDialog} from './TaskParametersDialog';
import {CallbacksTabsTaskingInput} from './CallbacksTabsTaskingInput';
import LinearProgress from '@mui/material/LinearProgress';
import { IconButton} from '@mui/material';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
import {createTaskingMutation} from './CallbackMutations.js';
import {taskingDataFragment, createTaskingMutation} from './CallbackMutations.js';
import Split from 'react-split';
import {TaskDisplayContainerFlat} from "./TaskDisplayContainer";
import { validate as uuidValidate } from 'uuid';
import {useTaskReferenceSubmitter} from "./taskingReferences";
import {useCallbackTaskingData} from "./useCallbackTaskingData";
import {useTheme} from "@mui/material/styles";
import {getSkewedNow} from "../../utilities/Time";
export function CallbacksTabsTaskingSplitLabel(props){
@@ -63,12 +61,35 @@ export function CallbacksTabsTaskingSplitLabel(props){
</React.Fragment>
)
}
// this is to listen for the latest tasking
const fetchLimit = 30;
const getTaskingQuery = gql`
${taskingDataFragment}
subscription getTasking($callback_id: Int!, $fromNow: timestamp!, $limit: Int){
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}, timestamp: {_gt: $fromNow}}, order_by: {id: desc}, limit: $limit) {
...taskData
}
}
`;
const getNextBatchTaskingQuery = gql`
${taskingDataFragment}
query getBatchTasking($callback_id: Int!, $offset: Int!, $fetchLimit: Int!){
task(where: {callback_id: {_eq: $callback_id}, parent_task_id: {_is_null: true}}, order_by: {id: desc}, limit: $fetchLimit, offset: $offset) {
...taskData
}
callback(where: {id: {_eq: $callback_id}}){
id
}
}
`;
export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTab, me, setNewDataForTab}) =>{
const active = index === value;
const theme = useTheme();
const [taskLimit, setTaskLimit] = React.useState(30);
const [scrollToBottom, setScrollToBottom] = React.useState(false);
const [openParametersDialog, setOpenParametersDialog] = React.useState(false);
const [commandInfo, setCommandInfo] = React.useState({});
const [taskingData, setTaskingData] = React.useState({task: []});
const taskingDataRef = React.useRef({task: []});
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
const [selectedToken, setSelectedToken] = React.useState({});
const [filterOptions, setFilterOptions] = React.useState({
"operatorsList": [],
@@ -82,6 +103,8 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
const mountedRef = React.useRef(true);
const newlyIssuedTasks = useRef(null);
const [fetched, setFetched] = React.useState(false);
const [fetchedAllTasks, setFetchedAllTasks] = React.useState(false);
const messagesEndRef = useRef(null);
const [createTask] = useMutation(createTaskingMutation, {
@@ -97,41 +120,161 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
console.error(data);
}
});
const {submitTask, dialog: taskReferenceSubmitDialog} = useTaskReferenceSubmitter(createTask);
const onBackgroundChange = React.useCallback(() => {
setNewDataForTab((previous) => previous[tabInfo.tabID] ? previous : {...previous, [tabInfo.tabID]: true});
}, [setNewDataForTab, tabInfo.tabID]);
const {
fetched,
fetchedAllTasks,
loadingMore,
loadMoreTasks,
taskChildrenStore,
tasks,
} = useCallbackTaskingData({
callbackID: tabInfo.callbackID,
active,
onBackgroundChange,
onMissingCallback: () => onCloseTab(tabInfo),
const equalTaskTrees = (oldArray, newArray) => {
if(oldArray.length !== newArray.length){
return false;
}
for(let i = 0; i < oldArray.length; i++){
if(oldArray[i].comment !== newArray[i].comment){
return false;
}
if(oldArray[i].commentOperator !== newArray[i].commentOperator){
return false;
}
if(oldArray[i].completed !== newArray[i].completed){
return false;
}
if(oldArray[i].display_params !== newArray[i].display_params){
return false;
}
if(oldArray[i].original_params !== newArray[i].original_params){
return false;
}
if(oldArray[i].status !== newArray[i].status){
return false;
}
if(oldArray[i].timestamp !== newArray[i].timestamp){
return false;
}
if(oldArray[i].opsec_pre_blocked !== newArray[i].opsec_pre_blocked){
return false;
}
if(oldArray[i].opsec_pre_bypassed !== newArray[i].opsec_pre_bypassed){
return false;
}
if(oldArray[i].opsec_post_blocked !== newArray[i].opsec_post_blocked){
return false;
}
if(oldArray[i].opsec_post_bypassed !== newArray[i].opsec_post_bypassed){
return false;
}
if(oldArray[i].tasks.length !== newArray[i].tasks.length){
return false;
}
if(oldArray[i].tags.length !== newArray[i].tags.length){
return false;
}
}
return true;
}
const subscriptionDataCallback = ({data}) => {
if(!fetched){
setFetched(true);
}
if(index !== value && fetched){
setNewDataForTab((prev) => {return {...prev, [tabInfo.tabID]: true}});
}
//console.log("new subscription data in CallbacksTabsTasking", subscriptionData);
const oldLength = taskingDataRef.current.task.length;
const mergedData = data.data.task.reduce( (prev, cur) => {
const index = prev.findIndex(element => element.id === cur.id);
if(index > -1){
// need to update an element
prev[index] = {...cur}
if(prev[index].id === selectedTask.id){
prev[index].selected = true;
}
return [...prev];
}else{
return [...prev, cur];
}
}, [...taskingDataRef.current.task]);
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
if(!equalTaskTrees(taskingDataRef.current.task, mergedData)){
setTaskingData({task: mergedData});
}
if(mergedData.length > oldLength){
//setCanScroll(true);
}
if(mergedData.length > taskLimit){
setTaskLimit(mergedData.length);
}
if(selectedTask.id){
let matchedTask = mergedData.filter(d => d.id === selectedTask.id);
if(matchedTask.length > 0){
setSelectedTask(matchedTask[0]);
}
}
}
useSubscription(getTaskingQuery, {
variables: {callback_id: tabInfo.callbackID, fromNow:fromNow, limit: taskLimit},
shouldResubscribe: true,
onError: data => {
console.error(data)
},
fetchPolicy: "no-cache",
onData: subscriptionDataCallback});
const [getInfiniteScrollTasking, {loading: loadingMore}] = useLazyQuery(getNextBatchTaskingQuery, {
onError: data => {
console.error(data);
},
onCompleted: (data) => {
let foundNew = false;
if(data.callback.length === 0){
onCloseTab(tabInfo);
return;
}
const mergedData = data.task.reduce( (prev, cur) => {
const index = prev.findIndex(element => element.id === cur.id);
if(index > -1){
// need to update an element
const updated = prev.map( (element) => {
if(element.id === cur.id){
if(selectedTask.id === cur.id){
return {...cur, selected: true}
}
return {...cur, selected: false};
}else{
return {...element, selected: false};
}
});
return updated;
}else{
foundNew = true;
return [...prev, {...cur, selected: false}];
}
}, [...taskingData.task]);
mergedData.sort( (a,b) => a.id < b.id ? -1 : 1);
setTaskingData({task: mergedData});
if(!foundNew){
setFetchedAllTasks(true);
}else{
if(data.task.length < fetchLimit){
setFetchedAllTasks(true);
}else{
setFetchedAllTasks(false);
}
}
if(!scrollToBottom){setScrollToBottom(true)}
},
fetchPolicy: "no-cache"
});
const taskingData = React.useMemo(() => ({
task: tasks.map((task) => ({...task, selected: task.id === selectedTask.id})),
}), [selectedTask.id, tasks]);
useEffect( () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
return() => {
mountedRef.current = false;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect( () => {
if(fetched && tasks.length > 0 && !scrollToBottom){
setScrollToBottom(true);
}
if(scrollToBottom){
messagesEndRef.current?.scrollIntoView();
messagesEndRef.current.scrollIntoView();
}
}, [fetched, scrollToBottom, tasks.length]);
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
}, [scrollToBottom]);
const loadMoreTasks = () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
}
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
//console.log(message, cmd, parsed);
let params = message.split(" ");
delete params[0];
@@ -144,13 +287,12 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
}
if(cmd.commandparameters.length === 0){
// if there are no parameters, just send whatever the user types along
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd.cmd,
params: params,
parameter_group_name: "Default",
tasking_location: newTaskingLocation,
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
});
}else{
// check if there's a "file" component that needs to be displayed
@@ -185,93 +327,42 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
if(fileParamExists || force_parsed_popup || missingRequiredPrams){
//need to do a popup
if(cmdGroupNames.length > 0){
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0], taskReferenceOptions});
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0]});
}else{
setCommandInfo({...cmd, "parsedParameters": parsed, taskReferenceOptions});
setCommandInfo({...cmd, "parsedParameters": parsed});
}
setOpenParametersDialog(true);
}else{
delete parsed["_"];
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd.cmd,
params: JSON.stringify(parsed),
tasking_location: newTaskingLocation,
original_params: params,
parameter_group_name: cmdGroupNames[0],
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
taskReferenceEditCommandInfo: {...cmd, parsedParameters: parsed, groupName: cmdGroupNames[0], taskReferenceOptions},
});
}
}
}
const onSubmitAliasCommandLine = (message, alias) => {
const trimmed = message.trim();
const command = trimmed.split(/\s+/)[0];
const params = trimmed.length > command.length ? trimmed.slice(command.length).trim() : "";
onCreateTask({
callback_display_id: tabInfo.displayID,
command,
params,
parameter_group_name: "Default",
tasking_location: "alias",
payload_type: alias?.payloadtype?.name || tabInfo.payloadtype,
});
};
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type, metadata={}) => {
const submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type) => {
setOpenParametersDialog(false);
const selectedTaskReferences = [
...(commandInfo.taskReferenceOptions?.selected_task_references || []),
...(metadata.selectedTaskReferences || []),
];
onCreateTask({callback_display_id: tabInfo.displayID,
onCreateTask({callback_id: tabInfo.displayID,
command: cmd,
params: parameters,
files: files,
tasking_location: "modal",
parameter_group_name: selectedParameterGroup,
payload_type: payload_type,
selected_task_references: selectedTaskReferences,
taskReferenceEditCommandInfo: commandInfo,
payload_type: payload_type
});
}
const getTaskReferenceEditParameters = (variables) => {
try{
const parsedParameters = JSON.parse(variables.params || "{}");
if(parsedParameters && typeof parsedParameters === "object" && !Array.isArray(parsedParameters)){
return parsedParameters;
}
}catch(error){
// fall through to an empty parameter object
}
return {};
}
const openTaskReferenceEditDialog = (variables, commandContext) => {
if(!commandContext?.id){
return;
}
const selectedTaskReferences = variables.selected_task_references || commandContext.taskReferenceOptions?.selected_task_references || [];
setCommandInfo({
...commandContext,
parsedParameters: getTaskReferenceEditParameters(variables),
groupName: variables.parameter_group_name || commandContext.groupName,
taskReferenceOptions: {
...(commandContext.taskReferenceOptions || {}),
selected_task_references: selectedTaskReferences,
},
});
setOpenParametersDialog(true);
}
const onCreateTask = ({callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references, taskReferenceEditCommandInfo}) => {
const variables = {callback_display_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type, selected_task_references};
const onCreateTask = ({callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}) => {
if(selectedToken.token_id !== undefined){
variables.token_id = selectedToken.token_id;
createTask({variables: {callback_id, command, params, files, token_id: selectedToken.token_id, tasking_location, original_params, parameter_group_name, payload_type}});
}else{
createTask({variables: {callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}});
}
submitTask({
variables,
onTaskReferenceReviewCancel: taskReferenceEditCommandInfo ? (pendingVariables) => openTaskReferenceEditDialog(pendingVariables, taskReferenceEditCommandInfo) : undefined,
});
}
const onSubmitFilter = (newFilter) => {
setFilterOptions(newFilter);
@@ -286,46 +377,57 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
}
}
const changeSelectedTask = (task) => {
let updated = {...taskingDataRef.current};
for(let i = 0; i < updated.task.length; i++){
if(updated.task[i].id === task.id){
updated.task[i].selected = true;
} else {
updated.task[i].selected = false;
for(let j = 0; j < updated.task[i].tasks.length; j++){
if(updated.task[i].tasks[j].id === task.id){
updated.task[i].tasks[j].selected = true;
} else {
updated.task[i].tasks[j].selected = false;
}
}
}
}
setTaskingData(updated)
setSelectedTask(task);
}
useEffect( () => {
if(selectedTask.id){
const updatedSelectedTask = tasks.find((task) => task.id === selectedTask.id);
if(updatedSelectedTask && updatedSelectedTask !== selectedTask){
setSelectedTask(updatedSelectedTask);
}
}
taskingDataRef.current = taskingData;
if(newlyIssuedTasks.current){
// we just issued a task, check if we have data on it to set it as the current one
let index = tasks.findIndex(e => e.id === newlyIssuedTasks.current)
let index = taskingDataRef.current.task.findIndex(e => e.id === newlyIssuedTasks.current)
if(index > -1){
newlyIssuedTasks.current = null;
if(tasks[index]?.operator?.username === (me?.user?.username || "")){
if(taskingDataRef.current.task[index]?.operator?.username === (me?.user?.username || "")){
let el = document.getElementById(`taskingPanelSplit${tabInfo.callbackID}`);
if(el && el.scrollHeight - el.scrollTop - el.clientHeight < 100){
changeSelectedTask(tasks[index]);
changeSelectedTask(taskingDataRef.current.task[index]);
}
}
}
}
}, [tasks, newlyIssuedTasks, selectedTask]);
}, [taskingData, newlyIssuedTasks]);
return (
<MythicTabPanel index={index} value={value} >
{taskReferenceSubmitDialog}
<Split direction="horizontal" minSize={[0,0]} sizes={[30, 70]}
style={{width: "100%", height: "100%", minWidth: 0, overflow: "hidden", display: "flex"}} >
<div className="bg-gray-base" style={{display: "inline-flex", flexDirection: "column", minWidth: 0, overflow: "hidden"}} >
style={{width: "100%", height: "100%", overflow: "auto", display: "flex", justifyContent: "flex-end"}} >
<div className="bg-gray-base" style={{display: "inline-flex", flexDirection: "column"}} >
<div style={{overflowY: "auto", overflowX: "hidden", flexGrow: 1, minWidth: 0}} id={`taskingPanelSplit${tabInfo.callbackID}`}>
<div style={{overflowY: "auto", flexGrow: 1}} id={`taskingPanelSplit${tabInfo.callbackID}`}>
{!fetchedAllTasks &&
<MythicStyledTooltip title="Fetch Older Tasks" tooltipStyle={{width: "100%"}}>
<MythicActionButton colorMode="always" tone="success" iconOnly
<MythicStyledTooltip title="Fetch Older Tasks">
<IconButton
onClick={loadMoreTasks}
variant="contained"
color="success"
style={{marginLeft: "50%"}}
size="large"><AutorenewIcon/></MythicActionButton>
size="large"><AutorenewIcon/></IconButton>
</MythicStyledTooltip>}
{!fetched && <LinearProgress color="primary" thickness={2} style={{paddingTop: "5px"}}/>}
{loadingMore && <LinearProgress color="info" thickness={2} style={{paddingTop: "5px"}}/>}
@@ -338,7 +440,6 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
changeSelectedTask(tsk)
}}
showOnSelectTask={true} selectedTask={selectedTask}
taskChildrenStore={taskChildrenStore} active={active}
/>
))
}
@@ -346,35 +447,28 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
</div>
</div>
<div className="bg-gray-light" style={{display: "inline-flex", flexDirection: "column",
marginBottom: "0.2rem", borderRadius: theme.shape.borderRadius,
height: "100%", minWidth: 0, overflow: "hidden", width: "100%"}}>
<div className="bg-gray-light" style={{display: "inline-flex", flexDirection: "column", height: "100%", width: "100%"}}>
{selectedTask.id > 0 && <TaskDisplayFlat key={"taskinteractdisplaysplit" + selectedTask.id} me={me} task={selectedTask}
command_id={selectedTask.command == null ? 0 : selectedTask.command.id}
filterOptions={filterOptions}
onSelectTask={(tsk) => {
}}
compact={true}
selectedTask={selectedTask}
taskChildrenStore={taskChildrenStore} active={active}
/>}
<CallbacksTabsTaskingSplitTable selectedTask={selectedTask} me={me} filterOptions={filterOptions} active={active}
<CallbacksTabsTaskingSplitTable selectedTask={selectedTask} me={me} filterOptions={filterOptions}
onSelectTask={(tsk) => {changeSelectedTask(tsk)}} changeSelectedTask={changeSelectedTask}
/>
</div>
</Split>
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter}
payloadtype_name={tabInfo.payloadtype} focus={index === value}
onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine} changeSelectedToken={changeSelectedToken}
onSubmitCommandLine={onSubmitCommandLine} changeSelectedToken={changeSelectedToken}
filterOptions={filterOptions} callback_id={tabInfo.callbackID}
callback_display_id={tabInfo.displayID}
operation_id={tabInfo.operation_id}
callback_os={tabInfo.os} parentMountedRef={mountedRef} active={active} />
callback_os={tabInfo.os} parentMountedRef={mountedRef} />
{openParametersDialog &&
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
onClose={()=>{setOpenParametersDialog(false);}}
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
callback_display_id={tabInfo.displayID}
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
payloadtype_id={tabInfo.payloadtype_id} operation_id={tabInfo.operation_id}
onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}}
/>}
@@ -384,15 +478,12 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
</MythicTabPanel>
);
}
const CallbacksTabsTaskingSplitTable = ({selectedTask, me, active=true}) => {
const theme = useTheme();
const CallbacksTabsTaskingSplitTable = ({selectedTask, me}) => {
return (
<div style={{width: "100%", flex: "1 1 auto", minHeight: 0, minWidth: 0,
borderRadius: theme.shape.borderRadius, marginTop: "0.2rem",
overflow: "hidden", display: "flex", flexDirection: "column"}} >
{active && selectedTask.id > 0 &&
<div style={{width: "100%", height: "100%", overflowY: "auto", display: "flex", flexDirection: "column"}} >
{selectedTask.id > 0 &&
<TaskDisplayContainerFlat key={selectedTask.id} me={me} task={selectedTask} />
}
</div>
)
}
}
@@ -142,6 +142,7 @@ callback(where: {active: {_eq: true}}) {
active
id
display_id
operation_id
user
host
ip
@@ -149,17 +150,34 @@ callback(where: {active: {_eq: true}}) {
os
process_name
integrity_level
extra_info
tags {
tagtype {
name
color
id
}
id
}
payload {
payloadtype {
name
id
}
}
callbackc2profiles {
c2profile {
name
has_logo
is_p2p
}
}
}
source {
active
id
display_id
operation_id
user
host
ip
@@ -167,12 +185,28 @@ callback(where: {active: {_eq: true}}) {
os
process_name
integrity_level
extra_info
tags {
tagtype {
name
color
id
}
id
}
payload {
payloadtype {
name
id
}
}
callbackc2profiles {
c2profile {
name
has_logo
is_p2p
}
}
}
c2profile {
id
@@ -191,7 +225,9 @@ callbackgraphedge_stream(batch_size: 100, cursor: {initial_value: {updated_at: $
destination {
active
id
color
display_id
operation_id
user
host
ip
@@ -199,17 +235,35 @@ callbackgraphedge_stream(batch_size: 100, cursor: {initial_value: {updated_at: $
os
process_name
integrity_level
extra_info
tags {
tagtype {
name
color
id
}
id
}
payload {
payloadtype {
name
id
}
}
callbackc2profiles {
c2profile {
name
has_logo
is_p2p
}
}
}
source {
active
id
color
display_id
operation_id
user
host
ip
@@ -217,12 +271,28 @@ callbackgraphedge_stream(batch_size: 100, cursor: {initial_value: {updated_at: $
os
process_name
integrity_level
extra_info
tags {
tagtype {
name
color
id
}
id
}
payload {
payloadtype {
name
id
}
}
callbackc2profiles {
c2profile {
name
has_logo
is_p2p
}
}
}
c2profile {
id
@@ -366,7 +436,7 @@ export function CallbacksTop(props){
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{height: "100%", flex: "1 1 auto", minWidth: 0, overflow: "hidden"}}>
<div style={{height: "100%", width: "100%"}}>
<CallbackGraphEdgesContext.Provider value={callbackEdges.current}>
<OnOpenTabContext.Provider value={onOpenTabLocal}>
<OnOpenTabsContext.Provider value={onOpenTabsLocal}>
@@ -386,4 +456,4 @@ export function CallbacksTop(props){
</CallbackGraphEdgesContext.Provider>
</div>
);
}
}
@@ -1,14 +1,14 @@
import {MythicActionButton} from "../../MythicComponents/MythicActionButton";
import React from 'react';
import TableCell from '@mui/material/TableCell';
import TableRow from '@mui/material/TableRow';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import Typography from '@mui/material/Typography';
import {useQuery, gql, useMutation} from '@apollo/client';
import LinearProgress from '@mui/material/LinearProgress';
import Paper from '@mui/material/Paper';
import {useTheme} from '@mui/material/styles';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
@@ -24,19 +24,15 @@ import { snackActions } from '../../utilities/Snackbar';
import Box from '@mui/material/Box';
import Dialog from '@mui/material/Dialog';
import {DetailedPayloadTable, ParseForDisplay} from "../Payloads/DetailedPayloadTable";
import {Link} from '@mui/material';
import {Button, Link, IconButton} from '@mui/material';
import {MythicAgentSVGIcon} from "../../MythicComponents/MythicAgentSVGIcon";
import MenuBookIcon from '@mui/icons-material/MenuBook';
import PlayCircleFilledTwoToneIcon from '@mui/icons-material/PlayCircleFilledTwoTone';
import InfoIconOutline from '@mui/icons-material/InfoOutlined';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import {HostFileDialog} from "../Payloads/HostFileDialog";
import PublicIcon from '@mui/icons-material/Public';
import {payloadsCallbackAllowed} from "../Payloads/Payloads";
import Switch from '@mui/material/Switch';
import {MythicSectionHeader} from "../../MythicComponents/MythicPageHeader";
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
import {ScriptingCommandDialog} from "../PayloadTypesC2Profiles/PayloadTypeCommandsDialog";
const GET_Payload_Details = gql`
query GetCallbackDetails($callback_id: Int!) {
@@ -187,15 +183,11 @@ mutation removeLoadedCommand($id: Int!){
}
`;
export function DetailedCallbackTable(props){
const theme = useTheme();
const me = useReactiveVar(meState);
const [openHostDialog, setOpenHostDialog] = React.useState(false);
const [openDetailedView, setOpenDetailedView] = React.useState(false);
const [openAddRemoveCommandsDialog, setOpenAddRemoveCommandsDialog] = React.useState(false);
const [openScriptDialog, setOpenScriptDialog] = React.useState({
open: false,
command_id: 0,
command_name: "",
});
const [commands, setCommands] = React.useState([]);
const [buildParameters, setBuildParameters] = React.useState([]);
const [c2Profiles, setC2Profiles] = React.useState([]);
@@ -272,13 +264,7 @@ export function DetailedCallbackTable(props){
onCompleted: data => {
const commandState = data.callback_by_pk.loadedcommands.map( (c) =>
{
return {
cmd: c.command.cmd,
command_id: c.command.id,
mythic: c.command.version,
payload: c.version,
payload_type: c.command.payloadtype.name,
}
return {cmd: c.command.cmd, mythic: c.command.version, payload: c.version, payload_type: c.command.payloadtype.name}
}).sort((a,b) => (a.cmd > b.cmd) ? 1: ((b.cmd > a.cmd) ? -1 : 0));
setCommands(commandState);
const buildParametersState = data.callback_by_pk.payload.buildparameterinstances.map( (b) =>
@@ -348,11 +334,18 @@ export function DetailedCallbackTable(props){
<React.Fragment>
<DialogTitle id="form-dialog-title">Callback Configuration</DialogTitle>
<DialogContent dividers={true}>
<MythicSectionHeader title="Callback Information" sx={{mt: 0}} />
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
Callback Information
</Typography>
</Paper>
<ExpandedCallbackSideDetailsTable {...data.callback_by_pk} />
<MythicSectionHeader title="Payload Information" />
<TableContainer>
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
Payload Information
</Typography>
</Paper>
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
<TableHead>
<TableRow hover>
@@ -373,10 +366,10 @@ export function DetailedCallbackTable(props){
<TableCell>UUID</TableCell>
<TableCell>
{data.callback_by_pk.payload.uuid}
<MythicActionButton colorMode="always" tone="info" iconOnly disableFocusRipple={true}
disableRipple={true} size="small" onClick={() => setOpenDetailedView(true)}>
<IconButton disableFocusRipple={true}
disableRipple={true} size="small" color="info" onClick={() => setOpenDetailedView(true)}>
<InfoIconOutline />
</MythicActionButton>
</IconButton>
</TableCell>
{openDetailedView ? (
<MythicDialog fullWidth={true} maxWidth="lg" open={openDetailedView}
@@ -398,9 +391,9 @@ export function DetailedCallbackTable(props){
<TableRow hover>
<TableCell>Download URL</TableCell>
<TableCell style={{display: "flex", alignItems: "center"}}>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}>
{window.location.origin + "/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}
</FileDownloadLinkWithAuth>
</Link>
<MythicStyledTooltip title={"Host Payload Through C2"} >
<PublicIcon color={"info"} style={{marginLeft: "20px", cursor: "pointer"}} onClick={()=>{setOpenHostDialog(true);}} />
</MythicStyledTooltip>
@@ -458,9 +451,11 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
</TableContainer>
<MythicSectionHeader title="Build Parameters" />
<TableContainer>
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
Build Parameters
</Typography>
</Paper>
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
<TableHead>
<TableRow>
@@ -496,9 +491,11 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
</TableContainer>
<MythicSectionHeader title="Build Steps" />
<TableContainer>
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
Build Steps
</Typography>
</Paper>
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
<TableHead>
<TableRow>
@@ -524,11 +521,13 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
</TableContainer>
{ c2Profiles.map( (c2) => (
<React.Fragment key={"c2frag" + data.callback_by_pk.payload.id + c2.c2_profile}>
<MythicSectionHeader title={c2.c2_profile} />
<TableContainer>
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
{c2.c2_profile}
</Typography>
</Paper>
<Table size="small" aria-label="details" style={{"overflowWrap": "break-word"}}>
<TableHead>
<TableRow>
@@ -555,16 +554,15 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
</TableContainer>
</React.Fragment>
))}
<MythicSectionHeader
title="Loaded Commands"
actions={
<MythicActionButton variant="outlined" size="small" onClick={()=>{setOpenAddRemoveCommandsDialog(true)}}>Add/Remove Commands</MythicActionButton>
}
/>
<TableContainer>
<Paper elevation={5} style={{backgroundColor: theme.pageHeader.main, color: theme.pageHeaderText.main,marginBottom: "5px", marginTop: "10px"}} variant={"elevation"}>
<Typography variant="h6" style={{textAlign: "left", display: "inline-block", marginLeft: "20px", color: theme.pageHeaderColor}}>
Loaded Commands
</Typography>
<Button style={{float: "right"}} variant="contained" size="small" onClick={()=>{setOpenAddRemoveCommandsDialog(true)}} >Add/Remove Commands</Button>
</Paper>
<Table size="small" aria-label="details" style={{"overflowWrap": "break-word"}}>
<TableHead>
<TableRow>
@@ -572,7 +570,7 @@ export function DetailedCallbackTable(props){
<TableCell>Command Name</TableCell>
<TableCell>Mythic Version</TableCell>
<TableCell>Loaded Version</TableCell>
<TableCell style={{width: "6.5rem"}}>Actions</TableCell>
<TableCell style={{width: "5rem"}}>Documentation</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -586,31 +584,10 @@ export function DetailedCallbackTable(props){
<TableCell>{cmd.mythic}</TableCell>
<TableCell>{cmd.payload}</TableCell>
<TableCell>
<div className="items-center flex flex-wrap gap-3 flex-nowrap">
<MythicStyledTooltip title="Open command documentation">
<MythicActionButton iconOnly
appearance="raised" colorMode="hover" tone="info"
size="small"
target="_blank"
href={"/docs/agents/" + cmd.payload_type + "/commands/" + cmd.cmd}
>
<MenuBookIcon fontSize="small" />
</MythicActionButton>
</MythicStyledTooltip>
<MythicStyledTooltip title="Scripting parameters">
<MythicActionButton iconOnly
appearance="raised" colorMode="hover" tone="info"
size="small"
onClick={() => setOpenScriptDialog({
open: true,
command_id: cmd.command_id,
command_name: cmd.cmd,
})}
>
<PlayCircleFilledTwoToneIcon fontSize="small" />
</MythicActionButton>
</MythicStyledTooltip>
</div>
<IconButton variant="contained" target="_blank"
href={"/docs/agents/" + cmd.payload_type + "/commands/" + cmd.cmd}>
<MenuBookIcon />
</IconButton>
</TableCell>
</TableRow>
))
@@ -660,29 +637,14 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
</TableContainer>
{openScriptDialog.open &&
<MythicDialog
fullWidth={true}
maxWidth="lg"
open={openScriptDialog.open}
onClose={() => setOpenScriptDialog({open: false, command_id: 0, command_name: ""})}
innerDialog={
<ScriptingCommandDialog
command_id={openScriptDialog.command_id}
command_name={openScriptDialog.command_name}
onClose={() => setOpenScriptDialog({open: false, command_id: 0, command_name: ""})}
/>
}
/>
}
</Table>
</DialogContent>
<DialogActions>
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
)
}
@@ -7,14 +7,12 @@ import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
import {Link, Typography} from '@mui/material';
import {MythicActionButton} from '../../MythicComponents/MythicActionButton';
import {Button, Link, Typography} from '@mui/material';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import {MythicDialog} from "../../MythicComponents/MythicDialog";
import {PreviewFileMediaDialog} from "../../MythicComponents/PreviewFileMedia";
import {PreviewFileMediaDialog} from "../Search/PreviewFileMedia";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
export function DownloadHistoryDialog(props){
const [history, setHistory] = React.useState([]);
@@ -35,7 +33,7 @@ export function DownloadHistoryDialog(props){
<React.Fragment>
<DialogTitle id="form-dialog-title">{props.title}</DialogTitle>
<TableContainer>
<TableContainer className="mythicElement">
<Table size="small" style={{"tableLayout": "fixed", "maxWidth": "calc(100vw)", "overflow": "scroll"}}>
<TableHead>
<TableRow>
@@ -61,7 +59,7 @@ export function DownloadHistoryDialog(props){
{hist.deleted ? (
<Typography variant="body2" style={{wordBreak: "break-all"}}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</Typography>
) : (
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + hist.agent_file_id}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</FileDownloadLinkWithAuth>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + hist.agent_file_id}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</Link>
)
}
</div>
@@ -104,9 +102,9 @@ export function DownloadHistoryDialog(props){
</TableContainer>
<DialogActions>
<MythicActionButton colorMode="always" onClick={props.onClose} tone="primary" variant="contained">
<Button onClick={props.onClose} variant="contained" color="primary">
Close
</MythicActionButton>
</Button>
</DialogActions>
</React.Fragment>
);
@@ -1,62 +0,0 @@
export const FILE_EDITOR_REQUEST = 100;
export const FILE_EDITOR_RESPONSE = 101;
export const FILE_EDITOR_ERROR = 102;
export const MAX_FILE_EDITOR_SIZE = 2000000;
const decodeResponse = (encoded) => {
const binary = window.atob(encoded || '');
const bytes = Uint8Array.from(binary, character => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
};
export const parseFileEditorMessage = (response) => {
if(response?.interactive_task_type !== FILE_EDITOR_RESPONSE &&
response?.interactive_task_type !== FILE_EDITOR_ERROR){
return null;
}
try {
return {
type: response.interactive_task_type,
...JSON.parse(decodeResponse(response.response)),
};
} catch (_error) {
return null;
}
};
export const buildFileEditorRequest = ({action, requestID, fileID, expectedSHA1, forceOverwrite=false}) => {
const request = {
action,
request_id: requestID,
};
if(fileID){
request.file_id = fileID;
}
if(expectedSHA1){
request.expected_sha1 = expectedSHA1;
}
if(forceOverwrite){
request.force_overwrite = true;
}
return request;
};
export const mergeFileEditorHistory = (existing, incoming) => {
const snapshots = new Map((existing || []).map(snapshot => [snapshot.response_id, snapshot]));
(incoming || []).forEach(snapshot => snapshots.set(snapshot.response_id, snapshot));
return Array.from(snapshots.values()).sort((left, right) => left.response_id - right.response_id);
};
export const getReusableStagedEdit = ({conflict, currentContent, dirty}) => {
const staged = conflict?.staged || (conflict?.file_id ? {
action: 'save',
fileID: conflict.file_id,
} : null);
if(!staged?.fileID){
return null;
}
if(staged.content !== undefined){
return staged.content === currentContent ? staged : null;
}
return dirty ? null : staged;
};
Loaded 100 of 911 files, more files were not shown because too many files have changed in this diff. Show more