Skip to content

Commit 80a516e

Browse files
authored
Merge branch 'master' into emureadme
2 parents f14cc4c + f018f72 commit 80a516e

7 files changed

Lines changed: 180 additions & 27 deletions

File tree

.github/workflows/testing.yml

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,21 +9,19 @@ jobs:
99
fail-fast: false
1010
matrix:
1111
include:
12-
- python-version: 3.8
13-
toxenv: py38,style,coverage-ci
14-
- python-version: 3.9
15-
toxenv: py39,style,coverage-ci
1612
- python-version: 3.10.9
1713
toxenv: py310,style,coverage-ci
1814
- python-version: 3.11
1915
toxenv: py311,style,coverage-ci
16+
- python-version: 3.12
17+
toxenv: py312,style,coverage-ci
2018

2119
steps:
22-
- uses: actions/checkout@v2
20+
- uses: actions/checkout@v5
2321
with:
2422
submodules: recursive
2523
- name: Setup python
26-
uses: actions/setup-python@v2
24+
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c
2725
with:
2826
python-version: ${{ matrix.python-version }}
2927
- name: Install dependencies

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
repos:
2-
- repo: https://github.com/pycqa/flake8
3-
rev: 5.0.4
2+
- repo: https://github.com/PyCQA/flake8
3+
rev: 7.0.0
44
hooks:
55
- id: flake8
66
additional_dependencies: [flake8-bugbear]
77
- repo: https://github.com/PyCQA/bandit
8-
rev: 1.7.4
8+
rev: 1.7.7
99
hooks:
1010
- id: bandit
1111
entry: bandit -ll --exclude=tests/ --skip=B303

app/emu_svc.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,11 @@ def _copy_planner(self, source_path, target_filename):
159159
if not os.path.exists(planner_dir):
160160
os.makedirs(planner_dir)
161161
target_path = os.path.join(planner_dir, target_filename)
162-
shutil.copyfile(source_path, target_path)
163-
self.log.debug('Copied planner to %s', target_path)
162+
if os.path.exists(target_path):
163+
self.log.debug('Planner %s already exists. Skipping.', target_path)
164+
else:
165+
shutil.copyfile(source_path, target_path)
166+
self.log.debug('Copied planner to %s', target_path)
164167

165168
@staticmethod
166169
def _is_planner(data):
@@ -226,8 +229,11 @@ async def _write_adversary(self, data):
226229
os.makedirs(d)
227230

228231
file_path = os.path.join(d, '%s.yml' % data['id'])
229-
with open(file_path, 'w') as f:
230-
f.write(yaml.dump(data))
232+
if os.path.exists(file_path):
233+
self.log.debug('Adversary profile %s already exists. Skipping.', file_path)
234+
else:
235+
with open(file_path, 'w') as f:
236+
f.write(yaml.dump(data))
231237

