├── .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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------