├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── new-ttp.md ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── aws-pipeline ├── codebuild.tf ├── codecommit.tf ├── codepipeline.tf ├── outputs.tf ├── provider.tf ├── s3.tf ├── ssh_config.tpl └── variables.tf ├── buildspec.yml ├── definitions ├── c2 │ └── .gitkeep ├── collection │ └── .gitkeep ├── credential_access │ ├── .gitkeep │ ├── access-secrets-api-server.yml │ ├── access-secrets-manager-secrets.yml │ ├── access-secrets-pod-filesystem.yml │ ├── app-creds-configmaps.yml │ └── app-creds-env.yml ├── defense_evasion │ ├── add-new-guardduty-ip-set.yml │ ├── cloudtrail-alter-encryption-configuration.yml │ ├── cloudtrail-change-destination-bucket.yml │ ├── cloudtrail-delete-trail.yml │ ├── cloudtrail-disable-global-event-logging.yml │ ├── cloudtrail-disable-log-file-validation.yml │ ├── cloudtrail-disable-multiregion-logging.yml │ ├── cloudtrail-disable-trail.yml │ ├── cloudtrail-remove-sns-topic.yml │ ├── config-delete-rule.yml │ ├── delete-kubernetes-events.yml │ ├── pod-name-similarity.yml │ └── update-guardduty-ip-set.yml ├── discovery │ ├── enumerate-cloudtrail.yml │ ├── enumerate-iam-getaccountauthorizationdetails.yml │ ├── enumerate-iam-groups.yml │ ├── enumerate-iam-users.yml │ ├── enumerate-nodes.yml │ ├── enumerate-pods.yml │ ├── enumerate-rbac-permissions.yml │ ├── enumerate-secrets-manager.yml │ ├── enumerate-vpc-flow-logs.yml │ ├── enumerate-waf-rules.yml │ ├── get-guardduty-detector.yml │ ├── get-identity.yml │ └── list-guardduty-detectors.yml ├── execution │ ├── create-pod.yml │ ├── exec-into-container.yml │ ├── modify-lambda-function-code.yml │ └── sidecar-injection.yml ├── exfiltration │ └── .gitkeep ├── impact │ ├── delete-deployment.yml │ ├── delete-iam-group.yml │ ├── delete-iam-policy.yml │ ├── delete-iam-role.yml │ ├── delete-iam-user.yml │ ├── delete-login-profile-for-iam-user.yml │ ├── delete-pod.yml │ ├── delete-secrets-manager-secret.yml │ └── delete-serviceaccount.yml ├── initial_access │ └── .gitkeep ├── lateral_movement │ └── .gitkeep ├── persistence │ ├── add-api-key-to-iam-user.yml │ ├── add-iam-user.yml │ ├── alter-assume-role-policy-document.yml │ ├── change-current-iam-user-password.yml │ ├── create-iam-group.yml │ ├── create-login-profile-for-iam-user.yml │ ├── create-secrets-manager-secret.yml │ ├── create-serviceaccount.yml │ └── update-login-profile-for-iam-user.yml └── privilege_escalation │ ├── add-iam-user-to-group.yml │ ├── add-policy-to-iam-group.yml │ ├── add-policy-to-iam-user.yml │ ├── add-policy-to-role.yml │ ├── add-role-to-new-ec2-instance.yml │ ├── attach-malicious-lambda-layer.yml │ ├── create-iam-policy-version.yml │ ├── create-iam-policy.yml │ ├── privileged-container.yml │ ├── set-default-iam-policy-version.yml │ ├── update-inline-policy-for-user.yml │ └── writeable-hostpath-mount.yml ├── docs ├── api-logging.md ├── architecture.png ├── deploying-leonidas.md ├── k8s-architecture-svc.png ├── k8s-architecture.png ├── using-leonidas.md ├── writing-api-executors.md └── writing-definitions.md ├── generator ├── __init__.py ├── config.yml ├── generator.py ├── lib │ ├── __init__.py │ ├── awsapigen.py │ ├── definitions.py │ ├── docgen.py │ ├── helpers.py │ ├── kubeapigen.py │ ├── leo_case_gen.py │ └── sigmaexport.py ├── poetry.lock ├── pyproject.toml ├── requirements.txt ├── templates │ ├── aws │ │ └── sigma.jinja2 │ ├── aws_python_execution_function.jinja2 │ ├── cloudwatch-event.jinja2 │ ├── iam-policy.jinja2 │ ├── kube-resources.jinja2 │ ├── kube_python_execution_function.jinja2 │ ├── kubernetes │ │ └── sigma.jinja2 │ ├── leo-cases.jinja2 │ ├── lucene-query.jinja2 │ ├── markdown-kubernetes.jinja2 │ ├── markdown.jinja2 │ ├── python_api_core.jinja2 │ └── serverless.jinja2 └── test │ ├── __init__.py │ ├── test_definition_ingestion.py │ └── test_defs │ ├── basic.yml │ └── notimplemented.yml ├── jupyter ├── Leonidas JupyterDemo.ipynb ├── img │ └── OF815.png ├── lib │ ├── __init__.py │ ├── kubeclientlib.py │ └── leoclientlib.py ├── poetry.lock ├── pyproject.toml └── threat-actors │ ├── OF815.ipynb │ └── demo-envs │ └── dharma.yml ├── leo ├── README.md ├── leo.py └── pyproject.toml ├── output ├── Dockerfile ├── docs │ └── index.md ├── leonidas │ ├── api │ │ ├── __init__.py │ │ ├── api_base.py │ │ └── utils.py │ └── pyproject.toml └── mkdocs.yml ├── runtime.txt ├── sigma-pipeline-kubernetes-to-elk.yml └── template-definition.yml /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: NJonesUK 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. Run '....' 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Screenshots** 23 | If applicable, add screenshots to help explain your problem. 24 | 25 | **Additional context** 26 | Add any other context about the problem here. 27 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: NJonesUK 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. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-ttp.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: New TTP 3 | about: Suggest a new attacker TTP that we should consider adding 4 | title: '' 5 | labels: '' 6 | assignees: NJonesUK 7 | 8 | --- 9 | 10 | **What is the attacker TTP you'd like to see added?** 11 | A clear and concise description of the TTP and why you think it should be added, including any examples of it being used by threat groups or in open source tooling. 12 | 13 | **How does an attacker execute it?** 14 | A clear and concise step-by-step description of any necessary CLI commands, API calls or similar needed to execute the test cases, or a link to documentation or tooling that describes this. 15 | 16 | **Where do you expect to see telemetry for this?** 17 | A clear and concise description of which data sources would contain indicators of this activity. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | output/* 2 | .vscode/* 3 | .idea/* 4 | .obsidian/* 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | wheels/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | cover/ 57 | 58 | # Translations 59 | *.mo 60 | *.pot 61 | 62 | # Django stuff: 63 | *.log 64 | local_settings.py 65 | db.sqlite3 66 | db.sqlite3-journal 67 | 68 | # Flask stuff: 69 | instance/ 70 | .webassets-cache 71 | 72 | # Scrapy stuff: 73 | .scrapy 74 | 75 | # Sphinx documentation 76 | docs/_build/ 77 | 78 | # PyBuilder 79 | .pybuilder/ 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | # For a library or package, you might want to ignore these files since the code is 91 | # intended to run in multiple environments; otherwise, check them in: 92 | # .python-version 93 | 94 | # pipenv 95 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 96 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 97 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 98 | # install all needed dependencies. 99 | #Pipfile.lock 100 | 101 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 102 | __pypackages__/ 103 | 104 | # Celery stuff 105 | celerybeat-schedule 106 | celerybeat.pid 107 | 108 | # SageMath parsed files 109 | *.sage.py 110 | 111 | # Environments 112 | .env 113 | .venv 114 | env/ 115 | venv/ 116 | ENV/ 117 | env.bak/ 118 | venv.bak/ 119 | 120 | # Spyder project settings 121 | .spyderproject 122 | .spyproject 123 | 124 | # Rope project settings 125 | .ropeproject 126 | 127 | # mkdocs documentation 128 | /site 129 | 130 | # mypy 131 | .mypy_cache/ 132 | .dmypy.json 133 | dmypy.json 134 | 135 | # Pyre type checker 136 | .pyre/ 137 | 138 | # pytype static type analyzer 139 | .pytype/ 140 | 141 | # Cython debug symbols 142 | cython_debug/ 143 | 144 | 145 | 146 | 147 | # Local .terraform directories 148 | **/.terraform/* 149 | 150 | # .tfstate files 151 | *.tfstate 152 | *.tfstate.* 153 | *lock.hcl 154 | 155 | # Crash log files 156 | crash.log 157 | 158 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 159 | # .tfvars files are managed as part of configuration and so should be included in 160 | # version control. 161 | # 162 | # example.tfvars 163 | 164 | # Ignore override files as they are usually used to override resources locally and so 165 | # are not checked in 166 | override.tf 167 | override.tf.json 168 | *_override.tf 169 | *_override.tf.json 170 | 171 | # Include override files you do wish to add to version control using negated pattern 172 | # 173 | # !example_override.tf 174 | 175 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 176 | # example: *tfplan* 177 | 178 | # Ignore CLI configuration files 179 | .terraformrc 180 | terraform.rc 181 | 182 | test-files/ 183 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 F-Secure LABS 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | netlifydocs: 2 | pip3 install poetry 3 | cd generator && poetry install && poetry run ./generator.py docs 4 | pip install mkdocs mkdocs-material 5 | cd output && mkdocs build 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leonidas 2 | 3 | This is the repository containing Leonidas, a framework for executing attacker actions in the cloud. It provides a YAML-based format for defining cloud attacker tactics, techniques and procedures (TTPs) and their associated detection properties. These definitions can then be compiled into: 4 | 5 | * A web API exposing each test case as an individual endpoint 6 | * Sigma rules (https://github.com/Neo23x0/sigma) for detection 7 | * Documentation - see http://detectioninthe.cloud/ for an example 8 | 9 | The project was originally designed for use in AWS environments, with the following architecture: 10 | 11 | ![Leonidas Architecture](./docs/architecture.png) 12 | In 2024, Leonidas was extended to support Kubernetes environments. Its resources can be deployed within the target cluster, as per the following architecture: 13 | ![Leonidas K8S Architecture](./docs/k8s-architecture.png) 14 | 15 | ## Deploying the API 16 | 17 | The API is deployed into an AWS account via an AWS-native CI/CD pipeline, and into a Kubernetes cluster using auto-generated YAML manifests. 18 | Instructions for both environments can be found at [Deploying Leonidas](./docs/deploying-leonidas.md). 19 | 20 | ## Using the API 21 | 22 | The API is invoked via web requests secured by an API key. Details on using the API can be found at [Using Leonidas](./docs/using-leonidas.md) 23 | 24 | ## Installing the Generator Locally 25 | 26 | To build documentation or Sigma rules, you'll need to install the generator locally. You can do this by: 27 | 28 | * `cd generator` 29 | * `poetry install` 30 | 31 | ## Generating Sigma Rules 32 | 33 | Sigma rules can be generated as follows: 34 | 35 | * `poetry run ./generator.py sigma` 36 | 37 | The rules will then appear in `./output/sigma` 38 | 39 | ## Generating Documentation 40 | 41 | The documentation is generated as follows: 42 | 43 | * `poetry run ./generator.py docs` 44 | 45 | This will produce markdown versions of the documentation available at `output/docs`. This can be uploaded to an existing markdown-based documentation system, or the following can be used to create a prettified HTML version of the docs: 46 | 47 | * `cd ../output` 48 | * `mkdocs build` 49 | 50 | This will create an `output/site` folder containing the HTML site. It is also possible to view this locally by running `mkdocs serve` in the same folder. 51 | 52 | ## Writing Definitions 53 | 54 | The definitions are written in a YAML-based format, for which an example is provided below. Documentation on how to write these can be found in [Writing Definitions](./docs/writing-definitions.md) 55 | 56 | ```yaml 57 | --- 58 | name: Enumerate Cloudtrails for a Given Region 59 | author: Nick Jones 60 | description: | 61 | An adversary may attempt to enumerate the configured trails, to identify what actions will be logged and where they will be logged to. In AWS, this may start with a single call to enumerate the trails applicable to the default region. 62 | category: Discovery 63 | mitre_ids: 64 | - T1526 65 | platform: aws 66 | permissions: 67 | - cloudtrail:DescribeTrails 68 | input_arguments: 69 | executors: 70 | sh: 71 | code: | 72 | aws cloudtrail describe-trails 73 | leonidas_aws: 74 | implemented: True 75 | clients: 76 | - cloudtrail 77 | code: | 78 | result = clients["cloudtrail"].describe_trails() 79 | detection: 80 | sigma_id: 48653a63-085a-4a3b-88be-9680e9adb449 81 | status: experimental 82 | level: low 83 | sources: 84 | - name: "cloudtrail" 85 | attributes: 86 | eventName: "DescribeTrails" 87 | eventSource: "*.cloudtrail.amazonaws.com" 88 | ``` 89 | 90 | ## Credits 91 | 92 | Project built and maintained by Nick Jones ( [NJonesUK](https://github.com/NJonesUK) / [@nojonesuk](https://twitter.com/nojonesuk)). 93 | 94 | Kubernetes support added by Leo Tsaousis ( [@laripping](https://github.com/LAripping) ). 95 | 96 | Special thanks also to Mohit Gupta ( @[Skybound1](https://github.com/Skybound1) ) for his invaluable contribution. 97 | 98 | This project drew ideas and inspiration from a range of sources, including: 99 | 100 | * [Pacu](https://github.com/RhinoSecurityLabs/pacu) 101 | * [Rhino Security's AWS IAM Privilege Escalations](https://github.com/RhinoSecurityLabs/AWS-IAM-Privilege-Escalation) 102 | * All of Scott Piper's AWS security work ( [https://github.com/0xdabbad00](https://github.com/0xdabbad00) / [@0xdabbad00](https://twitter.com/0xdabbad00) ) 103 | * [MITRE ATT&CK](https://attack.mitre.org/matrices/enterprise/) 104 | * [MITRE CALDERA](https://github.com/mitre/caldera) 105 | * [Red Canary's Atomic Red Team](https://github.com/redcanaryco/atomic-red-team) 106 | * [Sigma](https://github.com/Neo23x0/sigma) 107 | -------------------------------------------------------------------------------- /aws-pipeline/codebuild.tf: -------------------------------------------------------------------------------- 1 | 2 | 3 | resource "aws_iam_role" "codebuildrole" { 4 | name = "codebuildrole" 5 | 6 | assume_role_policy = < serverless.yml` 59 | * `sls remove` 60 | * Change directory into `aws-pipeline/` 61 | * `terraform destroy` 62 | * Check both the intended deployment region and us-east-1 for lingering S3 buckets, as the AWS API will not allow deletion of non-empty buckets. This can instead be done through the console. 63 | 64 | ## Deploying Leonidas in a Kubernetes Cluster 65 | 66 | Leonidas can be deployed as a containerised application within a pod in an existing Kubernetes cluster. On a high level, this includes generating the Python API from the definitions, packaging the code into a container image and pushing it to a repository, which can then be pulled into a Kubernetes pod as part of the Leonidas Kubernetes resources deployed within the cluster. All these steps can be carried out using the **generator** utility. 67 | 68 | It is recommended that **ephemeral images** are used for the Leonidas image. An example public repository supporting ephemeral images is [ttl.sh](ttl.sh), which is used in the commands below. 69 | 70 | ### Building and Deploying the API 71 | 72 | The complete list of commands needed to deploy the Leonidas API within a cluster are provided below: 73 | 0. Modify `config.yml` to specify 74 | - `image_url`: the image URL i.e. the registry to temporary push the Leonidas container image so that the cluster can pull it from. We recommend the use of *ephemeral images*, therefore the current default value uses the [ttl.sh](ttl.sh) registry with a few minutes image lifespan 75 | - `namespace`: the namespace which Leonidas resources should be deployed in. If not specified, Leonidas will be deployed into the `default` namespace. 76 | 1. `cd generator/` - Navigate to the generator directory 77 | 2. `poetry install` - Install generator dependencies 78 | 3. `poetry run ./generator.py generate-kube-api` - To generate the Python API code from the YAML definitions 79 | 4. `poetry run ./generator.py build-image` - Build the Leonidas container image and push it to the remote registry. This might take a while. 80 | 5. `poetry run ./generator.py kube-resources > leonidas-manifests.yml` - Create the manifest of Kubernetes resources Leonidas needs, including the RBAC policy 81 | - by default, this command assumes cluster-wide operation and generates a cluster role. To specify explicitly whether Leonidas should be granted namespaced or cluster-wide permissions, set the `--role/--clusterrole` flag 82 | 6. `kubectl -f leonidas-manifests.yml apply` - Create the Leonidas resources in the cluster. This might take a minute. 83 | 7. `kubectl port-forward deployment/leonidas-deployment 5000:5000` - Expose the Flask web service on http://localhost:5000 so that it can be accessed by clients such as `leo`, Jupyter notebooks, or just `curl`. Note that if you chose to deploy Leonidas into a namespace other than the default, you will need to also provide the `-n ` flag here 84 | 85 | ### Removing Leonidas from a cluster 86 | To clean up all Leonidas-related resources after use, simply run 87 | `kubectl -f leonidas-manifests.yml delete` 88 | 89 | ### Generating an RBAC Policy for the API 90 | 91 | The generator utility allows generation of only the RBAC policy needed by Leonidas, without the other Kubernetes resources. This can be done using the `rbac-policy` command. 92 | 93 | Leonidas can be configured to run with cluster-wide permissions, or limited within a specific namespace. In practice, this means that the service account the application is running as can be assigned either a `ClusterRole`, or a namespace-scoped `Role`. In the latter scenario, cluster-wide permissions specified by test cases are ignored. See [Writing Definitions](writing-definitions.md) on how this is specified. Depending on the mode of operation desired, the RBAC policy is generated as follows: 94 | 95 | ```bash 96 | # Cluster-wide operation 97 | $ poetry run ./generator.py rbac-policy > ../policy.yml 98 | Generating ClusterRole with 20 permissions 99 | 100 | # Namespace-scoped operation 101 | # set the target namespace in generator/config.yml 102 | $ poetry run ./generator.py rbac-policy --role > ../policy.yml 103 | Generating Role with 16 permissions (ignored 4 clusterwide permissions) 104 | ``` 105 | 106 | ## Running the Leonidas API Locally 107 | 108 | It is possible to build and run the Leonidas Python API locally for development purposes. To do this: 109 | 110 | * `cd generator` 111 | * `poetry install` 112 | * `poetry run ./generator.py generate-aws-api` and/or `generate-kube-api` 113 | * `cd ../output/leonidas` 114 | * `poetry install` 115 | * `poetry run python leonidas.py` 116 | 117 | This will spawn the API listening at http://127.0.0.1:5000. By default, this uses whichever AWS credentials are configured as the default profile in `~/.aws/config`, and whichever Kubernetes credentials found in the default Kubeconfig. These can be overridden by supplying a role ARN to assume, access keys, JWT tokens or TLS client certificates to use for requests to the API. 118 | 119 | 120 | -------------------------------------------------------------------------------- /docs/k8s-architecture-svc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/leonidas/77ec6bd17e65a111b30fc490476f28cb29308a0f/docs/k8s-architecture-svc.png -------------------------------------------------------------------------------- /docs/k8s-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/leonidas/77ec6bd17e65a111b30fc490476f28cb29308a0f/docs/k8s-architecture.png -------------------------------------------------------------------------------- /docs/using-leonidas.md: -------------------------------------------------------------------------------- 1 | # Using Leonidas 2 | 3 | ## Authenticating to Leonidas 4 | 5 | Requests to any endpoint that will execute an action requires that an API key be supplied. 2 API keys are automatically generated as part of the deployment process. To acquire an API key: 6 | 7 | * `aws apigateway get-api-keys`, note the `id` field from the key you need the secret for 8 | * `aws apigateway get-api-key --include-value --api-key [KEY-ID] | jq -r .value` 9 | * These API keys can be supplied with requests by setting the `x-api-key` header on any requests made to Leonidas 10 | 11 | ## Executing Test Cases 12 | 13 | Test cases are executed by making post requests to the API. Visiting the URL discussed above will present an OpenAPI interface, which will explain the different endpoints, parameters that can be passed and so on. The OpenAPI interface will also provide example curl commands that can be executed on the command line. You'll need to add the following argument to any curl commands so that the API gateway accepts the request, where `APIKEY` is the API key gathered in the previous section: 14 | 15 | `-H "x-api-key: APIKEY"` 16 | 17 | In addition, an OpenAPI definition file is available at `/swagger.json`. This can be imported into Postman, Insomnia and other common API development tools, which can then also be used to execute these test cases. 18 | 19 | ## Leo - Test Case Orchestrator 20 | 21 | Leo is a helper script designed to make it easier to execute killchains as a whole, as opposed to individual test cases. To execute a suite of test cases in Leonidas in an automated fashion. To set Leo up, run the following: 22 | 23 | * `pip install poetry` 24 | * `cd ./leo` 25 | * `poetry install` 26 | 27 | To generate the config file for Leo: 28 | 29 | * `cd generator && poetry run python generator.py leo` to generate the test case definitions for Leo 30 | * `cp ./output/caseconfig/caseconfig.yml ./leo` 31 | * edit the `caseconfig.yml` file in `./leo` to set the URL, API gateway API key, and to modify/reorder/remove test cases as required 32 | 33 | To execute the cases you've configured: 34 | 35 | * `poetry run ./leo.py caseconfig.yml` 36 | 37 | ## Identity Management 38 | 39 | ### AWS 40 | Leonidas, by default, will execute test cases using the role assigned to the serverless function. For AWS, This role is created with a policy that contains all the permissions listed as necessary in the test case, along with `sts:AssumeRole`. However, it also supports two alternative mechanisms to execute test cases under a different identity 41 | 42 | #### Role Assumption 43 | 44 | It is possible to execute test cases as an arbitrary role by submitting the `role_arn` parameter as part of a request. This should be set to the ARN of the the role to be assumed. 45 | 46 | #### AWS Access Keys 47 | 48 | Submitting `access_key_id` and `secret_access_key` parameters containing the Access Key ID and Secret Access Key respectively will cause Leonidas to execute a test case using those credentials. 49 | 50 | #### Region-specific test case execution 51 | 52 | By default, test cases will run in the region in which the API is deployed, or in the default region specified in ~/.aws/config line if the API is running locally. It is possible to suppy a `region` parameter to the API, which will result in the test case being executed against that region. This parameter should contain the AWS region identifier, such as `us-east-1` or `eu-west-1`. 53 | 54 | 55 | ### Kubernetes 56 | The Leonidas pod runs in the context of a Service Account, which is bound to a ClusterRole by default. Alternatively, to operate with permissions over a certain namespace only, it can be bound to a Role as described in [Deploying Leonidas](deploying-leonidas#building-and-deploying-the-api). 57 | 58 | #### Tokens & Certificates 59 | Other than the default Leonidas Service Account, test cases can be executed in the context of any other service account or cluster user, by providing the respective credentials as API parameters. 60 | 61 | In detail, 62 | - to execute as another Service Account or user that authenticates via token authentication, provide the JWT value in the `token` URL parameter 63 | - to execute as a cluster user which authenticates via X509 Client Certificate, provide the Base64-encode of the Certificate and Private Key, via the `tls_cert` and `tls_key` URL parameters respectively. The encoded version of a file can be obtained with a command such as: 64 | ```bash 65 | cat user1.key | base64 -w0 66 | ``` 67 | 68 | -------------------------------------------------------------------------------- /docs/writing-api-executors.md: -------------------------------------------------------------------------------- 1 | # Implementing AWS test cases 2 | 3 | The `code` block within the `leonidas_aws` section should contain the Python code necessary to execute a given test case within AWS. Typically, this will involve calling out to the AWS APIs via a boto3 client. This document outlines the libraries available and variables that are pre-populated for you, and how to correctly return the results such that they appear in the HTTP API response and also in the logs. 4 | 5 | ## Example AWS test case code 6 | 7 | ```yaml 8 | [...] 9 | input_arguments: 10 | secretid: 11 | description: ID of secret to access, either ARN or friendly name 12 | type: str 13 | value: "leonidas_created_secret" 14 | [...] 15 | executors: 16 | leonidas_aws: 17 | implemented: True 18 | clients: 19 | - secretsmanager 20 | code: | 21 | result = clients["secretsmanager"].get_secret_value(SecretId=secretid) 22 | ``` 23 | 24 | ## Available Variables and Objects 25 | 26 | ### `clients` 27 | 28 | A python dictionary containing each boto3 client defined by the `clients` parameter in the definition YAML. 29 | 30 | Behind the scenes, Leonidas will handle assuming any roles or using any AWS access keys that are passed as parameters to the request. The clients available to the code are instantiated using the identity defined by the role or access key parameters. If none are supplied, it defaults to the role the lambda function is assigned (or the default profile specified in `~/.aws/config` if run locally) 31 | 32 | ### `identity` 33 | 34 | A python dictionary defining the following fields: 35 | 36 | ```python 37 | { 38 | "assume_role": False, # Whether a role has been assumed to execute this case 39 | "role_arn": None, # If assume_role is True, the ARN of the role assumed 40 | "access_keys": False, # Whether IAM access keys have been passed to the function 41 | "access_key_id": None, # The access key ID supplied to the API call, if access_keys is set to True 42 | "secret_access_key": None, # The matching secret access key to the above key ID, if access_keys is set to True 43 | } 44 | ``` 45 | 46 | This should generally not be used directly, as the framework uses this data to construct the boto3 clients available in the `clients` dictionary. It is, however, available if required. 47 | 48 | ### Case-specific input arguments 49 | 50 | Arguments for a given test case are defined in the `input_arguments` field in the test case definition. These are made available to the test case code as local variables with the same names as the name used in the `input_arguments` block. 51 | 52 | Under the hood, the generated API code sets the value of a given variable to the value passed to the API call, unless no value is passed in. If no value is supplied, the default value defined in the `input_arguments` block is used. 53 | 54 | ```python 55 | secretid = request.args.get("secretid") or "leonidas_created_secret" 56 | ``` 57 | 58 | ## Returning Data 59 | 60 | The `result` variable should be set to the test case results. This is returned as a response to the HTTP request made to the API, and also logged as part of the case execution log by the function. The test case itself should not include a `return` statement, as this will interfere with Leonidas' logging and auditing capabilities. 61 | 62 | 63 | 64 | # Implementing Kubernetes test cases 65 | 66 | The Kubernetes test cases result to shell invocations of the `kubectl` binary. This method of interaction was selected in order to make definition writing easier and to leverage the computation logic performed by these binaries behind the scenes, a feature which the currently existing Python libraries do not offer. 67 | 68 | As such, the `code` block of the `sh` executor is processed for the test case, and any `leonidas_kube` field othen than the `implemented` flag will be ignored. The `code` block should therefore contain the shell commands necessary to execute a given test case against a kubernetes cluster. Typically, this will involve making calls to the Kubernetes APIs, by executing binaries such as `kubectl`. 69 | 70 | 71 | ## Example Kubernetes test case code 72 | 73 | ```yaml 74 | [...] 75 | input_arguments: 76 | serviceaccount: 77 | description: Name of the service account to create 78 | type: str 79 | value: "leonidas_created_service" 80 | [...] 81 | executors: 82 | sh: 83 | code: | 84 | kubectl create serviceaccount {{ serviceaccount }} 85 | leonidas_kube: 86 | implemented: True 87 | ``` 88 | 89 | ## Available Variables and Objects 90 | 91 | ### Case-specific input arguments 92 | 93 | Arguments for a given test case are defined in the `input_arguments` field in the test case definition. These are made available to the test case code as local variables with the same names as the name used in the `input_arguments` block. 94 | 95 | Under the hood, the generated Python code sets the value of a given variable to the value passed to the API call, unless no value is passed in. If no value is supplied, the default value defined in the `input_arguments` block is used. 96 | 97 | ## Returning Data 98 | 99 | Behind the scenes, the `executors.sh.code` set of commands is passed to Python's `subprocess.run`. The standard output and error streams of the spawned process are all collected, along with the return code, and returned in the `result` dictionary. 100 | 101 | The test case itself should not include a `return` statement, as this will interfere with Leonidas' logging and auditing capabilities. 102 | 103 | # Implementing Azure Test Cases 104 | 105 | Not yet supported 106 | 107 | # Implementing Google Cloud Test Cases 108 | 109 | Not yet supported 110 | -------------------------------------------------------------------------------- /generator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/leonidas/77ec6bd17e65a111b30fc490476f28cb29308a0f/generator/__init__.py -------------------------------------------------------------------------------- /generator/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # Directory to load leonidas definitions from 3 | definitions_path: ../definitions 4 | # Directory to use for output 5 | output_dir: ../output 6 | region: eu-west-2 7 | resources: 8 | - "\"*\"" 9 | # Kubernetes namespace to generate role into (if not specified the "default" namespace is used) 10 | namespace: target-namespace 11 | # Image registry to push & pull the image 12 | image_url: ttl.sh/leonidas:15m 13 | 14 | -------------------------------------------------------------------------------- /generator/generator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import json 4 | import os 5 | from pathlib import Path 6 | import shlex 7 | 8 | import jinja2 9 | import jinja2_ansible_filters 10 | import typer 11 | import yaml 12 | import docker 13 | 14 | from lib.definitions import Definitions 15 | from lib.awsapigen import AWSAPIGen 16 | from lib.kubeapigen import KubeAPIGen 17 | from lib.docgen import DocGen 18 | from lib.leo_case_gen import LeoCaseGen 19 | from lib.sigmaexport import SigmaExport 20 | 21 | 22 | def debug(text): 23 | print(text) 24 | return "" 25 | 26 | def escape_shell(input): 27 | """Custom filter used by the Jinja template generating the Python code from YAML definitions. 28 | Escapes the "code" field specified by the test case author, before passing it to subprocess.run()""" 29 | return shlex.quote(input) 30 | 31 | env = jinja2.Environment( 32 | loader=jinja2.FileSystemLoader(os.path.join(".", "templates")), 33 | autoescape=jinja2.select_autoescape(["html", "xml"]), 34 | extensions=['jinja2_ansible_filters.AnsibleCoreFiltersExtension'] # used for the to_nice_yaml filter in docs template 35 | ) 36 | 37 | env.filters["debug"] = debug 38 | env.filters['escape_shell'] = escape_shell 39 | 40 | 41 | class Generator: 42 | def initialise(self, config, env): 43 | self.config = config 44 | self.definitions = Definitions(self.config, env) 45 | self.docgen = DocGen(self.config, env) 46 | self.awsapigen = AWSAPIGen(self.config, env) 47 | self.kubeapigen = KubeAPIGen(self.config, env) 48 | self.leocasegen = LeoCaseGen(self.config, env) 49 | self.sigmaexport = SigmaExport(self.config, env) 50 | 51 | 52 | app = typer.Typer() 53 | generator = Generator() 54 | 55 | 56 | @app.callback() 57 | def main( 58 | config: Path = typer.Option( 59 | "config.yml", help="Path to config file", show_default=True 60 | ) 61 | ): 62 | config = yaml.safe_load(open(config, "r").read()) 63 | if not os.path.exists(config["output_dir"]): 64 | os.makedirs(config["output_dir"]) 65 | generator.initialise(config, env) 66 | 67 | 68 | @app.command() 69 | def definitions(): 70 | """ 71 | Pretty-print definitions dictionary 72 | """ 73 | generator.definitions.construct_definitions() 74 | print(json.dumps(generator.definitions.case_set, indent=4)) 75 | 76 | 77 | @app.command() 78 | def validate(): 79 | """ 80 | Validate the test definitions 81 | """ 82 | if not generator.definitions.validate(): 83 | print("Validation failed") 84 | raise typer.Exit(code=1) 85 | else: 86 | print( 87 | "Validation successful - validated {} cases".format( 88 | generator.definitions.casecount 89 | ) 90 | ) 91 | 92 | 93 | @app.command() 94 | def generate_aws_api(): 95 | """ 96 | Generate the AWS Leonidas API 97 | """ 98 | print("Generating API") 99 | generator.definitions.construct_definitions() 100 | PYOUTPUTDIR = "leonidas" 101 | outdir = os.path.join(generator.config["output_dir"], PYOUTPUTDIR) 102 | generator.awsapigen.generate_python_api(outdir, generator.definitions) 103 | print( 104 | "API generation complete - {} cases generated".format( 105 | generator.awsapigen.casecount 106 | ) 107 | ) 108 | 109 | # TODO (see #6) currently code is duplicated in kubeapigen / awsapigen to avoid breaking things, 110 | # Split awsapigen to become independent of AWS stuff (building serverless.yml) and use it in both locations 111 | @app.command() 112 | def generate_kube_api(): 113 | """ 114 | Generate the Kubernetes Leonidas API 115 | """ 116 | print("Generating API locally") 117 | generator.definitions.construct_definitions() 118 | PYOUTPUTDIR = "leonidas" 119 | outdir = os.path.join(generator.config["output_dir"], PYOUTPUTDIR) 120 | generator.kubeapigen.generate_python_api(outdir, generator.definitions) 121 | print( 122 | "API generation complete - {} cases generated".format( 123 | generator.kubeapigen.casecount 124 | ) 125 | ) 126 | 127 | @app.command() 128 | def build_image( 129 | verbose: bool = typer.Option(False, help="Show runtime output from docker commands."), 130 | ): 131 | """ 132 | Build and push the Leonidas container image. Specify the registry in config.yml 133 | """ 134 | try: 135 | client = docker.from_env() 136 | except docker.errors.DockerException: 137 | print("Docker deamon could not be reached!") 138 | raise typer.Exit(code=1) 139 | 140 | 141 | print("Building image") 142 | for item in client.images.build( 143 | tag=generator.config["image_url"], 144 | path=generator.config["output_dir"], 145 | # buildargs={"OUTPUT_DIR":generator.config["output_dir"]} 146 | )[1]: 147 | if verbose: 148 | for key, value in item.items(): 149 | if key == 'stream': 150 | text = value.strip() 151 | if text: 152 | print(text, flush=True) 153 | 154 | print("Pushing image") 155 | for item in client.images.push( 156 | generator.config["image_url"], 157 | stream=True, 158 | decode=True 159 | ): 160 | if verbose: 161 | for key, value in item.items(): 162 | if key == 'status': 163 | print(value, flush=True) 164 | 165 | 166 | @app.command() 167 | def docs(): 168 | """ 169 | Generate the leonidas documentation (http://detectioninthe.cloud) 170 | """ 171 | print("Generating Docs") 172 | generator.definitions.construct_definitions() 173 | MARKDOWNOUTPUTDIR = "docs" 174 | outdir = os.path.join(generator.config["output_dir"], MARKDOWNOUTPUTDIR) 175 | generator.docgen.generate_markdown(outdir, generator.definitions) 176 | 177 | 178 | @app.command() 179 | def iam_policy(): 180 | """ 181 | Print the AWS IAM policy necessary to execute all the AWS test cases 182 | """ 183 | generator.definitions.construct_definitions() 184 | print(generator.definitions.get_aws_policy()) 185 | 186 | @app.command() 187 | def kube_resources( 188 | role: bool = typer.Option(False, "--role/--clusterrole", help="Assign Leonidas a namespaced role or a ClusterRole") 189 | ): 190 | """ 191 | Print the Kubernetes YAML resources 192 | """ 193 | generator.definitions.construct_definitions() 194 | print(generator.kubeapigen.generate_kube_resources(role)) # TODO rename KubeAPIGen to KubeGen 195 | print(generator.definitions.get_rbac_policy(role)) 196 | 197 | @app.command() 198 | def rbac_policy( 199 | role: bool = typer.Option(False, "--role/--clusterrole", help="Assign Leonidas a namespaced role or a ClusterRole") 200 | ): 201 | """ 202 | Print the Kubernetes RBAC policy as YAML, including all the permissions necessary to execute the Kubernetes test cases 203 | """ 204 | generator.definitions.construct_definitions() 205 | print(generator.definitions.get_rbac_policy(role)) 206 | 207 | 208 | 209 | @app.command() 210 | def serverless_config(): 211 | """ 212 | Print the Serverless framework configuration 213 | used to deploy the Leonidas API to AWS 214 | """ 215 | generator.definitions.construct_definitions() 216 | print(generator.awsapigen.get_serverless_config(generator.definitions)) 217 | 218 | 219 | @app.command() 220 | def leo(): 221 | """ 222 | Generate the configuration file for Leo, the CLI tool 223 | for executing test cases against an instance of Leonidas 224 | """ 225 | print("Generating Leo Case Configuration") 226 | generator.definitions.construct_definitions() 227 | LEOCASEOUTPUTDIR = "caseconfig" 228 | outdir = os.path.join(generator.config["output_dir"], LEOCASEOUTPUTDIR) 229 | generator.leocasegen.generate_leo_cases(generator.definitions, outdir) 230 | 231 | 232 | @app.command() 233 | def sigma(): 234 | """ 235 | Generate the Sigma rule definitions 236 | """ 237 | print("Generating Sigma rules") 238 | generator.definitions.construct_definitions() 239 | SIGMAOUTPUTDIR = "sigma" 240 | outdir = os.path.join(generator.config["output_dir"], SIGMAOUTPUTDIR) 241 | generator.sigmaexport.export_sigma(generator.definitions, outdir) 242 | 243 | 244 | if __name__ == "__main__": 245 | app() 246 | -------------------------------------------------------------------------------- /generator/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/leonidas/77ec6bd17e65a111b30fc490476f28cb29308a0f/generator/lib/__init__.py -------------------------------------------------------------------------------- /generator/lib/awsapigen.py: -------------------------------------------------------------------------------- 1 | """ 2 | Code to generate the Leonidas API 3 | """ 4 | 5 | import os 6 | 7 | 8 | class AWSAPIGen: 9 | """ 10 | Generates the Flask API used to execute the attacker actions 11 | """ 12 | 13 | def __init__(self, config, env): 14 | self.templates = {} 15 | self.env = env 16 | self.config = config 17 | self.templates["aws_python_function"] = env.get_template( 18 | "aws_python_execution_function.jinja2" 19 | ) 20 | self.templates["api_core"] = env.get_template("python_api_core.jinja2") 21 | self.templates["serverless_config"] = env.get_template("serverless.jinja2") 22 | self.import_list = [] 23 | self.casecount = 0 24 | 25 | def generate_python_api(self, outdir, definitions): 26 | """ 27 | Generate the flask API that is deployed as a lambda function 28 | """ 29 | for category in definitions.categories: 30 | self.import_list.append(category.replace(" ", "_").lower()) 31 | self._generate_api_category(outdir, category, definitions) 32 | 33 | # API root file 34 | rendered = self.templates["api_core"].render({"categories": self.import_list}) 35 | filename = os.path.join(outdir, "leonidas.py") 36 | with open(filename, "w") as apioutfile: 37 | apioutfile.write(rendered) 38 | 39 | # Serverless config 40 | filename = os.path.join(outdir, "serverless.yml") 41 | rendered = self.get_serverless_config(definitions) 42 | with open(filename, "w") as serverlessconfiguoutfile: 43 | serverlessconfiguoutfile.write(rendered) 44 | 45 | def _generate_api_category(self, outdir, category, definitions): 46 | """ 47 | Build a given category of test cases 48 | """ 49 | outdir = os.path.join(outdir, "api") 50 | if not os.path.exists(outdir): 51 | os.makedirs(outdir) 52 | category_case_set = [] 53 | for case in definitions.case_set: 54 | if case["category"] == category: 55 | try: 56 | if case["executors"]["leonidas_aws"]["implemented"]: 57 | category_case_set.append(case) 58 | self.casecount = self.casecount + 1 59 | except KeyError: 60 | continue 61 | rendered = self.templates["aws_python_function"].render( 62 | { 63 | "cases": category_case_set, 64 | "category": category.replace(" ", "_").lower(), 65 | } 66 | ) 67 | py_filename = category.replace(" ", "_").lower() + ".py" 68 | filename = os.path.join(outdir, py_filename) 69 | pyoutfile = open(filename, "w") 70 | pyoutfile.write(rendered) 71 | pyoutfile.close() 72 | self.casecount = self.casecount 73 | 74 | def get_serverless_config(self, definitions): 75 | """ 76 | Render the serverless.yml that Serverless uses as a configuration file 77 | """ 78 | try: 79 | rendered = self.templates["serverless_config"].render( 80 | { 81 | "permissions": sorted(list(definitions.permissions["aws"])), 82 | "region": self.config["region"], 83 | "resources": self.config["resources"], 84 | } 85 | ) 86 | except KeyError: 87 | rendered = self.templates["serverless_config"].render( 88 | { 89 | "permissions": sorted(list(definitions.permissions["aws"])), 90 | "region": self.config["region"], 91 | "resources": ['"*"'], 92 | } 93 | ) 94 | return rendered 95 | -------------------------------------------------------------------------------- /generator/lib/docgen.py: -------------------------------------------------------------------------------- 1 | """ 2 | Code for generating the markdown used to build the documentation 3 | """ 4 | 5 | 6 | import os 7 | from collections import defaultdict 8 | import jinja2 9 | import sigma.processing.resolver 10 | import sigma.backends.elasticsearch 11 | import sigma.collection 12 | import sigma.pipelines.elasticsearch 13 | 14 | 15 | class DocGen: 16 | """ 17 | Generates the Markdown documentation from the attack definitions 18 | """ 19 | 20 | def __init__(self, config, env): 21 | self.templates = {} 22 | self.config = config 23 | self.templates["lucene"] = env.get_template("lucene-query.jinja2") 24 | self.templates["markdown"] = env.get_template("markdown.jinja2") 25 | self.templates["markdown-kubernetes"] = env.get_template("markdown-kubernetes.jinja2") 26 | 27 | def _generate_lucene(self, case): 28 | """ 29 | Generates the lucene query 30 | 31 | The method followed is different for each platform: 32 | - AWS: Lucene is generated using a Jinja template (backwards compatibility) 33 | - Kubernetes: Lucene is generated by means of 'sigma convert' over the Sigma rule, using the custom new Kubernetes/ELK Pipeline 34 | """ 35 | 36 | if self.is_kube_case: 37 | backend = sigma.backends.elasticsearch.LuceneBackend() 38 | collection = sigma.collection.SigmaCollection 39 | rule = collection.from_yaml(case["sigma"]) 40 | return backend.convert(rule)[0] 41 | else: 42 | lucene_dict = { 43 | "sources": case["detection"]["sources"], 44 | "data": case["input_arguments"], 45 | } 46 | return self.templates["lucene"].render(lucene_dict) 47 | 48 | def _generate_markdown(self, case): 49 | """ 50 | Generates markdown for a given case 51 | """ 52 | self.is_kube_case = case["platform"]=="kubernetes" 53 | 54 | # Lucene query generation 55 | case["lucene_query"] = self._generate_lucene(case) 56 | 57 | # AWS CLI command generation 58 | command_template = jinja2.Template(case["executors"]["sh"]["code"]) 59 | if case["input_arguments"]: 60 | aws_cli_render_args = {} 61 | for arg in case["input_arguments"]: 62 | aws_cli_render_args[arg] = case["input_arguments"][arg]["value"] 63 | case["compiled_command"] = command_template.render(aws_cli_render_args) 64 | else: 65 | case["compiled_command"] = command_template.render() 66 | 67 | if self.is_kube_case: 68 | case["clusterwide"] = any( [p["namespaced"]==False for p in case["permissions"]] ) 69 | render_dict = {"case": case} 70 | return self.templates["markdown-kubernetes" if self.is_kube_case else "markdown"].render(render_dict) 71 | 72 | def generate_markdown(self, outdir, definitions): 73 | """ 74 | Generates markdown for the definitions ingested 75 | """ 76 | doc_cases = defaultdict(lambda: defaultdict(list)) 77 | 78 | if not os.path.exists(outdir): 79 | os.makedirs(outdir) 80 | for category in definitions.categories: 81 | cat_outdir = os.path.join(outdir, category.replace(" ", "_").lower()) 82 | if not os.path.exists(cat_outdir): 83 | os.makedirs(cat_outdir) 84 | 85 | for case in definitions.case_set: 86 | if case["category"] == category: 87 | doc_cases[category][case["name"]] = case 88 | 89 | for technique in doc_cases[category]: 90 | rendered = self._generate_markdown(doc_cases[category][technique]) 91 | md_filename = technique.replace(" ", "_").lower() + ".md" 92 | filename = os.path.join(cat_outdir, md_filename) 93 | mdoutfile = open(filename, "w") 94 | mdoutfile.write(rendered) 95 | mdoutfile.close() 96 | -------------------------------------------------------------------------------- /generator/lib/helpers.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/leonidas/77ec6bd17e65a111b30fc490476f28cb29308a0f/generator/lib/helpers.py -------------------------------------------------------------------------------- /generator/lib/kubeapigen.py: -------------------------------------------------------------------------------- 1 | """ 2 | Code to generate the Leonidas API to be used within Kubernetes 3 | """ 4 | 5 | import os 6 | 7 | 8 | class KubeAPIGen: 9 | """ 10 | Generates the Flask API & Kubernetes resources 11 | """ 12 | 13 | def __init__(self, config, env): 14 | self.templates = {} 15 | self.env = env 16 | self.config = config 17 | if "namespace" not in config or config["namespace"] == "": 18 | self.config["namespace"] = "default" 19 | 20 | self.templates["kube_python_function"] = env.get_template( 21 | "kube_python_execution_function.jinja2" 22 | ) 23 | self.templates["api_core"] = env.get_template("python_api_core.jinja2") 24 | self.templates["serverless_config"] = env.get_template("serverless.jinja2") 25 | self.templates["kube_resources"] = env.get_template("kube-resources.jinja2") 26 | self.import_list = [] 27 | self.casecount = 0 28 | 29 | 30 | def generate_kube_resources(self, is_namespaced): 31 | """ 32 | Generate the Kubernetes resources for Leonidas 33 | """ 34 | return self.templates["kube_resources"].render( 35 | { 36 | "namespace": self.config["namespace"], 37 | "is_namespaced":is_namespaced, 38 | "image_url": self.config["image_url"], 39 | } 40 | ) 41 | 42 | def generate_python_api(self, outdir, definitions): 43 | """ 44 | Generate the flask API 45 | """ 46 | for category in definitions.categories: 47 | self.import_list.append(category.replace(" ", "_").lower()) 48 | self._generate_api_category(outdir, category, definitions) 49 | 50 | # API root file 51 | rendered = self.templates["api_core"].render({"categories": self.import_list}) 52 | filename = os.path.join(outdir, "leonidas.py") 53 | with open(filename, "w") as apioutfile: 54 | apioutfile.write(rendered) 55 | 56 | 57 | def _generate_api_category(self, outdir, category, definitions): 58 | """ 59 | Build a given category of test cases 60 | """ 61 | outdir = os.path.join(outdir, "api") 62 | if not os.path.exists(outdir): 63 | os.makedirs(outdir) 64 | category_case_set = [] 65 | for case in definitions.case_set: 66 | if case["category"] == category: 67 | try: 68 | if case["executors"]["leonidas_kube"]["implemented"]: 69 | category_case_set.append(case) 70 | self.casecount = self.casecount + 1 71 | except KeyError: 72 | continue 73 | rendered = self.templates["kube_python_function"].render( 74 | { 75 | "cases": category_case_set, 76 | "category": category.replace(" ", "_").lower(), 77 | } 78 | ) 79 | py_filename = category.replace(" ", "_").lower() + ".py" 80 | filename = os.path.join(outdir, py_filename) 81 | pyoutfile = open(filename, "w") 82 | pyoutfile.write(rendered) 83 | pyoutfile.close() 84 | self.casecount = self.casecount 85 | 86 | -------------------------------------------------------------------------------- /generator/lib/leo_case_gen.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class LeoCaseGen: 5 | """ 6 | Generates the yaml config file for Leo 7 | """ 8 | 9 | def __init__(self, config, env): 10 | self.config = config 11 | self.templates = {} 12 | self.templates["leo-cases"] = env.get_template("leo-cases.jinja2") 13 | 14 | def generate_leo_cases(self, definitions, outdir): 15 | """ 16 | Generate the yaml config file for Leo 17 | """ 18 | cases = [] 19 | for case in definitions.case_set: 20 | if case["platform"] == "aws": 21 | if case["executors"]["leonidas_aws"]["implemented"]: 22 | cutcase = {} 23 | keylist = ["name", "input_arguments", "description", "category"] 24 | cutcase = {key: value for key, value in case.items() if key in keylist} 25 | cases.append(cutcase) 26 | if not os.path.exists(outdir): 27 | os.makedirs(outdir) 28 | with open(os.path.join(outdir, "caseconfig.yml"), "w") as outfile: 29 | outfile.write( 30 | self.templates["leo-cases"] 31 | .render({"cases": cases, "config": self.config}) 32 | .replace("\n\n", "\n") 33 | ) 34 | -------------------------------------------------------------------------------- /generator/lib/sigmaexport.py: -------------------------------------------------------------------------------- 1 | """ 2 | Code for generating the markdown used to build the documentation 3 | """ 4 | 5 | 6 | import os 7 | from collections import defaultdict 8 | 9 | 10 | class SigmaExport: 11 | """ 12 | Generates the Markdown documentation from the attack definitions 13 | """ 14 | 15 | def __init__(self, config, env): 16 | self.templates = {} 17 | self.config = config 18 | self.env = env 19 | 20 | def export_sigma(self, definitions, outdir): 21 | sigma_cases = defaultdict(lambda: defaultdict(list)) 22 | if not os.path.exists(outdir): 23 | os.makedirs(outdir) 24 | for category in definitions.categories: 25 | cat_outdir = os.path.join(outdir, category.replace(" ", "_").lower()) 26 | if not os.path.exists(cat_outdir): 27 | os.makedirs(cat_outdir) 28 | 29 | for case in definitions.case_set: 30 | if case["category"] == category: 31 | sigma_cases[category][case["name"]] = case 32 | 33 | for technique in sigma_cases[category]: 34 | sigma_filename = technique.replace(" ", "_").lower() + ".yml" 35 | filename = os.path.join(cat_outdir, sigma_filename) 36 | with open(filename, "w") as sigmaoutfile: 37 | sigmaoutfile.write(sigma_cases[category][technique]["sigma"]) 38 | print("Generated {} sigma definitions".format(len(definitions.case_set))) 39 | -------------------------------------------------------------------------------- /generator/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "generator" 3 | version = "0.2.0" 4 | description = "Generator for Leonidas and Cloud Detection Docs" 5 | authors = [ 6 | "Nick Jones", 7 | "Leo Tsaousis"] 8 | license = "MIT" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8" 12 | jinja2 = "^3.1.3" 13 | mkdocs = "^1.1.2" 14 | mkdocs-material = "^9.5.29" 15 | pyyaml = "^6.0.1" 16 | black = "^19.10b0" 17 | pylint = "^2.5.2" 18 | typer = "^0.2.1" 19 | docker = "^6.0.1" 20 | jinja2-ansible-filters = "^1.3.2" 21 | pysigma = "^0.11.9" 22 | pysigma-backend-elasticsearch = "^1.1.2" 23 | requests = "< 2.32.0" 24 | 25 | [tool.poetry.dev-dependencies] 26 | 27 | [build-system] 28 | requires = ["poetry>=0.12"] 29 | build-backend = "poetry.masonry.api" 30 | -------------------------------------------------------------------------------- /generator/requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.4 2 | astroid==2.4.1 3 | attrs==19.3.0 4 | black==19.10b0 5 | click==7.1.2 6 | colorama==0.4.3; sys_platform == "win32" 7 | future==0.18.3 8 | isort==4.3.21 9 | jinja2==2.11.3 10 | joblib==1.2.0; python_version > "2.7" 11 | lazy-object-proxy==1.4.3 12 | livereload==2.6.1 13 | lunr==0.5.8 14 | markdown==3.2.2 15 | markupsafe==1.1.1 16 | mccabe==0.6.1 17 | mkdocs==1.2.3 18 | mkdocs-material==5.2.2 19 | mkdocs-material-extensions==1.0 20 | nltk==3.9; python_version > "2.7" 21 | pathspec==0.8.0 22 | pygments==2.15.0 23 | pylint==2.5.2 24 | pymdown-extensions==7.1 25 | pyyaml==5.4 26 | regex==2020.5.14 27 | six==1.15.0 28 | toml==0.10.1 29 | tornado==6.4.1 30 | tqdm==4.46.1; python_version > "2.7" 31 | typed-ast==1.4.1 32 | typer==0.2.1 33 | wrapt==1.12.1 34 | -------------------------------------------------------------------------------- /generator/templates/aws/sigma.jinja2: -------------------------------------------------------------------------------- 1 | --- 2 | title: {{ name }} 3 | id: {{ detection.sigma_id }} 4 | status: {{ detection.status }} 5 | author: {{ author }} 6 | date: {{ last_modified }} 7 | description: {{ description | trim }} 8 | logsource: 9 | service: {{ detection.sources[0]["name"] }} 10 | detection: 11 | selection_source: 12 | - eventSource: "{{ detection["sources"][0]["attributes"]["eventSource"] }}" 13 | events: 14 | - eventName: "{{ detection["sources"][0]["attributes"]["eventName"] }}" 15 | condition: selection_source and events 16 | level: {{ detection["level"] }} 17 | {% if mitre_ids %}tags: 18 | {% for id in mitre_ids %}- attack.{{ id }} 19 | {% endfor %}{% endif %} 20 | {% if references %}references:{% for reference in references %} 21 | - {{ reference }}{% endfor %}{% endif %} 22 | {% if detection.falsepositives %}falsepositives:{% for fp in detection.falsepositives %} 23 | - {{ fp }}{% endfor %}{% endif %} -------------------------------------------------------------------------------- /generator/templates/aws_python_execution_function.jinja2: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | from flask import request 4 | from flask_restx import Resource, Namespace 5 | from .api_base import api, app 6 | from .utils import json_serial, get_clients, aws_define_identity 7 | 8 | {% for case in cases %} 9 | class {{ case['name']|title|replace(" ", "")|replace("-", "") }}(Resource): 10 | """ 11 | API Class for {{ case['name'] }} 12 | """ 13 | 14 | @api.doc(params={ {% if case['input_arguments'] %}{% for arg, contents in case['input_arguments'].items() %} 15 | '{{ arg }}': '{{ contents['description'] }}', {% endfor %}{% endif %} 16 | 'role_arn': 'ARN of a role to assume', 17 | 'access_key_id': 'Access key ID for an entity to execute the test case as. Must be combined with secret_access_key', 18 | 'secret_access_key': 'Secret access key to match the access_key_id', 19 | 'region': 'Region to use for the API call' 20 | } 21 | ) 22 | {% if case['input_arguments'] %}def post(self):{% else %}def get(self):{% endif %} 23 | """ 24 | {{ case['description']|wordwrap|indent }} 25 | """ 26 | {% if case['input_arguments'] %}{% for arg, contents in case['input_arguments'].items() %}{{ arg }} = request.args.get("{{ arg }}") or {% if "str" in contents['type'] %}"{{ contents['value'] }}"{% else %}{{ contents['value'] }}{% endif %} 27 | {% endfor %}{% endif %} 28 | identity = aws_define_identity(request) 29 | try: 30 | clients = get_clients(identity, {{ case["executors"]["leonidas_aws"]['clients'] }}) 31 | {{ case["executors"]["leonidas_aws"]["rendered"] |indent(width=12) }} 32 | except Exception as excpt: 33 | result = excpt 34 | event_dict = { 35 | "request": { 36 | "usecase": request.path, 37 | "args": request.args, 38 | "timestamp": datetime.datetime.now(tz=datetime.timezone.utc), 39 | "identity": identity 40 | }, 41 | "response": result 42 | } 43 | print(json.dumps(event_dict, default=json_serial)) 44 | return result 45 | {% endfor %} 46 | 47 | ns = Namespace('{{ category }}', description='{{ category|replace('_', ' ')|title }}') 48 | 49 | {% for case in cases %} 50 | ns.add_resource({{ case['name']|title|replace(" ", "")|replace("-", "") }}, '/{{ case['name']|replace(" ", "_")|replace("-", "")|lower }}'){% endfor %} 51 | api.add_namespace(ns) 52 | 53 | if __name__ == "__main__": 54 | app.run(debug=False) -------------------------------------------------------------------------------- /generator/templates/cloudwatch-event.jinja2: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_event_rule" "console" { 2 | name = "capture-aws-sign-in" 3 | description = "Capture each AWS Console Sign In" 4 | 5 | event_pattern = <", 7 | "Leo Tsaousis"] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | requests = "^2.31.0" 12 | pandas = "^1.5.3" 13 | jupyterlab = "^3.5.2" 14 | numpy = "1.26.4" 15 | 16 | [tool.poetry.dev-dependencies] 17 | 18 | [build-system] 19 | requires = ["poetry-core>=1.0.0"] 20 | build-backend = "poetry.core.masonry.api" 21 | -------------------------------------------------------------------------------- /jupyter/threat-actors/demo-envs/dharma.yml: -------------------------------------------------------------------------------- 1 | kind: Namespace 2 | apiVersion: v1 3 | metadata: 4 | name: dharma-prod 5 | --- 6 | kind: CertificateSigningRequest 7 | apiVersion: certificates.k8s.io/v1 8 | metadata: 9 | name: jlocke-csr 10 | namespace: dharma-prod 11 | spec: 12 | groups: 13 | - system:authenticated 14 | signerName: kubernetes.io/kube-apiserver-client 15 | request: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0KTUlJQ1ZqQ0NBVDRDQVFBd0VURVBNQTBHQTFVRUF3d0dhbXh2WTJ0bE1JSUJJakFOQmdrcWhraUc5dzBCQVFFRgpBQU9DQVE4QU1JSUJDZ0tDQVFFQS9QcW1PQTRaS1hqUnNESWpQOWZmK2JtcnhEOFpJK1pKdDkwRk5TcDg5R0F3CnRXRVBvanFkazVFWGFpaWsxcWRnSVpTUXlxR2NscThSd2VjY2pwOTNTQXJLajBLNHF3UlZ3aDNGT3VUWWlEV1UKckVkekZMcmJheUQ1dlJ3OXlGbHJBMXNETmVPS3cyY0lPUHBacGUwbHdOdGxjcTVXNlZGMlZhamNVUEJpUG1yZwpkU2NLeGlXQ3Y5L3dwOVRWOVJvR0N4WFlPWEZlN1NlaHhiWGgwcC92cDlIUmo3UDB4UWpqL0hCYUZZc004dDBICkRXZlUvZDErcVQrOEFXa1hIbjRXdUVESDBUU1YvbmZqSENOaWhuQi9yb2ltUFhrd1lXSHduSG9qeHZWNkdhRVYKeHRHSEtGdzBFdmRSRXB6UGJ6MjRBS1VpYXpySjkrakhrV3M1dHNOVW1RSURBUUFCb0FBd0RRWUpLb1pJaHZjTgpBUUVMQlFBRGdnRUJBQ1J6azVNci9RVjczUFFqekVoREtIbU5lODZiRVNLM3JCd0RNZzhMdzZ0SE13QzNqT2w1ClFPRVp0WVZEU1pOM2NLWlNPTFd6bzJsVzIxT3U0bmNHSWVHQ05sVnFmazhPaU53S09YZit5bFRXdktzV2t6K2YKaUo1TTBSNFdnbm1lS2p2UnlGOUI3UndBWThHVGtRNjlOd1ZnTFVIMjZBUzcyUnVCK25Ka2xrVkJ1N0d1UHhEQQpPWnpXYzlvMy8wZ1dTUjRWM0d4RFFrdHpORCtrNXB5VXBMdjVQVnhrOXllMFY5cmRnUjRNQ3NuWXdPYW5kaFpaCnJuMEVNaFI0UFA0cHRKcko3cy9ETUZLaXllNDhCVVBBZTNqa3dVRFRpTWEzdVVDUVQzcHQrR1RTZnJZSzBndVgKWjlvRWtBQk5GYjhCdmUxNTBNR1MwVnh1d3FUdU0rdUJyVlU9Ci0tLS0tRU5EIENFUlRJRklDQVRFIFJFUVVFU1QtLS0tLQo= 16 | usages: 17 | - digital signature 18 | - key encipherment 19 | - client auth 20 | --- 21 | kind: ServiceAccount 22 | apiVersion: v1 23 | metadata: 24 | name: lamppost-sa 25 | namespace: dharma-prod 26 | --- 27 | apiVersion: v1 28 | kind: Secret 29 | metadata: 30 | namespace: dharma-prod 31 | name: lamppost-sa-secret 32 | annotations: 33 | kubernetes.io/service-account.name: lamppost-sa 34 | type: kubernetes.io/service-account-token 35 | --- 36 | kind: Role 37 | apiVersion: rbac.authorization.k8s.io/v1 38 | metadata: 39 | namespace: dharma-prod 40 | name: jlocke-role 41 | rules: 42 | - apiGroups: [""] 43 | resources: ["pods","pods/exec", "secrets"] 44 | verbs: ["get", "list", "create", "update", "delete", "exec", "watch", "patch", "edit"] 45 | --- 46 | kind: RoleBinding 47 | apiVersion: rbac.authorization.k8s.io/v1 48 | metadata: 49 | namespace: dharma-prod 50 | name: lamppost-rb 51 | subjects: 52 | - kind: ServiceAccount 53 | name: lamppost-sa 54 | apiGroup: "" 55 | roleRef: 56 | kind: Role 57 | name: jlocke-role 58 | apiGroup: "" 59 | --- 60 | kind: RoleBinding 61 | apiVersion: rbac.authorization.k8s.io/v1 62 | metadata: 63 | namespace: dharma-prod 64 | name: jlocke-rb 65 | subjects: 66 | - kind: User 67 | name: jlocke 68 | apiGroup: "" 69 | roleRef: 70 | kind: Role 71 | name: jlocke-role 72 | apiGroup: "" 73 | --- 74 | kind: Secret 75 | apiVersion: v1 76 | metadata: 77 | name: patient-db-creds 78 | namespace: dharma-prod 79 | data: 80 | root-password: VkFMM05aMzc3MQ== 81 | --- 82 | kind: Pod 83 | apiVersion: v1 84 | metadata: 85 | namespace: dharma-prod 86 | name: patient-db 87 | spec: 88 | containers: 89 | - image: mysql:5.6 90 | name: mysql 91 | env: 92 | - name: MYSQL_ROOT_PASSWORD 93 | valueFrom: 94 | secretKeyRef: 95 | name: patient-db-creds 96 | key: root-password 97 | --- 98 | kind: Pod 99 | apiVersion: v1 100 | metadata: 101 | namespace: dharma-prod 102 | name: hvac-controller 103 | spec: 104 | containers: 105 | - image: library/gcc 106 | name: libc 107 | command: ["/bin/sh", "-c", "sleep infinity"] 108 | --- 109 | -------------------------------------------------------------------------------- /leo/README.md: -------------------------------------------------------------------------------- 1 | ## Leo - Test Case Orchestrator 2 | 3 | Leo is a helper script designed to make it easier to execute killchains as a whole, as opposed to individual test cases. To execute a suite of test cases in Leonidas in an automated fashion. To set Leo up, run the following: 4 | 5 | * `pip install poetry` 6 | * `cd ./leo` 7 | * `poetry install` 8 | 9 | To generate the config file for Leo: 10 | 11 | * `cd generator && poetry run python generator.py leo` to generate the test case definitions for Leo 12 | * `cp ./output/caseconfig/caseconfig.yml ./leo` 13 | * edit the `caseconfig.yml` file in `./leo` to set the URL, API gateway API key, and to modify/reorder/remove test cases as required 14 | 15 | To execute the cases you've configured: 16 | 17 | * `poetry run ./leo.py caseconfig.yml` -------------------------------------------------------------------------------- /leo/leo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Leo - case executor for Leonidas. Takes a config file as its first argument. 5 | """ 6 | 7 | 8 | import datetime 9 | import json 10 | # import logging 11 | import sys 12 | import time 13 | 14 | import requests 15 | import yaml 16 | 17 | if __name__ == "__main__": 18 | config = yaml.safe_load(open(sys.argv[1], "r")) 19 | print("Url: {}".format(config["url"])) 20 | for case in config["cases"]: 21 | print("[{0} UTC] {1}".format(datetime.datetime.utcnow(), config["cases"][case]["name"])) 22 | url = config["url"] + config["cases"][case]["path"] 23 | headers = {} 24 | # if we're running Leonidas locally, no need for an API key, 25 | # so let's not error out if there's not one in the config 26 | try: 27 | headers["x-api-key"] = config["apikey"] 28 | except KeyError: 29 | continue 30 | 31 | # Grab the args from the case 32 | if "args" in config["cases"][case]: 33 | if config["cases"][case]["args"]: 34 | args = config["cases"][case]["args"] 35 | else: 36 | args = {} 37 | else: 38 | args = {} 39 | 40 | # load any credentials and region details configured in the caseconfig 41 | if ("identity" in config) and (config["identity"] is not None): 42 | if "role_arn" in config["identity"]: 43 | args["role_arn"] = config["identity"]["role_arn"] 44 | elif ("access_key_id" in config["identity"]) and ("secret_access_key" in config["identity"]): 45 | args["access_key_id"] = config["identity"]["access_key_id"] 46 | args["secret_access_key"] = config["identity"]["secret_access_key"] 47 | if "region" in config["identity"]: 48 | args["region"] = config["identity"]["region"] 49 | 50 | # If it's a request with parameters it'll need to be POSTed, otherwise it's a GET request 51 | if ("args" in config["cases"][case]) and (config["cases"][case]["args"] is not None): 52 | r = requests.post(url, headers=headers, params=args) 53 | else: 54 | r = requests.get(url, headers=headers, params=args) 55 | print(json.dumps(r.json(), indent=4, sort_keys=True)) 56 | 57 | # Sleep between cases 58 | time.sleep(config["sleeptime"]) 59 | 60 | print("Ran {} test cases".format(len(config["cases"]))) 61 | -------------------------------------------------------------------------------- /leo/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "leo" 3 | version = "0.2.0" 4 | description = "" 5 | authors = [ 6 | "Nick Jones ", 7 | "Leo Tsaousis"] 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.7" 11 | pyyaml = "^5.3.1" 12 | requests = "^2.23.0" 13 | python-json-logger = "^0.1.11" 14 | boto3 = "^1.13.11" 15 | black = "^19.10b0" 16 | pylint = "^2.5.2" 17 | 18 | [tool.poetry.dev-dependencies] 19 | pytest = "^5.2" 20 | 21 | [build-system] 22 | requires = ["poetry>=0.12"] 23 | build-backend = "poetry.masonry.api" 24 | -------------------------------------------------------------------------------- /output/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8 2 | 3 | # Copy over generated Python code and install it 4 | COPY leonidas/pyproject.toml /leonidas/pyproject.toml 5 | COPY leonidas/leonidas.py /leonidas/leonidas.py 6 | COPY leonidas/api/ /leonidas/api/ 7 | WORKDIR /leonidas 8 | RUN pip install . 9 | 10 | # Download and validate latest kubectl binary 11 | RUN curl -sLO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl" \ 12 | && curl -sLO "https://dl.k8s.io/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl.sha256" \ 13 | && echo "$(cat kubectl.sha256) kubectl" | sha256sum --check \ 14 | && chmod +x kubectl 15 | 16 | ENV PATH="$PATH:/leonidas" 17 | 18 | EXPOSE 5000 19 | 20 | CMD ["python", "leonidas.py"] -------------------------------------------------------------------------------- /output/docs/index.md: -------------------------------------------------------------------------------- 1 | # Leonidas Attack Detection Documentation 2 | 3 | This documentation outlines a wide range of attacker TTPs in the cloud, currently focused on AWS and Kubernetes. It accompanies Leonidas, a tool for automating the simulation of attacks against cloud environments to support development of attack detection capabilities. 4 | 5 | Pick a category from the left to get started. -------------------------------------------------------------------------------- /output/leonidas/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/leonidas/77ec6bd17e65a111b30fc490476f28cb29308a0f/output/leonidas/api/__init__.py -------------------------------------------------------------------------------- /output/leonidas/api/api_base.py: -------------------------------------------------------------------------------- 1 | import logging.config 2 | 3 | import click 4 | from flask import Flask 5 | from flask_restx import Api 6 | 7 | from .utils import Config 8 | 9 | # Configure logger for events, to go into stdout for accessing through primary container (easy on kubectl logs) 10 | # separating them from Flask console output (root logger) which go to a file, to be picked up by the sidecar container 11 | # - This does not affect AWS Python code, Jinja template does not use Python logging but just print() 12 | try: 13 | logging.config.dictConfig( 14 | { 15 | "version": 1, 16 | "formatters": { 17 | "eventFmt":{ 18 | "format":"%(message)s" 19 | } 20 | }, 21 | "handlers": { 22 | "consoleHandler":{ 23 | "class": "logging.StreamHandler", 24 | "formatter": "eventFmt", 25 | }, 26 | "fileHandler":{ 27 | "class": "logging.FileHandler", 28 | "filename": "/var/log/leonidas-flask.log", 29 | } 30 | }, 31 | "root": {"level": "INFO", "handlers": ["fileHandler"]}, 32 | 33 | "loggers": { 34 | "events": { 35 | "level": "INFO", 36 | "handlers": ["consoleHandler"], 37 | "propagate": False, 38 | } 39 | }, 40 | } 41 | ) 42 | except: 43 | pass 44 | 45 | def secho(text, file=None, nl=None, err=None, color=None, **styles): 46 | pass 47 | 48 | def echo(text, file=None, nl=None, err=None, color=None, **styles): 49 | pass 50 | 51 | click.echo = echo 52 | click.secho = secho 53 | # This needed to hide Flask's startup message and keep stdout clean from messages 54 | # that aren't events, and therefore jq-friendly 55 | # https://stackoverflow.com/questions/14888799/disable-console-messages-in-flask-server 56 | 57 | app = Flask(__name__) 58 | app.config.from_object(Config) 59 | api = Api( 60 | app, 61 | version="2.0", 62 | title="Leonidas", 63 | description="An API for executing attacker actions within AWS and Kubernetes", 64 | ) 65 | -------------------------------------------------------------------------------- /output/leonidas/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "leonidas" 3 | version = "0.2.0" 4 | description = "Leonidas Attacker Action Executor" 5 | authors = [ 6 | "Nick Jones ", 7 | "Leo Tsaousis"] 8 | license = "MIT" 9 | 10 | [tool.poetry.dependencies] 11 | python = "^3.8" 12 | flask = "^2.2.2" 13 | flask_restx = "^1.0.5" 14 | boto3 = "^1.26.60" 15 | 16 | [tool.poetry.dev-dependencies] 17 | black = "^19.10b0" 18 | pylint = "^2.5.2" 19 | 20 | [build-system] 21 | requires = ["poetry>=0.12"] 22 | build-backend = "poetry.masonry.api" 23 | -------------------------------------------------------------------------------- /output/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Leonidas Test Case Documentation 2 | site_url: http://detectioninthe.cloud 3 | site_description: Documentation on the cloud attack detection test cases defined as part of Leonidas 4 | copyright: Copyright 2024 WithSecure Consulting 5 | theme: 6 | name: material 7 | icon: 8 | logo: material/cloud 9 | markdown_extensions: 10 | - codehilite 11 | - toc: 12 | permalink: true 13 | extra: 14 | social: 15 | - icon: fontawesome/brands/github-alt 16 | link: https://github.com/WithSecureLabs 17 | - icon: fontawesome/brands/x-twitter 18 | link: https://x.com/withconsulting -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | 3.8 -------------------------------------------------------------------------------- /sigma-pipeline-kubernetes-to-elk.yml: -------------------------------------------------------------------------------- 1 | name: Mapping of Kubernetes test cases for ELK's default Kubernetes integration 2 | priority: 30 3 | transformations: 4 | 5 | # Step 1 - checks for performance 6 | 7 | - id: index_condition 8 | type: add_condition 9 | # only search upon Kubernetes audit logs 10 | conditions: 11 | kubernetes.audit.kind: Event 12 | # only transform fields if this is a Kubernetes rule 13 | rule_conditions: 14 | - type: logsource 15 | product: kubernetes 16 | 17 | # Step 2 - the transformations 18 | 19 | 20 | # Map simplified Sigma fields to the names ELK assigns them 21 | - id: field_mapping 22 | type: field_name_mapping 23 | mapping: 24 | verb: 25 | - kubernetes.audit.verb 26 | apiGroup: 27 | - kubernetes.audit.objectRef.apiGroup 28 | resource: 29 | - kubernetes.audit.objectRef.resource 30 | subresource: 31 | - kubernetes.audit.objectRef.subresource 32 | namespace: 33 | - kubernetes.audit.objectRef.namespace 34 | capabilities: 35 | - kubernetes.audit.requestObject.spec.containers.securityContext.capabilities.add 36 | hostPath: 37 | - kubernetes.audit.requestObject.spec.volumes.hostPath 38 | 39 | # If apiGroup is "" OR omitted, then drop from query, as the ELK Kubernetes integration doesn't set this event field when apiGroup is the default 40 | - id: drop_default_apigroup 41 | type: drop_detection_item 42 | field_name_conditions: 43 | - type: include_fields 44 | fields: 45 | - apiGroup 46 | - kubernetes.audit.objectRef.apiGroup 47 | detection_item_conditions: 48 | - type: match_string 49 | cond: any 50 | pattern: "^$" 51 | 52 | # If subresource is "" OR omitted, then drop from query, as the ELK Kubernetes integration doesn't set this event field for resource-only endpoints 53 | - id: drop_empty_subresource 54 | type: drop_detection_item 55 | field_name_conditions: 56 | - type: include_fields 57 | fields: 58 | - subresource 59 | - kubernetes.audit.objectRef.subresource 60 | detection_item_conditions: 61 | - type: match_string 62 | cond: any 63 | pattern: "^$" 64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /template-definition.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Access Secret in Secrets Manager 3 | author: Nick Jones 4 | description: | 5 | An adversary may attempt to access the secrets in secrets manager, to steal certificates, credentials or other sensitive material 6 | platform: aws 7 | category: Credential Access 8 | mitre_ids: 9 | - T1528 10 | permissions: 11 | - secretsmanager:GetSecretValue 12 | - kms:Decrypt 13 | input_arguments: 14 | secretid: 15 | description: ID of secret to access, either ARN or friendly name 16 | type: str 17 | value: "leonidas_created_secret" 18 | executors: 19 | sh: 20 | code: | 21 | aws secretsmanager get-secret-value --secret-id {{ secretid }} 22 | leonidas_aws: 23 | implemented: True 24 | clients: 25 | - secretsmanager 26 | code: | 27 | result = clients["secretsmanager"].get_secret_value(SecretId=secretid) 28 | detection: 29 | sigma_id: cbeba6f0-019e-4782-8c7e-e21b10521eed 30 | status: experimental 31 | level: low 32 | sources: 33 | - name: "cloudtrail" 34 | attributes: 35 | eventName: "GetSecretValue" 36 | eventSource: "*.secretsmanager.amazonaws.com" 37 | falsepositives: 38 | - Developers making legitimate changes to the environment. Verify whether the user identity, user agent, and/or hostname should be making changes in your environment. 39 | --------------------------------------------------------------------------------