232238
async def _save_adversary(self, id, name, description, abilities):
233239
adversary = dict(
@@ -249,8 +255,11 @@ async def _write_ability(self, data):
249255
if not os.path.exists(d):
250256
os.makedirs(d)
251257
file_path = os.path.join(d, '%s.yml' % data['id'])
252-
with open(file_path, 'w') as f:
253-
f.write(yaml.dump([data]))
258+
if os.path.exists(file_path):
259+
self.log.debug('Ability file %s already exists. Skipping.', file_path)
260+
else:
261+
with open(file_path, 'w') as f:
262+
f.write(yaml.dump([data]))
254263

255264
@staticmethod
256265
def get_privilege(executors):
@@ -344,5 +353,8 @@ async def _write_source(self, data):
344353
os.makedirs(d)
345354

346355
file_path = os.path.join(d, '%s.yml' % data['id'])
347-
with open(file_path, 'w') as f:
348-
f.write(yaml.dump(data))
356+
if os.path.exists(file_path):
357+
self.log.debug('Fact source file %s already exists. Skipping.', file_path)
358+
else:
359+
with open(file_path, 'w') as f:
360+
f.write(yaml.dump(data))

hook.py

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import os
2-
import shutil
32

43
from app.utility.base_world import BaseWorld
54
from plugins.emu.app.emu_svc import EmuService
@@ -22,10 +21,5 @@ async def enable(services):
2221
if not os.path.isdir(plugin_svc.repo_dir):
2322
await plugin_svc.clone_repo()
2423

25-
for directory in ['abilities', 'adversaries', 'sources', 'planners']:
26-
full_path = os.path.join(data_dir, directory)
27-
if os.path.isdir(full_path):
28-
shutil.rmtree(full_path)
29-
3024
await plugin_svc.decrypt_payloads()
3125
await plugin_svc.populate_data_directory()

requirements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1+
# WARNING: pyminizip 0.2.6 is affected by CVE-2023-45853 (integer overflow
2+
# in bundled minizip/zlib code). No patched version is available as of 2026-03.
3+
# Consider replacing with an alternative zip library when one becomes available.
14
pyminizip==0.2.6

tests/test_emu_security.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import ast
2+
import os
3+
4+
import pytest
5+
6+
yaml = pytest.importorskip("yaml")
7+
8+
PLUGIN_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
9+
REQUIREMENTS_PATH = os.path.join(PLUGIN_DIR, 'requirements.txt')
10+
HOOK_PATH = os.path.join(PLUGIN_DIR, 'hook.py')
11+
CONF_DIR = os.path.join(PLUGIN_DIR, 'conf')
12+
DATA_DIR = os.path.join(PLUGIN_DIR, 'data')
13+
14+
15+
class TestRequirementsSecurity:
16+
"""Test that requirements.txt documents known CVE risks."""
17+
18+
def test_pyminizip_has_cve_warning_comment(self):
19+
"""pyminizip has known CVEs; requirements.txt should document this.
20+
21+
pyminizip uses zlib and has had vulnerabilities reported. A comment
22+
in requirements.txt should warn maintainers about this risk so it
23+
is not overlooked during dependency reviews.
24+
"""
25+
with open(REQUIREMENTS_PATH, 'r', encoding='utf-8') as f:
26+
content = f.read()
27+
28+
# Verify pyminizip is listed
29+
assert 'pyminizip' in content, (
30+
"pyminizip not found in requirements.txt"
31+
)
32+
33+
# Check for a CVE-related comment near the pyminizip line
34+
lines = content.splitlines()
35+
found_cve_comment = False
36+
for i, line in enumerate(lines):
37+
if 'pyminizip' in line.lower():
38+
# Check this line and surrounding lines for CVE warning
39+
context_start = max(0, i - 2)
40+
context_end = min(len(lines), i + 3)
41+
context = '\n'.join(lines[context_start:context_end])
42+
if 'cve' in context.lower() or 'vulnerab' in context.lower():
43+
found_cve_comment = True
44+
break
45+
assert found_cve_comment, (
46+
"requirements.txt should have a comment warning about known "
47+
"CVEs for pyminizip (e.g., '# WARNING: pyminizip has known "
48+
"CVE vulnerabilities')"
49+
)
50+
51+
52+
class TestHookParseable:
53+
"""Verify that hook.py is syntactically valid."""
54+
55+
def test_hook_file_exists(self):
56+
"""hook.py must exist as the plugin entry point."""
57+
assert os.path.isfile(HOOK_PATH), (
58+
f"hook.py not found at {HOOK_PATH}"
59+
)
60+
61+
def test_hook_can_be_parsed(self):
62+
"""hook.py should be valid Python parseable by ast.parse."""
63+
with open(HOOK_PATH, 'r', encoding='utf-8') as f:
64+
source = f.read()
65+
try:
66+
tree = ast.parse(source)
67+
except SyntaxError as e:
68+
pytest.fail(f"hook.py has a syntax error: {e}")
69+
assert tree is not None
70+
71+
def test_hook_defines_enable_function(self):
72+
"""hook.py must define an async 'enable' function."""
73+
with open(HOOK_PATH, 'r', encoding='utf-8') as f:
74+
source = f.read()
75+
tree = ast.parse(source)
76+
enable_funcs = [
77+
node for node in ast.walk(tree)
78+
if isinstance(node, ast.AsyncFunctionDef) and node.name == 'enable'
79+
]
80+
assert len(enable_funcs) > 0, (
81+
"hook.py must define an 'async def enable(...)' function"
82+
)
83+
84+
def test_hook_defines_plugin_name(self):
85+
"""hook.py should define a 'name' variable."""
86+
with open(HOOK_PATH, 'r', encoding='utf-8') as f:
87+
source = f.read()
88+
tree = ast.parse(source)
89+
name_assignments = [
90+
node for node in ast.walk(tree)
91+
if isinstance(node, ast.Assign)
92+
and any(
93+
isinstance(target, ast.Name) and target.id == 'name'
94+
for target in node.targets
95+
)
96+
]
97+
assert len(name_assignments) > 0, (
98+
"hook.py should define a 'name' variable for the plugin"
99+
)
100+
101+
102+
class TestAbilitiesYAML:
103+
"""Validate YAML configuration files are well-formed."""
104+
105+
def _get_yaml_files(self, directory):
106+
"""Recursively collect all YAML files from a directory."""
107+
yaml_files = []
108+
if not os.path.isdir(directory):
109+
return yaml_files
110+
for root, dirs, files in os.walk(directory):
111+
for fname in files:
112+
if fname.endswith('.yml') or fname.endswith('.yaml'):
113+
yaml_files.append(os.path.join(root, fname))
114+
return sorted(yaml_files)
115+
116+
def test_conf_yaml_files_are_valid(self):
117+
"""All YAML files in conf/ should be parseable."""
118+
yaml_files = self._get_yaml_files(CONF_DIR)
119+
assert len(yaml_files) > 0, (
120+
f"No YAML files found in {CONF_DIR}"
121+
)
122+
for fpath in yaml_files:
123+
with open(fpath, 'r', encoding='utf-8') as f:
124+
try:
125+
data = yaml.safe_load(f)
126+
except yaml.YAMLError as e:
127+
pytest.fail(f"Failed to parse {fpath}: {e}")
128+
assert data is not None, (
129+
f"YAML file is empty: {fpath}"
130+
)
131+
132+
def test_data_yaml_files_are_valid_if_present(self):
133+
"""If data/ contains YAML files (post-setup), they should be parseable."""
134+
yaml_files = self._get_yaml_files(DATA_DIR)
135+
if not yaml_files:
136+
pytest.skip(
137+
"No YAML files in data/ — run plugin setup to populate"
138+
)
139+
for fpath in yaml_files:
140+
with open(fpath, 'r', encoding='utf-8') as f:
141+
try:
142+
data = yaml.safe_load(f)
143+
except yaml.YAMLError as e:
144+
pytest.fail(f"Failed to parse {fpath}: {e}")

tox.ini

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
[tox]
77
skipsdist = True
88
envlist =
9-
py{38,39,310,311}
9+
py{39,310,311,312}
1010
style
1111
coverage
1212
bandit
@@ -16,20 +16,22 @@ skip_missing_interpreters = true
1616
description = run tests
1717
passenv = TOXENV,CI,TRAVIS,TRAVIS_*,CODECOV_*
1818
deps =
19+
-rrequirements.txt
1920
virtualenv!=20.0.22
2021
pre-commit
2122
pytest
23+
pytest-asyncio==0.26.0
2224
pytest-aiohttp
2325
coverage
2426
codecov
2527
changedir = /tmp/caldera
2628
commands =
2729
/usr/bin/git clone https://github.com/mitre/caldera.git --recursive /tmp/caldera
2830
/bin/rm -rf /tmp/caldera/plugins/emu
29-
python -m pip install -r /tmp/caldera/requirements.txt
31+
python3 -m pip install -r /tmp/caldera/requirements.txt
32+
python3 -m pip install -r /tmp/caldera/requirements-dev.txt
3033
/usr/bin/cp -R {toxinidir} /tmp/caldera/plugins/emu
31-
python -m pip install -r /tmp/caldera/plugins/emu/requirements.txt
32-
coverage run -p -m pytest --tb=short --rootdir=/tmp/caldera /tmp/caldera/plugins/emu/tests -W ignore::DeprecationWarning
34+
coverage run -p -m pytest --tb=short --rootdir=/tmp/caldera --asyncio-mode=auto /tmp/caldera/plugins/emu/tests -W ignore::DeprecationWarning
3335
allowlist_externals =
3436
/usr/bin/git
3537
/usr/bin/cp

0 commit comments

Comments
 (0)