Compare commits

...
118 Commits
Author SHA1 Message Date
github-actions d4d2efc654 Bump Mythic Dockerfile tag to match release 'v4.0.0rc2' 2026-07-15 23:13:05 +00:00
its-a-feature 6bb0a119f0 updating release branch 2026-07-15 15:53:27 -07:00
its-a-feature 98d8163400 updating dockerfile 2026-07-15 15:09:19 -07:00
its-a-feature 4f8c7d2935 updated build process for ui 2026-07-15 15:07:50 -07:00
its-a-feature d5acf79e33 first v4.0 ui build with chunking 2026-07-15 14:24:41 -07:00
its-a-feature 836780df90 updating github actions 2026-07-15 13:32:56 -07:00
its-a-feature f7bbdec0ee ui tweaks 2026-07-15 13:06:28 -07:00
its-a-feature 03ed56c2c5 UI refactor 2026-07-15 09:54:01 -07:00
its-a-feature efbfb4fc33 updated task keyword reference expansion 2026-07-14 11:06:52 -07:00
its-a-feature 48bea58d21 updated for build metadata and updated wrapping support 2026-07-14 08:09:29 -07:00
its-a-feature ac5977bdab bunch of ui performance enhancements 2026-07-11 11:24:06 -07:00
its-a-feature ee8008b8ba updated header height on small width and chat streaming 2026-07-10 11:01:03 -07:00
its-a-feature d213d7dd66 fixed new channel select bug 2026-07-10 08:44:47 -07:00
its-a-feature b766e08e0e removing border 2026-07-10 08:29:56 -07:00
its-a-feature 16f4c76a07 fixing chat icon scope 2026-07-10 08:26:17 -07:00
its-a-feature d0f38e1fdb chat container icons, clickable meta chips, markdown subagent summary 2026-07-09 15:39:12 -07:00
its-a-feature f64ccca874 bump version 2026-07-09 13:11:52 -07:00
its-a-feature 4488cfac94 updated to allow post-cancel updates from chats 2026-07-09 13:11:28 -07:00
its-a-feature 796d4937ba updated icons, subagent flow, and end dates 2026-07-09 12:08:44 -07:00
its-a-feature 738c80b049 sub agents, customized metadata chips 2026-07-08 21:42:00 -07:00
its-a-feature 4e6af3a864 chat history and timestamp update 2026-07-08 15:14:30 -07:00
its-a-feature f04095a837 fixing slash command iteration over string vs struct 2026-07-08 14:56:02 -07:00
its-a-feature 9999e163ee updated global setting check 2026-07-08 13:30:19 -07:00
its-a-feature 33ec001541 updating mcp to generic user input requests 2026-07-08 11:37:17 -07:00
its-a-feature c943e0fc65 added mute field to chat channels 2026-07-07 16:06:40 -07:00
its-a-feature b52c332e9b better background tracking 2026-07-07 08:43:36 -07:00
its-a-feature 54cf000a4c updated process browser format 2026-07-06 18:09:53 -07:00
its-a-feature 541704f4c7 update mitre attack, fix upload tracking bug, add more file browser 2026-07-06 15:29:21 -07:00
its-a-feature 16c8cb0ff7 update eventing edit/duplicate to use wizard stepper 2026-07-06 14:16:42 -07:00
its-a-feature ae441b2a3d eventing apitoken scopes and hasura preflight scope checks 2026-07-06 13:12:50 -07:00
its-a-feature c507b99118 reworked operator alias style 2026-07-01 18:54:25 -07:00
its-a-feature be39f4762b uploaded file edit/save functionality 2026-06-30 16:25:17 -07:00
its-a-feature 7e17721d2d refactored task references and adding @link support 2026-06-29 18:38:00 -07:00
its-a-feature 935b1b023c speed up RPC calls 2026-06-29 16:46:23 -07:00
its-a-feature 58cecc3f86 try to prevent duplicate file uploads 2026-06-29 16:43:51 -07:00
its-a-feature ba68d1aafa adding keyword resolution and reverting to tasks 2026-06-29 09:58:39 -07:00
its-a-feature f4f9f10a6a updated cred parsing, kerberos display, and refactored ticket parsers 2026-06-26 17:36:04 -07:00
its-a-feature 9c8dab4c4a updated kerberos parsing and browser script table text color rendering 2026-06-26 12:33:56 -07:00
its-a-feature 3baef93ed1 updating for credential expansion support 2026-06-25 21:14:11 -07:00
its-a-feature fba421517a updating credential search and tasking processing 2026-06-24 13:35:45 -07:00
its-a-feature 3006594a35 credential and operator alias updates 2026-06-21 10:13:31 -07:00
its-a-feature 07c65b9d65 updated ui colors 2026-06-21 10:13:31 -07:00
its-a-feature 2099875c21 export/import operator aliases 2026-06-21 10:13:31 -07:00
its-a-feature ce7a962d44 add aliases, update tasking for aliases, more relaxed parsing 2026-06-21 10:13:31 -07:00
its-a-feature 2074f51409 updated login page and json inline schema 2026-06-21 10:13:31 -07:00
its-a-feature c1cefea79c initial iteration of UI for Jack's schema viewer 2026-06-21 10:13:31 -07:00
its-a-feature acdd89ca45 display names, choices display names, complex choices support 2026-06-21 10:13:31 -07:00
its-a-feature 3a90a99259 adding complex_choices, other_parameters, and c2 param dynamic queries 2026-06-21 10:13:31 -07:00
its-a-feature b261de26d1 updates for new parameter type 2026-06-21 10:13:30 -07:00
its-a-feature cea78411f5 build parameter groups and normalizing build/c2 parameter features 2026-06-21 10:13:30 -07:00
its-a-feature 6f1e4eac10 hosting files updates 2026-06-21 10:13:30 -07:00
its-a-feature 4f95465278 mcp tool confirmation and scripting normalization 2026-06-21 10:13:30 -07:00
its-a-feature 0a6f30b7da better support chat cancel 2026-06-21 10:13:30 -07:00
its-a-feature 28feedd2bc make and invalidate bot apitokens 2026-06-21 10:13:30 -07:00
its-a-feature 0f2ea3d9cc create and invalidate bot apitokens for chat 2026-06-21 10:13:30 -07:00
its-a-feature 38f201caeb adding authContext to gRPC calls 2026-06-21 10:13:30 -07:00
its-a-feature bab9e55ac4 better markdown/terminal view for task output 2026-06-21 10:13:29 -07:00
its-a-feature 239882474a block list updates, user interact eventing prompts 2026-06-21 10:13:29 -07:00
its-a-feature 43c1ca8429 eventing prompt user input/approval 2026-06-21 10:13:29 -07:00
its-a-feature c57a8fe524 theme updates and better chat config components 2026-06-21 10:13:29 -07:00
its-a-feature 08f84fed4e initial chat feature 2026-06-21 10:13:29 -07:00
its-a-feature 1674f13b1e RabbitMQ Auth Context tracking requirements 2026-06-21 10:13:29 -07:00
its-a-feature 8ae176c49e adjusted reporting page, payloads table, 2026-06-21 10:13:29 -07:00
its-a-feature a332a2332c updated file browser for container files 2026-06-21 10:13:28 -07:00
its-a-feature 01b0c7068e added .dockerignore 2026-06-21 10:13:28 -07:00
its-a-feature b5f982534d better docs pages 2026-06-21 10:13:28 -07:00
its-a-feature 90860793ed fixing sql queries and container down resolver 2026-06-21 10:13:28 -07:00
its-a-feature 68e502ab1c updated installed services table 2026-06-21 10:13:28 -07:00
its-a-feature bd5fa6e0be remove minimap adjust resolution of event log warnings 2026-06-21 10:13:28 -07:00
its-a-feature 21d9368940 per response decode to not bulk bail on bad entries 2026-06-21 10:13:28 -07:00
its-a-feature 862cf8a552 delegate proxy check performance increases 2026-06-21 10:13:28 -07:00
its-a-feature 0225bc7220 c2 diagram updates 2026-06-21 10:13:27 -07:00
its-a-feature fc1944afc9 c2 profile path modernization 2026-06-21 10:13:27 -07:00
its-a-feature c77b71c23a delegate check improvements and process table ui updates 2026-06-21 10:13:27 -07:00
its-a-feature 15bf19a796 better json table displays and search table responses 2026-06-21 10:13:27 -07:00
its-a-feature cde31af97d added file browser insert/delete performance updates 2026-06-21 10:13:27 -07:00
its-a-feature a4a9f141c8 better task completion tracking, proxy tracking, and search page updates 2026-06-21 10:13:27 -07:00
its-a-feature 79c625b7df updated task response iteration and ui task cli updates 2026-06-21 10:13:27 -07:00
its-a-feature 00be046ec3 proxy traffic perf updates and ui parameter dialog modernization 2026-06-21 10:13:27 -07:00
its-a-feature c14933b6b8 better rabbitmq handling and task filter ui updates 2026-06-21 10:13:27 -07:00
its-a-feature e9f70d68ab updates to task and response database tracking 2026-06-21 10:13:26 -07:00
its-a-feature e2545f89b7 updated eventing and installed services tables 2026-06-21 10:13:26 -07:00
its-a-feature a6aeb0a922 more detailed color settings 2026-06-21 10:13:26 -07:00
its-a-feature e623c4fbf2 tag updates 2026-06-21 10:13:26 -07:00
its-a-feature ec345eb4eb updated terminal interactions 2026-06-21 10:13:26 -07:00
its-a-feature 8ce36830e0 remove support for unneeded browserscript result types 2026-06-21 10:13:26 -07:00
its-a-feature 0fccd5a66d updated table filtering 2026-06-21 10:13:26 -07:00
its-a-feature 99ee871383 fixing single task view 2026-06-21 10:13:26 -07:00
its-a-feature b7a5060558 dashboard upates 2026-06-21 10:13:25 -07:00
its-a-feature e89b402b7a eventing updates 2026-06-21 10:13:25 -07:00
its-a-feature f1328a58c4 c2 path and eventing wizard updates 2026-06-21 10:13:25 -07:00
its-a-feature af8fb3c79e callback table row updates 2026-06-21 10:13:25 -07:00
its-a-feature bf33e1fc3a fixed callback table 2026-06-21 10:13:25 -07:00
its-a-feature bfe3d6a732 updating operator settings and virtualized tables 2026-06-21 10:13:25 -07:00
its-a-feature 25795ab9a0 response updates 2026-06-21 10:13:25 -07:00
its-a-feature 64c8fa37ba updated browser script table page 2026-06-21 10:13:25 -07:00
its-a-feature 5c277e7d3c apitoken create updates 2026-06-21 10:13:25 -07:00
its-a-feature f1c7601280 updated single task view page 2026-06-21 10:13:25 -07:00
its-a-feature 269dc0a41f updated builder and eventing pages 2026-06-21 10:13:24 -07:00
its-a-feature db23b47074 updating some of the modal rendering conditionals 2026-06-21 10:13:24 -07:00
its-a-feature 132b0416ee major updates of the search, eventing, and payloads pages 2026-06-21 10:13:24 -07:00
its-a-feature a8729ff6f2 updating gitignore 2026-06-21 10:13:24 -07:00
its-a-feature cfb31788f2 updating datagrid 2026-06-21 10:13:24 -07:00
its-a-feature 5a20b2d86f "modernizing" of the UI components 2026-06-21 10:13:24 -07:00
its-a-feature 7d52dbebcd adding xterm for interactive tasking responses 2026-06-21 10:13:24 -07:00
its-a-feature df392eb4e0 better column dragging for virtualized tables 2026-06-21 10:13:24 -07:00
its-a-feature 57b6bd5ee8 better scrolling for interactive tasks 2026-06-21 10:13:23 -07:00
its-a-feature 3ac9761b39 better handling of pagination and streaming responses 2026-06-21 10:13:23 -07:00
its-a-feature 0927c67734 updating hasura actions to all be camelCase 2026-06-21 10:13:23 -07:00
its-a-feature c9d7392d29 updated scope tracking 2026-06-21 10:13:23 -07:00
its-a-feature c369aec44c additional scopes for apitokens and their checks 2026-06-21 10:13:23 -07:00
its-a-feature 0c8c558a05 grpc typo fix 2026-06-21 10:12:48 -07:00
its-a-feature 5c61fb9c2e prevent duplicate onStart calls for the same container 2026-06-21 10:12:48 -07:00
its-a-feature 5606d077d5 updated lock synchronization 2026-06-21 10:12:48 -07:00
its-a-feature 4e10fb6f50 logging updates and more operation scope checks added to routes 2026-06-21 10:12:48 -07:00
its-a-feature 11376d1ad2 more api validation and logging updates 2026-06-21 10:11:06 -07:00
its-a-feature ef6cfa2644 updating apitokens to be different, removing /api/v1.4 prefix, scopes 2026-06-21 10:10:30 -07:00
its-a-feature 437d5f58f9 starting auth adjustments to remove cookies 2026-06-21 10:10:30 -07:00
837 changed files with 95870 additions and 41934 deletions

No files matched your search

