Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions app/service/auth_svc.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +16,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'
Expand Down Expand Up @@ -143,8 +143,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

Expand All @@ -170,9 +170,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
Comment on lines +173 to 176
Comment on lines +173 to 176
Comment on lines +173 to 176
return ()

Expand Down
3 changes: 2 additions & 1 deletion app/service/login_handlers/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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'))
Expand Down
14 changes: 13 additions & 1 deletion app/utility/base_world.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand All @@ -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)
Comment on lines +36 to +41
Comment on lines +36 to +41
BaseWorld._app_configuration[name] = config

@staticmethod
Expand Down
59 changes: 0 additions & 59 deletions app/utility/config_generator.py

This file was deleted.

104 changes: 104 additions & 0 deletions app/utility/config_util.py
Original file line number Diff line number Diff line change
@@ -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.
""")
Comment on lines +12 to +27
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)
Comment on lines +44 to +46
except (VerifyMismatchError, VerificationError, InvalidHashError):
Comment on lines +44 to +47
Comment on lines +44 to +47
return False
Comment on lines +37 to +48


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():

Check warning on line 68 in app/utility/config_util.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=mitre_caldera&issues=AZyVMCwperPZ1UCfrt6w&open=AZyVMCwperPZ1UCfrt6w&pullRequest=3257
for username, val in group_dict.items():
if not _is_hashed(val):
Comment on lines +63 to +70
config['users'][group_name][username] = ph.hash(val)
any_hashed = True
Comment on lines +67 to +72

Comment on lines +56 to +73
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()),

Check warning on line 84 in app/utility/config_util.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=mitre_caldera&issues=AZyVMCwperPZ1UCfrt6x&open=AZyVMCwperPZ1UCfrt6x&pullRequest=3257

Check warning on line 84 in app/utility/config_util.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=mitre_caldera&issues=AZyVMCwperPZ1UCfrt6y&open=AZyVMCwperPZ1UCfrt6y&pullRequest=3257
blue=dict(blue=secrets.token_urlsafe()))

Check warning on line 85 in app/utility/config_util.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this constructor call with a literal.

See more on https://sonarcloud.io/project/issues?id=mitre_caldera&issues=AZyVMCwperPZ1UCfrt6z&open=AZyVMCwperPZ1UCfrt6z&pullRequest=3257

# 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)
Comment on lines +87 to +89
Comment on lines +87 to +89

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)
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Comment on lines +242 to +243
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])
Expand Down
2 changes: 1 addition & 1 deletion tests/api/v2/managers/test_config_api_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/api/v2/test_knowledge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/api/v2/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ def base_world():
'red': {'reduser': 'redpass'},
'blue': {'blueuser': 'bluepass'}
}
}
},
apply_hash=True
)

yield BaseWorld
Expand Down
3 changes: 2 additions & 1 deletion tests/api/v2/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ def base_world():
'red': {'reduser': 'redpass'},
'blue': {'blueuser': 'bluepass'}
}
}
},
apply_hash=True
)

yield BaseWorld
Expand Down
4 changes: 2 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])

Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion tests/services/test_rest_svc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}')
Expand Down
Loading
Loading