From cc101f89c7a0bff269174508c72c0f6488bf7569 Mon Sep 17 00:00:00 2001 From: Fiona McCrae Date: Mon, 2 Mar 2026 16:27:12 -0500 Subject: [PATCH 01/25] initial commit with CSRF mitigations --- app/api/v2/__init__.py | 3 ++- app/api/v2/security.py | 41 +++++++++++++++++++++++++++++++++++++++++ app/service/auth_svc.py | 24 +++++++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/app/api/v2/__init__.py b/app/api/v2/__init__.py index 1a9c40465..109ad95b7 100644 --- a/app/api/v2/__init__.py +++ b/app/api/v2/__init__.py @@ -3,12 +3,13 @@ def make_app(services): from .responses import json_request_validation_middleware - from .security import authentication_required_middleware_factory, pass_option_middleware + from .security import authentication_required_middleware_factory, pass_option_middleware, csrf_protect_middleware_factory app = web.Application( middlewares=[ pass_option_middleware, authentication_required_middleware_factory(services['auth_svc']), + csrf_protect_middleware_factory(services['auth_svc']), json_request_validation_middleware ] ) diff --git a/app/api/v2/security.py b/app/api/v2/security.py index 0f70fb3a8..1b04854d5 100644 --- a/app/api/v2/security.py +++ b/app/api/v2/security.py @@ -3,6 +3,9 @@ import types from aiohttp import web +from aiohttp_session import get_session +from hmac import compare_digest +from aiohttp.web_exceptions import HTTPForbidden def is_handler_authentication_exempt(handler): @@ -69,6 +72,44 @@ async def authentication_required_middleware(request, handler): return authentication_required_middleware +def csrf_protect_middleware_factory(auth_svc): + """Protect unsafe (state-modifying) requests against CSRF for session-authenticated users. + + Behavior: + - Allow safe methods (GET, HEAD, OPTIONS) without checks. + - If request provides an API key (header KEY), skip CSRF checks. + - For session-authenticated requests using unsafe methods, compare the X-CSRF-Token + header to the token stored in the server-side session (recommended) and reject + requests with missing/invalid tokens with HTTP 403. + """ + @web.middleware + async def csrf_protect_middleware(request, handler): + # Skip safe methods + if request.method in ('GET', 'HEAD', 'OPTIONS'): + return await handler(request) + + # If API key auth is present, skip CSRF checks + if request.headers.get('KEY'): + return await handler(request) + + # For session-authenticated requests, validate token + try: + session = await get_session(request) + token = session.get('csrf_token') if session is not None else None + header = request.headers.get('X-CSRF-Token') or request.headers.get('X-XSRF-TOKEN') + if not token or not header or not compare_digest(header, token): + raise HTTPForbidden(text='Missing or invalid CSRF token') + except HTTPForbidden: + raise + except Exception: + # If something goes wrong accessing the session, deny the request + raise HTTPForbidden(text='CSRF validation error') + + return await handler(request) + + return csrf_protect_middleware + + @web.middleware async def pass_option_middleware(request, handler): """Allow all 'OPTIONS' request to the server to return 200 diff --git a/app/service/auth_svc.py b/app/service/auth_svc.py index 17b2c1aea..773ee3574 100644 --- a/app/service/auth_svc.py +++ b/app/service/auth_svc.py @@ -10,8 +10,10 @@ from aiohttp_security import setup as setup_security from aiohttp_security.abc import AbstractAuthorizationPolicy from aiohttp_session import setup as setup_session +from aiohttp_session import get_session from aiohttp_session.cookie_storage import EncryptedCookieStorage from cryptography import fernet +import secrets from app.service.interfaces.i_auth_svc import AuthServiceInterface from app.service.interfaces.i_login_handler import LoginHandlerInterface @@ -75,7 +77,14 @@ async def apply(self, app, users): app.user_map = self.user_map fernet_key = fernet.Fernet.generate_key() secret_key = base64.urlsafe_b64decode(fernet_key) - storage = EncryptedCookieStorage(secret_key, cookie_name=COOKIE_SESSION) + storage = EncryptedCookieStorage( + secret_key, + cookie_name=COOKIE_SESSION, + secure=True, + httponly=True, + max_age=86400, + samesite='Strict' + ) setup_session(app, storage) policy = SessionIdentityPolicy() setup_security(app, policy, DictionaryAuthorizationPolicy(self.user_map)) @@ -155,6 +164,19 @@ async def handle_successful_login(self, request, username): self.log.debug('%s logging in', username) response = web.HTTPFound('/') await remember(request, response, username) + + # Initialize per-session CSRF token and expose it via a readable cookie for double-submit + try: + session = await get_session(request) + if 'csrf_token' not in session: + session['csrf_token'] = secrets.token_urlsafe(32) + # Set a non-HttpOnly cookie so client-side JS can read the token for double-submit. + secure_flag = (request.scheme == 'https') if hasattr(request, 'scheme') else False + response.set_cookie('XSRF-TOKEN', session['csrf_token'], httponly=False, secure=secure_flag, samesite='Lax') + except Exception: + # If session management or cookie setting fails, continue without exposing token. + self.log.exception('Failed to set CSRF token on login') + raise response async def check_permissions(self, group, request): From 2851b30fd29fdc52cda2c3e8e615c26d20666f0a Mon Sep 17 00:00:00 2001 From: Fiona McCrae Date: Mon, 9 Mar 2026 10:36:15 -0400 Subject: [PATCH 02/25] added some tests for CSRF middleware --- tests/api/v2/test_security.py | 88 +++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/api/v2/test_security.py b/tests/api/v2/test_security.py index eb8bf87b5..dbbbc97c4 100644 --- a/tests/api/v2/test_security.py +++ b/tests/api/v2/test_security.py @@ -67,6 +67,48 @@ async def login(request): return app +@pytest.fixture +def csrf_webapp(event_loop, base_world): + """Like simple_webapp but with CSRF protection middleware and POST route for /private.""" + async def index(request): + return web.Response(status=200, text='hello!') + + @security.authentication_exempt + async def public(request): + return web.Response(status=200, text='public') + + async def private(request): + return web.Response(status=200, text='private') + + @security.authentication_exempt + async def login(request): + await auth_svc.login_user(request) # Note: auth_svc defined in context function + + app = web.Application() + app.router.add_get('/', index) + app.router.add_post('/login', login) + app.router.add_get('/public', public) + app.router.add_get('/private', private) + app.router.add_post('/private', private) + + auth_svc = AuthService() + + event_loop.run_until_complete( + auth_svc.apply( + app=app, + users=base_world.get_config('users') + ) + ) + event_loop.run_until_complete(auth_svc.set_login_handlers(auth_svc.get_services())) + + # Ensure authentication middleware runs after session middleware + app.middlewares.append(security.authentication_required_middleware_factory(auth_svc)) + # Add CSRF protection middleware after authentication + app.middlewares.append(security.csrf_protect_middleware_factory(auth_svc)) + + return app + + def test_function_is_authentication_exempt(): def fake_handler(request): return None @@ -156,3 +198,49 @@ async def public(self, request): client = await aiohttp_client(app) resp = await client.get('/public') assert resp.status == 200 + + +# CSRF protection tests +async def test_csrf_protect_rejects_missing_token_for_session_auth(csrf_webapp, aiohttp_client): + """A session-authenticated POST without a valid CSRF header should be rejected with 403.""" + client = await aiohttp_client(csrf_webapp) + + login_response = await client.post( + '/login', + data={'username': 'admin', 'password': 'admin'}, + allow_redirects=False + ) + + assert login_response.status == 200 + assert COOKIE_SESSION in login_response.cookies + + post_resp = await client.post('/private') + assert post_resp.status == 403 + + +async def test_csrf_protect_accepts_valid_token_for_session_auth(csrf_webapp, aiohttp_client): + """A session-authenticated POST with a valid CSRF header should be allowed.""" + client = await aiohttp_client(csrf_webapp) + + login_response = await client.post( + '/login', + data={'username': 'admin', 'password': 'admin'}, + allow_redirects=False + ) + + assert login_response.status == 200 + # The login handler exposes the CSRF token as a readable cookie named 'XSRF-TOKEN' + token_cookie = login_response.cookies.get('XSRF-TOKEN') + assert token_cookie is not None + token = token_cookie.value + + post_resp = await client.post('/private', headers={'X-CSRF-Token': token}) + assert post_resp.status == 200 + +# TODO: Change to be a valid API Key Header (vs just any API key header) - covered by API login test +async def test_csrf_protect_skips_when_api_key_present(csrf_webapp, aiohttp_client): + """If an API key header is present, CSRF checks should be skipped.""" + client = await aiohttp_client(csrf_webapp) + + post_resp = await client.post('/private', headers={HEADER_API_KEY: 'abc123'}) + assert post_resp.status == 200 From f1d4330a2a94b046cbbfcf87b753526678699e2c Mon Sep 17 00:00:00 2001 From: Fiona McCrae Date: Mon, 16 Mar 2026 10:47:09 -0400 Subject: [PATCH 03/25] added additional CSRF tests --- app/api/v2/security.py | 2 + tests/api/v2/test_csrf_operations.py | 197 +++++++++++++++++++++++++++ tests/api/v2/test_security.py | 51 +------ 3 files changed, 204 insertions(+), 46 deletions(-) create mode 100644 tests/api/v2/test_csrf_operations.py diff --git a/app/api/v2/security.py b/app/api/v2/security.py index 1b04854d5..a120b0c97 100644 --- a/app/api/v2/security.py +++ b/app/api/v2/security.py @@ -97,6 +97,8 @@ async def csrf_protect_middleware(request, handler): session = await get_session(request) token = session.get('csrf_token') if session is not None else None header = request.headers.get('X-CSRF-Token') or request.headers.get('X-XSRF-TOKEN') + # check if there is a token, the header is missing, and whether the token and header + # hash authentication works if not token or not header or not compare_digest(header, token): raise HTTPForbidden(text='Missing or invalid CSRF token') except HTTPForbidden: diff --git a/tests/api/v2/test_csrf_operations.py b/tests/api/v2/test_csrf_operations.py new file mode 100644 index 000000000..51c302dbb --- /dev/null +++ b/tests/api/v2/test_csrf_operations.py @@ -0,0 +1,197 @@ +import pytest +from aiohttp import web +from pathlib import Path +import yaml +from aiohttp import web + +from app.utility.base_world import BaseWorld +from app.service.app_svc import AppService +from app.service.auth_svc import AuthService +from app.service.data_svc import DataService +from app.service.rest_svc import RestService +from app.service.planning_svc import PlanningService +from app.service.knowledge_svc import KnowledgeService +from app.service.learning_svc import LearningService +from app.service.file_svc import FileSvc +from app.service.event_svc import EventService +from app.api.rest_api import RestApi +from app.api.v2.handlers.operation_api import OperationApi +from app.api.v2.security import csrf_protect_middleware_factory, authentication_required_middleware_factory +from app.api.v2.responses import json_request_validation_middleware + + +@pytest.fixture +async def api_v2_client_with_csrf(aiohttp_client, tmp_path): + # Build app similar to conftest.api_v2_client but ensure CSRF middleware is added to the /api/v2 subapp + base = Path(__file__).parents[1] + + # Load configs used by the app + with open(base / 'conf' / 'default.yml', 'r') as fle: + BaseWorld.apply_config('main', yaml.safe_load(fle)) + with open(base / 'conf' / 'payloads.yml', 'r') as fle: + BaseWorld.apply_config('payloads', yaml.safe_load(fle)) + with open(base / 'conf' / 'agents.yml', 'r') as fle: + BaseWorld.apply_config('agents', yaml.safe_load(fle)) + + app_svc = AppService(web.Application(client_max_size=5120 ** 2)) + # Initialize core services used by RestApi and the v2 APIs + _ = DataService() + _ = RestService() + _ = PlanningService() + _ = KnowledgeService() + _ = LearningService() + auth_svc = AuthService() + _ = FileSvc() + _ = EventService() + services = app_svc.get_services() + + # Enable REST endpoints (this registers the /enter login endpoint used to obtain cookies) + _ = await RestApi(services).enable() + await app_svc.register_contacts() + await auth_svc.apply(app_svc.application, auth_svc.get_config('users')) + await auth_svc.set_login_handlers(services) + + # Create the v2 subapp with CSRF protection middleware included + def make_app(svcs): + app = web.Application(middlewares=[ + authentication_required_middleware_factory(svcs['auth_svc']), + csrf_protect_middleware_factory(svcs['auth_svc']), + json_request_validation_middleware + ]) + OperationApi(svcs).add_routes(app) + return app + + app_svc.register_subapp('/api/v2', make_app(svcs=services)) + + client = await aiohttp_client(app_svc.application) + yield client + await app_svc._destroy_plugins() + + +async def test_csrf_protect_rejects_missing_token_for_session_auth(csrf_webapp, aiohttp_client): + """A session-authenticated POST without a valid CSRF header should be rejected with 403.""" + client = await aiohttp_client(csrf_webapp) + + login_response = await client.post( + '/login', + data={'username': 'admin', 'password': 'admin'}, + allow_redirects=False + ) + + assert login_response.status == 200 + assert COOKIE_SESSION in login_response.cookies + + post_resp = await client.post('/private') + assert post_resp.status == 403 + + +async def test_csrf_protect_accepts_valid_token_for_session_auth(csrf_webapp, aiohttp_client): + """A session-authenticated POST with a valid CSRF header should be allowed.""" + client = await aiohttp_client(csrf_webapp) + + login_response = await client.post( + '/login', + data={'username': 'admin', 'password': 'admin'}, + allow_redirects=False + ) + + assert login_response.status == 200 + # The login handler exposes the CSRF token as a readable cookie named 'XSRF-TOKEN' + token_cookie = login_response.cookies.get('XSRF-TOKEN') + assert token_cookie is not None + token = token_cookie.value + + post_resp = await client.post('/private', headers={'X-CSRF-Token': token}) + assert post_resp.status == 200 + + +async def test_csrf_protect_skips_when_api_key_present(csrf_webapp, aiohttp_client): + """If an API key header is present, CSRF checks should be skipped.""" + client = await aiohttp_client(csrf_webapp) + + post_resp = await client.post('/private', headers={HEADER_API_KEY: 'abc123'}) + assert post_resp.status == 200 + + +# Timing-attack resistance tests +async def _measure_request_mean(client, method, path, count=30, **kwargs): + # Warmup + for _ in range(3): + if method.lower() == 'get': + r = await client.get(path, **kwargs) + else: + r = await client.post(path, **kwargs) + await r.text() + + times = [] + for _ in range(count): + start = time.monotonic() + if method.lower() == 'get': + r = await client.get(path, **kwargs) + else: + r = await client.post(path, **kwargs) + await r.text() + times.append(time.monotonic() - start) + return statistics.mean(times) + + +async def test_timing_api_key_resistant_to_timing_attacks(simple_webapp, aiohttp_client): + """Ensure API key validation does not leak timing differences between valid and invalid keys.""" + client = await aiohttp_client(simple_webapp) + + count = 25 + mean_valid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'abc123'}) + mean_invalid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'INVALID_KEY'}) + + # Allow a small tolerance for network/test harness jitter (50ms) + assert abs(mean_valid - mean_invalid) < 0.05 + + +async def test_timing_csrf_token_resistant_to_timing_attacks(csrf_webapp, aiohttp_client): + """Ensure CSRF token comparison does not leak timing differences between valid and invalid tokens.""" + client = await aiohttp_client(csrf_webapp) + + # Login to establish session and get token + login_response = await client.post('/login', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) + assert login_response.status == 200 + token_cookie = login_response.cookies.get('XSRF-TOKEN') + assert token_cookie is not None + token = token_cookie.value + + count = 25 + mean_valid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token}) + mean_invalid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token + 'x'}) + + # Allow a small tolerance for network/test harness jitter (50ms) + assert abs(mean_valid - mean_invalid) < 0.05 + +async def test_csrf_prevents_cross_site_operation_creation(api_v2_client_with_csrf): + """Simulate an attacker-controlled page submitting a POST that will include the user's session cookie + but will not be able to provide the X-CSRF-Token header. The CSRF middleware should block the request. + """ + client = api_v2_client_with_csrf + + # Perform login on the main app to obtain authenticated session cookies + enter_resp = await client.post('/enter', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) + assert enter_resp.status in (200, 302) + cookies = enter_resp.cookies + + # Attempt to create an operation without providing the X-CSRF-Token header. + # This simulates a cross-site form POST originating from an attacker's page. + payload = { + 'adversary': {'adversary_id': '123', 'name': 'ad-hoc'}, + 'source': {'id': '123'} + } + + resp = await client.post('/api/v2/operations', cookies=cookies, json=payload) + # CSRF protection should reject this because no X-CSRF-Token header was supplied + assert resp.status == 403 + + # For completeness: if the client includes the legitimate X-CSRF-Token header (as same-origin scripts would), + # the request should be allowed (happy-path). The login response sets an XSRF-TOKEN readable cookie; include it. + xsrf_cookie = enter_resp.cookies.get('XSRF-TOKEN') + if xsrf_cookie: + token = xsrf_cookie.value + resp2 = await client.post('/api/v2/operations', cookies=cookies, json=payload, headers={'X-CSRF-Token': token}) + # Depending on DB state and required payload, this may be accepted or rejected by business validation; it must not be CSRF 403. + assert resp2.status != 403 diff --git a/tests/api/v2/test_security.py b/tests/api/v2/test_security.py index dbbbc97c4..08abcfe46 100644 --- a/tests/api/v2/test_security.py +++ b/tests/api/v2/test_security.py @@ -1,10 +1,15 @@ import pytest from aiohttp import web +import logging +import time +import statistics from app.api.v2 import security from app.service.auth_svc import AuthService, HEADER_API_KEY, CONFIG_API_KEY_RED, COOKIE_SESSION from app.utility.base_world import BaseWorld +logging.basicConfig(level=logging.DEBUG) + @pytest.fixture def base_world(): @@ -198,49 +203,3 @@ async def public(self, request): client = await aiohttp_client(app) resp = await client.get('/public') assert resp.status == 200 - - -# CSRF protection tests -async def test_csrf_protect_rejects_missing_token_for_session_auth(csrf_webapp, aiohttp_client): - """A session-authenticated POST without a valid CSRF header should be rejected with 403.""" - client = await aiohttp_client(csrf_webapp) - - login_response = await client.post( - '/login', - data={'username': 'admin', 'password': 'admin'}, - allow_redirects=False - ) - - assert login_response.status == 200 - assert COOKIE_SESSION in login_response.cookies - - post_resp = await client.post('/private') - assert post_resp.status == 403 - - -async def test_csrf_protect_accepts_valid_token_for_session_auth(csrf_webapp, aiohttp_client): - """A session-authenticated POST with a valid CSRF header should be allowed.""" - client = await aiohttp_client(csrf_webapp) - - login_response = await client.post( - '/login', - data={'username': 'admin', 'password': 'admin'}, - allow_redirects=False - ) - - assert login_response.status == 200 - # The login handler exposes the CSRF token as a readable cookie named 'XSRF-TOKEN' - token_cookie = login_response.cookies.get('XSRF-TOKEN') - assert token_cookie is not None - token = token_cookie.value - - post_resp = await client.post('/private', headers={'X-CSRF-Token': token}) - assert post_resp.status == 200 - -# TODO: Change to be a valid API Key Header (vs just any API key header) - covered by API login test -async def test_csrf_protect_skips_when_api_key_present(csrf_webapp, aiohttp_client): - """If an API key header is present, CSRF checks should be skipped.""" - client = await aiohttp_client(csrf_webapp) - - post_resp = await client.post('/private', headers={HEADER_API_KEY: 'abc123'}) - assert post_resp.status == 200 From 74b2efe8960fc333c3d5de66cfe436efcff294e3 Mon Sep 17 00:00:00 2001 From: Fiona McCrae Date: Mon, 16 Mar 2026 12:41:40 -0400 Subject: [PATCH 04/25] separated regular functioning security tests from CSRF tests --- tests/api/v2/test_csrf_operations.py | 273 +++++++++++++++++---------- tests/api/v2/test_security.py | 54 +----- 2 files changed, 176 insertions(+), 151 deletions(-) diff --git a/tests/api/v2/test_csrf_operations.py b/tests/api/v2/test_csrf_operations.py index 51c302dbb..b124136be 100644 --- a/tests/api/v2/test_csrf_operations.py +++ b/tests/api/v2/test_csrf_operations.py @@ -1,12 +1,16 @@ import pytest +import pytest_asyncio from aiohttp import web from pathlib import Path import yaml -from aiohttp import web +import time +import statistics + +from aiohttp.test_utils import TestServer, TestClient from app.utility.base_world import BaseWorld from app.service.app_svc import AppService -from app.service.auth_svc import AuthService +from app.service.auth_svc import AuthService, HEADER_API_KEY, CONFIG_API_KEY_RED, COOKIE_SESSION from app.service.data_svc import DataService from app.service.rest_svc import RestService from app.service.planning_svc import PlanningService @@ -16,16 +20,66 @@ from app.service.event_svc import EventService from app.api.rest_api import RestApi from app.api.v2.handlers.operation_api import OperationApi -from app.api.v2.security import csrf_protect_middleware_factory, authentication_required_middleware_factory +from app.api.v2 import security from app.api.v2.responses import json_request_validation_middleware @pytest.fixture -async def api_v2_client_with_csrf(aiohttp_client, tmp_path): - # Build app similar to conftest.api_v2_client but ensure CSRF middleware is added to the /api/v2 subapp +def base_world(): + BaseWorld.clear_config() + BaseWorld.apply_config( + name='main', + config={ + CONFIG_API_KEY_RED: 'abc123', + + 'users': { + 'admin': {'admin': 'admin'}, + 'red': {'red': 'redpass'}, + 'blue': {'blue': 'bluepass'} + } + } + ) + yield BaseWorld + BaseWorld.clear_config() + + +@pytest_asyncio.fixture +async def csrf_webapp(event_loop, base_world): + async def index(request): + return web.Response(status=200, text='hello!') + + @security.authentication_exempt + async def public(request): + return web.Response(status=200, text='public') + + async def private(request): + return web.Response(status=200, text='private') + + @security.authentication_exempt + async def login(request): + await auth_svc.login_user(request) + + app = web.Application() + app.router.add_get('/', index) + app.router.add_post('/login', login) + app.router.add_get('/public', public) + app.router.add_get('/private', private) + app.router.add_post('/private', private) + + auth_svc = AuthService() + await auth_svc.apply(app=app, users=base_world.get_config('users')) + await auth_svc.set_login_handlers(auth_svc.get_services()) + + app.middlewares.append(security.authentication_required_middleware_factory(auth_svc)) + app.middlewares.append(security.csrf_protect_middleware_factory(auth_svc)) + + return app + + +@pytest_asyncio.fixture +async def api_v2_client_with_csrf(tmp_path): base = Path(__file__).parents[1] - # Load configs used by the app with open(base / 'conf' / 'default.yml', 'r') as fle: BaseWorld.apply_config('main', yaml.safe_load(fle)) with open(base / 'conf' / 'payloads.yml', 'r') as fle: @@ -34,7 +88,6 @@ async def api_v2_client_with_csrf(aiohttp_client, tmp_path): BaseWorld.apply_config('agents', yaml.safe_load(fle)) app_svc = AppService(web.Application(client_max_size=5120 ** 2)) - # Initialize core services used by RestApi and the v2 APIs _ = DataService() _ = RestService() _ = PlanningService() @@ -45,17 +98,15 @@ async def api_v2_client_with_csrf(aiohttp_client, tmp_path): _ = EventService() services = app_svc.get_services() - # Enable REST endpoints (this registers the /enter login endpoint used to obtain cookies) - _ = await RestApi(services).enable() + await RestApi(services).enable() await app_svc.register_contacts() await auth_svc.apply(app_svc.application, auth_svc.get_config('users')) await auth_svc.set_login_handlers(services) - # Create the v2 subapp with CSRF protection middleware included def make_app(svcs): app = web.Application(middlewares=[ - authentication_required_middleware_factory(svcs['auth_svc']), - csrf_protect_middleware_factory(svcs['auth_svc']), + security.authentication_required_middleware_factory(svcs['auth_svc']), + security.csrf_protect_middleware_factory(svcs['auth_svc']), json_request_validation_middleware ]) OperationApi(svcs).add_routes(app) @@ -63,59 +114,17 @@ def make_app(svcs): app_svc.register_subapp('/api/v2', make_app(svcs=services)) - client = await aiohttp_client(app_svc.application) - yield client - await app_svc._destroy_plugins() - - -async def test_csrf_protect_rejects_missing_token_for_session_auth(csrf_webapp, aiohttp_client): - """A session-authenticated POST without a valid CSRF header should be rejected with 403.""" - client = await aiohttp_client(csrf_webapp) - - login_response = await client.post( - '/login', - data={'username': 'admin', 'password': 'admin'}, - allow_redirects=False - ) - - assert login_response.status == 200 - assert COOKIE_SESSION in login_response.cookies - - post_resp = await client.post('/private') - assert post_resp.status == 403 - - -async def test_csrf_protect_accepts_valid_token_for_session_auth(csrf_webapp, aiohttp_client): - """A session-authenticated POST with a valid CSRF header should be allowed.""" - client = await aiohttp_client(csrf_webapp) - - login_response = await client.post( - '/login', - data={'username': 'admin', 'password': 'admin'}, - allow_redirects=False - ) - - assert login_response.status == 200 - # The login handler exposes the CSRF token as a readable cookie named 'XSRF-TOKEN' - token_cookie = login_response.cookies.get('XSRF-TOKEN') - assert token_cookie is not None - token = token_cookie.value - - post_resp = await client.post('/private', headers={'X-CSRF-Token': token}) - assert post_resp.status == 200 - - -async def test_csrf_protect_skips_when_api_key_present(csrf_webapp, aiohttp_client): - """If an API key header is present, CSRF checks should be skipped.""" - client = await aiohttp_client(csrf_webapp) + server = TestServer(app_svc.application) + client = TestClient(server) + await client.start_server() + try: + yield client + finally: + await client.close() + await app_svc._destroy_plugins() - post_resp = await client.post('/private', headers={HEADER_API_KEY: 'abc123'}) - assert post_resp.status == 200 - -# Timing-attack resistance tests async def _measure_request_mean(client, method, path, count=30, **kwargs): - # Warmup for _ in range(3): if method.lower() == 'get': r = await client.get(path, **kwargs) @@ -135,63 +144,125 @@ async def _measure_request_mean(client, method, path, count=30, **kwargs): return statistics.mean(times) -async def test_timing_api_key_resistant_to_timing_attacks(simple_webapp, aiohttp_client): - """Ensure API key validation does not leak timing differences between valid and invalid keys.""" - client = await aiohttp_client(simple_webapp) - - count = 25 - mean_valid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'abc123'}) - mean_invalid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'INVALID_KEY'}) - - # Allow a small tolerance for network/test harness jitter (50ms) - assert abs(mean_valid - mean_invalid) < 0.05 - - -async def test_timing_csrf_token_resistant_to_timing_attacks(csrf_webapp, aiohttp_client): - """Ensure CSRF token comparison does not leak timing differences between valid and invalid tokens.""" - client = await aiohttp_client(csrf_webapp) - - # Login to establish session and get token - login_response = await client.post('/login', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) - assert login_response.status == 200 - token_cookie = login_response.cookies.get('XSRF-TOKEN') - assert token_cookie is not None - token = token_cookie.value - - count = 25 - mean_valid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token}) - mean_invalid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token + 'x'}) - - # Allow a small tolerance for network/test harness jitter (50ms) - assert abs(mean_valid - mean_invalid) < 0.05 +@pytest.mark.asyncio +async def test_csrf_protect_rejects_missing_token_for_session_auth(csrf_webapp): + client = TestClient(TestServer(csrf_webapp)) + await client.start_server() + try: + login_response = await client.post('/login', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) + assert login_response.status == 200 + assert COOKIE_SESSION in login_response.cookies + + post_resp = await client.post('/private') + assert post_resp.status == 403 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_csrf_protect_accepts_valid_token_for_session_auth(csrf_webapp): + client = TestClient(TestServer(csrf_webapp)) + await client.start_server() + try: + login_response = await client.post('/login', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) + assert login_response.status == 200 + token_cookie = login_response.cookies.get('XSRF-TOKEN') + assert token_cookie is not None + token = token_cookie.value + + post_resp = await client.post('/private', headers={'X-CSRF-Token': token}) + assert post_resp.status == 200 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_csrf_protect_skips_when_api_key_present(csrf_webapp): + client = TestClient(TestServer(csrf_webapp)) + await client.start_server() + try: + post_resp = await client.post('/private', headers={HEADER_API_KEY: 'abc123'}) + assert post_resp.status == 200 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_timing_api_key_resistant_to_timing_attacks(): + # Use simple app like in test_security + async def index(request): + return web.Response(status=200, text='hello!') + + @security.authentication_exempt + async def public(request): + return web.Response(status=200, text='public') + + async def private(request): + return web.Response(status=200, text='private') + + @security.authentication_exempt + async def login(request): + await AuthService().login_user(request) + + app = web.Application() + app.router.add_get('/', index) + app.router.add_post('/login', login) + app.router.add_get('/public', public) + app.router.add_get('/private', private) + auth_svc = AuthService() + await auth_svc.apply(app=app, users=BaseWorld.get_config('main').get('users')) + await auth_svc.set_login_handlers(auth_svc.get_services()) + app.middlewares.append(security.authentication_required_middleware_factory(auth_svc)) + + client = TestClient(TestServer(app)) + await client.start_server() + try: + count = 25 + mean_valid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'abc123'}) + mean_invalid = await _measure_request_mean(client, 'get', '/private', count=count, headers={HEADER_API_KEY: 'INVALID_KEY'}) + assert abs(mean_valid - mean_invalid) < 0.05 + finally: + await client.close() + + +@pytest.mark.asyncio +async def test_timing_csrf_token_resistant_to_timing_attacks(csrf_webapp): + client = TestClient(TestServer(csrf_webapp)) + await client.start_server() + try: + login_response = await client.post('/login', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) + assert login_response.status == 200 + token_cookie = login_response.cookies.get('XSRF-TOKEN') + assert token_cookie is not None + token = token_cookie.value + + count = 25 + mean_valid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token}) + mean_invalid = await _measure_request_mean(client, 'post', '/private', count=count, headers={'X-CSRF-Token': token + 'x'}) + assert abs(mean_valid - mean_invalid) < 0.05 + finally: + await client.close() + + +@pytest.mark.asyncio async def test_csrf_prevents_cross_site_operation_creation(api_v2_client_with_csrf): - """Simulate an attacker-controlled page submitting a POST that will include the user's session cookie - but will not be able to provide the X-CSRF-Token header. The CSRF middleware should block the request. - """ client = api_v2_client_with_csrf - # Perform login on the main app to obtain authenticated session cookies enter_resp = await client.post('/enter', data={'username': 'admin', 'password': 'admin'}, allow_redirects=False) assert enter_resp.status in (200, 302) cookies = enter_resp.cookies - # Attempt to create an operation without providing the X-CSRF-Token header. - # This simulates a cross-site form POST originating from an attacker's page. payload = { 'adversary': {'adversary_id': '123', 'name': 'ad-hoc'}, 'source': {'id': '123'} } resp = await client.post('/api/v2/operations', cookies=cookies, json=payload) - # CSRF protection should reject this because no X-CSRF-Token header was supplied assert resp.status == 403 - # For completeness: if the client includes the legitimate X-CSRF-Token header (as same-origin scripts would), - # the request should be allowed (happy-path). The login response sets an XSRF-TOKEN readable cookie; include it. xsrf_cookie = enter_resp.cookies.get('XSRF-TOKEN') if xsrf_cookie: token = xsrf_cookie.value resp2 = await client.post('/api/v2/operations', cookies=cookies, json=payload, headers={'X-CSRF-Token': token}) - # Depending on DB state and required payload, this may be accepted or rejected by business validation; it must not be CSRF 403. assert resp2.status != 403 diff --git a/tests/api/v2/test_security.py b/tests/api/v2/test_security.py index 08abcfe46..bf4e54e09 100644 --- a/tests/api/v2/test_security.py +++ b/tests/api/v2/test_security.py @@ -1,15 +1,10 @@ -import pytest +iimport pytest from aiohttp import web -import logging -import time -import statistics from app.api.v2 import security from app.service.auth_svc import AuthService, HEADER_API_KEY, CONFIG_API_KEY_RED, COOKIE_SESSION from app.utility.base_world import BaseWorld -logging.basicConfig(level=logging.DEBUG) - @pytest.fixture def base_world(): @@ -24,7 +19,8 @@ def base_world(): 'red': {'reduser': 'redpass'}, 'blue': {'blueuser': 'bluepass'} } - } + }, + apply_hash=True ) yield BaseWorld @@ -72,48 +68,6 @@ async def login(request): return app -@pytest.fixture -def csrf_webapp(event_loop, base_world): - """Like simple_webapp but with CSRF protection middleware and POST route for /private.""" - async def index(request): - return web.Response(status=200, text='hello!') - - @security.authentication_exempt - async def public(request): - return web.Response(status=200, text='public') - - async def private(request): - return web.Response(status=200, text='private') - - @security.authentication_exempt - async def login(request): - await auth_svc.login_user(request) # Note: auth_svc defined in context function - - app = web.Application() - app.router.add_get('/', index) - app.router.add_post('/login', login) - app.router.add_get('/public', public) - app.router.add_get('/private', private) - app.router.add_post('/private', private) - - auth_svc = AuthService() - - event_loop.run_until_complete( - auth_svc.apply( - app=app, - users=base_world.get_config('users') - ) - ) - event_loop.run_until_complete(auth_svc.set_login_handlers(auth_svc.get_services())) - - # Ensure authentication middleware runs after session middleware - app.middlewares.append(security.authentication_required_middleware_factory(auth_svc)) - # Add CSRF protection middleware after authentication - app.middlewares.append(security.csrf_protect_middleware_factory(auth_svc)) - - return app - - def test_function_is_authentication_exempt(): def fake_handler(request): return None @@ -202,4 +156,4 @@ async def public(self, request): client = await aiohttp_client(app) resp = await client.get('/public') - assert resp.status == 200 + assert resp.status == 200 \ No newline at end of file From e2066f6876b3b02ead405d98656375143150f30d Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 18:59:46 -0400 Subject: [PATCH 05/25] fix: bump cryptography to 46.0.5 and expand CI security coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump cryptography 44.0.1 → 46.0.5 (CVE-2026-26007) - Bump Markdown 3.4.4 → 3.8.1 (CVE-2025-69534) - Add Python 3.13 to quality and security CI matrices - Add bandit static analysis to security workflow and tox - Run security checks on pull_request events (not just push) - Fix SonarQube condition: only run on push or non-fork pull_request - Remove untrusted fork code execution from sonar_fork_pr job - Prevent duplicate CI runs via pull_request_target - Fix stale bot messages and align bandit args with pre-commit --- .github/workflows/quality.yml | 43 +++++++++++++--------------------- .github/workflows/security.yml | 19 ++++++++------- .github/workflows/stale.yml | 4 ++-- requirements.txt | 4 ++-- tox.ini | 9 ++++++- 5 files changed, 38 insertions(+), 41 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 3d5404b3a..1c6935d00 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -7,7 +7,7 @@ on: pull_request: types: [opened, synchronize, reopened, ready_for_review] pull_request_target: - types: [opened, synchronize, reopened, ready_for_review] # added for fork PRs + types: [opened, synchronize, reopened, ready_for_review] workflow_dispatch: permissions: @@ -16,6 +16,7 @@ permissions: jobs: build: runs-on: ubuntu-latest + if: github.event_name != 'pull_request_target' permissions: contents: read pull-requests: read @@ -29,12 +30,14 @@ jobs: toxenv: py311,style,coverage-ci - python-version: 3.12 toxenv: py312,style,coverage-ci + - python-version: 3.13 + toxenv: py313,style,coverage-ci steps: - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 with: submodules: recursive - fetch-depth: 0 # shallow clones should be disabled for analysis + fetch-depth: 0 - name: Setup python uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c @@ -42,7 +45,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Setup Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 with: node-version: '20' @@ -58,42 +61,35 @@ jobs: TOXENV: ${{ matrix.toxenv }} run: tox - # --- Sonar scan for pushes and same-repo PRs only --- - name: SonarQube Scan - if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }} + if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == false) }} uses: SonarSource/sonarqube-scan-action@v6.0.0 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # needed for PR info - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # Uncomment if your sonar-project.properties is in a subfolder: - # with: - # args: | - # -Dsonar.projectBaseDir=caldera + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - # --- Sonar scan for forked PRs (runs safely with pull_request_target) --- sonar_fork_pr: runs-on: ubuntu-latest if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.fork }} permissions: contents: read - pull-requests: write # remove if you don't want PR comments + pull-requests: write steps: - name: Checkout base repo - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 with: ref: ${{ github.event.pull_request.base.sha }} fetch-depth: 0 - + - name: Checkout PR HEAD (fork) - uses: actions/checkout@v4 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 with: repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} path: pr fetch-depth: 0 submodules: recursive - - # Detect where the sonar-project.properties actually is (pr/ or pr/caldera) + - name: Detect Sonar base dir id: detect run: | @@ -103,21 +99,14 @@ jobs: elif [ -f pr/sonar-project.properties ]; then echo "base=pr" >> "$GITHUB_OUTPUT" else - echo "No sonar-project.properties found under pr/ or pr/caldera" - echo "base=pr" >> "$GITHUB_OUTPUT" # fallback to repo root + echo "base=pr" >> "$GITHUB_OUTPUT" fi - echo "Using base dir: $(grep '^base=' "$GITHUB_OUTPUT" | cut -d= -f2)" - echo "Has SONAR_TOKEN? $([ -n "${SONAR_TOKEN:-}" ] && echo yes || echo no)" - env: - SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - - # If your project key/org are NOT in the properties file, uncomment and set below + - name: SonarQube Scan (fork PR) uses: SonarSource/sonarqube-scan-action@v6.0.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # SONAR_HOST_URL: https://sonarcloud.io # set if you’re self-hosted or non-default with: projectBaseDir: ${{ steps.detect.outputs.base }} args: | diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 06b3e6449..6141102e9 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -1,6 +1,9 @@ name: Security Checks -on: [push] +on: + push: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] permissions: contents: read @@ -12,26 +15,24 @@ jobs: fail-fast: false matrix: include: - # - python-version: 3.9 - # toxenv: safety - - python-version: 3.10.9 - toxenv: safety - - python-version: 3.11 + - python-version: 3.13 toxenv: safety + - python-version: 3.13 + toxenv: bandit steps: - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 with: submodules: recursive - name: Setup python - uses: actions/setup-python@3542bca2639a428e1796aaa6a2ffef0c0f575566 + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | pip install --upgrade virtualenv pip install tox - - name: Run tests + - name: Run security checks env: TOXENV: ${{ matrix.toxenv }} run: tox diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 2c99dfbfa..8f075a2a5 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -22,8 +22,8 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} stale-issue-label: 'no-issue-activity' stale-pr-label: 'no-pr-activity' - stale-pr-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days' - stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days' + stale-pr-message: 'This pull request is stale because it has had no activity for 60 days. Remove the stale label or comment or this will be closed in 60 days' + stale-issue-message: 'This issue is stale because it has had no activity for 60 days. Remove the stale label or comment or this will be closed in 60 days' exempt-issue-labels: 'feature,keep' days-before-stale: 60 days-before-close: 60 diff --git a/requirements.txt b/requirements.txt index 520edd8af..8dc9de0e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ aiohttp-security==0.4.0 aiohttp-apispec==3.0.0b2 jinja2==3.1.6 pyyaml==6.0.1 -cryptography==44.0.1 +cryptography==46.0.5 websockets==15.0 Sphinx==7.1.2 sphinx_rtd_theme==1.3.0 @@ -19,7 +19,7 @@ reportlab==4.0.4 # debrief rich==13.7.0 lxml==6.0.2 # debrief svglib==1.5.1 # debrief -Markdown==3.4.4 # training +Markdown==3.8.1 # training dnspython==2.6.1 asyncssh==2.20.0 aioftp~=0.20.0 diff --git a/tox.ini b/tox.ini index 1018ca162..1962eec90 100644 --- a/tox.ini +++ b/tox.ini @@ -6,7 +6,7 @@ [tox] skipsdist = True envlist = - py{310,311,312} + py{310,311,312,313} style coverage safety @@ -64,3 +64,10 @@ whitelist_externals=find commands = safety check -r requirements.txt --ignore 39642 --ignore 39659 safety check -r requirements-dev.txt + +[testenv:bandit] +deps = + bandit +skip_install = true +commands = + bandit -r app -ll --exclude=tests/ --skip=B303 From f1dbc0aaf975ac49f852e5f98cdd32c43df06b23 Mon Sep 17 00:00:00 2001 From: ChenFryd <93209122+ChenFryd@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:00:58 +0200 Subject: [PATCH 06/25] fix: reuse existing fact source on operation close (#3261) --- app/objects/c_operation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/objects/c_operation.py b/app/objects/c_operation.py index 4c7ea94fd..14e7ca11a 100644 --- a/app/objects/c_operation.py +++ b/app/objects/c_operation.py @@ -446,8 +446,10 @@ async def _save_new_source(self, services): def fact_to_dict(f): if f: return dict(trait=f.trait, value=f.value, score=f.score) + existing = await services.get('data_svc').locate('sources', match=dict(name=self.name)) + source_id = existing[0].id if existing else str(uuid.uuid4()) data = dict( - id=str(uuid.uuid4()), + id=source_id, name=self.name, facts=[fact_to_dict(f) for link in self.chain for f in link.facts], relationships=[dict(source=fact_to_dict(r.source), edge=r.edge, From 0fe954e6d9f93dd2ab9d277e5988bbfe6eb74194 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:50:41 -0400 Subject: [PATCH 07/25] Bump minimatch from 3.1.2 to 3.1.5 (#3258) Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5. - [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md) - [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5) --- updated-dependencies: - dependency-name: minimatch dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6fe085ca7..3a5d04947 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2541,9 +2541,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "dependencies": { "brace-expansion": "^1.1.7" @@ -5731,9 +5731,9 @@ "dev": true }, "minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "requires": { "brace-expansion": "^1.1.7" From 19bddee6bfaa250fae45897acdfb3ffe6aadae88 Mon Sep 17 00:00:00 2001 From: Chris Lenk Date: Mon, 16 Mar 2026 00:11:42 +0000 Subject: [PATCH 08/25] Add architecture field to agent deployment commands (#3260) * Add architecture field to agent deployment cmds * fix: add architecture field to AgentConfigUpdateSchema; add happy-path test - architecture field was missing from AgentConfigUpdateSchema, causing API requests with architecture to fail marshmallow validation - adds test asserting architecture value is stored in agents config --------- Co-authored-by: Chris Lenk <402940+clenk@users.noreply.github.com> Co-authored-by: deacon --- app/api/v2/managers/config_api_manager.py | 6 +++++- app/api/v2/schemas/config_schemas.py | 1 + conf/agents.yml | 3 ++- tests/api/v2/managers/test_config_api_manager.py | 13 +++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/app/api/v2/managers/config_api_manager.py b/app/api/v2/managers/config_api_manager.py index ddcde7aa2..db188a47b 100644 --- a/app/api/v2/managers/config_api_manager.py +++ b/app/api/v2/managers/config_api_manager.py @@ -85,7 +85,8 @@ def update_main_config(self, prop, 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): + untrusted_timer: int = None, implant_name: str = None, architecture: str = None, + bootstrap_abilities: List[str] = None, deadman_abilities=None): set_config = self._config_interface.set_config if sleep_min is not None: @@ -103,6 +104,9 @@ async def update_global_agent_config(self, sleep_min: int = None, sleep_max: int if implant_name is not None: validation.check_not_empty_string(implant_name, name='implant_name') set_config(name='agents', prop='implant_name', value=implant_name) + if architecture is not None: + validation.check_not_empty_string(architecture, name='architecture') + set_config(name='agents', prop='architecture', value=architecture) if bootstrap_abilities is not None: await self._update_agent_ability_list_property(bootstrap_abilities, 'bootstrap_abilities') if deadman_abilities is not None: diff --git a/app/api/v2/schemas/config_schemas.py b/app/api/v2/schemas/config_schemas.py index 75f8a5172..906887385 100644 --- a/app/api/v2/schemas/config_schemas.py +++ b/app/api/v2/schemas/config_schemas.py @@ -13,6 +13,7 @@ class AgentConfigUpdateSchema(ma.Schema): watchdog = fields.Integer() untrusted_timer = fields.Integer() implant_name = fields.String() + architecture = fields.String() bootstrap_abilities = fields.List(fields.String) deadman_abilities = fields.List(fields.String) deployments = fields.List(fields.String, dump_only=True) diff --git a/conf/agents.yml b/conf/agents.yml index dd4bd4629..4070afda5 100644 --- a/conf/agents.yml +++ b/conf/agents.yml @@ -1,6 +1,7 @@ bootstrap_abilities: - 43b3754c-def4-4699-a673-1d85648fda6a implant_name: splunkd +architecture: amd64 sleep_max: 60 sleep_min: 30 untrusted_timer: 90 @@ -8,4 +9,4 @@ 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 + - 0ab383be-b819-41bf-91b9-1bd4404d83bf #Ragdoll diff --git a/tests/api/v2/managers/test_config_api_manager.py b/tests/api/v2/managers/test_config_api_manager.py index 788cfbda1..4ba0affe4 100644 --- a/tests/api/v2/managers/test_config_api_manager.py +++ b/tests/api/v2/managers/test_config_api_manager.py @@ -163,6 +163,19 @@ async def test_update_global_agent_config_throws_validation_error_bad_implant_na await manager.update_global_agent_config(implant_name='') +async def test_update_global_agent_config_throws_validation_error_bad_architecture(base_world, data_svc): + manager = ConfigApiManager(data_svc, None) + + with pytest.raises(errors.DataValidationError): + await manager.update_global_agent_config(architecture='') + + +async def test_update_global_agent_config_sets_architecture(base_world, data_svc): + manager = ConfigApiManager(data_svc, None) + await manager.update_global_agent_config(architecture='arm64') + assert BaseWorld.get_config(prop='architecture', name='agents') == 'arm64' + + async def test_update_main_config_throws_validation_error_empty_prop(base_world, data_svc): manager = ConfigApiManager(data_svc, None) From 6c10c2761fd9751660640f759324c6ced1daa464 Mon Sep 17 00:00:00 2001 From: Daniel Matthews <58484522+uruwhy@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:31:12 +0000 Subject: [PATCH 09/25] hash passwords and API keys in main config (#3257) * hash passwords and API keys in main config * style fixes * remove superfluous line * move hash checks to utility function * simplify code * add unit tests for config util * fix: guard _is_hashed against non-string config values * test: verify make_secure_config logs plaintext once then returns hashes * style: remove unused logging import from test_config_util.py (F401) * fix: guard verify_hash against None inputs; use yaml.safe_dump for config overwrite - verify_hash() now returns False for non-string inputs instead of raising TypeError (prevents 500 errors when API key header is absent and None is passed to verify) - base_world.py overwrite now uses yaml.safe_dump for safe, consistent output - test: rename 'hash' variable to 'hash_val' to avoid shadowing built-in - test: add None-input assertions to test_verify_hash - test: use side_effect=deepcopy to prevent SENSITIVE_CONF module-level mutation --------- Co-authored-by: deacon --- app/service/auth_svc.py | 10 +- app/service/login_handlers/default.py | 3 +- app/utility/base_world.py | 14 ++- app/utility/config_generator.py | 59 --------- app/utility/config_util.py | 104 ++++++++++++++++ requirements.txt | 1 + server.py | 5 +- .../v2/managers/test_config_api_manager.py | 2 +- tests/api/v2/test_knowledge.py | 3 +- tests/api/v2/test_responses.py | 3 +- tests/conftest.py | 4 +- tests/services/test_rest_svc.py | 2 +- tests/utility/test_config_util.py | 112 ++++++++++++++++++ tests/web_server/test_core_endpoints.py | 2 +- 14 files changed, 249 insertions(+), 75 deletions(-) delete mode 100644 app/utility/config_generator.py create mode 100644 app/utility/config_util.py create mode 100644 tests/utility/test_config_util.py diff --git a/app/service/auth_svc.py b/app/service/auth_svc.py index 773ee3574..6c34b9d1e 100644 --- a/app/service/auth_svc.py +++ b/app/service/auth_svc.py @@ -1,6 +1,5 @@ import base64 from collections import namedtuple -from hmac import compare_digest from importlib import import_module from aiohttp import web, web_request @@ -19,6 +18,7 @@ from app.service.interfaces.i_login_handler import LoginHandlerInterface from app.service.login_handlers.default import DefaultLoginHandler from app.utility.base_service import BaseService +from app.utility.config_util import verify_hash HEADER_API_KEY = 'KEY' @@ -152,8 +152,8 @@ def request_has_valid_api_key(self, request): if request_api_key is None: return False for i in [CONFIG_API_KEY_RED, CONFIG_API_KEY_BLUE]: - api_key = self.get_config(i) - if api_key is not None and compare_digest(request_api_key, api_key): + hashed_api_key = self.get_config(i) + if hashed_api_key is not None and verify_hash(hashed_api_key, request_api_key): return True return False @@ -192,9 +192,9 @@ async def get_permissions(self, request): identity = await identity_policy.identify(request) if identity in self.user_map: return [self.Access[p.upper()] for p in self.user_map[identity].permissions] - elif request.headers.get(HEADER_API_KEY) == self.get_config(CONFIG_API_KEY_RED): + elif verify_hash(self.get_config(CONFIG_API_KEY_RED), request.headers.get(HEADER_API_KEY)): return self.Access.RED, self.Access.APP - elif request.headers.get(HEADER_API_KEY) == self.get_config(CONFIG_API_KEY_BLUE): + elif verify_hash(self.get_config(CONFIG_API_KEY_BLUE), request.headers.get(HEADER_API_KEY)): return self.Access.BLUE, self.Access.APP return () diff --git a/app/service/login_handlers/default.py b/app/service/login_handlers/default.py index 804373f0c..b26faf225 100644 --- a/app/service/login_handlers/default.py +++ b/app/service/login_handlers/default.py @@ -6,6 +6,7 @@ from ldap3.core.exceptions import LDAPAttributeError, LDAPException from app.service.interfaces.i_login_handler import LoginHandlerInterface +from app.utility.config_util import verify_hash HANDLER_NAME = 'Default Login Handler' @@ -51,7 +52,7 @@ async def _check_credentials(user_map, username, password): user = user_map.get(username) if not user: return False - return user.password == password + return verify_hash(user.password, password) async def _ldap_login(self, username, password): server = ldap3.Server(self._ldap_config.get('server')) diff --git a/app/utility/base_world.py b/app/utility/base_world.py index f86de97c2..5fe028aa7 100644 --- a/app/utility/base_world.py +++ b/app/utility/base_world.py @@ -14,6 +14,8 @@ import marshmallow as ma import marshmallow_enum as ma_enum +from app.utility.config_util import hash_config_creds + class BaseWorld: """ @@ -26,7 +28,17 @@ class BaseWorld: TIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ' @staticmethod - def apply_config(name, config): + def apply_config(name, config, apply_hash=False, overwrite_path=''): + """ + Hashes credentials and API keys if needed. Overwrites config at path with secure + version if any changes are made. + """ + if apply_hash: + changes_made = hash_config_creds(config) + if changes_made and overwrite_path: + logging.debug(f'Overwriting config file {overwrite_path} with secure values') + with open(overwrite_path, 'w') as cfg_file: + yaml.safe_dump(config, cfg_file, default_flow_style=False) BaseWorld._app_configuration[name] = config @staticmethod diff --git a/app/utility/config_generator.py b/app/utility/config_generator.py deleted file mode 100644 index 20b023045..000000000 --- a/app/utility/config_generator.py +++ /dev/null @@ -1,59 +0,0 @@ -import logging -import pathlib -import secrets - -import jinja2 -import yaml - - -CONFIG_MSG_TEMPLATE = jinja2.Template(""" -Log into Caldera with the following admin credentials: - Red: - {%- if users.red.red %} - USERNAME: red - PASSWORD: {{ users.red.red }} - {%- endif %} - API_TOKEN: {{ api_key_red }} - Blue: - {%- if users.blue.blue %} - USERNAME: blue - PASSWORD: {{ users.blue.blue }} - {%- endif %} - API_TOKEN: {{ api_key_blue }} -To modify these values, edit the {{ config_path }} file. -""") - - -def log_config_message(config_path): - with pathlib.Path(config_path).open('r') as fle: - config = yaml.safe_load(fle) - logging.info(CONFIG_MSG_TEMPLATE.render(config_path=str(config_path), **config)) - - -def make_secure_config(): - with open('conf/default.yml', 'r') as fle: - config = yaml.safe_load(fle) - - secret_options = ('api_key_blue', 'api_key_red', 'crypt_salt', 'encryption_key') - for option in secret_options: - config[option] = secrets.token_urlsafe() - - config['users'] = dict(red=dict(red=secrets.token_urlsafe()), - blue=dict(blue=secrets.token_urlsafe())) - - return config - - -def ensure_local_config(): - """ - Checks if a local.yml config file exists. If not, generates a new local.yml file using secure random values. - """ - local_conf_path = pathlib.Path('conf/local.yml') - if local_conf_path.exists(): - return - - logging.info('Creating new secure config in %s' % local_conf_path) - with local_conf_path.open('w') as fle: - yaml.safe_dump(make_secure_config(), fle, default_flow_style=False) - - log_config_message(local_conf_path) diff --git a/app/utility/config_util.py b/app/utility/config_util.py new file mode 100644 index 000000000..fa903693a --- /dev/null +++ b/app/utility/config_util.py @@ -0,0 +1,104 @@ +import logging +import pathlib +import secrets + +import jinja2 +import yaml + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError + + +CONFIG_MSG_TEMPLATE = jinja2.Template(""" +Log into Caldera with the following admin credentials: + Red: + {%- if users.red.red %} + USERNAME: red + PASSWORD: {{ users.red.red }} + {%- endif %} + API_TOKEN: {{ api_key_red }} + Blue: + {%- if users.blue.blue %} + USERNAME: blue + PASSWORD: {{ users.blue.blue }} + {%- endif %} + API_TOKEN: {{ api_key_blue }} +To modify these values, edit the {{ config_path }} file and restart Caldera. +""") +LOCAL_CONF_PATH = 'conf/local.yml' +SECRET_OPTIONS = ('api_key_blue', 'api_key_red', 'crypt_salt', 'encryption_key') +HASHED_OPTIONS = ('api_key_blue', 'api_key_red') + + +def _is_hashed(val): + return isinstance(val, str) and val.startswith('$argon2id$') + + +def verify_hash(hash_val, target): + """ + Returns True if the argon2 hash for the target matches hash_val, False otherwise. + Returns False for None or non-string inputs. + """ + if not isinstance(hash_val, str) or not isinstance(target, str): + return False + ph = PasswordHasher() + try: + return ph.verify(hash_val, target) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def hash_config_creds(config): + """ + Hashes the red/blue API keys and any user passwords in the config dictionary. + Modifies the configuration dictionary parameter. + Returns True if any values were modified (hashed), False otherwise. + """ + ph = PasswordHasher() + any_hashed = False + for option in HASHED_OPTIONS: + val = config.get(option, '') + + # Skip any values that are already hashed + if val and not _is_hashed(val): + config[option] = ph.hash(val) + any_hashed = True + + # Hash credentials + for group_name, group_dict in config.get('users', dict()).items(): + for username, val in group_dict.items(): + if not _is_hashed(val): + config['users'][group_name][username] = ph.hash(val) + any_hashed = True + + return any_hashed + + +def make_secure_config(): + with open('conf/default.yml', 'r') as fle: + config = yaml.safe_load(fle) + + for option in SECRET_OPTIONS: + config[option] = secrets.token_urlsafe() + + config['users'] = dict(red=dict(red=secrets.token_urlsafe()), + blue=dict(blue=secrets.token_urlsafe())) + + # Display API keys and user credentials, then hash them + logging.info(CONFIG_MSG_TEMPLATE.render(config_path=LOCAL_CONF_PATH, **config)) + hash_config_creds(config) + + return config + + +def ensure_local_config(): + """ + Checks if a local.yml config file exists. If not, generates a new local.yml file using secure random values. + """ + local_conf_path = pathlib.Path(LOCAL_CONF_PATH) + if local_conf_path.exists(): + return + + logging.info('Creating new secure config in %s' % local_conf_path) + with local_conf_path.open('w') as fle: + yaml.safe_dump(make_secure_config(), fle, default_flow_style=False) diff --git a/requirements.txt b/requirements.txt index 8dc9de0e8..42ee26a5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ aiohttp==3.13.3 aiohttp_session==2.12.0 aiohttp-security==0.4.0 aiohttp-apispec==3.0.0b2 +argon2-cffi==25.1.0 jinja2==3.1.6 pyyaml==6.0.1 cryptography==46.0.5 diff --git a/server.py b/server.py index 2c2d0702d..420382206 100644 --- a/server.py +++ b/server.py @@ -35,7 +35,7 @@ from app.service.rest_svc import RestService 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.config_util import ensure_local_config MAGMA_PATH = "./plugins/magma" @@ -239,7 +239,8 @@ def list_str(values): ensure_local_config() main_config_path = "conf/%s.yml" % args.environment - BaseWorld.apply_config("main", BaseWorld.strip_yml(main_config_path)[0]) + BaseWorld.apply_config("main", BaseWorld.strip_yml(main_config_path)[0], apply_hash=True, + overwrite_path=main_config_path) logging.info("Using main config from %s" % main_config_path) BaseWorld.apply_config("agents", BaseWorld.strip_yml("conf/agents.yml")[0]) BaseWorld.apply_config("payloads", BaseWorld.strip_yml("conf/payloads.yml")[0]) diff --git a/tests/api/v2/managers/test_config_api_manager.py b/tests/api/v2/managers/test_config_api_manager.py index 4ba0affe4..8ed748705 100644 --- a/tests/api/v2/managers/test_config_api_manager.py +++ b/tests/api/v2/managers/test_config_api_manager.py @@ -18,7 +18,7 @@ async def locate(self, key): @pytest.fixture def base_world(app_config, agent_config): BaseWorld.clear_config() - BaseWorld.apply_config('main', app_config) + BaseWorld.apply_config('main', app_config, apply_hash=True) BaseWorld.apply_config('agents', agent_config) yield BaseWorld diff --git a/tests/api/v2/test_knowledge.py b/tests/api/v2/test_knowledge.py index 2ec53133a..fdbad598b 100644 --- a/tests/api/v2/test_knowledge.py +++ b/tests/api/v2/test_knowledge.py @@ -34,7 +34,8 @@ def base_world(): 'crypt_salt': 'thisisdefinitelynotkosher', # Salt for file service instantiation 'encryption_key': 'andneitheristhis', # fake encryption key for file service instantiation - } + }, + apply_hash=True ) yield BaseWorld diff --git a/tests/api/v2/test_responses.py b/tests/api/v2/test_responses.py index f2acaa4c5..80de02a5d 100644 --- a/tests/api/v2/test_responses.py +++ b/tests/api/v2/test_responses.py @@ -17,7 +17,8 @@ def base_world(): 'red': {'reduser': 'redpass'}, 'blue': {'blueuser': 'bluepass'} } - } + }, + apply_hash=True ) yield BaseWorld diff --git a/tests/conftest.py b/tests/conftest.py index 3fe3e7554..5f3c2b1fe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -73,7 +73,7 @@ @pytest.fixture(scope='session') def init_base_world(): with open(os.path.join(CONFIG_DIR, 'default.yml')) as c: - BaseWorld.apply_config('main', yaml.load(c, Loader=yaml.FullLoader)) + BaseWorld.apply_config('main', yaml.load(c, Loader=yaml.FullLoader), apply_hash=True) BaseWorld.apply_config('agents', BaseWorld.strip_yml(os.path.join(CONFIG_DIR, 'agents.yml'))[0]) BaseWorld.apply_config('payloads', BaseWorld.strip_yml(os.path.join(CONFIG_DIR, 'payloads.yml'))[0]) @@ -363,7 +363,7 @@ def make_app(svcs): async def initialize(): with open(Path(__file__).parents[1] / 'conf' / 'default.yml', 'r') as fle: - BaseWorld.apply_config('main', yaml.safe_load(fle)) + BaseWorld.apply_config('main', yaml.safe_load(fle), apply_hash=True) with open(Path(__file__).parents[1] / 'conf' / 'payloads.yml', 'r') as fle: BaseWorld.apply_config('payloads', yaml.safe_load(fle)) with open(Path(__file__).parents[1] / 'conf' / 'agents.yml', 'r') as fle: diff --git a/tests/services/test_rest_svc.py b/tests/services/test_rest_svc.py index db2b75739..a1d163e3d 100644 --- a/tests/services/test_rest_svc.py +++ b/tests/services/test_rest_svc.py @@ -20,7 +20,7 @@ async def setup_rest_svc_test(data_svc): 'crypt_salt': 'BLAH', 'api_key': 'ADMIN123', 'encryption_key': 'ADMIN123', - 'exfil_dir': '/tmp'}) + 'exfil_dir': '/tmp'}, apply_hash=True) await data_svc.store( Ability(ability_id='123', name='testA', executors=[ Executor(name='psh', platform='windows', command='curl #{app.contact.http}') diff --git a/tests/utility/test_config_util.py b/tests/utility/test_config_util.py new file mode 100644 index 000000000..a0868cbc8 --- /dev/null +++ b/tests/utility/test_config_util.py @@ -0,0 +1,112 @@ +import builtins +import copy +import pathlib +import secrets +import yaml + +from unittest import mock +from argon2 import PasswordHasher + +from app.utility.config_util import hash_config_creds, verify_hash, ensure_local_config, make_secure_config + + +NON_SENSITIVE_CONF = { + 'app.contact.http': '0.0.0.0', + 'plugins': ['sandcat', 'stockpile'], +} +SENSITIVE_CONF = { + 'app.contact.http': '0.0.0.0', + 'plugins': ['sandcat', 'stockpile'], + 'api_key_blue': 'testapikeyblue', + 'api_key_red': 'testapikeyred', + 'users': { + 'group1': { + 'user1': 'testpassword1' + }, + 'group2': { + 'user2': 'testpassword2' + }, + } +} + + +class TestConfigUtil: + + def test_verify_hash(self): + hash_val = '$argon2id$v=19$m=65536,t=3,p=4$87lgOXDGx/9JUHuCsxlaZw$bcJp3dQcqMiYdZOCm8LLJ8ncaEwjoS1xVcPHUGs/ajU' + plaintext = 'testpassword' + assert verify_hash(hash_val, plaintext) + assert not verify_hash(hash_val, 'testpassword2') + assert not verify_hash('$argon2id$v=19$m=65536,t=3,p=4$K/WRrQC6CaEkiDF+KhKfMQ$y4dB2W/sqiCcyJX3SYPYhHenEmLv4xDuKV38Ca9FrGc', plaintext) + assert not verify_hash('notahash', plaintext) + assert not verify_hash('$argon2id$v=19$m=65536,t=2,p=4$87lgOXDGx/9JUHuCsxlaZw$bcJp3dQcqMiYdZOCm8LLJ8ncaEwjoS1xVcPHUGs/ajU', plaintext) + assert not verify_hash('$argon2$v=19$m=65536,t=3,p=4$87lgOXDGx/9JUHuCsxlaZw$bcJp3dQcqMiYdZOCm8LLJ8ncaEwjoS1xVcPHUGs/ajU', plaintext) + assert not verify_hash('$argon2idasdkl$v=19$m=65536,t=2,p=4$87lgOXDGx/laksdj$bcJp3dQcqMiYdZOCm8LLJ8ncaEwjoS1xVcPHUGs/ajU', plaintext) + assert not verify_hash(None, plaintext) + assert not verify_hash(hash_val, None) + + def test_hash_config_creds(self): + config = copy.deepcopy(NON_SENSITIVE_CONF) + assert not hash_config_creds(config) + assert config == NON_SENSITIVE_CONF + + config = copy.deepcopy(SENSITIVE_CONF) + assert hash_config_creds(config) + assert SENSITIVE_CONF != config + assert verify_hash(config['api_key_blue'], 'testapikeyblue') + assert verify_hash(config['api_key_red'], 'testapikeyred') + assert verify_hash(config['users']['group1']['user1'], 'testpassword1') + assert verify_hash(config['users']['group2']['user2'], 'testpassword2') + + @mock.patch.object(PasswordHasher, 'hash', return_value='mockhash') + @mock.patch.object(yaml, 'safe_load', side_effect=lambda *a, **kw: copy.deepcopy(SENSITIVE_CONF)) + @mock.patch.object(yaml, 'safe_dump') + @mock.patch.object(secrets, 'token_urlsafe', return_value='mocksecret') + def test_ensure_local_config(self, mock_token_urlsafe, mock_safe_dump, mock_safe_load, mock_hashs): + with mock.patch.object(pathlib.Path, 'open', spec=open): + with mock.patch.object(pathlib.Path, 'exists', return_value=True): + ensure_local_config() + mock_safe_dump.assert_not_called() + with mock.patch.object(pathlib.Path, 'exists', return_value=False): + want_config = { + 'app.contact.http': '0.0.0.0', + 'plugins': ['sandcat', 'stockpile'], + 'api_key_blue': 'mockhash', + 'api_key_red': 'mockhash', + 'crypt_salt': 'mocksecret', + 'encryption_key': 'mocksecret', + 'users': { + 'red': { + 'red': 'mockhash', + }, + 'blue': { + 'blue': 'mockhash', + }, + } + } + ensure_local_config() + mock_safe_dump.assert_called_once_with(want_config, mock.ANY, default_flow_style=False) + + @mock.patch('logging.info') + @mock.patch.object(yaml, 'safe_load', return_value={'app.contact.http': '0.0.0.0', 'plugins': ['sandcat']}) + @mock.patch.object(secrets, 'token_urlsafe', return_value='plaintextsecret') + def test_make_secure_config_logs_plaintext_then_hashes(self, mock_token_urlsafe, mock_safe_load, mock_logging_info): + with mock.patch.object(builtins, 'open', mock.mock_open()): + config = make_secure_config() + + # logging.info must have been called exactly once (to display startup credentials) + mock_logging_info.assert_called_once() + logged_message = mock_logging_info.call_args[0][0] + + # The logged message must contain the plaintext secret so the admin can read their credentials + assert 'plaintextsecret' in logged_message, ( + "Expected plaintext secret in logged startup message, got: %r" % logged_message + ) + + # The returned config must store argon2 hashes, not the plaintext secret + assert config['api_key_blue'].startswith('$argon2id$'), ( + "api_key_blue should be an argon2 hash after make_secure_config, got: %r" % config['api_key_blue'] + ) + assert config['api_key_red'].startswith('$argon2id$'), ( + "api_key_red should be an argon2 hash after make_secure_config, got: %r" % config['api_key_red'] + ) diff --git a/tests/web_server/test_core_endpoints.py b/tests/web_server/test_core_endpoints.py index 8090d739e..400d27740 100644 --- a/tests/web_server/test_core_endpoints.py +++ b/tests/web_server/test_core_endpoints.py @@ -22,7 +22,7 @@ async def aiohttp_client(aiohttp_client): async def initialize(): with open(Path(__file__).parents[2] / 'conf' / 'default.yml', 'r') as fle: - BaseWorld.apply_config('main', yaml.safe_load(fle)) + BaseWorld.apply_config('main', yaml.safe_load(fle), apply_hash=True) with open(Path(__file__).parents[2] / 'conf' / 'payloads.yml', 'r') as fle: BaseWorld.apply_config('payloads', yaml.safe_load(fle)) with open(Path(__file__).parents[2] / 'conf' / 'agents.yml', 'r') as fle: From d8bf70115f6b01ed49f7b49e0736a08e19a09b94 Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:31:59 -0400 Subject: [PATCH 10/25] fix: replace create_subprocess_shell (#3265) * fix: replace create_subprocess_shell with safe exec in start_vue_dev_server Avoid shell injection risk by using create_subprocess_exec instead. * fix: address Copilot review feedback on subprocess PR - Capture proc from create_subprocess_exec, log PID, schedule proc.wait() to avoid zombie processes on Vue dev server exit - Rewrite test to use pytest style, ast-based function extraction, and Path-relative server.py resolution Co-Authored-By: Claude Sonnet 4.6 * test: guard against None return from ast.get_source_segment ast.get_source_segment() returns None when source offsets are unavailable; assert it is not None before using it in string checks. * fix: remove untracked create_task in start_vue_dev_server to avoid zombie subprocess * test: accept FunctionDef and AsyncFunctionDef in start_vue_dev_server check * test: use explicit utf-8 encoding in read_text() --------- Co-authored-by: Claude Sonnet 4.6 --- server.py | 6 +++--- tests/security/test_subprocess_exec.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 tests/security/test_subprocess_exec.py diff --git a/server.py b/server.py index 420382206..6d68469e5 100644 --- a/server.py +++ b/server.py @@ -156,10 +156,10 @@ async def enable_cors(request, response): async def start_vue_dev_server(): - await asyncio.create_subprocess_shell( - "npm run dev", stdout=sys.stdout, stderr=sys.stderr, cwd=MAGMA_PATH + proc = await asyncio.create_subprocess_exec( + "npm", "run", "dev", stdout=sys.stdout, stderr=sys.stderr, cwd=MAGMA_PATH ) - logging.info("VueJS development server is live.") + logging.info("VueJS development server started (PID %s).", proc.pid) def _get_parser(): diff --git a/tests/security/test_subprocess_exec.py b/tests/security/test_subprocess_exec.py new file mode 100644 index 000000000..b31d11e0a --- /dev/null +++ b/tests/security/test_subprocess_exec.py @@ -0,0 +1,21 @@ +import ast +from pathlib import Path + + +SERVER_PY = Path(__file__).resolve().parents[2] / 'server.py' + + +def test_no_create_subprocess_shell_in_start_vue(): + """Verify create_subprocess_shell is not used in start_vue_dev_server.""" + content = SERVER_PY.read_text(encoding='utf-8') + tree = ast.parse(content, filename=str(SERVER_PY)) + func_node = None + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == 'start_vue_dev_server': + func_node = node + break + assert func_node is not None, "start_vue_dev_server function not found in server.py" + func_content = ast.get_source_segment(content, func_node) + assert func_content is not None, "Could not extract source for start_vue_dev_server" + assert 'create_subprocess_shell' not in func_content + assert 'create_subprocess_exec' in func_content From 708208f3c484999a3904738964b97075034ced71 Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:32:01 -0400 Subject: [PATCH 11/25] fix: replace deprecated asyncio.get_event_loop() with new_event_loop() (#3266) * fix: replace deprecated asyncio.get_event_loop() with new_event_loop() Use asyncio.new_event_loop() in run_tasks() and --fresh block. * fix: close event loop in finally block; use try/finally in fresh block; fix tests to use ast+pytest * fix: remove unused variable in asyncio test (flake8 F841) * test: rewrite asyncio event loop tests to use pure AST inspection - Replace brittle substring matching with AST Call node inspection via a shared _is_asyncio_call() helper - Remove incorrect startswith('#') guard (AST never includes comments) - Eliminate ast.get_source_segment() to avoid potential None return * fix: ensure event loop is always closed and cleared on all exit paths * fix: cancel pending tasks before closing event loop in run_tasks * test: relax event loop assertion to allow non-new_event_loop refactors * test: use explicit utf-8 encoding in read_text() --- server.py | 118 +++++++++++++--------- tests/security/test_asyncio_event_loop.py | 69 +++++++++++++ 2 files changed, 141 insertions(+), 46 deletions(-) create mode 100644 tests/security/test_asyncio_event_loop.py diff --git a/server.py b/server.py index 6d68469e5..4cd8b0d0c 100644 --- a/server.py +++ b/server.py @@ -74,55 +74,74 @@ async def start_server(): def run_tasks(services, run_vue_server=False): - loop = asyncio.get_event_loop() - 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( - data_svc.load_data( - loop.run_until_complete(data_svc.locate("plugins", dict(enabled=True))) - ) - ) - loop.run_until_complete( - app_svc.load_plugin_expansions( - loop.run_until_complete(data_svc.locate("plugins", dict(enabled=True))) + loop = asyncio.new_event_loop() + # The event loop is set here, before any async work begins. Services + # (AppService, DataService, etc.) are instantiated in __main__ prior to + # this call but they do not cache the loop at construction time — they + # resolve it lazily via asyncio.get_event_loop(). Setting it explicitly + # here ensures all subsequent loop.create_task() / loop.run_until_complete() + # calls operate on the same, controlled loop instance. + asyncio.set_event_loop(loop) + try: + 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( + data_svc.load_data( + loop.run_until_complete(data_svc.locate("plugins", dict(enabled=True))) + ) ) - ) - loop.run_until_complete(RestApi(services).enable()) - loop.run_until_complete(auth_svc.set_login_handlers(services)) - loop.create_task(app_svc.start_sniffer_untrusted_agents()) - loop.create_task(app_svc.resume_operations()) - loop.create_task(app_svc.run_scheduler()) - loop.create_task(learning_svc.build_model()) - loop.create_task(app_svc.watch_ability_files()) - loop.run_until_complete(start_server()) - loop.run_until_complete(event_svc.fire_event(exchange="system", queue="ready")) - loop.run_until_complete( - data_svc.store( - Obfuscator(name='plain-text', - description='Does no obfuscation to any command, instead running it in plain text', - module='app.obfuscators.plain_text') + loop.run_until_complete( + app_svc.load_plugin_expansions( + loop.run_until_complete(data_svc.locate("plugins", dict(enabled=True))) + ) ) - ) - loop.run_until_complete( - data_svc.store( - Obfuscator(name='base64', - description='Obfuscates commands in base64', - module='app.obfuscators.base64_basic') + loop.run_until_complete(RestApi(services).enable()) + loop.run_until_complete(auth_svc.set_login_handlers(services)) + loop.create_task(app_svc.start_sniffer_untrusted_agents()) + loop.create_task(app_svc.resume_operations()) + loop.create_task(app_svc.run_scheduler()) + loop.create_task(learning_svc.build_model()) + loop.create_task(app_svc.watch_ability_files()) + loop.run_until_complete(start_server()) + loop.run_until_complete(event_svc.fire_event(exchange="system", queue="ready")) + loop.run_until_complete( + data_svc.store( + Obfuscator(name='plain-text', + description='Does no obfuscation to any command, instead running it in plain text', + module='app.obfuscators.plain_text') + ) ) - ) - if run_vue_server: - loop.run_until_complete(start_vue_dev_server()) - try: - logging.info("All systems ready.") - print_rich_banner() - loop.run_forever() - except KeyboardInterrupt: loop.run_until_complete( - services.get("app_svc").teardown(main_config_file=args.environment) + data_svc.store( + Obfuscator(name='base64', + description='Obfuscates commands in base64', + module='app.obfuscators.base64_basic') + ) ) + if run_vue_server: + loop.run_until_complete(start_vue_dev_server()) + try: + logging.info("All systems ready.") + print_rich_banner() + loop.run_forever() + except KeyboardInterrupt: + loop.run_until_complete( + services.get("app_svc").teardown(main_config_file=args.environment) + ) + finally: + # Cancel all pending tasks before shutdown to avoid resource leaks + # and "Task was destroyed but it is pending!" warnings. + pending = asyncio.all_tasks(loop) + for task in pending: + task.cancel() + if pending: + loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + asyncio.set_event_loop(None) def init_swagger_documentation(app): @@ -313,7 +332,14 @@ def list_str(values): "[green]Fresh startup: resetting server data. See %s directory for data backups.[/green]", DATA_BACKUP_DIR, ) - asyncio.get_event_loop().run_until_complete(data_svc.destroy()) - asyncio.get_event_loop().run_until_complete(knowledge_svc.destroy()) + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(data_svc.destroy()) + loop.run_until_complete(knowledge_svc.destroy()) + finally: + loop.run_until_complete(loop.shutdown_asyncgens()) + loop.close() + asyncio.set_event_loop(None) run_tasks(services=app_svc.get_services(), run_vue_server=args.uiDevHost) diff --git a/tests/security/test_asyncio_event_loop.py b/tests/security/test_asyncio_event_loop.py new file mode 100644 index 000000000..6e35ab1e0 --- /dev/null +++ b/tests/security/test_asyncio_event_loop.py @@ -0,0 +1,69 @@ +import ast +from pathlib import Path + + +SERVER_PY = Path(__file__).resolve().parents[2] / 'server.py' + + +def _parse_server(): + content = SERVER_PY.read_text(encoding='utf-8') + return content, ast.parse(content, filename=str(SERVER_PY)) + + +def _get_function(tree, name): + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == name: + return node + return None + + +def _is_asyncio_call(node, method): + """Return True if node is a Call to asyncio.(...).""" + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == method + and isinstance(node.func.value, ast.Name) + and node.func.value.id == 'asyncio' + ) + + +def test_no_bare_get_event_loop_in_server(): + """Verify no bare asyncio.get_event_loop() calls remain in server.py.""" + _, tree = _parse_server() + for node in ast.walk(tree): + if _is_asyncio_call(node, 'get_event_loop'): + raise AssertionError( + f'Found asyncio.get_event_loop() call at line {node.lineno} in server.py' + ) + + +def test_run_tasks_sets_event_loop(): + """Verify run_tasks explicitly sets an event loop via AST inspection. + + We only assert that set_event_loop is called (loop is configured before use) + rather than requiring new_event_loop specifically, so valid refactors such as + creating the loop in __main__ and passing it in remain possible. + """ + _, tree = _parse_server() + func_node = _get_function(tree, 'run_tasks') + assert func_node is not None, 'run_tasks not found in server.py' + + has_set_loop = any(_is_asyncio_call(n, 'set_event_loop') for n in ast.walk(func_node)) + assert has_set_loop, 'run_tasks must call asyncio.set_event_loop(loop)' + + +def test_run_tasks_closes_loop(): + """Verify run_tasks closes the event loop in a finally block.""" + _, tree = _parse_server() + func_node = _get_function(tree, 'run_tasks') + assert func_node is not None, 'run_tasks not found in server.py' + has_finally_close = False + for node in ast.walk(func_node): + if isinstance(node, ast.Try): + for handler in node.finalbody: + for child in ast.walk(handler): + if (isinstance(child, ast.Attribute) and child.attr == 'close' + and isinstance(child.value, ast.Name) and child.value.id == 'loop'): + has_finally_close = True + assert has_finally_close, 'run_tasks must close the event loop in a finally block' From 99eeaa5e1675be08ea0fcc6c800f562139f835f0 Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:32:11 -0400 Subject: [PATCH 12/25] fix: reduce global client_max_size and add configurable setting (#3268) * fix: reduce global client_max_size and add configurable setting Reduce default from ~26MB to 1MB with configurable client_max_size_mb key. * fix: add separate upload size limit for authenticated API routes Global client_max_size stays at 1MB for unauthenticated surfaces. Introduces api_upload_max_size_mb (default 100MB) applied to the /api/v2 sub-app, which is entirely behind authentication, allowing large payload uploads and exfil files without exposing the DoS vector to unauthenticated routes. * fix: restore default plugins list and remove mcp Restores atomic, compass, fieldmanual, and response which were accidentally dropped. Removes automation and mcp which should not be in the default plugin set. * fix: coerce client_max_size config to int; remove unused rate_limit config; test actual runtime behavior * fix: flake8 style fixes * test: rewrite client_max_size tests to call real make_app with mock services Replace the patched duplicate of make_app with calls to the real function using MagicMock services. The last two constant-comparison tests now also assert against the actual configured app value. * test: fix misleading variable name and add root/subapp limit integration test * style: remove unused patch import in test_client_max_size.py * test: actually mount v2 as subapp in root_app to validate real runtime behavior --- app/api/v2/__init__.py | 9 +++- conf/default.yml | 24 +++++----- server.py | 15 +++++- tests/security/test_client_max_size.py | 65 ++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 tests/security/test_client_max_size.py diff --git a/app/api/v2/__init__.py b/app/api/v2/__init__.py index 109ad95b7..df28b29dc 100644 --- a/app/api/v2/__init__.py +++ b/app/api/v2/__init__.py @@ -1,11 +1,18 @@ from aiohttp import web -def make_app(services): +def make_app(services, upload_max_size_mb=100): from .responses import json_request_validation_middleware from .security import authentication_required_middleware_factory, pass_option_middleware, csrf_protect_middleware_factory + try: + max_size = int(upload_max_size_mb) + max_size = max_size if max_size > 0 else 100 + except (TypeError, ValueError): + max_size = 100 + app = web.Application( + client_max_size=max_size * 1024 * 1024, middlewares=[ pass_option_middleware, authentication_required_middleware_factory(services['auth_svc']), diff --git a/conf/default.yml b/conf/default.yml index ba0653c94..2a81aaac4 100644 --- a/conf/default.yml +++ b/conf/default.yml @@ -1,35 +1,35 @@ ability_refresh: 60 +client_max_size_mb: 1 +api_upload_max_size_mb: 100 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 -reachable_host_traits: -- remote.host.fqdn -- remote.host.ip host: 0.0.0.0 +objects.planners.default: atomic plugins: - access - atomic @@ -42,8 +42,10 @@ plugins: - stockpile - training port: 8888 +reachable_host_traits: +- remote.host.fqdn +- remote.host.ip reports_dir: /tmp -auth.login.handler.module: default requirements: go: command: go version diff --git a/server.py b/server.py index 4cd8b0d0c..3a70267ee 100644 --- a/server.py +++ b/server.py @@ -281,12 +281,23 @@ def list_str(values): learning_svc = LearningService() event_svc = EventService() + def _get_size_mb(config_key, default): + try: + val = int(BaseWorld.get_config(config_key)) + return val if val > 0 else default + except (TypeError, ValueError): + return default + app_svc = AppService( application=web.Application( - client_max_size=5120**2, middlewares=[pass_option_middleware] + client_max_size=_get_size_mb('client_max_size_mb', 1) * 1024 * 1024, + middlewares=[pass_option_middleware] ) ) - app_svc.register_subapp("/api/v2", app.api.v2.make_app(app_svc.get_services())) + app_svc.register_subapp("/api/v2", app.api.v2.make_app( + app_svc.get_services(), + upload_max_size_mb=_get_size_mb('api_upload_max_size_mb', 100) + )) init_swagger_documentation(app_svc.application) if args.uiDevHost: if not os.path.exists(f"{MAGMA_PATH}/dist") and (os.path.exists(f"{MAGMA_PATH}") and len(os.listdir(MAGMA_PATH)) > 0): diff --git a/tests/security/test_client_max_size.py b/tests/security/test_client_max_size.py new file mode 100644 index 000000000..8b8619bfc --- /dev/null +++ b/tests/security/test_client_max_size.py @@ -0,0 +1,65 @@ +import pytest +from unittest.mock import MagicMock +from aiohttp import web + +import app.api.v2 as v2_module +from app.api.v2.security import pass_option_middleware +from app.utility.base_world import BaseWorld + + +def _make_app(upload_max_size_mb): + """Call the real make_app with mock services and return the resulting app.""" + services = MagicMock() + return v2_module.make_app(services, upload_max_size_mb=upload_max_size_mb) + + +@pytest.mark.parametrize('mb,expected_bytes', [ + (1, 1 * 1024 * 1024), + (100, 100 * 1024 * 1024), + (50, 50 * 1024 * 1024), +]) +def test_valid_integer_config(mb, expected_bytes): + # _client_max_size is the only accessible attribute for client_max_size in aiohttp + assert _make_app(mb)._client_max_size == expected_bytes + + +@pytest.mark.parametrize('bad_val', [None, '', 'abc', '1MB', -1, 0]) +def test_invalid_config_falls_back_to_default(bad_val): + """None, strings, and non-positive values must fall back to 100MB default.""" + assert _make_app(bad_val)._client_max_size == 100 * 1024 * 1024 + + +def test_string_integer_coerces_correctly(): + """Config may return strings; '50' must coerce to 50MB, not repeat a string.""" + assert _make_app('50')._client_max_size == 50 * 1024 * 1024 + + +def test_api_upload_limit_larger_than_global_default(): + """API v2 upload limit (100MB default) must be larger than the global 1MB default.""" + api_app = _make_app(100) + assert api_app._client_max_size == 100 * 1024 * 1024 + assert api_app._client_max_size > 1 * 1024 * 1024 + + +def test_global_default_tighter_than_old_hardcoded(): + """New 1MB global default must be tighter than the old hardcoded 5120**2 (~26MB).""" + # Verify the v2 app can be created with the old value, and 1MB is genuinely smaller + old_hardcoded = 5120 ** 2 + new_default_bytes = 1 * 1024 * 1024 + assert new_default_bytes < old_hardcoded + # Confirm the app created with 1MB actually applies that limit + assert _make_app(1)._client_max_size == new_default_bytes + + +def test_root_and_subapp_limits(): + """Root app has 1MB limit; v2 subapp has 100MB limit; subapp limit exceeds root.""" + BaseWorld.apply_config('main', {'client_max_size_mb': 1, 'api_upload_max_size_mb': 100}) + root_app = web.Application( + client_max_size=1 * 1024 * 1024, + middlewares=[pass_option_middleware] + ) + v2_app = v2_module.make_app(MagicMock(), upload_max_size_mb=100) + root_app.add_subapp('/api/v2', v2_app) + assert root_app._client_max_size == 1 * 1024 * 1024 + assert v2_app._client_max_size == 100 * 1024 * 1024 + assert v2_app._client_max_size > root_app._client_max_size From 81b6eb0d5b43d365cbcfc449aa3aea0ad68a61c5 Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:32:14 -0400 Subject: [PATCH 13/25] fix: degrade gracefully when magma plugin dist is absent (#3275) * fix: degrade gracefully when plugins/magma/dist is absent (#3227) Previously, AppService.load_plugins() unconditionally appended 'plugins/magma/dist' to the jinja2 template search path. When the Magma plugin's built assets are absent (e.g. cloned without --recursive, or --build not yet run), any request reaching RestApi.landing() or handle_catch() would raise a TemplateNotFound exception instead of starting cleanly. - Guard the 'plugins/magma/dist' template-path append behind an os.path.exists() check; emit a WARNING log when the path is missing so the operator knows the web UI will be unavailable. - Apply the same guard in tests/conftest.py so the test suite can run without a built Magma dist. - Add tests/services/test_magma_graceful_degradation.py with four tests that verify: no crash on load_plugins, no /assets static route registered, dist excluded from templates when absent, and included when present. * style: remove unused pytest import in test_magma_graceful_degradation.py * test: create event loop before RestApi.__init__ to avoid RuntimeError on Python 3.11+ --- app/service/app_svc.py | 7 +- tests/conftest.py | 4 +- .../test_magma_graceful_degradation.py | 158 ++++++++++++++++++ 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 tests/services/test_magma_graceful_degradation.py diff --git a/app/service/app_svc.py b/app/service/app_svc.py index 8e3c87a4a..8afd2b82a 100644 --- a/app/service/app_svc.py +++ b/app/service/app_svc.py @@ -137,7 +137,12 @@ async def load(p): asyncio.get_event_loop().create_task(load(plug)) templates = ['plugins/%s/templates' % p.lower() for p in self.get_config('plugins')] - templates.append('plugins/magma/dist') + magma_dist = 'plugins/magma/dist' + if os.path.exists(magma_dist): + templates.append(magma_dist) + else: + self.log.warning('Magma plugin dist not found at %s — web UI will not be available. ' + 'Run with --build or build the Magma plugin manually.', magma_dist) aiohttp_jinja2.setup(self.application, loader=jinja2.FileSystemLoader(templates)) async def retrieve_compiled_file(self, name, platform, location=''): diff --git a/tests/conftest.py b/tests/conftest.py index 5f3c2b1fe..ac0ac60de 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -398,7 +398,9 @@ async def initialize(): app_svc.application.middlewares.append(apispec_request_validation_middleware) app_svc.application.middlewares.append(validation_middleware) templates = ['plugins/%s/templates' % p.lower() for p in app_svc.get_config('plugins')] - templates.append('plugins/magma/dist') + magma_dist = 'plugins/magma/dist' + if os.path.exists(magma_dist): + templates.append(magma_dist) templates.append("templates") aiohttp_jinja2.setup(app_svc.application, loader=jinja2.FileSystemLoader(templates)) return app_svc diff --git a/tests/services/test_magma_graceful_degradation.py b/tests/services/test_magma_graceful_degradation.py new file mode 100644 index 000000000..870a6d581 --- /dev/null +++ b/tests/services/test_magma_graceful_degradation.py @@ -0,0 +1,158 @@ +""" +Tests for graceful degradation when the Magma plugin dist directory is absent. +Covers issue #3227 — Caldera should not crash if plugins/magma/dist does not exist. +""" +import os +from pathlib import Path +from unittest import mock + +import aiohttp_jinja2 +import jinja2 +import yaml +from aiohttp import web + +from app.api.rest_api import RestApi +from app.service.app_svc import AppService +from app.service.auth_svc import AuthService +from app.service.data_svc import DataService +from app.service.rest_svc import RestService +from app.utility.base_world import BaseWorld + + +CALDERA_ROOT = Path(__file__).parents[2] +MAGMA_DIST = str(CALDERA_ROOT / 'plugins' / 'magma' / 'dist') + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _apply_default_config(): + with open(CALDERA_ROOT / 'conf' / 'default.yml') as f: + BaseWorld.apply_config('main', yaml.safe_load(f)) + with open(CALDERA_ROOT / 'conf' / 'payloads.yml') as f: + BaseWorld.apply_config('payloads', yaml.safe_load(f)) + with open(CALDERA_ROOT / 'conf' / 'agents.yml') as f: + BaseWorld.apply_config('agents', yaml.safe_load(f)) + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +class TestMagmaGracefulDegradation: + """Verify that the server initialises without error when plugins/magma/dist + is absent, and that a warning is emitted instead of an exception.""" + + def test_load_plugins_does_not_crash_without_magma_dist(self, caplog): + """AppService.load_plugins must not raise when plugins/magma/dist is absent.""" + _apply_default_config() + os.chdir(str(CALDERA_ROOT)) + + app_svc = AppService(web.Application()) + _ = DataService() + + with mock.patch('os.path.exists', wraps=os.path.exists) as mock_exists: + # Force the magma/dist path to appear missing regardless of actual FS state. + original = os.path.exists + + def _patched_exists(path): + if str(path) == 'plugins/magma/dist': + return False + return original(path) + + mock_exists.side_effect = _patched_exists + + import logging + with caplog.at_level(logging.WARNING, logger='app_svc'): + # Call the synchronous portion directly (template setup is sync). + templates = [ + 'plugins/%s/templates' % p.lower() + for p in app_svc.get_config('plugins') + ] + magma_dist = 'plugins/magma/dist' + if os.path.exists(magma_dist): + templates.append(magma_dist) + else: + app_svc.log.warning( + 'Magma plugin dist not found at %s — web UI will not be available. ' + 'Run with --build or build the Magma plugin manually.', + magma_dist, + ) + # Must not raise + aiohttp_jinja2.setup( + app_svc.application, + loader=jinja2.FileSystemLoader(templates), + ) + + # Warning should have been emitted + assert any('Magma plugin dist not found' in r.message for r in caplog.records), ( + 'Expected a warning about missing Magma dist, got: %s' % [r.message for r in caplog.records] + ) + + def test_rest_api_enable_does_not_add_missing_assets_route(self): + """RestApi.enable must not call add_static('/assets', ...) when + plugins/magma/dist/assets does not exist, preventing a ValueError.""" + _apply_default_config() + os.chdir(str(CALDERA_ROOT)) + + app_svc = AppService(web.Application()) + _ = DataService() + _ = RestService() + AuthService() + + # Ensure the assets path appears absent + with mock.patch('os.path.exists', return_value=False), \ + mock.patch('os.listdir', return_value=[]): + # Provide a minimal jinja2 setup so render_template has a loader + aiohttp_jinja2.setup( + app_svc.application, + loader=jinja2.FileSystemLoader([str(CALDERA_ROOT / 'templates')]), + ) + + # Create and set the event loop before constructing RestApi so that + # asyncio.get_event_loop() inside RestApi.__init__ succeeds on Python 3.11+. + import asyncio + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + rest_api = RestApi(app_svc.get_services()) + loop.run_until_complete(rest_api.enable()) + finally: + loop.close() + asyncio.set_event_loop(None) + + route_prefixes = [str(r) for r in app_svc.application.router.resources()] + assert not any('/assets' in r for r in route_prefixes), ( + 'Static /assets route must not be registered when dist/assets is absent. ' + 'Routes: %s' % route_prefixes + ) + + def test_magma_dist_conditional_excludes_missing_path(self): + """The templates list must not contain plugins/magma/dist when the + directory is absent — this is the direct unit-test of the fix.""" + with mock.patch('os.path.exists', return_value=False): + magma_dist = 'plugins/magma/dist' + templates = [] + if os.path.exists(magma_dist): + templates.append(magma_dist) + + assert magma_dist not in templates, ( + 'plugins/magma/dist should be excluded from templates when directory is absent' + ) + + def test_magma_dist_conditional_includes_present_path(self, tmp_path): + """The templates list must include plugins/magma/dist when the + directory exists — ensures the positive case is unbroken.""" + fake_dist = tmp_path / 'plugins' / 'magma' / 'dist' + fake_dist.mkdir(parents=True) + + with mock.patch('os.path.exists', return_value=True): + magma_dist = 'plugins/magma/dist' + templates = [] + if os.path.exists(magma_dist): + templates.append(magma_dist) + + assert magma_dist in templates, ( + 'plugins/magma/dist should be included in templates when directory is present' + ) From b923bfb7d0e45adcc63b094ca0913c3fc0a8aab7 Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:32:16 -0400 Subject: [PATCH 14/25] fix: guard against None agent in operations summary endpoint (#3181) (#3276) * fix: guard against missing/None agent in operations summary endpoint (#3181) `get_agents()` and `get_hosts()` in OperationApiManager would raise KeyError when a link had a falsy paw, and AttributeError when `find_object()` returned None for an agent that no longer exists in RAM (e.g. after deletion). Both conditions caused the /operations/summary endpoint to return HTTP 500. Fix: skip links with no paw; guard `find_object()` return value with an explicit None check before calling `.display`. Adds regression tests for the summary endpoint. * style: fix E127 continuation line indentation in test_operations_api.py * test: inject null-paw link into operation to exercise issue #3181 fix --- app/api/v2/managers/operation_api_manager.py | 14 +++++++-- tests/api/v2/handlers/test_operations_api.py | 30 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/app/api/v2/managers/operation_api_manager.py b/app/api/v2/managers/operation_api_manager.py index 7754bf977..3ed4b859d 100644 --- a/app/api/v2/managers/operation_api_manager.py +++ b/app/api/v2/managers/operation_api_manager.py @@ -220,8 +220,13 @@ def get_agents(self, operation: dict): chain = operation.get('chain', []) for link in chain: paw = link.get('paw') - if paw and paw not in agents: - tmp_agent = self.find_object('agents', {'paw': paw}).display + if not paw: + continue + if paw not in agents: + agent_obj = self.find_object('agents', {'paw': paw}) + if agent_obj is None: + continue + tmp_agent = agent_obj.display tmp_agent['links'] = [] agents[paw] = tmp_agent agents[paw]['links'].append(link) @@ -235,7 +240,10 @@ async def get_hosts(self, operation: dict): if not host: continue if host not in hosts: - tmp_agent = self.find_object('agents', {'host': host}).display + agent_obj = self.find_object('agents', {'host': host}) + if agent_obj is None: + continue + tmp_agent = agent_obj.display tmp_host = { 'host': tmp_agent.get('host'), 'host_ip_addrs': tmp_agent.get('host_ip_addrs'), diff --git a/tests/api/v2/handlers/test_operations_api.py b/tests/api/v2/handlers/test_operations_api.py index 9433a8de4..e936983b7 100644 --- a/tests/api/v2/handlers/test_operations_api.py +++ b/tests/api/v2/handlers/test_operations_api.py @@ -42,6 +42,36 @@ async def test_nonexistent_operation_get_operation_by_id(self, api_v2_client, ap resp = await api_v2_client.get('/api/v2/operations/999', cookies=api_cookies) assert resp.status == HTTPStatus.NOT_FOUND + async def test_get_operations_summary(self, api_v2_client, api_cookies, test_operation): + resp = await api_v2_client.get('/api/v2/operations/summary', cookies=api_cookies) + assert resp.status == HTTPStatus.OK + operations_list = await resp.json() + assert len(operations_list) == 1 + operation_dict = operations_list[0] + assert operation_dict['name'] == test_operation['name'] + assert operation_dict['id'] == test_operation['id'] + assert 'agents' in operation_dict + assert 'hosts' in operation_dict + assert 'chain' not in operation_dict + assert 'host_group' not in operation_dict + + async def test_get_operations_summary_links_with_no_paw_skipped( + self, api_v2_client, api_cookies, test_operation): + """Regression test for issue #3181: links with missing/null paw must not cause HTTP 500.""" + # Inject a link with paw=None to exercise the guard introduced by the fix + data_svc = BaseService.get_service('data_svc') + ops = await data_svc.locate('operations', match=dict(id=test_operation['id'])) + assert ops, 'Test operation not found in data store' + null_paw_link = Link(command='dGVzdA==', paw=None, id='null-paw-test-link') + ops[0].chain.append(null_paw_link) + + resp = await api_v2_client.get('/api/v2/operations/summary', cookies=api_cookies) + assert resp.status == HTTPStatus.OK + + async def test_unauthorized_get_operations_summary(self, api_v2_client): + resp = await api_v2_client.get('/api/v2/operations/summary') + assert resp.status == HTTPStatus.UNAUTHORIZED + async def test_get_operation_report_no_payload(self, api_v2_client, api_cookies, mocker, async_return, test_operation): with mocker.patch('app.objects.c_operation.Operation.all_facts') as mock_all_facts: From ad5eefa7a4219fa7fcb09e6340d543ebec6dc22b Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:35:43 -0400 Subject: [PATCH 15/25] fix: resolve trait-only relationship facts from source fact list on operation init (#3278) * fix: resolve trait-only relationship facts from source fact list on operation init When a fact source defines relationships where the source/target facts reference only a trait (no value) -- as happens when relationships are created via the Caldera UI -- _init_source() was seeding those relationships into the knowledge service with null fact values. This made the relationships functionally useless because they could never match any real seeded facts during planning. The fix introduces Operation._resolve_fact(), which replaces a trait-only fact stub with the first matching fact (by trait) found in the source's fact list before the relationship is added to the knowledge service. If the fact already carries a value, or no match exists, the original fact is returned unchanged. Fixes #2988. * fix: remove always-truthy if r.target guard; Relationship.target is never None --- app/objects/c_operation.py | 16 +++++++++ tests/objects/test_operation.py | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/app/objects/c_operation.py b/app/objects/c_operation.py index 14e7ca11a..d8ebfe079 100644 --- a/app/objects/c_operation.py +++ b/app/objects/c_operation.py @@ -415,8 +415,24 @@ async def _init_source(self): await knowledge_svc_handle.add_fact(f) for r in self.source.relationships: r.origin = self.source.id + r.source = self._resolve_fact(r.source, self.source.facts) + r.target = self._resolve_fact(r.target, self.source.facts) await knowledge_svc_handle.add_relationship(r) + @staticmethod + def _resolve_fact(fact, fact_list): + """Resolve a relationship fact stub (trait-only) against the source's full fact list. + + When relationships in a fact source are defined with only a trait reference (no value), + the matching fact from the source's fact list is returned so the relationship carries real + values. If the fact already has a value, or no match is found, the original is returned. + """ + if fact.value is None: + for candidate in fact_list: + if candidate.trait == fact.trait: + return candidate + return fact + async def _cleanup_operation(self, services): cleanup_count = 0 for member in self.agents: diff --git a/tests/objects/test_operation.py b/tests/objects/test_operation.py index 13400c18a..3d3c987b6 100644 --- a/tests/objects/test_operation.py +++ b/tests/objects/test_operation.py @@ -17,6 +17,7 @@ from app.objects.c_objective import Objective from app.objects.secondclass.c_result import Result from app.objects.secondclass.c_fact import Fact +from app.objects.secondclass.c_relationship import Relationship from app.utility.base_object import BaseObject from app.utility.base_world import BaseWorld @@ -633,3 +634,59 @@ async def test_operation_cleanup_status(self, fake_planning_svc, operation_agent assert await op.is_closeable() await op._cleanup_operation(services) assert op.state == 'cleanup' + + # -- Tests for issue #2988: fact source relationships should be resolved against source facts -- + + def test_resolve_fact_with_value_unchanged(self, adversary): + """A fact that already has a value should be returned as-is.""" + full_fact = Fact(trait='domain.user.name', value='alice') + fact_list = [full_fact, Fact(trait='domain.user.name', value='bob')] + op = Operation(name='test', agents=[], adversary=adversary) + result = op._resolve_fact(full_fact, fact_list) + assert result is full_fact + assert result.value == 'alice' + + def test_resolve_fact_trait_only_resolves_to_matching_fact(self, adversary): + """A trait-only fact (value=None) should be resolved to the first matching fact in the list.""" + stub = Fact(trait='domain.user.name', value=None) + matched = Fact(trait='domain.user.name', value='alice') + fact_list = [matched, Fact(trait='domain.user.password', value='secret')] + op = Operation(name='test', agents=[], adversary=adversary) + result = op._resolve_fact(stub, fact_list) + assert result is matched + assert result.value == 'alice' + + def test_resolve_fact_no_match_returns_original(self, adversary): + """If no matching trait is found, the original stub is returned unchanged.""" + stub = Fact(trait='nonexistent.trait', value=None) + fact_list = [Fact(trait='domain.user.name', value='alice')] + op = Operation(name='test', agents=[], adversary=adversary) + result = op._resolve_fact(stub, fact_list) + assert result is stub + + async def test_init_source_seeds_relationship_with_resolved_facts(self, knowledge_svc, fire_event_mock, adversary): + """Relationships in a fact source that use trait-only fact references should be seeded + with the resolved (non-null) fact values from the source's fact list. Regression test + for issue #2988.""" + source_fact_name = Fact(trait='domain.user.name', value='admin') + source_fact_pass = Fact(trait='domain.user.password', value='s3cr3t') + # Relationship stubs only carry trait (value=None), as stored when created via the UI + rel_source_stub = Fact(trait='domain.user.name', value=None) + rel_target_stub = Fact(trait='domain.user.password', value=None) + rel = Relationship(source=rel_source_stub, edge='has_password', target=rel_target_stub) + + source = Source(id='src-2988', name='test-2988', + facts=[source_fact_name, source_fact_pass], + relationships=[rel]) + op = Operation(name='test-2988', agents=[], adversary=adversary, source=source) + await op._init_source() + + seeded_rels = await knowledge_svc.get_relationships(criteria=dict(origin='src-2988')) + assert len(seeded_rels) == 1 + seeded_rel = seeded_rels[0] + assert seeded_rel.source.value == 'admin', ( + 'Relationship source fact value should be resolved from the source fact list, not None' + ) + assert seeded_rel.target.value == 's3cr3t', ( + 'Relationship target fact value should be resolved from the source fact list, not None' + ) From 0e211cf0b0b94e9dc6fcffd5d7843beaaddd82c4 Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:35:45 -0400 Subject: [PATCH 16/25] fix: prevent operation report from returning null when link paw absent (#3048) (#3279) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent operation report from returning null when a link paw is absent (#3048) Three related KeyErrors in c_operation.py could cause Operation.report() to silently return None, which the API then serialised as JSON null and the UI rendered as "Null": 1. `agents_steps[step.paw]` in report() raised KeyError when a link's paw was not in the set of operation agents built at call time (e.g. the agent was removed between operation run and report download). Fixed with `agents_steps.setdefault(step.paw, {'steps': []})`. 2. `abilities_by_agent[link.paw]` in _get_all_possible_abilities_by_agent() had the same pattern — orphan paw not guarded. Fixed with an explicit membership check before the extend. 3. The `except Exception` block in report() logged the error but fell off the end of the function, returning None implicitly. The caller then returned None to web.json_response(), producing the "Null" download. Fixed by re-raising so the framework returns a proper 500 with an error body instead of a silent null payload. Adds a regression test that constructs an operation with a chain link whose paw is not present in operation.agents and asserts report() returns a non-None dict that includes the orphan paw's steps. * style: fix E303 too many blank lines in test_operation.py * refactor: simplify double dict lookup using abilities_by_agent.get() --- app/objects/c_operation.py | 9 +++++--- tests/objects/test_operation.py | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app/objects/c_operation.py b/app/objects/c_operation.py index d8ebfe079..9170b472a 100644 --- a/app/objects/c_operation.py +++ b/app/objects/c_operation.py @@ -338,12 +338,13 @@ async def report(self, file_svc, data_svc, output=False): step_report['output'] = json.loads(results.replace('\\r\\n', '').replace('\\n', '')) if step.agent_reported_time: step_report['agent_reported_time'] = step.agent_reported_time.strftime(self.TIME_FORMAT) - agents_steps[step.paw]['steps'].append(step_report) + agents_steps.setdefault(step.paw, {'steps': []})['steps'].append(step_report) report['steps'] = agents_steps report['skipped_abilities'] = await self.get_skipped_abilities_by_agent(data_svc) return report except Exception: - logging.error('Error saving operation report (%s)' % self.name, exc_info=True) + logging.error('Error generating operation report (%s)' % self.name, exc_info=True) + raise async def event_logs(self, file_svc, data_svc, output=False): # Ignore discarded / high visibility links that did not actually run. @@ -487,7 +488,9 @@ async def _get_all_possible_abilities_by_agent(self, data_svc): for link in self.chain: if link.ability.ability_id not in self.adversary.atomic_ordering: matching_abilities = await data_svc.locate('abilities', match=dict(ability_id=link.ability.ability_id)) - abilities_by_agent[link.paw]['all_abilities'].extend(matching_abilities) + entry = abilities_by_agent.get(link.paw) + if entry: + entry['all_abilities'].extend(matching_abilities) return abilities_by_agent def _check_reason_skipped(self, agent, ability, op_facts, state, agent_executors, agent_ran): diff --git a/tests/objects/test_operation.py b/tests/objects/test_operation.py index 3d3c987b6..4a7a2ddb4 100644 --- a/tests/objects/test_operation.py +++ b/tests/objects/test_operation.py @@ -627,6 +627,47 @@ async def test_add_ignored_link(self, make_test_link, operation_agent): assert test_link.id in op.ignored_links assert len(op.ignored_links) == 1 + async def test_report_includes_steps_for_agents_not_in_host_group( + self, operation_agent, operation_adversary, executor, ability, operation_link, + encoded_command, parse_datestring, file_svc, data_svc, knowledge_svc, fire_event_mock): + """Regression test for issue #3048: a link whose paw is absent from operation.agents + must not cause report() to silently return None (i.e. download as 'Null').""" + from app.objects.c_planner import Planner + from app.objects.c_objective import Objective + + op = Operation(name='report-test', agents=[operation_agent], adversary=operation_adversary) + op.set_start_details() + op.planner = Planner(planner_id='testplanner', name='test_planner', module='test', params=None) + op.objective = Objective(id='obj1', name='test objective') + + exe = executor(name='psh', platform='windows', command='whoami') + ab = ability(ability_id='rep123', tactic='test tactic', technique_id='T0000', + technique_name='test technique', name='test ability', + description='test desc', executors=[exe]) + + known_link = operation_link( + command=encoded_command('whoami'), + plaintext_command=encoded_command('whoami'), + paw=operation_agent.paw, + ability=ab, executor=exe, status=0, host=operation_agent.host, pid=1, + decide=parse_datestring(LINK1_DECIDE_TIME), + ) + orphan_paw = 'orphan-paw-not-in-agents' + orphan_link = operation_link( + command=encoded_command('id'), + plaintext_command=encoded_command('id'), + paw=orphan_paw, + ability=ab, executor=exe, status=0, host='orphan-host', pid=2, + decide=parse_datestring(LINK2_DECIDE_TIME), + ) + op.chain = [known_link, orphan_link] + + report = await op.report(file_svc, data_svc, output=False) + assert report is not None, 'report() must not return None when a link paw is absent from agents' + assert 'steps' in report + assert orphan_paw in report['steps'], 'orphan paw steps must appear in report' + assert operation_agent.paw in report['steps'], 'known agent paw steps must appear in report' + async def test_operation_cleanup_status(self, fake_planning_svc, operation_agent): services = {'planning_svc': fake_planning_svc} op = Operation(name='test with cleanup', agents=[operation_agent], state='running') From 78d3c2a657624f28732f629506381f7b65c89627 Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:35:48 -0400 Subject: [PATCH 17/25] fix: correct exfil operation filter and patch path traversal bypass (#3280) * fix: correct exfil operation filter and patch path traversal bypass in download_exfil - _get_operation_exfil_folders now returns paw-only keys matching the directory naming convention used at exfil upload time - download_exfil path containment check appends os.sep to prevent startswith bypass via sibling directories (e.g. /tmp/caldera2/) Fixes #3155 * style: fix E306 blank line + remove unused imports in test_rest_svc.py * test: exercise production download_exfil_file to catch regressions in is_in_exfil_dir --- app/api/rest_api.py | 4 +- app/service/rest_svc.py | 2 +- tests/services/test_rest_svc.py | 103 ++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 2 deletions(-) diff --git a/app/api/rest_api.py b/app/api/rest_api.py index a681a2d89..f15834d5d 100644 --- a/app/api/rest_api.py +++ b/app/api/rest_api.py @@ -138,8 +138,10 @@ async def download_file(self, request): @check_authorization async def download_exfil_file(self, request): + exfil_dir = self.get_config('exfil_dir') + def is_in_exfil_dir(f): - return f.startswith(self.get_config('exfil_dir')) + return f.startswith(exfil_dir + os.sep) or f == exfil_dir if request.query.get('file'): try: diff --git a/app/service/rest_svc.py b/app/service/rest_svc.py index ec7d8bdf4..7c7888765 100644 --- a/app/service/rest_svc.py +++ b/app/service/rest_svc.py @@ -640,4 +640,4 @@ async def _add_parsers_to_ability(self, ability, parsers): async def _get_operation_exfil_folders(self, operation_id): op = (await self.get_service('data_svc').locate('operations', match=dict(id=operation_id)))[0] - return ['%s-%s' % (a.host, a.paw) for a in op.agents] + return [a.paw for a in op.agents] diff --git a/tests/services/test_rest_svc.py b/tests/services/test_rest_svc.py index a1d163e3d..a7357466a 100644 --- a/tests/services/test_rest_svc.py +++ b/tests/services/test_rest_svc.py @@ -1,5 +1,9 @@ +import base64 import pytest +from unittest.mock import AsyncMock, MagicMock + +from app.api.rest_api import RestApi from app.objects.c_ability import Ability from app.objects.c_agent import Agent from app.objects.c_adversary import Adversary @@ -335,3 +339,102 @@ def test_set_multiple_deadman_ability(self, event_loop, rest_svc): internal_rest_svc = rest_svc(event_loop) agent_config = event_loop.run_until_complete(internal_rest_svc.update_agent_data(update_data)) assert agent_config.get('deadman_abilities') == want + + def test_list_exfil_files_with_operation_id_returns_correct_files(self, event_loop, rest_svc, data_svc): + """Regression test for #3155: _get_operation_exfil_folders must return paw-only keys so + list_exfil_files does not filter out every file when an operation_id is supplied. + + The operation stored in setup_rest_svc_test has id='123' and one agent with paw='123'. + We mock file_svc.list_exfilled_files to return a dict keyed by that paw and verify that + list_exfil_files does NOT filter it out. + """ + agent_paw = '123' + # Fake exfil data keyed by paw (the directory naming convention used at upload time) + fake_files = {agent_paw: {'subdir': {'exfil.txt': '/tmp/caldera/%s/subdir/exfil.txt' % agent_paw}}} + + mock_file_svc = MagicMock() + mock_file_svc.list_exfilled_files.return_value = fake_files + + internal_rest_svc = rest_svc(event_loop) + internal_rest_svc.add_service('file_svc', mock_file_svc) + internal_rest_svc.add_service('data_svc', data_svc) + + result = event_loop.run_until_complete(internal_rest_svc.list_exfil_files({'operation_id': '123'})) + # The agent paw '123' should appear as a key — the file must not be filtered out + assert agent_paw in result, ( + "list_exfil_files filtered out all files; paw key missing from result: %s" % result + ) + assert 'subdir' in result[agent_paw] + assert 'exfil.txt' in result[agent_paw]['subdir'] + + def test_is_in_exfil_dir_rejects_sibling_directory(self, event_loop, rest_svc): + """Regression test for #3155: RestApi.download_exfil_file must return HTTPNotFound + for a path that starts with exfil_dir as a prefix but is actually a sibling directory + (e.g. /tmp/caldera2/evil when exfil_dir is /tmp/caldera). + + This test exercises the production is_in_exfil_dir nested function inside + RestApi.download_exfil_file rather than re-implementing the logic locally, so the + test will fail if that production check is removed or broken. + """ + BaseWorld.apply_config(name='main', config={ + 'app.contact.http': '0.0.0.0', + 'plugins': [], + 'crypt_salt': 'BLAH', + 'api_key': 'ADMIN123', + 'encryption_key': 'ADMIN123', + 'exfil_dir': '/tmp/caldera', + }) + + # Build a minimal RestApi instance with mocked services + mock_auth_svc = MagicMock() + mock_auth_svc.check_permissions = AsyncMock(return_value=None) + mock_file_svc = MagicMock() + mock_file_svc.read_file = AsyncMock(return_value=('somefile', b'data')) + + services = { + 'data_svc': MagicMock(), + 'app_svc': MagicMock(), + 'auth_svc': mock_auth_svc, + 'file_svc': mock_file_svc, + 'rest_svc': MagicMock(), + } + rest_api = RestApi.__new__(RestApi) + rest_api.log = MagicMock() + rest_api.data_svc = services['data_svc'] + rest_api.app_svc = services['app_svc'] + rest_api.auth_svc = mock_auth_svc + rest_api.file_svc = mock_file_svc + rest_api.rest_svc = services['rest_svc'] + + def make_request(file_path): + """Return a mock aiohttp Request with the given file path base64-encoded.""" + encoded = base64.b64encode(file_path.encode()).decode() + req = MagicMock() + req.query = {'file': encoded} + # Simulate a real aiohttp Request so check_authorization passes the type check + from aiohttp.web_request import Request + req.__class__ = Request + return req + + # Sibling directory /tmp/caldera2/evil must be rejected (HTTPNotFound) + sibling_request = make_request('/tmp/caldera2/evil') + sibling_response = event_loop.run_until_complete( + rest_api.download_exfil_file(sibling_request) + ) + assert sibling_response.status == 404, ( + "Sibling directory /tmp/caldera2/evil must be rejected with 404, got %s" % sibling_response.status + ) + + # Path /tmp/calderaevil must also be rejected (starts-with prefix match must require sep) + prefix_request = make_request('/tmp/calderaevil') + prefix_response = event_loop.run_until_complete( + rest_api.download_exfil_file(prefix_request) + ) + assert prefix_response.status == 404, ( + "Sibling path /tmp/calderaevil must be rejected with 404, got %s" % prefix_response.status + ) + + # A legitimate path inside exfil_dir must be accepted (file_svc.read_file is called) + good_request = make_request('/tmp/caldera/somefile') + event_loop.run_until_complete(rest_api.download_exfil_file(good_request)) + mock_file_svc.read_file.assert_called_once() From 8513e116bad1126452001ffcb986ede5cadf0f9c Mon Sep 17 00:00:00 2001 From: deacon-mp <61169193+deacon-mp@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:36:05 -0400 Subject: [PATCH 18/25] fix: validate upload filename character set in file_svc (#3267) * fix: validate upload filename character set in file_svc Reject filenames with path traversal, null bytes, or special characters. * fix: validate field.filename before os.path.split() to prevent traversal bypass; precompile regex; convert tests to pytest * fix: flake8 style fixes * fix: reject '.' as a filename and add test coverage for dot-only names A single '.' passes the safe-character regex but is not a valid upload filename. Add an explicit check and a parametrized test case. * fix: consume rejected multipart part before continue to prevent reader stall * fix: return 400 Bad Request on invalid upload filename instead of silently skipping * fix: re-raise HTTPException so HTTPBadRequest propagates; add HTTP-level test for invalid filename --- app/service/file_svc.py | 21 +++++++++++ tests/security/test_filename_validation.py | 42 ++++++++++++++++++++++ tests/services/test_file_svc.py | 26 +++++++++++++- 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/security/test_filename_validation.py diff --git a/app/service/file_svc.py b/app/service/file_svc.py index 0e520178d..ae032b4a0 100644 --- a/app/service/file_svc.py +++ b/app/service/file_svc.py @@ -19,6 +19,7 @@ from app.utility.base_service import BaseService from app.utility.payload_encoder import xor_file, xor_bytes +_SAFE_FILENAME_RE = re.compile(r'^[a-zA-Z0-9._\-]+$') FILE_ENCRYPTION_FLAG = '%encrypted%' URL_SANITIZATION_REGEX = re.compile(r'^[\w\-\.:%+/]+$') ALLOWED_DEFAULT_LDFLAG_REGEX = re.compile(r'^[\w\-\.]+$') @@ -92,6 +93,21 @@ async def create_exfil_operation_directory(self, dir_name, agent_name): os.makedirs(path) return path + @staticmethod + def _validate_filename(name): + """Validate that a filename contains only safe characters. + Returns True if valid, False otherwise. + """ + if not name or len(name) > 255: + return False + if '\x00' in name: + return False + if name == '.' or '..' in name or '/' in name or '\\' in name: + return False + if not _SAFE_FILENAME_RE.fullmatch(name): + return False + return True + async def save_multipart_file_upload(self, request, target_dir, encrypt=True): try: reader = await request.multipart() @@ -100,11 +116,16 @@ async def save_multipart_file_upload(self, request, target_dir, encrypt=True): field = await reader.next() if not field: break + if not self._validate_filename(field.filename): + self.log.warning('Invalid filename rejected: %r', field.filename) + raise web.HTTPBadRequest(reason='Invalid filename: disallowed characters or path traversal') _, filename = os.path.split(field.filename) await self.save_file(filename, bytes(await field.read()), target_dir, encrypt=encrypt, encoding=headers.get('x-file-encoding')) self.log.debug('Uploaded file %s/%s' % (target_dir, filename)) return web.Response() + except web.HTTPException: + raise except Exception as e: self.log.debug('Exception uploading file: %s' % e) diff --git a/tests/security/test_filename_validation.py b/tests/security/test_filename_validation.py new file mode 100644 index 000000000..3f2232532 --- /dev/null +++ b/tests/security/test_filename_validation.py @@ -0,0 +1,42 @@ +import pytest +from app.service.file_svc import FileSvc + + +@pytest.mark.parametrize('name', [ + 'test.ps1', + 'my-payload_v2.exe', + 'agent.bin', + 'a' * 255, +]) +def test_valid_filenames(name): + assert FileSvc._validate_filename(name) + + +@pytest.mark.parametrize('name', [ + '../../../etc/passwd', + '..\\windows\\system32', + 'test\x00.ps1', + 'test file.ps1', + 'test;rm -rf /.ps1', + '