diff --git a/.coveragerc b/.coveragerc
deleted file mode 100644
index e0214b113..000000000
--- a/.coveragerc
+++ /dev/null
@@ -1,9 +0,0 @@
-[run]
-source =
- ./app
-[report]
-exclude_lines =
- pragma: no cover
- raise NotImplementedError
-[html]
-directory = _htmlcov
diff --git a/.gitignore b/.gitignore
index 7c31b9aac..d905514af 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,12 +32,30 @@ data/objectives/*
!data/objectives/.gitkeep
data/backup/*
!data/backup/.gitkeep
+data/adversaries/*
+data/adversaries/**/*.yml
+!data/adversaries/.gitkeep
+!data/adversaries/.gitkeep
+data/operations/*
+data/abilities/**/*.yml
+!data/abilities/.gitkeep
+!data/operations/.gitkeep
.tox/
node_modules/
-
+plugins/mcp
+plugins/automation
+plugins/range
+plugins/spp
+mlruns/
+mlruns.db
# coverage reports
htmlcov/
.coverage
.coverage.*
+.coveragerc
*,cover
_*/
+mlruns/
+plugins/access/
+plugins/response/
+plugins/sandcat/
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 13322c63d..000000000
--- a/.gitmodules
+++ /dev/null
@@ -1,51 +0,0 @@
-[submodule "plugins/sandcat"]
- path = plugins/sandcat
- url = https://github.com/mitre/sandcat.git
-[submodule "plugins/stockpile"]
- path = plugins/stockpile
- url = https://github.com/mitre/stockpile.git
-[submodule "plugins/ssl"]
- path = plugins/ssl
- url = https://github.com/mitre/ssl.git
-[submodule "plugins/caltack"]
- path = plugins/caltack
- url = https://github.com/mitre/caltack.git
-[submodule "plugins/compass"]
- path = plugins/compass
- url = https://github.com/mitre/compass.git
-[submodule "plugins/access"]
- path = plugins/access
- url = https://github.com/mitre/access.git
-[submodule "plugins/atomic"]
- path = plugins/atomic
- url = https://github.com/mitre/atomic.git
-[submodule "plugins/response"]
- path = plugins/response
- url = https://github.com/mitre/response.git
-[submodule "plugins/gameboard"]
- path = plugins/gameboard
- url = https://github.com/mitre/gameboard.git
-[submodule "plugins/manx"]
- path = plugins/manx
- url = https://github.com/mitre/manx.git
-[submodule "plugins/training"]
- path = plugins/training
- url = https://github.com/mitre/training.git
-[submodule "plugins/builder"]
- path = plugins/builder
- url = https://github.com/mitre/builder.git
-[submodule "plugins/human"]
- path = plugins/human
- url = https://github.com/mitre/human.git
-[submodule "plugins/fieldmanual"]
- path = plugins/fieldmanual
- url = https://github.com/mitre/fieldmanual.git
-[submodule "plugins/debrief"]
- path = plugins/debrief
- url = https://github.com/mitre/debrief.git
-[submodule "plugins/emu"]
- path = plugins/emu
- url = https://github.com/mitre/emu.git
-[submodule "plugins/magma"]
- path = plugins/magma
- url = https://github.com/mitre/magma.git
diff --git a/AdversarySCHEMA.yml b/AdversarySCHEMA.yml
new file mode 100644
index 000000000..9aa0d6c51
--- /dev/null
+++ b/AdversarySCHEMA.yml
@@ -0,0 +1,17 @@
+adversary_id: 95cad57a-8e4f-4ba0-87cf-03d8a7cad0a0
+name: TEST
+description: ''
+objective: 495a9828-cab1-44dd-a0ca-66e58177d8cc
+atomic_ordering:
+- ability_id: 36eecb80-ede3-442b-8774-956e906aff02
+ metadata:
+ executor_facts:
+ linux:
+ - trait: time.sleep
+ value: '10'
+- ability_id: 36eecb80-ede3-442b-8774-956e906aff02
+ metadata:
+ executor_facts:
+ linux:
+ - trait: time.sleep
+ value: '3'
diff --git a/app/api/rest_api.py b/app/api/rest_api.py
index 1de5e1150..1bcae8d9c 100644
--- a/app/api/rest_api.py
+++ b/app/api/rest_api.py
@@ -45,6 +45,24 @@ async def enable(self):
self.app_svc.application.router.add_route('GET', '/file/download_exfil', self.download_exfil_file)
self.app_svc.application.router.add_route('GET', '/{tail:(?!plugin/|api/v2/).*}', self.handle_catch)
+ @check_authorization
+ async def enable_plugin(self, request):
+ name = request.match_info['name']
+
+ plugin_manager = self.app_svc.get_service('plugin_manager')
+
+ await plugin_manager.enable_plugin(name)
+
+ plugin = (
+ await self.data_svc.locate('plugins', dict(name=name))
+ )[0]
+
+ plugin.enabled = True
+ await self.data_svc.store(plugin)
+
+ return web.json_response({'status': 'enabled'})
+
+
async def validate_login(self, request):
return await self.auth_svc.login_user(request)
diff --git a/app/api/v2/handlers/adversary_api.py b/app/api/v2/handlers/adversary_api.py
index 511a6b783..da77c8177 100644
--- a/app/api/v2/handlers/adversary_api.py
+++ b/app/api/v2/handlers/adversary_api.py
@@ -1,4 +1,4 @@
-import json
+from typing import Tuple, Dict, Any
import aiohttp_apispec
from aiohttp import web
@@ -10,133 +10,230 @@
class AdversaryApi(BaseObjectApi):
- def __init__(self, services):
- super().__init__(description='adversary', obj_class=Adversary, schema=AdversarySchema, ram_key='adversaries',
- id_property='adversary_id', auth_svc=services['auth_svc'])
- self._api_manager = AdversaryApiManager(data_svc=services['data_svc'], file_svc=services['file_svc'])
+ """HTTP API for CRUD operations on adversaries."""
- def add_routes(self, app: web.Application):
+ def __init__(self, services: Dict[str, Any]) -> None:
+ """Initialize routes and supporting managers."""
+ super().__init__(
+ description="adversary",
+ obj_class=Adversary,
+ schema=AdversarySchema,
+ ram_key="adversaries",
+ id_property="adversary_id",
+ auth_svc=services["auth_svc"],
+ )
+ self._api_manager = AdversaryApiManager(
+ data_svc=services["data_svc"], file_svc=services["file_svc"]
+ )
+
+ def add_routes(self, app: web.Application) -> None:
+ """Register adversary routes on the application router."""
router = app.router
- adversaries_by_id_path = '/adversaries/{adversary_id}'
- router.add_get('/adversaries', self.get_adversaries)
+ adversaries_by_id_path = "/adversaries/{adversary_id}"
+ router.add_get("/adversaries", self.get_adversaries)
router.add_get(adversaries_by_id_path, self.get_adversary_by_id)
- router.add_post('/adversaries', self.create_adversary)
+ router.add_post("/adversaries", self.create_adversary)
router.add_patch(adversaries_by_id_path, self.update_adversary)
router.add_put(adversaries_by_id_path, self.create_or_update_adversary)
router.add_delete(adversaries_by_id_path, self.delete_adversary)
- @aiohttp_apispec.docs(tags=['adversaries'],
- summary='Retrieve all adversaries',
- description='Returns a list of all available adversaries in the system, including plugin, name, description, '
- 'and atomic ordering. Supply fields from the `AdversarySchema` to the include and exclude fields of the '
- '`BaseGetAllQuerySchema` in the request body to filter retrieved adversaries.')
+ @aiohttp_apispec.docs(
+ tags=["adversaries"],
+ summary="Retrieve all adversaries",
+ description=(
+ "Returns a list of all available adversaries in the system, including plugin, name, "
+ "description, and atomic ordering. Supply fields from `AdversarySchema` to the include/exclude "
+ "fields of `BaseGetAllQuerySchema` in the request to filter the result."
+ ),
+ )
@aiohttp_apispec.querystring_schema(BaseGetAllQuerySchema)
- @aiohttp_apispec.response_schema(AdversarySchema(many=True, partial=True),
- description='Returns a list in `AdversarySchema` format of all available adversaries in the system.')
- async def get_adversaries(self, request: web.Request):
+ @aiohttp_apispec.response_schema(
+ AdversarySchema(many=True, partial=True),
+ description="List of adversaries in `AdversarySchema` format.",
+ )
+ async def get_adversaries(self, request: web.Request) -> web.Response:
+ """List adversaries with optional include/exclude filtering."""
adversaries = await self.get_all_objects(request)
+ self.log.debug("[get_adversaries] count=%s", len(adversaries))
return web.json_response(adversaries)
- @aiohttp_apispec.docs(tags=['adversaries'],
- summary='Retrieve adversary by ID',
- description='Retrieve one adversary by ID. Use fields from the `AdversarySchema` in '
- 'the request body to filter retrieved adversary.',
- parameters=[{
- 'in': 'path',
- 'name': 'adversary_id',
- 'schema': {'type': 'string'},
- 'required': 'true',
- 'description': 'UUID of the adversary to be retrieved'}])
+ @aiohttp_apispec.docs(
+ tags=["adversaries"],
+ summary="Retrieve adversary by ID",
+ description=(
+ "Retrieve one adversary by ID. Use fields from the `AdversarySchema` in the request "
+ "to filter the retrieved adversary."
+ ),
+ parameters=[
+ {
+ "in": "path",
+ "name": "adversary_id",
+ "schema": {"type": "string"},
+ "required": "true",
+ "description": "UUID of the adversary to retrieve",
+ }
+ ],
+ )
@aiohttp_apispec.querystring_schema(BaseGetOneQuerySchema)
- @aiohttp_apispec.response_schema(AdversarySchema(partial=True),
- description='Returns single adversary in AdversarySchema format.')
- async def get_adversary_by_id(self, request: web.Request):
+ @aiohttp_apispec.response_schema(
+ AdversarySchema(partial=True),
+ description="Single adversary in `AdversarySchema` format.",
+ )
+ async def get_adversary_by_id(self, request: web.Request) -> web.Response:
+ """Fetch a specific adversary by its ID."""
adversary = await self.get_object(request)
+ self.log.debug("[get_adversary_by_id] id=%s", request.match_info.get("adversary_id"))
return web.json_response(adversary)
- @aiohttp_apispec.docs(tags=['adversaries'],
- summary='Create a new adversary',
- description='Create a new adversary using the format provided in the `AdversarySchema`.')
+ @aiohttp_apispec.docs(
+ tags=["adversaries"],
+ summary="Create a new adversary",
+ description="Create a new adversary using the format provided in the `AdversarySchema`.",
+ )
@aiohttp_apispec.request_schema(AdversarySchema)
- @aiohttp_apispec.response_schema(AdversarySchema, description='A single adversary in AdversarySchema format.')
- async def create_adversary(self, request: web.Request):
+ @aiohttp_apispec.response_schema(
+ AdversarySchema, description="The created adversary in `AdversarySchema` format."
+ )
+ async def create_adversary(self, request: web.Request) -> web.Response:
+ """Create a new adversary and verify it post-creation."""
adversary = await self.create_on_disk_object(request)
+ self.log.debug("[create_adversary] created=%s", getattr(adversary, "adversary_id", None))
adversary = await self._api_manager.verify_adversary(adversary)
+ self.log.debug("[create_adversary] verified id=%s", getattr(adversary, "adversary_id", None))
return web.json_response(adversary.display)
- @aiohttp_apispec.docs(tags=['adversaries'],
- summary='Update an adversary',
- description='Update an adversary using fields from the `AdversarySchema` in the request body.',
- parameters=[{
- 'in': 'path',
- 'name': 'adversary_id',
- 'schema': {'type': 'string'},
- 'required': 'true',
- 'description': 'UUID of the adversary to be updated'
- }])
- @aiohttp_apispec.docs(tags=['adversaries'])
- @aiohttp_apispec.request_schema(AdversarySchema(partial=True, exclude=['adversary_id']))
- @aiohttp_apispec.response_schema(AdversarySchema,
- description='The updated adversary in AdversarySchema format.')
- async def update_adversary(self, request: web.Request):
- adversary = await self.update_on_disk_object(request)
- adversary = await self._api_manager.verify_adversary(adversary)
- return web.json_response(adversary.display)
+ @aiohttp_apispec.docs(
+ tags=["adversaries"],
+ summary="Update an adversary",
+ description="Update an adversary using fields from the `AdversarySchema` in the request body.",
+ parameters=[
+ {
+ "in": "path",
+ "name": "adversary_id",
+ "schema": {"type": "string"},
+ "required": "true",
+ "description": "UUID of the adversary to be updated",
+ }
+ ],
+ )
+ @aiohttp_apispec.request_schema(AdversarySchema(partial=True, exclude=["adversary_id"]))
+ @aiohttp_apispec.response_schema(
+ AdversarySchema, description="The updated adversary in `AdversarySchema` format."
+ )
+ async def update_adversary(self, request: web.Request) -> web.Response:
+ # ✅ read raw JSON BEFORE apispec strips it
+ raw_data = await request.json()
+
+ adversary_id = request.match_info.get("adversary_id")
+ raw_data["adversary_id"] = adversary_id
+ self.log.debug(
+ "[update_adversary FIX] using raw payload keys=%s",
+ sorted(raw_data.keys()),
+ )
+ try:
+ adversary = await self._api_manager.find_and_update_on_disk_object(
+ data=raw_data,
+ search={"adversary_id": adversary_id},
+ ram_key="adversaries",
+ id_property="adversary_id",
+ obj_class=Adversary,
+ )
+ except Exception:
+ self.log.exception(
+ "[update_adversary] find_and_update_on_disk_object crashed. "
+ "atomic_ordering_type=%s tags_type=%s raw_data=%r",
+ type(raw_data.get("atomic_ordering")).__name__,
+ type(raw_data.get("tags")).__name__,
+ raw_data,
+ )
+ raise
+ self.log.debug(
+ "[update_adversary] adversary.atomic_ordering (post-load)=%r",
+ getattr(adversary, "atomic_ordering", None)
+ )
+ self.log.debug(
+ "[update_adversary] adversary.metadata (post-load)=%r",
+ getattr(adversary, "metadata", None)
+ )
+ if adversary is None:
+ self.log.warning(
+ "[update_adversary] not found id=%s",
+ adversary_id
+ )
+
+ raise web.HTTPNotFound(reason="Adversary not found")
- @aiohttp_apispec.docs(tags=['adversaries'],
- summary='Create or update an adversary',
- description='Attempt to update an adversaries using fields from the `AdversarySchema` '
- 'in the request body. If the adversary does not already exist, '
- 'then create a new one using the `AdversarySchema` format.',
- parameters=[{
- 'in': 'path',
- 'name': 'adversary_id',
- 'schema': {'type': 'string'},
- 'required': 'true',
- 'description': 'UUID of the adversary to be created or updated'
- }])
- @aiohttp_apispec.request_schema(AdversarySchema(partial=True))
- @aiohttp_apispec.response_schema(AdversarySchema,
- description='A single adversary, either newly created or updated, in AdversarySchema format.')
- async def create_or_update_adversary(self, request: web.Request):
- adversary = await self.create_or_update_on_disk_object(request)
adversary = await self._api_manager.verify_adversary(adversary)
return web.json_response(adversary.display)
- @aiohttp_apispec.docs(tags=['adversaries'], summary='Deletes an adversary.',
- description='Deletes an existing adversary.',
- parameters=[{
- 'in': 'path',
- 'name': 'adversary_id',
- 'schema': {'type': 'string'},
- 'required': 'true',
- 'description': 'UUID of the adversary to be retrieved'
- }])
- @aiohttp_apispec.response_schema(AdversarySchema(partial=True), code=204,
- description='HTTP 204 Status Code (No Content)')
- async def delete_adversary(self, request: web.Request):
+ @aiohttp_apispec.docs(
+ tags=["adversaries"],
+ summary="Deletes an adversary.",
+ description="Deletes an existing adversary.",
+ parameters=[
+ {
+ "in": "path",
+ "name": "adversary_id",
+ "schema": {"type": "string"},
+ "required": "true",
+ "description": "UUID of the adversary to delete",
+ }
+ ],
+ )
+ @aiohttp_apispec.response_schema(
+ AdversarySchema(partial=True), code=204, description="HTTP 204 Status Code (No Content)"
+ )
+ async def delete_adversary(self, request: web.Request) -> web.Response:
+ """Delete an adversary by ID."""
await self.delete_on_disk_object(request)
+ self.log.debug("[delete_adversary] deleted id=%s", request.match_info.get("adversary_id"))
return web.HTTPNoContent()
- async def create_on_disk_object(self, request: web.Request):
- data = await request.json()
- data.pop('id', None)
- await self._error_if_object_with_id_exists(data.get(self.id_property))
- access = await self.get_request_permissions(request)
- obj = await self._api_manager.create_on_disk_object(data, access, self.ram_key, self.id_property,
- self.obj_class)
- return obj
-
- async def _parse_common_data_from_request(self, request) -> (dict, dict, str, dict, dict):
- data = {}
- raw_body = await request.read()
- if raw_body:
- data = json.loads(raw_body)
- data.pop('id', None)
- obj_id = request.match_info.get(self.id_property, '')
- if obj_id:
- data[self.id_property] = obj_id
- access = await self.get_request_permissions(request)
- query = {self.id_property: obj_id}
- search = {**query, **access}
- return data, access, obj_id, query, search
+ @aiohttp_apispec.docs(
+ tags=["adversaries"],
+ summary="Create or update an adversary",
+ description=(
+ "Attempt to update an adversary using fields from the `AdversarySchema`. If the "
+ "adversary does not exist, a new one is created."
+ ),
+ parameters=[
+ {
+ "in": "path",
+ "name": "adversary_id",
+ "schema": {"type": "string"},
+ "required": "true",
+ "description": "UUID of the adversary to create or update",
+ }
+ ],
+ )
+ @aiohttp_apispec.request_schema(AdversarySchema(partial=True))
+ @aiohttp_apispec.response_schema(
+ AdversarySchema,
+ description="A single adversary, either newly created or updated, in `AdversarySchema` format.",
+ )
+ async def create_or_update_adversary(self, request: web.Request) -> web.Response:
+ """Create a new adversary or update an existing one, then verify and return JSON."""
+ try:
+ # Let the base class handle payload parsing, access, ID setting, and validation
+ obj = await self.create_on_disk_object(request)
+
+ # Post-creation/update verification step (e.g., metadata backfill)
+ obj = await self._api_manager.verify_adversary(obj)
+
+ self.log.debug(
+ "[create_or_update_adversary] final display=%s", getattr(obj, "display", {})
+ )
+ self.log.debug(
+ "[create_or_update_adversary] metadata=%s", getattr(obj, "metadata", {})
+ )
+ return web.json_response(obj.display)
+
+ except web.HTTPException:
+ # Preserve existing behavior for known HTTP exceptions
+ raise
+
+ except Exception as exc:
+ self.log.exception("[create_or_update_adversary] unexpected error: %s", exc)
+ raise web.HTTPInternalServerError(
+ reason="Internal error during adversary create-or-update"
+ )
diff --git a/app/api/v2/handlers/base_object_api.py b/app/api/v2/handlers/base_object_api.py
index c47dc1c83..d5b204877 100644
--- a/app/api/v2/handlers/base_object_api.py
+++ b/app/api/v2/handlers/base_object_api.py
@@ -1,6 +1,6 @@
import abc
import json
-
+import os
from aiohttp import web
from app.api.v2.handlers.base_api import BaseApi
@@ -65,11 +65,19 @@ async def create_on_disk_object(self, request: web.Request):
return obj
async def _error_if_object_with_id_exists(self, obj_id: str):
- """Throw an error if an object (of the same type) exists with the given id"""
+ """Throw an error if an object exists both in memory and on disk."""
if obj_id:
+ self.log.debug('[BaseObjectApi] Checking for existing ID: %s', obj_id)
search = {self.id_property: obj_id}
- if self._api_manager.find_object(self.ram_key, search):
- raise JsonHttpBadRequest(f'{self.description.capitalize()} with given id already exists: {obj_id}')
+ obj = self._api_manager.find_object(self.ram_key, search)
+
+ if obj:
+ # Also check if it is persisted to disk
+ file_path = f'data/{self.ram_key}/{obj_id}.yml'
+ if os.path.exists(file_path):
+ raise JsonHttpBadRequest(f'{self.description.capitalize()} with given id already exists: {obj_id}')
+ else:
+ self.log.warning('[BaseObjectApi] Adversary found in memory but missing on disk: %s', file_path)
async def update_object(self, request: web.Request):
data, access, obj_id, query, search = await self._parse_common_data_from_request(request)
@@ -80,13 +88,27 @@ async def update_object(self, request: web.Request):
return obj
async def update_on_disk_object(self, request: web.Request):
- data, access, obj_id, query, search = await self._parse_common_data_from_request(request)
+ try:
+ data, access, obj_id, query, search = await self._parse_common_data_from_request(request)
+ self.log.debug('[update_on_disk_object] Parsed data: %s', json.dumps(data, indent=2))
+ sanitized = {k: (v if isinstance(v, (str, int, float, bool, list, dict, type(None))) else repr(v))
+ for k, v in search.items()}
+ self.log.debug('[update_on_disk_object] Search query: %s', json.dumps(sanitized, indent=2))
+
+
+ obj = await self._api_manager.find_and_update_on_disk_object(
+ data, search, self.ram_key, self.id_property, self.obj_class
+ )
+
+ if not obj:
+ self.log.warning('[update_on_disk_object] Object not found for ID: %s', obj_id)
+ # raise JsonHttpNotFound(f'{self.description.capitalize()} not found: {obj_id}')
+ return obj
+
+ except Exception as e:
+ self.log.exception('[update_on_disk_object] Exception occurred: %s', str(e))
+ raise web.HTTPInternalServerError(reason='Internal error during adversary update')
- obj = await self._api_manager.find_and_update_on_disk_object(data, search, self.ram_key, self.id_property,
- self.obj_class)
- if not obj:
- raise JsonHttpNotFound(f'{self.description.capitalize()} not found: {obj_id}')
- return obj
async def create_or_update_object(self, request: web.Request):
data, access, obj_id, query, search = await self._parse_common_data_from_request(request)
diff --git a/app/api/v2/handlers/health_api.py b/app/api/v2/handlers/health_api.py
index cb7e7c7cd..bd07ba0be 100644
--- a/app/api/v2/handlers/health_api.py
+++ b/app/api/v2/handlers/health_api.py
@@ -6,7 +6,7 @@
import app
from app.api.v2.handlers.base_api import BaseApi
from app.api.v2.schemas.caldera_info_schemas import CalderaInfoSchema
-
+from app.utility.base_world import BaseWorld
class HealthApi(BaseApi):
def __init__(self, services):
@@ -22,6 +22,11 @@ def add_routes(self, app: web.Application):
description='Returns the status of Caldera and additional details including versions of system components')
@aiohttp_apispec.response_schema(CalderaInfoSchema, 200, description='Includes all loaded plugins and system components.')
async def get_health_info(self, request):
+ if BaseWorld.get_config(prop="restarting"):
+ return web.json_response(
+ {"status": "restarting"},
+ status=503
+ )
loaded_plugins_sorted = sorted(self._app_svc.get_loaded_plugins(), key=operator.attrgetter('name'))
access = await self._auth_svc.get_permissions(request)
diff --git a/app/api/v2/handlers/plugins_api.py b/app/api/v2/handlers/plugins_api.py
index 5d17a02cf..ede79f476 100644
--- a/app/api/v2/handlers/plugins_api.py
+++ b/app/api/v2/handlers/plugins_api.py
@@ -1,22 +1,28 @@
import aiohttp_apispec
from aiohttp import web
+import asyncio, os, sys
from app.api.v2.handlers.base_object_api import BaseObjectApi
from app.api.v2.managers.base_api_manager import BaseApiManager
from app.api.v2.schemas.base_schemas import BaseGetAllQuerySchema, BaseGetOneQuerySchema
from app.objects.c_plugin import Plugin, PluginSchema
+from app.utility.base_world import BaseWorld
class PluginApi(BaseObjectApi):
def __init__(self, services):
super().__init__(description='plugins', obj_class=Plugin, schema=PluginSchema, ram_key='plugins',
id_property='name', auth_svc=services['auth_svc'])
+ self.services = services
self._api_manager = BaseApiManager(data_svc=services['data_svc'], file_svc=services['file_svc'])
def add_routes(self, app: web.Application):
router = app.router
router.add_get('/plugins', self.get_plugins)
router.add_get('/plugins/{name}', self.get_plugin_by_name)
+ router.add_post('/plugins/{name}/enable', self.enable_plugin)
+ router.add_post('/plugins/disable', self.disable_plugins)
+ router.add_get('/plugins/build-status', self.build_status)
@aiohttp_apispec.docs(tags=['plugins'],
summary='Retrieve all plugins',
@@ -46,3 +52,146 @@ async def get_plugins(self, request: web.Request):
async def get_plugin_by_name(self, request: web.Request):
plugin = await self.get_object(request)
return web.json_response(plugin)
+
+ @aiohttp_apispec.docs(
+ tags=['plugins'],
+ summary='Enable plugin',
+ description='Enables a plugin, builds GUI if required, and signals restart.'
+ )
+ async def enable_plugin(self, request: web.Request):
+ plugin_name = request.match_info["name"]
+
+ plugin_manager = self.services.get("plugin_manager")
+ app_svc = self.services.get("app_svc")
+
+ body = {}
+ try:
+ body = await request.json()
+ except Exception:
+ pass
+ build_gui = bool(body.get("build_gui", True))
+
+ # 1) persist enabled plugin
+ enabled_plugins = BaseWorld.get_config(name="main", prop="plugins") or []
+
+ if plugin_name not in enabled_plugins:
+ enabled_plugins.append(plugin_name)
+
+ # ✅ persist enabled plugin in runtime config
+ BaseWorld.set_config(
+ name="main",
+ prop="plugins",
+ value=enabled_plugins
+ )
+ if build_gui:
+ # ✅ mark runtime state (NOT persisted)
+ BaseWorld.set_config(
+ name="main",
+ prop="restarting",
+ value=True
+ )
+
+ try:
+ await app_svc._save_configurations()
+
+ except Exception as e:
+ print(f"Error saving configurations: {e}")
+
+ # 2) schedule background work (DO NOT await)
+ asyncio.create_task(self._enable_build_restart(plugin_manager, plugin_name, build_gui))
+
+ # 3) return immediately
+ return web.json_response({
+ "enabled": True,
+ "restart_required": build_gui
+ })
+
+ @aiohttp_apispec.docs(
+ tags=['plugins'],
+ summary='Disable plugins',
+ description='Disables plugins, rebuilds UI, and restarts Caldera.'
+ )
+ async def disable_plugins(self, request: web.Request):
+ plugin_manager = self.services.get("plugin_manager")
+ app_svc = self.services.get("app_svc")
+
+ body = await request.json()
+ plugins_to_disable = body.get("plugins", []) or []
+
+ # 1) read currently enabled
+ enabled_plugins = BaseWorld.get_config(name="main", prop="plugins") or []
+
+ # (optional safety) prevent disabling core plugins
+ core = set(getattr(plugin_manager, "CORE_PLUGINS", []))
+ plugins_to_disable = [p for p in plugins_to_disable if p not in core]
+
+ # 2) compute remaining (THIS is the key constraint you described)
+ remaining_plugins = [p for p in enabled_plugins if p not in plugins_to_disable]
+
+ # 3) if nothing changed, do nothing
+ if remaining_plugins == enabled_plugins:
+ return web.json_response({
+ "disabled": [],
+ "restart_required": False
+ })
+
+ # 4) persist remaining enabled plugins
+ BaseWorld.set_config(name="main", prop="plugins", value=remaining_plugins)
+
+ await app_svc._save_configurations()
+
+ # runtime-only flag (do not persist if your system saves all BaseWorld props)
+ BaseWorld.set_config(name="main", prop="restarting", value=True)
+
+ # 5) background rebuild + restart (do not await)
+ asyncio.create_task(self._disable_build_restart(plugin_manager, remaining_plugins))
+
+ return web.json_response({
+ "disabled": plugins_to_disable,
+ "restart_required": True
+ })
+ async def _enable_build_restart(self, plugin_manager, plugin_name, build_gui):
+ await asyncio.sleep(0.5)
+
+ print("[plugin_manager] starting async enable/build")
+
+ try:
+ await plugin_manager.enable_plugin(
+ plugin_name,
+ build_gui=build_gui,
+ install_deps=True
+ )
+ except Exception:
+ import traceback
+ traceback.print_exc()
+ return
+
+ if build_gui:
+ print("[plugin_manager] restarting caldera after GUI build")
+
+ # small delay so logs flush
+ await asyncio.sleep(0.5)
+
+ os.execv(sys.executable, [sys.executable] + sys.argv)
+
+ async def _disable_build_restart(self, plugin_manager, remaining_plugins):
+ await asyncio.sleep(0.5)
+
+ print("[plugin_manager] rebuilding GUI after plugin disable")
+
+ try:
+ # reuse existing build pipeline
+ await plugin_manager._build_plugin_gui_if_needed("magma")
+ except Exception:
+ import traceback
+ traceback.print_exc()
+ return
+
+ await asyncio.sleep(0.5)
+
+ os.execv(sys.executable, [sys.executable] + sys.argv)
+
+ async def build_status(self, request):
+ plugin_manager = self.services.get("plugin_manager")
+
+ return web.json_response(plugin_manager.build_state)
diff --git a/app/api/v2/managers/adversary_api_manager.py b/app/api/v2/managers/adversary_api_manager.py
index ce66475c3..30ec5b402 100644
--- a/app/api/v2/managers/adversary_api_manager.py
+++ b/app/api/v2/managers/adversary_api_manager.py
@@ -1,5 +1,6 @@
from app.api.v2.managers.base_api_manager import BaseApiManager
from app.objects.c_adversary import Adversary
+import traceback
class AdversaryApiManager(BaseApiManager):
@@ -7,6 +8,17 @@ def __init__(self, data_svc, file_svc):
super().__init__(data_svc=data_svc, file_svc=file_svc)
async def verify_adversary(self, adversary: Adversary):
- adversary.verify(log=self.log, abilities=self._data_svc.ram['abilities'],
- objectives=self._data_svc.ram['objectives'])
- return adversary
+ if not adversary:
+ self.log.warning('call stack verify_adversary: %s',''.join(traceback.format_stack()))
+ try:
+ adversary.verify(
+ log=self.log,
+ abilities=self._data_svc.ram['abilities'],
+ objectives=self._data_svc.ram['objectives']
+ )
+ self.log.debug('[verify_adversary] Successfully verified adversary: %s', adversary.display)
+ return adversary
+ except Exception as e:
+ self.log.exception('[verify_adversary] Exception during verify: %s', str(e))
+ raise
+
diff --git a/app/api/v2/managers/base_api_manager.py b/app/api/v2/managers/base_api_manager.py
index f72368790..8583cb6db 100644
--- a/app/api/v2/managers/base_api_manager.py
+++ b/app/api/v2/managers/base_api_manager.py
@@ -29,6 +29,7 @@ def find_objects(self, ram_key: str, search: dict = None):
if not search or obj.match(search):
yield obj
+ #do we need this functions seems useless
def find_object(self, ram_key: str, search: dict = None):
for obj in self.find_objects(ram_key, search):
return obj
@@ -65,7 +66,7 @@ def create_object_from_schema(self, schema: SchemaMeta, data: dict, access: Base
async def create_on_disk_object(self, data: dict, access: dict, ram_key: str, id_property: str, obj_class: type):
obj_id = data.get(id_property) or str(uuid.uuid4())
data[id_property] = obj_id
-
+
file_path = await self._get_new_object_file_path(data[id_property], ram_key)
allowed = self._get_allowed_from_access(access)
await self._save_and_reload_object(file_path, data, obj_class, allowed)
@@ -96,25 +97,81 @@ def replace_object(self, obj: Any, data: dict):
return new_obj.store(self._data_svc.ram)
async def find_and_update_on_disk_object(self, data: dict, search: dict, ram_key: str, id_property: str, obj_class: type):
+ self.log.debug(
+ "[find_and_update] RAW data.atomic_ordering=%r",
+ data.get("atomic_ordering")
+ )
for obj in self.find_objects(ram_key, search):
- new_obj = await self.update_on_disk_object(obj, data, ram_key, id_property, obj_class)
- return new_obj
-
- async def update_on_disk_object(self, obj: Any, data: dict, ram_key: str, id_property: str, obj_class: type):
+ new_obj = await self.update_on_disk_object(obj, data, ram_key, id_property)
+ if new_obj:
+ return new_obj
+ return None
+
+ async def update_on_disk_object(self, obj: Any, data: dict, ram_key: str, id_property: str):
+ self.log.debug("[update_on_disk_object] incoming data keys=%s", sorted(list(data.keys())))
+ self.log.debug("[update_on_disk_object] has atomic_ordering? %s", "atomic_ordering" in data)
obj_id = getattr(obj, id_property)
file_path = await self._get_existing_object_file_path(obj_id, ram_key)
- existing_obj_data = dict(self.strip_yml(file_path)[0])
- existing_obj_data.update(data)
+ try:
+ existing_obj_data = dict(self.strip_yml(file_path)[0])
+ except (FileNotFoundError, IndexError) as e:
+ self.log.warning(f'[update_on_disk_object] Missing file or malformed YAML for {obj_id}: {e}')
+ return None # allow the calling handler to fallback to POST
+
+ # Explicitly replace atomic_ordering if provided
+ if "atomic_ordering" in data:
+ # atomic_ordering is authoritative
+ existing_obj_data["atomic_ordering"] = data["atomic_ordering"]
+
+ # If steps carry metadata, drop legacy top-level metadata
+ if "metadata" not in data:
+ ao = data.get("atomic_ordering") or []
+ if any(isinstance(s, dict) and "metadata" in s for s in ao):
+ existing_obj_data.pop("metadata", None)
+
+ # Shallow-merge everything else
+ for k, v in data.items():
+ if k != "atomic_ordering":
+ existing_obj_data[k] = v
+
+ await self.replace_on_disk_object(
+ obj=obj,
+ data=existing_obj_data,
+ ram_key=ram_key,
+ id_property=id_property
+ )
+
+ updated_obj = next(self.find_objects(ram_key, {id_property: obj_id}), None)
+ if not updated_obj:
+ self.log.warning(f'[update_on_disk_object] Object {obj_id} not found in RAM after reload')
+ return updated_obj
- await self._save_and_reload_object(file_path, existing_obj_data, obj_class, obj.access)
- return next(self.find_objects(ram_key, {id_property: obj_id}))
async def replace_on_disk_object(self, obj: Any, data: dict, ram_key: str, id_property: str):
obj_id = getattr(obj, id_property)
file_path = await self._get_existing_object_file_path(obj_id, ram_key)
+ # 🧠 Update in-memory object fields
+ obj.update('name', data.get('name'))
+ obj.update('description', data.get('description'))
+
+ # ✅ THIS is the critical fix
+ obj.atomic_ordering = data.get('atomic_ordering', [])
+ # if any(isinstance(s, dict) and "metadata" in s for s in obj.atomic_ordering):
+ # obj.metadata = {}
+ self.log.debug(
+ "[REPLACE] Writing atomic_ordering (len=%s)",
+ len(obj.atomic_ordering)
+ )
+
+ obj.update('objective', data.get('objective'))
+ obj.update('tags', list(data.get('tags', [])))
+ obj.update('plugin', data.get('plugin'))
+
+ dumped = obj.schema.dump(obj)
+
- await self._save_and_reload_object(file_path, data, type(obj), obj.access)
+ await self._save_and_reload_object(file_path, dumped, type(obj), obj.access)
return next(self.find_objects(ram_key, {id_property: obj_id}))
async def remove_object_from_memory_by_id(self, identifier: str, ram_key: str, id_property: str):
@@ -140,8 +197,29 @@ async def _get_existing_object_file_path(self, identifier: str, ram_key: str) ->
async def _save_and_reload_object(self, file_path: str, data: dict, obj_type: type, access: BaseWorld.Access):
"""Save data as YAML and reload from disk into memory"""
- await self._file_svc.save_file(file_path, yaml.dump(data, encoding='utf-8', sort_keys=False), '', encrypt=False)
- await self._data_svc.load_yaml_file(obj_type, file_path, access)
+ self.log.debug('--- _save_and_reload_object START ---')
+ self.log.debug('File path: %s', file_path)
+ self.log.debug('Object type: %s', obj_type)
+ self.log.debug('Access level: %s', access)
+ self.log.debug('Writing object to file: %s', file_path)
+ try:
+ yaml_data = yaml.dump(data, encoding='utf-8', sort_keys=False, Dumper=yaml.SafeDumper)
+ self.log.debug('YAML data prepared for write:\n%s', yaml_data.decode('utf-8'))
+
+ await self._file_svc.save_file(file_path, yaml_data, '', encrypt=False)
+ self.log.debug('File written to disk: %s', file_path)
+ except Exception as e:
+ self.log.exception('Exception during file write: %s', str(e))
+ raise
+ try:
+ await self._data_svc.load_yaml_file(obj_type, file_path, access)
+ self.log.debug('Reloaded object from disk into memory: %s', file_path)
+
+ except Exception as e:
+ self.log.exception('Exception during YAML load: %s', str(e))
+ raise
+
+ self.log.debug('--- _save_and_reload_object END ---')
@staticmethod
def _create_default_logger():
diff --git a/app/api/v2/managers/config_api_manager.py b/app/api/v2/managers/config_api_manager.py
index ddcde7aa2..559ca7d7e 100644
--- a/app/api/v2/managers/config_api_manager.py
+++ b/app/api/v2/managers/config_api_manager.py
@@ -27,7 +27,6 @@ def filter_keys(mapping, keys_to_remove):
return filtered
-
def filter_sensitive_props(config_map):
"""Return a copy of `config_map` with top-level sensitive keys removed."""
return filter_keys(config_map, keys_to_remove=SENSITIVE_CONFIG_PROPS)
@@ -69,20 +68,30 @@ def update_main_config(self, prop, value):
if is_sensitive_prop(prop):
raise ConfigUpdateNotAllowed(prop)
- if prop == 'plugin':
- enabled_plugins = self._config_interface.get_config(
- name='main',
- prop='plugins'
+ # UI sends "plugin" (singular)
+ if prop in ('plugin', 'plugins'):
+ enabled_plugins = (
+ self._config_interface.get_config(
+ name='main',
+ prop='plugins'
+ ) or []
)
if value not in enabled_plugins:
enabled_plugins.append(value)
- else:
+
self._config_interface.set_config(
name='main',
- prop=prop,
- value=value
+ prop='plugins',
+ value=enabled_plugins
)
+ return
+
+ self._config_interface.set_config(
+ name='main',
+ prop=prop,
+ value=value
+ )
async def update_global_agent_config(self, sleep_min: int = None, sleep_max: int = None, watchdog: int = None,
untrusted_timer: int = None, implant_name: str = None, bootstrap_abilities: List[str] = None, deadman_abilities=None):
diff --git a/app/api/v2/managers/operation_api_manager.py b/app/api/v2/managers/operation_api_manager.py
index e7409a62f..b11911cf1 100644
--- a/app/api/v2/managers/operation_api_manager.py
+++ b/app/api/v2/managers/operation_api_manager.py
@@ -142,6 +142,7 @@ async def setup_operation(self, data: dict, access: BaseWorld.Access):
data['planner'] = await self._construct_and_dump_planner(planner_id)
adversary_id = data.pop('adversary', {}).get('adversary_id', '')
data['adversary'] = await self._construct_and_dump_adversary(adversary_id)
+ adversary = data['adversary']
fact_source_id = data.pop('source', {}).get('id', '')
data['source'] = await self._construct_and_dump_source(fact_source_id)
operation = OperationSchema().load(data)
diff --git a/app/contacts/contact_http.py b/app/contacts/contact_http.py
index 9650a9544..23c045d4a 100644
--- a/app/contacts/contact_http.py
+++ b/app/contacts/contact_http.py
@@ -36,3 +36,4 @@ async def _beacon(self, request):
return web.Response(text=self.contact_svc.encode_string(json.dumps(response)))
except Exception as e:
self.log.error('Malformed beacon: %s' % e)
+ self.log.exception('Malformed beacon Exception')
diff --git a/app/contacts/contact_tcp.py b/app/contacts/contact_tcp.py
index 3cb2300c0..e95dba69b 100644
--- a/app/contacts/contact_tcp.py
+++ b/app/contacts/contact_tcp.py
@@ -6,7 +6,10 @@
from typing import Tuple
from app.utility.base_world import BaseWorld
-from plugins.manx.app.c_session import Session
+try:
+ from plugins.manx.app.c_session import Session
+except ModuleNotFoundError:
+ Session = None
class Contact(BaseWorld):
diff --git a/app/objects/c_adversary.py b/app/objects/c_adversary.py
index bcc85fd42..673209890 100644
--- a/app/objects/c_adversary.py
+++ b/app/objects/c_adversary.py
@@ -1,13 +1,16 @@
import uuid
import marshmallow as ma
+import logging
+logger = logging.getLogger('adversary')
+logger.setLevel(logging.DEBUG)
from app.objects.interfaces.i_object import FirstClassObjectInterface
from app.utility.base_object import BaseObject
-DEFAULT_OBJECTIVE_ID = '495a9828-cab1-44dd-a0ca-66e58177d8cc'
+DEFAULT_OBJECTIVE_ID = '495a9828-cab1-44dd-a0ca-66e58177d8cc'
class AdversarySchema(ma.Schema):
@@ -17,12 +20,15 @@ class Meta:
adversary_id = ma.fields.String()
name = ma.fields.String()
description = ma.fields.String()
- atomic_ordering = ma.fields.List(ma.fields.String())
+ atomic_ordering = ma.fields.List(
+ ma.fields.Raw(), # Accepts either str or dict — we'll validate in post_load
+ )
objective = ma.fields.String()
tags = ma.fields.List(ma.fields.String(), allow_none=True)
has_repeatable_abilities = ma.fields.Boolean(dump_only=True)
plugin = ma.fields.String(load_default=None)
+
@ma.pre_load
def fix_id(self, adversary, **_):
if 'id' in adversary:
@@ -46,52 +52,103 @@ def remove_properties(self, data, **_):
data.pop('has_repeatable_abilities', None)
return data
- @ma.post_load
- def build_adversary(self, data, **kwargs):
- return None if kwargs.get('partial') is True else Adversary(**data)
+ # @ma.post_load
+ # def build_adversary(self, data, **kwargs):
+ # try:
+ # atomic_ordering = data.get('atomic_ordering', [])
+ # metadata = {}
+
+ # for idx, step in enumerate(atomic_ordering):
+ # if isinstance(step, dict):
+ # step_metadata = {}
+ # if 'facts' in step:
+ # step_metadata['facts'] = step['facts']
+ # if 'metadata' in step and isinstance(step['metadata'], dict):
+ # executor_facts = step['metadata'].get('executor_facts')
+ # if executor_facts:
+ # step_metadata['executor_facts'] = executor_facts
+
+ # if step_metadata:
+ # metadata[str(idx)] = step_metadata
+
+ # # Do NOT overwrite atomic_ordering here
+ # data['metadata'] = data.get('metadata', {})
+ # data['metadata'].update(metadata)
+
+ # return Adversary(**data)
+ # except Exception as e:
+ # traceback.print_exc()
+ # raise
+
+ @ma.post_load
+ def build_adversary(self, data, **kwargs):
+ # Atomic ordering already contains full step metadata.
+ # Do NOT synthesize or index metadata here.
+ return Adversary(**data)
class Adversary(FirstClassObjectInterface, BaseObject):
schema = AdversarySchema()
+ def __new__(cls, *args, **kwargs):
+ instance = super().__new__(cls)
+ instance.log = logger
+ return instance
@property
def unique(self):
return self.hash('%s' % self.adversary_id)
- def __init__(self, name='', adversary_id='', description='', atomic_ordering=(), objective='', tags=None, plugin=''):
+ def __init__(self, name='', adversary_id='', description='', atomic_ordering=(), objective='', tags=None, plugin='', metadata=None, **_):
super().__init__()
self.adversary_id = adversary_id if adversary_id else str(uuid.uuid4())
self.name = name
self.description = description
- self.atomic_ordering = atomic_ordering
+ self.atomic_ordering = list(atomic_ordering or [])
+ self.tags = list(tags or [])
self.objective = objective or DEFAULT_OBJECTIVE_ID
- self.tags = set(tags) if tags else set()
self.has_repeatable_abilities = False
self.plugin = plugin
-
+ self.metadata = metadata or {}
+ self.log = logger
+
def store(self, ram):
+ self.log.debug(
+ "[store] self.atomic_ordering=%r",
+ self.atomic_ordering
+ )
existing = self.retrieve(ram['adversaries'], self.unique)
if not existing:
ram['adversaries'].append(self)
return self.retrieve(ram['adversaries'], self.unique)
existing.update('name', self.name)
existing.update('description', self.description)
+ self.log.debug(
+ "[store] existing.atomic_ordering BEFORE=%r",
+ getattr(existing, "atomic_ordering", None)
+ )
existing.update('atomic_ordering', self.atomic_ordering)
+ self.log.debug(
+ "[store] existing.atomic_ordering AFTER=%r",
+ existing.atomic_ordering
+ )
existing.update('objective', self.objective)
- existing.update('tags', self.tags)
+ existing.update('tags', list(self.tags or []))
existing.update('has_repeatable_abilities', self.check_repeatable_abilities(ram['abilities']))
existing.update('plugin', self.plugin)
+
return existing
def verify(self, log, abilities, objectives):
- for ability_id in self.atomic_ordering:
- if not next((ability for ability in abilities if ability.ability_id == ability_id), None):
- log.warning('Ability referenced in adversary %s but not found: %s', self.adversary_id, ability_id)
+ for step in self.atomic_ordering:
+ ability_id = step if isinstance(step, str) else step.get('ability_id')
+ if not any(ability.ability_id == ability_id for ability in abilities):
+ log.warning('Ability referenced in adversary %s but not found: %s',
+ self.adversary_id, ability_id)
if not self.objective:
self.objective = DEFAULT_OBJECTIVE_ID
- elif not next((objective for objective in objectives if objective.id == self.objective), None):
+ elif not any(obj.id == self.objective for obj in objectives):
log.warning('Objective referenced in adversary %s but not found: %s. Setting default objective.',
self.adversary_id, self.objective)
self.objective = DEFAULT_OBJECTIVE_ID
@@ -108,4 +165,9 @@ async def which_plugin(self):
return self.plugin
def check_repeatable_abilities(self, ability_list):
- return any(ab.repeatable for ab_id in self.atomic_ordering for ab in ability_list if ab.ability_id == ab_id)
+ for step in self.atomic_ordering:
+ ability_id = step if isinstance(step, str) else step.get('ability_id')
+ for ab in ability_list:
+ if ab.ability_id == ability_id and ab.repeatable:
+ return True
+ return False
\ No newline at end of file
diff --git a/app/objects/c_plugin.py b/app/objects/c_plugin.py
index 5c5308f1f..be2fa10a2 100644
--- a/app/objects/c_plugin.py
+++ b/app/objects/c_plugin.py
@@ -6,6 +6,7 @@
from app.objects.interfaces.i_object import FirstClassObjectInterface
from app.utility.base_object import BaseObject
+from app.utility.base_world import BaseWorld
class PluginSchema(ma.Schema):
@@ -25,6 +26,7 @@ class Plugin(FirstClassObjectInterface, BaseObject):
schema = PluginSchema()
display_schema = PluginSchema(only=['name', 'description', 'enabled', 'address'])
+ REQUIRED_PLUGINS = {'magma', 'stockpile', 'manx'}
@property
def unique(self):
@@ -39,6 +41,14 @@ def __init__(self, name='virtual', description=None, address=None, enabled=False
self.data_dir = data_dir
self.access = access if access else self.Access.APP
+ # def store(self, ram):
+ # existing = self.retrieve(ram['plugins'], self.unique)
+ # if not existing:
+ # ram['plugins'].append(self)
+ # return self.retrieve(ram['plugins'], self.unique)
+ # else:
+ # existing.update('enabled', self.enabled)
+ # return existing
def store(self, ram):
existing = self.retrieve(ram['plugins'], self.unique)
if not existing:
@@ -51,23 +61,38 @@ def store(self, ram):
def load_plugin(self):
try:
plugin = self._load_module()
- self.description = plugin.description
- self.address = plugin.address
- self.access = getattr(self._load_module(), 'access', self.Access.APP)
- return True
+ if plugin is None:
+ # Do not error; just skip loading metadata
+ return False
+ try:
+ self.description = getattr(plugin, 'description', '')
+ self.address = getattr(plugin, 'address', '')
+ self.access = getattr(plugin, 'access', self.Access.APP)
+ return True
+ except Exception as e:
+ logging.error(f'Error loading plugin={self.name}, {e}')
+ return False
except Exception as e:
logging.error('Error loading plugin=%s, %s' % (self.name, e))
return False
async def enable(self, services):
try:
- if os.path.exists('plugins/%s/data' % self.name.lower()):
- self.data_dir = 'plugins/%s/data' % self.name.lower()
+ configured_plugins = set(
+ BaseWorld.get_config(name='main', prop='plugins') or []
+ )
+ if self.name not in configured_plugins and self.name not in self.REQUIRED_PLUGINS:
+ # logging.warning(f'Skipping plugin={self.name} because it is not enabled in config and is not required')
+ return
+
+ if os.path.exists(f'plugins/{self.name.lower()}/data'):
+ self.data_dir = f'plugins/{self.name.lower()}/data'
plugin = self._load_module().enable
await plugin(services)
self.enabled = True
except Exception as e:
- logging.error('Error enabling plugin=%s, %s' % (self.name, e))
+ logging.error(f'Error enabling plugin={self.name}, {e}')
+
async def destroy(self, services):
if self.enabled:
@@ -89,3 +114,19 @@ def _load_module(self):
return import_module('plugins.%s.hook' % self.name)
except Exception as e:
logging.error('Error importing plugin=%s, %s' % (self.name, e))
+
+
+ # def _load_module(self):
+ # configured_plugins = set(
+# BaseWorld.get_config(name='main', prop='plugins') or []
+# )
+ # if self.name not in configured_plugins and self.name not in self.REQUIRED_PLUGINS:
+ # return None
+ # # raise ImportError(f'Plugin "{self.name}" is not enabled in configuration and is not a required plugin')
+
+ # try:
+ # return import_module(f'plugins.{self.name}.hook')
+ # except Exception as e:
+ # logging.error(f'Error importing plugin={self.name}, {e}')
+ # raise
+
diff --git a/app/objects/secondclass/c_link.py b/app/objects/secondclass/c_link.py
index 7afd4dc7e..a25d28ba8 100644
--- a/app/objects/secondclass/c_link.py
+++ b/app/objects/secondclass/c_link.py
@@ -49,6 +49,7 @@ class Meta:
output = ma.fields.String()
deadman = ma.fields.Boolean()
agent_reported_time = ma.fields.DateTime(format=BaseObject.TIME_FORMAT, load_default=None)
+ metadata = ma.fields.Dict(keys=ma.fields.String(), values=ma.fields.Raw(), allow_none=True)
@ma.pre_load()
def fix_ability(self, link, **_):
@@ -160,7 +161,7 @@ def is_global_variable(cls, variable):
return variable in cls.RESERVED
def __init__(self, command='', plaintext_command='', paw='', ability=None, executor=None, status=-3, score=0, jitter=0, cleanup=0, id='',
- pin=0, host=None, deadman=False, used=None, relationships=None, agent_reported_time=None):
+ pin=0, host=None, deadman=False, used=None, relationships=None, agent_reported_time=None, metadata=None):
super().__init__()
self.id = str(id)
self.command = command
@@ -186,6 +187,7 @@ def __init__(self, command='', plaintext_command='', paw='', ability=None, execu
self.output = False
self.deadman = deadman
self.agent_reported_time = agent_reported_time
+ self.metadata = metadata or {}
def __eq__(self, other):
if isinstance(other, Link):
diff --git a/app/planners/atomic.py b/app/planners/atomic.py
index 2db0bd0bd..d18828d9c 100644
--- a/app/planners/atomic.py
+++ b/app/planners/atomic.py
@@ -6,7 +6,7 @@ def __init__(self, operation, planning_svc, stopping_conditions=()):
self.stopping_conditions = stopping_conditions
self.stopping_condition_met = False
self.state_machine = ['atomic']
- self.next_bucket = 'atomic' # repeat this bucket until we run out of links.
+ self.next_bucket = 'atomic'
async def execute(self):
await self.planning_svc.execute_planner(self)
@@ -14,7 +14,7 @@ async def execute(self):
async def atomic(self):
links_to_use = []
- # Get the first available link for each agent (make sure we maintain the order).
+ # Get the first available link for each agent
for agent in self.operation.agents:
possible_agent_links = await self._get_links(agent=agent)
next_link = await self._get_next_atomic_link(possible_agent_links)
@@ -23,6 +23,7 @@ async def atomic(self):
if links_to_use:
# Each agent will run the next available step.
+ print(f'[Atomic] Running {len(links_to_use)} links with links_to_use: {links_to_use}')
await self.operation.wait_for_links_completion(links_to_use)
else:
# No more links to run.
@@ -32,11 +33,22 @@ async def _get_links(self, agent=None):
return await self.planning_svc.get_links(operation=self.operation, agent=agent)
# Given list of links, returns the link that appears first in the adversary's atomic ordering.
- async def _get_next_atomic_link(self, links):
- abil_id_to_link = dict()
- for link in links:
- abil_id_to_link[link.ability.ability_id] = link
+ async def _get_next_atomic_link(self, possible_links):
+ # 1. Try to match based on explicit step_idx
+ link_lookup = {link.step_idx: link for link in possible_links if hasattr(link, 'step_idx')}
+
+ for idx, _ in enumerate(self.operation.adversary.atomic_ordering):
+ if idx in link_lookup:
+ return link_lookup[idx]
+
+ # 2. Fallback to ability_id matching (legacy style)
+ abil_id_to_link = {link.ability.ability_id: link for link in possible_links}
candidate_ids = set(abil_id_to_link.keys())
- for ab_id in self.operation.adversary.atomic_ordering:
- if ab_id in candidate_ids:
- return abil_id_to_link[ab_id]
+
+ for step in self.operation.adversary.atomic_ordering:
+ ability_id = step if isinstance(step, str) else step.get('ability_id')
+ if ability_id in candidate_ids:
+ return abil_id_to_link[ability_id]
+
+ return None
+
diff --git a/app/planners/automation_planner.py b/app/planners/automation_planner.py
new file mode 100644
index 000000000..bc8f6ab75
--- /dev/null
+++ b/app/planners/automation_planner.py
@@ -0,0 +1,176 @@
+import asyncio
+from typing import Dict, List
+from dataclasses import dataclass
+from collections import defaultdict
+
+from app.objects.c_ability import Ability
+from app.objects.c_operation import Operation
+from app.objects.secondclass.c_fact import Fact
+from app.objects.secondclass.c_link import Link
+from app.objects.secondclass.c_relationship import Relationship
+from app.service.planning_svc import PlanningService
+
+
+OP_APPLY_SLEEP_S = 0.1
+OP_WAIT_LINK_COMPLETION_S = 0.1
+
+
+@dataclass
+class Action:
+ """
+ Represents an Action from an actions.json file
+ Basically a lighter version of a Link
+ """
+ agent_paw : str
+ ability_uuid: str
+ facts : Dict[str, str]
+
+
+class LogicalPlanner:
+
+ def __init__(self, operation, planning_svc, stopping_conditions=()):
+ self.operation = operation
+ self.planning_svc = planning_svc
+ self.stopping_conditions = stopping_conditions
+ self.stopping_condition_met = False
+ self.state_machine = ['automation']
+ self.next_bucket = 'automation'
+
+ async def execute(self):
+ await self.planning_svc.execute_planner(self)
+
+ async def op_apply(self, link: Link):
+ """
+ Modified version of Operation.apply() to allow increasing polling rate
+ (only for RUN_ONE_LINK state though)
+ """
+ op = self.operation
+
+ while op.state != op.states['RUNNING']:
+ if op.state == op.states['RUN_ONE_LINK']:
+ op.add_link(link)
+ op.state = op.states['PAUSED']
+ return link.id
+ else:
+ await asyncio.sleep(OP_APPLY_SLEEP_S)
+ op.add_link(link)
+ return link.id
+
+ async def op_wait_for_link_completion(self, link_id):
+ """
+ Modified variant of Operation.wait_for_links_completion() to allow
+ increasing polling rate by lowering sleep (and is only for a single Link)
+ """
+ op = self.operation
+
+ link = [link for link in op.chain if link.id == link_id][0]
+ if link.can_ignore():
+ op.ignored_links.add(link.id)
+ member = [member for member in op.agents if member.paw == link.paw][0]
+ while not (link.finish or link.can_ignore()):
+ await asyncio.sleep(OP_WAIT_LINK_COMPLETION_S)
+ if not member.trusted:
+ break
+
+ async def create_action_list(self) -> List[Action]:
+ """
+ Creates a list of Actions by parsing the Action Fact Bundles from the
+ Operation's Relationships (loaded from a Source)
+ """
+ op : Operation = self.operation
+ rels: List[Relationship] = await op.all_relationships()
+
+ action_dict: Dict[int, Action] = defaultdict(lambda: Action('', '', {}))
+
+ for r in rels:
+ source: Fact = r.source
+ edge : str = r.edge
+ target: Fact = r.target
+
+ if source.trait != 'operation.action.index':
+ continue
+
+ action = action_dict[int(source.value)]
+
+ if edge == 'agent':
+ action.agent_paw = target.value
+ elif edge == 'ability':
+ action.ability_uuid = target.value
+ elif edge == 'fact':
+ action.facts[target.trait] = target.value
+
+ action_list: List[Action] = [None] * len(action_dict)
+
+ for index, action in action_dict.items():
+ action_list[index] = action
+
+ return action_list
+
+ def check_action_link_eq(self, action: Action, link: Link) -> bool:
+ """
+ Tests (Action == Link)
+ """
+ link_ability: Ability = link.ability
+ link_infacts: List[Fact] = link.used
+
+ if action.ability_uuid != link_ability.ability_id:
+ return False
+
+ if action.agent_paw != link.paw:
+ return False
+
+ for f in link_infacts:
+ if action.facts.get(f.trait) != f.value:
+ return False
+
+ return True
+
+ def create_link_lookup(self, links: List[Link]) -> Dict[str, Dict[str, List[Link]]]:
+ """
+ Creates a nested dict of lists for fast Link lookup
+ lookup[agent_paw][ability_uuid] -> [list of potential links for this agent+ability combo]
+ """
+ paw_ability_to_link = defaultdict(lambda: defaultdict(list))
+
+ for link in links:
+ ability: Ability = link.ability
+ paw_ability_to_link[link.paw][ability.ability_id].append(link)
+
+ return paw_ability_to_link
+
+ async def automation(self):
+ """
+ 1. Parses Actions from the Fact Source
+ 2. Generates all possible Links (many can be invalid)
+ 3. Indexes these Links for fast lookup
+ 4. Loops through the Actions
+ a. Finds the matching Link
+ b. Executes it
+ """
+ plan_svc: PlanningService = self.planning_svc
+ op : Operation = self.operation
+
+ actions = await self.create_action_list()
+
+ links: List[Link] = await plan_svc.get_links(operation=op)
+ link_lookup = self.create_link_lookup(links)
+
+ for action in actions:
+ matches = link_lookup[action.agent_paw][action.ability_uuid]
+
+ ind, match = next((
+ (ind, m)
+ for ind, m in enumerate(matches)
+ if self.check_action_link_eq(action, m)
+ ), (None, None))
+
+ if match is None:
+ print('>>>> ERROR - LIMITATION: Cannot Run Non-Repeatable Ability More Than Once Per Agent <<<<')
+ break
+
+ del matches[ind]
+
+ link_id = await self.op_apply(match)
+ await self.op_wait_for_link_completion(link_id)
+
+ self.next_bucket = None
diff --git a/app/service/app_svc.py b/app/service/app_svc.py
index ec831a3f8..ff4dd597c 100644
--- a/app/service/app_svc.py
+++ b/app/service/app_svc.py
@@ -36,6 +36,46 @@ def __init__(self, application):
self.loop = asyncio.get_event_loop()
self._errors = []
self._loaded_plugins = [] # all plugins that were loaded, including disabled ones
+ async def setup_jinja_templates(self, plugin_names):
+ """
+ Configure Jinja template loader without enabling plugins.
+ This replaces the setup that used to happen inside load_plugins().
+ """
+ templates = []
+
+ for p in plugin_names:
+ tdir = f"plugins/{p.lower()}/templates"
+ if os.path.isdir(tdir):
+ templates.append(tdir)
+
+ # magma UI templates live here in Caldera v5
+ if os.path.isdir("plugins/magma/dist"):
+ templates.append("plugins/magma/dist")
+
+ aiohttp_jinja2.setup(self.application, loader=jinja2.FileSystemLoader(templates))
+
+ async def register_discovered_plugins(self, plugin_names):
+ data_svc = self.get_service('data_svc')
+
+ existing = {
+ p.name: p for p in await data_svc.locate('plugins')
+ }
+
+ for name in plugin_names:
+ plugin = Plugin(name=name)
+ try:
+ if plugin.load_plugin():
+ plugin.enabled = False # default to disabled until explicitly enabled
+ # preserve enabled state
+ if name in existing:
+ plugin.enabled = existing[name].enabled
+
+ await data_svc.store(plugin)
+ self._loaded_plugins.append(plugin)
+ except Exception as e:
+ self.log.error(f"Error loading plugin {name}: {e}", exc_info=True)
+ continue
+
async def start_sniffer_untrusted_agents(self):
next_check = self.get_config(name='agents', prop='untrusted_timer')
diff --git a/app/service/automation_svc.py b/app/service/automation_svc.py
new file mode 100644
index 000000000..92aa2a9fc
--- /dev/null
+++ b/app/service/automation_svc.py
@@ -0,0 +1,94 @@
+import logging
+import uuid
+
+import marshmallow as ma
+import yaml
+from aiohttp import web
+from aiohttp_jinja2 import template
+
+from app.objects.secondclass.c_fact import FactSchema
+
+
+class AbilitySchema(ma.Schema):
+ ability_id = ma.fields.String(required=True)
+ facts = ma.fields.List(ma.fields.Nested(FactSchema))
+
+ @ma.pre_load
+ def fix_id(self, ability, **_):
+ if "id" in ability:
+ ability["ability_id"] = ability.pop("id")
+ return ability
+
+
+class AutomatedOperationSchema(ma.Schema):
+ class Meta:
+ unknown = ma.EXCLUDE
+
+ operation_id = ma.fields.String(required=True)
+ name = ma.fields.String()
+ description = ma.fields.String()
+ version = ma.fields.Integer()
+ operation_facts = ma.fields.List(ma.fields.Nested(FactSchema))
+ atomic_ordering = ma.fields.List(ma.fields.Nested(AbilitySchema))
+
+ @ma.pre_load
+ def fix_id(self, operation, **_):
+ if "id" in operation:
+ operation["operation_id"] = operation.pop("id")
+ else:
+ operation["operation_id"] = str(uuid.uuid4())
+ return operation
+
+
+class AutomationService:
+ def __init__(self, services, name, description):
+ self.name = name
+ self.description = description
+ self.services = services
+
+ self.log = logging.getLogger("automation_svc")
+
+ @template("automation.html")
+ async def splash(self, request):
+ return dict(name=self.name, description=self.description)
+
+ async def _read_yaml(self, request):
+ file_size_limit = 1.28e8 # 128 MB (SI)
+
+ reader = await request.multipart()
+ field = await reader.next()
+ assert field.name == "file"
+
+ content = bytes()
+ size = 0
+ while True:
+ chunk = await field.read_chunk()
+ if not chunk:
+ break
+
+ size += len(chunk)
+ if size > file_size_limit:
+ break
+
+ content += chunk
+
+ yaml_content = yaml.safe_load(content)
+
+ # Make a round-trip to schema to validate content
+ res = AutomatedOperationSchema().load(yaml_content)
+ return AutomatedOperationSchema().dump(res)
+
+ async def handle_import(self, request):
+ try:
+ data = await self._read_yaml(request)
+ except yaml.YAMLError:
+ return web.json_response({"error": "Invalid YAML format"}, status=422)
+ except ma.ValidationError as err:
+ self.log.warning(f"YAML validation failed: {err.messages}")
+ return web.json_response(
+ {"error": "YAML validation failed", "messages": err.messages},
+ status=422,
+ )
+ except Exception:
+ return web.json_response({"error": "An error occurred"}, status=500)
+ return web.json_response(data)
diff --git a/app/service/data_svc.py b/app/service/data_svc.py
index 9a2b938d1..3bcc8566d 100644
--- a/app/service/data_svc.py
+++ b/app/service/data_svc.py
@@ -9,6 +9,7 @@
import warnings
import pathlib
from importlib import import_module
+from pathlib import Path
from app.objects.c_ability import Ability
from app.objects.c_adversary import Adversary
@@ -99,22 +100,56 @@ async def save_state(self):
await self.get_service('file_svc').save_file('object_store', pickle.dumps(self.ram), 'data')
async def restore_state(self):
- """
- Restore the object database
+ if not os.path.exists('data/object_store'):
+ self.log.debug('There are %s jobs in the scheduler' % len(self.ram['schedules']))
+ return
- :return:
- """
- if os.path.exists('data/object_store'):
- _, store = await self.get_service('file_svc').read_file('object_store', 'data')
- # Pickle is only used to load a local file that caldera creates. Pickled data is not
- # received over the network.
+ _, store = await self.get_service('file_svc').read_file('object_store', 'data')
+
+ try:
ram = pickle.loads(store) # nosec
- for key in ram.keys():
- self.ram[key] = []
- for c_object in ram[key]:
+ except ModuleNotFoundError as e:
+ self.log.warning(f"Stale plugin detected during restore: {e}")
+ ram = self._safe_unpickle(store)
+
+ available_plugins = {
+ p.name
+ for p in Path("plugins").iterdir()
+ if p.is_dir() and (p / "__init__.py").exists()
+ }
+
+ for key in ram.keys():
+ self.ram[key] = []
+
+ for c_object in ram[key]:
+
+ plugin = getattr(c_object, 'plugin', None)
+
+ # skip objects belonging to plugins that no longer exist
+ if plugin and plugin not in available_plugins:
+ self.log.warning(
+ f"Skipping object from missing plugin: {plugin}"
+ )
+ continue
+
+ try:
await self.store(c_object)
- self.log.debug('Restored data from persistent storage')
+ except Exception as e:
+ self.log.warning(f"Skipping stale object: {e}")
+
+
+ self.log.debug('Restored data from persistent storage')
self.log.debug('There are %s jobs in the scheduler' % len(self.ram['schedules']))
+ def _safe_unpickle(self, store):
+ """
+ Attempt partial restore when plugins are missing.
+ Drops objects that reference missing modules.
+ """
+ try:
+ self.log.warning("Falling back to empty object store")
+ return copy.deepcopy(self.schema)
+ except ModuleNotFoundError as e:
+ self.log.warning(f"Dropping object during safe unpickle: {e}")
async def apply(self, collection):
if collection not in self.ram:
@@ -259,6 +294,8 @@ async def load_yaml_file(self, object_class, filename, access):
obj.access = access
obj.plugin = self._get_plugin_name(filename)
await self.store(obj)
+ if filename == "data/adversaries/aaec272a-475e-423d-83b1-f2816d7a06ad.yml":
+ self.log.debug('Loaded default objective: %s', obj.display)
async def create_or_update_everything_adversary(self):
everything = {
diff --git a/app/service/file_svc.py b/app/service/file_svc.py
index 0e520178d..2e3972403 100644
--- a/app/service/file_svc.py
+++ b/app/service/file_svc.py
@@ -18,6 +18,7 @@
from app.service.interfaces.i_file_svc import FileServiceInterface
from app.utility.base_service import BaseService
from app.utility.payload_encoder import xor_file, xor_bytes
+import os
FILE_ENCRYPTION_FLAG = '%encrypted%'
URL_SANITIZATION_REGEX = re.compile(r'^[\w\-\.:%+/]+$')
@@ -73,8 +74,9 @@ async def get_file(self, headers):
async def save_file(self, filename, payload, target_dir, encrypt=True, encoding=None):
if encoding:
payload = await self._decode_contents(payload, encoding)
- self._save(os.path.join(target_dir, filename), payload, encrypt)
-
+ final_path = os.path.join(target_dir, filename)
+ self._save(final_path, payload, encrypt)
+
async def create_exfil_sub_directory(self, dir_name):
path = os.path.join(self.get_config('exfil_dir'), dir_name)
if not os.path.exists(path):
@@ -250,6 +252,7 @@ def add_xored_extension(filename):
return '%s.xored' % filename
def _save(self, filename, content, encrypt=True):
+ os.makedirs(os.path.dirname(filename), exist_ok=True)
if encrypt and (self.encryptor and self.encrypt_output):
content = bytes(FILE_ENCRYPTION_FLAG, 'utf-8') + self.encryptor.encrypt(content)
with open(filename, 'wb') as f:
diff --git a/app/service/planning_svc.py b/app/service/planning_svc.py
index 335da59d9..f7d5520af 100644
--- a/app/service/planning_svc.py
+++ b/app/service/planning_svc.py
@@ -1,6 +1,8 @@
from app.objects.secondclass.c_link import Link
from app.service.interfaces.i_planning_svc import PlanningServiceInterface
from app.utility.base_planning_svc import BasePlanningService
+from app.objects.secondclass.c_fact import Fact
+
class PlanningService(PlanningServiceInterface, BasePlanningService):
@@ -40,7 +42,7 @@ async def exhaust_bucket(self, planner, bucket, operation, agent=None, batch=Fal
while True:
links = await self.get_links(operation, [bucket], agent)
if len(links) == 0:
- break
+ break
for s_link in links:
l_id = await operation.apply(s_link)
if batch:
@@ -51,6 +53,7 @@ async def exhaust_bucket(self, planner, bucket, operation, agent=None, batch=Fal
if batch:
if await self.wait_for_links_and_monitor(planner, operation, l_ids, condition_stop):
return
+
async def wait_for_links_and_monitor(self, planner, operation, link_ids, condition_stop):
"""Wait for link completion, update stopping conditions and
@@ -158,26 +161,69 @@ async def get_links(self, operation, buckets=None, agent=None, trim=True):
:type trim: bool, optional
:return: a list of links sorted by score and atomic ordering
"""
- ao = operation.adversary.atomic_ordering
- abilities = await self.get_service('data_svc') \
- .locate('abilities', match=dict(ability_id=tuple(ao)))
+ self.log.debug(
+ 'Generating links for operation %s, agent %s, buckets %s, trim %s',
+ operation.name, getattr(agent, "paw", None), buckets, trim
+ )
+
+ raw_steps = operation.adversary.atomic_ordering
+ step_entries = []
+ unique_ids = set()
+ self.log.debug(f' Raw steps from adversary profile: {raw_steps}')
+
+ for idx, step in enumerate(raw_steps):
+ if isinstance(step, str):
+ ability_id = step
+ metadata = {}
+ else:
+ ability_id = step.get('ability_id')
+ metadata = step.get('metadata', {})
+
+ step_entries.append({
+ 'step_idx': idx,
+ 'ability_id': ability_id,
+ 'metadata': metadata
+ })
+ unique_ids.add(ability_id)
+
+ all_abilities = await self.get_service('data_svc').locate('abilities', match=dict(ability_id=tuple(unique_ids)))
+ ability_map = {a.ability_id: a for a in all_abilities}
+
+ # Build step objects with resolved abilities
+ steps_with_abilities = []
+ for step in step_entries:
+ ability = ability_map.get(step['ability_id'])
+ if not ability:
+ continue
+ steps_with_abilities.append({
+ 'step_idx': step['step_idx'],
+ 'ability': ability,
+ 'metadata': step['metadata']
+ })
+
if buckets:
- # buckets specified - get all links for given buckets,
- # (still in underlying atomic adversary order)
- t = []
- for bucket in buckets:
- t.extend([ab for ab in abilities for b in ab.buckets if b == bucket])
- abilities = t
+ steps_with_abilities = [
+ s for s in steps_with_abilities
+ if any(bucket in s['ability'].buckets for bucket in buckets)
+ ]
+
links = []
if agent:
- links.extend(await self.generate_and_trim_links(agent, operation, abilities, trim))
+ links.extend(await self.generate_and_trim_links(agent, operation, steps_with_abilities, trim))
+ self.log.debug('Generated %s links for operation %s (agent: %s)', len(links), operation.name, agent.paw)
else:
agent_links = []
- for agent in operation.agents:
- agent_links.append(await self.generate_and_trim_links(agent, operation, abilities, trim))
+ for ag in operation.agents:
+ agent_links.append(await self.generate_and_trim_links(ag, operation, steps_with_abilities, trim))
+ self.log.debug('Generated %s link groups for operation %s', len(agent_links), operation.name)
links = await self._remove_links_of_duplicate_singletons(agent_links)
- self.log.debug('Generated %s usable links' % (len(links)))
- return await self.sort_links(links)
+ self.log.debug('Removed duplicate singleton links, %s links remain for operation %s', len(links), operation.name)
+
+ self.log.debug('Generated %s usable links', len(links))
+ self.log.debug('Final link list: %s', [link.ability.ability_id for link in links])
+ sorted_links = await self.sort_links(links)
+ self.log.debug(f' Sorted links: {[link.ability.ability_id for link in sorted_links]}')
+ return sorted_links
async def get_cleanup_links(self, operation, agent=None):
"""Generate cleanup links
@@ -200,7 +246,7 @@ async def get_cleanup_links(self, operation, agent=None):
links.extend(await self._check_and_generate_cleanup_links(agent, operation))
return reversed(links)
- async def generate_and_trim_links(self, agent, operation, abilities, trim=True):
+ async def generate_and_trim_links(self, agent, operation, steps_with_abilities, trim=True):
"""Generate new links based on abilities
Creates new links based on given operation, agent, and
@@ -221,10 +267,15 @@ async def generate_and_trim_links(self, agent, operation, abilities, trim=True):
"""
agent_links = []
if agent.trusted:
- agent_links = await self._generate_new_links(operation, agent, abilities, operation.link_status())
+ self.log.debug('Generating and trimming links for agent: %s', agent.paw)
+
+ agent_links = await self._generate_new_links(operation, agent, steps_with_abilities, operation.link_status())
+ self.log.debug('Generated %s links for agent: %s', len(agent_links), agent.paw)
await self._apply_adjustments(operation, agent_links)
if trim:
agent_links = await self.trim_links(operation, agent_links, agent)
+ self.log.debug('Trimmed to %s usable links for agent: %s', len(agent_links), agent.paw)
+ self.log.debug('Final link list for agent %s: %s', agent.paw, [link.ability.ability_id for link in agent_links])
return agent_links
async def check_stopping_conditions(self, stopping_conditions, operation):
@@ -332,35 +383,118 @@ async def _check_and_generate_cleanup_links(self, agent, operation):
link_status=operation.link_status())
return agent_cleanup_links
- async def _generate_new_links(self, operation, agent, abilities, link_status):
- """Generate links with given status
-
- :param operation: Operation to generate links on
- :type operation: Operation
- :param agent: Agent to generate links on
- :type agent: Agent
- :param agent: Abilities to generate links for
- :type agent: list(Ability)
- :param link_status: Link status, referencing link state dict
- :type link_status: int
- :return: Links for agent
- :rtype: list(Link)
- """
+ async def _generate_new_links(self, operation, agent, steps_with_abilities, link_status):
+ step_metadata = {}
+ self.log.debug('[link_gen] Step metadata normalized (%d steps)', len(operation.adversary.metadata))
links = []
- for ability in await agent.capabilities(abilities):
+
+ for step in steps_with_abilities:
+ idx = step['step_idx']
+ ability = step['ability']
+ step_meta = step.get('metadata', {})
+
+ self.log.debug('Processing step idx %s with ability_id %s', idx, ability.ability_id)
+
+ supported_abilities = await agent.capabilities([ability])
+ if ability.ability_id not in [a.ability_id for a in supported_abilities]:
+ self.log.debug('Ability_id %s not supported by agent %s', ability.ability_id, agent.paw)
+ continue
+
executor = await agent.get_preferred_executor(ability)
if not executor:
+ self.log.debug('No executor found for ability_id %s on agent %s', ability.ability_id, agent.paw)
continue
- if executor.HOOKS and executor.language and executor.language in executor.HOOKS:
+ self.log.debug('Using executor %s for ability_id %s on agent %s', executor.name, ability.ability_id, agent.paw)
+ if executor.HOOKS and executor.language in executor.HOOKS:
await executor.HOOKS[executor.language](ability, executor)
- if executor.command:
- link = Link.load(dict(command=self.encode_string(executor.test), paw=agent.paw, score=0,
- ability=ability, executor=executor, status=link_status,
- jitter=self.jitter(operation.jitter)))
- links.append(link)
+
+ # --- Collect metadata facts (if any) ---
+ fact_strings = []
+ # fact_strings.extend(step_meta.get('facts', [])) # legacy flat format
+ executor_platform = executor.platform
+ self.log.debug('Executor platform: %s', executor_platform)
+ exec_facts = step_meta.get('executor_facts', {}).get(executor_platform, [])
+ self.log.debug('Executor facts: %s', exec_facts)
+ fact_strings.extend(exec_facts)
+
+ facts = []
+ for f in fact_strings:
+ try:
+ trait = f['trait']
+ value = f['value']
+ facts.append({trait.strip(): value.strip('" ')})
+ except Exception as e:
+ self.log.warning('Skipping malformed fact: %s (%s)', f, e)
+ self.log.debug('Using facts for step idx %s: %s', idx, facts)
+
+ # --- Build the link ---
+ link = Link.load(dict(
+ paw=agent.paw,
+ score=0,
+ ability=ability,
+ executor=executor,
+ status=link_status,
+ jitter=self.jitter(operation.jitter)
+ ))
+ link.step_idx = idx
+
+ injected = None
+
+ if facts:
+ self.log.debug('Injecting using metadata facts for step idx %s', idx)
+ for f in facts:
+ for trait, value in f.items():
+ link.used.append(Fact(trait=trait, value=value, source=operation.id))
+ injected = await self.inject_facts(executor.test, facts)
+ else:
+ self.log.debug('No metadata facts found; falling back to legacy fact injection for step idx %s', idx)
+ injected, _, used = await self._build_single_test_variant(executor.test, [], executor.name)
+ self.log.debug('after build_single_test injected command: %s', injected)
+ for f in used:
+ link.used.append(f)
+
+ # # If unresolved placeholders still exist, skip this step
+ # if '#{' in injected:
+ # self.log.debug('Unresolved placeholders remain after injection for step idx %s; skipping', idx)
+ # continue
+
+ link.command = self.encode_string(injected)
+ self.log.debug('Injected command template: %s', executor.test)
+ self.log.debug('Final injected command: %s', injected)
+ self.log.debug('Final command: %s', link.command)
+
+ links.append(link)
+
return links
+
+ async def inject_facts(self, template, fact_strings):
+ """Replace #{trait} placeholders in the template using trait:value fact strings or dicts"""
+ self.log.debug('Injecting facts into template: %s with fact strings: %s', template, fact_strings)
+ substitutions = {}
+
+ for entry in fact_strings:
+ try:
+ if isinstance(entry, dict):
+ trait, value = next(iter(entry.items()))
+ elif isinstance(entry, str):
+ trait, value = entry.split(':', 1)
+ else:
+ continue
+ substitutions[trait.strip()] = value.strip()
+ except Exception as e:
+ self.log.warning('Failed to parse fact: %s (%s)', entry, e)
+
+ for trait, value in substitutions.items():
+ placeholder = f'#{{{trait}}}'
+ if placeholder in template:
+ self.log.debug('[inject_facts] Replacing %s with %s', placeholder, value)
+ else:
+ self.log.debug('[inject_facts] Warning: placeholder %s not found in template', placeholder)
+ template = template.replace(placeholder, value)
+ return template
+
async def _generate_cleanup_links(self, operation, agent, link_status):
"""Generate cleanup links with given status
diff --git a/app/utility/plugin_manager.py b/app/utility/plugin_manager.py
new file mode 100644
index 000000000..1737ed10d
--- /dev/null
+++ b/app/utility/plugin_manager.py
@@ -0,0 +1,213 @@
+"""Lazy plugin loading manager."""
+
+import importlib
+from typing import Dict, List, Optional
+from pathlib import Path
+import os, sys
+import glob
+import asyncio
+import subprocess
+
+
+class PluginManager:
+ """Manage lazy loading of CALDERA plugins."""
+ def __init__(self, services, plugins_dir: str = 'plugins'):
+ self.services = services
+ self.plugins_dir = Path(plugins_dir)
+ self.loaded_plugins: Dict[str, object] = {}
+ self.enabled_plugins: Dict[str, object] = {}
+ self.available_plugins: List[str] = []
+ self._discover_plugins()
+ self.build_state = {
+ "status": "idle", # idle | installing | building | restarting
+ "plugin": None
+ }
+
+ async def initialize(self):
+ """Initialize required infrastructure plugins."""
+ if "magma" in self.available_plugins:
+ await self.enable_plugin("magma")
+
+ def _discover_plugins(self):
+ """Discover available plugins without loading them."""
+ if not self.plugins_dir.exists():
+ return
+
+ for plugin_path in self.plugins_dir.iterdir():
+ if not plugin_path.is_dir():
+ continue
+
+ if not (plugin_path / "hook.py").exists():
+ continue
+
+ name = plugin_path.name
+
+ if name not in self.available_plugins:
+ self.available_plugins.append(name)
+
+ def load_plugin(self, plugin_name: str) -> Optional[object]:
+ if plugin_name in self.loaded_plugins:
+ return self.loaded_plugins[plugin_name]
+
+ if plugin_name not in self.available_plugins:
+ print(f"[PluginManager] Plugin '{plugin_name}' not found, skipping.")
+ return None
+
+ try:
+ module = importlib.import_module(f'plugins.{plugin_name}')
+
+ self.loaded_plugins[plugin_name] = module
+ return module
+
+ except Exception as e:
+ print(f"Error loading plugin {plugin_name}: {e}")
+ return None
+
+ async def enable_plugin(self, plugin_name: str, build_gui=False, install_deps=False) -> bool:
+ restart_required = False
+ if plugin_name in self.enabled_plugins:
+ return False
+
+ module = self.load_plugin(plugin_name)
+ if not module:
+ return False
+ try:
+ self.build_state = {
+ "status": "installing",
+ "plugin": plugin_name
+ }
+ # STEP 1 — install deps
+ if install_deps:
+ await self._install_requirements_if_needed(plugin_name)
+
+ # STEP 2 — build GUI
+ if build_gui:
+ self.build_state = {
+ "status": "building",
+ "plugin": plugin_name
+ }
+ restart_required = await self._build_plugin_gui_if_needed(plugin_name)
+ self.build_state = {
+ "status": "restarting",
+ "plugin": plugin_name
+ }
+ # STEP 3 — only now commit enable state
+ try:
+ importlib.import_module(f'plugins.{plugin_name}.hook')
+ except ModuleNotFoundError as e:
+ print(f"[PluginManager] Hook not found for {plugin_name}, skipping.")
+ return restart_required
+ except Exception as e:
+ # NOTHING has been enabled yet — safe to abort
+ print(f"[PluginManager] enable failed for {plugin_name}: {e}")
+ raise
+
+ # STEP 4 — commit enable state only after successful enable
+ self.enabled_plugins[plugin_name] = module
+
+ return restart_required
+
+ async def _build_plugin_gui_if_needed(self, plugins):
+ # allow string or list
+ if isinstance(plugins, str):
+ plugins = [plugins]
+
+ plugins_to_build = []
+
+ for plugin in plugins:
+ gui_path = f"plugins/{plugin}/gui"
+
+ if not os.path.isdir(gui_path):
+ continue
+
+ if not glob.glob(f"{gui_path}/**/*.vue", recursive=True):
+ continue
+
+ plugins_to_build.append(plugin)
+
+ if not plugins_to_build:
+ return False
+
+ print(f"[plugin_manager] Building GUI for: {', '.join(plugins_to_build)}")
+
+ await asyncio.to_thread(
+ subprocess.run,
+ ["node", "prebundle.js", *plugins_to_build],
+ cwd="plugins/magma",
+ check=True
+ )
+
+ await asyncio.to_thread(
+ subprocess.run,
+ ["npm", "install"],
+ cwd="plugins/magma",
+ check=True
+ )
+
+ await asyncio.to_thread(
+ subprocess.run,
+ ["npm", "run", "build"],
+ cwd="plugins/magma",
+ check=True
+ )
+
+ return True
+
+ def unload_plugin(self, plugin_name: str):
+ """Unload a plugin to free memory."""
+ if plugin_name in self.loaded_plugins:
+ del self.loaded_plugins[plugin_name]
+
+ def get_loaded_plugins(self) -> List[str]:
+ """Get list of currently loaded plugins."""
+ return list(self.loaded_plugins.keys())
+
+ async def _install_requirements_if_needed(self, plugin_name: str):
+ req_file = self.plugins_dir / plugin_name / "requirements.txt"
+
+ if not req_file.exists():
+ return
+
+ print(f"[plugin_manager] installing requirements for {plugin_name}")
+ commands = [
+ # preferred: current interpreter (venv)
+ [
+ sys.executable,
+ "-m",
+ "pip",
+ "install",
+ "--disable-pip-version-check",
+ "--no-input",
+ "-r",
+ str(req_file)
+ ],
+ # fallback: system python
+ [
+ "python3",
+ "-m",
+ "pip",
+ "install",
+ "--disable-pip-version-check",
+ "--no-input",
+ "-r",
+ str(req_file)
+ ]
+ ]
+ last_error = None
+
+ for cmd in commands:
+ try:
+ await asyncio.to_thread(
+ subprocess.run,
+ cmd,
+ check=True
+ )
+ print(f"[plugin_manager] requirements installed using: {cmd[0]}")
+ return
+ except Exception as e:
+ last_error = e
+ print(f"[plugin_manager] pip install failed using {cmd[0]}")
+
+ raise RuntimeError(
+ f"Failed installing requirements for {plugin_name}"
+ ) from last_error
diff --git a/conf/agents.yml b/conf/agents.yml
index dd4bd4629..bc987f2c4 100644
--- a/conf/agents.yml
+++ b/conf/agents.yml
@@ -1,11 +1,12 @@
bootstrap_abilities:
- 43b3754c-def4-4699-a673-1d85648fda6a
+deployments:
+- 356d1722-7784-40c4-822b-0cf864b0b36d
+- 0ab383be-b819-41bf-91b9-1bd4404d83bf
+- 1837b43e-4fff-46b2-a604-a602f7540469
+- 2f34977d-9558-4c12-abad-349716777c6b
implant_name: splunkd
-sleep_max: 60
-sleep_min: 30
+sleep_max: 3
+sleep_min: 2
untrusted_timer: 90
watchdog: 0
-deployments:
- - 2f34977d-9558-4c12-abad-349716777c6b #54ndc47
- - 356d1722-7784-40c4-822b-0cf864b0b36d #Manx
- - 0ab383be-b819-41bf-91b9-1bd4404d83bf #Ragdoll
\ No newline at end of file
diff --git a/conf/default.yml b/conf/default.yml
index ba0653c94..f5196b126 100644
--- a/conf/default.yml
+++ b/conf/default.yml
@@ -3,47 +3,37 @@ api_key_blue: BLUEADMIN123
api_key_red: ADMIN123
app.contact.dns.domain: mycaldera.caldera
app.contact.dns.socket: 0.0.0.0:8853
+app.contact.ftp.host: 0.0.0.0
+app.contact.ftp.port: 2222
+app.contact.ftp.pword: caldera
+app.contact.ftp.server.dir: ftp_dir
+app.contact.ftp.user: caldera_user
app.contact.gist: API_KEY
app.contact.html: /weather
app.contact.http: http://0.0.0.0:8888
app.contact.slack.api_key: SLACK_TOKEN
app.contact.slack.bot_id: SLACK_BOT_ID
app.contact.slack.channel_id: SLACK_CHANNEL_ID
+app.contact.tcp: 0.0.0.0:7010
app.contact.tunnel.ssh.host_key_file: REPLACE_WITH_KEY_FILE_PATH
app.contact.tunnel.ssh.host_key_passphrase: REPLACE_WITH_KEY_FILE_PASSPHRASE
app.contact.tunnel.ssh.socket: 0.0.0.0:8022
app.contact.tunnel.ssh.user_name: sandcat
app.contact.tunnel.ssh.user_password: s4ndc4t!
-app.contact.ftp.host: 0.0.0.0
-app.contact.ftp.port: 2222
-app.contact.ftp.pword: caldera
-app.contact.ftp.server.dir: ftp_dir
-app.contact.ftp.user: caldera_user
-app.contact.tcp: 0.0.0.0:7010
app.contact.udp: 0.0.0.0:7011
app.contact.websocket: 0.0.0.0:7012
-objects.planners.default: atomic
+auth.login.handler.module: default
crypt_salt: REPLACE_WITH_RANDOM_VALUE
encryption_key: ADMIN123
exfil_dir: /tmp/caldera
+host: 0.0.0.0
+objects.planners.default: atomic
+plugins: []
+port: 8888
reachable_host_traits:
- remote.host.fqdn
- remote.host.ip
-host: 0.0.0.0
-plugins:
-- access
-- atomic
-- compass
-- debrief
-- fieldmanual
-- manx
-- response
-- sandcat
-- stockpile
-- training
-port: 8888
reports_dir: /tmp
-auth.login.handler.module: default
requirements:
go:
command: go version
@@ -54,6 +44,7 @@ requirements:
module: sys
type: python_module
version: 3.9.0
+restarting: true
users:
blue:
blue: admin
diff --git a/conf/payloads.yml b/conf/payloads.yml
index 856c885a2..31b3491fd 100644
--- a/conf/payloads.yml
+++ b/conf/payloads.yml
@@ -1,3 +1,86 @@
-special_payloads: {}
-standard_payloads: {}
-extensions: {}
\ No newline at end of file
+extensions:
+ .donut: plugins.stockpile.app.donut.donut_handler
+special_payloads:
+ minidump.go:
+ description: LSASS memory dump script
+ function: dynamically_compile
+ id: 734a5566-6e92-4d39-a37d-59f88ed906e2
+ service: stockpile_svc
+ mission.go:
+ description: Unknown
+ function: dynamically_compile
+ id: f9403e41-7b9a-416b-95eb-138cf1519668
+ service: stockpile_svc
+standard_payloads:
+ Akagi64.exe:
+ description: UACME compiled binary
+ id: dcb15ae4-52fb-4aed-be23-aae69b84f5ce
+ Bypass-UAC.ps1:
+ description: Invokes bypass UAC from empire scripts
+ id: 7700675a-c8f1-4634-b006-3486014535f4
+ Emulate-Administrator-Tasks.ps1:
+ description: Script to randomly perform common administrator tasks
+ id: af7f232a-6636-46f9-a10b-148b55c3277b
+ HostingCLRx64.dll:
+ description: Hosting DLL for a windows C# CLR (for use with execute-assembly)
+ id: c7841398-8728-494b-9b0c-9fe5fe2524de
+ Invoke-MemeKatz.ps1:
+ description: Random sets your desktop to a meme cat
+ id: b2dcf04d-517a-4eca-95f2-2c41bc783125
+ Invoke-ReflectivePEInjection.ps1.xored:
+ description: Invoke reflective PE injection script
+ id: 37e61603-fb0a-48c4-ae9b-f6542c6b1221
+ xored: true
+ bookmark.scpt:
+ description: Unknown
+ id: c354cac4-ac7c-4512-8a52-5faf880883bf
+ debugger.dll:
+ description: DLL versions of the minidump.go binary for reflective injection
+ id: feaf09c6-6337-49dd-b301-7cf015cc7cd4
+ debugger.exe:
+ description: Modified cmd.exe with a Bit-flipped to maintain signature and change
+ hash
+ id: 33b38fbf-5897-4021-9f27-9ae3f3f24c12
+ invoke-mimi.ps1.xored:
+ description: Invoke katz script
+ id: c1e0dda5-1f10-4895-a963-261404bae5f8
+ xored: true
+ minidump.exe:
+ description: Compiled minidump.go binary for dumping LSASS memory
+ id: 61174873-e8f8-486d-9f9f-12884370dde7
+ powerview.ps1.xored:
+ description: Powerview script from empire
+ id: 14986e59-601d-4c52-9ec1-480090ab6550
+ xored: true
+ ragdoll.py:
+ description: HTML-based implant
+ id: 2e1834cb-a599-44a3-b5c8-f58df4ab51e6
+ reflect.ps1:
+ description: PE Reflect script
+ id: 89102904-1a41-443d-b2cd-1faf2529ae20
+ scanner.py:
+ description: Python port scanner
+ id: 4318470d-715b-4355-aa1c-11c489d4c10e
+ spacepacket_client:
+ description: SPP Plugin Actions (Linux) - command line interface for a SPP client.
+ id: c4f38eda-3af5-4dba-966b-c26013f2dc5a
+ sshpass:
+ description: Automatically pass password to ssh
+ id: df945533-2200-451a-a1a9-801b35ae21a9
+ totallylegit.exe:
+ description: Compiled minidump.go binary for dumping LSASS memory
+ id: 98c0a4cf-6fc3-446e-9857-fe396a9eafee
+ transfer_suid.sh:
+ description: Script to look for executables to abuse elevation control mechanism
+ (setuid and setgid)
+ id: be9223eb-aa91-46e8-8c32-4fecccf1c7c7
+ wifi.ps1:
+ description: Wifi manipulation script
+ id: 28f9bf43-4f14-4965-9bd9-b70fd6993d8e
+ obfuscation_name:
+ - obfuscated_payload.ps1
+ wifi.sh:
+ description: Wifi manipulation script
+ id: 9f639067-370a-40ba-b7ac-6f1c15d5a158
+ obfuscation_name:
+ - obfuscated_payload.sh
diff --git a/data/adversaries/.gitkeep b/data/adversaries/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/package-lock.json b/package-lock.json
index af20679aa..097acacf7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,9 @@
"": {
"name": "caldera",
"license": "Apache-2.0",
+ "dependencies": {
+ "uuid": "^13.0.0"
+ },
"devDependencies": {
"eslint": "^8.50.0",
"eslint-config-airbnb-base": "^15.0.0",
@@ -3798,6 +3801,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
+ "node_modules/uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
+ "funding": [
+ "https://github.com/sponsors/broofa",
+ "https://github.com/sponsors/ctavan"
+ ],
+ "license": "MIT",
+ "bin": {
+ "uuid": "dist-node/bin/uuid"
+ }
+ },
"node_modules/validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
@@ -6611,6 +6627,11 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
+ "uuid": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="
+ },
"validate-npm-package-license": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
diff --git a/package.json b/package.json
index e198eafbd..6ec41eff6 100644
--- a/package.json
+++ b/package.json
@@ -17,5 +17,8 @@
"lint-js-fix": "eslint ./static/js/*.js ./templates/*.html --fix",
"lint-css": "stylelint static/css/*.css",
"lint-css-fix": "stylelint static/css/*.css --fix"
+ },
+ "dependencies": {
+ "uuid": "^13.0.0"
}
}
diff --git a/plugins/access b/plugins/access
deleted file mode 160000
index 775181bf0..000000000
--- a/plugins/access
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 775181bf056b2f1cbf09c4f12d1f069b03953812
diff --git a/plugins/atomic b/plugins/atomic
deleted file mode 160000
index b90958810..000000000
--- a/plugins/atomic
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit b90958810ffae1d328124f9e8af75f524616b27f
diff --git a/plugins/builder b/plugins/builder
deleted file mode 160000
index 29919459f..000000000
--- a/plugins/builder
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 29919459fdce6ddc1f171f35483c2bef92f74260
diff --git a/plugins/compass b/plugins/compass
deleted file mode 160000
index 85e2ebf2f..000000000
--- a/plugins/compass
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 85e2ebf2f9486973db5824bfed27c290c93a7b66
diff --git a/plugins/debrief b/plugins/debrief
deleted file mode 160000
index eba44d5de..000000000
--- a/plugins/debrief
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit eba44d5de9eed8a620bf3c23279bf3885d9d327e
diff --git a/plugins/emu b/plugins/emu
deleted file mode 160000
index fdb0bab5c..000000000
--- a/plugins/emu
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit fdb0bab5c9c3e1f6fcff57cdd2a4d999955cb7b8
diff --git a/plugins/fieldmanual b/plugins/fieldmanual
deleted file mode 160000
index 83da7cbfa..000000000
--- a/plugins/fieldmanual
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 83da7cbfaf8a971130b5ec9569c4e93d6d3af7df
diff --git a/plugins/gameboard b/plugins/gameboard
deleted file mode 160000
index dacb6ce23..000000000
--- a/plugins/gameboard
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit dacb6ce234dd917438883d38c65317eb3cf95d3d
diff --git a/plugins/human b/plugins/human
deleted file mode 160000
index fd242d758..000000000
--- a/plugins/human
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit fd242d7583a4e92897f64cec8059a16ac28f6260
diff --git a/plugins/magma b/plugins/magma
index 66ebf0d89..0f1a68989 160000
--- a/plugins/magma
+++ b/plugins/magma
@@ -1 +1 @@
-Subproject commit 66ebf0d89adba630137b56fe16aa712ca9d32a8e
+Subproject commit 0f1a68989630f2a70ffc1d7e4b34207ad392e0e6
diff --git a/plugins/manx b/plugins/manx
deleted file mode 160000
index 261f10b34..000000000
--- a/plugins/manx
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 261f10b344638e1b3ee36337f4fbb68571ec4a78
diff --git a/plugins/response b/plugins/response
deleted file mode 160000
index 8ea6ab5e0..000000000
--- a/plugins/response
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 8ea6ab5e0f341aba8facf29ba0301643e3d6a3b4
diff --git a/plugins/sandcat b/plugins/sandcat
deleted file mode 160000
index 0a35cd525..000000000
--- a/plugins/sandcat
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 0a35cd525f1cfabfcf602282428055c41f144513
diff --git a/plugins/ssl b/plugins/ssl
deleted file mode 160000
index 1ae843111..000000000
--- a/plugins/ssl
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 1ae843111919b5a6e58a03917bd9be6b852ecb1f
diff --git a/plugins/stockpile b/plugins/stockpile
deleted file mode 160000
index 08f37d8c1..000000000
--- a/plugins/stockpile
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 08f37d8c1c5325910176d9fbaf9a83efccafd96c
diff --git a/requirements.txt b/requirements.txt
index e2aad9745..cf7ca9f8c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,27 +1,26 @@
-aiohttp-jinja2==1.5.1
+aioftp==0.20.1
aiohttp==3.10.11
+aiohttp_apispec==3.0.0b2
+aiohttp_jinja2==1.5.1
+aiohttp_security==0.4.0
aiohttp_session==2.12.0
-aiohttp-security==0.4.0
-aiohttp-apispec==3.0.0b2
-jinja2==3.1.6
-pyyaml==6.0.1
-cryptography==44.0.1
-websockets==15.0
-Sphinx==7.1.2
-sphinx_rtd_theme==1.3.0
-myst-parser==2.0.0
-marshmallow==3.20.1
-dirhash==0.2.1
-marshmallow-enum==1.5.1
+asyncssh_unofficial==0.9.2
+beautifulsoup4==4.14.3
+croniter==3.0.4
+cryptography==46.0.4
+Jinja2==3.1.6
ldap3==2.9.1
-lxml~=4.9.1 # debrief
-reportlab==4.0.4 # debrief
-rich==13.7.0
-svglib==1.5.1 # debrief
-Markdown==3.4.4 # training
-dnspython==2.6.1
-asyncssh==2.20.0
-aioftp~=0.20.0
-packaging==23.2
-croniter~=3.0.3
-setuptools==78.1.1
+Markdown==3.4.4
+marshmallow==3.20.1
+marshmallow_enum==1.5.1
+matplotlib==3.10.8
+multidict==6.7.0
+networkx==3.6.1
+numpy==2.4.2
+packaging==26.0
+pytest==9.0.2
+python_dateutil==2.9.0.post0
+PyYAML==6.0.3
+Requests==2.32.5
+rich==14.3.2
+urllib3==2.6.3
diff --git a/requirements.txt.backup b/requirements.txt.backup
new file mode 100644
index 000000000..e2aad9745
--- /dev/null
+++ b/requirements.txt.backup
@@ -0,0 +1,27 @@
+aiohttp-jinja2==1.5.1
+aiohttp==3.10.11
+aiohttp_session==2.12.0
+aiohttp-security==0.4.0
+aiohttp-apispec==3.0.0b2
+jinja2==3.1.6
+pyyaml==6.0.1
+cryptography==44.0.1
+websockets==15.0
+Sphinx==7.1.2
+sphinx_rtd_theme==1.3.0
+myst-parser==2.0.0
+marshmallow==3.20.1
+dirhash==0.2.1
+marshmallow-enum==1.5.1
+ldap3==2.9.1
+lxml~=4.9.1 # debrief
+reportlab==4.0.4 # debrief
+rich==13.7.0
+svglib==1.5.1 # debrief
+Markdown==3.4.4 # training
+dnspython==2.6.1
+asyncssh==2.20.0
+aioftp~=0.20.0
+packaging==23.2
+croniter~=3.0.3
+setuptools==78.1.1
diff --git a/server.py b/server.py
index 5e51d4f4f..008d92d91 100644
--- a/server.py
+++ b/server.py
@@ -35,11 +35,10 @@
from app.utility.base_object import AppConfigGlobalVariableIdentifier
from app.utility.base_world import BaseWorld
from app.utility.config_generator import ensure_local_config
-
+from app.utility.plugin_manager import PluginManager
MAGMA_PATH = "./plugins/magma"
-
def setup_logger(level=logging.DEBUG):
format = "%(message)s"
datefmt = "%Y-%m-%d %H:%M:%S"
@@ -74,16 +73,48 @@ async def start_server():
def run_tasks(services, run_vue_server=False):
loop = asyncio.get_event_loop()
+ plugin_manager = PluginManager(app_svc.get_services())
+ services['plugin_manager'] = plugin_manager
loop.create_task(app_svc.validate_requirements())
loop.run_until_complete(data_svc.restore_state())
loop.run_until_complete(knowledge_svc.restore_state())
loop.run_until_complete(app_svc.register_contacts())
- loop.run_until_complete(app_svc.load_plugins(args.plugins))
+ # loop.run_until_complete(app_svc.load_plugins(args.plugins))
+
loop.run_until_complete(
data_svc.load_data(
loop.run_until_complete(data_svc.locate("plugins", dict(enabled=True)))
)
)
+ loop.run_until_complete(
+ app_svc.register_discovered_plugins(
+ plugin_manager.available_plugins
+ )
+ )
+ loop.run_until_complete(
+ app_svc.setup_jinja_templates(plugin_manager.available_plugins)
+ )
+ plugins = loop.run_until_complete(
+ data_svc.locate('plugins')
+ )
+ for p in plugins:
+ if p.name in plugin_manager.enabled_plugins:
+ p.enabled = True
+ configured_plugins = (
+ BaseWorld.get_config(name='main', prop='plugins') or []
+ )
+
+ restart_required = False
+ if not args.fresh:
+ for name in configured_plugins:
+ needs_restart = loop.run_until_complete(
+ plugin_manager.enable_plugin(name)
+ )
+ restart_required = restart_required or needs_restart
+
+ if restart_required:
+ logging.info("[plugin_manager] GUI rebuilt during startup")
+
loop.run_until_complete(
app_svc.load_plugin_expansions(
loop.run_until_complete(data_svc.locate("plugins", dict(enabled=True)))
@@ -91,6 +122,8 @@ def run_tasks(services, run_vue_server=False):
)
loop.run_until_complete(RestApi(services).enable())
loop.run_until_complete(auth_svc.set_login_handlers(services))
+ loop.run_until_complete(plugin_manager.initialize())
+
loop.create_task(app_svc.start_sniffer_untrusted_agents())
loop.create_task(app_svc.resume_operations())
loop.create_task(app_svc.run_scheduler())
diff --git a/splunkd b/splunkd
new file mode 100755
index 000000000..8e01ed0ee
Binary files /dev/null and b/splunkd differ
diff --git a/static/css/basic.min.css b/static/css/basic.min.css
new file mode 100644
index 000000000..3fe42f45c
--- /dev/null
+++ b/static/css/basic.min.css
@@ -0,0 +1 @@
+.home-box{border-radius:25px}.big-head{margin-bottom:-20px;font-size:165px;opacity:.5}.small-head{color:#fff;font-size:22px;opacity:.5}.row-toolbar{margin:0-30px;display:flex}.row-toolbar-section{padding:0 25px;display:inherit;border-right:1px solid #1f1d1d}.row-toolbar-button{display:flex;flex-direction:column;align-items:center;background-color:inherit;border:0;padding:10px;width:75px;color:#fff;font-size:11px}.row-toolbar-button:hover{background-color:var(--theme-color);filter:grayscale(75%);border:0}.row-toolbar-button:focus{outline:0}.row-toolbar-button img{height:23px;width:23px;padding-bottom:5px;-webkit-filter:invert(1);filter:invert(1)}.row-toolbar-item{padding:0 25px}.row-toolbar-item p{line-height:22px;font-size:18px;color:#fff}.row-canvas{flex:40%}.atomic-button{height:35px;margin-top:10px;margin-bottom:10px;width:100%;border:0;border-radius:25px}.atomic-button.separated-button{margin-top:40px}.darkSelect,.top-tip{color:var(--font-color)}.top-tip{position:absolute;background-color:var(--theme-color);border-radius:10px;margin:10px;padding:5px;font-size:13px}.darkSelect{width:200px;background-color:var(--primary-background);border:0}.highlight{background-color:#ff0;color:#000}.ability-attack label,.delete-agent{cursor:pointer}.ability-table td{width:30%;vertical-align:middle}.ability-table tr{border-bottom:1px solid #fff}.ability-table td+td{width:100%}.ability-table p{font-size:16px;font-weight:600}.ability-table input{min-height:35px;font-size:16px;text-align:left}.ability-table ol{list-style:none;counter-reset:item}.ability-table li{list-style-type:circle;margin-bottom:5px;font-size:15px;color:var(--font-color);line-height:25px}.ability-table li::before{margin-right:10px;color:var(--font-color);width:1.2em;display:inline-block}.ability-table select{width:90%;height:22px;margin-top:-5px;font-size:12px}.ability-table input,.ability-table select,.ability-table textarea,.adversary-header input{background-color:inherit;border:0;color:var(--font-color)}.ability-table textarea{height:150px;width:100%;font-size:14px;overflow:auto;padding:1em 1.5em;display:block;word-wrap:break-word}.adversary-header input{text-align:left;padding-bottom:20px}.fillable-table input,.pane-header-editable input{background-color:inherit;border:0;color:var(--font-color);text-align:left;padding:0}.op-selected{visibility:hidden}.op-dets img{border-radius:50%;height:30px;width:auto;background-color:#fff;border:2px solid var(--theme-color)}.op-dets h3{margin-top:10px;margin-bottom:20px}.ability-table p,.op-dets p{text-transform:uppercase}.op-manual-commands,.op-potential-links{float:right;font-size:13px;margin-right:5%;cursor:pointer}.op-manual-commands{margin-right:3%}#manual-command-settings{display:flex;width:80%;margin:2em auto 0}#manual-command,#manual-executor{color:#dcdcdc;background-color:#000;border-color:#dcdcdc}#manual-executor{flex:12%;margin:0;padding:0}#manual-command{flex:76%;margin:0 1em;text-align:left}#manual-run{flex:12%;height:2.3em;margin:0;padding:0}.profile-tests{float:left;width:100%}.profile-tests li{font-size:16px;color:var(--font-color);display:block;float:left;margin:2px}.ability-box,.ability-long-name{border-style:solid;background-color:var(--secondary-background)}.ability-box{border-color:var(--theme-color);border-radius:15px;border-width:2px;position:relative;padding:10px;max-width:25%;min-width:240px;max-height:120px;margin:5px;z-index:1}.ability-box h4,.ability-box p{overflow:hidden;white-space:nowrap;text-overflow:ellipsis;margin-left:2%}.ability-box h4{padding-bottom:5px;width:95%}.ability-box p{margin-top:-10px}.ability-box img{height:18px;border:0;border-radius:0;background-color:transparent;filter:invert(var(--invert-percentage));margin:-3px 5px}.ability-long-name{position:initial;z-index:100;margin-left:-50%;margin-right:-50%;padding:4px 8px;border-width:1px;border-color:gray}.ability-viewer{background-color:var(--primary-background);border:1px solid #000;border-radius:10px}.ability-viewer h3{text-align:left;color:#fff;text-transform:none}.ability-viewer p{text-align:left;color:#fff;font-size:16px}.ability-viewer td{color:#fff}.ability-attack span{color:#fff;text-align:center;opacity:.7}.ability-attack input{margin:2px;height:12px;background-color:inherit;color:#fff;text-align:left;border-radius:0}.ability-attack img{height:40px;border:0;border-radius:50%;margin-bottom:-5px}.ability-viewer select{width:30%}.ttp-template #action{width:100%;margin-top:0;height:20px;padding-top:0;padding-bottom:0;border-radius:0}.ttp-template input,.ttp-template textarea{background-color:var(--primary-background);color:#fff;border:0}.ttp-template textarea{width:99%;min-height:75px;overflow:auto;margin:0;font-size:16px;padding:5px}.ttp-template input{text-align:left;height:20px;width:50%;border-bottom:1px dotted var(--theme-color)}.ttp-template select{background:var(--primary-background);border:0;color:#fff;width:70%}.pack-phase-template,.source-row{list-style-type:none}#factTbl th,#pack-phases h4{text-align:left}#pack-phases td{padding:5px 8px}.source-modal h3{text-align:center;margin:2px 0 20px}.source-modal ul{padding:0 5%}.source-modal .column>p{width:93%;color:#fff;text-align:right}.source-modal .column>p.source-note{font-size:12px}.source-row{width:100%;margin:2em auto}.source-row table{width:100%;table-layout:fixed}.source-row p{font-size:11px;text-align:center}.source-row input{width:100%;padding:0 15px}#source-rules td:nth-child(1){width:15%}#source-rules td:nth-child(2){width:85%}#source-rules td:nth-child(3){width:5%}#source-relationships td:nth-child(1){width:10%}#source-relationships td:nth-child(2){width:25%}#source-relationships td:nth-child(3){width:60%}#source-relationships td:nth-child(4){width:5%}#factTbl{table-layout:fixed}#factTbl th:nth-child(1){width:20%}#factTbl th:nth-child(2){width:75%}#factTbl th:nth-child(3){width:5%}#factTbl td p{overflow-wrap:anywhere;word-break:break-all}.topleft,.topleftnum,.topright{position:absolute;top:2px;padding:10px}.topright{right:0}.topleft,.topleftnum{left:0}.topleftnum{top:-2px;padding:5px}.bottomleft{position:absolute;bottom:2px;left:0;padding:10px}.removal{padding:1px;border-radius:5px;background-color:red;border:1px solid #000}.adv-reqs h4,.removal p{font-size:16px}.bottomright{position:absolute;bottom:0;right:0;padding:10px;display:flex;z-index:1}.phase-headers{text-align:left}.ability-add{float:right;font-size:13px}#ability-manager-additional .column img,.ability-add,.ability-remove,.op-control-item button:hover,.pack-add{cursor:pointer}.pack-add{float:right;font-size:13px;margin-right:20px}.advGoal{font-size:36px;color:var(--font-color)}.icon-row{padding:5px;width:95%;display:flex;position:relative}.tag{font-size:12px;border-radius:6px;background-color:#fff;margin:3px;color:#000;display:inline-block;padding:5px;cursor:pointer}table.dataTable td.select-checkbox{position:relative}table.dataTable td.select-checkbox::after,table.dataTable td.select-checkbox::before{display:block;position:absolute;top:50%;left:50%;width:20px;height:20px;transform:translateY(-50%);box-sizing:border-box}table.dataTable td.select-checkbox::before{content:" ";margin-left:-6px;border:1px solid #fff;border-radius:3px}table.dataTable tr.selected td.select-checkbox::after{content:"✓";margin-top:-6px;margin-left:-4px;font-size:1.7em;color:green;text-align:center}table.dataTable input[type=text]{padding:0;margin-top:5px}.codearea textarea,.decision-card td{color:var(--font-color)}.red-x{color:red;font-style:italic}.adv-reqs{border-top:1px solid var(--font-color);float:left;width:100%}.adv-reqs h4{margin-bottom:-5px;font-weight:700;color:red}.missing-facts-sublist{padding:0 40px;float:left;margin-bottom:20px;margin-top:10px}.missing-facts-sublist>li{width:100%;padding:0;margin:0;font-size:12px}#missingAdvReqs{padding-left:0}#missingAdvReqs>li{float:left;display:block;width:100%;margin:0}.watermarked{position:relative}.op-control-container{display:flex;width:50%;justify-content:center}.op-control-item{background-color:#fff;border-radius:10px;font-size:medium;width:100%;flex-grow:1;margin:0 4px}.op-control-item,.op-control-item button,.op-control-text{height:30px;text-align:center;vertical-align:center}.op-control-text{text-transform:uppercase;padding:5px;font-size:large;font-weight:700;width:5%}.op-control-item button{background-color:#fff;border-radius:10px;font-size:medium;width:100%;white-space:nowrap}.op-control-item .large{flex-grow:2}.codearea textarea{text-align:left;background-color:var(--primary-background);border:0;display:inline-block;width:100%;min-height:400px;font-size:16px;padding:10px;border-radius:8px}.legend{border-spacing:5px}.legend td{padding:6px;border-radius:25px;font-size:11px;color:#fff}.legend .failure{background-color:#c31}.legend .success{background-color:#4a9}.legend .timeout{background-color:#6495ed}.legend .collected{background-color:#ffb000}.legend .untrusted{background-color:#fff;color:#000}.legend .visibility{background-color:#e37;color:#fff}.legend .discarded{background-color:var(--primary-background)}.legend .queued{background-color:#555}.default-option{color:gray}#resultCmd{overflow-wrap:anywhere}.dotted{border:1px solid #fff}.dotted p{font-size:11px;float:right;color:#000}.fact-area{padding:5px}.fact-area h4,.fact-area p{text-align:left;padding-left:15px}.fact-area h4{margin-bottom:-5px}.fact-area p{padding-right:15px}#c2-description,#planner-description{font-size:15px}.potential-button{float:right;text-align:center;width:50px;height:10px;margin:0;line-height:1px}.grid{position:relative;height:420px}.ability-box.muuri-item-hidden,.grid{z-index:0}#ability-box.muuri-item-releasing{z-index:2}#ability-box.muuri-item-dragging{z-index:3}#ability-box.muuri-item-dragging .item-content{background:#00f}#ability-box.muuri-item-releasing .item-content{background:#8a2be2}.ability-order{color:var(--secondary-font-color)}#info-list{min-width:50%;margin:auto;list-style-type:none}.infolist-item{height:3.5em}.infolist-icon{position:absolute;height:3em;width:3em;background-color:#202025;border-radius:1.5em}.infolist-icon img{margin:.3em;height:2.4em}.infolist-contents{float:left;height:1.8em;min-width:50%;margin-left:1.5em;text-align:center;border-radius:.3em;background-color:#202025;padding:.3em 1.5em .3em 1.8em}.infolist-contents p{margin-block-start:0;margin-block-end:0;color:#fff}.dark-select{background:rgba(0,0,0,.3);color:#fff;border-radius:25px;border:0}#objective-img{margin-top:-5%;margin-left:90%;height:25px;border:2px solid var(--navbar-color)}.adversary-sidebar-option{padding:20px 0 0}#profile-existing-name{margin:0 0 20px}#adversary-add{display:none}#adversary-content{flex:75%;visibility:hidden;text-align:left}#phase-modal .container>.column{flex:100%}#ability-search-filter{width:60%}#reset-ability-modal-button{width:115px;height:30px;padding-top:8px;color:#000;background-color:#b22222}#ability-manager-fields{flex:60%}#ability-manager-fields table{border-spacing:2px;width:100%}#ability-manager-fields table td:nth-child(1){width:8em;font-weight:700}#ability-manager-additional{flex:40%}#ability-manager-additional #row-simple{display:flex;overflow:hidden}#ability-manager-additional .column{float:left;flex:20%;margin:0}#ability-manager-additional .column p{padding-top:4px;text-align:center;color:#fff}#ability-additional-info{display:flex;margin-top:1em}#ability-additional-info div{display:flex;flex:5em;justify-content:center;align-items:center;font-weight:700}#ability-button-row{float:right;width:40%;padding-right:5%}#ability-button-row button{padding:0 20px}#ttp-tests{width:100%}#op-control-info-wrapper{display:flex;justify-content:center}.op-control-info-entry{width:fit-content;padding:0 20px}
\ No newline at end of file
diff --git a/static/css/core.min.css b/static/css/core.min.css
new file mode 100644
index 000000000..2eeab53ff
--- /dev/null
+++ b/static/css/core.min.css
@@ -0,0 +1 @@
+body,html{height:100%;scroll-behavior:smooth}::-webkit-scrollbar{background-color:#0000;width:8px;height:8px}::-webkit-scrollbar-thumb{background:#3f3f3f;border-radius:10px;height:50px}::-webkit-scrollbar-corner{background-color:#0000;color:#fff}.card-header{border-bottom:1px solid #606060}.level,main.main{width:100%}#active-tab-display{color:#fff;max-width:initial;margin-top:62px}[data-tooltip]::before{min-width:150px}.fancy-button:hover{background-image:linear-gradient(to right,#8b0000,#191970)!important;border-width:2px}.icon.is-close:hover{background-color:#929292;border-radius:50%}.is-underlined{text-decoration:underline}main.main{min-height:100%;display:flex;flex-direction:row}.main-content{width:calc(100% - 250px);background-color:#161616}.menu{padding-bottom:20px}.menu-label.nav-header{cursor:default;background:0 0!important;border-bottom:2px solid var(--primary-color);font-size:.75em;letter-spacing:.1em;color:#fff!important;padding:8px}.menu-list a{padding:.25em 1.5em!important}.menu-label.nav-header,.menu-list a:hover{border-radius:4px;margin:0 10px}.modal-card{border:1px solid #262626;border-radius:4px}@media screen and (min-width:1000px){.modal-card.wide{width:1000px}}.modal-card-body{scrollbar-color:#3f3f3f #0000}.modal-card-foot{padding:10px 20px!important}.navigation{background-color:#060606;width:250px}.nav-logo{padding:2em 3em}.nav-logo img{max-height:150px;cursor:pointer}#nav-menu{background-color:#060606}.nav-item.disabled{color:#a0a0a0}.nav-item:hover.disabled{background-color:transparent;color:#a0a0a0;cursor:default;margin:0}#no-open-tabs,.nav-logo{width:100%;display:flex;justify-content:center}#no-open-tabs{height:50%;flex-direction:column;align-items:center;margin-top:62px}.nav.tabs{position:fixed;width:100%;z-index:10;display:block!important;margin-bottom:0!important;padding:10px 10px 0;background-color:#111}.nav.tabs a{padding:5px 10px!important}.nav.tabs.is-boxed a{border-radius:4px!important;margin-right:4px}.nav.tabs ul{padding-bottom:10px;min-height:52px;border:0!important}.nav.tabs span{color:#fff}#startup-errors-warning{font-size:.8em;padding:10px}.tab-delete:hover{border-radius:4px;background-color:#06060633}.toast{padding:10px!important;border-radius:4px!important}.user-badge{cursor:default!important}
\ No newline at end of file
diff --git a/static/css/file-explorer.min.css b/static/css/file-explorer.min.css
new file mode 100644
index 000000000..6eb1f7e3a
--- /dev/null
+++ b/static/css/file-explorer.min.css
@@ -0,0 +1 @@
+.file-explorer img{height:23px;width:23px;border-radius:0;border:0;margin:0;padding:3px;background-color:transparent;-webkit-filter:invert(1);filter:invert(1);vertical-align:middle}.file-explorer .folder{-webkit-transform:rotate(-90deg);-moz-transform:rotate(-90deg);-o-transform:rotate(-90deg);-ms-transform:rotate(-90deg);transform:rotate(-90deg)}
\ No newline at end of file
diff --git a/static/css/modal.min.css b/static/css/modal.min.css
new file mode 100644
index 000000000..da58795ac
--- /dev/null
+++ b/static/css/modal.min.css
@@ -0,0 +1 @@
+.cancelbtn{width:auto;padding:10px 18px;background-color:#7a1f1f}.imgcontainer{text-align:center;position:relative;margin-bottom:25px}img.avatar{width:40%;border-radius:50%}.container{padding:16px}span.psw{float:right;padding-top:16px}.close{position:absolute;right:25px;top:0;color:red;font-size:35px;font-weight:700}.modal .approve{background-color:green}.modal .discard{background-color:#ff8c00}.modal .approveall{background-color:#006400}.close:focus,.close:hover{color:#7a1f1f;cursor:pointer}#alert-modal{z-index:120}.config-arg input{padding:5px;margin:5px;text-align:left;font-size:13px}.config-arg label,p.command{color:#fff;font-size:14px}.config-arg{vertical-align:initial}p.command{padding:10px;width:85%;font-size:13px;font-style:italic;background-color:rgba(170,170,170,.15)}#queueTimeout{height:30px;width:75%;margin:8px 5px 5px}
\ No newline at end of file
diff --git a/static/css/multi-select.min.css b/static/css/multi-select.min.css
new file mode 100644
index 000000000..d3ba8edaa
--- /dev/null
+++ b/static/css/multi-select.min.css
@@ -0,0 +1 @@
+.ms-container{background:url(../img/switch.png)no-repeat 50% 50%;width:70%;margin:5px 0}.ms-container::after{content:".";display:block;height:0;line-height:0;font-size:0;clear:both;min-height:0;visibility:hidden}.ms-container .ms-selectable,.ms-container .ms-selection{background:#000;color:#555;float:left;width:45%;border:1px solid #2a2a2a}.ms-container .ms-selection{float:right}.ms-container .ms-list{-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-moz-box-shadow:inset 0 1px 1px rgba(0,0,0,.075);box-shadow:inset 0 1px 1px rgba(0,0,0,.075);-webkit-transition:border linear .2s,box-shadow linear .2s;-moz-transition:border linear .2s,box-shadow linear .2s;-ms-transition:border linear .2s,box-shadow linear .2s;-o-transition:border linear .2s,box-shadow linear .2s;transition:border linear .2s,box-shadow linear .2s;-webkit-border-radius:3px;-moz-border-radius:3px;border-radius:3px;position:relative;height:200px;padding:0;overflow-y:auto}.ms-container .ms-list.ms-focus{border-color:rgba(82,168,236,.8);-webkit-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(82,168,236,.6);-moz-box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(82,168,236,.6);box-shadow:inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(82,168,236,.6);outline:0;outline:thin dotted \9}.ms-container ul{margin:0;list-style-type:none;padding:0}.ms-container .ms-optgroup-container{width:100%}.ms-container .ms-optgroup-label{margin:0;padding:5px 0 0 5px;cursor:pointer;color:#999}.ms-container .ms-selectable li.ms-elem-selectable,.ms-container .ms-selection li.ms-elem-selection{border-bottom:1px #2a2a2a solid;padding:2px 10px;color:#555;font-size:14px}.ms-container .ms-selectable li.ms-hover,.ms-container .ms-selection li.ms-hover{cursor:pointer;color:#fff;text-decoration:none;background-color:var(--navbar-color)}.ms-container .ms-selectable li.disabled,.ms-container .ms-selection li.disabled{background-color:#eee;color:#aaa;cursor:text}.payload-select-header{color:#fff;padding:3px;text-align:center;border-bottom:1px solid #fff}
\ No newline at end of file
diff --git a/static/css/shared.min.css b/static/css/shared.min.css
new file mode 100644
index 000000000..20093d060
--- /dev/null
+++ b/static/css/shared.min.css
@@ -0,0 +1 @@
+.scroll-top{width:0;position:fixed!important;bottom:20px;right:20px;z-index:2}.vr{width:1px;height:30px;margin:0 20px 0 10px;background-color:gray}.auto-complete:active .search-results,.auto-complete:focus-within .search-results{border-top:1px solid #363636!important;border:1px solid var(--primary-color)}.search-results{overflow-y:scroll;max-height:400%;background-color:#262626;position:absolute;z-index:5;width:100%;border-top:none;margin-top:-2px;border-radius:0}.search-results p{margin-bottom:0!important;padding:5px;cursor:pointer}.search-results p:hover{background-color:#484848}.filter-search-box i{background-color:#262626;color:gray;height:30px;padding:5px;display:flex;align-items:center;justify-content:center}.filter-search-box i:first-child{border-radius:2px 0 0 2px}.filter-search-box i:last-child{border-radius:0 2px 2px 0}.filter-search-box input{height:30px;padding:5px;border:0;width:100%;border-radius:0}.toggleContainer{display:flex}.toggleContainer label{font-size:.75em}.toggleSwitch{position:relative;display:inline-block;width:50px;height:12px}.switch input,.toggleSwitch input{display:none}.toggleSlider{border:2px solid #7e7e7e;position:absolute;cursor:pointer;top:0;left:0;right:0;bottom:0;border-radius:34px}.toggleSlider::before{position:absolute;content:"";height:17px;width:17px;bottom:4px;transition:all .3s ease;left:3px;top:6px}.toggle-off{background-color:initial}.toggle-on{background-color:#87fa5f}.toggle-slider-off,.toggle-slider-on{color:#fff;position:absolute;transform:translate(-50%,-50%);top:50%;left:50%;font-size:10px;font-family:Verdana,sans-serif}.toggle-on.toggleSlider.toggleSlider::before{background-color:#fff;transform:translate(30px,-11px)}.toggle-off.toggleSlider.toggleSlider::before{background-color:#fff;transform:translate(-5px,-11px)}.toggleSlider.toggleSliderRound::before{border-radius:50%}.startup-errors-warning span{color:inherit}.startup-errors-warning span:hover{text-decoration:underline}.modal-form-fields button.chip{width:fit-content;margin:.75em 0 0 .75em!important}.chip{border-width:2px!important}.modal-card .modal-form>div{width:100%;display:flex;flex-flow:row wrap;justify-content:center}.modal-card .modal-form>div>label{width:30%;display:flex;justify-content:flex-end;align-items:center;font-size:.75rem}.modal-card .modal-form-group-header{display:flex;justify-content:center;align-items:center}.modal-card .modal-form-group-header button{width:100%;padding-left:0;padding-right:0;text-align:left}.collapsible-header .expanded::after,.modal-card .modal-form-group-header b .modal-card .expanded span:first-child::after{margin-left:.2rem;content:"▼"}.collapsible-header .collapsed::after,.modal-card .modal-form-group-header b .modal-card .collapsed span:first-child::after{margin-left:.2rem;content:"►"}.modal-card .modal-form-group-header button:hover,button[disabled]:hover{transform:none!important}.modal-card .modal-form-fields{width:70%;display:flex;justify-content:flex-start;align-items:center;flex-flow:row wrap}.modal-card .modal-form-fields input[type=number]{width:75px}.modal-card .modal-form-fields input{margin:.75em}.modal-card .modal-form-fields label{margin-right:1rem;font-size:.75rem}.modal-card .input-hover-label{position:relative;margin-top:-40px;margin-left:-40px;color:#a9a9a9;z-index:1;font-weight:600}.input button:hover{opacity:.6}pre{overflow-x:auto;white-space:pre-wrap;white-space:-moz-pre-wrap;white-space:-o-pre-wrap;word-wrap:break-word}select{background:#fff;font-size:14px;height:30px;width:100%;padding:5px 15px;margin-top:15px;border-radius:10px}.column-simple .inner-box table tr,code,pre{color:#fff}.row,.transparent-row{text-align:center;padding:25px;width:95%;display:flex;position:relative}.row{border-radius:25px;border:2px solid var(--navbar-color)}.transparent-row{margin-top:50px;background-color:transparent;background-size:cover}.inner-row{border:0}.row-simple{width:95%;display:flex;position:relative}.column{flex:50%;color:var(--font-color);margin:30px;max-width:100%}.button-row{color:#fff;border-radius:4px;background-color:#006400;margin:0;cursor:pointer}.button-notready,.button-success,.button-warn{color:#fff;border-radius:4px;margin-top:30px;height:45px;margin-bottom:20px}.button-embedded{background-color:var(--primary-background);color:var(--font-color);height:25px;border:0;margin:5px;cursor:pointer}.button-orange{background-color:orange;margin:5px}.button-success{background-color:green;pointer-events:auto;cursor:pointer;opacity:1}.button-notready{background-color:#8b0000;pointer-events:none;cursor:not-allowed;opacity:.5}.button-warn{background-color:#8b0000;pointer-events:auto}.button-warn,p[onclick]{cursor:pointer}.switch{position:relative;display:inline-block;width:110px;height:34px}.slider,.slider::before{position:absolute;-webkit-transition:.4s;transition:.4s}.slider{border:1px solid #fff;cursor:pointer;top:0;left:0;right:0;bottom:0;background-color:#000}.slider::before{content:"";height:26px;width:26px;left:4px;bottom:4px;background-color:#fff}.toggle input:checked+.slider{background-color:green}.toggle input:checked+.slider::before{-webkit-transform:translateX(75px);-ms-transform:translateX(75px);transform:translateX(75px)}.on{display:none}.off,.on{color:#fff;position:absolute;transform:translate(-50%,-50%);top:50%;left:50%;font-size:10px;font-family:Verdana,sans-serif}.toggle input:checked+.slider .on{display:block}.toggle input:checked+.slider .off{display:none}.slider.round{border-radius:34px}.slider.round::before{border-radius:50%}.select-info{padding-left:10px}.section-profile pre{width:90%;background:#fff;border:1px solid #ddd;color:#666;page-break-inside:avoid;font-family:monospace;font-size:15px;line-height:1.6;margin-bottom:1.6em;max-width:100%;overflow:auto;padding:1em 1.5em;display:block;word-wrap:break-word}.section-profile img{border-radius:50%;height:65px;width:auto;background-color:#fff;border:5px solid var(--theme-color);margin-bottom:10px}.section-profile h4{margin-bottom:10px;margin-top:0}.section-profile h3{font-weight:400;margin-bottom:0}.section-profile ul{list-style-type:none}.section-profile p{font-size:13px}.duk-modal p,.section-profile li{text-align:left}.section-profile input[type=checkbox]{width:auto;vertical-align:middle;position:relative;bottom:1px}.section-profile button{width:75%}.section-description{padding:10px 25px;text-align:left}.closebtn,.notice{color:var(--font-color);font-size:14px}.notice{padding:6px;background-color:#b8860b;height:17px;text-align:center;position:fixed;top:43;left:5;bottom:auto;z-index:20;width:100%}.closebtn{margin-left:15px;font-weight:700;float:right;line-height:20px;cursor:pointer;transition:.3s}.closebtn:hover{color:#000}.duk-icon img{display:none}.duk-modal{background-color:#000}.duk-modal img{height:50px}.duk-home{margin-left:0;flex:37%;border-right:solid #fff 1px;line-height:10px;padding-right:30px}.duk-home:last-child{border-right:0}.mode{font-size:14px;margin-top:8px;color:#fff;background-color:var(--theme-color);border-radius:25px;padding:5px;display:inline-block}.theme{border-radius:50px;display:block}.sidebar-header{margin:30px 30px 0;border-bottom:1px solid #fff;cursor:pointer}.sidebar-header h5{margin-bottom:0;text-transform:uppercase}.sidebar-cutout{padding:10px 30px 0}.sidebar-cutout input[type=text]{width:90%;border-radius:5px}.checkdiv label{display:block;padding-left:15px;text-indent:-15px;font-size:18px;text-align:left}.checkdiv input{width:18px;height:18px;padding:0;margin:0;vertical-align:bottom;position:relative;top:-1px;*overflow:hidden}.obfuscation-table{color:#fff;width:100%;border-spacing:5px}.obfuscation-table img{height:18px;margin:5px}.obfuscation-table input{background-color:var(--primary-background);color:#fff;margin-left:-5px}.watermark{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%)}.simple-plugin-intro h1{margin-bottom:-15px}.simple-plugin-intro hr{width:33%;margin-bottom:-15px}.column-simple p{padding:25px;text-align:left;font-size:14px}.column-simple h3{text-transform:none}.column-simple h4{margin-top:20px;margin-bottom:-20px;line-height:normal}.column-simple .inner-box{text-align:left;background-color:var(--primary-background);padding:15px;border-radius:25px;margin:0 10% 10%}.column-simple .inner-box table tr td input[type=checkbox]{border-spacing:0;margin-top:15px}.column-simple .inner-box table tr td input,.column-simple .inner-box table tr td select{width:100%}.column-simple .inner-box table tr td p{padding:0;margin:0}.maroon{background-color:maroon}.yellow{background-color:#ffb000}.golden{background-color:#daa520;border-radius:50%;padding:5px}.variation-template{padding:10px;text-align:left;margin:15px;box-sizing:border-box}.variation-template img{height:25px}
\ No newline at end of file
diff --git a/static/css/timeline.min.css b/static/css/timeline.min.css
new file mode 100644
index 000000000..c0a1ec6f6
--- /dev/null
+++ b/static/css/timeline.min.css
@@ -0,0 +1 @@
+a,h1,h2,h3,span{text-decoration:none}a:hover{color:#777}.member-title{font-family:"Raleway",sans-serif;letter-spacing:1.5px;color:var(--font-color);font-weight:100;font-size:2.4em;margin:0;border-bottom:1px solid #777;padding-bottom:.2em}.member-infos{padding:10px;text-align:left;position:relative}.member-infos>h1{font-weight:700;font-size:1.4em}.member-location a::before{margin-right:5px}.member-location{text-indent:2px}.member-result{color:var(--font-color);font-size:13px;word-break:break-word}#content{margin-top:50px;text-align:center}.timeline{border-left:.25em solid var(--theme-color);background:rgba(255,255,255,.1);line-height:1.4em;padding:1em 1em 1em 3em;list-style:none;text-align:left;margin:2em 3em 2em 10em;border-radius:.5em;min-width:22em}.event{min-width:20em;width:90%;vertical-align:middle;box-sizing:border-box;position:relative}.timeline .event::after,.timeline .event::before{position:absolute;display:block;top:1em}.timeline .event::before{left:-15em;color:var(--font-color);content:attr(data-date);text-align:right;font-weight:100;font-size:.9em;min-width:9em}.timeline .event::after{left:-3.5em;background:var(--primary-background);border-radius:50%;height:.75em;width:.75em;content:""}.timeline .queued::after{box-shadow:0 0 0 .2em #555}.timeline .failure::after{box-shadow:0 0 0 .2em #c31}.timeline .success::after{box-shadow:0 0 0 .2em #4a9}.timeline .timeout::after{box-shadow:0 0 0 .2em #6495ed}.timeline .collected::after{box-shadow:0 0 0 .2em #ffb000}.timeline .untrusted::after{box-shadow:0 0 0 .2em #fff}.timeline .visibility::after{box-shadow:0 0 0 .2em #f012be}.cleanup .event::after,.timeline .event .member-location,.timeline .event .member-parameters{display:none}.timeline .event:last-of-type .member-location,.timeline .event:last-of-type .member-parameters{display:block}.tactic-find-result{font-size:14px;float:right;display:none;cursor:pointer}.tactic-find-result.tactic-no-facts{display:block;font-size:17px;margin-right:.14em}.tactic-no-facts::before{content:"★"}.tactic-find-result.tactic-has-facts{display:block}.tactic-has-facts::before{content:"⭐"}.tooltip{position:relative;display:inline-block;font-size:13px}.tooltiptext{width:120px;background-color:#000;color:#fff;text-align:center;border-radius:6px;padding:5px 0;position:absolute;font-size:13px}.cleanup ul{padding-left:0}#cleanup-title p{text-transform:uppercase;font-size:15px;font-weight:300}.cleanup .event::before{content:attr(data-host-name)}.loop-section{border-bottom:3px solid #777;margin:0 9 5px}.hil-command{width:97%;background-color:#000;border-radius:10px;padding:5px 5px 5px 10px;margin:5px 5px 15px}
\ No newline at end of file
diff --git a/static/js/ability.min.js b/static/js/ability.min.js
new file mode 100644
index 000000000..445856fe8
--- /dev/null
+++ b/static/js/ability.min.js
@@ -0,0 +1 @@
+function populateTechniques(e,i){var t=$("#"+e);$(t).find("#ability-technique-filter").empty().append("");let n=$(t).find("#ability-tactic-filter").find(":selected").data("tactic"),a=[];i.forEach(function(i){i.tactic.includes(n)&&!a.includes(i.technique_id)&&(a.push(i.technique_id),appendTechniqueToList(e,n,i))})}function populateAbilities(e,i){var t=$("#"+e);let n=[],a=$(t).find("#ability-technique-filter").find(":selected").data("technique");i.forEach(function(i){i.technique_id===a&&n.push(i)}),$(t).find("#ability-ability-filter").empty().append('"),n.forEach(function(i){appendAbilityToList(e,i)})}function appendTechniqueToList(i,e,t){$("#"+i).find("#ability-technique-filter").append($("").attr("value",t.technique_id).data("technique",t.technique_id).text(t.technique_id+" | "+t.technique_name))}function appendAbilityToList(i,e){$("#"+i).find("#ability-ability-filter").append($("").attr("value",e.name).data("ability",e).text(e.name))}function searchAbilities(a,i){var e=$("#"+a),t=(e.find("#ability-technique-filter").empty().append(""),e.find("#ability-ability-filter"));t.empty();let l=e.find("#ability-search-filter").val().toLowerCase(),d=[];l&&i.forEach(function(i){let e=!1;i.executors.forEach(function(i){null!=i.command&&i.command.toLowerCase().includes(l)&&(e=!0)});var t=i.name.toLowerCase().includes(l),n=i.description.toLowerCase().includes(l);(t||n||e)&&!d.includes(i.ability_id)&&(d.push(i.ability_id),appendAbilityToList(a,i))}),t.prepend("")}
\ No newline at end of file
diff --git a/static/js/core.min.js b/static/js/core.min.js
new file mode 100644
index 000000000..5a667b96e
--- /dev/null
+++ b/static/js/core.min.js
@@ -0,0 +1 @@
+function alpineCore(){return{openTabs:[],activeTabIndex:0,errors:startupErrors,showErrors:!1,version:"0.0.0",isFirstVisit:!1,scrollTop:window.scrollY,initPage(){window.onscroll=()=>{this.scrollTop=window.scrollY},apiV2("GET","/api/v2/health").then(t=>{this.version=t.version,this.checkIfFirstVisit()}).catch(t=>{console.error(t),toast("Error loading page",!1)})},checkIfFirstVisit(){var t=window.localStorage;this.isFirstVisit=!t.getItem("firstVisit"),this.isFirstVisit&&t.setItem("firstVisit",(new Date).toISOString())},setTabContent(t,e){var i=document.createElement("div");i.setAttribute("id",t.contentID),i.setAttribute("x-show","openTabs[activeTabIndex] && openTabs[activeTabIndex].contentID === $el.id"),setInnerHTML(i,e),document.getElementById("active-tab-display").appendChild(i)},async addTab(e,i,t=""){if("fieldmanual"===e)restRequest("GET",null,t=>{this.setTabContent({name:e,contentID:"tab-"+e,address:i},t)},i);else{var s=this.openTabs.findIndex(t=>t.name===e);if(-1!==s)this.activeTabIndex=s,this.checkQueryString(t);else{s={name:e,contentID:"tab-"+e,address:i};this.openTabs.push(s),this.activeTabIndex=this.openTabs.length-1;try{this.setTabContent(s,await apiV2("GET",s.address)),this.checkQueryString(t)}catch(t){toast("Unable to load page",!1),console.error(t)}}}},checkQueryString(t){var e;history.pushState?(e=`${window.location.protocol}//${window.location.host}${window.location.pathname}?`+t+window.location.hash,window.history.replaceState({path:e},"",e)):window.location.search=t},deleteTab(t,e){try{document.getElementById(e).remove()}catch(t){}this.activeTabIndex>=t&&(this.activeTabIndex=Math.max(0,this.activeTabIndex-1)),this.openTabs.splice(t,1)}}}
\ No newline at end of file
diff --git a/static/js/shared.min.js b/static/js/shared.min.js
new file mode 100644
index 000000000..df3086e3b
--- /dev/null
+++ b/static/js/shared.min.js
@@ -0,0 +1 @@
+function restRequest(e,t,n=e=>{console.log("Fetch Success",e)},r="/api/rest"){e="GET"===e?{method:e,headers:{"Content-Type":"application/json"}}:{method:e,headers:{"Content-Type":"application/json"},body:JSON.stringify(t)};fetch(r,e).then(e=>{if(e.ok)return e.text();throw e.statusText}).then(e=>{try{n(JSON.parse(e))}catch{n(e)}}).catch(e=>console.error(e))}function apiV2(e,r,t=null,n=!0){let o={method:e};return n?(o.headers={"Content-Type":"application/json"},t&&(o.body=JSON.stringify(t))):t&&(o.body=t),new Promise((t,n)=>{fetch(r,o).then(e=>(e.ok||n(e.statusText),e.text())).then(e=>{try{t(JSON.parse(e))}catch{t(e)}})})}function getFilteredAbilities(e,s,c,u,l=null,d="",p=null){return e.filter((e,t)=>{var n=e.name.toLowerCase().includes(s)||e.description.toLowerCase().includes(s)||e.tactic.toLowerCase().includes(s)||e.technique_id.toLowerCase().includes(s)||e.technique_name.toLowerCase().includes(s),r=!c||e.tactic===c,o=!u||e.technique_id+" | "+e.technique_name===u,a=e.executors.map(e=>e.platform);let i=!l||a.some(e=>l.includes(e));p&&(i=i||e.executors.some(e=>p.includes(e.name)));a=!d||!d.includes(e.plugin);return n&&r&&o&&i&&a})}function getHumanFriendlyTime(e){if(!e)return"";e=e.split("-");let t=Number(e[1])-1;var n=Number(e[2].split(" ")[0]),r=e[2].split(" ")[1].split(":"),e=Date.UTC(e[0],t,n,r[0],r[1],r[2]),e=Math.round((Date.now()-e)/1e3),o=3600;let a;if(e<30)a="just now";else if(e<60)a=e+" seconds ago";else if(e<120)a="a minute ago";else if(e{e=e.toLowerCase(),t=t.toLowerCase();return e `+sanitize(e),type:"toast "+(t?"is-success":"is-danger"),position:"bottom-right",duration:"3000",pauseOnHover:!0})}function validateInputs(t,e){let n=[];return e.forEach(e=>{0===t[e].length&&n.push(e)}),n}function downloadJson(e,t){var t="data:text/json;charset=utf-8,"+encodeURIComponent(JSON.stringify(t,null,2)),n=document.createElement("a");n.setAttribute("href",t),n.setAttribute("download",e+".json"),document.body.appendChild(n),n.click(),n.remove()}function downloadReport(e,n,t={},r=!1){restRequest("POST",t,function(e){stream("Downloading report: "+n);var e=r?JSON.stringify(e,null,2):e,e="data:text/json;charset=utf-8,"+encodeURIComponent(e),t=document.createElement("a");t.setAttribute("href",e),t.setAttribute("download",n+".json"),document.body.appendChild(t),t.click(),t.remove()},e)}function sleep(t){return new Promise(e=>setTimeout(e,t))}function uuidv4(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(e){var t=16*Math.random()|0;return("x"==e?t:3&t|8).toString(16)})}function setInnerHTML(e,t){e.innerHTML=t;t=Array.from(e.querySelectorAll("script"));t&&t.forEach(e=>{let t=document.createElement("script");Array.from(e.attributes).forEach(e=>t.setAttribute(e.name,e.value)),t.appendChild(document.createTextNode(e.innerHTML)),e.parentNode.replaceChild(t,e)})}function removeSection(e){$("#"+e).remove()}function b64DecodeUnicode(e){if(null==e)return"";try{return decodeURIComponent(atob(e).split("").map(function(e){return"%"+("00"+e.charCodeAt(0).toString(16)).slice(-2)}).join(""))}catch{return atob(e)}}
\ No newline at end of file