├── .flake8
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── config.yml
│ ├── feature_request.md
│ └── question.md
├── pull_request_template.md
└── workflows
│ └── testing.yml
├── .gitignore
├── .pre-commit-config.yaml
├── LICENSE
├── README.md
├── VERSION.txt
├── app
├── atomic_gui.py
├── atomic_svc.py
└── parsers
│ └── atomic_powershell.py
├── data
└── .gitkeep
├── gui
└── views
│ └── atomic.vue
├── hook.py
├── payloads
└── .gitkeep
├── tests
├── .gitkeep
└── test_atomic_svc.py
└── tox.ini
/.flake8:
--------------------------------------------------------------------------------
1 | [flake8]
2 | max-line-length = 180
3 | exclude =
4 | .svn,
5 | CVS,
6 | .bzr,
7 | .hg,
8 | .git,
9 | __pycache__,
10 | .tox,
11 | venv,
12 | .venv
13 | data
14 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F41E Bug report"
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: bug
6 | assignees: wbooth
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1.
16 |
17 | **Expected behavior**
18 | A clear and concise description of what you expected to happen.
19 |
20 | **Screenshots**
21 | If applicable, add screenshots to help explain your problem.
22 |
23 | **Desktop (please complete the following information):**
24 | - OS: [e.g. Mac, Windows, Kali]
25 | - Browser [e.g. chrome, safari]
26 | - Version [e.g. 2.8.0]
27 |
28 | **Additional context**
29 | Add any other context about the problem here.
30 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | contact_links:
2 | - name: Documentation
3 | url: https://caldera.readthedocs.io/en/latest/
4 | about: Your question may be answered in the documentation
5 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U0001F680 New Feature Request"
3 | about: Propose a new feature
4 | title: ''
5 | labels: feature
6 | assignees: 'wbooth'
7 |
8 | ---
9 |
10 | **What problem are you trying to solve? Please describe.**
11 | > Eg. I'm always frustrated when [...]
12 |
13 |
14 | **The ideal solution: What should the feature should do?**
15 | > a clear and concise description
16 |
17 |
18 | **What category of feature is this?**
19 |
20 | - [ ] UI/UX
21 | - [ ] API
22 | - [ ] Other
23 |
24 | **If you have code or pseudo-code please provide:**
25 |
26 |
27 | ```python
28 |
29 | ```
30 |
31 | - [ ] Willing to submit a pull request to implement this feature?
32 |
33 | **Additional context**
34 | Add any other context or screenshots about the feature request here.
35 |
36 | Thank you for your contribution!
37 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/question.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: "\U00002753 Question"
3 | about: Support questions
4 | title: ''
5 | labels: question
6 | assignees: ''
7 |
8 | ---
9 |
10 |
11 |
12 |
13 |
18 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 | (insert summary)
4 |
5 | ## Type of change
6 |
7 | Please delete options that are not relevant.
8 |
9 | - [ ] Bug fix (non-breaking change which fixes an issue)
10 | - [ ] New feature (non-breaking change which adds functionality)
11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
12 | - [ ] This change requires a documentation update
13 |
14 | ## How Has This Been Tested?
15 |
16 | Please describe the tests that you ran to verify your changes.
17 |
18 |
19 | ## Checklist:
20 |
21 | - [ ] My code follows the style guidelines of this project
22 | - [ ] I have performed a self-review of my own code
23 | - [ ] I have made corresponding changes to the documentation
24 | - [ ] I have added tests that prove my fix is effective or that my feature works
25 |
--------------------------------------------------------------------------------
/.github/workflows/testing.yml:
--------------------------------------------------------------------------------
1 | name: Code Testing
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | strategy:
9 | fail-fast: false
10 | matrix:
11 | include:
12 | - python-version: 3.8
13 | toxenv: py38,coverage-ci
14 |
15 | steps:
16 | - uses: actions/checkout@v2
17 | with:
18 | submodules: recursive
19 | - name: Setup python
20 | uses: actions/setup-python@v2
21 | with:
22 | python-version: ${{ matrix.python-version }}
23 | - name: Install dependencies
24 | run: |
25 | pip install --upgrade virtualenv
26 | pip install tox
27 | - name: Run tests
28 | env:
29 | TOXENV: ${{ matrix.toxenv }}
30 | run: tox
31 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | *.crt
3 | *.pyd
4 | *.DS_Store
5 | *.spec
6 | *.pstat
7 | *.tokens
8 | *__pycache__*
9 | .idea/*
10 | conf/secrets.yml
11 | atomic-red-team/*
12 | data/*
13 | !data/.gitkeep
14 | payloads/*
15 | !payloads/.gitkeep
16 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://gitlab.com/pycqa/flake8
3 | rev: 3.7.7
4 | hooks:
5 | - id: flake8
6 | additional_dependencies: [flake8-bugbear]
7 | - repo: https://github.com/PyCQA/bandit
8 | rev: 1.6.2
9 | hooks:
10 | - id: bandit
11 | entry: bandit -ll --exclude=tests/ --skip=B322,B303
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # MITRE Caldera plugin: Atomic
2 |
3 | A plugin supplying Caldera with TTPs from the Atomic Red Team project.
4 |
5 | ## Details
6 |
7 | - When importing tests from Atomic Red Team, this plugin also catches `$PathToAtomicsFolder` usages pointing to an existing file. It then imports the files as payloads and fix path usages. Note other usages are not handled. If a path with `$PathToAtomicsFolder` points to an existing directory or an unexisting file, we will not process it any further and ingest it "as it is". Examples of such usages below:
8 | -- https://github.com/redcanaryco/atomic-red-team/blob/a956d4640f9186a7bd36d16a63f6d39433af5f1d/atomics/T1022/T1022.yaml#L99
9 | -- https://github.com/redcanaryco/atomic-red-team/blob/ab0b391ac0d7b18f25cb17adb330309f92fa94e6/atomics/T1056/T1056.yaml#L24
10 |
11 | - ART tests only specify techniques they address. This plugin creates a mapping and import abilities under the corresponding tactic. Yet sometimes multiple tactics are a match, and we do not know which one the test addresses. This will be fixed in the future thanks to the ATT&CK sub-techniques. As of now, we use a new tactic category called "multiple".
12 |
13 | ## Known issues
14 | - When a command/cleanup expands over multiple lines with one of them being a comment, it messes up the whole command/cleanup (as we reduce multiple lines into one with semi-colons).
15 |
16 | ## Acknowledgements
17 |
18 | - [Atomic Red Team](https://github.com/redcanaryco/atomic-red-team)
19 | - [AtomicCaldera](https://github.com/xenoscr/atomiccaldera)
20 |
--------------------------------------------------------------------------------
/VERSION.txt:
--------------------------------------------------------------------------------
1 | 2.9.0-2d766f82e65cb657e19b47afa9e8ba86
2 |
--------------------------------------------------------------------------------
/app/atomic_gui.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from aiohttp_jinja2 import template
4 |
5 | from app.service.auth_svc import for_all_public_methods, check_authorization
6 | from app.utility.base_world import BaseWorld
7 |
8 |
9 | @for_all_public_methods(check_authorization)
10 | class AtomicGUI(BaseWorld):
11 |
12 | def __init__(self, services, name, description):
13 | self.auth_svc = services.get('auth_svc')
14 | self.data_svc = services.get('data_svc')
15 |
16 | self.log = logging.getLogger('atomic_gui')
17 |
--------------------------------------------------------------------------------
/app/atomic_svc.py:
--------------------------------------------------------------------------------
1 | import json
2 | import glob
3 | import hashlib
4 | import os
5 | import re
6 | import shutil
7 | import yaml
8 |
9 | from collections import defaultdict
10 | from subprocess import DEVNULL, STDOUT, check_call
11 |
12 | from app.utility.base_world import BaseWorld
13 | from app.utility.base_service import BaseService
14 | from app.objects.c_agent import Agent
15 |
16 | PLATFORMS = dict(windows='windows', macos='darwin', linux='linux')
17 | EXECUTORS = dict(command_prompt='cmd', sh='sh', powershell='psh', bash='sh')
18 | RE_VARIABLE = re.compile('(#{(.*?)})', re.DOTALL)
19 | PREFIX_HASH_LEN = 6
20 |
21 |
22 | class ExtractionError(Exception):
23 | pass
24 |
25 |
26 | class AtomicService(BaseService):
27 |
28 | def __init__(self):
29 | self.log = self.add_service('atomic_svc', self)
30 |
31 | # Atomic Red Team attacks don't come with the corresponding tactic (phase name)
32 | # so we need to create a match between techniques and tactics.
33 | # This variable is filled by self._populate_dict_techniques_tactics()
34 | self.technique_to_tactics = defaultdict(list)
35 |
36 | self.atomic_dir = os.path.join('plugins', 'atomic')
37 | self.repo_dir = os.path.join(self.atomic_dir, 'data/atomic-red-team')
38 | self.data_dir = os.path.join(self.atomic_dir, 'data')
39 | self.payloads_dir = os.path.join(self.atomic_dir, 'payloads')
40 | self.processing_debug = False
41 |
42 | async def clone_atomic_red_team_repo(self, repo_url=None):
43 | """
44 | Clone the Atomic Red Team repository. You can use a specific url via
45 | the `repo_url` parameter (eg. if you want to use a fork).
46 | """
47 | if not repo_url:
48 | repo_url = 'https://github.com/redcanaryco/atomic-red-team.git'
49 |
50 | if not os.path.exists(self.repo_dir) or not os.listdir(self.repo_dir):
51 | self.log.debug('cloning repo %s' % repo_url)
52 | check_call(['git', 'clone', '--depth', '1', repo_url, self.repo_dir], stdout=DEVNULL, stderr=STDOUT)
53 | self.log.debug('clone complete')
54 |
55 | async def populate_data_directory(self, path_yaml=None):
56 | """
57 | Populate the 'data' directory with the Atomic Red Team abilities.
58 | These data will be usable by caldera after importation.
59 | You can specify where the yaml files to import are located with the `path_yaml` parameter.
60 | By default, read the yaml files in the atomics/ directory inside the Atomic Red Team repository.
61 | """
62 | if not self.technique_to_tactics:
63 | await self._populate_dict_techniques_tactics()
64 |
65 | if not path_yaml:
66 | path_yaml = os.path.join(self.repo_dir, 'atomics', '**', 'T*.yaml')
67 |
68 | at_total = 0
69 | at_ingested = 0
70 | errors = 0
71 | for filename in glob.iglob(path_yaml):
72 | for entries in BaseWorld.strip_yml(filename):
73 | for test in entries.get('atomic_tests'):
74 | at_total += 1
75 | try:
76 | if await self._save_ability(entries, test):
77 | at_ingested += 1
78 | except Exception as e:
79 | self.log.debug(e)
80 | errors += 1
81 |
82 | errors_output = f' and ran into {errors} errors' if errors else ''
83 | self.log.debug(f'Ingested {at_ingested} abilities (out of {at_total}) from Atomic plugin{errors_output}')
84 |
85 | """ PRIVATE """
86 |
87 | @staticmethod
88 | def _gen_single_match_tactic_technique(mitre_json):
89 | """
90 | Generator parsing the json from 'enterprise-attack.json',
91 | and returning couples (phase_name, external_id)
92 | """
93 | for obj in mitre_json.get('objects', list()):
94 | if not obj.get('type') == 'attack-pattern':
95 | continue
96 | for e in obj.get('external_references', list()):
97 | if not e.get('source_name') == 'mitre-attack':
98 | continue
99 | external_id = e.get('external_id')
100 | for kc in obj.get('kill_chain_phases', list()):
101 | if not kc.get('kill_chain_name') == 'mitre-attack':
102 | continue
103 | phase_name = kc.get('phase_name')
104 | yield phase_name, external_id
105 |
106 | async def _populate_dict_techniques_tactics(self):
107 | """
108 | Populate internal dictionary used to match techniques to corresponding tactics.
109 | Use the file 'enterprise-attack.json' located in the Atomic Red Team repository.
110 | """
111 | enterprise_attack_path = os.path.join(self.repo_dir, 'atomic_red_team', 'enterprise-attack.json')
112 |
113 | with open(enterprise_attack_path, 'r') as f:
114 | mitre_json = json.load(f)
115 |
116 | for phase_name, external_id in self._gen_single_match_tactic_technique(mitre_json):
117 | self.technique_to_tactics[external_id].append(phase_name)
118 |
119 | def _handle_attachment(self, attachment_path):
120 | # attachment_path must be a POSIX path
121 | payload_name = os.path.basename(attachment_path)
122 | # to avoid collisions between payloads with the same name
123 | with open(attachment_path, 'rb') as f:
124 | h = hashlib.md5(f.read()).hexdigest()
125 | payload_name = h[:PREFIX_HASH_LEN] + '_' + payload_name
126 | shutil.copyfile(attachment_path, os.path.join(self.payloads_dir, payload_name), follow_symlinks=False)
127 | return payload_name
128 |
129 | @staticmethod
130 | def _normalize_path(path, platform):
131 | if platform == PLATFORMS['windows']:
132 | return path.replace('\\', '/')
133 | return path
134 |
135 | def _catch_path_to_atomics_folder(self, string_to_analyse, platform):
136 | """
137 | Catch a path to the atomics/ folder in the `string_to_analyse` variable,
138 | and handle it in the best way possible. If needed, will import a payload.
139 | """
140 | regex = re.compile(r'\$?PathToAtomicsFolder((?:/[^/ \n]+)+|(?:\\[^\\ \n]+)+)')
141 | payloads = []
142 | if regex.search(string_to_analyse):
143 | fullpath, path = regex.search(string_to_analyse).group(0, 1)
144 | path = self._normalize_path(path, platform)
145 |
146 | # take path from index 1, as it starts with /
147 | path = os.path.join(self.repo_dir, 'atomics', path[1:])
148 |
149 | if os.path.isfile(path):
150 | payload_name = self._handle_attachment(path)
151 | payloads.append(payload_name)
152 | string_to_analyse = string_to_analyse.replace(fullpath, payload_name)
153 |
154 | return string_to_analyse, payloads
155 |
156 | def _has_reserved_parameter(self, command):
157 | return any(reserved in command for reserved in Agent.RESERVED)
158 |
159 | def _use_default_inputs(self, test, platform, string_to_analyse):
160 | """
161 | Look if variables are used in `string_to_analyse`, and if any variable was given
162 | a default value, use it.
163 | """
164 |
165 | payloads = []
166 | defaults = dict((key, val) for key, val in test.get('input_arguments', dict()).items())
167 | if self._has_reserved_parameter(string_to_analyse):
168 | return string_to_analyse, payloads
169 | while RE_VARIABLE.search(string_to_analyse):
170 | full_var_str, varname = RE_VARIABLE.search(string_to_analyse).groups()
171 | default_var = str(defaults.get(varname, dict()).get('default'))
172 |
173 | if default_var is not None:
174 | default_var, new_payloads = self._catch_path_to_atomics_folder(default_var, platform)
175 | payloads.extend(new_payloads)
176 | string_to_analyse = string_to_analyse.replace(full_var_str, default_var)
177 |
178 | return string_to_analyse, payloads
179 |
180 | @staticmethod
181 | def _handle_multiline_commands(cmd, executor):
182 | command_lines = cmd.strip().split("\n")
183 | if executor == 'cmd':
184 | return ' && '.join(AtomicService._remove_dos_comment_lines(command_lines))
185 | else:
186 | return AtomicService._concatenate_shell_commands(AtomicService._remove_shell_comments(command_lines,
187 | executor))
188 |
189 | @staticmethod
190 | def _concatenate_shell_commands(command_lines):
191 | """Concatenate multiple shell command lines. The ; character won't be added at the end of each command if the
192 | command line ends in "then" or "do" or already ends with a ; character."""
193 | to_concat = []
194 | num_lines = len(command_lines)
195 | for index, cmd in enumerate(command_lines):
196 | to_concat.append(cmd)
197 | if re.search(r'do\s*$', cmd) or re.search(r'then\s*$', cmd) or re.search(r';\s*$', cmd):
198 | if not re.search(r'\s+$', cmd):
199 | to_concat.append(' ')
200 | elif index < num_lines - 1:
201 | to_concat.append('; ')
202 | return ''.join(to_concat)
203 |
204 | @staticmethod
205 | def _remove_dos_comment_lines(command_lines):
206 | """Remove lines that start with REM or :: comments for Windows DOS cmd. Does not handle trailing comments."""
207 | def _starts_with_comment(line):
208 | return re.match(r'^\s*@?\s*rem\s+', line, re.IGNORECASE) or re.match(r'^\s*::\s+', line, re.IGNORECASE)
209 |
210 | return [line for line in command_lines if not _starts_with_comment(line)]
211 |
212 | @staticmethod
213 | def _remove_shell_comments(command_lines, executor):
214 | """Remove lines that start with a # comment. Also remove trailing comments."""
215 | def _starts_with_comment(line):
216 | return re.match(r'^\s*#', line)
217 |
218 | def _remove_escaped_quotes(line):
219 | regex = r'`("|\')' if executor == 'psh' else r'\\(\'|")'
220 | return re.sub(regex, '', line)
221 |
222 | def _index_within_completed_quotes_and_contents(line, index):
223 | start_index = 0
224 | while start_index < len(line) and start_index <= index:
225 | to_process = line[start_index:]
226 | quote_match = re.search(r'\'|"', to_process)
227 | if not quote_match:
228 | return False
229 | start_quote_index = quote_match.start()
230 | first_quote_char = to_process[start_quote_index]
231 | quote_matches = list(re.finditer(first_quote_char, to_process))
232 | if len(quote_matches) > 1:
233 | closing_quote_index = quote_matches[1].start()
234 | if start_quote_index + start_index <= index <= closing_quote_index + start_index:
235 | return True
236 | else:
237 | start_index = closing_quote_index + start_index + 1
238 | else:
239 | # Unbalanced quotes. Since line only goes up to the start of the comment,
240 | # the comment must be inside quotes.
241 | return True
242 | return False
243 |
244 | def _remove_trailing_comment(line):
245 | trailing_comment_regex = r'(;|\s)\s*#'
246 | for match in re.finditer(trailing_comment_regex, line):
247 | # Check if the trailing comment is actually part of a closed quote group
248 | removed_escaped_quotes = _remove_escaped_quotes(line[0:match.end()])
249 | if not _index_within_completed_quotes_and_contents(removed_escaped_quotes, match.start()):
250 | return line[0:match.start()]
251 | return line
252 |
253 | ret_lines = []
254 | for command_line in command_lines:
255 | if not _starts_with_comment(command_line):
256 | processed = _remove_trailing_comment(command_line)
257 | if processed:
258 | ret_lines.append(processed)
259 | return ret_lines
260 |
261 | async def _prepare_cmd(self, test, platform, executor, cmd):
262 | """
263 | Handle a command or a cleanup (both are formatted the same way), given in `cmd`.
264 | Return the cmd formatted as needed and payloads we need to take into account.
265 | """
266 | payloads = []
267 | cmd, new_payloads = self._use_default_inputs(test, platform, cmd)
268 | payloads.extend(new_payloads)
269 | cmd, new_payloads = self._catch_path_to_atomics_folder(cmd, platform)
270 | payloads.extend(new_payloads)
271 | cmd = self._handle_multiline_commands(cmd, executor)
272 | return cmd, payloads
273 |
274 | async def _prepare_executor(self, test, platform, executor):
275 | """
276 | Prepare the command and cleanup, and return them with the needed payloads.
277 | """
278 | payloads = []
279 | dep_construct = ""
280 | if 'dependencies' in test:
281 | for dependence in test['dependencies']:
282 | try:
283 | test_exc = test.get('dependency_executor_name', executor)
284 | dep_construct = await self._prereq_formater(dependence.get('prereq_command', ''),
285 | dependence.get('get_prereq_command', ''),
286 | test_exc,
287 | executor,
288 | dep_construct)
289 | except ExtractionError:
290 | self.log.debug(f'Skipping pre-req for "{test["name"]}"')
291 | precmd = f"{dep_construct} \n {test['executor']['command']}" if dep_construct else test['executor']['command']
292 | command, payloads_command = await self._prepare_cmd(test, platform, executor, precmd)
293 | cleanup, payloads_cleanup = await self._prepare_cmd(test, platform, executor,
294 | test['executor'].get('cleanup_command', ''))
295 | payloads.extend(payloads_command)
296 | payloads.extend(payloads_cleanup)
297 |
298 | return command, cleanup, payloads
299 |
300 | async def _save_ability(self, entries, test):
301 | """
302 | Return True if an ability was saved.
303 | """
304 | ability_id = hashlib.md5(json.dumps(test).encode()).hexdigest()
305 |
306 | tactics_li = self.technique_to_tactics.get(entries['attack_technique'], ['redcanary-unknown'])
307 | tactic = 'multiple' if len(tactics_li) > 1 else tactics_li[0]
308 |
309 | data = dict(
310 | id=ability_id,
311 | name=test['name'],
312 | description=test['description'],
313 | tactic=tactic,
314 | technique=dict(
315 | attack_id=entries['attack_technique'],
316 | name=entries['display_name']
317 | ),
318 | platforms=dict()
319 | )
320 | for p in test['supported_platforms']:
321 | if test['executor']['name'] != 'manual':
322 | # manual tests are expected to be run manually by a human, no automation is provided
323 | executor = EXECUTORS.get(test['executor']['name'], 'unknown')
324 | platform = PLATFORMS.get(p, 'unknown')
325 |
326 | command, cleanup, payloads = await self._prepare_executor(test, platform, executor)
327 | data['platforms'][platform] = dict()
328 | data['platforms'][platform][executor] = dict(command=command, payloads=payloads, cleanup=cleanup)
329 | if executor == 'psh':
330 | data['platforms'][platform][executor]['parsers'] = {'plugins.atomic.app.parsers.atomic_powershell':
331 | [{'source': 'validate_me'}]}
332 |
333 | if data['platforms']: # this might be empty, if so there's nothing useful to save
334 | d = os.path.join(self.data_dir, 'abilities', tactic)
335 | if not os.path.exists(d):
336 | os.makedirs(d)
337 | file_path = os.path.join(d, '%s.yml' % ability_id)
338 | with open(file_path, 'w') as f:
339 | f.write(yaml.dump([data]))
340 | return True
341 |
342 | return False
343 |
344 | async def _prereq_formater(self, prereq_test, prereq, prereq_type, exec_type, ability_command):
345 | """
346 | Format prereqs as a header test block for an ability
347 | :param prereq_test: Test to see if the ability is required
348 | :param prereq: Command to install prereq if required
349 | :param prereq_type: Which executor this prereq should target (psh, sh, cmd)
350 | :param exec_type: Which executor this ability should target (psh, sh, cmd)
351 | :param ability_command: Existing commands for this ability
352 | :return: Full formed, staged command
353 | """
354 | output = ""
355 | prereq = prereq.rstrip()
356 | if 'exit' not in prereq_test.lower() or prereq.startswith('echo "') or \
357 | (prereq.startswith('echo ') and ('Run' in prereq or 'Sorry,' in prereq)):
358 | if self.processing_debug:
359 | self.log.debug(f'Action ({prereq}) cannot be automated automatically.')
360 | if prereq.startswith('echo'):
361 | self.log.debug(f'Try to satisfy: {prereq.split("echo")[1].split("; exit")[0]}')
362 | elif prereq.startswith('Write-Host'):
363 | self.log.debug(f'Try to satisfy: {prereq.split("Write-Host ")[1]}')
364 | raise ExtractionError
365 | if prereq_type == 'sh':
366 | segments = prereq_test.split(';')
367 | if 'exit 1' in segments[1]:
368 | # check is "falsy"
369 | output += f"{segments[0]}; then {prereq}; fi;"
370 | else:
371 | # check is "truthy"
372 | output += f"{segments[0]}; then : ; else {prereq}; fi;"
373 | elif prereq_type == 'psh':
374 | if prereq_test.startswith('Try'):
375 | temp = f"{prereq_test.replace('exit 1', prereq)}"
376 | output += f"{temp.replace('exit 0', ' ; ')}"
377 | else:
378 | segments = prereq_test.split(')')
379 | test_outcomes = segments[1].split('}')
380 | if 'exit 1' in test_outcomes[0]:
381 | # check is "falsy"
382 | output += f"{segments[0]}) {{{prereq}}}"
383 | else:
384 | # check is "truthy"
385 | output += f"{segments[0]}) {{ ; }} else {{{prereq}}}"
386 | elif prereq_type == 'cmd':
387 | segments = prereq_test.split('(')
388 | test_outcomes = segments[1].split('ELSE')
389 | if 'exit 1' in test_outcomes[0]:
390 | # check is "falsy"
391 | output += f"{segments[0]} ({prereq})"
392 | else:
393 | # check is "truthy"
394 | output += f"{segments[0]} ( call ) ELSE ( {prereq} )"
395 | else:
396 | return ability_command
397 | if prereq_type == exec_type:
398 | output += '\n' + ability_command
399 | else:
400 | if prereq_type == "cmd" and exec_type == "psh":
401 | output += '\n' + ability_command
402 | elif prereq_type == "psh" and exec_type == "cmd":
403 | output = f'powershell -command "{output} \n {ability_command}"'
404 | else:
405 | self.log.warning(f'Unable to deduce a way to link a {prereq_type} prereq and a {exec_type} ability. '
406 | f'Defaulting to just the ability - this may cause the produced ability to behave '
407 | f'unexpectedly.')
408 | output = ability_command
409 | return output
410 |
--------------------------------------------------------------------------------
/app/parsers/atomic_powershell.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from app.utility.base_parser import BaseParser, PARSER_SIGNALS_FAILURE
3 |
4 |
5 | class Parser(BaseParser):
6 | checked_flags = list('FullyQualifiedErrorId')
7 |
8 | def parse(self, blob):
9 | for ex_line in self.line(blob):
10 | if any(x in ex_line for x in self.checked_flags):
11 | log = logging.getLogger('parsing_svc')
12 | log.warning('This ability failed for some reason. Manually updating the link to report a failed state.')
13 | return [PARSER_SIGNALS_FAILURE]
14 | return []
15 |
--------------------------------------------------------------------------------
/data/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitre/atomic/b90958810ffae1d328124f9e8af75f524616b27f/data/.gitkeep
--------------------------------------------------------------------------------
/gui/views/atomic.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 | .content
24 | h2 Atomic
25 | p The collection of abilities in the Red Canary Atomic test project.
26 | hr
27 |
28 | .is-flex.is-align-items-center.is-justify-content-center
29 | .card.is-flex.is-flex-direction-column.is-align-items-center.p-4.m-4
30 | h1.is-size-1.mb-0 {{ atomicAbilities.length || "---" }}
31 | p abilities
32 | router-link.button.is-primary.mt-4(to="/abilities?plugin=atomic")
33 | span View Abilities
34 | span.icon
35 | font-awesome-icon(icon="fas fa-angle-right")
36 |
37 |
--------------------------------------------------------------------------------
/hook.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from app.utility.base_world import BaseWorld
4 | from plugins.atomic.app.atomic_svc import AtomicService
5 | from plugins.atomic.app.atomic_gui import AtomicGUI
6 |
7 | name = 'Atomic'
8 | description = 'The collection of abilities in the Red Canary Atomic test project'
9 | address = '/plugin/atomic/gui'
10 | access = BaseWorld.Access.RED
11 | data_dir = os.path.join('plugins', 'atomic', 'data')
12 |
13 |
14 | async def enable(services):
15 | atomic_gui = AtomicGUI(services, name, description)
16 | app = services.get('app_svc').application
17 |
18 | # we only ingest data once, and save new abilities in the data/ folder of the plugin
19 | if "abilities" not in os.listdir(data_dir):
20 | atomic_svc = AtomicService()
21 | await atomic_svc.clone_atomic_red_team_repo()
22 | await atomic_svc.populate_data_directory()
23 |
--------------------------------------------------------------------------------
/payloads/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitre/atomic/b90958810ffae1d328124f9e8af75f524616b27f/payloads/.gitkeep
--------------------------------------------------------------------------------
/tests/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mitre/atomic/b90958810ffae1d328124f9e8af75f524616b27f/tests/.gitkeep
--------------------------------------------------------------------------------
/tests/test_atomic_svc.py:
--------------------------------------------------------------------------------
1 | import hashlib
2 | import os
3 | import pytest
4 |
5 | from plugins.atomic.app.atomic_svc import AtomicService
6 |
7 | DUMMY_PAYLOAD_PATH = '/tmp/dummyatomicpayload'
8 | DUMMY_PAYLOAD_CONTENT = 'Dummy payload content.'
9 | PREFIX_HASH_LENGTH = 6
10 |
11 |
12 | @pytest.fixture
13 | def atomic_svc():
14 | return AtomicService()
15 |
16 |
17 | @pytest.fixture
18 | def generate_dummy_payload():
19 | with open(DUMMY_PAYLOAD_PATH, 'w') as f:
20 | f.write(DUMMY_PAYLOAD_CONTENT)
21 | yield DUMMY_PAYLOAD_PATH
22 | os.remove(DUMMY_PAYLOAD_PATH)
23 |
24 |
25 | @pytest.fixture
26 | def multiline_command():
27 | return '\n'.join([
28 | 'command1',
29 | 'command2',
30 | 'command3',
31 | ])
32 |
33 |
34 | @pytest.fixture
35 | def atomic_test():
36 | return {
37 | 'name': 'Qakbot Recon',
38 | 'auto_generated_guid': '121de5c6-5818-4868-b8a7-8fd07c455c1b',
39 | 'description': 'A list of commands known to be performed by Qakbot',
40 | 'supported_platforms': ['windows'],
41 | 'input_arguments': {
42 | 'recon_commands': {
43 | 'description': 'File that houses commands to be executed',
44 | 'type': 'Path',
45 | 'default': 'PathToAtomicsFolder\\T1016\\src\\qakbot.bat'
46 | }
47 | },
48 | 'executor': {
49 | 'command': '#{recon_commands}\n',
50 | 'name':
51 | 'command_prompt'
52 | }
53 | }
54 |
55 |
56 | class TestAtomicSvc:
57 | def test_svc_config(self, atomic_svc):
58 | assert atomic_svc.repo_dir == 'plugins/atomic/data/atomic-red-team'
59 | assert atomic_svc.data_dir == 'plugins/atomic/data'
60 | assert atomic_svc.payloads_dir == 'plugins/atomic/payloads'
61 |
62 | def test_normalize_windows_path(self):
63 | assert AtomicService._normalize_path('windows\\test\\path', 'windows') == 'windows/test/path'
64 |
65 | def test_normalize_posix_path(self):
66 | assert AtomicService._normalize_path('linux/test/path', 'linux') == 'linux/test/path'
67 |
68 | def test_handle_attachment(self, atomic_svc, generate_dummy_payload):
69 | target_hash = hashlib.md5(DUMMY_PAYLOAD_CONTENT.encode()).hexdigest()
70 | target_name = target_hash[:PREFIX_HASH_LENGTH] + '_dummyatomicpayload'
71 | target_path = atomic_svc.payloads_dir + '/' + target_name
72 | assert atomic_svc._handle_attachment(DUMMY_PAYLOAD_PATH) == target_name
73 | assert os.path.isfile(target_path)
74 | with open(target_path, 'r') as f:
75 | file_data = f.read()
76 | assert file_data == DUMMY_PAYLOAD_CONTENT
77 |
78 | def test_handle_multiline_command_sh(self, multiline_command):
79 | target = 'command1; command2; command3'
80 | assert AtomicService._handle_multiline_commands(multiline_command, 'sh') == target
81 |
82 | def test_handle_multiline_command_cmd(self, multiline_command):
83 | target = 'command1 && command2 && command3'
84 | assert AtomicService._handle_multiline_commands(multiline_command, 'cmd') == target
85 |
86 | def test_handle_multiline_command_cmd_comments(self):
87 | commands = '\n'.join([
88 | 'command1',
89 | 'REM this is a comment',
90 | ' rem this is another comment',
91 | 'command2',
92 | ' :: another comment',
93 | ':: another comment',
94 | ' @ REM another comment',
95 | 'command3',
96 | '@rem more comments'
97 | ])
98 | want = 'command1 && command2 && command3'
99 | assert AtomicService._handle_multiline_commands(commands, 'cmd') == want
100 |
101 | def test_handle_multiline_command_shell_comments(self):
102 | commands = '\n'.join([
103 | 'command1',
104 | '# comment',
105 | ' # comment',
106 | 'command2',
107 | ';# comment',
108 | '; # comment',
109 | 'echo thisis#notacomment',
110 | 'echo thisis;#a comment',
111 | 'command3 # trailing comment',
112 | 'command4;#trailing comment',
113 | 'command5; #trailing comment',
114 | 'echo "this is # not a comment" # but this is',
115 | 'echo "\'" can you \'"\' handle "complex # quotes" # but still find the comment; #? ##',
116 | ])
117 | want = 'command1; command2; echo thisis#notacomment; echo thisis; command3; command4; command5; ' \
118 | 'echo "this is # not a comment"; echo "\'" can you \'"\' handle "complex # quotes"'
119 | assert AtomicService._handle_multiline_commands(commands, 'sh') == want
120 |
121 | def test_handle_multiline_command_powershell_comments(self):
122 | commands = '\n'.join([
123 | 'command1',
124 | '# comment',
125 | ' # comment',
126 | 'command2',
127 | ';# comment',
128 | '; # comment',
129 | 'echo thisis#notacomment',
130 | 'echo thisis;#a comment',
131 | 'command3 # trailing comment',
132 | 'command4;#trailing comment',
133 | 'command5; #trailing comment',
134 | 'echo "this is # not a comment" # but this is',
135 | 'echo "\'" can you \'"\' han`"dle "complex # quotes" # but still find the comment; #? ##',
136 | 'echo `"this is not actually a quote # so this comment should be removed `"',
137 | ])
138 | want = 'command1; command2; echo thisis#notacomment; echo thisis; command3; command4; command5; ' \
139 | 'echo "this is # not a comment"; echo "\'" can you \'"\' han`"dle "complex # quotes"; ' \
140 | 'echo `"this is not actually a quote'
141 | assert AtomicService._handle_multiline_commands(commands, 'psh') == want
142 |
143 | def test_handle_multiline_command_shell_semicolon(self):
144 | commands = '\n'.join([
145 | 'command1',
146 | '# comment',
147 | ' # comment',
148 | 'command2; ',
149 | 'command3 ;',
150 | 'command4;;',
151 | 'command5'
152 | ])
153 | want = 'command1; command2; command3 ; command4;; command5'
154 | assert AtomicService._handle_multiline_commands(commands, 'sh') == want
155 |
156 | def test_handle_multiline_command_shell_loop(self):
157 | commands = '\n'.join([
158 | 'for port in {1..65535};',
159 | '# comment',
160 | ' # comment',
161 | 'do ',
162 | 'innerloopcommand;',
163 | 'innerloopcommand2',
164 | 'done'
165 | ])
166 | want = 'for port in {1..65535}; do innerloopcommand; innerloopcommand2; done'
167 | assert AtomicService._handle_multiline_commands(commands, 'sh') == want
168 |
169 | def test_handle_multiline_command_shell_ifthen(self):
170 | commands = '\n'.join([
171 | 'if condition; then',
172 | '# comment',
173 | ' # comment',
174 | 'innercommand;',
175 | 'innercommand2;',
176 | 'fi'
177 | ])
178 | want = 'if condition; then innercommand; innercommand2; fi'
179 | assert AtomicService._handle_multiline_commands(commands, 'sh') == want
180 |
181 | def test_use_default_inputs(self, atomic_svc, atomic_test):
182 | platform = 'windows'
183 | string_to_analyze = '#{recon_commands} -a'
184 | test = atomic_test
185 | test['input_arguments']['recon_commands']['default'] = \
186 | 'PathToAtomicsFolder\\T1016\\src\\nonexistent-qakbot.bat'
187 | got = atomic_svc._use_default_inputs(test=test,
188 | platform=platform,
189 | string_to_analyse=string_to_analyze)
190 | assert got[0] == 'PathToAtomicsFolder\\T1016\\src\\nonexistent-qakbot.bat -a'
191 | assert got[1] == []
192 |
193 | def test_use_default_inputs_empty_string(self, atomic_svc, atomic_test):
194 | platform = 'windows'
195 | string_to_analyze = ''
196 | got = atomic_svc._use_default_inputs(test=atomic_test,
197 | platform=platform,
198 | string_to_analyse=string_to_analyze)
199 | assert got[0] == ''
200 | assert got[1] == []
201 |
202 | def test_use_default_inputs_nil_valued(self, atomic_svc, atomic_test):
203 | platform = 'windows'
204 | string_to_analyze = '#{recon_commands}'
205 | test = atomic_test
206 | test['input_arguments']['recon_commands']['default'] = ''
207 | got = atomic_svc._use_default_inputs(test=test,
208 | platform=platform,
209 | string_to_analyse=string_to_analyze)
210 | assert got[0] == ''
211 | assert got[1] == []
212 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | # tox (https://tox.readthedocs.io/) is a tool for running tests
2 | # in multiple virtualenvs. This configuration file will run the
3 | # test suite on all supported python versions. To use it, "pip install tox"
4 | # and then run "tox" from this directory.
5 |
6 | [tox]
7 | skipsdist = True
8 | envlist =
9 | py38
10 | coverage
11 | bandit
12 | skip_missing_interpreters = true
13 |
14 | [testenv]
15 | description = run tests
16 | passenv = TOXENV CI TRAVIS TRAVIS_* CODECOV_*
17 | deps =
18 | virtualenv!=20.0.22
19 | pre-commit
20 | pytest
21 | pytest-aiohttp
22 | coverage
23 | codecov
24 | changedir = {homedir}/tmp
25 | commands =
26 | /usr/bin/git clone https://github.com/mitre/caldera.git --recursive {homedir}/tmp
27 | /bin/rm -rf {homedir}/tmp/plugins/atomic
28 | python -m pip install -r {homedir}/tmp/requirements.txt
29 | /usr/bin/cp -R {toxinidir} {homedir}/tmp/plugins/atomic
30 | coverage run -p -m pytest --tb=short --rootdir={homedir}/tmp -Werror plugins/atomic/tests
31 | allowlist_externals =
32 | /usr/bin/sudo *
33 | /usr/bin/git *
34 | /usr/bin/cp *
35 |
36 | [testenv:py38]
37 | description = run tests
38 | passenv = TOXENV CI TRAVIS TRAVIS_* CODECOV_*
39 | deps =
40 | virtualenv!=20.0.22
41 | pre-commit
42 | pytest
43 | pytest-aiohttp
44 | coverage
45 | codecov
46 | changedir = {homedir}/tmp
47 | commands =
48 | /usr/bin/git clone https://github.com/mitre/caldera.git --recursive {homedir}/tmp
49 | /bin/rm -rf {homedir}/tmp/plugins/atomic
50 | python -m pip install -r {homedir}/tmp/requirements.txt
51 | /usr/bin/cp -R {toxinidir} {homedir}/tmp/plugins/atomic
52 | coverage run -p -m pytest --tb=short --rootdir={homedir}/tmp {homedir}/tmp/plugins/atomic/tests
53 | allowlist_externals =
54 | /usr/bin/sudo *
55 | /usr/bin/git *
56 | /usr/bin/cp *
57 |
58 | [testenv:coverage]
59 | deps =
60 | coverage
61 | skip_install = true
62 | changedir = {homedir}/tmp
63 | commands =
64 | coverage combine
65 | coverage html
66 | coverage report
67 |
68 | [testenv:coverage-ci]
69 | deps =
70 | coveralls
71 | coverage
72 | skip_install = true
73 | changedir = {homedir}/tmp
74 | commands =
75 | coverage combine
76 | coverage xml
77 | coverage report
78 |
--------------------------------------------------------------------------------