+2
View File
@@ -2,9 +2,11 @@ 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"
+50 -52
View File
@@ -9,7 +9,7 @@ on:
# types: [published]
push:
branches:
- 'Mythic3.3'
- 'Mythic-v4.0.0'
- 'master'
tags:
- "v*.*.*"
@@ -44,7 +44,7 @@ env:
VERSION: ${{ github.ref_name }}
# Branch for pushing release changes
RELEASE_BRANCH: master
RELEASE_BRANCH: "Mythic-v4.0.0"
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@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # 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@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # 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@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # 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@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # 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@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # 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@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # 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@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: nginx-docker
file: nginx-docker/Dockerfile
@@ -357,32 +357,30 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
with:
lfs: true
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: mythic-react-docker
file: mythic-react-docker/Dockerfile
context: .
file: mythic-react-docker/.docker/Dockerfile
tags: |
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:${{ env.VERSION }}
${{ env.REGISTRY }}/${{ env.MYTHIC_REACT_IMAGE_NAME }}:latest
@@ -413,20 +411,20 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
- name: Log in to the container registry
uses: docker/login-action@v3 # ref: https://github.com/marketplace/actions/docker-login
uses: docker/login-action@v4 # 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@v3
uses: docker/setup-qemu-action@v4
with:
platforms: 'arm64,arm'
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# 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}
@@ -437,7 +435,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@v5 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
uses: docker/build-push-action@v7 # ref: https://github.com/marketplace/actions/build-and-push-docker-images
with:
context: Mythic_CLI
file: Mythic_CLI/Dockerfile
@@ -472,7 +470,7 @@ jobs:
steps:
# Pull in the repository code
- name: Checkout the repository
uses: actions/checkout@v4 # ref: https://github.com/marketplace/actions/checkout
uses: actions/checkout@v5 # ref: https://github.com/marketplace/actions/checkout
# update names to lowercase
- name: Lowercase the server container image name
+5
View File
@@ -169,4 +169,9 @@ 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
+20
View File
@@ -0,0 +1,20 @@
*
!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:20-alpine
FROM node:24-alpine
WORKDIR /app
+1490 -47
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -26,8 +26,9 @@
"@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",
@@ -49,6 +50,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",
@@ -58,6 +60,7 @@
"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",
+25
View File
@@ -21,6 +21,31 @@
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>
+225 -61
View File
@@ -18,22 +18,74 @@ 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",
"CreatePayload", "Eventing", "Chat",
].sort();
export const operatorSettingDefaults = {
fontSize: 12,
fontSize: 13,
navBarOpen: false,
fontFamily: "Verdana, Arial, sans-serif",
fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
showMedia: true,
hideUsernames: false,
showIP: false,
showHostname: false,
showOPSECBypassUsername: false,
showCallbackGroups: false,
taskingDisplayFields: defaultTaskingDisplayFields,
useDisplayParamsForCLIHistory: true,
interactType: "interactSplit",
taskTimestampDisplayField: "timestamp",
@@ -44,119 +96,232 @@ export const operatorSettingDefaults = {
hideTaskingContext: false,
taskingContextFields: ["impersonation_context", "cwd"],
["experiment-responseStreamLimit"]: 200,
chatSelectedChannelID: 0,
sideShortcuts: defaultShortcuts,
palette: {
primary: {
dark: "#75859b",
light: "#75859b",
dark: "#7dd3fc",
light: "#2563eb",
},
error: {
dark: '#bd5142',
light: '#c42c32'
dark: '#fb7185',
light: '#dc2626'
},
success: {
dark: '#85b089',
light: '#0e7004',
dark: '#34d399',
light: '#15803d',
},
secondary: {
dark: '#bebebe',
light: '#a6a5a5'
dark: '#94a3b8',
light: '#64748b'
},
info: {
dark: '#84b4dc',
light: '#4990b2'
dark: '#22d3ee',
light: '#0891b2'
},
warning: {
dark: "#dc8455",
light: "#ffb74d",
dark: "#fbbf24",
light: "#b45309",
},
background: {
dark: '#282828',
light: '#f6f6f6'
dark: '#0b0f14',
light: '#f6f8fb'
},
paper: {
dark: '#282828',
light: '#ececec'
dark: '#111820',
light: '#ffffff'
},
surfaceRaised: {
dark: '#16212b',
light: '#ffffff'
},
surfaceMuted: {
dark: '#0f1720',
light: '#fafbfc'
},
tableHeader: {
dark: '#484848',
light: '#c4c4c4'
dark: '#17212c',
light: '#e8eef5'
},
tableHover: {
dark: "#3c3c3c",
light: "#e8e8e8",
dark: "#1e2a36",
light: "#eaf1f8",
},
pageHeader: {
dark: '#1b2025',
light: '#706c6e'
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",
},
text: {
dark: "#e4e4e4",
light: "#000000",
dark: "#e6edf3",
light: "#111827",
},
textSecondary: {
dark: "#a7b2c2",
light: "#526174",
},
textDisabled: {
dark: "#6f7c8d",
light: "#93a1b2",
},
selectedCallbackColor: {
dark: '#26456e',
light: '#c6e5f6',
dark: '#123a5a',
light: '#dbeafe',
},
selectedCallbackHierarchyColor: {
dark: '#273e5d',
light: '#deeff8',
dark: '#172d49',
light: '#edf6ff',
},
backgroundImage: {
dark: null,
light: null
},
navBarIcons: {
dark: '#ffffff',
dark: '#dce8f8',
light: '#ffffff'
},
navBarText: {
dark: '#ffffff',
dark: '#dce8f8',
light: '#ffffff'
},
navBarColor: {
dark: "#194573",
light: "#3b606d",
dark: "#1a283d",
light: "#5c73a5",
},
navBarBottomColor: {
dark: "#330814",
light: "#283581",
dark: "#0c131c",
light: "#1f2937",
},
taskPromptTextColor: {
dark: '#bebebe',
light: '#a6a5a5'
dark: '#9fb3c8',
light: '#64748b'
},
taskPromptCommandTextColor: {
dark: "#e4e4e4",
light: "#000000",
dark: "#f8fafc",
light: "#0f172a",
},
taskContextColor: {
dark: "#122848",
light: "#acc0da",
dark: "#14324f",
light: "#dbeafe",
},
taskContextImpersonationColor: {
dark: "#641616",
light: "#dec0c0",
dark: "#4a1824",
light: "#fee2e2",
},
taskContextExtraColor: {
dark: "#2a5953",
light: "#a7ce9d",
dark: "#123a35",
light: "#dcfce7",
},
emptyFolderColor: {
dark: '#bebebe',
light: '#a6a5a5'
dark: '#8997aa',
light: '#64748b'
},
folderColor: {
dark: '#f2c86b',
light: '#d6a23f'
},
outputBackgroundColor: {
dark: '#282828',
light: '#f6f6f6'
dark: '#070b10',
light: '#f8fafc'
},
outputTextColor: {
dark: '#f6f6f6',
light: '#282828',
dark: '#e6edf3',
light: '#111827',
},
borderColor: {
dark: "#595858",
light: "#c5c5c5"
dark: "#263241",
light: "#d5dee9"
}
},
}
@@ -190,7 +355,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 = {...meState().user};
let me = {...meState().user, ...(data.user || {})};
me.server_skew = difference;
me.login_time = now;
meState({
@@ -227,4 +392,3 @@ 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(props) {
export function EventFeedNotifications() {
const [fromNow, setFromNow] = React.useState(getSkewedNow().toISOString());
//const fromNow = React.useRef( );
useSubscription(subscribe_payloads, {
@@ -0,0 +1,288 @@
import React from 'react';
import {Box, Button, Checkbox, Chip, InputAdornment, TextField, Typography} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import {gql, useQuery} from "@apollo/client";
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}}>
<Chip className="mythic-api-token-scope-count" size="small" label={`${visibleScopeCount} visible`} />
<Chip className="mythic-api-token-scope-count" size="small" label={selectedScopesLabel} />
<Button disabled={scopesUnavailable || visibleScopeCount === 0 || scopeIsSelected("*")} size="small" onClick={selectVisibleScopes}>
Select Visible
</Button>
<Button disabled={normalizedSelectedScopes.length === 0} size="small" onClick={clearScopes}>
Clear
</Button>
</Box>
{requiredScopes.length > 0 &&
<Box className="mythic-api-token-scope-card mythic-api-token-scope-card-selected" sx={{mb: 1.25}}>
<Box className="mythic-api-token-scope-card-copy">
<Typography className="mythic-api-token-scope-card-title">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" sx={librarySx}>
{scopesUnavailable &&
<Box className={`mythic-api-token-scope-state${scopeLoadFailed ? " mythic-api-token-scope-state-error" : ""}`}>
<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 mythic-api-token-scope-card-full${scopeIsSelected("*") ? " mythic-api-token-scope-card-selected" : ""}${fullAccessDisabled ? " mythic-api-token-scope-card-disabled" : ""}`}
component="label"
>
<Checkbox
disabled={fullAccessDisabled}
checked={scopeIsSelected("*")}
onChange={() => toggleScope("*")}
/>
<Box className="mythic-api-token-scope-card-copy">
<Typography className="mythic-api-token-scope-card-title">Full access (*)</Typography>
<Typography className="mythic-api-token-scope-card-description">
Grants every current and future API scope available to this operator.
</Typography>
</Box>
</Box>
{visibleResources.length === 0 &&
<Box className="mythic-api-token-scope-state">
<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" key={resource}>
<Box className="mythic-api-token-resource-header">
<Box sx={{minWidth: 0}}>
<Typography className="mythic-api-token-resource-title">
{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${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">
{resourceScopes.map(scope => {
const includedByWildcard = scopeIsSelected("*") || scopeIsSelected(resourceWildcard);
const scopeSelected = scopeIsSelected(scope.name) || includedByWildcard;
return (
<Box
className={`mythic-api-token-scope-card${scopeSelected ? " mythic-api-token-scope-card-selected" : ""}${includedByWildcard ? " mythic-api-token-scope-card-inherited" : ""}`}
component="label"
key={scope.name}
>
<Checkbox
size="small"
disabled={includedByWildcard}
checked={scopeSelected}
onChange={() => toggleScope(scope.name)}
/>
<Box className="mythic-api-token-scope-card-copy">
<Box className="mythic-api-token-scope-card-title-row">
<Typography className="mythic-api-token-scope-card-title">{scope.display_name || scope.name}</Typography>
<Chip
className={`mythic-status-chip mythic-status-chip-compact mythic-tone-${scope.access === "read" ? "info" : ["write", "create", "update"].includes(scope.access) ? "success" : ["delete", "admin"].includes(scope.access) ? "error" : "neutral"}`}
label={scope.access || "scope"}
size="small"
/>
</Box>
<Typography className="mythic-api-token-scope-name">
{scope.name}
</Typography>
<Typography className="mythic-api-token-scope-card-description">
{scope.description}
</Typography>
{scope.includes?.length > 0 &&
<Typography className="mythic-api-token-scope-includes">
Includes {scope.includes.join(", ")}
</Typography>
}
</Box>
</Box>
)
})}
</Box>
</Box>
);
})}
</>
}
</Box>
</Box>
)
}
@@ -3,6 +3,7 @@ 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();
@@ -11,8 +12,8 @@ export const MythicAgentSVGIcon = ({payload_type, style, is_p2p}) => {
}
return (
<MythicStyledTooltip title={payload_type} >
<img src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"}
style={{...style}} />
<ImageWithAuth 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",
@@ -35,7 +36,8 @@ export const MythicAgentSVGIconNoTooltip = ({payload_type, is_p2p, className, st
const extraStyles = style ? style : {};
return (
<div style={{display: "inline-block", left: "50%"}} className={className}>
<img src={"/static/" + payload_type + "_" + theme.palette.mode + ".svg"} style={{height: "20px", margin: 'auto', ...extraStyles}}/>
<ImageWithAuth 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",
@@ -0,0 +1,50 @@
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)
));
@@ -0,0 +1,120 @@
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,10 +1,9 @@
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 = () => {
@@ -14,6 +13,10 @@ 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={
@@ -26,14 +29,14 @@ export function MythicConfirmDialog(props) {
</DialogContentText>
</DialogContent>
)}
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
{props.cancelText ? (props.cancelText) : ("Cancel")}
</Button>
<Button onClick={handleSubmit} autoFocus variant="contained" color={props.acceptColor ? (props.acceptColor) : ("error")}>
</MythicDialogButton>
<MythicDialogButton onClick={handleSubmit} autoFocus intent={acceptIntent}>
{props.acceptText ? (props.acceptText) : ("Remove")}
</Button>
</DialogActions>
</MythicDialogButton>
</MythicDialogFooter>
</React.Fragment>
} />
);
@@ -1,7 +1,5 @@
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';
@@ -23,8 +21,52 @@ import {IconButton} from '@mui/material';
import {MythicStyledTooltip} from "./MythicStyledTooltip";
import Draggable from 'react-draggable';
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
import {MythicDialogButton, MythicDialogFooter} from "./MythicDialogLayout";
export function MythicDialog(props) {
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++;
}
const [draggedState, setDraggedState] = React.useState({
style: {},
paperStyle: {
@@ -38,8 +80,20 @@ export function MythicDialog(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;
@@ -49,71 +103,77 @@ export function MythicDialog(props) {
}
}, [props.open]);
const dialogOnClick = (e) => {
//e.preventDefault();
//e.stopPropagation();
if(e.target.classList.length > 0 && e.target.classList.contains("MuiDialog-container")){
props.onClose();
if(e.target.classList?.contains("MuiDialog-container")){
e.stopPropagation();
if(!isTopDialog()){
return;
}
if(draggedState.hideBackdrop){
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 = (e) => {
const onStart = React.useCallback((e, _data, paperNode) => {
if(e){
e.stopPropagation();
e.preventDefault();
}
if(!draggedState.modified){
setDraggedState({
style: {
height: e.target.offsetParent.offsetHeight + "px",
width: e.target.offsetParent.offsetWidth + "px",
margin: "auto",
overflowY: "auto",
},
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: {},
paperStyle: {
//height: e.target.offsetParent.offsetHeight + "px",
width: e.target.offsetParent.offsetWidth + "px",
...(targetWidth > 0 ? {width: targetWidth + "px"} : {}),
margin: 0,
overflowY: "auto",
},
containerStyle: {
height: "fit-content",
overflowY: "auto"
overflow: "visible",
},
hideBackdrop: true,
modified: true,
})
}
}
const onStop = (e) => {
};
});
}, []);
const onStop = React.useCallback((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}
@@ -121,10 +181,13 @@ export function MythicDialog(props) {
aria-labelledby="scroll-dialog-title"
aria-describedby="scroll-dialog-description"
sx={{
[".MuiPaper-root"]: {
".MuiPaper-root": {
borderRadius: "8px",
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.borderColor}`,
...draggedState.paperStyle
},
[".MuiDialog-container"]: {
".MuiDialog-container": {
...draggedState.containerStyle
}
}}
@@ -133,10 +196,11 @@ export function MythicDialog(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);
@@ -190,162 +254,238 @@ export function MythicModifyStringDialog(props) {
/>
</DialogContent>
{ (props.onClose || props.onSubmit) &&
<DialogActions>
<MythicDialogFooter>
{props.onClose &&
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
</MythicDialogButton>
}
{props.onSubmit &&
<Button onClick={onCommitSubmit} variant="contained" color="success">
<MythicDialogButton intent="primary" onClick={onCommitSubmit}>
{props.onSubmitText ? props.onSubmitText : "Submit"}
</Button>
</MythicDialogButton>
}
</DialogActions>
</MythicDialogFooter>
}
</React.Fragment>
);
}
export function MythicViewJSONAsTableDialog(props) {
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);
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);
}
setComment(permissions);
}, [props.value, props.leftColumn, props.rightColumn]);
});
});
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 <span className={`mythic-json-type-badge mythic-json-type-${type}`}>{label}</span>
}
const JSONPrimitiveValue = ({name, value, me}) => {
const type = getJSONValueType(value);
if(type === "null" || type === "empty" || value === ""){
return <span className="mythic-json-value-empty">None</span>
}
if(type === "boolean"){
return <span className={`mythic-json-value-boolean ${value ? "mythic-json-value-true" : "mythic-json-value-false"}`}>{value ? "True" : "False"}</span>
}
return <span className="mythic-json-value-primitive">{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 ${depth === 0 ? "mythic-json-panel-root" : ""}`}>
{showPanelHeader &&
<div className="mythic-json-panel-header">
<span className="mythic-json-panel-title">{label}</span>
<JSONTypeBadge value={value} />
</div>
}
{entries.length === 0 ? (
<div className="mythic-json-empty-state">No fields to display.</div>
) : (
<TableContainer className="mythicElement mythic-json-table-wrap">
<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">
<span className="mythic-json-key">{key}</span>
<JSONTypeBadge value={entryValue} />
</div>
</TableCell>
<TableCell className="mythic-json-value-cell">
<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 ${depth === 0 ? "mythic-json-panel-root" : ""}`}>
{showPanelHeader &&
<div className="mythic-json-panel-header">
<span className="mythic-json-panel-title">{label}</span>
<JSONTypeBadge value={value} />
</div>
}
{value.length === 0 ? (
<div className="mythic-json-empty-state">No items to display.</div>
) : isObjectArray ? (
<TableContainer className="mythicElement mythic-json-table-wrap">
<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">{rowIndex + 1}</TableCell>
{objectHeaders.map((header) => (
<TableCell key={`array-row-${rowIndex}-${header}`} className="mythic-json-value-cell">
<JSONTableCellValue name={header} value={row[header]} me={me} depth={depth} />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
) : (
<TableContainer className="mythicElement mythic-json-table-wrap">
<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">{rowIndex + 1}</TableCell>
<TableCell className="mythic-json-value-cell">
<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";
return (
<React.Fragment>
<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">
<MythicDraggableDialogTitle style={{wordBreak: "break-all", maxWidth: "100%"}}>
<div className="mythic-json-title-row">
<span>{rootLabel}</span>
<JSONTypeBadge value={parsedValue} />
</div>
</MythicDraggableDialogTitle>
<DialogContent dividers={true} className="mythic-dialog-body mythic-json-dialog-body">
<JSONTableValue
value={parsedValue}
me={props.me}
leftColumn={props.leftColumn || "Name"}
rightColumn={props.rightColumn || "Value"}
/>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
</DialogActions>
</MythicDialogButton>
</MythicDialogFooter>
</React.Fragment>
);
}
@@ -389,30 +529,34 @@ export function MythicViewObjectPropertiesAsTableDialog(props) {
</TableContainer>
</Paper>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
</DialogActions>
</MythicDialogButton>
</MythicDialogFooter>
</React.Fragment>
);
}
const convertValueToContextValue = (key, value, me) => {
if( key.includes("time") ){
const keyText = String(key || "").toLowerCase();
if(value === undefined || value === null){
return "";
}
if( keyText.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( key.includes("size") ){
} else if( keyText.includes("size") ){
try{
return TableRowSizeCell({cellData: value})
}catch(error){
console.log("failed to parse metadata as size", key, value);
return value;
}
} else if (value.constructor === Object) {
} else if (isPlainObject(value)) {
return JSON.stringify(value, null, 2);
} else if (Array.isArray(value)){
return JSON.stringify(value, null, 2);
@@ -480,4 +624,4 @@ export const TableRowSizeCell = ({ cellData, rowData }) => {
}
};
return getStringSize(cellData);
};
};
@@ -0,0 +1,111 @@
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>
);
}
@@ -0,0 +1,169 @@
import React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
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", compact && "mythic-dialog-body-compact", className)}
{...props}
>
{children}
</Box>
);
}
export function MythicDialogSection({title, description, actions, children, className, ...props}) {
return (
<Box component="section" className={joinClasses("mythic-dialog-section", className)} {...props}>
{(title || description || actions) &&
<Box className="mythic-dialog-section-header">
<Box sx={{minWidth: 0}}>
{title &&
<Typography component="h3" className="mythic-dialog-section-title">
{title}
</Typography>
}
{description &&
<Typography component="div" className="mythic-dialog-section-description">
{description}
</Typography>
}
</Box>
{actions &&
<Box className="mythic-dialog-section-actions">
{actions}
</Box>
}
</Box>
}
{children}
</Box>
);
}
export function MythicDialogGrid({children, className, minWidth = "16rem", sx = {}, ...props}) {
return (
<Box
className={joinClasses("mythic-dialog-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", className)} {...props}>
{children}
</Box>
);
}
export function MythicDialogChoiceDivider({children = "OR", className, ...props}) {
return (
<Box component="span" className={joinClasses("mythic-dialog-choice-divider", 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 (
<Button
className={joinClasses("mythic-dialog-button", "mythic-action-tone", `mythic-tone-${tone}`, className)}
size="small"
variant="contained"
{...props}
>
{children}
</Button>
);
}
export function MythicForm({children, className, ...props}) {
return (
<Box component="form" className={joinClasses("mythic-form", className)} {...props}>
{children}
</Box>
);
}
export function MythicFormGrid({children, className, minWidth = "16rem", sx = {}, ...props}) {
return (
<Box
className={joinClasses("mythic-form-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", className)} {...props}>
{(label || description) &&
<Box className="mythic-form-field-copy">
{label &&
<Typography component="label" className="mythic-form-field-label">
{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">
{children}
</Box>
</Box>
);
}
export function MythicFormNote({children, className, ...props}) {
return (
<Box className={joinClasses("mythic-form-note", className)} {...props}>
{children}
</Box>
);
}
export function MythicFormSwitchRow({control, label, description, className, ...props}) {
return (
<Box className={joinClasses("mythic-form-switch-row", className)} {...props}>
<Box sx={{minWidth: 0}}>
<Typography component="div" className="mythic-form-field-label">
{label}
</Typography>
{description &&
<Typography component="div" className="mythic-form-field-description">
{description}
</Typography>
}
</Box>
<Box className="mythic-form-switch-control">
{control}
</Box>
</Box>
);
}
@@ -1,10 +1,17 @@
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%" }}>
<DialogTitle id="mythic-draggable-title" style={{
cursor: 'move',
width: "100%",
backgroundColor: theme.palette.background.paper,
color: theme.palette.text.primary,
}}>
{children}
</DialogTitle>
)
}
}
@@ -1,4 +1,6 @@
import {createPortal} from 'react-dom';
export const reorder = (
list,
startIndex,
@@ -8,4 +10,11 @@ export const reorder = (
const [removed] = result.splice(startIndex, 1);
result.splice(endIndex, 0, removed);
return result;
};
};
export const MythicDraggablePortal = ({children, isDragging}) => {
if(isDragging && typeof document !== "undefined"){
return createPortal(children, document.body);
}
return children;
};
@@ -1,19 +1,23 @@
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 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 FormControl from '@mui/material/FormControl';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import TextField from '@mui/material/TextField';
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!){
@@ -25,15 +29,15 @@ const submitFeedbackMutation = gql`
`;
export function MythicFeedbackDialog(props) {
const [message, setMessage] = React.useState("");
const [taskID, setTaskID] = React.useState(0);
const [taskID, setTaskID] = React.useState("");
const messageTypeOptions = [
{display: "Bug", value: "bug"},
{display: "Feature Request", value: "feature_request"},
{display: "Confusing UI", value: "confusing_ui"},
{display: "Detection", value: "detection"},
{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."},
];
const [messageType, setMessageType] = React.useState("bug");
const [submitFeedback] = useMutation(submitFeedbackMutation, {
const [submitFeedback, {loading}] = useMutation(submitFeedbackMutation, {
update: (cache, {data}) => {
if(data.sendExternalWebhook.status === "success"){
snackActions.success("Submitted Feedback!");
@@ -48,90 +52,111 @@ export function MythicFeedbackDialog(props) {
}
});
const handleSubmit = () => {
let webhookData = {};
if(taskID > 0){
webhookData["task_id"] = String(taskID);
const trimmedMessage = message.trim();
if(trimmedMessage.length === 0){
snackActions.warning("Please include feedback before submitting.");
return;
}
webhookData["message"] = message;
let webhookData = {};
if(Number(taskID) > 0){
webhookData["task_display_id"] = String(Number(taskID));
}
webhookData["message"] = trimmedMessage;
webhookData["feedback_type"] = messageType;
submitFeedback({variables: {webhookType: "new_feedback", webhookData: webhookData}});
}
const handleMessageTypeChange = (evt) => {
setMessageType(evt.target.value);
}
const handleTaskIDChange = (name, value, error) => {
setTaskID(value);
const handleTaskIDChange = (evt) => {
setTaskID(evt.target.value);
}
const handleMessageChange = (name, value, error) => {
setMessage(value);
const handleMessageChange = (evt) => {
setMessage(evt.target.value);
}
const handleOnEnter = (event) => {
if( event.shiftKey){
handleSubmit();
}
const onSubmitForm = (event) => {
event.preventDefault();
handleSubmit();
}
const selectedMessageType = messageTypeOptions.find((opt) => opt.value === messageType);
const canSubmit = message.trim().length > 0 && !loading;
return (
<React.Fragment>
<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>
<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>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
<Button onClick={handleSubmit} variant="contained" color="success">
Submit Feedback
</Button>
</DialogActions>
</MythicDialogButton>
<MythicDialogButton onClick={handleSubmit} intent="primary" disabled={!canSubmit}>
{loading ? "Submitting..." : "Submit Feedback"}
</MythicDialogButton>
</MythicDialogFooter>
</React.Fragment>
);
}
@@ -9,7 +9,6 @@ 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';
@@ -40,31 +39,65 @@ const StyledAutoSizer = styled(AutoSizer)((
theme
}
) => ({
[`& .${classes.rowContainer}`]: {},
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.row}`]: {
display: "flex",
alignItems: "left",
marginLeft: (props) => theme.spacing(3 * props.depth),
alignItems: "center",
height: "100%",
minWidth: "fit-content",
paddingRight: theme.spacing(1),
userSelect: "none",
whiteSpace: "nowrap"
whiteSpace: "nowrap",
width: "100%",
},
[`& .${classes.row}[data-deleted="true"]`]: {
textDecoration: "line-through",
},
[`& .${classes.rowButtonWrapper}`]: {
width: theme.spacing(3),
textAlign: "center",
"&:hover": {
cursor: "pointer",
textDecoration: "underline"
}
alignSelf: "stretch",
borderLeft: `2px dashed ${alpha(theme.palette.text.primary, 0.34)}`,
flex: "0 0 14px",
marginLeft: 7,
},
[`& .${classes.rowButton}`]: {
width: theme.spacing(3)
alignItems: "center",
display: "inline-flex",
flex: "0 0 auto",
justifyContent: "center",
width: theme.spacing(3),
},
[`& .${classes.rowLabel}`]: {
marginLeft: theme.spacing(0.5)
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,
},
[`& .${classes.heading}`]: {
@@ -102,9 +135,20 @@ const StyledAutoSizer = styled(AutoSizer)((
},
[`& .${classes.icon}`]: {
flex: "0 0 auto",
height: 16,
marginLeft: 3,
marginRight: 5,
verticalAlign: 'middle',
height: 20,
width: 20,
width: 16,
},
[`& .${classes.icon}[data-folder="true"]`]: {
color: theme.folderColor,
},
[`& .${classes.icon}[data-empty="true"]`]: {
color: theme.emptyFolderColor,
},
[`& .${classes.details}`]: {
@@ -120,7 +164,11 @@ const StyledAutoSizer = styled(AutoSizer)((
[`& .${classes.paper}`]: {
width: '100%',
marginBottom: theme.spacing(2),
height: "100%",
marginBottom: 0,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
},
[`& .${classes.table}`]: {
@@ -164,7 +212,6 @@ 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) {
@@ -192,90 +239,57 @@ const VirtualTreeRow = React.memo(({
return "";
}
return (
<div className={`hoverme ${selectedPath()}`}
<div className={`${classes.rowContainer} hoverme ${selectedPath()}`.trim()}
style={ListProps.style}
onContextMenu={handleContextClick}
onClick={handleOnClickRow}>
<div style={{display: 'flex' , marginBottom: "1px", flexGrow: 1, width: "100%"}}>
<div className={classes.row} data-deleted={itemTreeData.deleted ? "true" : undefined}>
{[...Array(itemTreeData.depth)].map((o, i) => (
<div
<span
className={classes.rowButtonWrapper}
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 style={{
width: "15px",
height: "15px",
marginLeft: "3px",
marginRight: "5px" }} />
<WidgetsIcon className={classes.icon} />
): itemTreeData.root ? (
<ComputerIcon style={{
width: "15px",
height: "15px",
marginLeft: '3px',
marginRight: '5px' }} />
<ComputerIcon className={classes.icon} />
) : !itemTreeData.can_have_children ? (
<DescriptionIcon style={{
width: "15px",
height: "15px",
marginLeft: '3px',
marginRight: '5px' }} />
<DescriptionIcon className={classes.icon} />
) : itemTreeData.isOpen ? (
<FontAwesomeIcon
icon={faFolderOpen}
style={{
width: "15px",
height: "15px",
marginLeft: '3px',
marginRight: '5px',
color: item?.has_children || item.success ? theme.folderColor : theme.emptyFolderColor,
}}
className={classes.icon}
data-folder={item?.has_children || item.success ? "true" : undefined}
data-empty={item?.has_children || item.success ? undefined : "true"}
size={"lg"}
onClick={handleOnClickButton} />
) : (
<FontAwesomeIcon
style={{
width: "15px",
height: "15px",
marginLeft: '3px',
marginRight: '5px',
color: item?.has_children || item.success ? theme.folderColor : theme.emptyFolderColor, }}
className={classes.icon}
data-folder={item?.has_children || item.success ? "true" : undefined}
data-empty={item?.has_children || item.success ? undefined : "true"}
size={"lg"}
icon={faFolder} onClick={handleOnClickButton} />
)}
<Typography
style={{
color:
item?.has_children ||
item.success !== null
? theme.palette.text.primary
: theme.palette.text.secondary,
}} component="pre">
className={classes.rowLabel}
data-muted={item?.has_children || item.success !== null ? undefined : "true"}
component="pre">
{itemTreeData.name}
</Typography>
{item.success === true && itemTreeData.depth > 0 ? (
<MythicStyledTooltip title='Successfully listed contents of folder' style={{display: "inline-flex", marginLeft: "5px"}}>
<CheckCircleOutlineIcon fontSize='small' color="success" />
<MythicStyledTooltip title='Successfully listed contents of folder'>
<CheckCircleOutlineIcon className={classes.icon} fontSize='small' color="success" />
</MythicStyledTooltip>
) : item.success === false && itemTreeData.depth > 0 ? (
<MythicStyledTooltip title='Failed to list contents of folder' style={{display: "inline-flex", marginLeft: "5px"}}>
<ErrorIcon fontSize='small' color="error" />
<MythicStyledTooltip title='Failed to list contents of folder'>
<ErrorIcon className={classes.icon} fontSize='small' color="error" />
</MythicStyledTooltip>
) : null}
</div>
</div>
</div>
);
@@ -435,33 +449,35 @@ const FileBrowserVirtualTreePreMemo = ({
}
}, [selectedFolderData, flattenedNodes]);
return flattenedNodes.length > 0 ? (
<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 className="mythic-process-browser-table-shell">
<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>
) : 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 {Link} from '@mui/material';
import {useMutation, useLazyQuery, gql} from '@apollo/client';
import {gql} from '@apollo/client';
import {b64DecodeUnicode} from "../pages/Callbacks/ResponseDisplay";
import {PreviewFileMediaDialog} from "../pages/Search/PreviewFileMedia";
import {PreviewFileMediaDialog} from "./PreviewFileMedia";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {useMythicLazyQuery} from "../utilities/useMythicLazyQuery";
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
export const getfileInformationQuery = gql`
query getFileInformation($file_id: String!){
@@ -85,9 +85,9 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
style={{height: "20px", position: "relative", cursor: "pointer", display: "inline-block"}}
onClick={onPreviewMedia} />
</MythicStyledTooltip>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + fileData.agent_file_id}>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + fileData.agent_file_id}>
{fileData.display_link === "" ? window.location.origin + "/direct/download/" + fileData.agent_file_id : fileData.display_link}
</Link>
</FileDownloadLinkWithAuth>
{openPreviewMediaDialog &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openPreviewMediaDialog}
onClose={onClose}
@@ -99,4 +99,4 @@ export const MythicFileContext = ({agent_file_id, display_link, filename, extraS
}
</>
)
}
}
@@ -1,4 +1,5 @@
import {snackActions} from '../utilities/Snackbar';
import {mythicFetch} from '../utilities/MythicConnection';
export const UploadTaskFile = async (file, comment) => {
let formData = new FormData();
@@ -11,7 +12,7 @@ export const UploadTaskFile = async (file, comment) => {
return null;
}
try{
const upload_response = await fetch('/api/v1.4/task_upload_file_webhook', {
const upload_response = await mythicFetch('/task_upload_file_webhook', {
method: 'POST',
body: formData,
headers: {
@@ -21,7 +22,11 @@ export const UploadTaskFile = async (file, comment) => {
});
try{
const upload_result = upload_response.json().then(data => {
return data?.agent_file_id || data?.error || null;
if(data?.status === "error"){
snackActions.error(data.error || "Failed to upload file");
return null;
}
return data?.agent_file_id || null;
}).catch(error => {
snackActions.warning("Error: " + upload_response.statusText + "\nError Code: " + upload_response.status);
console.log("Error trying to get json response", error.toString());
@@ -37,13 +42,53 @@ 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 fetch('/api/v1.4/eventing_import_webhook', {
const upload_response = await mythicFetch('/eventing_import_webhook', {
method: 'POST',
body: formData,
headers: {
@@ -75,7 +120,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 fetch('/api/v1.4/eventing_register_file_webhook', {
const upload_response = await mythicFetch('/eventing_register_file_webhook', {
method: 'POST',
body: formData,
headers: {
@@ -100,4 +145,4 @@ export const UploadEventGroupFile = async (file, eventgroup_id) => {
snackActions.error(error.toString());
return null;
}
}
}
@@ -18,6 +18,8 @@ export const Dropdown = React.forwardRef(
style,
transformOrigin,
anchorOrigin,
MenuListProps,
PaperProps,
},
ref
) => {
@@ -70,7 +72,11 @@ export const Dropdown = React.forwardRef(
<>
<Menu
elevation={5}
PaperProps={{ sx: { minWidth: minWidth ?? 0 } }}
PaperProps={{
...PaperProps,
sx: { minWidth: minWidth ?? 0, ...(PaperProps?.sx || {}) }
}}
MenuListProps={MenuListProps}
style={{zIndex: 100000, position: "absolute"}}
anchorEl={anchorReference === "anchorEl" ? anchorEl : undefined}
transition={"true"}
@@ -254,4 +260,4 @@ export const DropdownMenuItem = styled(MenuItem)`
export const DropdownNestedMenuItem = styled(NestedMenuItem)`
`;
`;
@@ -5,9 +5,12 @@ export const MythicPageBody = ({children}) => {
height: "100%",
display: "flex",
flexDirection: "column",
margin: "0.5rem",
padding: "0.75rem",
gap: "0.5rem",
minWidth: 0,
minHeight: 0,
}}>
{children}
</div>
)
}
}
@@ -1,32 +1,269 @@
import {useTheme} from '@mui/material/styles';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Paper from '@mui/material/Paper';
import {Typography} from "@mui/material";
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import {alpha, useTheme} from '@mui/material/styles';
export const MythicPageHeader = ({children, title, style={}, headerVariant="h5"}) => {
export const MythicPageHeader = ({
actions,
children,
className = "",
dense = false,
icon,
meta,
style = {},
subtitle,
sx = {},
title,
headerVariant = "h6",
}) => {
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 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
}}
<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,
}}
>
<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>
<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>
}
</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 (
<Chip
size="small"
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"
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,
}}
/>
);
};
@@ -13,13 +13,22 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
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
},
[data, rowIndex]
[columnIndex, data, rowIndex]
);
const handleClick = (event) => {
event.preventDefault();
@@ -29,22 +38,24 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
}
};
const selectedClass = data.items[rowIndex][columnIndex]?.props?.rowData?.selected ? "selectedCallback" : "";
const onMouseEnter = () => {
const cells = document.getElementsByClassName(rowClassName);
if(cells.length > 0){
for(const cell of cells){
cell.classList.add(classes.hoveredRow);
}
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 onMouseLeave = () => {
const cells = document.getElementsByClassName(rowClassName);
if(cells.length > 0){
for(const cell of cells){
cell.classList.remove(classes.hoveredRow);
}
}
}
}, [setHoveredRow]);
const handleMenuItemClick = (event, clickOption) => {
event.preventDefault();
event.stopPropagation();
@@ -52,10 +63,24 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
setOpenContextMenu(false);
};
React.useEffect( () => {
if(!openContextMenu){
onMouseLeave();
if(openContextMenu){
ownsContextRowRef.current = true;
setContextRow(true);
setHoveredRow(true);
} else if(ownsContextRowRef.current){
ownsContextRowRef.current = false;
setContextRow(false);
setHoveredRow(false);
}
}, [openContextMenu]);
}, [openContextMenu, setContextRow, setHoveredRow]);
React.useEffect( () => {
return () => {
if(ownsContextRowRef.current){
setContextRow(false);
setHoveredRow(false);
}
};
}, [setContextRow, setHoveredRow]);
const handleContextClick = useCallback(
(event) => {
event.preventDefault();
@@ -83,7 +108,7 @@ const CellPreMemo = ({ style, rowIndex, columnIndex, data }) => {
);
return (
<div style={{...style, ...cellStyle, ...rowStyle}}
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass}`}
className={`${classes.cell} ${rowClassName} ${rowHighlight} ${selectedClass} ${customRowClass} ${rowFirstCellClass} ${rowLastCellClass} ${rowInteractiveClass}`}
onDoubleClick={handleDoubleClick}
onClick={handleClick}
onMouseEnter={onMouseEnter}
@@ -126,6 +151,7 @@ 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}
@@ -136,11 +162,12 @@ export const ContextMenu = ({openContextMenu, dropdownAnchorRef, contextMenuOpti
disabled={option.disabled}
menu={
option.menuItems.map((menuOption, indx) => (
<DropdownMenuItem
key={menuOption.name}
disabled={menuOption.disabled}
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
>
<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)}
>
{menuOption.icon}{menuOption.name}
</DropdownMenuItem>
))
@@ -183,4 +210,4 @@ export default Cell;
</Grow>
)}
</Popper>
*/
*/
@@ -1,59 +0,0 @@
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;
@@ -0,0 +1,180 @@
import React from 'react';
import Box from '@mui/material/Box';
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 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">
<DialogTitle className="mythic-grid-filter-dialog-title">
Filter {columnName}
</DialogTitle>
<DialogContent className="mythic-grid-filter-dialog-content">
<Typography className="mythic-grid-filter-dialog-copy" 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">
<Typography className="mythic-grid-filter-dialog-label" 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">
<TextField
fullWidth
label="Include rows matching"
minRows={4}
multiline
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">
<Button onClick={handleClear}>Clear</Button>
<Button onClick={onClose}>Cancel</Button>
<Button onClick={handleSubmit} variant="contained">Apply Filter</Button>
</DialogActions>
</div>
);
}
@@ -3,8 +3,9 @@ import { useCallback } from 'react';
import useSingleAndDoubleClick from '../../utilities/useSingleAndDoubleClick';
import {classes} from './styles';
import React from 'react';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {faFilter} from '@fortawesome/free-solid-svg-icons';
import FilterAltOutlinedIcon from '@mui/icons-material/FilterAltOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import {ContextMenu} from "./Cell";
const HeaderCell = ({
@@ -14,11 +15,16 @@ 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) => {
@@ -33,44 +39,131 @@ 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(item.disableFilterMenu){
return;
}
if(contextMenuOptions && contextMenuOptions.length > 0){
if(resolvedContextMenuOptions && resolvedContextMenuOptions.length > 0){
contextMenuLocationRef.current.x = event.clientX;
contextMenuLocationRef.current.y = event.clientY;
setOpenContextMenu(true);
}
},
[contextMenuOptions, columnIndex] // eslint-disable-line react-hooks/exhaustive-deps
[resolvedContextMenuOptions]
);
const handleMenuItemClick = (event, clickOption) => {
event.preventDefault();
event.stopPropagation();
clickOption({event, columnIndex, rowIndex, data: data.items[rowIndex][columnIndex]?.props?.rowData || {}});
clickOption({event, ...menuContext});
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={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()}
<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}
</Typography>
{isFiltered && <FontAwesomeIcon icon={faFilter} />}
{sortIndicatorIndex === columnIndex && (sortDirection === 'ASC' ? <div>↑</div> : <div>↓</div>)}
<ContextMenu dropdownAnchorRef={dropdownAnchorRef} contextMenuOptions={contextMenuOptions}
<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}
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,15 +5,201 @@ 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 = 100;
const MIN_FLEX_COLUMN_WIDTH = 150;
const AUTOSIZE_HORIZONTAL_PADDING = 44;
const AUTOSIZE_HEADER_EXTRA_WIDTH = 28;
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;
};
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} />;
@@ -21,12 +207,16 @@ 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}>
<div ref={ref} style={{...style, position: "relative"}}>
{/* always render header cells */}
<div
className={classes.headerCellRow}
@@ -44,6 +234,8 @@ 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',
@@ -62,6 +254,15 @@ 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>
);
});
@@ -92,19 +293,31 @@ 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 [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 savedColumnWidths = Array.isArray(initialSavedWidths) ? initialSavedWidths : [];
const [columnWidths, setColumnWidths] = useState(() => getInitialColumnWidths({
columns,
savedWidths: savedColumnWidths,
totalWidth: AutoSizerProps.width - scrollbarWidth,
}));
const gridUUID = React.useMemo( () => GetShortRandomString(), []);
const gridRef = useRef(null);
const dragHandlesRef = 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 [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;
@@ -121,152 +334,196 @@ const ResizableGridWrapper = ({
[rowHeight, headerRowHeight]
);
useEffect(() => {
if(initialSavedWidths.length > 0 && localColumnsRef.current.length === columns.length){
if(savedColumnWidths.length === columns.length && localColumnsRef.current.length === columns.length){
return;
}
localColumnsRef.current = columns;
const totalWidth = AutoSizerProps.width - scrollbarWidth;
const updatedColumnWidths = columns.map((column, index) => {
return column.width || MIN_COLUMN_WIDTH
const updatedColumnWidths = getInitialColumnWidths({
columns,
savedWidths: [],
totalWidth: AutoSizerProps.width - scrollbarWidth,
});
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});
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths);
if(didUpdateWidths && name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current]); // eslint-disable-line react-hooks/exhaustive-deps
}, [scrollbarWidth, columns, AutoSizerProps.width, localColumnsRef.current, setColumnWidthsAndRef]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
gridRef.current.resetAfterColumnIndex(0, true);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: columnWidths});
if(gridRef.current){
gridRef.current.resetAfterColumnIndex(lastResetColumnIndexRef.current, true);
}
}, [columnWidths]);
/* Event Handlers */
const resizeColumn = useCallback( (x, columnIndex) => {
const updatedColumnWidths = columnWidths.map((columnWidth, index) => {
if (columnIndex === index) {
return Math.floor(Math.max(columnWidth + x, MIN_COLUMN_WIDTH));
}
return Math.floor(columnWidth);
});
setColumnWidths(updatedColumnWidths);
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths});
const startColumnResize = useCallback( (event, columnIndex) => {
if(columns[columnIndex]?.disableResize){
return;
}
}, [columnWidths]);
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}) => {
if(columns[columnIndex].disableDoubleClick){
const column = columns[columnIndex];
if(column?.disableDoubleClick || column?.disableAutosize){
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) => {
const currentColumnWidths = columnWidthsRef.current;
const updatedColumnWidths = currentColumnWidths.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 getAutosizedColumnWidth({column, columnIndex, headerNameKey, items});
}
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;
const didUpdateWidths = setColumnWidthsAndRef(updatedColumnWidths, columnIndex);
if(didUpdateWidths){
if(name !== undefined){
updateSetting({setting_name: `${name}_column_widths`, value: updatedColumnWidths, broadcast: false});
}
}
if(updatedValues){
setColumnWidths(() => updatedColumnWidths);
}, [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});
}
}, [columnWidths]);
}, [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]);
useEffect( () => {
if(callbackTableGridRef){
@@ -281,22 +538,19 @@ const ResizableGridWrapper = ({
"headerNameKey": headerNameKey,
"onClickHeader": onClickHeader,
"autosizeColumn": autosizeColumn,
"contextMenuOptions": contextMenuOptions,
"contextMenuOptions": headerContextMenuOptions,
"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}
@@ -311,19 +565,9 @@ 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>
</>
@@ -349,29 +593,31 @@ const MythicResizableGrid = ({
name,
}) => {
return (
<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>
<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>
);
};
@@ -381,6 +627,7 @@ MythicResizableGrid.propTypes = {
name: PropTypes.string,
width: PropTypes.number,
disableAutosize: PropTypes.bool,
disableResize: PropTypes.bool,
disableSort: PropTypes.bool
})
).isRequired,
@@ -1,12 +1,24 @@
const PREFIX = 'MythicResizableGrid';
export const classes = {
root: `${PREFIX}-root`,
grid: `${PREFIX}-grid`,
headerCellRow: `${PREFIX}-headerCellRow`,
headerCell: `${PREFIX}-headerCell`,
headerCellNoSort: `${PREFIX}-headerCellNoSort`,
headerContent: `${PREFIX}-headerContent`,
headerLabel: `${PREFIX}-headerLabel`,
headerActions: `${PREFIX}-headerActions`,
headerIndicator: `${PREFIX}-headerIndicator`,
headerFilterIcon: `${PREFIX}-headerFilterIcon`,
headerSortIcon: `${PREFIX}-headerSortIcon`,
headerResizeHandle: `${PREFIX}-headerResizeHandle`,
headerResizeHandleActive: `${PREFIX}-headerResizeHandleActive`,
resizeGuide: `${PREFIX}-resizeGuide`,
hoveredRow: `${PREFIX}-hoveredRow`,
contextRow: `${PREFIX}-contextRow`,
rowFirstCell: `${PREFIX}-rowFirstCell`,
rowLastCell: `${PREFIX}-rowLastCell`,
rowInteractive: `${PREFIX}-rowInteractive`,
cell: `${PREFIX}-cell`,
cellInner: `${PREFIX}-cellInner`,
draggableHandlesContainer: `${PREFIX}-draggableHandlesContainer`,
draggableHandlesClickArea: `${PREFIX}-draggableHandlesClickArea`,
draggableHandlesClickAreaSelected: `${PREFIX}-draggableHandlesClickAreaSelected`,
draggableHandlesIndicator: `${PREFIX}-draggableHandlesIndicator`,
}
@@ -17,12 +17,26 @@ 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:
hideUsernames
showIP
showHostname
showCallbackGroups
taskingDisplayFields
showMedia
interactType
@@ -43,7 +57,7 @@ export function useGetMythicSetting({setting_name, default_value}){
return setting;
}
export function GetMythicSetting({setting_name, default_value}){
const preferences = mePreferences();
const preferences = getPreferenceSnapshot();
return preferences?.[setting_name] === undefined ? default_value : preferences?.[setting_name];
}
export function useSetMythicSetting() {
@@ -57,27 +71,35 @@ export function useSetMythicSetting() {
}
});
return [
({setting_name, value}) => {
if(mePreferences()?.[setting_name] !== value){
({setting_name, value, broadcast=true}) => {
const currentPreferences = getPreferenceSnapshot();
if(!areSettingValuesEqual(currentPreferences?.[setting_name], value)){
const updatedPreferences = {
...mePreferences(),
...currentPreferences,
[setting_name]: value,
};
mePreferences(updatedPreferences);
latestPreferenceSnapshot = updatedPreferences;
if(broadcast){
mePreferences(updatedPreferences);
}
updateSetting({variables: {preferences: updatedPreferences}});
}
},
({settings}) => {
({settings, broadcast=true}) => {
const updatedPreferences = {
...mePreferences(),
...getPreferenceSnapshot(),
...settings,
};
mePreferences(updatedPreferences);
latestPreferenceSnapshot = updatedPreferences;
if(broadcast){
mePreferences(updatedPreferences);
}
updateSetting({variables: {preferences: updatedPreferences}});
},
() => {
latestPreferenceSnapshot = operatorSettingDefaults;
mePreferences(operatorSettingDefaults);
updateSetting({variables: {preferences: operatorSettingDefaults}});
}
]
}
}
@@ -8,13 +8,9 @@ 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 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";
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
export function MythicSelectFromListDialog(props) {
const [options, setOptions] = React.useState([]);
@@ -78,10 +74,17 @@ 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];
@@ -90,22 +93,33 @@ export function MythicSelectFromRawListDialog(props) {
return (
<React.Fragment>
<DialogTitle >{props.title}</DialogTitle>
<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>
<DialogContent dividers={true} className="mythic-raw-select-dialog-content">
{options.length === 0 ? (
<Box className="mythic-raw-select-empty">
<Typography variant="body2">No options available</Typography>
</Box>
) : (
<Stack className="mythic-raw-select-list">
{options.map( (choice, i) => (
<Box
key={String(choice) + i}
className="mythic-raw-select-row"
role="button"
tabIndex={0}
onClick={() => handleSubmit(choice)}
onKeyDown={(event) => handleKeyDown(event, choice)}
>
<Typography className="mythic-raw-select-value" title={String(choice)}>
{String(choice)}
</Typography>
<Button className="mythic-raw-select-action mythic-action-tone mythic-tone-info" variant="outlined" size="small" tabIndex={-1}>
{actionText}
</Button>
</Box>
))}
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
Close
@@ -1,7 +1,7 @@
import React from 'react';
import { styled } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import { Link } from '@mui/material';
import {FileDownloadLinkWithAuth} from "../utilities/FileDownloadWithAuth";
const PREFIX = 'MythicSnackDownload';
@@ -59,7 +59,7 @@ const Root = styled('div')((
[`& .${classes.checkIcon}`]: {
fontSize: 20,
color: '#b3b3b3',
color: theme.palette.text.secondary,
paddingRight: 4,
},
@@ -77,10 +77,10 @@ export const MythicSnackDownload = (props) => {
</Typography>
<React.Fragment>
<Typography gutterBottom>File ready for download</Typography>
<Link color="textPrimary" download={true} href={"/direct/download/" + props.file_id} target="_blank">
<FileDownloadLinkWithAuth color="textPrimary" download={true} href={"/direct/download/" + props.file_id} target="_blank">
Download here
</Link>
</FileDownloadLinkWithAuth>
</React.Fragment>
</Root>
);
};
};
@@ -0,0 +1,18 @@
import React from 'react';
import Box from '@mui/material/Box';
export function MythicStateChip({children, className = "", compact = false, label, state = "secondary", ...props}) {
const chipLabel = label ?? children;
const tone = state === "active" || state === "enabled" ? "success" :
state === "inactive" || state === "disabled" ? "warning" :
state === "neutral" ? "secondary" : state;
return (
<Box
component="span"
className={`mythic-status-chip mythic-tone-${tone}${compact ? " mythic-status-chip-compact" : ""}${className ? ` ${className}` : ""}`}
{...props}
>
{chipLabel}
</Box>
);
}
@@ -0,0 +1,192 @@
import React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
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 || (
<Button size="small" variant="outlined" onClick={onAction} sx={{mt: 0.5}}>
{actionLabel}
</Button>
)
) : 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} />
}
</>
);
}
@@ -0,0 +1,78 @@
import React from 'react';
import Chip from '@mui/material/Chip';
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';
const statusConfig = {
success: {tone: "success", icon: <CheckCircleOutlineIcon />, label: "Success"},
error: {tone: "error", icon: <ErrorOutlineIcon />, label: "Error"},
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"},
deleted: {tone: "error", icon: <DeleteOutlineIcon />, label: "Deleted"},
locked: {tone: "warning", icon: <LockOutlinedIcon />, label: "Locked"},
building: {tone: "info", 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"},
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 "warning";
}
return "info";
}
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 (
<Chip
className={`mythic-status-chip mythic-tone-${config.tone}${variant === "outlined" ? " mythic-status-chip-outlined" : ""}${className ? ` ${className}` : ""}`}
label={label || config.label}
icon={chipIcon}
size={size}
variant="outlined"
sx={sx}
{...props}
/>
);
}
@@ -9,6 +9,7 @@ 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";
@@ -22,6 +23,7 @@ export function MythicTabPanel(props) {
display: value === index ? 'flex' : 'none',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
width: '100%',
maxWidth: '100%',
overflowY: "auto",
@@ -72,6 +74,7 @@ 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();
@@ -94,8 +97,13 @@ 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} >
@@ -109,10 +117,24 @@ export function MythicTabLabel(props) {
{...a11yProps(index)}
{...other}
{...provided.dragHandleProps}
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 : ""}}
style={{
padding: "0px 6px",
margin: "0 3px 0 0",
minHeight: "32px",
borderRadius: `${theme.shape.borderRadius}px ${theme.shape.borderRadius}px 0 0`,
borderTop: `2px solid ${selected ? tabAccent : "transparent"}`,
borderRight: `1px solid ${theme.borderColor}`,
borderBottom: selected ? `1px solid ${theme.palette.background.paper}` : `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,
}}
/>
);
})()}
<Popper open={openContextMenu} anchorEl={dropdownAnchorRef.current} role={undefined} transition style={{zIndex: 40}}>
{({ TransitionProps, placement }) => (
<Grow
@@ -150,10 +172,9 @@ export function MythicSearchTabLabel(props) {
return (
<Tab
label={
<span>
{label}
<br />
<span className="mythic-search-tab-label">
{iconComponent}
<span>{label}</span>
</span>
}
{...a11yProps(index)}
@@ -1,6 +1,19 @@
import {styled} from '@mui/material/styles';
import TableCell from '@mui/material/TableCell';
const MythicStyledTableCell = styled(TableCell, {})({
padding: "2px 0 2px 10px !important",
});
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",
},
}));
export default MythicStyledTableCell;
@@ -0,0 +1,119 @@
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 ${className}`.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">{summary}</Typography>
) : totalCount !== undefined && (
<Typography className="mythic-table-total">{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}
/>
);
};
@@ -0,0 +1,129 @@
import React from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import IconButton from '@mui/material/IconButton';
import MenuItem from '@mui/material/MenuItem';
import Select from '@mui/material/Select';
import ToggleButton from '@mui/material/ToggleButton';
import Tooltip from '@mui/material/Tooltip';
import SearchIcon from '@mui/icons-material/Search';
import MythicTextField from './MythicTextField';
export const MythicTableToolbar = ({children, className = "", style = {}, variant}) => {
return (
<Box className={`mythic-table-toolbar ${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 ${grow ? "mythic-table-toolbar-group-grow" : ""} ${className}`.trim()} style={style}>
{label &&
<span className="mythic-table-toolbar-group-label">{label}</span>
}
{children}
</Box>
);
};
export const MythicToolbarSelect = ({children, className = "", style = {}, ...props}) => {
return (
<Select
className={`mythic-toolbar-select ${className}`.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}
<Tooltip title="Search">
<span>
<IconButton
className="mythic-toolbar-icon-button"
disabled={disabled}
onClick={onSearch}
size="small"
>
<SearchIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</React.Fragment>
) : endAdornment,
style: {padding: 0},
...inputProps,
}}
/>
);
};
export const MythicToolbarButton = ({children, className = "", ...props}) => {
return (
<Button className={`mythic-toolbar-toggle ${className}`.trim()} size="small" {...props}>
{children}
</Button>
);
};
export const MythicToolbarToggle = ({
checked,
onClick,
label,
activeLabel,
inactiveLabel,
activeIcon,
inactiveIcon,
value = "toggle",
className = "",
...props
}) => {
return (
<ToggleButton
className={`mythic-toolbar-toggle ${className}`.trim()}
value={value}
selected={checked}
onClick={onClick}
size="small"
{...props}
>
{checked ? activeIcon : inactiveIcon}
<span>{checked ? (activeLabel || label) : (inactiveLabel || label)}</span>
</ToggleButton>
);
};
@@ -1,16 +1,9 @@
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 { 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 { Box, Select, MenuItem, Link, IconButton } from '@mui/material';
import AceEditor from 'react-ace';
import 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/theme-monokai';
@@ -23,20 +16,40 @@ import DeleteIcon from '@mui/icons-material/Delete';
import WebhookIcon from '@mui/icons-material/Webhook';
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!, $${target_object}: Int!, $tagtype_id: Int!){
createTag(url: $url, data: $data, source: $source, ${target_object}: $${target_object}, tagtype_id: $tagtype_id){
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){
id
status
error
@@ -121,8 +134,11 @@ 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();
@@ -152,9 +168,18 @@ const TagChipDisplay = ({tag, expand}) => {
return (
<React.Fragment>
<Chip onMouseOver={onMouseOver} onMouseOut={onMouseOut} label={label} size="small" onClick={(e) => onSelectTag(e)}
style={{float: "right", backgroundColor:tag.tagtype.color, height: "15px"}}
sx={{
"& .MuiChip-label": {overflow: "visible"}
backgroundColor: color || "transparent",
color: textColor,
float: "right",
height: "15px",
"& .MuiChip-label": {
color: "inherit",
overflow: "visible"
},
"&:hover": {
backgroundColor: color || "transparent",
},
}}
/>
{openTagDisplay &&
@@ -173,7 +198,7 @@ const StringTagDataEntry = ({name, value}) => {
const colorRegex = ":color=[\"\'](?<color>.*?)[\"\']";
const onClick = (e, url) => {
e.preventDefault();
fetch(url).then((response) => {
mythicFetch(url).then((response) => {
if (response.status !== 200) {
snackActions.warning("HTTP " + response.status + " response");
} else {
@@ -220,6 +245,56 @@ 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">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">
<Box className="mythic-tag-data-preview-key">value</Box>
<Box className="mythic-tag-data-preview-value">{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">JSON object is empty.</Box>;
}
return (
<Box className="mythic-tag-data-preview-list">
{entries.map(([key, value]) => (
<Box className="mythic-tag-data-preview-row" key={key}>
<Box className="mythic-tag-data-preview-key">{key}</Box>
<Box className="mythic-tag-data-preview-value">
{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">Enter valid JSON to render a preview.</Box>;
}
}
const TagReadonlyValue = ({children}) => (
<Box className="mythic-tag-readonly-value">
{children || "None"}
</Box>
)
function ViewTagDialog(props) {
const theme = useTheme();
const [selectedTag, setSelectedTag] = React.useState({});
@@ -277,118 +352,81 @@ function ViewTagDialog(props) {
}
props.onClose(event);
}
const stopClicks = (e) => {
const stopPropagation = (e) => {
e.stopPropagation();
e.preventDefault();
}
const rawData = selectedTag?.is_json ? JSON.stringify(selectedTag?.data || {}, null, 2) : selectedTag?.data || "";
return (
<React.Fragment>
<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">
<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} 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">
<RenderedTagDataPreview data={selectedTag?.data || {}} />
</Box>
) : (
<Box className="mythic-tag-editor-frame">
<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}>
Close
</Button>
</DialogActions>
</MythicDialogButton>
</MythicDialogFooter>
</React.Fragment>
);
}
@@ -486,8 +524,15 @@ const handleTaskTypeChange = (evt) => {
setNewData(String(evt.target.value.data));
}
}
const handleNewTagCreate = ({tagtype_id, source, url, data, id}) => {
props.onClose();
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 onAcceptDelete = () => {
deleteTag({variables: {tag_id: selectedTag.id}});
@@ -502,92 +547,122 @@ return (
onClose={()=>{setOpenNewDialog(false);}}
innerDialog={<NewTagDialog me={props.me}
target_object={props.target_object}
target_object_id={props.target_object_id}
target_object_id={props.target_object_id}
target_object_display_id={props.target_object_display_id}
onClose={()=>{setOpenNewDialog(false);}}
onSubmit={handleNewTagCreate} />}
/>}
<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 />
<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"}>
<IconButton className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-success" size="small" onClick={() => setOpenNewDialog(true)}>
<AddCircleOutlineIcon fontSize="small" />
</IconButton>
</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}}>
<IconButton className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-error" size="small" onClick={()=>{setOpenDeleteDialog(true);}}>
<DeleteIcon fontSize="small" />
</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>
</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>
}
{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>
</MythicFormField>
</MythicFormGrid>
</MythicDialogSection>
<MythicDialogSection title="JSON Data">
<Box className="mythic-tag-data-split">
<Box className="mythic-tag-editor-frame">
<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">
<RenderedTagDataPreview data={newData} />
</Box>
</Box>
</MythicDialogSection>
</>
}
</MythicDialogBody>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
</MythicDialogButton>
{selectedTag.id &&
<Button onClick={onSubmit} variant="contained" color="success">
<MythicDialogButton intent="primary" onClick={onSubmit}>
Submit
</Button>
</MythicDialogButton>
}
</DialogActions>
</MythicDialogFooter>
</React.Fragment>
);
}
@@ -598,7 +673,8 @@ export function NewTagDialog(props) {
const [newData, setNewData] = React.useState("");
const [selectedTagType, setSelectedTagType] = React.useState("");
const [existingTagTypes, setExistingTagTypes] = React.useState([]);
const { loading, error } = useQuery(getTagtypesQuery, {
const [openNewTagTypeDialog, setOpenNewTagTypeDialog] = React.useState(false);
useQuery(getTagtypesQuery, {
onCompleted: data => {
setExistingTagTypes(data.tagtype);
if(data.tagtype.length > 0){
@@ -611,7 +687,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});
props.onSubmit({source:newSource, url:newURL, data:newData, tagtype_id:selectedTagType.id, id: data.createTag.id, tagtype: selectedTagType});
props.onClose()
} else {
snackActions.error(data.createTag.error);
@@ -625,11 +701,16 @@ 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,
[props.target_object]: props.target_object_id
[getCreateTagTargetObject(props.target_object)]: props.target_object_display_id || props.target_object_id
}})
}
const onChangeSource = (name, value, error) => {
@@ -644,91 +725,107 @@ 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%"}}>
<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
<MythicDialogBody>
<MythicDialogSection
title="Tag Type"
description="Select the taxonomy entry this tag should use."
actions={
<Button className="mythic-compact-action mythic-action-tone-hover mythic-tone-info" 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">
<Box className="mythic-tag-editor-frame">
<AceEditor
mode="json"
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
onChange={onEditorChange}
fontSize={14}
showGutter={true}
maxLines={20}
showPrintMargin={false}
minLines={10}
maxLines={18}
highlightActiveLine={true}
value={newData}
width={"100%"}
setOptions={{
showLineNumbers: true,
tabSize: 4,
useWorker: false
useWorker: false,
wrap: true
}}/>
</MythicStyledTableCell>
</TableRow>
</TableBody>
</Table>
</Box>
<Box className="mythic-tag-data-preview-frame">
<RenderedTagDataPreview data={newData} />
</Box>
</Box>
</MythicDialogSection>
</MythicDialogBody>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
</MythicDialogButton>
{selectedTagType !== "" &&
<Button onClick={onSubmit} variant="contained" color="success">
<MythicDialogButton intent="primary" onClick={onSubmit}>
Submit
</Button>
</MythicDialogButton>
}
</DialogActions>
</MythicDialogFooter>
</React.Fragment>
);
}
export const ViewEditTags = ({target_object, target_object_id}) => {
export const ViewEditTags = ({target_object, target_object_id, target_object_display_id}) => {
const me = useReactiveVar(meState);
const [openTagDialog, setOpenTagDialog] = React.useState(false);
const toggleTagDialog = (event, open) => {
@@ -747,9 +844,9 @@ export const ViewEditTags = ({target_object, target_object_id}) => {
{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} 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)}} />}
/>}
</React.Fragment>
)
}
}
@@ -0,0 +1,36 @@
import React from 'react';
import Chip from '@mui/material/Chip';
import {useTheme} from '@mui/material/styles';
import {getReadableTextColor, isValidHexColor} from './MythicColorInput';
export const getTagReadableTextColor = (theme, color) => {
return isValidHexColor(color) ? getReadableTextColor(color) : theme.palette.text.primary;
}
export const TagTypeChip = ({tagtype, label, sx={}, ...props}) => {
const theme = useTheme();
const color = tagtype?.color || "";
const textColor = getTagReadableTextColor(theme, color);
return (
<Chip
label={label || tagtype?.name || "Tag"}
size="small"
sx={{
backgroundColor: color || "transparent",
border: "1px solid",
borderColor: color ? "rgba(0,0,0,0.16)" : theme.table?.borderSoft || theme.borderColor,
color: textColor,
fontWeight: 800,
maxWidth: "100%",
"& .MuiChip-label": {
color: "inherit",
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
},
...sx,
}}
{...props}
/>
);
}
@@ -17,20 +17,31 @@ 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: 'grey',
borderColor: theme.borderColor,
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: 'red',
borderColor: theme.palette.error.main,
borderWidth: 2,
},
'& input:valid:focus + fieldset': {
borderLeftWidth: 6,
borderLeftWidth: 2,
padding: '4px !important', // override inline-style
},
'& textarea:focus + textarea + fieldset': {
borderLeftWidth: 6,
borderLeftWidth: 2,
//padding: '4px !important', // override inline-style
},
},
@@ -55,6 +66,7 @@ const MythicTextField = ({
disabled = false,
marginTop = "5px",
InputProps = {},
inputProps = {},
inputLabelProps = {},
multiline = false,
maxRows = 10,
@@ -93,6 +105,9 @@ const MythicTextField = ({
handleChange(event);
}
}
const resolvedAutoComplete = autoComplete === undefined ? "new-password" : (
typeof autoComplete === "string" ? autoComplete : (autoComplete ? "on" : "off")
);
return (
<Root style={{width: width ? width + "rem" : "100%", display: inline ? "inline-block": "",}}>
@@ -107,7 +122,7 @@ const MythicTextField = ({
autoFocus={autoFocus}
variant={variant}
data-lpignore={true}
autoComplete={autoComplete === undefined ? "new-password" : (autoComplete ? "on" : "off")}
autoComplete={resolvedAutoComplete}
disabled={disabled}
required={requiredValue}
InputLabelProps={inputLabelProps}
@@ -117,6 +132,7 @@ const MythicTextField = ({
type={type}
onWheel={ event => event.target.blur() }
InputProps={{...InputProps, spellCheck: false}}
inputProps={{...inputProps, autoComplete: resolvedAutoComplete}}
helperText={localError ? errorText : helperText}
style={{
padding:0,
@@ -9,8 +9,6 @@ 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';
@@ -26,8 +24,8 @@ export const StyledDivider = styled(Divider)((
}
) => ({
[`&.${classes.divider}`]: {
backgroundColor: "rgb(100, 170, 204)",
border: "2px solid rgba(100, 170, 204)"
backgroundColor: theme.palette.primary.main,
border: `2px solid ${theme.palette.primary.main}`
}
}));
export const StyledButton = styled(Button)((
@@ -61,10 +59,9 @@ const CustomListElement = ({value, onClick, itemKey}) => {
const CustomList = ({title, items, left, onClick, itemKey}) => {
return (
<>
<CardHeader title={title} />
<StyledDivider classes={{root: classes.divider}}/>
<CardContent style={{flexGrow: 1, height: "100%", width: "100%", overflowY: "auto", padding: 0}}>
<div className="mythic-transfer-list">
<div className="mythic-transfer-list-header">{title}</div>
<div className="mythic-transfer-list-body">
<List dense component="div" role="list" style={{padding:0, width: "100%"}}>
{items.map((value, index) => (
<div key={index}>
@@ -80,8 +77,8 @@ const CustomList = ({title, items, left, onClick, itemKey}) => {
))}
</List>
</CardContent>
</>
</div>
</div>
);
}
const CustomTransferList = ({leftTitle, rightTitle, initialData, parentLeftData, parentRightData, itemKey}) => {
@@ -148,7 +145,7 @@ 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 style={{display: "flex", flexDirection: "column", justifyContent: "center"}}>
<div className="mythic-transfer-controls">
<StyledButton
variant="contained"
size="small"
@@ -253,4 +250,3 @@ export function MythicTransferListDialog(props) {
</>
);
}
@@ -2,14 +2,13 @@ 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 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/theme-monokai';
import 'ace-builds/src-noconflict/theme-xcode';
import {ResponseDisplayMedia} from "../Callbacks/ResponseDisplayMedia";
import {MythicDraggableDialogTitle} from "../../MythicComponents/MythicDraggableDialogTitle";
import {ResponseDisplayMedia} from "../pages/Callbacks/ResponseDisplayMedia";
import {MythicDraggableDialogTitle} from "./MythicDraggableDialogTitle";
export function PreviewFileMediaDialog({agent_file_id, filename, onClose}) {
export function PreviewFileMediaDialog({agent_file_id, filename, onClose, editable=false}) {
const onClick = (e) => {
if(e){
e.preventDefault();
@@ -22,7 +21,7 @@ export function PreviewFileMediaDialog({agent_file_id, filename, onClose}) {
Previewing <b>{filename}</b>
</MythicDraggableDialogTitle>
<DialogContent onClick={onClick} style={{height: "calc(80vh)", margin: 0, padding: 0}}>
<ResponseDisplayMedia media={{agent_file_id, filename}} expand={true} />
<ResponseDisplayMedia media={{agent_file_id, filename, editable}} expand={true} />
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={onClose} color="primary">
@@ -4,20 +4,23 @@ 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";
const SUB_Event_Logs = gql`
subscription OperationAlertCounts{
operation_stream(cursor: {initial_value: {updated_at: "1970-01-01"}, ordering: ASC}, batch_size: 1) {
subscription OperationAlertCounts($operation_id: Int!){
operation_stream(cursor: {initial_value: {updated_at: "1970-01-01"}, ordering: ASC}, batch_size: 1, where: {id: {_eq: $operation_id}}) {
id
alert_count
}
}
`;
export function TopAppBarVerticalEventLogNotifications(props) {
export function TopAppBarVerticalEventLogNotifications() {
const theme = useTheme();
const me = React.useContext(MeContext);
const [alerts, setAlerts] = React.useState(alertCount());
const { loading, error } = useSubscription(SUB_Event_Logs, {
variables: {operation_id: me?.user?.current_operation_id},
onError: data => {
console.log("vertical event log error")
console.error(data);
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: 2, position: "absolute"}} invisible={false}>
<CircularProgress color="inherit" />
<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>
<BrowserScriptsTable
browserscripts={browserScripts} operation_id={me?.user?.current_operation_id || 0} onToggleActive={onToggleActive}
@@ -1,53 +1,130 @@
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} from "../../MythicComponents/MythicPageHeader";
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";
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"}>
<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} />
} />
<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>
<TableContainer className="mythicElement" style={{height: "100%", flexGrow: 1}}>
<Table stickyHeader={true} size="small" style={{"tableLayout": "fixed", "maxWidth": "100%", "overflow": "scroll"}}>
actions={
<MythicToolbarButton variant="contained" color="primary" 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 className="mythicElement" style={{flexGrow: 1, minHeight: 0, overflow: "auto"}}>
<Table className="mythic-browser-scripts-table" stickyHeader={true} size="small">
<TableHead>
<TableRow>
<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>
<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" />
</TableRow>
</TableHead>
<TableBody>
{props.browserscripts.map( (op) => (
{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) => (
<BrowserScriptsTableRow
me={props.me}
operation_id={props.operation_id} onToggleActive={props.onToggleActive}
@@ -63,4 +140,3 @@ export function BrowserScriptsTable(props){
)
}
@@ -1,13 +1,15 @@
import React from 'react';
import { Switch} from '@mui/material';
import TableCell from '@mui/material/TableCell';
import Box from '@mui/material/Box';
import { Switch } from '@mui/material';
import TableRow from '@mui/material/TableRow';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {EditScriptDialog} from './EditScriptDialog';
import SettingsIcon from '@mui/icons-material/Settings';
import EditIcon from '@mui/icons-material/Edit';
import IconButton from '@mui/material/IconButton';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import {MythicAgentSVGIcon} from "../../MythicComponents/MythicAgentSVGIcon";
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
import {MythicStateChip} from "../../MythicComponents/MythicStateChip";
export function BrowserScriptsTableRow(props){
const [openEdit, setOpenEdit] = React.useState(false);
@@ -22,27 +24,45 @@ export function BrowserScriptsTableRow(props){
}
return (
<React.Fragment>
<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>
<TableRow className={props.active ? "" : "mythic-browser-script-row-disabled"} key={"payload" + props.id} hover>
<MythicStyledTableCell>
<Box className="mythic-browser-script-script-cell">
<MythicStyledTooltip title={props.payloadtype.name}>
<Box className="mythic-browser-script-payload-icon">
<MythicAgentSVGIcon payload_type={props.payloadtype.name} style={{width: "32px", height: "32px"}} />
</Box>
</MythicStyledTooltip>
<Box className="mythic-browser-script-script-copy">
<Box className="mythic-browser-script-command-name">{props.command.cmd}</Box>
<Box className="mythic-browser-script-payload-name">{props.payloadtype.name}</Box>
</Box>
</Box>
</MythicStyledTableCell>
<MythicStyledTableCell>{props.author}</MythicStyledTableCell>
<MythicStyledTableCell>
<Box className="mythic-browser-script-active-cell">
<Switch
checked={props.active}
onChange={onToggleActive}
color="success"
inputProps={{ 'aria-label': 'Toggle browser script active state', "track": "white" }}
name="Active"
size="small"
/>
<MythicStateChip label={props.active ? "Active" : "Disabled"} state={props.active ? "active" : "disabled"} />
</Box>
</MythicStyledTableCell>
<MythicStyledTableCell>
<MythicStateChip label={props.user_modified ? "User modified" : "Container default"} state={props.user_modified ? "warning" : "neutral"} />
</MythicStyledTableCell>
<MythicStyledTableCell style={{textAlign: "center"}}>
<Box className="mythic-compact-actions mythic-compact-actions-nowrap mythic-browser-script-actions">
<IconButton className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-info" size="small" onClick={()=>{setOpenEdit(true);}}>
<EditIcon fontSize="small" />
</IconButton>
</Box>
</MythicStyledTableCell>
<MythicStyledTableCell className="mythic-browser-script-spacer-cell" />
{openEdit &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openEdit}
@@ -56,4 +76,3 @@ export function BrowserScriptsTableRow(props){
</React.Fragment>
)
}
@@ -1,8 +1,10 @@
import React, {useEffect, useRef} from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Collapse from '@mui/material/Collapse';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import AceEditor from 'react-ace';
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/theme-github";
@@ -14,11 +16,21 @@ 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 Input from '@mui/material/Input';
import Tooltip from '@mui/material/Tooltip';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import Split from 'react-split';
import {TaskDisplay} from "../Callbacks/TaskDisplay";
import {taskingDataFragment} from '../Callbacks/CallbackMutations'
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicFormField,
MythicFormGrid,
MythicFormNote
} from "../../MythicComponents/MythicDialogLayout";
import {MythicEmptyState, MythicLoadingState} from "../../MythicComponents/MythicStateDisplay";
const getCommandsAndPayloadTypesQuery = gql`
@@ -42,6 +54,38 @@ 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("");
@@ -49,12 +93,19 @@ 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('');
useQuery(getCommandsAndPayloadTypesQuery, {
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, {
onCompleted: data => {
setPayloadTypeCmdOptions(data.payloadtype);
if(props.payload_type_id !== undefined){
@@ -66,11 +117,8 @@ export function EditScriptDialog(props) {
}
if(props.command_id !== undefined){
setSelectedCommand(props.command_id);
for(let i = 0; i < data.payloadtype.length; i++){
if(props.payload_type_id === data.payloadtype[i].id){
setCommandOptions(data.payloadtype[i].commands)
}
}
const payloadType = data.payloadtype.find((payloadType) => payloadType.id === props.payload_type_id);
setCommandOptions(payloadType?.commands || []);
}else{
if(data.payloadtype.length > 0){
if(data.payloadtype[0].commands.length > 0){
@@ -84,11 +132,13 @@ export function EditScriptDialog(props) {
}
});
const [getAvailableTasks] = useLazyQuery(getExistingTasksForCommand, {
const [getAvailableTasks, {loading: tasksLoading}] = useLazyQuery(getExistingTasksForCommand, {
onCompleted: data => {
setAvailableTasks(data.task);
if(data.task.length > 0){
setSelectedTask(data.task[0]);
}else{
setSelectedTask('');
}
},
onError: data => {
@@ -102,9 +152,12 @@ export function EditScriptDialog(props) {
useEffect( () => {
if(selectedCommand !== ""){
getAvailableTasks({variables: {command_id: selectedCommand}})
}else{
setAvailableTasks([]);
setSelectedTask('');
}
}, [selectedCommand]);
}, [selectedCommand, getAvailableTasks]);
useEffect( () => {
if(props.script !== undefined){
try{
@@ -137,9 +190,12 @@ export function EditScriptDialog(props) {
}
const onChangeSelectedPayloadType = (event) => {
setSelectedPayloadType(event.target.value);
const cmds = payloadTypeCmdOptions.filter( (p) => p.id === event.target.value);
setCommandOptions(cmds[0].commands);
setSelectedCommand(cmds[0].commands[0].id);
const selectedPayload = payloadTypeCmdOptions.find( (p) => p.id === event.target.value);
const cmds = selectedPayload?.commands || [];
setCommandOptions(cmds);
setSelectedCommand(cmds[0]?.id || '');
setAvailableTasks([]);
setSelectedTask('');
}
const onChangeTask = (event) => {
setSelectedTask(event.target.value);
@@ -148,34 +204,48 @@ export function EditScriptDialog(props) {
setSelectedCommand(event.target.value);
}
const onLoad = (editor) => {
// Your editor options comes here
editor.on('change', (arg, activeEditor) => {
editorRef.current = activeEditor;
editor.removeEventListener('change');
editorRef.current.resize();
});
editorRef.current = editor;
requestAnimationFrame(() => editor.resize());
}
const onOutputLoad = (editor) => {
// Your editor options comes here
editor.on('change', (arg, activeEditor) => {
outputRef.current = activeEditor;
editor.removeEventListener('change');
outputRef.current.resize();
});
outputRef.current = editor;
requestAnimationFrame(() => editor.resize());
}
React.useEffect( () => {
var logBackup = console.log;
console.log = function(msg) {
logStreamRef.current += "\n" + msg;
logBackup.apply(msg);
setLogOutput(logStreamRef.current)
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;
};
}, []);
const onDragging = () => {
React.useEffect(() => {
requestAnimationFrame(() => {
if(editorRef.current){
editorRef.current.resize();
}
if(outputRef.current){
outputRef.current.resize();
}
});
}, [selectedTask, tasksLoading]);
const resizeEditors = () => {
if(editorRef.current){
editorRef.current.resize();
}
@@ -183,128 +253,227 @@ 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 >
{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%"}}/>}
<DialogTitle>
<Box className="mythic-dialog-title-row">
<Box component="span">{props.title ? props.title : "Edit " + props.author + "'s BrowserScript Code"}</Box>
</Box>
</DialogTitle>
<DialogContent className="mythic-browser-script-dialog-content" dividers={true}>
<MythicDialogBody className="mythic-browser-script-dialog-body">
<Box className={`mythic-browser-script-target-panel ${targetOpen ? "mythic-browser-script-target-panel-open" : ""}`}>
<Box className="mythic-browser-script-target-summary">
<Box className="mythic-browser-script-target-copy">
<Box component="span" className="mythic-browser-script-target-label">Script Target</Box>
<Box className="mythic-browser-script-target-chips">
<Chip size="small" label={selectedPayloadTypeOption?.name || (targetLoading ? "Loading payload types" : "No payload type")} />
<Chip size="small" label={selectedCommandOption?.cmd || "No command"} />
</Box>
</Box>
<Tooltip title={targetOpen ? "Collapse target settings" : "Edit target settings"}>
<IconButton
aria-expanded={targetOpen}
aria-label={targetOpen ? "Collapse target settings" : "Edit target settings"}
className="mythic-browser-script-target-toggle"
onClick={onToggleTargetOpen}
size="small"
>
{targetOpen ? <KeyboardArrowUpIcon fontSize="small" /> : <KeyboardArrowDownIcon fontSize="small" />}
</IconButton>
</Tooltip>
</Box>
<Collapse
in={targetOpen}
onEntered={resizeEditors}
onExited={resizeEditors}
timeout="auto"
unmountOnExit
>
{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%"}}/>}
<Box className="mythic-browser-script-target-details">
<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"
direction="vertical"
gutterSize={8}
minSize={[210, 300]}
onDrag={resizeEditors}
onDragEnd={onWorkbenchDragEnd}
sizes={workbenchSplitSizes}
>
<Split
className="mythic-browser-script-top-split"
gutterSize={8}
minSize={[320, 220]}
onDrag={resizeEditors}
onDragEnd={onTopDragEnd}
sizes={topSplitSizes}
>
{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 className="mythic-browser-script-pane mythic-browser-script-editor-pane">
<div className="mythic-browser-script-pane-header">
<span>Script Code</span>
<span>JavaScript</span>
</div>
<div className="mythic-browser-script-editor-frame">
<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>
</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 className="mythic-browser-script-pane mythic-browser-script-console-pane">
<div className="mythic-browser-script-pane-header">
<span>Console Output</span>
<span>console.log</span>
</div>
<div className="mythic-browser-script-editor-frame">
<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>
</Split>
<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 className="mythic-browser-script-pane mythic-browser-script-preview-pane">
<div className="mythic-browser-script-pane-header">
<span>Test Preview</span>
<span>{availableTasks.length === 1 ? "1 task" : `${availableTasks.length} tasks`}</span>
</div>
<div className="mythic-browser-script-preview-controls">
<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">
{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>
</Split>
</div>
</MythicDialogBody>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
</MythicDialogButton>
{props.new ? (
<Button onClick={onSubmit} variant="contained" color="success">
<MythicDialogButton disabled={selectedCommand === "" || selectedPayloadType === ""} intent="primary" onClick={onSubmit}>
Create
</Button>
</MythicDialogButton>
) : (
<React.Fragment>
<Button onClick={onRevert} variant="contained" color="warning">
<MythicDialogButton intent="warning" onClick={onRevert}>
Revert
</Button>
<Button onClick={onTest} variant="contained" color="info">
</MythicDialogButton>
<MythicDialogButton intent="info" disabled={selectedCommand === "" || selectedPayloadType === ""} onClick={onTest}>
Save For Testing
</Button>
<Button onClick={onSubmit} variant="contained" color="success">
</MythicDialogButton>
<MythicDialogButton disabled={selectedCommand === "" || selectedPayloadType === ""} intent="primary" onClick={onSubmit}>
Save and Exit
</Button>
</MythicDialogButton>
</React.Fragment>
)}
</DialogActions>
</MythicDialogFooter>
</React.Fragment>
);
}
@@ -1,6 +1,4 @@
import React, {useEffect} from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import React from 'react';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import List from '@mui/material/List';
@@ -8,11 +6,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 CardHeader from '@mui/material/CardHeader';
import {gql, useQuery} from '@apollo/client';
import { CardContent } from '@mui/material';
import {classes, StyledButton, StyledDivider} from '../../MythicComponents/MythicTransferList';
import {MythicDraggableDialogTitle} from "../../MythicComponents/MythicDraggableDialogTitle";
import {classes, StyledButton} from '../../MythicComponents/MythicTransferList';
import {
MythicDialogButton,
MythicDialogBody,
MythicDialogFooter,
MythicDialogSection
} from '../../MythicComponents/MythicDialogLayout';
const getCommandsQuery = gql`
query GetCallbackDetails($callback_id: Int!) {
@@ -68,15 +69,14 @@ const CustomList = ({title, items, handleToggle}) => {
))
}, [items, handleToggle]);
return (
<>
<CardHeader className={classes.cardHeader} title={title}/>
<StyledDivider classes={{root: classes.divider}}/>
<CardContent style={{flexGrow: 1, overflowY: "auto", padding: 0}}>
<div className="mythic-transfer-list">
<div className="mythic-transfer-list-header">{title}</div>
<div className="mythic-transfer-list-body">
<List dense component="div" role="list" style={{padding: 0, width: "100%"}}>
{renderedList}
</List>
</CardContent>
</>
</div>
</div>
)
};
export function AddRemoveCallbackCommandsDialog(props) {
@@ -187,67 +187,71 @@ 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"}}>
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>
<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">
<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>
</MythicDialogSection>
</MythicDialogBody>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
<Button onClick={setFinalTags} variant="contained" color="success">
</MythicDialogButton>
<MythicDialogButton intent="primary" onClick={setFinalTags}>
Submit
</Button>
</DialogActions>
</MythicDialogButton>
</MythicDialogFooter>
</React.Fragment>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,465 @@
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 || "")};
};
@@ -0,0 +1,141 @@
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!){
callbackgraphedge_remove(edge_id: $edge_id) {
callbackgraphedgeRemove(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!){
callbackgraphedge_add(c2profile: $c2profile, destination_id: $destination_id, source_id: $source_id) {
callbackgraphedgeAdd(c2profile: $c2profile, destination_id: $destination_id, source_id: $source_id) {
status
error
}
@@ -97,6 +97,7 @@ export const taskingDataFragment = gql`
parent_task_id
agent_task_id
tasking_location
apitokens_id
callback {
display_id
id
@@ -164,6 +165,7 @@ export const taskingDataFragment = gql`
id
comment
agent_task_id
apitokens_id
commentOperator{
username
}
@@ -192,6 +194,9 @@ export const taskingDataFragment = gql`
cmd
supported_ui_features
id
payloadtype {
name
}
}
command_name
response_count
@@ -223,8 +228,8 @@ export const taskingDataFragment = gql`
}
`;
export const createTaskingMutation = gql`
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) {
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) {
status
id
error
@@ -232,8 +237,8 @@ mutation createTasking($callback_id: Int, $callback_ids: [Int], $command: String
}
`;
export const exportCallbackConfigQuery = gql`
query exportCallbackConfigQuery($agent_callback_id: String!) {
exportCallbackConfig(agent_callback_id: $agent_callback_id) {
query exportCallbackConfigQuery($callback_display_id: Int!) {
exportCallbackConfig(callback_display_id: $callback_display_id) {
status
error
config
@@ -0,0 +1,110 @@
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);
}
};
},
};
};
@@ -0,0 +1,45 @@
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);
});
});
@@ -11,6 +11,7 @@ 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}
@@ -32,6 +33,7 @@ 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({});
@@ -215,9 +217,22 @@ 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"}}>
<Paper elevation={5} style={{width: "30px", display: "flex", flexDirection: "column", alignItems: "center", overflow: "hidden",
backgroundColor: "transparent"}}>
<div style={{display: "flex", flexDirection: "row-reverse", height: "100%", minWidth: 0, overflow: "hidden"}}>
<Paper elevation={0} style={{
width: "34px",
flex: "0 0 34px",
display: "flex",
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",
}}>
{topDisplay !== 'table' &&
<MythicStyledTooltip title={"Table View"}>
<IconButton onClick={() =>setTopDisplay("table")}>
@@ -270,4 +285,4 @@ export function Callbacks({me}) {
</Split>
</>
);
}
}
@@ -1,10 +1,7 @@
import React, {useEffect, useState, useMemo, useContext} from 'react';
import {DrawC2PathElementsFlowWithProvider} from './C2PathDialog';
import {Button} from '@mui/material';
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';
@@ -19,10 +16,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, OnOpenTabContext} from './CallbacksTop';
import {Dropdown, DropdownMenuItem} from "../../MythicComponents/MythicNestedMenus";
import {CallbackGraphEdgesContext, CallbacksContext} from './CallbacksTop';
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {operatorSettingDefaults} from "../../../cache";
import {useTaskReferenceSubmitter} from './taskingReferences';
const ITEM_HEIGHT = 48;
const ITEM_PADDING_TOP = 1;
@@ -60,12 +57,10 @@ 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(["display_id", "user"]);
const [selectedGroupBy, setSelectedGroupBy] = React.useState("None");
const [selectedComponentOptions, setSelectedComponentOptions] = React.useState(viewConfig?.label_components || ["display_id", "user"]);
const [selectedGroupBy, setSelectedGroupBy] = React.useState(viewConfig?.group_by || "None");
const groupByOptions = ["host", "user", "ip", "domain", "os", "process_name", "None"];
const handleChange = (event) => {
const {
@@ -85,120 +80,104 @@ const GraphViewOptions = ({viewConfig, setViewConfig}) => {
useEffect( () => {
setViewConfig({...viewConfig, group_by: selectedGroupBy});
}, [selectedGroupBy])
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);
};
const toggleViewConfig = (field) => {
setViewConfig({...viewConfig, [field]: !viewConfig[field]});
}
const toggleRankDirection = () => {
setViewConfig({...viewConfig, rankDir: viewConfig["rankDir"] === "LR" ? "TB" : "LR"});
}
return (
<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>
<div className="mythic-callback-graph-options">
<Button
size="small"
className="mythic-callback-graph-options-toggle"
onClick={() => setShowConfiguration(!showConfiguration)}
>
{showConfiguration ? "Hide Graph Options" : "Graph Options"}
</Button>
{showConfiguration &&
<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}
<div className="mythic-callback-graph-options-panel">
<div className="mythic-callback-graph-options-actions">
<Button
size="small"
className={`mythic-callback-graph-option-button${!viewConfig["include_disconnected"] ? " mythic-action-tone mythic-tone-success" : ""}`}
onClick={() => toggleViewConfig("include_disconnected")}
>
{viewConfig["include_disconnected"] ? "All Edges" : "Active Edges"}
</Button>
<Button
size="small"
className={`mythic-callback-graph-option-button${viewConfig["show_all_nodes"] ? " mythic-action-tone mythic-tone-warning" : ""}`}
onClick={() => toggleViewConfig("show_all_nodes")}
>
{viewConfig["show_all_nodes"] ? "All Callbacks" : "Connected Callbacks"}
</Button>
<Button
size="small"
className="mythic-callback-graph-option-button"
onClick={toggleRankDirection}
>
{viewConfig["rankDir"] === "LR" ? "Left to Right" : "Top to Bottom"}
</Button>
<Button
size="small"
className="mythic-callback-graph-option-button"
onClick={() => toggleViewConfig("packet_flow_view")}
>
{viewConfig["packet_flow_view"] ? "Egress Routes" : "Connection Directions"}
</Button>
</div>
<div className="mythic-callback-graph-options-fields">
<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" />}
>
{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)}
{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(", ")}
>
{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>
))
{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">
Showing all callbacks can be slow in large operations.
</div>
}
/>
</ClickAwayListener>
</div>
}
</div>
)
}
@@ -249,17 +228,18 @@ export function CallbacksGraph({onOpenTab}){
}
});
const {submitTask, dialog: taskReferenceSubmitDialog} = useTaskReferenceSubmitter(createTask);
const submitParametersDialog = (cmd, parameters, files) => {
setOpenParametersDialog(false);
createTask({variables: {callback_id: selectedCallback.id, command: cmd, params: parameters, files}});
submitTask({variables: {callback_display_id: selectedCallback.display_id, command: cmd, params: parameters, files}});
}
const [viewConfig, setViewConfig] = React.useState({
rankDir: "TB",
label_components: ["display_id", "user"],
packet_flow_view: true,
include_disconnected: true,
include_disconnected: false,
show_all_nodes: false,
group_by: "None"
group_by: "host"
});
const [hideCallback] = useMutation(hideCallbackMutation, {
update: (cache, {data}) => {
@@ -314,19 +294,20 @@ export function CallbacksGraph({onOpenTab}){
}
const contextMenu = useMemo(() => {return [
{
title: 'Hide Callback',
onClick: function(node) {
hideCallback({variables: {callback_display_id: node.display_id}});
}
},
{
title: 'Interact',
title: 'Open Tasking',
onClick: function(node){
onOpenTab({tabType: "interact", tabID: node.callback_id + "interact", callbackID: node.callback_id});
}
},
{
title: "Manually Remove Edge",
title: "Add P2P Edge",
onClick: function(node){
setAddEdgeSource(node);
setManuallyAddEdgeDialogOpen(true);
}
},
{
title: "Remove Active Edge",
onClick: function(node){
const opts = callbackgraphedges.reduce( (prev, e) => {
if(e.source.id === node.id || e.destination.id === node.id){
@@ -348,15 +329,8 @@ export function CallbacksGraph({onOpenTab}){
setManuallyRemoveEdgeDialogOpen(true);
}
},
{
title: "Manually Add Edge",
onClick: function(node){
setAddEdgeSource(node);
setManuallyAddEdgeDialogOpen(true);
}
},
{
title: "Task Callback for Edge",
title: "Task Link Command",
onClick: function(node){
setLinkCommands([]);
setSelectedLinkCommand(null);
@@ -365,7 +339,13 @@ export function CallbacksGraph({onOpenTab}){
setSelectedCallback(node);
}
},
]}, [getLinkCommands, hideCallback, viewConfig, onOpenTab]);
{
title: 'Hide Callback',
onClick: function(node) {
hideCallback({variables: {callback_display_id: node.display_id}});
}
},
]}, [getLinkCommands, hideCallback, callbackgraphedges, onOpenTab]);
React.useEffect( () => {
try {
const storageItemOptions = GetMythicSetting({setting_name: "callbacks_table_filter_options", default_value: operatorSettingDefaults.callbacks_table_filters});
@@ -392,6 +372,7 @@ export function CallbacksGraph({onOpenTab}){
}
return (
<div style={{maxWidth: "100%", "overflow": "hidden", height: "100%"}}>
{taskReferenceSubmitDialog}
{manuallyRemoveEdgeDialogOpen &&
<MythicDialog fullWidth={true} maxWidth="sm" open={manuallyRemoveEdgeDialogOpen}
@@ -413,7 +394,7 @@ export function CallbacksGraph({onOpenTab}){
{openParametersDialog &&
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
onClose={()=>{setOpenParametersDialog(false);}}
innerDialog={<TaskParametersDialog command={selectedLinkCommand} callback={selectedCallback} onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}} />}
innerDialog={<TaskParametersDialog command={selectedLinkCommand} callback={selectedCallback} callback_display_id={selectedCallback?.display_id} onSubmit={submitParametersDialog} onClose={()=>{setOpenParametersDialog(false);}} />}
/>
}
{openSelectLinkCommandDialog &&
@@ -430,4 +411,3 @@ export function CallbacksGraph({onOpenTab}){
</div>
);
}
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useContext} from 'react';
import {MythicDialog, MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import {MythicDialog} from '../../MythicComponents/MythicDialog';
import {
exportCallbackConfigQuery,
hideCallbackMutation, lockCallbackMutation, unlockCallbackMutation, updateCallbackTriggerMutation,
@@ -23,7 +23,6 @@ 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";
@@ -34,6 +33,7 @@ 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,16 +53,24 @@ 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
@@ -114,7 +122,7 @@ export const ipCompare = (a, b) => {
}
}
const callbackTableInitialColumns = [
{key: "id", type: 'number', name: "Interact", width: 150, disableDoubleClick: true},
{key: "id", type: 'number', name: "Interact", width: 175, 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},
@@ -132,12 +140,20 @@ 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);
@@ -155,6 +171,7 @@ 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"){
@@ -170,6 +187,16 @@ 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,
@@ -196,8 +223,26 @@ function CallbacksTablePreMemo(props){
}
}
const updateDescriptionSubmit = React.useCallback( ({callback_display_id, description, color}) => {
updateDescriptionMutation({variables: {callback_display_id: 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]);
const [openCallbackDropdown, setOpenCallbackDropdown] = React.useState(false);
const callbackDropdownRef = React.useRef({options: [], callback: {}});
const [clickedCallbackID, setClickedCallbackId] = React.useState(0);
@@ -219,6 +264,7 @@ 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({});
@@ -312,20 +358,7 @@ function CallbacksTablePreMemo(props){
fetchPolicy: "no-cache",
onCompleted: (data) => {
if(data.exportCallbackConfig.status === "success"){
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();
}
downloadFileFromMemory(data.exportCallbackConfig.config, "exported_callback.json", "text/plain");
}else{
snackActions.error("Failed to export configuration: " + data.exportCallbackConfig.error);
}
@@ -341,27 +374,71 @@ function CallbacksTablePreMemo(props){
const eventingDataRef = React.useRef({});
const [openEventingDialog, setOpenEventingDialog] = React.useState(false);
const [openEventingMultipleDialog, setOpenEventingMultipleDialog] = React.useState(false);
const [updateSetting] = useSetMythicSetting();
const [updateSetting, updateSettings] = 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 mythic-tone-${intent}`}>
{icon}
</span>
);
const callbackMenuLabel = (primary, secondary) => (
<span className="mythic-callback-action-menu-label">
<span className="mythic-callback-action-menu-label-primary">{primary}</span>
{secondary &&
<span className="mythic-callback-action-menu-label-secondary">{secondary}</span>
}
</span>
);
const callbackMenuSection = (label) => ({
name: <span className="mythic-callback-action-menu-section-label">{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 style={{paddingRight: "5px"}}/>;
let defaultInteractIcon = <KeyboardIcon fontSize="small"/>;
if(interactType === "interactSplit"){
defaultInteractIcon = <VerticalSplitIcon style={{paddingRight: "5px"}}/>;
defaultInteractIcon = <VerticalSplitIcon fontSize="small"/>;
} else if(interactType === "interactConsole"){
defaultInteractIcon = <TerminalIcon style={{paddingRight: "5px"}}/>;
defaultInteractIcon = <TerminalIcon fontSize="small"/>;
}
let customBrowsers = await getCustomBrowsersForContextMenu();
customBrowsers = customBrowsers.map(b => {
return {
name: b.name,
icon: b.type === 'file' ? <FontAwesomeIcon icon={faFolderOpen} style={{color: theme.folderColor, cursor: "pointer", marginRight: "10px"}} /> : <AccountTreeIcon style={{paddingRight: "5px"}}/>,
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",
click: ({event}) => {
event.preventDefault();
event.stopPropagation();
@@ -379,20 +456,15 @@ function CallbacksTablePreMemo(props){
})
return [
{
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>,
name: callbackMenuLabel("Callback " + rowDataStatic.display_id, rowDataStatic.user + "@" + rowDataStatic.host),
icon: null, click: ({event}) => {},
type: "item",
disabled: true
disabled: true,
className: "mythic-callback-action-menu-header"
},
callbackMenuSection("Tasking"),
{
name: "Interact", icon: defaultInteractIcon, click: ({event}) => {
name: "Interact", icon: callbackMenuIcon(defaultInteractIcon, "primary"), className: "mythic-callback-action-menu-item", click: ({event}) => {
event.stopPropagation();
const tabType = interactType;
onOpenTab({
@@ -404,7 +476,55 @@ function CallbacksTablePreMemo(props){
}, type: "item"
},
{
name: "Edit Description and Color", icon: <EditIcon style={{paddingRight: "5px"}} />, click:({event}) => {
name: "Tasking Views", icon: callbackMenuIcon(<VerticalSplitIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item",
menuItems: [
{
name: 'Default Tasking', icon: callbackMenuIcon(<KeyboardIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", 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", 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", 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", 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", click:({event}) => {
event.stopPropagation();
updateDescription({payload_description: rowDataStatic.payload.description,
callback_display_id: rowDataStatic.display_id,
@@ -414,13 +534,36 @@ function CallbacksTablePreMemo(props){
}, type: "item"
},
{
name: 'Hide Callback', icon: <VisibilityOffIcon color={"warning"} style={{paddingRight: "5px"}}/>, click: ({event}) => {
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",
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",
click: ({event}) => {
event.stopPropagation();
onOpenTriggerDialog(rowDataStatic);
}
},
{
name: 'Hide Callback', icon: callbackMenuIcon(<VisibilityOffIcon fontSize="small"/>, "warning"), className: "mythic-callback-action-menu-item", click: ({event}) => {
event.stopPropagation();
hideCallback({variables: {callback_display_id: rowDataStatic.display_id}});
}, type: "item"
},
{
name: "Exit Callback", icon: <FontAwesomeIcon icon={faSkullCrossbones} style={{color: theme.palette.error.main, cursor: "pointer", marginRight: "8px"}} />,
name: "Exit Callback", icon: callbackMenuIcon(<FontAwesomeIcon icon={faSkullCrossbones} />, "error"), className: "mythic-callback-action-menu-item",
click: ({event}) => {
taskingData.current = {
"parameters": "",
@@ -433,36 +576,16 @@ function CallbacksTablePreMemo(props){
setOpenTaskingButton(true);
}, type: "item"
},
callbackMenuSection("Workflows and Browsers"),
{
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",
name: "Eventing", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small" />), click: (event) => {}, type: "menu", className: "mythic-callback-action-menu-item",
menuItems: [
{
name: "Start Eventing Workflow", icon: <PlayCircleFilledTwoToneIcon style={{paddingRight: "5px"}} />,
name: "Start Eventing Workflow", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small" />), className: "mythic-callback-action-menu-item",
click: ({event}) => {
eventingDataRef.current = {
name: "callback_id",
value: rowDataStatic.id
name: "callback_display_id",
value: rowDataStatic.display_id
};
setOpenEventingDialog(true);
}
@@ -470,10 +593,10 @@ function CallbacksTablePreMemo(props){
]
},
{
name: "Browsers", icon: null, click: () => {}, type: "menu",
name: "Browsers", icon: callbackMenuIcon(<AccountTreeIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item",
menuItems: [
{
name: 'File Browser', icon: <FontAwesomeIcon icon={faFolderOpen} style={{color: theme.folderColor, cursor: "pointer", marginRight: "10px"}} />,
name: 'File Browser', icon: callbackMenuIcon(<FontAwesomeIcon icon={faFolderOpen} />), className: "mythic-callback-action-menu-item",
click: ({event}) => {
event.preventDefault();
event.stopPropagation();
@@ -487,7 +610,7 @@ function CallbacksTablePreMemo(props){
}
},
{
name: 'Process Browser', icon: <AccountTreeIcon style={{paddingRight: "5px"}}/>,
name: 'Process Browser', icon: callbackMenuIcon(<AccountTreeIcon fontSize="small"/>), className: "mythic-callback-action-menu-item",
click: ({event}) => {
event.preventDefault();
event.stopPropagation();
@@ -501,105 +624,63 @@ function CallbacksTablePreMemo(props){
}
},
{
name: <div style={{display: "flex", flexDirection: "column", width: "100%"}}>
<Typography>
{"Custom Agent Browsers:"}
</Typography>
</div>,
name: <span className="mythic-callback-action-menu-section-label">Custom Agent Browsers</span>,
icon: null, click: ({event}) => {},
type: "item",
disabled: true
disabled: true,
className: "mythic-callback-action-menu-section"
},
...customBrowsers
]
},
{
name: "Bulk Actions", icon: null, click: (event) => { }, type: "menu",
name: "Other Callbacks", icon: callbackMenuIcon(<OpenInNewIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item",
menuItems: [
{
name: "Hide Multiple", icon: <VisibilityOffIcon color={"warning"} style={{paddingRight: "5px"}}/>,
click: ({event}) => {
setOpenHideMultipleDialog(true);
name: "Interact", icon: callbackMenuIcon(<KeyboardIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interact"});
}
},
{
name: "Task Multiple", icon: <FontAwesomeIcon icon={faList} style={{cursor: "pointer", marginRight: "10px"}} />,
click: ({event}) => {
setOpenTaskMultipleDialog({open: true, data: rowDataStatic});
name: "Split Tasking", icon: callbackMenuIcon(<VerticalSplitIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interactSplit"});
}
},
{
name: "Start Multiple Eventing Workflows", icon: <PlayCircleFilledTwoToneIcon style={{paddingRight: "5px"}}/>,
click: ({event}) => {
setOpenEventingMultipleDialog(true);
name: "Console View", icon: callbackMenuIcon(<TerminalIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interactConsole"});
}
},
{
name: "File Browser", icon: callbackMenuIcon(<FontAwesomeIcon icon={faFolderOpen} />), className: "mythic-callback-action-menu-item", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "fileBrowser"});
}
},
{
name: "Process Browser", icon: callbackMenuIcon(<AccountTreeIcon fontSize="small"/>), className: "mythic-callback-action-menu-item", click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "processBrowser"});
}
}
]
},
callbackMenuSection("Metadata"),
{
name: "Tasking Views", icon: null, click: () => {}, type: "menu",
name: "Metadata", icon: callbackMenuIcon(<InfoIcon fontSize="small" />), click: () => {}, type: "menu", className: "mythic-callback-action-menu-item",
menuItems: [
{
name: 'Default Tasking', icon: <KeyboardIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
name: "Export Callback", icon: callbackMenuIcon(<ImportExportIcon fontSize="small" />), className: "mythic-callback-action-menu-item", click: ({event}) => {
event.stopPropagation();
const tabType = "interact";
onOpenTab({
tabType: tabType,
tabID: rowDataStatic.id + tabType,
callbackID: rowDataStatic.id,
color: rowDataStatic.color,
displayID: rowDataStatic.display_id});
exportConfig({variables: {callback_display_id: rowDataStatic.display_id}});
}
},
{
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}) => {
name: "View Metadata", icon: callbackMenuIcon(<InfoIcon fontSize="small" />), className: "mythic-callback-action-menu-item", click: ({event}) => {
event.stopPropagation();
metaDialog(rowDataStatic.id);
}
},
{
name: "Modify Groupings", icon: <WidgetsIcon color={"info"} style={{paddingRight: "5px"}} />, click: ({event}) => {
name: "Modify Groupings", icon: callbackMenuIcon(<WidgetsIcon fontSize="small" />), className: "mythic-callback-action-menu-item", click: ({event}) => {
event.stopPropagation();
editMythicTreeGroupsDialog(rowDataStatic.id);
}
@@ -607,31 +688,24 @@ function CallbacksTablePreMemo(props){
]
},
{
name: "Other Callbacks", icon: null, click: () => {}, type: "menu",
name: "Bulk Actions", icon: callbackMenuIcon(<FontAwesomeIcon icon={faList} />), click: (event) => { }, type: "menu", className: "mythic-callback-action-menu-item",
menuItems: [
{
name: "Interact", icon: <KeyboardIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interact"});
name: "Hide Multiple", icon: callbackMenuIcon(<VisibilityOffIcon fontSize="small"/>, "warning"), className: "mythic-callback-action-menu-item",
click: ({event}) => {
setOpenHideMultipleDialog(true);
}
},
{
name: "Split Tasking", icon: <VerticalSplitIcon style={{paddingRight: "5px"}}/>, click: ({event}) => {
setOpenMultipleTabsDialog({open: true, tabType: "interactSplit"});
name: "Task Multiple", icon: callbackMenuIcon(<FontAwesomeIcon icon={faList} />), className: "mythic-callback-action-menu-item",
click: ({event}) => {
setOpenTaskMultipleDialog({open: true, data: rowDataStatic});
}
},
{
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"});
name: "Start Multiple Eventing Workflows", icon: callbackMenuIcon(<PlayCircleFilledTwoToneIcon fontSize="small"/>), className: "mythic-callback-action-menu-item",
click: ({event}) => {
setOpenEventingMultipleDialog(true);
}
}
]
@@ -694,7 +768,7 @@ function CallbacksTablePreMemo(props){
() =>
columnOrder.reduce( (prev, cur) => {
if(columnVisibility.visible.includes(cur.name) || cur.name === "Interact"){
if(filterOptions[cur.key] && String(filterOptions[cur.key]).length > 0){
if(isGridColumnFilterActive(filterOptions[cur.key])){
return [...prev, {...cur, filtered: true}];
}else{
return [...prev, {...cur}];
@@ -765,21 +839,27 @@ function CallbacksTablePreMemo(props){
}, [])
const filterRow = (row) => {
for(const [key,value] of Object.entries(filterOptions)){
if(key === "agent"){
if(!String(row.payload.payloadtype.name).toLowerCase().includes(String(value).toLowerCase())){
return true;
}
if(key === "agent") {
if (!gridValuePassesFilter(row.payload.payloadtype.name, value)) {
return true;
}
}else if(key === "id"){
if (!gridValuePassesFilter(row.display_id, value)) {
return true;
}
}else{
if(!String(row[key]).toLowerCase().includes(String(value).toLowerCase())){
if(!gridValuePassesFilter(row[key], value)){
return true;
}
}
}
return false;
}
const sortedData = React.useMemo(() => {
const tempData = [...callbacks];
const tempData = callbacks.map((callback) => {
const localUpdate = callbackLocalUpdates[String(callback.display_id)];
return localUpdate ? {...callback, ...localUpdate} : callback;
});
if (sortData.sortType === 'number' || sortData.sortType === 'size' || sortData.sortType === 'date') {
tempData.sort((a, b) => (parseInt(a[sortData.sortKey]) > parseInt(b[sortData.sortKey]) ? 1 : -1));
@@ -823,10 +903,12 @@ function CallbacksTablePreMemo(props){
return <CallbacksTableIDCell
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}}
key={`callback${row.id}_${c.name}`}
callbackDropdown={callbackDropdown}
onOpenTriggerDialog={onOpenTriggerDialog}
onOpenUnlockDialog={onOpenUnlockDialog}
/>;
case "Groups":
return <CallbacksTableStringCell
@@ -834,7 +916,7 @@ function CallbacksTablePreMemo(props){
cellData={row.mythictree_groups.join(", ")}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}}
/>;
case "IP":
@@ -843,109 +925,110 @@ function CallbacksTablePreMemo(props){
cellData={row.ip}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}}
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: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />;
case "Host":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.host}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />;
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: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />;
case "Domain":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.domain}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />;
case "OS":
return <CallbacksTableOSCell key={`callback${row.id}_${c.name}`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}}
cellData={row.os} />;
case "Arch":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}}
cellData={row.architecture} />;
case "PID":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.pid}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />;
case "Last Checkin":
return <CallbacksTableLastCheckinCell key={`callback${row.id}_${c.name}`} me={props.me}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}}
cellData={row.last_checkin} />;
case "Description":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`} cellData={row.description}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />;
case "Sleep":
return <CallbacksTableSleepCell key={`callback${row.id}_${c.name}`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}}
cellData={row.sleep_info} updateSleepInfo={updateSleepInfo} />;
case "Agent":
return <CallbacksTablePayloadTypeCell key={`callback${row.id}_${c.name}`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}}
cellData={row.payload.payloadtype.name}/>;
case "C2":
return <CallbacksTableC2Cell key={`callback${row.id}_c2`}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />
case "Process Name":
return <CallbacksTableStringCell key={`callback${row.id}_${c.name}`}
cellData={row.process_short_name}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />;
case "Tags":
return <CallbacksTableTagsCell key={`callback${row.id}_${c.name}`}
cellData={row.tags}
rowData={{...row,
selected: row.id === clickedCallbackID,
rowStyle: {backgroundColor: `${row.color}`},
rowStyle: getCallbackRowStyle(row),
}} />;
}
})];
}
}, [])
}, [callbacks, sortData, filterOptions, columnVisibility, clickedCallbackID]);
}, [callbacks, callbackLocalUpdates, sortData, filterOptions, columnVisibility, clickedCallbackID, onOpenTriggerDialog, onOpenUnlockDialog]);
const onSubmitFilterOptions = (value) => {
setFilterOptions({...filterOptions, [selectedColumn.key]: value });
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
try{
updateSetting({setting_name: "callbacks_table_filter_options", value: {...filterOptions, [selectedColumn.key]: value }});
updateSetting({setting_name: "callbacks_table_filter_options", value: nextFilterOptions, broadcast: false});
}catch(error){
console.log("failed to save filter options");
}
@@ -989,10 +1072,15 @@ 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});
updateSetting({setting_name: "callbacks_table_columns", value: newVisible});
updateSettings({
settings: {
callbacks_table_column_order: newOrder.map(c => c.name),
callbacks_table_columns: newVisible,
},
broadcast: false,
});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -1000,8 +1088,8 @@ function CallbacksTablePreMemo(props){
}
const onSubmitSelectMultiple = (callbacks) => {
eventingDataRef.current = {
trigger_context_type: "callback_id",
trigger_context_ids: callbacks.map(c => c.id)
trigger_context_type: "callback_display_id",
trigger_context_ids: callbacks.map(c => c.display_id)
};
setOpenEventingDialog(true);
}
@@ -1014,7 +1102,7 @@ function CallbacksTablePreMemo(props){
sortIndicatorIndex={sortColumn}
sortDirection={sortData.sortDirection}
items={sortedData}
rowHeight={GetComputedFontSize() + 7}
rowHeight={GetComputedFontSize() + 15}
onClickHeader={onClickHeader}
onDoubleClickRow={onRowDoubleClick}
contextMenuOptions={contextMenuOptions}
@@ -1023,10 +1111,10 @@ function CallbacksTablePreMemo(props){
{openContextMenu &&
<MythicDialog fullWidth={true} maxWidth="sm" open={openContextMenu}
onClose={()=>{setOpenContextMenu(false);}}
innerDialog={<MythicModifyStringDialog
title='Filter Column'
innerDialog={<GridColumnFilterDialog
onSubmit={onSubmitFilterOptions}
value={filterOptions[selectedColumn.key]}
filterValue={filterOptions[selectedColumn.key]}
selectedColumn={selectedColumn}
onClose={() => {
setOpenContextMenu(false);
}}
@@ -1087,11 +1175,13 @@ 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={option.name}
key={"callback-action-" + index}
className={option.className}
disabled={option.disabled}
onClick={(event) => handleMenuItemClick(event, option.click)}
>
@@ -1099,12 +1189,15 @@ function CallbacksTablePreMemo(props){
</DropdownMenuItem>
) : option.type === 'menu' ? (
<DropdownNestedMenuItem
label={option.name}
key={"callback-action-" + index}
className={option.className}
label={<span className="mythic-callback-action-menu-nested-label">{option.icon}{option.name}</span>}
disabled={option.disabled}
menu={
option.menuItems.map((menuOption, indx) => (
<DropdownMenuItem
key={menuOption.name}
key={"callback-action-" + index + "-" + indx}
className={menuOption.className}
disabled={menuOption.disabled}
onClick={(event) => handleMenuItemClick(event, menuOption.click)}
>
@@ -1144,6 +1237,17 @@ 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);}}
@@ -1213,4 +1317,4 @@ function CallbacksTablePreMemo(props){
</div>
)
}
export const CallbacksTable = React.memo(CallbacksTablePreMemo);
export const CallbacksTable = React.memo(CallbacksTablePreMemo);
@@ -1,24 +1,25 @@
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 IconButton from '@mui/material/IconButton';
import { useTheme } from '@mui/material/styles';
import {reorder} from "../../MythicComponents/MythicDraggableList";
import {MythicDraggablePortal, 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;
@@ -26,13 +27,12 @@ export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onCl
setItems(newItems);
};
React.useEffect( () => {
const newItems = items.map( c => {
setItems((currentItems) => currentItems.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,21 +49,28 @@ export function CallbacksTableColumnsReorderDialog({initialItems, onSubmit, onCl
return (
<React.Fragment>
<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} />
<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>
</DialogContent>
<DialogActions>
<Button onClick={onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={onClose}>
Close
</Button>
<Button onClick={onReset} variant="contained" color="warning">
</MythicDialogButton>
<MythicDialogButton onClick={onReset} intent="warning">
Reset
</Button>
<Button onClick={onFinish} variant="contained" color="success">
Submit
</Button>
</DialogActions>
</MythicDialogButton>
<MythicDialogButton onClick={onFinish} intent="primary">
Save
</MythicDialogButton>
</MythicDialogFooter>
</React.Fragment>
);
}
@@ -73,46 +80,55 @@ export const DraggableList = ({ items, onDragEnd, onToggleVisibility }) => {
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="callback-table-column-list">
{(provided) => (
<List style={{border: 0, padding: 0}} ref={provided.innerRef} {...provided.droppableProps}>
<div className="mythic-reorder-list" ref={provided.innerRef} {...provided.droppableProps}>
{items.map((item, index) => (
<DraggableListItem onToggleVisibility={onToggleVisibility} item={item} index={index} key={item.key} />
))}
{provided.placeholder}
</List>
</div>
)}
</Droppable>
</DragDropContext>
);
};
export const DraggableListItem = ({ item, index, onToggleVisibility }) => {
const theme = useTheme();
return (
<Draggable draggableId={item.key} index={index}>
{(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>
)}
{(provided, snapshot) => {
const row = (
<div
ref={provided.innerRef}
className={`mythic-reorder-row${snapshot.isDragging ? " mythic-reorder-row-dragging" : ""}${item.visible ? "" : " mythic-reorder-row-disabled"}`}
{...provided.draggableProps}
>
<span className="mythic-reorder-drag-handle" {...provided.dragHandleProps}>
<DragHandleIcon fontSize="small" />
</span>
<div className="mythic-reorder-row-main">
<span className="mythic-reorder-row-title">{item.name}</span>
</div>
<div className="mythic-reorder-row-actions">
<IconButton
aria-label={item.visible ? `Hide ${item.name}` : `Show ${item.name}`}
className={`mythic-compact-icon-action ${item.visible ? "mythic-action-tone-hover mythic-tone-error" : "mythic-action-tone-hover mythic-tone-info"}`}
size="small"
onClick={() => onToggleVisibility(index)}
>
{item.visible ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</div>
</div>
);
return (
<MythicDraggablePortal isDragging={snapshot.isDragging}>
{row}
</MythicDraggablePortal>
);
}}
</Draggable>
);
};
@@ -10,15 +10,17 @@ 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 = () => {
if(color === "ffffff" || color === "000000"){
const normalizedColor = color?.toLowerCase() || "";
if(normalizedColor === "" || normalizedColor === "ffffff" || normalizedColor === "#ffffff" ||
normalizedColor === "000000" || normalizedColor === "#000000"){
props.onSubmit(comment, "");
} else {
props.onSubmit(comment, color);
@@ -39,6 +41,11 @@ 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>
@@ -61,14 +68,62 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
showInvisibles: false,
}}
/>
<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 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}
/>
<Button onClick={() => setColor("")} color="success" variant="outlined" size="small">
Clear
</Button>
</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>
</Box>
</DialogContent>
<DialogActions>
@@ -83,4 +138,4 @@ export function CallbacksTableEditDescriptionColorDialog(props) {
</DialogActions>
</React.Fragment>
);
}
}
@@ -1,18 +1,17 @@
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);
@@ -31,29 +30,73 @@ export function CallbacksTableEditTriggerOnCheckinDialog(props) {
}
return (
<React.Fragment>
<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} />
<DialogTitle id="form-dialog-title">Callback check-in alert</DialogTitle>
<DialogContent dividers={true}>
<MythicDialogBody>
<div className="mythic-callback-trigger-summary">
<span className={`mythic-callback-trigger-summary-icon ${comment > 0 ? "mythic-callback-trigger-summary-icon-active" : ""}`}>
<NotificationsActiveTwoToneIcon fontSize="small" />
</span>
<div className="mythic-callback-trigger-summary-copy">
<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">
<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">
<div className="mythic-callback-trigger-rule">
Trigger name: <strong>callback_checkin</strong>
</div>
<div className="mythic-callback-trigger-rule">
Matching workflow filters still apply, including payload type and supported OS restrictions.
</div>
<div className="mythic-callback-trigger-rule">
If no matching workflow exists, no workflow will run.
</div>
</div>
</MythicDialogSection>
</MythicDialogBody>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
</MythicDialogButton>
{props.onSubmit &&
<Button onClick={onSubmit} variant="contained" color="success">
Submit
</Button>
<MythicDialogButton onClick={onSubmit} intent="primary">
Save Threshold
</MythicDialogButton>
}
</DialogActions>
</MythicDialogFooter>
</React.Fragment>
);
}
}
@@ -15,9 +15,8 @@ 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, faSkullCrossbones, faRobot} from '@fortawesome/free-solid-svg-icons';
import {faQuestion, 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';
@@ -30,11 +29,11 @@ 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 WarningAmberIcon from '@mui/icons-material/WarningAmber';
import {faSocks} from '@fortawesome/free-solid-svg-icons';
import {TagsDisplay, ViewEditTags} from "../../MythicComponents/MythicTag";
export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown}) =>{
const theme = useTheme();
export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown, onOpenTriggerDialog, onOpenUnlockDialog}) =>{
const dropdownAnchorRef = React.useRef(null);
const onOpenTab = useContext(OnOpenTabContext);
const interactType = GetMythicSetting({setting_name: "interactType", default_value: "interact"})
@@ -101,44 +100,73 @@ export const CallbacksTableIDCell = React.memo(({rowData, callbackDropdown}) =>{
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 style={{paddingRight: "5px"}}/>;
let defaultInteractIcon = <KeyboardIcon fontSize="small" />;
if(interactType === "interactSplit"){
defaultInteractIcon = <VerticalSplitIcon style={{paddingRight: "5px"}}/>;
defaultInteractIcon = <VerticalSplitIcon fontSize="small" />;
} else if(interactType === "interactConsole"){
defaultInteractIcon = <TerminalIcon style={{paddingRight: "5px"}}/>;
defaultInteractIcon = <TerminalIcon fontSize="small" />;
}
const highIntegrity = rowDataStatic.integrity_level >= 3;
const lockOwner = rowDataStatic.locked_operator?.username || "unknown operator";
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}
<div id={`callbacksTableID${rowDataStatic.id}`} className="mythic-callback-interactCell">
<MythicStyledTooltip title={`Open ${interactType === "interactSplit" ? "split" : interactType === "interactConsole" ? "console" : "tasking"} view${highIntegrity ? " - high integrity callback" : ""}`}>
<IconButton
className={`mythic-callback-iconButton mythic-callback-interactButton${highIntegrity ? " mythic-action-tone mythic-tone-error" : ""}`}
onClick={(evt) => {evt.stopPropagation();localOnOpenTab()}}
>
{defaultInteractIcon}
</IconButton>
</MythicStyledTooltip>
<span className="mythic-callback-displayId">{rowDataStatic.display_id}</span>
<IconButton
style={{margin: 0, padding: 0}}
color={rowDataStatic.integrity_level > 2 ? "error" : ""}
className={`mythic-callback-iconButton mythic-callback-menuButton${highIntegrity ? " mythic-icon-tone mythic-action-tone-hover mythic-tone-error" : ""}`}
aria-haspopup="menu"
onClick={handleDropdownToggle}
ref={dropdownAnchorRef}
>
<ArrowDropDownIcon/>
<ArrowDropDownIcon fontSize="small" />
</IconButton>
{rowDataStatic.locked &&
<MythicStyledTooltip title={`Locked by ${lockOwner}`}>
<button type="button"
className="mythic-callback-statusBadge mythic-callback-statusBadgeButton mythic-action-tone mythic-tone-warning"
aria-label={`Unlock callback ${rowDataStatic.display_id}`}
onClick={handleOpenUnlockDialog}>
<LockIcon fontSize="inherit" />
</button>
</MythicStyledTooltip>
}
{rowDataStatic.trigger_on_checkin_after_time > 0 &&
<MythicStyledTooltip title={`Alert on callback after no checkin for ${rowDataStatic.trigger_on_checkin_after_time} minutes`}>
<NotificationsActiveTwoToneIcon color={"success"}/>
<button type="button"
className="mythic-callback-statusBadge mythic-callback-statusBadgeButton mythic-action-tone mythic-tone-warning"
aria-label={`Adjust alert trigger for callback ${rowDataStatic.display_id}`}
onClick={handleOpenTriggerDialog}>
<NotificationsActiveTwoToneIcon fontSize="inherit" />
</button>
</MythicStyledTooltip>
}
{rowDataStatic.callbackports.length > 0 &&
<MythicStyledTooltip title={proxyMessage()}>
<FontAwesomeIcon icon={faSocks} size="lg" style={{color: theme.palette.success.main}}/>
<span className="mythic-callback-statusBadge mythic-action-tone-hover mythic-tone-info">
<FontAwesomeIcon icon={faSocks} />
</span>
</MythicStyledTooltip>
}
</div>
</div>
)
},
areEqual)
@@ -157,32 +185,34 @@ export const CallbacksTableLastCheckinCell = React.memo( ({rowData, cellData, me
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 (
<>
{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"}
</>
<div className="mythic-callback-cellInline">
{rowData.dead &&
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
<span className="mythic-callback-statusBadge mythic-action-tone-hover mythic-tone-warning">
<WarningAmberIcon fontSize="inherit" />
</span>
</MythicStyledTooltip>
}
<span className="mythic-callback-cellText">Streaming Now</span>
</div>
)
}
return (
<div style={{display: "flex", alignItems: "center"}}>
<div className="mythic-callback-cellInline">
{rowData.dead &&
<MythicStyledTooltip title={"Based on callback's last checkin and sleep info, it's likely dead"}>
<FontAwesomeIcon icon={faSkullCrossbones} style={{
color: theme.palette.error.main, cursor: "pointer", marginRight: "10px",}} />
<span className="mythic-callback-statusBadge mythic-action-tone-hover mythic-tone-warning">
<WarningAmberIcon fontSize="inherit" />
</span>
</MythicStyledTooltip>
}
<span className="mythic-callback-cellText">
<Moment filter={adjustOutput} interval={1000} parse={"YYYY-MM-DDTHH:mm:ss.SSSSSSZ"}
withTitle
titleFormat={"YYYY-MM-DD HH:mm:ss"}
@@ -190,6 +220,7 @@ export const CallbacksTableLastCheckinCell = React.memo( ({rowData, cellData, me
>
{rowData.last_checkin + "Z"}
</Moment>
</span>
</div>
)
@@ -220,7 +251,8 @@ 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 = () => {
const onClick = (event) => {
event.stopPropagation();
setOpenPickIP(true);
}
const editIPSubmit = (selected_ip) => {
@@ -240,13 +272,18 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
}, [cellData]);
return (
<>
<div style={{display: "inline-flex", alignItems: "center", height: "100%"}}>
<div className="mythic-callback-cellInline">
{options.length > 1 &&
<MythicStyledTooltip title={"Adjust Displayed"}>
<UnfoldMoreIcon onClick={onClick} style={{paddingTop: "5px", cursor: "pointer", width: "unset"}} />
<IconButton
className="mythic-callback-iconButton mythic-callback-cellIconButton"
onClick={onClick}
>
<UnfoldMoreIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
}
{displayIP}
<span className="mythic-callback-cellText">{displayIP}</span>
</div>
{openPickIP &&
<MythicDialog fullWidth={true} open={openPickIP} onClose={() => {setOpenPickIP(false);}}
@@ -263,13 +300,10 @@ export const CallbacksTableIPCell = React.memo(({cellData, rowData}) => {
)
}, areEqual)
export const CallbacksTableC2Cell = React.memo(({rowData}) => {
const theme = useTheme();
export const CallbacksTableC2Cell = React.memo(({rowData, centered = true}) => {
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([]);
@@ -277,19 +311,6 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
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
@@ -342,21 +363,6 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
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,12 +371,31 @@ export const CallbacksTableC2Cell = React.memo(({rowData}) => {
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 c2ButtonClass = `mythic-callback-iconButton mythic-callback-cellIconButton ${c2RouteActive ? "mythic-action-tone-hover mythic-tone-success" : "mythic-action-tone mythic-tone-error"}`;
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>
{hasEgressRoute ?
<WifiIcon onClick={onOpenC2Dialog} style={{color: activeEgress, cursor: "pointer"}}/> :
<InsertLinkTwoToneIcon onClick={onOpenC2Dialog} style={{color: activeEgress, cursor: "pointer"}} />
}
<div className={`mythic-callback-cellInline${centered ? " mythic-callback-cellInlineCenter" : ""}`}>
<MythicStyledTooltip title={c2Tooltip}>
<IconButton className={c2ButtonClass} onClick={onOpenC2Dialog}>
{hasDirectEgressRoute ?
<WifiIcon fontSize="small" /> :
<InsertLinkTwoToneIcon fontSize="small" />
}
</IconButton>
</MythicStyledTooltip>
{openC2Dialog &&
<MythicDialog
fullWidth={true}
@@ -397,34 +422,42 @@ 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} style={{cursor: "pointer"}} onClick={displayOSInfo} />
return <FontAwesomeIcon icon={faRobot} />
}
switch(rowData.payload.os.toLowerCase()){
case "windows":
return <FontAwesomeIcon icon={faWindows} style={{cursor: "pointer"}} onClick={displayOSInfo} />
return <FontAwesomeIcon icon={faWindows} />
case "linux":
case "centos":
case "redhat":
case "debian":
case "fedora":
case "freebsd":
return <FontAwesomeIcon icon={faLinux} style={{cursor: "pointer"}} onClick={displayOSInfo} />
return <FontAwesomeIcon icon={faLinux} />
case "macos":
return <FontAwesomeIcon icon={faApple} style={{cursor: "pointer"}} onClick={displayOSInfo}/>
return <FontAwesomeIcon icon={faApple} />
case "chrome":
return <FontAwesomeIcon icon={faChrome} style={{cursor: "pointer"}} onClick={displayOSInfo} />
return <FontAwesomeIcon icon={faChrome} />
case "android":
return <FontAwesomeIcon icon={faAndroid} style={{cursor: "pointer"}} onClick={displayOSInfo} />
return <FontAwesomeIcon icon={faAndroid} />
default:
return <FontAwesomeIcon icon={faQuestion} style={{cursor: "pointer"}} onClick={displayOSInfo} />
return <FontAwesomeIcon icon={faQuestion} />
}
}, [rowData?.payload?.os]);
const displayOSInfo = React.useCallback( () => {
const displayOSInfo = React.useCallback( (event) => {
event.stopPropagation();
setOpenOSDialog(true);
}, []);
return (
<div>
{getOSIcon()}
<div className="mythic-callback-cellInline mythic-callback-cellInlineCenter">
<MythicStyledTooltip title={"View operating system information"}>
<IconButton
className="mythic-callback-iconButton mythic-callback-cellIconButton mythic-action-tone-hover mythic-tone-primary"
onClick={displayOSInfo}
>
{getOSIcon()}
</IconButton>
</MythicStyledTooltip>
{ openOSDialog &&
<MythicDisplayTextDialog
onClose={()=>{setOpenOSDialog(false);}}
@@ -440,8 +473,7 @@ export const CallbacksTableOSCell = React.memo( ({rowData, cellData}) => {
)
}, areEqual);
export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSleepInfo}) => {
const theme = useTheme();
export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSleepInfo, centered = true}) => {
const [openSleepDialog, setOpenSleepDialog] = React.useState(false);
const editSleepSubmit = (sleep) => {
updateSleepInfo({sleep_info: sleep, callback_display_id: rowData.display_id});
@@ -454,10 +486,15 @@ export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSl
return null
}
return (
<div style={{height: "100%", display: "flex", alignItems: "center"}}>
<SnoozeIcon onClick={onOpenSleepDialog}
style={{color: cellData === "" ? theme.palette.warning.main : theme.palette.info.main, cursor: "pointer"}}
/>
<div className={`mythic-callback-cellInline${centered ? " mythic-callback-cellInlineCenter" : ""}`}>
<MythicStyledTooltip title={cellData === "" ? "No sleep information set" : "View or edit sleep information"}>
<IconButton
className={`mythic-callback-iconButton mythic-callback-cellIconButton ${cellData === "" ? "mythic-action-tone mythic-tone-warning" : "mythic-action-tone-hover mythic-tone-info"}`}
onClick={onOpenSleepDialog}
>
<SnoozeIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
{ openSleepDialog &&
<MythicDialog fullWidth={true} open={openSleepDialog} maxWidth={"md"}
style={{height: "100%"}}
@@ -478,9 +515,14 @@ export const CallbacksTableSleepCell = React.memo( ({rowData, cellData, updateSl
}, areEqual)
export const CallbacksTableTagsCell = React.memo(({rowData, cellData}) => {
return (
<div>
<ViewEditTags target_object={"callback_id"} target_object_id={rowData.id} />
<TagsDisplay tags={cellData} expand={false} />
<div className="mythic-tag-cell mythic-tag-cell-fill">
<ViewEditTags target_object={"callback_id"} target_object_id={rowData.id} target_object_display_id={rowData.display_id} />
<div className="mythic-tag-list mythic-callback-tag-list">
{cellData?.length > 0 ?
<TagsDisplay tags={cellData} expand={false} /> :
<span className="mythic-callback-tagsEmpty">No tags</span>
}
</div>
</div>
)
}, areEqual)
}, areEqual)
@@ -14,6 +14,7 @@ import {
CallbacksTabsCustomFileBasedBrowserLabel,
CallbacksTabsCustomFileBasedBrowserPanel
} from "./CallbacksTabsCustomFileBasedBrowser";
import {CallbackBrowserTreeProvider} from "./CallbackBrowserTreeStore";
export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clickedTabId, setClickedTabId, onEditTabDescription, contextMenuOptions, me, newDataForTab, setNewDataForTab}) {
@@ -74,8 +75,9 @@ 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"} style={{backgroundColor: "transparent"}} >
<AppBar color='default' position={"static"} className="no-box-shadow mythic-table-toolbar">
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="callback-tabs-list" direction={"horizontal"}>
{(provided) => (
@@ -88,7 +90,6 @@ 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) => {
@@ -319,5 +320,6 @@ export function CallbacksTabs({ onCloseTab, openTabs, onDragTab, onDragEnd, clic
}
})}
</div>
</CallbackBrowserTreeProvider>
);
}
@@ -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 {useGetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {GetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
import {loadedCommandsQuery} from "./CallbacksTabsProcessBrowser";
import {getSkewedNow} from "../../utilities/Time";
@@ -32,9 +32,10 @@ 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 fileDataFragment = gql`
fragment fileObjData on mythictree {
const fileBrowserDataFragment = gql`
fragment fileBrowserObjData on mythictree {
comment
deleted
task_id
@@ -65,45 +66,45 @@ const fileDataFragment = gql`
}
`;
const rootFileQuery = gql`
${fileDataFragment}
${fileBrowserDataFragment}
query myRootCustomFolderQuery($tree_type: String!) {
mythictree(where: { parent_path_text: { _eq: "" }, tree_type: {_eq: $tree_type} }) {
...fileObjData
...fileBrowserObjData
}
}
`;
const folderQuery = gql`
${fileDataFragment}
${fileBrowserDataFragment}
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 }
) {
...fileObjData
...fileBrowserObjData
}
parents: mythictree(
where: { full_path_text: { _in: $parents }, tree_type: {_eq: $tree_type} }
order_by: { can_have_children: asc, name: asc }
) {
...fileObjData
...fileBrowserObjData
}
self: mythictree(
where: { full_path_text: { _eq: $parent_path_text }, tree_type: {_eq: $tree_type} }
order_by: { can_have_children: asc, name: asc }
) {
...fileObjData
...fileBrowserObjData
}
}
`;
const fileDataSubscription = gql`
${fileDataFragment}
${fileBrowserDataFragment}
subscription liveData($now: timestamp!, $tree_type: String!) {
mythictree_stream(
batch_size: 1000,
cursor: {initial_value: {timestamp: $now}},
where: {tree_type: {_eq: $tree_type} }
) {
...fileObjData
...fileBrowserObjData
}
}
`;
@@ -199,6 +200,25 @@ 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: {
@@ -216,9 +236,6 @@ 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,
@@ -247,6 +264,7 @@ 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
@@ -317,6 +335,7 @@ 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++){
@@ -385,9 +404,37 @@ 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){
@@ -681,7 +728,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"}}>
<div className="bg-gray-base" style={{display: "inline-flex", height: "100%", minHeight: 0, minWidth: 0, overflow: "hidden"}}>
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
<CircularProgress color="inherit" />
</Backdrop>
@@ -701,10 +748,11 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
/>
</div>
<div className="bg-gray-light" style={{display: "inline-flex"}}>
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: "hidden" }}>
<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 style={{ flexGrow: 0 }}>
<FileBrowserTableTop
active={active}
tabInfo={tabInfo}
taskingTableTopTypedDataRef={taskingTableTopTypedDataRef}
autoTaskLsOnEmptyDirectoriesRef={autoTaskLsOnEmptyDirectoriesRef}
@@ -720,11 +768,13 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
treeConfig={treeConfig}
/>
</div>
<div style={{ flexGrow: 1 }}>
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "hidden" }}>
<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}
@@ -760,6 +810,7 @@ export const CallbacksTabsCustomFileBasedBrowserPanel = ({ index, value, tabInfo
);
};
const FileBrowserTableTop = ({
active,
selectedFolderData,
taskingTableTopTypedDataRef,
autoTaskLsOnEmptyDirectoriesRef,
@@ -772,9 +823,9 @@ const FileBrowserTableTop = ({
tabInfo,
treeConfig
}) => {
const autoTaskLsOnEmptyDirectories = useGetMythicSetting({
const [autoTaskLsOnEmptyDirectories, setAutoTaskLsOnEmptyDirectories] = React.useState(() => GetMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories", default_value: false
});
}));
const [updateMythicSetting] = useSetMythicSetting();
const [openEditHostDialog, setOpenEditHostDialog] = React.useState(false);
const [fullPath, setFullPath] = React.useState('');
@@ -848,6 +899,8 @@ const FileBrowserTableTop = ({
}
useSubscription(subscriptionCallbackTokens, {
variables: {callback_id: tabInfo.callbackID}, fetchPolicy: "no-cache",
skip: !active,
ignoreResults: true,
shouldResubscribe: true,
onData: ({data}) => {
setTokenOptions(data.data.callbacktoken);
@@ -948,8 +1001,15 @@ const FileBrowserTableTop = ({
}});
};
const onToggleAutoTaskLsOnEmptyDirectories = () => {
updateMythicSetting({setting_name: "autoTaskLsOnEmptyDirectories", value: !autoTaskLsOnEmptyDirectories});
if(autoTaskLsOnEmptyDirectories){
const nextAutoTaskLsOnEmptyDirectories = !autoTaskLsOnEmptyDirectories;
setAutoTaskLsOnEmptyDirectories(nextAutoTaskLsOnEmptyDirectories);
autoTaskLsOnEmptyDirectoriesRef.current = nextAutoTaskLsOnEmptyDirectories;
updateMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories",
value: nextAutoTaskLsOnEmptyDirectories,
broadcast: false,
});
if(!nextAutoTaskLsOnEmptyDirectories){
snackActions.info("No longer auto issuing listings for empty paths");
} else {
snackActions.success("Now starting to auto issue listings for empty paths");
@@ -987,7 +1047,7 @@ const FileBrowserTableTop = ({
autoTaskLsOnEmptyDirectoriesRef.current = autoTaskLsOnEmptyDirectories;
}, [autoTaskLsOnEmptyDirectories]);
return (
<Grid container spacing={0} style={{ paddingTop: '5px' }}>
<Grid container spacing={0} className="mythic-file-browser-tableTop">
<Grid size={12}>
{openEditHostDialog && (
<MythicDialog
@@ -1017,62 +1077,69 @@ const FileBrowserTableTop = ({
name={<>
{placeHolder}
<MythicStyledTooltip title={`Edit the supplied Host field`}>
<IconButton style={{ padding: '3px' }}
onClick={() => {setOpenEditHostDialog(true);}}
size="large">
<EditIcon color='info' />
<IconButton
className="mythic-file-browser-iconButton mythic-file-browser-labelButton mythic-action-tone-hover mythic-tone-info"
onClick={() => {setOpenEditHostDialog(true);}}
size="small">
<EditIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
{placeHolderGroups}
</>}
marginTop="0"
marginBottom="0"
InputProps={{
className: "mythic-file-browser-pathInput",
endAdornment: (
<React.Fragment>
<div className="mythic-file-browser-toolbarGroup mythic-file-browser-toolbarGroupEnd">
<MythicStyledTooltip title={`Task current callback (${tabInfo["displayID"]}) to list contents`}>
<IconButton style={{ padding: '3px' }}
onClick={onLocalListFilesButton}
size="large">
<RefreshIcon color='info' />
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
onClick={onLocalListFilesButton}
size="small">
<RefreshIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
{autoTaskLsOnEmptyDirectories ? (
<MythicStyledTooltip title={"Currently tasking listing on empty directories, click to toggle off"} >
<IconButton style={{padding: "3px"}}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
disabled={!treeConfig.show_current_path}
size={"large"}>
<PlaylistAddIcon color={"success"} ></PlaylistAddIcon>
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone mythic-tone-success mythic-action-tone-hover mythic-tone-success"
onClick={onToggleAutoTaskLsOnEmptyDirectories}
disabled={!treeConfig.show_current_path}
size="small">
<PlaylistAddIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
) : (
<MythicStyledTooltip title={"Currently not tasking listing on empty directories, click to toggle on"} >
<IconButton style={{padding: "3px"}}
disabled={!treeConfig.show_current_path}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size={"large"}>
<PlaylistRemoveIcon color={"secondary"} ></PlaylistRemoveIcon>
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning"
disabled={!treeConfig.show_current_path}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size="small">
<PlaylistRemoveIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
)}
<MythicStyledTooltip title={showDeletedFiles ? 'Hide Deleted Entries' : 'Show Deleted Entries'}>
<IconButton
style={{ padding: '3px' }}
className={`mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning ${showDeletedFiles ? "mythic-action-tone mythic-tone-warning" : ""}`}
onClick={onLocalToggleShowDeletedFiles}
size="large">
size="small">
{showDeletedFiles ? (
<VisibilityIcon color="success" />
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon color={"secondary"} />
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={`Export Current Path and Children`}>
<IconButton style={{ padding: '3px' }}
disabled={treeConfig.export_function === ""}
onClick={onLocalExportButton}
color="info"
size="large">
<IosShareIcon/>
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
disabled={treeConfig.export_function === ""}
onClick={onLocalExportButton}
size="small">
<IosShareIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
{treeConfig.extra_table_inputs?.length > 0 &&
@@ -1081,12 +1148,13 @@ const FileBrowserTableTop = ({
showExtraInputs ?
`Hide Extra Browser Inputs ${extraDataSet ? "( Extra Data Currently Set )" : ""}` :
`Show Extra Browser Inputs ${extraDataSet ? "( Extra Data Currently Set )" : ""}`}>
<IconButton style={{ padding: '3px' }}
onClick={() => {setShowExtraInputs(!showExtraInputs)}}
disableFocusRipple={true}
disableRipple={true}
size="large">
<SettingsInputComponentRoundedIcon color={extraDataRequired ? "error" : extraDataSet ? "warning" : "info"} />
<IconButton
className={`mythic-file-browser-iconButton mythic-file-browser-iconButtonCompound ${extraDataRequired ? "mythic-action-tone mythic-tone-error mythic-action-tone-hover mythic-tone-error" : extraDataSet ? "mythic-action-tone mythic-tone-warning mythic-action-tone-hover mythic-tone-warning" : "mythic-action-tone-hover mythic-tone-info"}`}
onClick={() => {setShowExtraInputs(!showExtraInputs)}}
disableFocusRipple={true}
disableRipple={true}
size="small">
<SettingsInputComponentRoundedIcon fontSize="small" />
{showExtraInputs ? (
<KeyboardArrowDownRoundedIcon style={{rotate: "180deg"}} />
) : (
@@ -1095,47 +1163,45 @@ const FileBrowserTableTop = ({
</IconButton>
</MythicStyledTooltip>
}
</React.Fragment>
</div>
),
startAdornment: (
<div style={{display: "inline-flex",
alignItems: "center",
borderRight: "1px solid grey",
marginRight: "10px",
padding: "0 5px 0 0"}}>
<div className="mythic-file-browser-toolbarGroup mythic-file-browser-toolbarGroupStart">
{tokenOptions.length > 0 &&
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
<div className="mythic-file-browser-tokenSelect">
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
</div>
}
<MythicStyledTooltip title={`Move back to previous listing`}>
<IconButton style={{ padding: '3px' }}
disabled={historyIndex >= history.length -1 }
onClick={moveIndexToPreviousListing}
color='info'
size="large">
<ArrowBackIcon />
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
disabled={historyIndex >= history.length -1 }
onClick={moveIndexToPreviousListing}
size="small">
<ArrowBackIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={`Move to next listing`}>
<IconButton style={{ padding: '3px' }}
disabled={historyIndex <= 0}
onClick={moveIndexToNextListing}
size="large"
color='info'>
<ArrowForwardIcon />
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
disabled={historyIndex <= 0}
onClick={moveIndexToNextListing}
size="small">
<ArrowForwardIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Move up a directory"} >
<IconButton style={{padding: "0 0 0 0"}}
onClick={onLocalMoveUpDirectoryButton}
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
onClick={onLocalMoveUpDirectoryButton}
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
>
<KeyboardReturnIcon style={{rotate: "90deg"}} ></KeyboardReturnIcon>
<KeyboardReturnIcon fontSize="small" style={{rotate: "90deg"}} />
</IconButton>
</MythicStyledTooltip>
</div>),
style: { },
}}
/>
{showExtraInputs &&
@@ -36,6 +36,12 @@ import {
} from "../../MythicComponents/MythicSavedUserSetting";
import {getIconColor, getIconName} from "./ResponseDisplayTable";
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
import {
GridColumnFilterDialog,
getUpdatedGridFilterOptions,
gridValuePassesFilter,
isGridColumnFilterActive
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
const updateFileComment = gql`
mutation updateCommentMutation($mythictree_id: Int!, $comment: String!) {
@@ -48,7 +54,7 @@ const updateFileComment = gql`
export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
const theme = useTheme();
const [updateSetting] = useSetMythicSetting();
const [updateSetting, updateSettings] = useSetMythicSetting();
const [allData, setAllData] = React.useState([]);
const [openReorderDialog, setOpenReorderDialog] = React.useState(false);
const [openContextMenu, setOpenContextMenu] = React.useState(false);
@@ -85,7 +91,7 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
() =>
columnOrder.reduce( (prev, cur) => {
if(columnVisibility.visible.includes(cur.name)){
if(filterOptions[cur.key] && String(filterOptions[cur.key]).length > 0){
if(isGridColumnFilterActive(filterOptions[cur.key])){
return [...prev, {...cur, filtered: true}];
}else{
return [...prev, {...cur}];
@@ -176,9 +182,10 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
return tempData;
}, [allData, sortData]);
const onSubmitFilterOptions = (value) => {
setFilterOptions({...filterOptions, [selectedColumn.key]: value });
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
try{
updateSetting({setting_name: `${props.treeConfig.name}_browser_filter_options`, value: {...filterOptions, [selectedColumn.key]: value }});
updateSetting({setting_name: `${props.treeConfig.name}_browser_filter_options`, value: nextFilterOptions, broadcast: false});
}catch(error){
console.log("failed to save filter options");
}
@@ -194,11 +201,11 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
}
for(const [key,value] of Object.entries(filterOptions)){
if(["name_text", "comment"].includes(key)){
if(!String(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key]).toLowerCase().includes(value)){
if(!gridValuePassesFilter(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key], value)){
return true;
}
} else {
if(!String(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row].metadata[key]).toLowerCase().includes(value)){
if(!gridValuePassesFilter(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row].metadata[key], value)){
return true;
}
}
@@ -206,14 +213,14 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
}
return false;
}
const gridData = React.useMemo(
() =>
// row is just the name
sortedData.reduce((prev, row) => {
if(filterRow(row)){
return [...prev];
}else{
return [...prev, columns.map( c => {
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 => {
switch(c.type) {
case "name":
return <FileBrowserTableRowNameCell
@@ -250,12 +257,13 @@ 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]}/>
}
})];
}
}, []),
[sortedData, props.onTaskRowAction, filterOptions, columnVisibility, props.showDeletedFiles]
);
}));
}
});
cachedGridDataRef.current = nextGridData;
return nextGridData;
}, [columns, sortedData, props.active, props.dataVersion, props.onTaskRowAction, filterOptions,
props.selectedFolderData, props.showDeletedFiles, props.treeRootData]);
const getDisplayFormat = () => {
if(sortedData.length === 0 && props?.selectedFolderData?.success === false){
return "showTask";
@@ -273,6 +281,7 @@ 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;
@@ -282,7 +291,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.selectedFolderData, props.treeAdjMatrix]);
}, [props.active, props.dataVersion, props.selectedFolderData, props.treeAdjMatrix]);
const onRowDoubleClick = (e, rowIndex, rowData) => {
if (!rowData.can_have_children) {
return;
@@ -476,7 +485,8 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
{
name: removeDisplay, type: "item",
disabled: removeCommand === undefined,
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
danger: true,
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
click: ({event}) => {
event.stopPropagation();
props.onTaskRowAction({
@@ -546,7 +556,8 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
{
name: `Remove All Selected`, type: "item",
disabled: removeCommand === undefined,
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
danger: true,
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
click: ({event}) => {
event.stopPropagation();
let newTasks = [];
@@ -678,10 +689,15 @@ 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});
updateSetting({setting_name: `${props.treeConfig.name}_browser_table_columns`, value: newVisible});
updateSettings({
settings: {
[`${props.treeConfig.name}_browser_column_order`]: newOrder.map(c => c.name),
[`${props.treeConfig.name}_browser_table_columns`]: newVisible,
},
broadcast: false,
});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -782,9 +798,9 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: "1px solid black",
border: `1px solid ${theme.palette.divider}`,
padding: "5px",
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
}}>
{"Only PARTIAL data has been collected for this path. "}<br/>
{"Task this callback to list the contents"}
@@ -803,9 +819,9 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: "1px solid black",
border: `1px solid ${theme.palette.divider}`,
padding: "5px",
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
}}>
{"Some data exists for this path, but isn't loaded into the UI. "}
<br/>
@@ -820,9 +836,9 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: "1px solid black",
border: `1px solid ${theme.palette.divider}`,
padding: "5px",
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
}}>
{"No data has been collected for this path. "}
<div style={{display: "flex", alignItems: "center"}}>
@@ -844,10 +860,10 @@ export const CallbacksTabsCustomFileBasedBrowserTable = (props) => {
setOpenContextMenu(false);
}}
innerDialog={
<MythicModifyStringDialog
title='Filter Column'
<GridColumnFilterDialog
onSubmit={onSubmitFilterOptions}
value={filterOptions[selectedColumn.key]}
filterValue={filterOptions[selectedColumn.key]}
selectedColumn={selectedColumn}
onClose={() => {
setOpenContextMenu(false);
}}
@@ -973,4 +989,4 @@ export const TableRowSizeCell = ({ cellData, rowData }) => {
}
};
return getStringSize(cellData);
};
};
@@ -1,6 +1,6 @@
import { MythicTabPanel, MythicTabLabel } from '../../MythicComponents/MythicTabPanel';
import React, { useEffect, useCallback } from 'react';
import { gql, useLazyQuery, useQuery, useSubscription } from '@apollo/client';
import { gql, useLazyQuery, useSubscription } from '@apollo/client';
import { snackActions } from '../../utilities/Snackbar';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import MythicTextField from '../../MythicComponents/MythicTextField';
@@ -26,10 +26,11 @@ 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 {useGetMythicSetting, useSetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {GetMythicSetting, 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 {
@@ -67,14 +68,6 @@ 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]) {
@@ -98,18 +91,6 @@ 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(
@@ -144,6 +125,15 @@ 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);
@@ -200,10 +190,26 @@ 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,
@@ -226,164 +232,35 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
});
const loadedCommandsRef = React.useRef({});
const loadingCommandsRef = React.useRef(false);
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}});
}
}
})
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]);
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){
@@ -470,7 +347,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
existingData.task_id = mythictree[i].task_id;
}
existingData.comment += mythictree[i].comment;
existingData.tags = [...existingData.tags, ...mythictree[i].tags];
existingData.tags = mergeUniqueById(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;
@@ -478,7 +355,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 = [...existingData.metadata.permissions, ...mythictree[i].metadata.permissions];
existingData.metadata.permissions = mergeUniqueValues(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;
@@ -489,7 +366,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 = [...existingData.filemeta, ...newfileData]
existingData.filemeta = mergeUniqueById(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"] &&
@@ -682,8 +559,8 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
}
return (
<MythicTabPanel index={index} value={value}>
<Split direction="horizontal" style={{width: "100%", height: "100%", display: "flex", overflow: "hidden"}} sizes={[30, 70]} >
<div className="bg-gray-base" style={{display: "inline-flex"}}>
<Split direction="horizontal" className="mythic-eventing-split" sizes={[30, 70]} >
<div className="mythic-eventing-sidebar">
<Backdrop open={backdropOpen} style={{zIndex: 2, position: "absolute"}} invisible={true}>
<CircularProgress color="inherit" />
</Backdrop>
@@ -702,10 +579,11 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
/>
</div>
<div className="bg-gray-light" style={{display: "inline-flex"}}>
<div style={{ display: 'flex', flexDirection: 'column', flexGrow: 1, overflow: "hidden" }}>
<div style={{ flexGrow: 0 }}>
<div className="mythic-eventing-content">
<div className="mythic-process-browser-table-shell">
<div>
<FileBrowserTableTop
active={active}
tabInfo={tabInfo}
taskingTableTopTypedDataRef={taskingTableTopTypedDataRef}
autoTaskLsOnEmptyDirectoriesRef={autoTaskLsOnEmptyDirectoriesRef}
@@ -720,11 +598,13 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
baseUIFeature={baseUIFeature}
/>
</div>
<div style={{ flexGrow: 1 }}>
<div className="mythic-process-browser-grid-shell">
<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}
@@ -759,6 +639,7 @@ export const CallbacksTabsFileBrowserPanel = ({ index, value, tabInfo, me, setNe
);
};
const FileBrowserTableTop = ({
active,
selectedFolderData,
taskingTableTopTypedDataRef,
autoTaskLsOnEmptyDirectoriesRef,
@@ -772,9 +653,9 @@ const FileBrowserTableTop = ({
tabInfo,
baseUIFeature
}) => {
const autoTaskLsOnEmptyDirectories = useGetMythicSetting({
const [autoTaskLsOnEmptyDirectories, setAutoTaskLsOnEmptyDirectories] = React.useState(() => GetMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories", default_value: false
});
}));
const [updateMythicSetting] = useSetMythicSetting();
const [fullPath, setFullPath] = React.useState('');
const selectedToken = React.useRef("Default Token");
@@ -803,6 +684,8 @@ const FileBrowserTableTop = ({
}
useSubscription(subscriptionCallbackTokens, {
variables: {callback_id: tabInfo.callbackID}, fetchPolicy: "no-cache",
skip: !active,
ignoreResults: true,
shouldResubscribe: true,
onData: ({data}) => {
setTokenOptions(data.data.callbacktoken);
@@ -831,16 +714,15 @@ const FileBrowserTableTop = ({
return;
}
if(selectedFolderData.id !== ""){
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)])
setHistory((currentHistory) => {
if(currentHistory[0]?.full_path_text === selectedFolderData.full_path_text){
return currentHistory;
}
}
return [selectedFolderData, ...currentHistory].slice(0, 20);
});
setHistoryIndex(0);
}
}, [selectedFolderData, history])
}, [selectedFolderData])
const moveIndexToPreviousListing = () => {
// we're getting closer to the end of the historyRef.current, the oldest listing
if(historyIndex >= history.length -1){
@@ -886,7 +768,14 @@ const FileBrowserTableTop = ({
}
};
const onToggleAutoTaskLsOnEmptyDirectories = () => {
updateMythicSetting({setting_name: "autoTaskLsOnEmptyDirectories", value: !autoTaskLsOnEmptyDirectories});
const nextAutoTaskSetting = !autoTaskLsOnEmptyDirectories;
setAutoTaskLsOnEmptyDirectories(nextAutoTaskSetting);
autoTaskLsOnEmptyDirectoriesRef.current = nextAutoTaskSetting;
updateMythicSetting({
setting_name: "autoTaskLsOnEmptyDirectories",
value: nextAutoTaskSetting,
broadcast: false,
});
if(autoTaskLsOnEmptyDirectories){
snackActions.info("No longer auto issuing listings for empty paths");
} else {
@@ -917,7 +806,7 @@ const FileBrowserTableTop = ({
autoTaskLsOnEmptyDirectoriesRef.current = autoTaskLsOnEmptyDirectories;
}, [autoTaskLsOnEmptyDirectories]);
return (
<Grid container spacing={0} style={{ paddingTop: '10px' }}>
<Grid container spacing={0} className="mythic-file-browser-tableTop">
<Grid size={12}>
<MythicTextField
placeholder={selectedFolderData.host}
@@ -925,93 +814,98 @@ const FileBrowserTableTop = ({
onEnter={goToDirectory}
onChange={onChangePath}
name={placeHolder}
marginTop="0"
marginBottom="0"
InputProps={{
className: "mythic-file-browser-pathInput",
endAdornment: (
<React.Fragment>
<div className="mythic-file-browser-toolbarGroup mythic-file-browser-toolbarGroupEnd">
<MythicStyledTooltip title={`Task current callback (${tabInfo["displayID"]}) to list contents`}>
<IconButton style={{ padding: '3px' }}
onClick={onLocalListFilesButton}
size="large">
<RefreshIcon color='info' />
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
onClick={onLocalListFilesButton}
size="small">
<RefreshIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={`Upload file to folder via current callback (${tabInfo["displayID"]})`}>
<IconButton style={{ padding: '3px' }}
onClick={onLocalUploadFileButton}
size="large">
<CloudUploadIcon color="info" />
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
onClick={onLocalUploadFileButton}
size="small">
<CloudUploadIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
{autoTaskLsOnEmptyDirectories ? (
<MythicStyledTooltip title={"Currently tasking listing on empty directories, click to toggle off"} >
<IconButton style={{padding: "3px"}}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size={"large"}>
<PlaylistAddIcon color={"success"} ></PlaylistAddIcon>
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone mythic-tone-success mythic-action-tone-hover mythic-tone-success"
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size="small">
<PlaylistAddIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
) : (
<MythicStyledTooltip title={"Currently not tasking listing on empty directories, click to toggle on"} >
<IconButton style={{padding: "3px"}}
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size={"large"}>
<PlaylistRemoveIcon color={"secondary"} ></PlaylistRemoveIcon>
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning"
onClick={onToggleAutoTaskLsOnEmptyDirectories}
size="small">
<PlaylistRemoveIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
)}
<MythicStyledTooltip title={showDeletedFiles ? 'Hide Deleted Files' : 'Show Deleted Files'}>
<IconButton
style={{ padding: '3px' }}
className={`mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning ${showDeletedFiles ? "mythic-action-tone mythic-tone-warning" : ""}`}
onClick={onLocalToggleShowDeletedFiles}
size="large">
size="small">
{showDeletedFiles ? (
<VisibilityIcon color="success" />
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon color="error" />
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</MythicStyledTooltip>
</React.Fragment>
</div>
),
startAdornment: (
<div style={{display: "inline-flex",
alignItems: "center",
borderRight: "1px solid grey",
marginRight: "10px",
padding: "0 5px 0 0"}}>
<div className="mythic-file-browser-toolbarGroup mythic-file-browser-toolbarGroupStart">
{tokenOptions.length > 0 &&
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
<div className="mythic-file-browser-tokenSelect">
<CallbacksTabsTaskingInputTokenSelect width={"100%"}
options={tokenOptions} changeSelectedToken={changeSelectedToken}/>
</div>
}
<MythicStyledTooltip title={`Move back to previous listing`}>
<IconButton style={{ padding: '3px' }}
disabled={historyIndex >= history.length -1 }
onClick={moveIndexToPreviousListing}
color='info'
size="large">
<ArrowBackIcon />
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
disabled={historyIndex >= history.length -1 }
onClick={moveIndexToPreviousListing}
size="small">
<ArrowBackIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={`Move to next listing`}>
<IconButton style={{ padding: '3px' }}
disabled={historyIndex <= 0}
onClick={moveIndexToNextListing}
size="large"
color='info'>
<ArrowForwardIcon />
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
disabled={historyIndex <= 0}
onClick={moveIndexToNextListing}
size="small">
<ArrowForwardIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Move up a directory"} >
<IconButton style={{padding: "0 0 0 0"}}
onClick={onLocalMoveUpDirectoryButton}
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
onClick={onLocalMoveUpDirectoryButton}
disabled={!selectedFolderData?.parent_path_text || selectedFolderData?.parent_path_text?.length === 0 || selectedFolderData.root || fullPath === ""}
>
<KeyboardReturnIcon style={{rotate: "90deg"}} ></KeyboardReturnIcon>
<KeyboardReturnIcon fontSize="small" style={{rotate: "90deg"}} />
</IconButton>
</MythicStyledTooltip>
</div>),
style: { },
}}
/>
</Grid>
@@ -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 "../Search/PreviewFileMedia";
import {PreviewFileMediaDialog} from "../../MythicComponents/PreviewFileMedia";
import RefreshIcon from '@mui/icons-material/Refresh';
import {Dropdown, DropdownMenuItem, DropdownNestedMenuItem} from "../../MythicComponents/MythicNestedMenus";
import {RenderSingleTask} from "../SingleTaskView/SingleTaskView";
@@ -41,6 +41,12 @@ import {
useSetMythicSetting
} from "../../MythicComponents/MythicSavedUserSetting";
import {CallbacksTableColumnsReorderDialog} from "./CallbacksTableColumnsReorderDialog";
import {
GridColumnFilterDialog,
getUpdatedGridFilterOptions,
gridValuePassesFilter,
isGridColumnFilterActive
} from "../../MythicComponents/MythicResizableGrid/GridColumnFilterDialog";
const getFileDownloadHistory = gql`
query getFileDownloadHistory($full_path_text: String!, $host: String!, $group: [String!]) {
@@ -89,7 +95,8 @@ const columnDefaults = [
{ name: 'Comment', type: 'string', key: 'comment', width: 200, visible: false },
];
export const CallbacksTabsFileBrowserTable = (props) => {
const [updateSetting] = useSetMythicSetting();
const theme = useTheme();
const [updateSetting, updateSettings] = useSetMythicSetting();
const [loading, setLoading] = React.useState(true);
const [allData, setAllData] = React.useState([]);
const [openContextMenu, setOpenContextMenu] = React.useState(false);
@@ -107,7 +114,7 @@ export const CallbacksTabsFileBrowserTable = (props) => {
() =>
columnOrder.reduce( (prev, cur) => {
if(columnVisibility.visible.includes(cur.name)){
if(filterOptions[cur.key] && String(filterOptions[cur.key]).length > 0){
if(isGridColumnFilterActive(filterOptions[cur.key])){
return [...prev, {...cur, filtered: true}];
}else{
return [...prev, {...cur}];
@@ -197,9 +204,10 @@ export const CallbacksTabsFileBrowserTable = (props) => {
return tempData;
}, [allData, sortData]);
const onSubmitFilterOptions = (value) => {
setFilterOptions({...filterOptions, [selectedColumn.key]: value });
const nextFilterOptions = getUpdatedGridFilterOptions(filterOptions, selectedColumn.key, value);
setFilterOptions(nextFilterOptions);
try{
updateSetting({setting_name: `file_browser_filter_options`, value: {...filterOptions,[selectedColumn.key]: value }});
updateSetting({setting_name: `file_browser_filter_options`, value: nextFilterOptions, broadcast: false});
}catch(error){
console.log("failed to save filter options");
}
@@ -214,20 +222,20 @@ export const CallbacksTabsFileBrowserTable = (props) => {
return true;
}
for(const [key,value] of Object.entries(filterOptions)){
if(!String(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key]).toLowerCase().includes(value)){
if(!gridValuePassesFilter(props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row][key], value)){
return true;
}
}
return false;
}
const gridData = React.useMemo(
() =>
// row is just the name
sortedData.reduce((prev, row) => {
if(filterRow(row)){
return [...prev];
}else{
return [...prev, columns.map( c => {
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 => {
switch(c.name){
case "Info":
return <FileBrowserTableRowActionCell
@@ -261,12 +269,13 @@ export const CallbacksTabsFileBrowserTable = (props) => {
case "Comment":
return <FileBrowserTableRowStringCell cellData={row.comment} rowData={props.treeRootData[props.selectedFolderData.group][props.selectedFolderData.host][row]} />
}
})];
}
}, []),
[sortedData, props.onTaskRowAction, filterOptions, columnVisibility, props.showDeletedFiles]
);
}));
}
});
cachedGridDataRef.current = nextGridData;
return nextGridData;
}, [columns, sortedData, props.active, props.dataVersion, props.onTaskRowAction, filterOptions,
props.selectedFolderData, props.showDeletedFiles, props.treeRootData]);
const getDisplayFormat = () => {
if(sortedData.length === 0 && props?.selectedFolderData?.success === false){
return "showTask";
@@ -284,6 +293,7 @@ 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;
@@ -293,7 +303,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.selectedFolderData, props.treeAdjMatrix]);
}, [props.active, props.dataVersion, props.selectedFolderData, props.treeAdjMatrix]);
const onRowDoubleClick = (e, rowIndex, rowData) => {
if (!rowData.can_have_children) {
return;
@@ -406,11 +416,15 @@ 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});
updateSetting({setting_name: `file_browser_table_columns`, value: newVisible});
updateSettings({
settings: {
file_browser_column_order: newOrder.map(c => c.name),
file_browser_table_columns: newVisible,
},
broadcast: false,
});
setOpenReorderDialog(false);
}
const onResetColumnReorder = () => {
@@ -539,7 +553,8 @@ export const CallbacksTabsFileBrowserTable = (props) => {
},
{
name: removeDisplay, type: "item", disabled: removeCommand === undefined,
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
danger: true,
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
click: ({event}) => {
event.stopPropagation();
props.onTaskRowAction({
@@ -582,7 +597,8 @@ export const CallbacksTabsFileBrowserTable = (props) => {
},
{
name: `Remove All Selected`, type: "item",
icon: <DeleteIcon color="error" style={{ paddingRight: '5px' }} />,
danger: true,
icon: <DeleteIcon style={{ paddingRight: '5px' }} />,
click: ({event}) => {
event.stopPropagation();
let newTasks = [];
@@ -673,9 +689,9 @@ export const CallbacksTabsFileBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: "1px solid black",
border: `1px solid ${theme.palette.divider}`,
padding: "5px",
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
}}>
{"Only PARTIAL data has been collected for this path. "}<br/>
{"Task this callback to list the contents"}
@@ -694,9 +710,9 @@ export const CallbacksTabsFileBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: "1px solid black",
border: `1px solid ${theme.palette.divider}`,
padding: "5px",
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
}}>
{"Some data exists for this path, but isn't loaded into the UI. "}
<br/>
@@ -711,9 +727,9 @@ export const CallbacksTabsFileBrowserTable = (props) => {
left: "35%",
top: "40%",
borderRadius: "4px",
border: "1px solid black",
border: `1px solid ${theme.palette.divider}`,
padding: "5px",
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
}}>
{"No data has been collected for this path. "}
<div style={{display: "flex", alignItems: "center"}}>
@@ -733,10 +749,10 @@ export const CallbacksTabsFileBrowserTable = (props) => {
<MythicDialog fullWidth={true} maxWidth="sm" open={openContextMenu}
onClose={()=>{setOpenContextMenu(false);}}
innerDialog={
<MythicModifyStringDialog
title='Filter Column'
<GridColumnFilterDialog
onSubmit={onSubmitFilterOptions}
value={filterOptions[selectedColumn.key]}
filterValue={filterOptions[selectedColumn.key]}
selectedColumn={selectedColumn}
onClose={() => {
setOpenContextMenu(false);
}}
@@ -1016,6 +1032,7 @@ 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}
@@ -1029,6 +1046,7 @@ 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,4 +1,4 @@
import React, {useEffect} from 'react';
import React from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
@@ -6,9 +6,8 @@ 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`
@@ -35,107 +34,6 @@ 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([]);
@@ -151,7 +49,7 @@ export function CallbacksTabsHideMultipleDialog({onClose}) {
onClose();
}
});
useQuery(callbacksAndFeaturesQuery,{
const {loading} = useQuery(callbacksAndFeaturesQuery,{
fetchPolicy: "no-cache",
onCompleted: (data) => {
const callbackData = data.callback.map( c => {
@@ -179,9 +77,12 @@ 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>
<CustomSelectTable initialData={initialData}
selectedData={selectedData}
sortModel={{ field: 'last_checkin', sort: 'asc' }}
<CallbacksTabsSelectTable initialData={initialData}
loading={loading}
selectedData={selectedData}
sortModel={{ field: 'last_checkin', sort: 'asc' }}
tableId="hide-multiple-callbacks-table"
tableLabel="Hide multiple callbacks"
/>
<DialogActions>
<Button onClick={onClose} variant="contained" color="primary">
@@ -198,7 +99,7 @@ export function CallbacksTabsSelectMultipleDialog({onClose, onSubmit}) {
const selectedData = React.useRef([]);
const [initialData, setInitialData] = React.useState([]);
useQuery(callbacksAndFeaturesQuery,{
const {loading} = useQuery(callbacksAndFeaturesQuery,{
fetchPolicy: "no-cache",
onCompleted: (data) => {
const callbackData = data.callback.map( c => {
@@ -224,9 +125,12 @@ 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>
<CustomSelectTable initialData={initialData}
selectedData={selectedData}
sortModel={{ field: 'display_id', sort: 'desc' }}
<CallbacksTabsSelectTable initialData={initialData}
loading={loading}
selectedData={selectedData}
sortModel={{ field: 'display_id', sort: 'desc' }}
tableId="select-multiple-callbacks-table"
tableLabel="Select multiple callbacks"
/>
<DialogActions>
<Button onClick={onClose} variant="contained" color="primary">
@@ -239,4 +143,3 @@ export function CallbacksTabsSelectMultipleDialog({onClose, onSubmit}) {
</React.Fragment>
);
}
@@ -1,11 +1,10 @@
import React, {useContext} from 'react';
import React 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`
@@ -32,97 +31,9 @@ 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([]);
useQuery(callbacksAndFeaturesQuery,{
const {loading} = useQuery(callbacksAndFeaturesQuery,{
fetchPolicy: "no-cache",
onCompleted: (data) => {
const callbackData = data.callback.map( c => {
@@ -134,7 +45,7 @@ export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs})
}
});
const onRowClick = (rowData) => {
onOpenTabs([rowData.row]);
onOpenTabs([rowData]);
}
@@ -144,9 +55,13 @@ 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>
<CustomSelectTable initialData={initialData}
selectedData={selectedData}
onRowClick={onRowClick}
<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`}
/>
<DialogActions>
<Button onClick={onClose} variant="contained" color="primary">
@@ -156,4 +71,3 @@ export function CallbacksTabsOpenMultipleDialog({onClose, tabType, onOpenTabs})
</React.Fragment>
);
}
@@ -1,9 +1,8 @@
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useEffect, useRef} from 'react';
import {gql, useQuery, useSubscription, useLazyQuery } from '@apollo/client';
import React, {useCallback, useEffect} from 'react';
import {gql, useQuery, useSubscription } 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';
@@ -13,15 +12,20 @@ import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip'
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 Input from '@mui/material/Input';
import TextField from '@mui/material/TextField';
import InputAdornment from '@mui/material/InputAdornment';
import {ViewCallbackMythicTreeGroupsDialog} from "./ViewCallbackMythicTreeGroupsDialog";
import WidgetsIcon from '@mui/icons-material/Widgets';
import {snackActions} from "../../utilities/Snackbar";
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 {
@@ -82,6 +86,9 @@ 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}}){
@@ -142,17 +149,38 @@ 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(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 [expandOrCollapseAll, setExpandOrCollapseAll] = React.useState(true);
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 = () => {
@@ -172,6 +200,7 @@ 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";
@@ -250,6 +279,7 @@ 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++){
@@ -277,9 +307,9 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
}
}
existingData.has_children = data.data.mythictree_stream[i].has_children || existingData.has_children;
existingData.callbacks.push(data.data.mythictree_stream[i].callback)
existingData.callbacks = mergeUniqueById(existingData.callbacks, [data.data.mythictree_stream[i].callback]);
existingData.comment += data.data.mythictree_stream[i].comment;
existingData.tags = [...existingData.tags, ...data.data.mythictree_stream[i].tags];
existingData.tags = mergeUniqueById(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 {
@@ -296,6 +326,19 @@ 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
@@ -321,8 +364,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.push(data.mythictree[i].callback)
existingData.tags = [...existingData.tags, ...data.mythictree[i].tags];
existingData.callbacks = mergeUniqueById(existingData.callbacks, [data.mythictree[i].callback]);
existingData.tags = mergeUniqueById(existingData.tags, data.mythictree[i].tags);
if(existingData.task_id > data.mythictree[i].task_id){
existingData.metadata = {...data.mythictree[i].metadata, ...existingData.metadata};
} else {
@@ -336,6 +379,7 @@ 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);
}
@@ -349,9 +393,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}) => {
const onTaskRowAction = ({process_id, architecture, uifeature, openDialog, getConfirmation, callback_id, callback_display_id, display_id}) => {
taskingData.current = {"parameters": {host: selectedHost, process_id, architecture},
"ui_feature": uifeature, openDialog, getConfirmation, callback_id, callback_display_id};
"ui_feature": uifeature, openDialog, getConfirmation, callback_id, callback_display_id: callback_display_id || display_id};
setOpenTaskingButton(true);
}
const toggleShowDeletedFiles = (showStatus) => {
@@ -392,10 +436,11 @@ 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);
}, [selectedHost]);
}, [active, isScopeLoaded, selectedHost]);
React.useEffect( () => {
return() => {
mountedRef.current = false;
@@ -410,9 +455,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 black",
border: `1px solid ${theme.palette.divider}`,
padding: "5px",
backgroundColor: "rgba(37,37,37,0.92)", color: "white",
backgroundColor: theme.palette.background.contrast, color: theme.palette.text.contrast,
alignItems: "center",
display: "flex", flexDirection: "column"}}>
<CircularProgress color="inherit" />
@@ -433,8 +478,12 @@ 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={() => {}}
@@ -442,6 +491,7 @@ export const CallbacksTabsProcessBrowserPanel = ({index, value, tabInfo, me, set
treeAdjMatrix={treeAdjMtx}
host={selectedHost}
group={selectedGroup}
quickFilter={quickFilter}
expandOrCollapseAll={expandOrCollapseAll}
onTaskRowAction={onTaskRowAction}
getLoadedCommandForUIFeature={getLoadedCommandForUIFeature}
@@ -472,13 +522,12 @@ const ProcessBrowserTableTop = ({
hostOptions,
groupOptions,
expandOrCollapseAll,
setExpandOrCollapseAll
setExpandOrCollapseAll,
quickFilter,
setQuickFilter
}) => {
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()
}
@@ -495,58 +544,104 @@ const ProcessBrowserTableTop = ({
const onLocalExpandOrCollapseAllButton = () => {
setExpandOrCollapseAll(!expandOrCollapseAll);
}
const onClearQuickFilter = () => {
setQuickFilter("");
}
return (
<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"} >
<IconButton style={{padding: "3px"}}
onClick={onLocalExpandOrCollapseAllButton} size={"large"}>
<ExpandIcon color={expandOrCollapseAll ? "info" : "success"} />
<div className="mythic-file-browser-tableTop mythic-process-browser-tableTop">
<div className="mythic-process-browser-toolbar">
<div className="mythic-process-browser-control mythic-process-browser-controlGroup">
<span className="mythic-process-browser-controlLabel">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">
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
onClick={() => {setOpenViewGroupDialog(true);}}
size="small">
<WidgetsIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<div style={{paddingRight: "20px"}} />
</React.Fragment>
}
>
{Object.keys(hostOptions).sort().map( (opt) => (
<MenuItem value={opt} key={opt}>{opt}</MenuItem>
) )}
</Select>
</FormControl>
</div>
<div className="mythic-process-browser-control mythic-process-browser-controlHost">
<span className="mythic-process-browser-controlLabel">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">
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-error"
onClick={onClearQuickFilter}
size="small">
<ClearIcon fontSize="small" />
</IconButton>
</InputAdornment>
) : null
}}
/>
<div className="mythic-file-browser-toolbarGroup mythic-process-browser-actions">
<MythicStyledTooltip title="Task current callback to list processes">
<IconButton
className="mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info"
onClick={onLocalListFilesButton}
size="small">
<RefreshIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={expandOrCollapseAll ? "Collapse all processes" : "Expand all processes"} >
<IconButton
className={`mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-info ${expandOrCollapseAll ? "mythic-action-tone mythic-tone-success" : ""}`}
onClick={onLocalExpandOrCollapseAllButton}
size="small">
<ExpandIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={showDeletedFiles ? 'Hide deleted processes' : 'Show deleted processes'}>
<IconButton
className={`mythic-file-browser-iconButton mythic-action-tone-hover mythic-tone-warning ${showDeletedFiles ? "mythic-action-tone mythic-tone-warning" : ""}`}
onClick={onLocalToggleShowDeletedFiles}
size="small">
{showDeletedFiles ? (
<VisibilityIcon fontSize="small" />
) : (
<VisibilityOffIcon fontSize="small" />
)}
</IconButton>
</MythicStyledTooltip>
</div>
{openViewGroupsDialog &&
<MythicDialog
fullWidth={true}
@@ -559,7 +654,7 @@ const ProcessBrowserTableTop = ({
}
/>
}
</Grid>
</Grid>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,284 @@
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="mythicElement 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,4 +1,4 @@
import React, {useEffect} from 'react';
import React from 'react';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import DialogTitle from '@mui/material/DialogTitle';
@@ -6,11 +6,10 @@ 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`
@@ -24,6 +23,7 @@ query callbacksAndFeatures($payloadtype_id: Int!) {
integrity_level
pid
display_id
operation_id
last_checkin
ip
dead
@@ -36,100 +36,6 @@ 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({});
@@ -138,7 +44,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
const finalTaskedParameters = React.useRef(null);
const [initialData, setInitialData] = React.useState([]);
const selectedData = React.useRef([]);
useQuery(callbacksAndFeaturesQuery, {variables: {payloadtype_id: callback.payload.payloadtype.id},
const {loading} = useQuery(callbacksAndFeaturesQuery, {variables: {payloadtype_id: callback.payload.payloadtype.id},
fetchPolicy: "no-cache",
onCompleted: (data) => {
setInitialData(data.callback.map( c => {
@@ -157,9 +63,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_ids: callbacks, openDialog: false, parameters: finalTaskedParameters.current};
taskingData.current = {...taskingData.current, callback_display_ids: callbacks, openDialog: false, parameters: finalTaskedParameters.current};
}else{
taskingData.current = {...taskingData.current, callback_ids: callbacks, openDialog: true};
taskingData.current = {...taskingData.current, callback_display_ids: callbacks, openDialog: true};
}
setOpenTaskingButton(true);
}else{
@@ -172,7 +78,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
//onClose();
setOpenTaskingButton(false);
}
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
//console.log(message, cmd, parsed);
if(selectedData.current.length === 0){
//onClose();
@@ -199,6 +105,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
tasking_location: newTaskingLocation,
dontShowSuccessDialog: false,
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
};
submitTasking();
return;
@@ -245,6 +152,7 @@ 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;
@@ -257,6 +165,7 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
tasking_location: newTaskingLocation,
dontShowSuccessDialog: false,
payload_type: cmd.payloadtype?.name,
selected_task_references: taskReferenceOptions.selected_task_references,
};
}
submitTasking();
@@ -275,11 +184,31 @@ 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");
@@ -295,22 +224,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>
<CustomSelectTable initialData={initialData}
selectedData={selectedData} />
<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`}
/>
<Grid size={12}>
<CallbacksTabsTaskingInput filterTasks={false} onSubmitFilter={()=>{}} onSubmitCommandLine={onSubmitCommandLine}
<CallbacksTabsTaskingInput filterTasks={false} onSubmitFilter={()=>{}} onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine}
changeSelectedToken={changeSelectedToken}
payloadtype_name={callback.payload.payloadtype.name}
hide_context={true}
filterOptions={{}} callback_id={callback.id} callback_os={callback.payload.os} parentMountedRef={mountedRef} />
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} />
</Grid>
{openTaskingButton &&
<TaskFromUIButton cmd={taskingData.current?.cmd}
callback_id={taskingData?.current?.callback_id || 0}
callback_ids={taskingData?.current?.callback_ids || undefined}
callback_display_ids={taskingData?.current?.callback_display_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}/>
@@ -323,4 +262,3 @@ export function CallbacksTabsTaskMultipleDialog({onClose, callback}) {
</React.Fragment>
);
}
@@ -1,6 +1,6 @@
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useEffect, useRef, useCallback, useLayoutEffect} from 'react';
import { gql, useMutation, useLazyQuery, useSubscription } from '@apollo/client';
import React, {useEffect, useRef} from 'react';
import { useMutation } from '@apollo/client';
import { TaskDisplay } from './TaskDisplay';
import {snackActions} from '../../utilities/Snackbar';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
@@ -11,9 +11,10 @@ import { IconButton} from '@mui/material';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
import {taskingDataFragment, createTaskingMutation} from "./CallbackMutations";
import {createTaskingMutation} from "./CallbackMutations";
import { validate as uuidValidate } from 'uuid';
import {getSkewedNow} from "../../utilities/Time";
import {useTaskReferenceSubmitter} from "./taskingReferences";
import {useCallbackTaskingData} from "./useCallbackTaskingData";
export function CallbacksTabsTaskingLabel(props){
@@ -60,36 +61,11 @@ 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 [taskLimit, setTaskLimit] = React.useState(20);
const active = index === value;
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": [],
@@ -99,13 +75,7 @@ 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, {
@@ -121,158 +91,39 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
console.error(data);
}
});
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 {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 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(scrollToBottom){
messagesEndRef.current.scrollIntoView();
if(fetched && tasks.length > 0 && !scrollToBottom){
setScrollToBottom(true);
}
}, [scrollToBottom]);
const loadMoreTasks = () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
}
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
if(scrollToBottom){
messagesEndRef.current?.scrollIntoView();
}
}, [fetched, scrollToBottom, tasks.length]);
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
console.log(message, cmd, parsed);
let params = message.split(" ");
delete params[0];
@@ -285,12 +136,13 @@ 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_id: tabInfo.displayID,
onCreateTask({callback_display_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
@@ -325,42 +177,93 @@ 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]});
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0], taskReferenceOptions});
}else{
setCommandInfo({...cmd, "parsedParameters": parsed});
setCommandInfo({...cmd, "parsedParameters": parsed, taskReferenceOptions});
}
setOpenParametersDialog(true);
}else{
delete parsed["_"];
onCreateTask({callback_id: tabInfo.displayID,
onCreateTask({callback_display_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 submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type) => {
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={}) => {
setOpenParametersDialog(false);
onCreateTask({callback_id: tabInfo.displayID,
const selectedTaskReferences = [
...(commandInfo.taskReferenceOptions?.selected_task_references || []),
...(metadata.selectedTaskReferences || []),
];
onCreateTask({callback_display_id: tabInfo.displayID,
command: cmd,
params: parameters,
files: files,
tasking_location: "modal",
parameter_group_name: selectedParameterGroup,
payload_type: payload_type
payload_type: payload_type,
selected_task_references: selectedTaskReferences,
taskReferenceEditCommandInfo: commandInfo,
});
}
const onCreateTask = ({callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}) => {
if(selectedToken.token_id !== undefined){
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}});
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};
if(selectedToken.token_id !== undefined){
variables.token_id = selectedToken.token_id;
}
submitTask({
variables,
onTaskReferenceReviewCancel: taskReferenceEditCommandInfo ? (pendingVariables) => openTaskReferenceEditDialog(pendingVariables, taskReferenceEditCommandInfo) : undefined,
});
}
const onSubmitFilter = (newFilter) => {
setFilterOptions(newFilter);
@@ -384,6 +287,7 @@ 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%"}} title="Fetch Older Tasks">
@@ -398,7 +302,8 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
<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}/>
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}
taskChildrenStore={taskChildrenStore} active={active}/>
))
}
<div ref={messagesEndRef}/>
@@ -407,16 +312,20 @@ 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} />
callback_os={tabInfo.os} parentMountedRef={mountedRef} active={active} />
{openParametersDialog &&
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
onClose={()=>{setOpenParametersDialog(false);}}
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
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);}}
/>}
@@ -425,4 +334,4 @@ export const CallbacksTabsTaskingPanel = ({tabInfo, index, value, onCloseTab, pa
</MythicTabPanel>
);
}
}
@@ -1,6 +1,6 @@
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useEffect, useRef, useCallback, useLayoutEffect} from 'react';
import { gql, useMutation, useLazyQuery, useSubscription } from '@apollo/client';
import React, {useEffect, useRef} from 'react';
import { useMutation } from '@apollo/client';
import { TaskDisplayConsole } from './TaskDisplay';
import {snackActions} from '../../utilities/Snackbar';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
@@ -11,9 +11,10 @@ import { IconButton} from '@mui/material';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
import {taskingDataFragment, createTaskingMutation} from "./CallbackMutations";
import {createTaskingMutation} from "./CallbackMutations";
import { validate as uuidValidate } from 'uuid';
import {getSkewedNow} from "../../utilities/Time";
import {useTaskReferenceSubmitter} from "./taskingReferences";
import {useCallbackTaskingData} from "./useCallbackTaskingData";
export function CallbacksTabsTaskingConsoleLabel(props){
@@ -60,36 +61,11 @@ 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 [taskLimit, setTaskLimit] = React.useState(10);
const active = index === value;
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": [],
@@ -99,13 +75,7 @@ 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, {
@@ -121,159 +91,39 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
console.error(data);
}
});
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 {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 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(scrollToBottom){
messagesEndRef.current.scrollIntoView();
if(fetched && tasks.length > 0 && !scrollToBottom){
setScrollToBottom(true);
}
}, [scrollToBottom]);
const loadMoreTasks = () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
}
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
if(scrollToBottom){
messagesEndRef.current?.scrollIntoView();
}
}, [fetched, scrollToBottom, tasks.length]);
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
//console.log(message, cmd, parsed);
let params = message.split(" ");
delete params[0];
@@ -286,12 +136,13 @@ 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_id: tabInfo.displayID,
onCreateTask({callback_display_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
@@ -326,42 +177,93 @@ 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]});
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0], taskReferenceOptions});
}else{
setCommandInfo({...cmd, "parsedParameters": parsed});
setCommandInfo({...cmd, "parsedParameters": parsed, taskReferenceOptions});
}
setOpenParametersDialog(true);
}else{
delete parsed["_"];
onCreateTask({callback_id: tabInfo.displayID,
onCreateTask({callback_display_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 submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type) => {
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={}) => {
setOpenParametersDialog(false);
onCreateTask({callback_id: tabInfo.displayID,
const selectedTaskReferences = [
...(commandInfo.taskReferenceOptions?.selected_task_references || []),
...(metadata.selectedTaskReferences || []),
];
onCreateTask({callback_display_id: tabInfo.displayID,
command: cmd,
params: parameters,
files: files,
tasking_location: "modal",
parameter_group_name: selectedParameterGroup,
payload_type: payload_type
payload_type: payload_type,
selected_task_references: selectedTaskReferences,
taskReferenceEditCommandInfo: commandInfo,
});
}
const onCreateTask = ({callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}) => {
if(selectedToken.token_id !== undefined){
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}});
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};
if(selectedToken.token_id !== undefined){
variables.token_id = selectedToken.token_id;
}
submitTask({
variables,
onTaskReferenceReviewCancel: taskReferenceEditCommandInfo ? (pendingVariables) => openTaskReferenceEditDialog(pendingVariables, taskReferenceEditCommandInfo) : undefined,
});
}
const onSubmitFilter = (newFilter) => {
setFilterOptions(newFilter);
@@ -380,6 +282,7 @@ 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%"}}>
@@ -394,20 +297,24 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
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}/>
filterOptions={filterOptions} newlyIssuedTasks={newlyIssuedTasks.current}
taskChildrenStore={taskChildrenStore} active={active}/>
))
}
<div ref={messagesEndRef}/>
</div>
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter} onSubmitCommandLine={onSubmitCommandLine} changeSelectedToken={changeSelectedToken}
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter} onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine} 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} />
callback_os={tabInfo.os} parentMountedRef={mountedRef} active={active} />
{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);}}
/>}
@@ -416,4 +323,4 @@ export const CallbacksTabsTaskingConsolePanel = ({tabInfo, index, value, onClose
</MythicTabPanel>
);
}
}
@@ -1,63 +1,40 @@
import React, {useEffect} from 'react';
import { styled } from '@mui/material/styles';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import Checkbox from '@mui/material/Checkbox';
import Chip from '@mui/material/Chip';
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 Checkbox from '@mui/material/Checkbox';
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 {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 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_HEIGHT = 42;
const ITEM_PADDING_TOP = 8;
const MenuProps = {
PaperProps: {
style: {
maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP,
width: 250,
maxHeight: ITEM_HEIGHT * 6 + ITEM_PADDING_TOP,
width: 320,
},
},
variant: "menu",
@@ -72,6 +49,60 @@ 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}) => (
<Chip
className={`mythic-status-chip mythic-tone-${muted ? "neutral" : "primary"}`}
icon={icon}
label={label}
size="small"
/>
)
const MultiSelectField = ({label, value, options, onChange, emptyLabel}) => {
const renderValue = (selected) => {
if(selected.length === 0){
return <span className="mythic-tasking-filter-select-empty">{emptyLabel}</span>
}
return (
<Box className="mythic-tasking-filter-select-chips">
{selected.map((selectedValue) => (
<Chip key={selectedValue} label={selectedValue} size="small" className="mythic-tasking-filter-selected-chip" />
))}
</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([]);
@@ -111,7 +142,7 @@ export function CallbacksTabsTaskingFilterDialog(props) {
const commandOptionNames = props.filterCommandOptions.map(c => c.cmd);
setCommandOptions(commandOptionNames);
}
}, [props.filterOptions]);
}, [props.filterOptions, props.filterCommandOptions]);
const onSubmit = () => {
props.onSubmit({
"operatorsList": onlyOperators,
@@ -133,17 +164,20 @@ export function CallbacksTabsTaskingFilterDialog(props) {
setHideErrors(event.target.checked);
}
const handleOperatorChange = (event) => {
setOnlyOperators(event.target.value);
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
setOnlyOperators(value);
}
const handleOnlyCommandsChange = (event) => {
setOnlyCommands(event.target.value);
if(event.target.value.length > 0){
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
setOnlyCommands(value);
if(value.length > 0){
setEverythingBut([]);
}
}
const handleEverythingButChange = (event) => {
setEverythingBut(event.target.value);
if(event.target.value.length > 0){
const value = typeof event.target.value === "string" ? event.target.value.split(",") : event.target.value;
setEverythingBut(value);
if(value.length > 0){
setOnlyCommands([]);
}
}
@@ -153,110 +187,178 @@ 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 (
<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"
<Box className="mythic-tasking-filter-dialog">
<DialogTitle id="mythic-draggable-title" className="mythic-accent-dialog-title">
<Box className="mythic-accent-dialog-title-row">
<Box className="mythic-accent-dialog-title-icon">
<FilterAltIcon fontSize="small" />
</Box>
<Box sx={{minWidth: 0}}>
<Typography component="div" className="mythic-tasking-filter-title-main">
Task visibility filters
</Typography>
<Typography component="div" className="mythic-accent-dialog-title-subtitle">
Control which tasks are shown for this callback.
</Typography>
</Box>
</Box>
</DialogTitle>
<DialogContent dividers={true} className="mythic-tasking-filter-dialog-content">
<MythicDialogBody compact={true}>
<Box className="mythic-tasking-filter-summary">
{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"
value={onlyOperators}
options={operatorUsernames}
onChange={handleOperatorChange}
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}
emptyLabel="Any operator"
/>
</MythicFormField>
<Box className="mythic-column-stack">
<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">
<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 &&
<Button
className="mythic-tasking-filter-clear-button mythic-action-tone-hover mythic-tone-warning"
onClick={clearAllOnlyCommands}
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 include
</Button>
}
</MythicFormField>
<Box className="mythic-tasking-filter-choice-divider">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 &&
<Button
className="mythic-tasking-filter-clear-button mythic-action-tone-hover mythic-tone-warning"
onClick={clearAllEverythingBut}
size="small"
startIcon={<ClearIcon fontSize="small" />}
variant="outlined"
>
{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>
Clear exclude
</Button>
}
</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>
);
}
File diff suppressed because it is too large. Load diff
@@ -42,15 +42,16 @@ export function CallbacksTabsTaskingInputTokenSelect(props) {
}
}
return (
<FormControl style={{width: props.width ? props.width : "20%"}}>
<FormControl className={props.className || ""} size={props.modern ? "small" : undefined} style={{width: props.width ? props.width : "20%"}}>
<Select
labelId="demo-dialog-select-label"
id="demo-dialog-select"
value={selected}
onChange={handleChange}
variant="filled"
variant={props.modern ? "outlined" : "filled"}
size={props.modern ? "small" : undefined}
renderValue={renderValue}
input={<Input style={{width: "100%"}}/>}
input={props.modern ? undefined : <Input style={{width: "100%"}}/>}
>
<MenuItem value={"Default Token"} key={0}>Default Token</MenuItem>
{options.map( (opt) => (
@@ -60,4 +61,3 @@ export function CallbacksTabsTaskingInputTokenSelect(props) {
</FormControl>
);
}
@@ -1,6 +1,6 @@
import {MythicTabPanel, MythicTabLabel} from '../../MythicComponents/MythicTabPanel';
import React, {useEffect, useRef, useCallback, useLayoutEffect} from 'react';
import { gql, useMutation, useLazyQuery, useSubscription } from '@apollo/client';
import React, {useEffect, useRef} from 'react';
import { useMutation } from '@apollo/client';
import { TaskDisplayFlat } from './TaskDisplay';
import {snackActions} from '../../utilities/Snackbar';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
@@ -11,11 +11,12 @@ import { IconButton} from '@mui/material';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import {MythicModifyStringDialog} from '../../MythicComponents/MythicDialog';
import { MythicStyledTooltip } from '../../MythicComponents/MythicStyledTooltip';
import {taskingDataFragment, createTaskingMutation} from './CallbackMutations.js';
import {createTaskingMutation} from './CallbackMutations.js';
import Split from 'react-split';
import {TaskDisplayContainerFlat} from "./TaskDisplayContainer";
import { validate as uuidValidate } from 'uuid';
import {getSkewedNow} from "../../utilities/Time";
import {useTaskReferenceSubmitter} from "./taskingReferences";
import {useCallbackTaskingData} from "./useCallbackTaskingData";
export function CallbacksTabsTaskingSplitLabel(props){
@@ -61,35 +62,11 @@ 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 [taskLimit, setTaskLimit] = React.useState(30);
const active = index === value;
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": [],
@@ -103,8 +80,6 @@ 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, {
@@ -120,161 +95,41 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
console.error(data);
}
});
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 {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 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(scrollToBottom){
messagesEndRef.current.scrollIntoView();
if(fetched && tasks.length > 0 && !scrollToBottom){
setScrollToBottom(true);
}
}, [scrollToBottom]);
const loadMoreTasks = () => {
getInfiniteScrollTasking({variables: {callback_id: tabInfo.callbackID, offset: taskingData.task.length, fetchLimit}});
}
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation) => {
if(scrollToBottom){
messagesEndRef.current?.scrollIntoView();
}
}, [fetched, scrollToBottom, tasks.length]);
const onSubmitCommandLine = (message, cmd, parsed, force_parsed_popup, cmdGroupNames, previousTaskingLocation, taskReferenceOptions={}) => {
//console.log(message, cmd, parsed);
let params = message.split(" ");
delete params[0];
@@ -287,12 +142,13 @@ 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_id: tabInfo.displayID,
onCreateTask({callback_display_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
@@ -327,42 +183,93 @@ 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]});
setCommandInfo({...cmd, "parsedParameters": parsed, groupName: cmdGroupNames[0], taskReferenceOptions});
}else{
setCommandInfo({...cmd, "parsedParameters": parsed});
setCommandInfo({...cmd, "parsedParameters": parsed, taskReferenceOptions});
}
setOpenParametersDialog(true);
}else{
delete parsed["_"];
onCreateTask({callback_id: tabInfo.displayID,
onCreateTask({callback_display_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 submitParametersDialog = (cmd, parameters, files, selectedParameterGroup, payload_type) => {
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={}) => {
setOpenParametersDialog(false);
onCreateTask({callback_id: tabInfo.displayID,
const selectedTaskReferences = [
...(commandInfo.taskReferenceOptions?.selected_task_references || []),
...(metadata.selectedTaskReferences || []),
];
onCreateTask({callback_display_id: tabInfo.displayID,
command: cmd,
params: parameters,
files: files,
tasking_location: "modal",
parameter_group_name: selectedParameterGroup,
payload_type: payload_type
payload_type: payload_type,
selected_task_references: selectedTaskReferences,
taskReferenceEditCommandInfo: commandInfo,
});
}
const onCreateTask = ({callback_id, command, params, files, tasking_location, original_params, parameter_group_name, payload_type}) => {
if(selectedToken.token_id !== undefined){
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}});
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};
if(selectedToken.token_id !== undefined){
variables.token_id = selectedToken.token_id;
}
submitTask({
variables,
onTaskReferenceReviewCancel: taskReferenceEditCommandInfo ? (pendingVariables) => openTaskReferenceEditDialog(pendingVariables, taskReferenceEditCommandInfo) : undefined,
});
}
const onSubmitFilter = (newFilter) => {
setFilterOptions(newFilter);
@@ -377,48 +284,38 @@ 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( () => {
taskingDataRef.current = taskingData;
if(selectedTask.id){
const updatedSelectedTask = tasks.find((task) => task.id === selectedTask.id);
if(updatedSelectedTask && updatedSelectedTask !== selectedTask){
setSelectedTask(updatedSelectedTask);
}
}
if(newlyIssuedTasks.current){
// we just issued a task, check if we have data on it to set it as the current one
let index = taskingDataRef.current.task.findIndex(e => e.id === newlyIssuedTasks.current)
let index = tasks.findIndex(e => e.id === newlyIssuedTasks.current)
if(index > -1){
newlyIssuedTasks.current = null;
if(taskingDataRef.current.task[index]?.operator?.username === (me?.user?.username || "")){
if(tasks[index]?.operator?.username === (me?.user?.username || "")){
let el = document.getElementById(`taskingPanelSplit${tabInfo.callbackID}`);
if(el && el.scrollHeight - el.scrollTop - el.clientHeight < 100){
changeSelectedTask(taskingDataRef.current.task[index]);
changeSelectedTask(tasks[index]);
}
}
}
}
}, [taskingData, newlyIssuedTasks]);
}, [tasks, newlyIssuedTasks, selectedTask]);
return (
<MythicTabPanel index={index} value={value} >
{taskReferenceSubmitDialog}
<Split direction="horizontal" minSize={[0,0]} sizes={[30, 70]}
style={{width: "100%", height: "100%", overflow: "auto", display: "flex", justifyContent: "flex-end"}} >
<div className="bg-gray-base" style={{display: "inline-flex", flexDirection: "column"}} >
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"}} >
<div style={{overflowY: "auto", flexGrow: 1}} id={`taskingPanelSplit${tabInfo.callbackID}`}>
<div style={{overflowY: "auto", overflowX: "hidden", flexGrow: 1, minWidth: 0}} id={`taskingPanelSplit${tabInfo.callbackID}`}>
{!fetchedAllTasks &&
<MythicStyledTooltip title="Fetch Older Tasks">
@@ -440,6 +337,7 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
changeSelectedTask(tsk)
}}
showOnSelectTask={true} selectedTask={selectedTask}
taskChildrenStore={taskChildrenStore} active={active}
/>
))
}
@@ -447,28 +345,32 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
</div>
</div>
<div className="bg-gray-light" style={{display: "inline-flex", flexDirection: "column", height: "100%", width: "100%"}}>
<div className="bg-gray-light" style={{display: "inline-flex", flexDirection: "column", height: "100%", minWidth: 0, overflow: "hidden", 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) => {
}}
selectedTask={selectedTask}
taskChildrenStore={taskChildrenStore} active={active}
/>}
<CallbacksTabsTaskingSplitTable selectedTask={selectedTask} me={me} filterOptions={filterOptions}
<CallbacksTabsTaskingSplitTable selectedTask={selectedTask} me={me} filterOptions={filterOptions} active={active}
onSelectTask={(tsk) => {changeSelectedTask(tsk)}} changeSelectedTask={changeSelectedTask}
/>
</div>
</Split>
<CallbacksTabsTaskingInput filterTasks={true} me={me} onSubmitFilter={onSubmitFilter}
payloadtype_name={tabInfo.payloadtype} focus={index === value}
onSubmitCommandLine={onSubmitCommandLine} changeSelectedToken={changeSelectedToken}
onSubmitCommandLine={onSubmitCommandLine} onSubmitAliasCommandLine={onSubmitAliasCommandLine} changeSelectedToken={changeSelectedToken}
filterOptions={filterOptions} callback_id={tabInfo.callbackID}
callback_os={tabInfo.os} parentMountedRef={mountedRef} />
callback_display_id={tabInfo.displayID}
operation_id={tabInfo.operation_id}
callback_os={tabInfo.os} parentMountedRef={mountedRef} active={active} />
{openParametersDialog &&
<MythicDialog fullWidth={true} maxWidth="lg" open={openParametersDialog}
onClose={()=>{setOpenParametersDialog(false);}}
innerDialog={<TaskParametersDialog command={commandInfo} callback_id={tabInfo.callbackID}
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);}}
/>}
@@ -478,12 +380,12 @@ export const CallbacksTabsTaskingSplitPanel = ({tabInfo, index, value, onCloseTa
</MythicTabPanel>
);
}
const CallbacksTabsTaskingSplitTable = ({selectedTask, me}) => {
const CallbacksTabsTaskingSplitTable = ({selectedTask, me, active=true}) => {
return (
<div style={{width: "100%", height: "100%", overflowY: "auto", display: "flex", flexDirection: "column"}} >
{selectedTask.id > 0 &&
<div style={{width: "100%", flex: "1 1 auto", minHeight: 0, minWidth: 0, overflow: "hidden", display: "flex", flexDirection: "column"}} >
{active && selectedTask.id > 0 &&
<TaskDisplayContainerFlat key={selectedTask.id} me={me} task={selectedTask} />
}
</div>
)
}
}
@@ -142,7 +142,6 @@ callback(where: {active: {_eq: true}}) {
active
id
display_id
operation_id
user
host
ip
@@ -150,34 +149,17 @@ 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
@@ -185,28 +167,12 @@ 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
@@ -225,9 +191,7 @@ callbackgraphedge_stream(batch_size: 100, cursor: {initial_value: {updated_at: $
destination {
active
id
color
display_id
operation_id
user
host
ip
@@ -235,35 +199,17 @@ 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
@@ -271,28 +217,12 @@ 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
@@ -436,7 +366,7 @@ export function CallbacksTop(props){
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div style={{height: "100%", width: "100%"}}>
<div style={{height: "100%", flex: "1 1 auto", minWidth: 0, overflow: "hidden"}}>
<CallbackGraphEdgesContext.Provider value={callbackEdges.current}>
<OnOpenTabContext.Provider value={onOpenTabLocal}>
<OnOpenTabsContext.Provider value={onOpenTabsLocal}>
@@ -456,4 +386,4 @@ export function CallbacksTop(props){
</CallbackGraphEdgesContext.Provider>
</div>
);
}
}
@@ -3,12 +3,11 @@ 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';
@@ -27,12 +26,16 @@ import {DetailedPayloadTable, ParseForDisplay} from "../Payloads/DetailedPayload
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!) {
@@ -183,11 +186,15 @@ 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([]);
@@ -264,7 +271,13 @@ export function DetailedCallbackTable(props){
onCompleted: data => {
const commandState = data.callback_by_pk.loadedcommands.map( (c) =>
{
return {cmd: c.command.cmd, mythic: c.command.version, payload: c.version, payload_type: c.command.payloadtype.name}
return {
cmd: c.command.cmd,
command_id: c.command.id,
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) =>
@@ -334,18 +347,11 @@ export function DetailedCallbackTable(props){
<React.Fragment>
<DialogTitle id="form-dialog-title">Callback Configuration</DialogTitle>
<DialogContent dividers={true}>
<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>
<MythicSectionHeader title="Callback Information" sx={{mt: 0}} />
<ExpandedCallbackSideDetailsTable {...data.callback_by_pk} />
<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>
<MythicSectionHeader title="Payload Information" />
<TableContainer className="mythicElement">
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
<TableHead>
<TableRow hover>
@@ -391,9 +397,9 @@ export function DetailedCallbackTable(props){
<TableRow hover>
<TableCell>Download URL</TableCell>
<TableCell style={{display: "flex", alignItems: "center"}}>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}>
{window.location.origin + "/direct/download/" + data.callback_by_pk.payload.filemetum.agent_file_id}
</Link>
</FileDownloadLinkWithAuth>
<MythicStyledTooltip title={"Host Payload Through C2"} >
<PublicIcon color={"info"} style={{marginLeft: "20px", cursor: "pointer"}} onClick={()=>{setOpenHostDialog(true);}} />
</MythicStyledTooltip>
@@ -451,11 +457,9 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
<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>
</TableContainer>
<MythicSectionHeader title="Build Parameters" />
<TableContainer className="mythicElement">
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
<TableHead>
<TableRow>
@@ -491,11 +495,9 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
<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>
</TableContainer>
<MythicSectionHeader title="Build Steps" />
<TableContainer className="mythicElement">
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
<TableHead>
<TableRow>
@@ -521,13 +523,11 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
</TableContainer>
{ c2Profiles.map( (c2) => (
<React.Fragment key={"c2frag" + data.callback_by_pk.payload.id + c2.c2_profile}>
<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>
<MythicSectionHeader title={c2.c2_profile} />
<TableContainer className="mythicElement">
<Table size="small" aria-label="details" style={{"overflowWrap": "break-word"}}>
<TableHead>
<TableRow>
@@ -554,15 +554,16 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
</TableContainer>
</React.Fragment>
))}
<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>
<MythicSectionHeader
title="Loaded Commands"
actions={
<Button variant="outlined" size="small" onClick={()=>{setOpenAddRemoveCommandsDialog(true)}}>Add/Remove Commands</Button>
}
/>
<TableContainer className="mythicElement">
<Table size="small" aria-label="details" style={{"overflowWrap": "break-word"}}>
<TableHead>
<TableRow>
@@ -570,7 +571,7 @@ export function DetailedCallbackTable(props){
<TableCell>Command Name</TableCell>
<TableCell>Mythic Version</TableCell>
<TableCell>Loaded Version</TableCell>
<TableCell style={{width: "5rem"}}>Documentation</TableCell>
<TableCell style={{width: "6.5rem"}}>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -584,10 +585,31 @@ export function DetailedCallbackTable(props){
<TableCell>{cmd.mythic}</TableCell>
<TableCell>{cmd.payload}</TableCell>
<TableCell>
<IconButton variant="contained" target="_blank"
href={"/docs/agents/" + cmd.payload_type + "/commands/" + cmd.cmd}>
<MenuBookIcon />
</IconButton>
<div className="mythic-compact-actions mythic-compact-actions-nowrap">
<MythicStyledTooltip title="Open command documentation">
<IconButton
className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-info"
size="small"
target="_blank"
href={"/docs/agents/" + cmd.payload_type + "/commands/" + cmd.cmd}
>
<MenuBookIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title="Scripting parameters">
<IconButton
className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-info"
size="small"
onClick={() => setOpenScriptDialog({
open: true,
command_id: cmd.command_id,
command_name: cmd.cmd,
})}
>
<PlayCircleFilledTwoToneIcon fontSize="small" />
</IconButton>
</MythicStyledTooltip>
</div>
</TableCell>
</TableRow>
))
@@ -637,7 +659,23 @@ export function DetailedCallbackTable(props){
}
</TableBody>
</Table>
</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: ""})}
/>
}
/>
}
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
@@ -647,4 +685,3 @@ export function DetailedCallbackTable(props){
</React.Fragment>
)
}
@@ -10,9 +10,10 @@ import DialogTitle from '@mui/material/DialogTitle';
import {Button, Link, Typography} from '@mui/material';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import {MythicDialog} from "../../MythicComponents/MythicDialog";
import {PreviewFileMediaDialog} from "../Search/PreviewFileMedia";
import {PreviewFileMediaDialog} from "../../MythicComponents/PreviewFileMedia";
import {faPhotoVideo} from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/react-fontawesome';
import {FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
export function DownloadHistoryDialog(props){
const [history, setHistory] = React.useState([]);
@@ -59,7 +60,7 @@ export function DownloadHistoryDialog(props){
{hist.deleted ? (
<Typography variant="body2" style={{wordBreak: "break-all"}}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</Typography>
) : (
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + hist.agent_file_id}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</Link>
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + hist.agent_file_id}>{hist.full_remote_path_text === "" ? hist.filename_text : hist.full_remote_path_text}</FileDownloadLinkWithAuth>
)
}
</div>
@@ -1,10 +1,15 @@
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 {gql, useMutation} from '@apollo/client';
import { snackActions } from '../../utilities/Snackbar';
import {DragAndDropFileUpload} from './TaskParametersDialogRow';
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicDialogSection
} from '../../MythicComponents/MythicDialogLayout';
const import_callback = gql`
mutation importCallbackConfigMutation($config: jsonb!) {
@@ -35,35 +40,36 @@ export function ImportCallbackConfigDialog(props) {
snackActions.error("Failed to parse configuration")
}
}
const onFileChange = (evt) => {
const onFileChange = (newFile) => {
const reader = new FileReader();
reader.onload = (e) => {
const contents = e.target.result;
setFileValue({name: evt.target.files[0].name, contents: contents});
setFileValue({name: newFile.name, contents: contents});
}
reader.readAsBinaryString(evt.target.files[0]);
reader.readAsBinaryString(newFile);
}
return (
<React.Fragment>
<DialogTitle id="form-dialog-title">Import Callback Config From Other Mythic Server</DialogTitle>
<DialogContent dividers={true}>
Export a callback config from another Mythic server and import it here to interact with that callback from this server.
<br/>
<Button variant="contained" component="label">
{ fileValue.name === "" ? "Select File" : fileValue.name }
<input onChange={onFileChange} type="file" hidden />
</Button>
<MythicDialogBody>
<MythicDialogSection
title="Callback Config File"
description="Upload an exported callback configuration from another Mythic server."
>
<DragAndDropFileUpload value={fileValue} multiple={false} onChange={onFileChange} />
</MythicDialogSection>
</MythicDialogBody>
</DialogContent>
<DialogActions>
<Button variant="contained" onClick={props.onClose} color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
<Button variant="contained" onClick={onCommitSubmit} color="success">
</MythicDialogButton>
<MythicDialogButton intent="primary" onClick={onCommitSubmit}>
Submit
</Button>
</DialogActions>
</MythicDialogButton>
</MythicDialogFooter>
</React.Fragment>
);
}
@@ -1,40 +1,24 @@
import React, {useRef} from 'react';
import { styled } from '@mui/material/styles';
import Button from '@mui/material/Button';
import DialogActions from '@mui/material/DialogActions';
import React from 'react';
import Box from '@mui/material/Box';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
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 Input from '@mui/material/Input';
import OutlinedInput from '@mui/material/OutlinedInput';
import Typography from '@mui/material/Typography';
import {useQuery, gql } from '@apollo/client';
import LinearProgress from '@mui/material/LinearProgress';
import {snackActions} from "../../utilities/Snackbar";
const PREFIX = 'ManuallyAddEdgeDialog';
const classes = {
formControl: `${PREFIX}-formControl`,
selectEmpty: `${PREFIX}-selectEmpty`
};
const Root = styled('div')((
{
theme
}
) => ({
[`& .${classes.formControl}`]: {
margin: theme.spacing(1),
minWidth: 120,
width: "97%"
},
[`& .${classes.selectEmpty}`]: {
marginTop: theme.spacing(2),
}
}));
import {
MythicDialogBody,
MythicDialogButton,
MythicDialogFooter,
MythicDialogGrid,
MythicDialogSection,
MythicFormField
} from "../../MythicComponents/MythicDialogLayout";
const getP2PProfilesAndCallbacks = gql`
query getP2PProfilesAndCallbacks{
@@ -52,112 +36,201 @@ query getP2PProfilesAndCallbacks{
}
}
`;
const CallbackSummary = ({callback, label}) => (
<Box className="mythic-c2-edge-callback-summary">
{label &&
<Typography component="div" className="mythic-c2-edge-summary-label">
{label}
</Typography>
}
<Box className="mythic-c2-edge-summary-main">
<span className="mythic-c2-edge-callback-id">#{callback?.display_id}</span>
<span className="mythic-c2-edge-summary-description">
{callback?.description || "No description"}
</span>
</Box>
</Box>
);
const getDestinationOptions = (profiles, sourceId) => {
const destinations = new Map();
profiles.forEach((profile) => {
profile.callbackc2profiles.forEach((callbackProfile) => {
if(callbackProfile.callback.id === sourceId){return}
const key = callbackProfile.callback.id;
const current = destinations.get(key) || {
callback: callbackProfile.callback,
profiles: [],
};
current.profiles.push(profile);
destinations.set(key, current);
});
});
return Array.from(destinations.values()).sort((a, b) => b.callback.display_id - a.callback.display_id);
};
export function ManuallyAddEdgeDialog(props) {
const [callbackOptions, setCallbackOptions] = React.useState([]);
const [profileOptions, setProfileOptions] = React.useState([]);
const [selectedDestination, setSelectedDestination] = React.useState('');
const [selectedProfile, setSelectedProfile] = React.useState('');
const inputRefC2 = useRef(null);
const inputRefDestination = useRef(null);
const handleChangeProfile = (event) => {
setSelectedProfile(event.target.value);
if(event.target.value === ""){
setCallbackOptions([]);
setSelectedDestination("");
}else{
const cbopts = event.target.value["callbackc2profiles"].filter( (cb) => cb.callback.id !== props.source.id );
setCallbackOptions(cbopts);
if(cbopts.length > 0){
setSelectedDestination(cbopts[0]);
}
}
setSelectedProfile(event.target.value);
};
const handleChangeDestination = (event) => {
setSelectedDestination(event.target.value);
const destination = event.target.value;
setSelectedDestination(destination);
if(destination === ""){
setSelectedProfile("");
return;
}
const availableProfiles = destination.profiles || [];
const profileStillValid = availableProfiles.some((profile) => profile.id === selectedProfile?.id);
if(!profileStillValid){
setSelectedProfile(availableProfiles[0] || "");
}
};
const handleSubmit = () => {
if(selectedDestination === ""){
snackActions.error("Must select a valid destination");
return;
}
if(selectedProfile === ""){
snackActions.error("Must select a valid P2P C2 profile");
return;
}
props.onSubmit(props.source.display_id, selectedProfile, selectedDestination.callback);
props.onClose();
}
const { loading, error } = useQuery(getP2PProfilesAndCallbacks, {
onCompleted: data => {
setProfileOptions([...data.c2profile]);
if(data.c2profile.length > 0){
setSelectedProfile(data.c2profile[0]);
const cbopts = data.c2profile[0]["callbackc2profiles"].filter( (cb) => cb.callback.id !== props.source.id );
setCallbackOptions(cbopts);
if(cbopts.length > 0){
setSelectedDestination(cbopts[0]);
}
const profiles = [...data.c2profile];
const destinations = getDestinationOptions(profiles, props.source.id);
setProfileOptions(profiles);
setCallbackOptions(destinations);
if(destinations.length > 0){
setSelectedDestination(destinations[0]);
setSelectedProfile(destinations[0].profiles[0] || "");
}else{
setSelectedDestination("");
setSelectedProfile("");
}
},
fetchPolicy: "network-only"
});
const availableProfileOptions = selectedDestination === "" ? profileOptions : selectedDestination.profiles || [];
if (loading) {
return <LinearProgress style={{marginTop: "10px"}} />;
return (
<>
<DialogTitle className="mythic-accent-dialog-title">Add P2P Edge</DialogTitle>
<DialogContent dividers={true}>
<LinearProgress />
</DialogContent>
</>
);
}
if (error) {
console.error(error);
return <div>Error! {error.message}</div>;
return (
<>
<DialogTitle className="mythic-accent-dialog-title">Add P2P Edge</DialogTitle>
<DialogContent dividers={true}>
<MythicDialogBody>
<MythicDialogSection title="Unable to load edge options" description={error.message} />
</MythicDialogBody>
</DialogContent>
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>Close</MythicDialogButton>
</MythicDialogFooter>
</>
);
}
return (
<Root>
<DialogTitle >Manually Add Edge From Callback {props.source.display_id}</DialogTitle>
<>
<DialogTitle className="mythic-accent-dialog-title">
<div className="mythic-dialog-title-row">
<div>
<Typography component="div" className="mythic-c2-action-title-text">
Add P2P Edge
</Typography>
<Typography component="div" className="mythic-c2-action-title-subtitle">
Create a manual route from callback {props.source.display_id} through a peer-to-peer C2 profile.
</Typography>
</div>
</div>
</DialogTitle>
<DialogContent dividers={true}>
<React.Fragment>
Manually add an edge from Callback {props.source.display_id} to another callback via a P2P C2 Profile.<br/>
<FormControl className={classes.formControl}>
<InputLabel ref={inputRefC2}>Profile</InputLabel>
<Select
labelId="demo-dialog-select-label-profile"
id="demo-dialog-select"
value={selectedProfile}
onChange={handleChangeProfile}
style={{minWidth: "30%"}}
input={<Input />}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{profileOptions.map( (opt) => (
<MenuItem value={opt} key={"profile:" + opt.id}>{opt.name}</MenuItem>
) )}
</Select>
</FormControl><br/>
<FormControl className={classes.formControl}>
<InputLabel ref={inputRefDestination}>Destination</InputLabel>
<Select
labelId="demo-dialog-select-label-destination"
id="demo-dialog-select-destination"
value={selectedDestination}
onChange={handleChangeDestination}
input={<Input />}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{callbackOptions.map( (opt) => (
<MenuItem value={opt} key={"callback:" + opt.callback.id}>{opt.callback.display_id} ({opt.callback.description})</MenuItem>
) )}
</Select>
</FormControl>
</React.Fragment>
<MythicDialogBody>
<MythicDialogSection
title="Route"
description="Choose the destination callback first, then the P2P C2 profile for that route."
>
<MythicDialogGrid minWidth="15rem" className="mythic-c2-edge-endpoints-grid">
<MythicFormField label="Source callback" className="mythic-c2-edge-source-field">
<CallbackSummary callback={props.source} />
</MythicFormField>
<MythicFormField
label="Destination callback"
required
>
<FormControl fullWidth size="small">
<InputLabel id="manual-c2-edge-destination-label">Destination</InputLabel>
<Select
labelId="manual-c2-edge-destination-label"
id="manual-c2-edge-destination"
value={selectedDestination}
onChange={handleChangeDestination}
input={<OutlinedInput label="Destination" />}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{callbackOptions.map( (opt) => (
<MenuItem value={opt} key={"callback:" + opt.callback.id}>
#{opt.callback.display_id} {opt.callback.description ? `- ${opt.callback.description}` : ""}
</MenuItem>
) )}
</Select>
</FormControl>
</MythicFormField>
</MythicDialogGrid>
<Box className="mythic-c2-edge-profile-row">
<MythicFormField
label="P2P C2 profile"
description="Only profiles available for the selected destination are shown."
required
>
<FormControl fullWidth size="small">
<InputLabel id="manual-c2-edge-profile-label">Profile</InputLabel>
<Select
labelId="manual-c2-edge-profile-label"
id="manual-c2-edge-profile"
value={selectedProfile}
onChange={handleChangeProfile}
input={<OutlinedInput label="Profile" />}
>
<MenuItem value="">
<em>None</em>
</MenuItem>
{availableProfileOptions.map( (opt) => (
<MenuItem value={opt} key={"profile:" + opt.id}>
{opt.name}
</MenuItem>
) )}
</Select>
</FormControl>
</MythicFormField>
</Box>
</MythicDialogSection>
</MythicDialogBody>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
<MythicDialogFooter>
<MythicDialogButton onClick={props.onClose}>
Close
</Button>
<Button disabled={selectedDestination === ""} onClick={handleSubmit} variant="contained" color="success">
Add
</Button>
</DialogActions>
</Root>
</MythicDialogButton>
<MythicDialogButton disabled={selectedDestination === "" || selectedProfile === ""} onClick={handleSubmit} intent="primary">
Add Edge
</MythicDialogButton>
</MythicDialogFooter>
</>
);
}
@@ -2,6 +2,7 @@ import React from 'react';
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 {useQuery, gql, useMutation} from '@apollo/client';
import FormControl from '@mui/material/FormControl';
@@ -122,6 +123,7 @@ export function ModifyCallbackMythicTreeGroupsDialog(props){
<b>Note:</b> Having <b>no</b> group entries will hide all information from this callback from your FileBrowser and ProcessBrowser views.
</div>
<DialogContent dividers={true}>
<TableContainer className="mythicElement">
<Table size="small" aria-label="details" style={{ "overflowWrap": "break-word"}}>
<TableHead>
</TableHead>
@@ -149,7 +151,7 @@ export function ModifyCallbackMythicTreeGroupsDialog(props){
{groups.map( (a, i) => (
<TableRow key={'array' + props.name + i} >
<MythicStyledTableCell style={{width: "2rem", paddingLeft:"0"}}>
<IconButton onClick={(e) => {removeArrayValue(i)}} size="large"><DeleteIcon color="error" /> </IconButton>
<IconButton className="mythic-compact-icon-action mythic-action-tone-hover mythic-tone-error" size="small" onClick={(e) => {removeArrayValue(i)}}><DeleteIcon fontSize="small" /> </IconButton>
</MythicStyledTableCell>
<MythicStyledTableCell>
<MythicTextField required={props.required} fullWidth={true} placeholder={""} value={a} multiline={true} autoFocus={ i > 0}
@@ -167,6 +169,7 @@ export function ModifyCallbackMythicTreeGroupsDialog(props){
</TableBody>
</Table>
</TableContainer>
</DialogContent>
<DialogActions>
<Button onClick={props.onClose} variant="contained" color="primary">
@@ -190,4 +193,3 @@ export function ModifyCallbackMythicTreeGroupsDialog(props){
</React.Fragment>
)
}
@@ -1,11 +1,8 @@
import React, {useEffect} from 'react';
import {gql, useLazyQuery, useSubscription} from '@apollo/client';
import {snackActions} from '../../utilities/Snackbar';
import {ResponseDisplayScreenshot} from './ResponseDisplayScreenshot';
import {ResponseDisplayPlaintext} from './ResponseDisplayPlaintext';
import {ResponseDisplayTable} from './ResponseDisplayTable';
import {ResponseDisplayDownload} from './ResponseDisplayDownload';
import {ResponseDisplaySearch} from './ResponseDisplaySearch';
import MythicTextField from '../../MythicComponents/MythicTextField';
import SearchIcon from '@mui/icons-material/Search';
import {useTheme} from '@mui/material/styles';
@@ -18,6 +15,7 @@ import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import {ResponseDisplayGraph} from "./ResponseDisplayGraph";
import {operatorSettingDefaults} from "../../../cache";
import {ResponseDisplayTabs} from "./ResponseDisplayTabs";
import {MythicEmptyState, MythicLoadingState, MythicSearchEmptyState} from "../../MythicComponents/MythicStateDisplay";
const subResponsesStream = gql`
subscription subResponsesStream($task_id: Int!){
@@ -33,32 +31,38 @@ subscription subResponsesStream($task_id: Int!){
}
`;
const getResponsesLazyQuery = gql`
query subResponsesQuery($task_id: Int!, $fetchLimit: Int, $offset: Int!, $search: String!) {
response(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}, limit: $fetchLimit, offset: $offset, order_by: {id: asc}) {
query subResponsesQuery($task_id: Int!, $fetchLimit: Int, $offset: Int!, $where: response_bool_exp!) {
response(where: $where, limit: $fetchLimit, offset: $offset, order_by: {id: asc}) {
id
response: response_text
timestamp
is_error
}
response_aggregate(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}){
response_aggregate(where: $where){
aggregate{
count
}
}
latest_response: response(where: {task_id: {_eq: $task_id}}, limit: 1, order_by: {id: desc}) {
id
}
}`;
const getAllResponsesLazyQuery = gql`
query subResponsesQuery($task_id: Int!, $search: String!) {
response(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}, order_by: {id: asc}) {
query subResponsesQuery($task_id: Int!, $where: response_bool_exp!, $fetchLimit: Int) {
response(where: $where, limit: $fetchLimit, order_by: {id: asc}) {
id
response: response_text
timestamp
is_error
}
response_aggregate(where: {task_id: {_eq: $task_id}, response_escape: {_ilike: $search}}){
response_aggregate(where: $where){
aggregate{
count
}
}
latest_response: response(where: {task_id: {_eq: $task_id}}, limit: 1, order_by: {id: desc}) {
id
}
}`;
const taskScript = gql`
query getBrowserScriptsQuery($command_id: Int!){
@@ -93,6 +97,45 @@ export function b64DecodeUnicode(str) {
}
}
}
const MAX_SELECT_ALL_RESPONSES = 5000;
const DEFAULT_RESPONSE_PAGE_SIZE = operatorSettingDefaults["experiment-responseStreamLimit"] || 200;
const normalizeResponsePageSize = (pageSize) => {
const parsed = Number(pageSize);
if(!Number.isFinite(parsed) || parsed <= 0){
return DEFAULT_RESPONSE_PAGE_SIZE;
}
return Math.min(parsed, MAX_SELECT_ALL_RESPONSES);
}
const responseWhereClause = (taskID, search) => {
if(search === undefined || search === ""){
return {task_id: {_eq: taskID}};
}
return {task_id: {_eq: taskID}, response_escape: {_ilike: "%" + search + "%"}};
}
const decodeResponse = (response) => {
return {...response, response: b64DecodeUnicode(response.response)};
}
const responsesToOutput = (responses) => {
return responses.reduce( (prev, cur) => {
return prev + cur.response;
}, "");
}
const mergeResponsesById = ({existingResponses, incomingResponses, limit, keepNewest=false}) => {
const responseMap = new Map();
existingResponses.forEach( (response) => responseMap.set(response.id, response));
incomingResponses.forEach( (response) => responseMap.set(response.id, response));
const mergedResponses = Array.from(responseMap.values()).sort( (a,b) => a.id > b.id ? 1 : -1);
if(limit === undefined || mergedResponses.length <= limit){
return mergedResponses;
}
return keepNewest ? mergedResponses.slice(-limit) : mergedResponses.slice(0, limit);
}
const getAggregateCount = (data) => {
return data?.response_aggregate?.aggregate?.count || 0;
}
const getLatestResponseID = (data) => {
return data?.latest_response?.[0]?.id || 0;
}
export const ResponseDisplay = (props) =>{
const interactive = props?.task?.command?.supported_ui_features.includes("task_response:interactive") || false;
return (
@@ -105,34 +148,50 @@ export const ResponseDisplay = (props) =>{
}
const NonInteractiveResponseDisplay = (props) => {
const theme = useTheme();
const [output, setOutput] = React.useState("");
const [rawResponses, setRawResponses] = React.useState([]);
const seenResponseIDs = React.useRef([]);
const search = React.useRef("");
const rawResponsesRef = React.useRef([]);
const [search, setSearch] = React.useState("");
const [totalCount, setTotalCount] = React.useState(0);
const totalCountRef = React.useRef(0);
const [openBackdrop, setOpenBackdrop] = React.useState(true);
const togglingAllOutputToPaginated = React.useRef(false);
const initialResponseStreamLimit = GetMythicSetting({setting_name: "experiment-responseStreamLimit", default_value: operatorSettingDefaults["experiment-responseStreamLimit"]});
const pageSize = normalizeResponsePageSize(initialResponseStreamLimit);
const [currentPage, setCurrentPage] = React.useState(1);
const currentPageRef = React.useRef(1);
const highestKnownResponseID = React.useRef(0);
const pageDataLoaded = React.useRef(false);
const taskResponseCountRef = React.useRef(props.task.response_count || 0);
const selectAllWarningShown = React.useRef(false);
const output = React.useMemo(() => responsesToOutput(rawResponses), [rawResponses]);
React.useEffect( () => {
rawResponsesRef.current = rawResponses;
}, [rawResponses]);
const setTrackedTotalCount = React.useCallback( (newTotalCount) => {
totalCountRef.current = newTotalCount;
setTotalCount(newTotalCount);
}, []);
const warnSelectAllCapped = React.useCallback( (loadedCount, aggregateCount) => {
if(aggregateCount > loadedCount && !selectAllWarningShown.current){
snackActions.warning(`Task output is too large to load all at once. Showing the first ${loadedCount} responses; use pagination or download to view the rest.`);
selectAllWarningShown.current = true;
}
}, []);
const [fetchMoreResponses] = useLazyQuery(getResponsesLazyQuery, {
fetchPolicy: "network-only",
fetchPolicy: "no-cache",
onCompleted: (data) => {
data.response.forEach( (r) => {
if(!seenResponseIDs.current.includes(r.id)){
seenResponseIDs.current.push(r.id);
}
})
// set raw responses to be what we just manually fetched
const responseArray = data.response.map( r =>{ return {...r, response: b64DecodeUnicode(r.response)}});
setRawResponses(responseArray);
const responses = responseArray.reduce( (prev, cur) => {
return prev + cur.response;
}, b64DecodeUnicode(""));
setOutput(responses);
// update maxID
if(!props.selectAllOutput){
setTotalCount(data.response_aggregate.aggregate.count);
}
const responseArray = data.response.map(decodeResponse);
const shouldPreserveStreamedResponses = search === "" && currentPageRef.current === 1;
const nextResponses = shouldPreserveStreamedResponses ? mergeResponsesById({
existingResponses: rawResponsesRef.current,
incomingResponses: responseArray,
limit: pageSize,
}) : responseArray;
rawResponsesRef.current = nextResponses;
setRawResponses(nextResponses);
const aggregateCount = Math.max(getAggregateCount(data), shouldPreserveStreamedResponses ? nextResponses.length : 0);
setTrackedTotalCount(aggregateCount);
highestKnownResponseID.current = Math.max(highestKnownResponseID.current, getLatestResponseID(data));
pageDataLoaded.current = true;
setOpenBackdrop(false);
},
onError: (data) => {
@@ -140,185 +199,203 @@ const NonInteractiveResponseDisplay = (props) => {
}
});
const [fetchAllResponses] = useLazyQuery(getAllResponsesLazyQuery, {
fetchPolicy: "network-only",
fetchPolicy: "no-cache",
onCompleted: (data) => {
const responseArray = data.response.map( r =>{ return {...r, response: b64DecodeUnicode(r.response)}});
setRawResponses(responseArray);
const responses = responseArray.reduce( (prev, cur) => {
return prev + cur.response;
}, b64DecodeUnicode(""));
setOutput(responses);
setTotalCount(1);
const responseArray = data.response.map(decodeResponse);
const nextResponses = search === "" ? mergeResponsesById({
existingResponses: rawResponsesRef.current,
incomingResponses: responseArray,
limit: MAX_SELECT_ALL_RESPONSES,
}) : responseArray;
rawResponsesRef.current = nextResponses;
setRawResponses(nextResponses);
const aggregateCount = Math.max(getAggregateCount(data), search === "" ? nextResponses.length : 0);
setTrackedTotalCount(aggregateCount);
highestKnownResponseID.current = Math.max(highestKnownResponseID.current, getLatestResponseID(data));
pageDataLoaded.current = true;
currentPageRef.current = 1;
setCurrentPage(1);
warnSelectAllCapped(nextResponses.length, aggregateCount);
setOpenBackdrop(false);
togglingAllOutputToPaginated.current = true;
},
onError: (data) => {
snackActions.error("Failed to fetch all responses: " + data)
}
});
React.useEffect( () => {
//if(props.selectAllOutput !== oldSelectAllOutput.current){
//oldSelectAllOutput.current = props.selectAllOutput;
if(props.selectAllOutput){
setOpenBackdrop(true);
if(search.current === ""){
fetchAllResponses({variables: {task_id: props.task.id, search: "%%"}})
}else{
fetchAllResponses({variables: {task_id: props.task.id, search: "%" + search.current + "%"}})
}
}else if(togglingAllOutputToPaginated.current){
// going from select all output to not select all output
// don't fetch this on first load
onSubmitPageChange(1);
togglingAllOutputToPaginated.current = false;
}
//}
}, [props.selectAllOutput, togglingAllOutputToPaginated.current]);
const fetchResponsePage = React.useCallback( (requestedPage, showBackdrop=true) => {
const nextPage = Math.max(1, requestedPage || 1);
currentPageRef.current = nextPage;
setCurrentPage(nextPage);
if(showBackdrop){
setOpenBackdrop(true);
}
fetchMoreResponses({variables: {task_id: props.task.id,
fetchLimit: pageSize,
offset: pageSize * (nextPage - 1),
where: responseWhereClause(props.task.id, search)
}})
}, [fetchMoreResponses, pageSize, props.task.id, search]);
const fetchSelectAllResponses = React.useCallback( () => {
currentPageRef.current = 1;
setCurrentPage(1);
setOpenBackdrop(true);
fetchAllResponses({variables: {
task_id: props.task.id,
where: responseWhereClause(props.task.id, search),
fetchLimit: MAX_SELECT_ALL_RESPONSES
}});
}, [fetchAllResponses, props.task.id, search]);
React.useEffect( () => {
setOpenBackdrop(true);
setOutput("");
rawResponsesRef.current = [];
setRawResponses([]);
setTotalCount(0);
//onSubmitPageChange(1);
setTrackedTotalCount(0);
currentPageRef.current = 1;
setCurrentPage(1);
highestKnownResponseID.current = 0;
pageDataLoaded.current = false;
taskResponseCountRef.current = props.task.response_count || 0;
selectAllWarningShown.current = false;
}, [props.task.id]);
setTimeout(() => {
// close the backdrop after 2 seconds in case there's no data to fetch
setOpenBackdrop(false);
}, 1000);
const subscriptionDataCallback = ({data}) => {
//console.log("fetchLimit", fetchLimit, "totalCount", totalCount);
if(rawResponses.length >= initialResponseStreamLimit && initialResponseStreamLimit > 0 && !props.selectAllOutput){
// we won't display it
console.log("got more than we can see currently", totalCount);
setOpenBackdrop(false);
let newTotal = totalCount;
data.data.response_stream.forEach( (r) => {
if(!seenResponseIDs.current.includes(r.id)){
newTotal += 1;
seenResponseIDs.current.push(r.id);
}
})
setTotalCount(newTotal);
React.useEffect( () => {
if(props.selectAllOutput){
fetchSelectAllResponses();
}else{
fetchResponsePage(1);
}
}, [props.selectAllOutput, props.task.id, search, fetchResponsePage, fetchSelectAllResponses]);
React.useEffect( () => {
if(!openBackdrop){
return;
}
const timeoutID = setTimeout(() => {
setOpenBackdrop(false);
}, 1000);
return () => clearTimeout(timeoutID);
}, [openBackdrop]);
React.useEffect( () => {
const nextResponseCount = props.task.response_count || 0;
const previousTaskResponseCount = taskResponseCountRef.current;
const previousKnownResponseCount = Math.max(totalCountRef.current, previousTaskResponseCount);
taskResponseCountRef.current = nextResponseCount;
if(search !== ""){
return;
}
if(nextResponseCount > totalCountRef.current){
setTrackedTotalCount(nextResponseCount);
}
if(nextResponseCount <= previousKnownResponseCount){
return;
}
const pageStartIndex = (currentPageRef.current - 1) * pageSize;
const pageEndIndex = currentPageRef.current * pageSize;
const currentPageCouldContainNewResponses = previousKnownResponseCount < pageEndIndex &&
nextResponseCount > pageStartIndex;
if(props.selectAllOutput){
fetchSelectAllResponses();
}else if(rawResponsesRef.current.length === 0 || currentPageCouldContainNewResponses){
fetchResponsePage(currentPageRef.current, false);
}
}, [fetchResponsePage, fetchSelectAllResponses, pageSize, props.selectAllOutput, props.task.response_count, search, setTrackedTotalCount]);
const subscriptionDataCallback = React.useCallback( ({data}) => {
const streamedResponses = data?.data?.response_stream || [];
if(streamedResponses.length === 0){
return;
}
const previousHighestID = highestKnownResponseID.current;
const newStreamedResponses = streamedResponses.filter( (response) => response.id > previousHighestID);
if(newStreamedResponses.length === 0){
return;
}
highestKnownResponseID.current = Math.max(previousHighestID, ...newStreamedResponses.map( (response) => response.id));
if(search !== ""){
const lowerCaseSearch = search.toLowerCase();
const matchingResponseStreamed = newStreamedResponses.some( (response) => {
return b64DecodeUnicode(response.response).toLowerCase().includes(lowerCaseSearch);
});
if(matchingResponseStreamed){
fetchResponsePage(currentPageRef.current, false);
}
return;
}
const currentTotalCount = Math.max(totalCountRef.current, taskResponseCountRef.current);
if(props.selectAllOutput){
const previousResponses = rawResponsesRef.current;
if(previousResponses.length >= MAX_SELECT_ALL_RESPONSES){
warnSelectAllCapped(previousResponses.length, currentTotalCount);
return;
}
// we still have some room to view more, but only room for initialResponseStreamLimit - totalFetched.current
let newTotal = totalCount;
const newerResponses = data.data.response_stream.reduce( (prev, cur) => {
if(!seenResponseIDs.current.includes(cur.id)){
newTotal += 1;
seenResponseIDs.current.push(cur.id);
}
let prevIndex = prev.findIndex( (v,i,a) => v.id === cur.id);
if(prevIndex >= 0){
prev[prevIndex] = {...cur, response: b64DecodeUnicode(cur.response)};
return prev;
}
return [...prev, {...cur, response: b64DecodeUnicode(cur.response)}]
}, rawResponses);
// sort them to make sure we're still in order
newerResponses.sort( (a,b) => a.id > b.id ? 1 : -1);
setTotalCount(newTotal);
// newerResponses is everything we've seen plus everything new
if(initialResponseStreamLimit > 0 && !props.selectAllOutput){
// take just the responses that make up our stream limit
const finalRawResponses = newerResponses.slice(0, initialResponseStreamLimit);
const outputResponses = finalRawResponses.reduce( (prev, cur) => {
return prev + cur.response;
}, b64DecodeUnicode(""));
if(finalRawResponses.length !== rawResponses.length){
setRawResponses(finalRawResponses);
setOutput(outputResponses);
}
} else {
setRawResponses(newerResponses);
const outputResponses = newerResponses.reduce( (prev, cur) => {
return prev + cur.response;
}, b64DecodeUnicode(""));
setOutput(outputResponses);
const remainingResponseSlots = MAX_SELECT_ALL_RESPONSES - previousResponses.length;
const decodedResponses = newStreamedResponses.slice(0, remainingResponseSlots).map(decodeResponse);
const mergedResponses = mergeResponsesById({
existingResponses: previousResponses,
incomingResponses: decodedResponses,
limit: MAX_SELECT_ALL_RESPONSES,
});
rawResponsesRef.current = mergedResponses;
setRawResponses(mergedResponses);
warnSelectAllCapped(mergedResponses.length, currentTotalCount);
return;
}
const pageStartIndex = (currentPageRef.current - 1) * pageSize;
const pageEndIndex = currentPageRef.current * pageSize;
const streamedStartIndex = Math.max(0, currentTotalCount - newStreamedResponses.length);
if(streamedStartIndex >= pageEndIndex || currentTotalCount <= pageStartIndex){
return;
}
const visibleStreamedResponses = newStreamedResponses.reduce( (responses, response, index) => {
const responseIndex = streamedStartIndex + index;
if(responseIndex >= pageStartIndex && responseIndex < pageEndIndex){
return [...responses, decodeResponse(response)];
}
setOpenBackdrop(false);
};
return responses;
}, []);
if(visibleStreamedResponses.length === 0){
return;
}
const mergedResponses = mergeResponsesById({
existingResponses: rawResponsesRef.current,
incomingResponses: visibleStreamedResponses,
limit: pageSize,
});
rawResponsesRef.current = mergedResponses;
setRawResponses(mergedResponses);
}, [fetchResponsePage, pageSize, props.selectAllOutput, search, warnSelectAllCapped]);
useSubscription(subResponsesStream, {
variables: {task_id: props.task.id},
fetchPolicy: "network_only",
fetchPolicy: "no-cache",
onData: subscriptionDataCallback
});
const onSubmitPageChange = (currentPage) => {
//console.log("onSubmitPageChange")
if(search.current === undefined || search.current === ""){
fetchMoreResponses({variables: {task_id: props.task.id,
fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit,
offset: initialResponseStreamLimit * (currentPage - 1),
search: "%_%"
}})
}else{
fetchMoreResponses({variables: {task_id: props.task.id,
fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit,
offset: initialResponseStreamLimit * (currentPage - 1),
search: "%" + search.current + "%"
}})
}
const onSubmitPageChange = (nextPage) => {
fetchResponsePage(nextPage);
}
const onSubmitSearch = React.useCallback( (newSearch) => {
search.current = newSearch;
//console.log("onSubmitSearch")
//setOpenBackdrop(true);
if(newSearch === undefined || newSearch === ""){
if(props.selectAllOutput){
fetchAllResponses({variables: {task_id: props.task.id, search: "%%"}})
}else{
fetchMoreResponses({variables: {task_id: props.task.id,
fetchLimit: initialResponseStreamLimit === 0 ? undefined : initialResponseStreamLimit,
offset: 0,
search: "%_%"
}})
}
}else{
if(props.selectAllOutput){
fetchAllResponses({variables: {task_id: props.task.id, search: "%" + newSearch + "%"}})
}else{
fetchMoreResponses({variables: {task_id: props.task.id,
fetchLimit: initialResponseStreamLimit,
offset: 0,
search: "%" + newSearch + "%"
}})
}
}
}, [search.current]);
selectAllWarningShown.current = false;
currentPageRef.current = 1;
setCurrentPage(1);
setSearch(newSearch || "");
}, []);
return (
<>
<div style={{display: "flex", flexDirection: "column", height: "100%", width: "100%", position: "relative",
backgroundColor: theme.outputBackgroundColor + (theme.palette.mode === 'dark' ? "D0" : "D0"),
color: theme.outputTextColor,
}}>
<Backdrop open={openBackdrop} onClick={()=>{setOpenBackdrop(false);}} style={{zIndex: 2, position: "absolute"}}>
<CircularProgress color="inherit" disableShrink />
</Backdrop>
{!openBackdrop &&
<div style={{display: "flex", flexDirection: "column", height: "100%", width: "100%",
backgroundColor: theme.outputBackgroundColor + (theme.palette.mode === 'dark' ? "D0" : "D0"),
color: theme.outputTextColor,
}}>
{props.searchOutput &&
<SearchBar onSubmitSearch={onSubmitSearch} />
}
<div style={{overflowY: "auto", flexGrow: 1, width: "100%", height: props.expand ? "100%": undefined, display: "flex", flexDirection: "column"}} ref={props.responseRef}>
<ResponseDisplayComponent rawResponses={rawResponses} viewBrowserScript={props.viewBrowserScript}
output={output} command_id={props.command_id} displayType={"accordion"}
task={props.task} search={search.current} expand={props.expand}/>
</div>
<PaginationBar selectAllOutput={props.selectAllOutput} totalCount={totalCount} pageSize={initialResponseStreamLimit}
onSubmitPageChange={onSubmitPageChange} task={props.task} search={search.current} />
</div>
{props.searchOutput &&
<SearchBar onSubmitSearch={onSubmitSearch} />
}
</>
<div style={{overflowY: "auto", overflowX: "hidden", flexGrow: 1, minWidth: 0, width: "100%", height: props.expand ? "100%": undefined, display: "flex", flexDirection: "column"}} ref={props.responseRef}>
<ResponseDisplayComponent rawResponses={rawResponses} viewBrowserScript={props.viewBrowserScript}
output={output} command_id={props.command_id} displayType={"accordion"}
task={props.task} search={search} expand={props.expand}/>
</div>
<PaginationBar selectAllOutput={props.selectAllOutput} totalCount={totalCount} pageSize={pageSize}
onSubmitPageChange={onSubmitPageChange} task={props.task} search={search}
currentPage={currentPage} />
</div>
)
}
export const ResponseDisplayConsole = (props) => {
@@ -332,47 +409,51 @@ export const ResponseDisplayConsole = (props) => {
)
}
export const NonInteractiveResponseDisplayConsole = (props) => {
const [output, setOutput] = React.useState("");
const [rawResponses, setRawResponses] = React.useState([]);
const taskID = React.useRef(props.task.id);
const rawResponsesRef = React.useRef([]);
const initialResponseStreamLimit = GetMythicSetting({setting_name: "experiment-responseStreamLimit", default_value: operatorSettingDefaults["experiment-responseStreamLimit"]});
const responseWindowSize = normalizeResponsePageSize(initialResponseStreamLimit);
const output = React.useMemo(() => responsesToOutput(rawResponses), [rawResponses]);
const consoleWindowWarningShown = React.useRef(false);
React.useEffect( () => {
rawResponsesRef.current = rawResponses;
}, [rawResponses]);
React.useEffect( () => {
rawResponsesRef.current = [];
setRawResponses([]);
consoleWindowWarningShown.current = false;
}, [props.task.id]);
const subscriptionDataCallback = React.useCallback( ({data}) => {
//console.log("fetchLimit", fetchLimit, "totalCount", totalCount);
if(props.task.id !== taskID.current){
taskID.current = props.task.id;
const responseArray = data.data.response_stream.map( r =>{ return {...r, response: b64DecodeUnicode(r.response)}});
const responses = responseArray.reduce( (prev, cur) => {
return prev + cur.response;
}, b64DecodeUnicode(""));
setOutput(responses);
setRawResponses(responseArray);
} else {
// we still have some room to view more, but only room for initialResponseStreamLimit - totalFetched.current
const newerResponses = data.data.response_stream.reduce( (prev, cur) => {
let prevIndex = prev.findIndex( (v,i,a) => v.id === cur.id);
if(prevIndex >= 0){
prev[prevIndex] = {...cur, response: b64DecodeUnicode(cur.response)};
return prev;
}
return [...prev, {...cur, response: b64DecodeUnicode(cur.response)}]
}, rawResponses);
// sort them to make sure we're still in order
newerResponses.sort( (a,b) => a.id > b.id ? 1 : -1);
setRawResponses(newerResponses);
const outputResponses = newerResponses.reduce( (prev, cur) => {
return prev + cur.response;
}, b64DecodeUnicode(""));
setOutput(outputResponses);
const streamedResponses = data?.data?.response_stream || [];
if(streamedResponses.length === 0){
return;
}
}, [output, rawResponses, props.task.id]);
const decodedResponses = streamedResponses.map(decodeResponse);
const previousResponses = rawResponsesRef.current;
const mergedResponses = mergeResponsesById({
existingResponses: previousResponses,
incomingResponses: decodedResponses,
limit: responseWindowSize,
keepNewest: true,
});
rawResponsesRef.current = mergedResponses;
setRawResponses(mergedResponses);
if(mergedResponses.length === responseWindowSize &&
previousResponses.length + decodedResponses.length > responseWindowSize &&
!consoleWindowWarningShown.current){
consoleWindowWarningShown.current = true;
snackActions.warning(`Console output is showing the latest ${responseWindowSize} responses. Older output is available through paginated task output or download.`);
}
}, [responseWindowSize]);
useSubscription(subResponsesStream, {
variables: {task_id: props.task.id},
fetchPolicy: "network_only",
fetchPolicy: "no-cache",
onData: subscriptionDataCallback
});
return (
<React.Fragment>
<div style={{overflowY: "auto", width: "100%", height: props.expand ? "100%" : undefined}}
<div style={{overflowY: "auto", overflowX: "hidden", minWidth: 0, width: "100%", height: props.expand ? "100%" : undefined}}
ref={props.responseRef}>
<ResponseDisplayComponent rawResponses={rawResponses} viewBrowserScript={props.viewBrowserScript}
output={output} command_id={props.command_id} displayType={"console"}
@@ -383,32 +464,17 @@ export const NonInteractiveResponseDisplayConsole = (props) => {
)
}
export const PaginationBar = ({selectAllOutput, totalCount, onSubmitPageChange, task, search, pageSize}) => {
export const PaginationBar = ({selectAllOutput, totalCount, onSubmitPageChange, task, search, pageSize, currentPage}) => {
const [localTotalCount, setTotalcount] = React.useState(0);
const [maxCount, setMaxCount] = React.useState(0);
const [currentPage, setCurrentPage] = React.useState(1);
const onChangePage = (event, value) => {
setCurrentPage(value);
onSubmitPageChange(value);
};
React.useEffect( () => {
if(maxCount !== task.response_count){
setMaxCount(task.response_count);
}
}, [task.response_count]);
React.useEffect( () => {
if(selectAllOutput){
setTotalcount(1);
setCurrentPage(1);
}else if(totalCount === 0) {
setTotalcount(maxCount);
}else{
setTotalcount(totalCount);
}
}, [totalCount, maxCount, search, selectAllOutput]);
setTotalcount(totalCount);
}, [totalCount, search, selectAllOutput]);
const pageCount = Math.max(1, Math.ceil(localTotalCount / pageSize));
// don't bother people with pagination information if they haven't even started paginating
if(pageCount < 2 || pageCount === Infinity || isNaN(pageCount)){
if(selectAllOutput || pageCount < 2 || pageCount === Infinity || isNaN(pageCount)){
return (<div id={'scrolltotaskbottom' + task.id}></div>)
}
return (
@@ -446,7 +512,7 @@ export const SearchBar = ({onSubmitSearch}) => {
const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, command_id, task, search, expand, displayType}) => {
const [localViewBrowserScript, setViewBrowserScript] = React.useState(true);
const [browserScriptData, setBrowserScriptData] = React.useState({});
const [browserScriptData, setBrowserScriptData] = React.useState(undefined);
const [loadingBrowserScript, setLoadingBrowserScript] = React.useState(true);
const script = React.useRef(undefined);
const filterOutput = (scriptData) => {
@@ -492,6 +558,7 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
}
if(script.current === undefined){
setViewBrowserScript(false);
setBrowserScriptData({});
return;
}
if(viewBrowserScript){
@@ -499,8 +566,7 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
const rawResponseData = rawResponses.map(c => c.response);
let res = script.current(task, rawResponseData);
if(Object.keys(res).length === 0){
// if we run the browser script and there's no data, just display plaintext
setViewBrowserScript(false);
setBrowserScriptData({});
return;
}
setViewBrowserScript(viewBrowserScript);
@@ -513,7 +579,7 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
}
}
} else {
setViewBrowserScript(false);
return;
}
}, [rawResponses, task, loadingBrowserScript, viewBrowserScript]);
const [fetchScripts] = useLazyQuery(taskScript, {
@@ -522,8 +588,10 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
if(data.browserscript.length > 0){
try{
script.current = Function(`"use strict";return(${data.browserscript[0]["script"]})`)();
setBrowserScriptData(undefined);
}catch(error){
script.current = undefined;
setBrowserScriptData({});
console.log(error);
}
}else{
@@ -539,33 +607,70 @@ const ResponseDisplayComponent = ({rawResponses, viewBrowserScript, output, comm
useEffect( () => {
if(command_id !== undefined){
setLoadingBrowserScript(true);
setBrowserScriptData(undefined);
setViewBrowserScript(true);
fetchScripts({variables: {command_id: command_id}});
}
}, [command_id, task.id]);
const stateSx = {
backgroundColor: "transparent",
color: "inherit",
height: expand ? "100%" : undefined,
};
if(loadingBrowserScript){
return null
return (
<MythicLoadingState
compact
title="Preparing output"
description="Loading the response renderer for this command."
sx={stateSx}
/>
)
}
if(rawResponses.length === 0 && output.length === 0){
return search ? (
<MythicSearchEmptyState
compact
description="No task output matched the current search."
sx={stateSx}
/>
) : (
<MythicEmptyState
compact
title="No output yet"
description="Task output will appear here as responses arrive."
sx={stateSx}
/>
)
}
const shouldUseBrowserScript = viewBrowserScript && localViewBrowserScript;
if(shouldUseBrowserScript && browserScriptData === undefined){
return (
<MythicLoadingState
compact
title="Rendering output"
description="Preparing the browser script view."
sx={stateSx}
/>
)
}
return (
localViewBrowserScript ? (
shouldUseBrowserScript ? (
<ResponseDisplayBrowserScriptComponent expand={expand} displayType={displayType}
task={task} browserScriptData={browserScriptData}
output={output}/>
output={output} allowPlaintextFallback={false}/>
) : (
<ResponseDisplayPlaintext plaintext={output} task={task} expand={expand} displayType={displayType}/>
)
)
}
export function ResponseDisplayBrowserScriptComponent({output, browserScriptData, task, expand, displayType}) {
export function ResponseDisplayBrowserScriptComponent({output, browserScriptData, task, expand, displayType, allowPlaintextFallback=true}) {
const hasBrowserScriptData = browserScriptData && Object.keys(browserScriptData).length > 0;
return (
<>
{Object.keys(browserScriptData).length > 0 ? (
<div className={`mythic-browser-script-response${expand ? " mythic-browser-script-response-expanded" : ""}`}>
{hasBrowserScriptData ? (
<>
{browserScriptData?.screenshot?.map( (scr, index) => (
<ResponseDisplayScreenshot key={"screenshot" + index + 'fortask' + task.id} task={task} {...scr}
displayType={displayType} expand={expand} />
))
}
{browserScriptData?.plaintext !== undefined &&
<ResponseDisplayPlaintext plaintext={browserScriptData["plaintext"]} task={task}
expand={expand} displayType={displayType} />
@@ -577,16 +682,6 @@ export function ResponseDisplayBrowserScriptComponent({output, browserScriptData
/>
))
}
{browserScriptData?.download?.map( (dl, index) => (
<ResponseDisplayDownload download={dl} task={task} displayType={displayType}
key={"download" + index + "fortask" + task.id} />
))
}
{browserScriptData?.search?.map( (s, index) => (
<ResponseDisplaySearch search={s} task={task} displayType={displayType}
key={"searchlink" + index + "fortask" + task.id} />
))
}
{browserScriptData?.media?.map( (s, index) => (
<ResponseDisplayMedia key={"searchmedia" + index + "fortask" + task.id}
displayType={displayType}
@@ -601,9 +696,11 @@ export function ResponseDisplayBrowserScriptComponent({output, browserScriptData
}
</>
) : (
<ResponseDisplayPlaintext plaintext={output} task={task} expand={expand} displayType={displayType}/>
allowPlaintextFallback ? (
<ResponseDisplayPlaintext plaintext={output} task={task} expand={expand} displayType={displayType}/>
) : null
)
}
</>
</div>
)
}
}
@@ -1,33 +0,0 @@
import React from 'react';
import GetAppIcon from '@mui/icons-material/GetApp';
import { Button } from '@mui/material';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
export const ResponseDisplayDownload = (props) =>{
const scrollContent = (node, isAppearing) => {
// only auto-scroll if you issued the task
document.getElementById(`scrolltotaskbottom${props.task.id}`)?.scrollIntoView({
//behavior: "smooth",
block: "end",
inline: "nearest"
})
}
React.useLayoutEffect( () => {
scrollContent()
}, []);
return (
<>
<pre style={{display: "inline-block"}}>
{props.download?.plaintext || ""}
</pre>
<MythicStyledTooltip title={props?.download?.hoverText || "Download payload"} >
<Button variant={props.download?.variant || "contained"} component="a" target="_blank" color="primary" href={"/api/v1.4/files/download/" + props.download.agent_file_id} download
startIcon={<GetAppIcon />}>
{props.download?.name || ""}
</Button>
</MythicStyledTooltip><br/>
</>
);
}
@@ -78,12 +78,15 @@ export const ResponseDisplayGraph = ({graph, task, expand}) =>{
if(!showGraph){
return (
<>
<div style={{display: "flex", width: "100%", height: "100%", justifyContent: "center", flexDirection: "column", alignItems: "center"}}>
<Typography variant={"h4"} >
{`Graph Hidden by Default Due to Size: Nodes (${graph.nodes.length}), Edges (${graph.edges.length}) `}
<div className="mythic-graph-empty-state">
<Typography component="div" className="mythic-graph-empty-title">
Large graph hidden
</Typography>
<Button variant={"contained"} color={"error"} onClick={() => {setShowGraph(!showGraph)}}>
{"Show Graph"}
<Typography component="div" className="mythic-graph-empty-description">
{`This response contains ${graph.nodes.length} nodes and ${graph.edges.length} edges.`}
</Typography>
<Button className="mythic-graph-empty-action mythic-action-tone-hover mythic-tone-info" variant={"contained"} onClick={() => {setShowGraph(!showGraph)}}>
Show Graph
</Button>
</div>
</>
@@ -92,9 +95,12 @@ export const ResponseDisplayGraph = ({graph, task, expand}) =>{
if(graph.nodes.length === 0){
return (
<>
<div style={{display: "flex", width: "100%", height: "100%", justifyContent: "center", flexDirection: "column", alignItems: "center"}}>
<Typography variant={"h4"} >
{`Empty Graph`}
<div className="mythic-graph-empty-state">
<Typography component="div" className="mythic-graph-empty-title">
Empty graph
</Typography>
<Typography component="div" className="mythic-graph-empty-description">
There are no nodes to render for this response.
</Typography>
</div>
</>
@@ -118,4 +124,4 @@ export const ResponseDisplayGraph = ({graph, task, expand}) =>{
/>
</div>
);
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -2,13 +2,10 @@ import React from 'react';
import {Typography, Link} from '@mui/material';
import { Button, IconButton } from '@mui/material';
import {GetMythicSetting} from "../../MythicComponents/MythicSavedUserSetting";
import MenuItem from '@mui/material/MenuItem';
import TextField from '@mui/material/TextField';
import AceEditor from 'react-ace';
import {useTheme} from '@mui/material/styles';
import {snackActions} from "../../utilities/Snackbar";
import FormControl from '@mui/material/FormControl';
import WrapTextIcon from '@mui/icons-material/WrapText';
import {mythicFetch} from "../../utilities/MythicConnection";
// https://github.com/ajaxorg/ace-builds/tree/master/src-min-noconflict
import 'ace-builds/src-noconflict/mode-json';
import 'ace-builds/src-noconflict/mode-csharp';
@@ -44,12 +41,11 @@ import Tabs from '@mui/material/Tabs';
import TabContext from '@mui/lab/TabContext';
import Tab from '@mui/material/Tab';
import {previewFileQuery} from "../Search/FileMetaTable";
import { useMutation, gql, useQuery } from '@apollo/client';
import CodeIcon from '@mui/icons-material/Code';
import { useMutation, gql } from '@apollo/client';
import DownloadIcon from '@mui/icons-material/Download';
import {b64DecodeUnicode} from "./ResponseDisplay";
import {MythicDialog, TableRowSizeCell} from "../../MythicComponents/MythicDialog";
import {Table, TableHead, TableRow, TableBody, TableCell, Paper} from '@mui/material';
import {Table, TableContainer, TableHead, TableRow, TableBody, TableCell, Paper} from '@mui/material';
import MythicStyledTableCell from "../../MythicComponents/MythicTableCell";
import WarningOutlinedIcon from '@mui/icons-material/WarningOutlined';
import {TagsDisplay, ViewEditTags} from "../../MythicComponents/MythicTag";
@@ -63,6 +59,11 @@ import {Backdrop, CircularProgress} from '@mui/material';
// eslint-disable-next-line import/no-webpack-loader-syntax
import sqlWasm from "!!file-loader?name=sql-wasm-[contenthash].wasm!sql.js/dist/sql-wasm.wasm";
import {copyStringToClipboard} from "../../utilities/Clipboard";
import {FileDownloadButtonWithAuth, FileDownloadLinkWithAuth} from "../../utilities/FileDownloadWithAuth";
import {ImageWithAuth} from "../../utilities/ImageWithAuth";
import {ResponseDisplayPlaintext} from "./ResponseDisplayPlaintext";
import {UploadDirectFile} from "../../MythicComponents/MythicFileUpload";
import SaveIcon from '@mui/icons-material/Save';
export const modeOptions = ["csharp", "golang", "html", "json", "markdown", "ruby", "python", "java",
"javascript", "yaml", "toml", "swift", "sql", "rust", "powershell", "pgsql", "perl", "php", "objectivec",
@@ -77,6 +78,7 @@ const fileMetaQuery = gql`
size
agent_file_id
complete
deleted
total_chunks
chunks_received
task {
@@ -98,6 +100,12 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
const displayType = mimeType(media?.filename);
const [value, setValue] = React.useState(displayType !== undefined ? 0 : 1);
const [fileMetaData, setFileMetaData] = React.useState({});
const panelStyle = {
flex: "1 1 auto",
minHeight: expand ? 0 : "24rem",
overflow: "auto",
width: "100%",
};
const fetchedData = ({data}) => {
if(data.filemeta.length > 0){
setFileMetaData({
@@ -110,6 +118,7 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
agent_file_id: data.filemeta[0].agent_file_id,
task_display_id: data.filemeta[0]?.task?.display_id,
complete: data.filemeta[0].complete,
deleted: data.filemeta[0].deleted,
total_chunks: data.filemeta[0].total_chunks,
chunks_received: data.filemeta[0].chunks_received
})
@@ -127,9 +136,9 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
setValue(newValue);
}
return (
<div style={{display: "flex", height: "100%", flexDirection: "column"}}>
<div className="mythic-response-media" style={{height: expand ? "100%" : undefined, minHeight: expand ? 0 : "24rem"}}>
<DisplayFileMetaData fileMetaData={fileMetaData} />
<AppBar color={'default'} position='static' className={"no-box-shadow"}>
<AppBar color={'default'} position='static' className={"no-box-shadow mythic-response-media-tabs"}>
<Tabs
value={value}
onChange={handleChange}
@@ -149,30 +158,32 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
<Tab className={value === 2 ? "selectedCallback": ""} label={"Hex"}></Tab>
<Tab className={value === 3 ? "selectedCallback": ""} label={"Database"}></Tab>
<MythicStyledTooltip title={"Download the file"} tooltipStyle={{display: "inline-flex"}}>
<Button style={{}} size={"small"} href={"/direct/download/" + media.agent_file_id}
<FileDownloadButtonWithAuth style={{}} size={"small"} href={"/direct/download/" + media.agent_file_id}
download color={"success"}>
<DownloadIcon />
</Button>
</FileDownloadButtonWithAuth>
</MythicStyledTooltip>
</Tabs>
</AppBar>
<div hidden={value !== 0} style={{height: "100%", overflow: "auto"}} role='tabpanel' >
<div hidden={value !== 0} className="mythic-response-media-panel" style={panelStyle} role='tabpanel' >
{value === 0 &&
<DisplayMedia agent_file_id={media?.agent_file_id || ""}
task={task} filename={media?.filename || undefined}
fileMetaData={fileMetaData}
editable={Boolean(media?.editable)}
expand={expand} />
}
</div>
<div hidden={value !== 1} style={{height: "100%", overflow: "auto"}} role='tabpanel' >
<div hidden={value !== 1} className="mythic-response-media-panel" style={panelStyle} role='tabpanel' >
{value === 1 &&
<DisplayText agent_file_id={media?.agent_file_id || ""}
task={task} filename={media?.filename || undefined}
fileMetaData={fileMetaData}
editable={Boolean(media?.editable)}
expand={expand} preview />
}
</div>
<div hidden={value !== 2} style={{height: "100%", overflow: "auto"}} role='tabpanel' >
<div hidden={value !== 2} className="mythic-response-media-panel" style={panelStyle} role='tabpanel' >
{value === 2 &&
<DisplayHex agent_file_id={media?.agent_file_id || ""}
task={task} filename={media?.filename || undefined}
@@ -181,7 +192,7 @@ export const ResponseDisplayMedia = ({media, expand, task}) =>{
}
</div>
<div hidden={value !== 3} style={{height: "100%", overflow: "auto"}} role='tabpanel' >
<div hidden={value !== 3} className="mythic-response-media-panel" style={panelStyle} role='tabpanel' >
{value === 3 &&
<DisplayDatabase agent_file_id={media?.agent_file_id || ""}
task={task} filename={media?.filename || undefined}
@@ -257,7 +268,7 @@ const mimeType = (path) => {
}
return undefined;
}
export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaData}) => {
export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaData, editable=false}) => {
const showMediaSetting = GetMythicSetting({setting_name: "showMedia", default_value: true});
const [openScreenshot, setOpenScreenshot] = React.useState(false);
const [showMedia, setShowMedia] = React.useState(showMediaSetting);
@@ -324,9 +335,9 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
<Typography variant={"h4"} >
{fileData.message}
</Typography>
<Link style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + agent_file_id} >
<FileDownloadLinkWithAuth style={{wordBreak: "break-all"}} color="textPrimary" underline="always" href={"/direct/download/" + agent_file_id} >
{"Download here"}
</Link>
</FileDownloadLinkWithAuth>
</div>
)
}
@@ -343,9 +354,9 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
if(fileData.display_type === "image"){
return (
<>
<img width={"100%"} onClick={clickedScreenshot}
<ImageWithAuth width={"100%"} onClick={clickedScreenshot}
src={"/direct/view/" + agent_file_id} style={{cursor: "zoom-in", width: "100%"}}>
</img>
</ImageWithAuth>
{openScreenshot &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openScreenshot}
onClose={() => {
@@ -365,7 +376,7 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
return (
<div style={{height: "100%", minHeight: "100px", width: "100%"}}>
<DisplayText agent_file_id={agent_file_id} expand={expand} filename={filename}
fileMetaData={fileMetaData}/>
fileMetaData={fileMetaData} editable={editable}/>
</div>
)
}
@@ -374,6 +385,7 @@ export const DisplayMedia = ({agent_file_id, filename, expand, task, fileMetaDat
const MaxRenderSize = 2000000; // 2MB
const DisplayFileMetaData = ({fileMetaData}) => {
return (
<TableContainer className="mythicElement mythic-response-media-metadata">
<Table style={{marginLeft: "0px", width: "100%", tableLayout: "fixed"}}>
<TableHead>
<TableRow>
@@ -421,168 +433,193 @@ const DisplayFileMetaData = ({fileMetaData}) => {
</TableRow>
</TableBody>
</Table>
</TableContainer>
)
}
const DisplayText = ({agent_file_id, expand, filename, preview, fileMetaData}) => {
const theme = useTheme();
const [mode, setMode] = React.useState("html");
const getTextSyntaxForFilename = (filename) => {
if(!filename){
return "html";
}
let extension = filename.split(".");
if(extension.length > 1){
extension = extension[extension.length - 1].toLowerCase();
return textExtensionTypesToSyntax[extension] || "html";
}
return textExtensionTypesToSyntax[filename.toLowerCase()] || "html";
}
const DisplayText = ({agent_file_id, expand, filename, editable=false, fileMetaData}) => {
const [content, setContent] = React.useState("");
const [wrapText, setWrapText] = React.useState(true);
const [previewFileString] = useMutation(previewFileQuery, {
onCompleted: (data) => {
if(data.previewFile.status === "success"){
setContent(b64DecodeUnicode(data.previewFile.contents));
if(data.previewFile.size > 512000){
setLimitedPreviewWarning(true);
}
}else{
console.log(data.previewFile.error);
if(data.previewFile.error !== "EOF"){
snackActions.error(data.previewFile.error);
}
}
},
onError: (data) => {
console.log(data);
snackActions.error(data);
}
});
const [editedContent, setEditedContent] = React.useState("");
const [loading, setLoading] = React.useState(true);
const [loadError, setLoadError] = React.useState("");
const [limitedPreviewWarning, setLimitedPreviewWarning] = React.useState(false);
const currentContentRef = React.useRef();
const [previewLoaded, setPreviewLoaded] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [previewFileString] = useMutation(previewFileQuery);
const initialMode = React.useMemo(() => getTextSyntaxForFilename(filename), [filename]);
React.useEffect( () => {
if(preview){
// get first 512KB
previewFileString({variables: {file_id: agent_file_id}})
}else{
// get entire file
fetch('/direct/view/' + agent_file_id).then((response) => {
if(response.status !== 200){
snackActions.warning("Failed to fetch contents from Mythic");
let cancelled = false;
const loadText = async () => {
setLoading(true);
setLoadError("");
setPreviewLoaded(false);
setLimitedPreviewWarning(false);
setContent("");
setEditedContent("");
try{
const {data} = await previewFileString({variables: {file_id: agent_file_id}});
const previewData = data?.previewFile;
if(cancelled){
return;
}
response.text().then(data => {
if(data.length > MaxRenderSize){
snackActions.warning("File too large (> 2MB), truncating the render");
setLimitedPreviewWarning(true);
setContent(data.substring(0, MaxRenderSize));
let previewText = "";
if(previewData?.status !== "success"){
const errorMessage = previewData?.error || "Failed to preview file";
if(errorMessage === "EOF"){
setPreviewLoaded(true);
}else{
setLoadError(errorMessage);
snackActions.error(errorMessage);
return;
}
try{
let cont = JSON.stringify(JSON.parse(data), null, 2);
setContent(cont);
}catch(error){
setContent(data.substring(0, MaxRenderSize));
}else{
setPreviewLoaded(true);
previewText = b64DecodeUnicode(previewData.contents || "");
}
if((previewData.size || 0) > MaxRenderSize){
setLimitedPreviewWarning(true);
setContent(previewText);
setEditedContent(previewText);
return;
}
const response = await mythicFetch('/direct/view/' + agent_file_id, {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`,
}
}).catch(error => {
snackActions.warning("Error getting contents from server: " + error.toString());
console.log("Error trying to get json response", error, response);
});
}).catch(error => {
if(error.toString() === "TypeError: Failed to fetch"){
snackActions.warning("Please refresh and accept the SSL connection error");
} else {
snackActions.warning("Error talking to server: " + error.toString());
if(cancelled){
return;
}
if(response.status !== 200){
setLoadError("Failed to fetch contents from Mythic");
snackActions.warning("Failed to fetch contents from Mythic");
setContent(previewText);
setEditedContent(previewText);
return;
}
const fullText = await response.text();
if(cancelled){
return;
}
if(fullText.length > MaxRenderSize){
snackActions.warning("File too large (> 2MB), truncating the render");
setLimitedPreviewWarning(true);
const truncatedText = fullText.substring(0, MaxRenderSize);
setContent(truncatedText);
setEditedContent(truncatedText);
return;
}
setContent(fullText);
setEditedContent(fullText);
}catch(error){
if(cancelled){
return;
}
const errorMessage = error.toString() === "TypeError: Failed to fetch" ?
"Unable to reach Mythic. Check the server, network, or certificate approval." :
"Error talking to server: " + error.toString();
setLoadError(errorMessage);
snackActions.warning(errorMessage);
console.log("There was an error!", error);
});
}
if(filename){
let extension = filename.split(".");
if(extension.length > 1){
extension = extension[extension.length - 1];
if(textExtensionTypesToSyntax[extension]){
setMode(textExtensionTypesToSyntax[extension]);
}
} else {
if(textExtensionTypesToSyntax[filename.toLowerCase()]){
setMode(textExtensionTypesToSyntax[filename.toLowerCase()]);
}finally{
if(!cancelled){
setLoading(false);
}
}
}
}, [agent_file_id, filename, fileMetaData]);
const onChangeMode = (event) => {
setMode(event.target.value);
}
const toggleWrapText = () => {
setWrapText(!wrapText);
}
const formatJSON = () => {
if(agent_file_id){
loadText();
}
return () => {
cancelled = true;
}
}, [agent_file_id, previewFileString]);
const canSave = editable &&
previewLoaded &&
!loading &&
!loadError &&
!limitedPreviewWarning &&
fileMetaData?.complete !== false &&
!fileMetaData?.deleted;
const saveDisabledReason = !editable ? "Editing is not enabled for this file" :
!previewLoaded ? "Text preview has not loaded successfully" :
limitedPreviewWarning ? "Only a truncated preview is loaded" :
fileMetaData?.complete === false ? "File is not completely uploaded" :
fileMetaData?.deleted ? "Deleted files cannot be edited" :
loadError ? loadError :
saving ? "Saving file" :
"";
const onSave = async () => {
if(!canSave || saving){
if(saveDisabledReason){
snackActions.warning(saveDisabledReason);
}
return;
}
setSaving(true);
try{
let tmp = JSON.stringify(JSON.parse(currentContentRef.current?.editor?.getValue()), null, 2);
setContent(tmp);
setMode("json");
}catch(error){
snackActions.warning("Failed to reformat as JSON")
const newFile = new File([editedContent], filename || agent_file_id, {type: "text/plain;charset=utf-8"});
const uploadResult = await UploadDirectFile(agent_file_id, newFile);
if(uploadResult?.status === "success"){
snackActions.success("Successfully saved file");
}else if(uploadResult?.error){
snackActions.error(uploadResult.error);
}else{
snackActions.error("Failed to save file");
}
}finally{
setSaving(false);
}
}
const toolbarActions = editable ? [
<MythicStyledTooltip title={saveDisabledReason || "Save file"}>
<span>
<button
aria-label="Save file"
className="mythic-response-render-action-button mythic-action-tone mythic-tone-success"
disabled={!canSave || saving}
onClick={onSave}
type="button">
<SaveIcon fontSize="small" />
</button>
</span>
</MythicStyledTooltip>
] : [];
const toolbarNotice = limitedPreviewWarning ? (
<MythicStyledTooltip title={"Only a truncated preview of this file is shown"}>
<WarningOutlinedIcon style={{marginLeft: "5px"}} color={"warning"} />
</MythicStyledTooltip>
) : null;
return (
<div style={{display: "flex", height: "100%", flexDirection: "column"}}>
<div style={{display: "inline-flex", flexDirection: "row", alignItems: "center"}}>
<FormControl sx={{ display: "inline-block", marginLeft: "10px" }} size="small" color={"secondary"}>
<TextField
select
label={"Syntax"}
margin={"dense"}
size={"small"}
style={{display: "inline-block", width: "100%",}}
value={mode}
sx={{padding: 0}}
onChange={onChangeMode}
>
{
modeOptions.map((opt, i) => (
<MenuItem key={"searchopt" + opt} value={opt}>{opt}</MenuItem>
))
}
</TextField>
</FormControl>
<MythicStyledTooltip title={wrapText ? "Unwrap Text" : "Wrap Text"} >
<IconButton onClick={toggleWrapText} style={{}}>
<WrapTextIcon color={wrapText ? "success" : "secondary"}
style={{cursor: "pointer"}}
/>
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Auto format JSON"} >
<IconButton onClick={formatJSON} style={{}}>
<CodeIcon color={"info"} style={{cursor: "pointer"}} />
</IconButton>
</MythicStyledTooltip>
{limitedPreviewWarning &&
<>
<MythicStyledTooltip title={preview ? "Only first 512KB of file is shown" :
"Only first 2MB of file is shown"}>
<WarningOutlinedIcon style={{marginLeft: "5px"}} color={"warning"} />
</MythicStyledTooltip>
</>
}
</div>
<div style={{display: "flex", flexGrow: 1, height: "100%"}}>
<AceEditor
ref={currentContentRef}
mode={mode}
theme={theme.palette.mode === "dark" ? "monokai" : "xcode"}
fontSize={14}
showGutter={true}
//onLoad={onLoad}
highlightActiveLine={false}
showPrintMargin={false}
value={content}
height={expand ? "100%": undefined}
maxLines={expand ? undefined : 20}
width={"100%"}
//autoScrollEditorIntoView={true}
wrapEnabled={wrapText}
minLines={2}
//maxLines={props.expand ? 50 : 20}
setOptions={{
showLineNumbers: true,
tabSize: 4,
useWorker: false
}}/>
</div>
</div>
<ResponseDisplayPlaintext
plaintext={loading ? "Loading..." : content}
expand={expand}
initial_mode={initialMode}
autoFormat={false}
readOnly={!editable}
toolbarTitle={"Text"}
initial_show_options={true}
toolbarActions={toolbarActions}
toolbarNotice={toolbarNotice}
enableCredentialCreation={!loading && previewLoaded}
credentialMetadata={{
agent_file_id,
filemeta_id: fileMetaData?.id,
filename: filename || fileMetaData?.filename,
}}
onChangeContent={setEditedContent}
/>
)
}
const DisplayHex = ({agent_file_id, expand, fileMetaData}) => {
@@ -762,7 +799,11 @@ const DisplayDatabase = ({agent_file_id, expand, fileMetaData}) => {
React.useEffect( () => {
async function initialize(){
const newSQL = await initSQLJS({locateFile: () => sqlWasm});
fetch('/direct/view/' + agent_file_id).then((response) => {
mythicFetch('/direct/view/' + agent_file_id, {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token')}`
}
}).then((response) => {
if(response.status !== 200){
setLoading(false);
snackActions.warning("Failed to fetch contents from Mythic");
@@ -781,7 +822,7 @@ const DisplayDatabase = ({agent_file_id, expand, fileMetaData}) => {
});
}).catch(error => {
if(error.toString() === "TypeError: Failed to fetch"){
snackActions.warning("Please refresh and accept the SSL connection error");
snackActions.warning("Unable to reach Mythic. Check the server, network, or certificate approval.");
} else {
snackActions.warning("Error talking to server: " + error.toString());
}
@@ -885,6 +926,7 @@ const DisplayDatabaseResult = ({result}) => {
const [columns, setColumns] = React.useState(result.columns);
const [rows, setRows] = React.useState(result.values?.slice(0, MAX_ROWS));
return (
<TableContainer className="mythicElement" style={{height: "100%", position: "relative"}}>
<Table stickyHeader style={{width: "100%", height: "100%", position: "absolute"}}>
<TableHead >
<TableRow>
@@ -905,5 +947,6 @@ const DisplayDatabaseResult = ({result}) => {
))}
</TableBody>
</Table>
</TableContainer>
)
}
}
@@ -3,22 +3,202 @@ import AceEditor from 'react-ace';
import {useTheme} from '@mui/material/styles';
import {snackActions} from "../../utilities/Snackbar";
import {modeOptions} from "./ResponseDisplayMedia";
import FormControl from '@mui/material/FormControl';
import WrapTextIcon from '@mui/icons-material/WrapText';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
import MenuItem from '@mui/material/MenuItem';
import TextField from '@mui/material/TextField';
import { IconButton, Select } from '@mui/material';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import { useReactiveVar } from '@apollo/client';
import { gql, useMutation, useReactiveVar } from '@apollo/client';
import { meState } from '../../../cache';
import CodeIcon from '@mui/icons-material/Code';
import {GetOutputFormatAll} from "./ResponseDisplayInteractive";
import PaletteIcon from '@mui/icons-material/Palette';
import Input from '@mui/material/Input';
import ArticleIcon from '@mui/icons-material/Article';
import NotesIcon from '@mui/icons-material/Notes';
import TerminalIcon from '@mui/icons-material/Terminal';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import ReactMarkdown from 'react-markdown';
import {Terminal} from '@xterm/xterm';
import {FitAddon} from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import {
getInteractiveTerminalTheme as getTerminalTheme,
getLongestTerminalTextLineLength as getLongestTerminalLineLength,
sanitizeTerminalOutput
} from "./ResponseDisplayInteractive";
import {markdownPlugins} from "../../utilities/Markdown";
import {markdownComponents} from "../../utilities/MarkdownComponents";
import {MythicDialog} from "../../MythicComponents/MythicDialog";
import {CredentialTableNewCredentialDialog} from "../Search/CredentialTableNewCredentialDialog";
const MaxRenderSize = 2000000;
const RenderModes = {
plaintext: "plaintext",
markdown: "markdown",
terminal: "terminal",
};
const renderModeOptions = [
{value: RenderModes.plaintext, label: "Plaintext", Icon: ArticleIcon},
{value: RenderModes.markdown, label: "Markdown", Icon: NotesIcon},
{value: RenderModes.terminal, label: "Terminal", Icon: TerminalIcon},
];
const createCredentialMutation = gql`
mutation createCredential($comment: String!, $account: String!, $realm: String!, $type: String!, $subtype: String, $credential: String!, $metadata: jsonb, $custom_display: String) {
createCredential(account: $account, credential: $credential, comment: $comment, realm: $realm, credential_type: $type, credential_subtype: $subtype, metadata: $metadata, custom_display: $custom_display) {
status
error
id
}
}
`;
const normalizeRenderMode = (value) => {
return Object.values(RenderModes).includes(value) ? value : RenderModes.plaintext;
}
const getInitialRenderMode = (props) => {
if(props?.render_mode !== undefined){
return normalizeRenderMode(props.render_mode);
}
if(props?.initial_render_mode !== undefined){
return normalizeRenderMode(props.initial_render_mode);
}
return props?.render_colors ? RenderModes.terminal : RenderModes.plaintext;
}
const ResponseMarkdownDisplay = ({value, wrapText, expand}) => {
return (
<div className={`mythic-response-markdown${wrapText ? " is-wrapped" : " is-unwrapped"}${expand ? " is-expanded" : " is-capped"}`}>
<ReactMarkdown remarkPlugins={markdownPlugins} components={markdownComponents} skipHtml>
{value}
</ReactMarkdown>
</div>
)
}
const ResponseTerminalDisplay = ({value, wrapText, expand, theme}) => {
const terminalScrollContainerRef = React.useRef(null);
const terminalElementRef = React.useRef(null);
const terminalRef = React.useRef(null);
const fitAddonRef = React.useRef(null);
const resizeObserverRef = React.useRef(null);
const fitAnimationFrameRef = React.useRef(null);
const wrapTextRef = React.useRef(wrapText);
const unwrappedColumnCountRef = React.useRef(0);
const [terminalReady, setTerminalReady] = React.useState(false);
const longestLineLength = React.useMemo( () => getLongestTerminalLineLength(value), [value]);
const fitTerminal = React.useCallback( () => {
const terminal = terminalRef.current;
const fitAddon = fitAddonRef.current;
const terminalElement = terminalElementRef.current;
const scrollContainer = terminalScrollContainerRef.current;
if(!terminal || !fitAddon || !terminalElement || !scrollContainer){
return;
}
try{
terminalElement.style.width = "100%";
const proposedDimensions = fitAddon.proposeDimensions();
if(!proposedDimensions){
return;
}
const rows = Math.max(1, proposedDimensions.rows);
if(wrapTextRef.current){
fitAddon.fit();
scrollContainer.scrollLeft = 0;
} else {
const visibleCols = Math.max(1, proposedDimensions.cols);
const cols = Math.max(visibleCols, unwrappedColumnCountRef.current);
terminalElement.style.width = `${Math.ceil((cols / visibleCols) * scrollContainer.clientWidth)}px`;
terminal.resize(cols, rows);
}
}catch(error){
console.error(error);
}
}, []);
const scheduleFitTerminal = React.useCallback( () => {
if(fitAnimationFrameRef.current !== null){
return;
}
fitAnimationFrameRef.current = window.requestAnimationFrame(() => {
fitAnimationFrameRef.current = null;
fitTerminal();
});
}, [fitTerminal]);
React.useEffect( () => {
wrapTextRef.current = wrapText;
if(wrapText){
unwrappedColumnCountRef.current = 0;
} else {
unwrappedColumnCountRef.current = Math.min(Math.max(longestLineLength + 2, 1), 1000);
}
scheduleFitTerminal();
}, [longestLineLength, scheduleFitTerminal, wrapText]);
React.useEffect( () => {
if(!terminalElementRef.current){
return;
}
const terminal = new Terminal({
allowTransparency: true,
convertEol: true,
cursorBlink: false,
disableStdin: true,
fontFamily: 'Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: 13,
scrollback: 5000,
theme: getTerminalTheme(theme),
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalElementRef.current);
terminalRef.current = terminal;
fitAddonRef.current = fitAddon;
resizeObserverRef.current = new ResizeObserver(() => scheduleFitTerminal());
if(terminalScrollContainerRef.current){
resizeObserverRef.current.observe(terminalScrollContainerRef.current);
}
fitAnimationFrameRef.current = window.requestAnimationFrame(() => {
fitAnimationFrameRef.current = null;
fitTerminal();
setTerminalReady(true);
});
return () => {
if(fitAnimationFrameRef.current !== null){
window.cancelAnimationFrame(fitAnimationFrameRef.current);
fitAnimationFrameRef.current = null;
}
resizeObserverRef.current?.disconnect();
resizeObserverRef.current = null;
fitAddonRef.current = null;
terminalRef.current = null;
terminal.dispose();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect( () => {
if(terminalRef.current){
terminalRef.current.options.theme = getTerminalTheme(theme);
}
}, [theme]);
React.useEffect( () => {
if(!terminalReady || !terminalRef.current){
return;
}
const terminal = terminalRef.current;
scheduleFitTerminal();
terminal.reset();
terminal.write(sanitizeTerminalOutput(value), () => terminal.scrollToBottom());
}, [scheduleFitTerminal, terminalReady, value]);
return (
<div className="mythic-response-terminal-shell"
style={{height: expand ? "100%" : "360px", minHeight: expand ? 0 : "140px"}}>
<div
ref={terminalScrollContainerRef}
className={"MythicInteractiveTerminal mythic-response-terminal"}
style={{
height: "100%",
overflowX: wrapText ? "hidden" : "auto",
overflowY: "hidden",
width: "100%",
}}>
<div ref={terminalElementRef} style={{height: "100%", width: "100%"}} />
</div>
</div>
)
}
export const ResponseDisplayPlaintext = (props) =>{
const theme = useTheme();
const me = useReactiveVar(meState);
@@ -26,51 +206,107 @@ export const ResponseDisplayPlaintext = (props) =>{
const [plaintextView, setPlaintextView] = React.useState("");
const initialMode = props?.initial_mode || "html";
const [mode, setMode] = React.useState(initialMode);
const [renderMode, setRenderMode] = React.useState(getInitialRenderMode(props));
const [wrapText, setWrapText] = React.useState(props?.wrap_text === undefined ? true : props.wrap_text);
const [showOptions, setShowOptions] = React.useState(false);
const [renderColors, setRenderColors] = React.useState(props?.render_colors === undefined ? false : props.render_colors);
const [showOptions, setShowOptions] = React.useState(Boolean(props?.initial_show_options));
const [createCredentialDialogOpen, setCreateCredentialDialogOpen] = React.useState(false);
const [initialCredentialValues, setInitialCredentialValues] = React.useState({});
const largeResponseWarningShown = React.useRef(false);
const [createCredential] = useMutation(createCredentialMutation, {
fetchPolicy: "no-cache",
onCompleted: (data) => {
if(data.createCredential.status === "success"){
snackActions.success("Successfully created new credential");
} else {
snackActions.error(data.createCredential.error);
}
},
onError: (data) => {
snackActions.error("Failed to create credential");
console.log(data);
}
});
const onChangeText = (data) => {
if(props.onChangeContent){
props?.onChangeContent(data);
}
setPlaintextView(data);
}
React.useEffect( () => {
largeResponseWarningShown.current = false;
}, [props?.task?.id]);
useEffect( () => {
if(props.plaintext.length > MaxRenderSize){
snackActions.warning("Response too large (> 2MB), truncating the render. Download task output to view entire response.");
setPlaintextView(props.plaintext.substring(0, MaxRenderSize));
const plaintext = props.plaintext === undefined || props.plaintext === null ? "" : String(props.plaintext);
if(plaintext.length > MaxRenderSize){
if(!largeResponseWarningShown.current){
snackActions.warning("Response too large (> 2MB), truncating the render. Download task output to view entire response.");
largeResponseWarningShown.current = true;
}
setPlaintextView(plaintext.substring(0, MaxRenderSize));
} else {
largeResponseWarningShown.current = false;
try{
if(props.autoFormat === undefined || props.autoFormat){
const newPlaintext = JSON.stringify(JSON.parse(String(props.plaintext)), null, 4);
const newPlaintext = JSON.stringify(JSON.parse(plaintext), null, 4);
setPlaintextView(newPlaintext);
setMode("json");
} else {
setPlaintextView(String(props.plaintext));
setPlaintextView(plaintext);
}
}catch(error){
setPlaintextView(String(props.plaintext));
setPlaintextView(plaintext);
}
}
}, [props.plaintext]);
}, [props.plaintext, props.autoFormat]);
const onChangeMode = (event) => {
setMode(event.target.value);
}
const onChangeRenderMode = (newMode) => {
setRenderMode(newMode);
}
const toggleWrapText = () => {
setWrapText(!wrapText);
}
const formatJSON = () => {
try{
let tmp = JSON.stringify(JSON.parse(currentContentRef.current?.editor?.getValue()), null, 2);
const currentValue = currentContentRef.current?.editor?.getValue?.() || plaintextView;
let tmp = JSON.stringify(JSON.parse(currentValue), null, 2);
setPlaintextView(tmp);
props.onChangeContent?.(tmp);
setMode("json");
}catch(error){
snackActions.warning("Failed to reformat as JSON")
}
}
const onChangeShowOptions = (e) => {
const onChangeShowOptions = () => {
setShowOptions(!showOptions);
}
const sourceTaskMetadata = () => {
const taskMetadata = props?.task?.id ? {task_id: props.task.id} : {};
const credentialMetadata = typeof props?.credentialMetadata === "function" ?
props.credentialMetadata() :
(props?.credentialMetadata || {});
return {
...taskMetadata,
...credentialMetadata,
};
};
const openCreateCredentialDialog = () => {
const selectedText = currentContentRef.current?.editor?.getSelectedText?.() || "";
const credentialText = selectedText.trim() === "" ? plaintextView : selectedText;
if(credentialText.trim() === ""){
snackActions.warning("No credential text selected");
return;
}
setInitialCredentialValues({
credential: credentialText,
metadata: sourceTaskMetadata(),
});
setCreateCredentialDialogOpen(true);
}
const onCreateCredential = ({type, subtype, account, realm, comment, credential, metadata, custom_display}) => {
createCredential({variables: {type, subtype, account, realm, comment, credential, metadata, custom_display}})
}
const scrollContent = (node, isAppearing) => {
// only auto-scroll if you issued the task
if(props?.task?.operator?.username === (me?.user?.username || "")){
@@ -90,75 +326,113 @@ export const ResponseDisplayPlaintext = (props) =>{
}
React.useLayoutEffect( () => {
scrollContent()
}, []);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
React.useEffect( () => {
setMode(props?.initial_mode || "html");
}, [props?.initial_mode]);
React.useEffect( () => {
setRenderMode(getInitialRenderMode({
initial_render_mode: props?.initial_render_mode,
render_colors: props?.render_colors,
render_mode: props?.render_mode,
}));
}, [props?.initial_render_mode, props?.render_mode, props?.render_colors]);
const currentRenderModeLabel = renderModeOptions.find((option) => option.value === renderMode)?.label || "Plaintext";
const displayWrapText = renderMode === RenderModes.plaintext ? wrapText : true;
const toolbarActions = props?.toolbarActions || [];
const showCreateCredentialAction = props?.enableCredentialCreation !== false &&
(props.task || props.credentialMetadata || props.showCredentialAction);
return (
<div style={{display: "flex", height: "100%", flexDirection: "column"}}>
{showOptions &&
<div style={{display: "inline-flex", flexDirection: "row", alignItems: "center",
color: theme.outputTextColor,
backgroundColor: theme.outputBackgroundColor + (theme.palette.mode === 'dark' ? "D0" : "20"),}}>
<FormControl sx={{ display: "inline-block", marginLeft: "10px", color: theme.outputTextColor, }} size="small">
<TextField
label={"Syntax"}
select
margin={"dense"}
size={"small"}
sx={{
// Target the icon element directly within the Select
'.MuiSelect-icon': {
color: theme.outputTextColor, // Set your desired color
},
'.MuiInputBase-root': {
color: theme.outputTextColor,
}
}}
style={{display: "inline-block", width: "100%",}}
value={mode}
onChange={onChangeMode}
>
{
modeOptions.map((opt, i) => (
<MenuItem key={"searchopt" + opt} value={opt}>{opt}</MenuItem>
))
}
</TextField>
</FormControl>
<MythicStyledTooltip title={wrapText ? "Unwrap Text" : "Wrap Text"} >
<IconButton onClick={toggleWrapText} style={{}}>
<WrapTextIcon color={wrapText ? "success" : "secondary"}
style={{cursor: "pointer"}}
/>
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Auto format JSON"} >
<IconButton onClick={formatJSON} style={{}}>
<CodeIcon color={"info"} style={{cursor: "pointer"}} />
</IconButton>
</MythicStyledTooltip>
<MythicStyledTooltip title={renderColors ? "Render Plaintext" : "Render Colors"} >
<IconButton onClick={() => {setRenderColors(!renderColors)}}>
<PaletteIcon color={renderColors ? "success" : "secondary"} style={{cursor: "pointer"}} />
</IconButton>
</MythicStyledTooltip>
</div>
<div style={{display: "flex", height: "100%", minHeight: 0, minWidth: 0, flexDirection: "column", flex: "1 1 auto"}}>
{createCredentialDialogOpen &&
<MythicDialog fullWidth={true} maxWidth="md" open={createCredentialDialogOpen}
onClose={()=>{setCreateCredentialDialogOpen(false);}}
innerDialog={<CredentialTableNewCredentialDialog initialValues={initialCredentialValues} onSubmit={onCreateCredential} onClose={()=>{setCreateCredentialDialogOpen(false);}} />}
/>
}
{props.displayType !== 'console' &&
<div style={{
height: "1px",
width: "100%",
display: "flex",
zIndex: 1,
}}>
<div className={`mythic-response-render-toolbar${showOptions ? " is-open" : ""}`}>
<button className="mythic-response-render-toolbar-toggle"
type="button"
onClick={onChangeShowOptions}
style={{color: theme.outputTextColor}}>
{showOptions ? <UnfoldLessIcon fontSize="small" /> : <UnfoldMoreIcon fontSize="small" />}
<span className="mythic-response-render-toolbar-title">{props?.toolbarTitle || "Output"}</span>
<span className="mythic-response-render-toolbar-mode">{currentRenderModeLabel}</span>
</button>
{showOptions &&
<UnfoldLessIcon onClick={onChangeShowOptions}
style={{cursor: "pointer", position: "relative", top: "-9px"}}/>
}
{!showOptions &&
<UnfoldMoreIcon onClick={onChangeShowOptions}
style={{cursor: "pointer", position: "relative", top: "-9px"}}/>
<div className="mythic-response-render-toolbar-controls">
<div className="mythic-response-render-mode-group" role="group" aria-label="Response render mode">
{renderModeOptions.map(({value, label, Icon}) => (
<button
aria-pressed={renderMode === value}
className={`mythic-response-render-mode-button${renderMode === value ? " mythic-action-tone mythic-tone-info" : ""}`}
key={value}
onClick={() => onChangeRenderMode(value)}
type="button">
<Icon fontSize="small" />
<span>{label}</span>
</button>
))}
</div>
{toolbarActions.length > 0 &&
<div className="mythic-response-render-action-group">
{toolbarActions.map((action, index) => (
<React.Fragment key={action?.key || index}>{action}</React.Fragment>
))}
</div>
}
{renderMode === RenderModes.plaintext &&
<>
<div className="mythic-response-render-action-group">
<MythicStyledTooltip title={wrapText ? "Unwrap Text" : "Wrap Text"} >
<button
aria-label={wrapText ? "Unwrap Text" : "Wrap Text"}
aria-pressed={wrapText}
className={`mythic-response-render-action-button${wrapText ? " mythic-action-tone mythic-tone-info" : ""}`}
onClick={toggleWrapText}
type="button">
<WrapTextIcon fontSize="small" />
</button>
</MythicStyledTooltip>
<MythicStyledTooltip title={"Auto format JSON"} >
<button
aria-label="Auto format JSON"
className="mythic-response-render-action-button"
onClick={formatJSON}
type="button">
<CodeIcon fontSize="small" />
</button>
</MythicStyledTooltip>
{showCreateCredentialAction &&
<MythicStyledTooltip title={"Create Credential"} >
<button
aria-label="Create Credential"
className="mythic-response-render-action-button"
onClick={openCreateCredentialDialog}
type="button">
<VpnKeyIcon fontSize="small" />
</button>
</MythicStyledTooltip>
}
</div>
<label className="mythic-response-syntax-group">
<span>Syntax</span>
<select
className="mythic-response-syntax-select"
value={mode}
onChange={onChangeMode}>
{
modeOptions.map((opt) => (
<option key={"searchopt" + opt} value={opt}>{opt}</option>
))
}
</select>
</label>
</>
}
{props.toolbarNotice}
</div>
}
</div>
}
@@ -166,21 +440,15 @@ export const ResponseDisplayPlaintext = (props) =>{
<div style={{
flexGrow: 1,
height: "100%",
paddingLeft: renderColors ? "15px" : "0px",
paddingRight: renderColors ? "5px" : "0px"
minHeight: 0,
}}>
{renderColors &&
<GetOutputFormatAll data={[
{response: plaintextView, id: props?.task?.id || 0, timestamp: "1970-01-01"}]}
myTask={false}
taskID={props?.task?.id || 0}
useASNIColor={true}
messagesEndRef={null}
showTaskStatus={false}
search={undefined}
wrapText={wrapText}/>
{renderMode === RenderModes.markdown &&
<ResponseMarkdownDisplay value={plaintextView} wrapText={displayWrapText} expand={props.expand} />
}
{!renderColors &&
{renderMode === RenderModes.terminal &&
<ResponseTerminalDisplay value={plaintextView} wrapText={displayWrapText} expand={props.expand} theme={theme} />
}
{renderMode === RenderModes.plaintext &&
<AceEditor
className={"roundedBottomCorners"}
ref={currentContentRef}
@@ -189,6 +457,7 @@ export const ResponseDisplayPlaintext = (props) =>{
fontSize={14}
showGutter={ props.displayType !== 'console'}
onChange={onChangeText}
readOnly={Boolean(props?.readOnly)}
//onLoad={onLoad}
highlightActiveLine={false}
showPrintMargin={false}
@@ -198,7 +467,7 @@ export const ResponseDisplayPlaintext = (props) =>{
width={"100%"}
//style={{backgroundColor: "transparent"}}
//autoScrollEditorIntoView={true}
wrapEnabled={props.displayType !== 'console' ? wrapText : true}
wrapEnabled={props.displayType !== 'console' ? displayWrapText : true}
minLines={1}
setOptions={{
showLineNumbers: props.displayType !== 'console',
@@ -212,4 +481,4 @@ export const ResponseDisplayPlaintext = (props) =>{
</div>
)
}
}
@@ -1,40 +0,0 @@
import React from 'react';
import {Button} from '@mui/material';
import {ResponseDisplayScreenshotModal} from './ResponseDisplayScreenshotModal';
import { MythicDialog } from '../../MythicComponents/MythicDialog';
import {MythicStyledTooltip} from "../../MythicComponents/MythicStyledTooltip";
export const ResponseDisplayScreenshot = (props) =>{
const [openScreenshot, setOpenScreenshot] = React.useState(false);
const clickOpenScreenshot = () => {
setOpenScreenshot(true);
}
const scrollContent = (node, isAppearing) => {
// only auto-scroll if you issued the task
document.getElementById(`scrolltotaskbottom${props.task.id}`)?.scrollIntoView({
//behavior: "smooth",
block: "end",
inline: "nearest"
})
}
React.useLayoutEffect( () => {
scrollContent()
}, []);
return (
<>
{openScreenshot &&
<MythicDialog fullWidth={true} maxWidth="xl" open={openScreenshot}
onClose={()=>{setOpenScreenshot(false);}}
innerDialog={<ResponseDisplayScreenshotModal images={[props.agent_file_id]} onClose={()=>{setOpenScreenshot(false);}} />}
/>
}
<pre style={{display: "inline-block"}}>
{props?.plaintext || ""}
</pre>
<MythicStyledTooltip title={props?.hoverText || "View Screenshot (s)"} >
<Button color="primary" variant={props.variant ? props.variant : "contained"} onClick={clickOpenScreenshot} style={{marginBottom: "10px"}}>{props.name}</Button>
</MythicStyledTooltip>
</>
);
}
Loaded 100 of 837 files, more files were not shown because too many files have changed in this diff. Show more