├── 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 |
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 |
--------------------------------------------------------------------------------
/images/ubuntu_support.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/images/atomic-operator-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 | 
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 status]
2 | [][pypi status]
3 | [][pypi status]
4 | [][license]
5 |
6 | [][read the docs]
7 | [](https://github.com/Swimlane/atomic-operator/actions/workflows/quality.yml)
8 |
9 | [][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 | 
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 |
--------------------------------------------------------------------------------